Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add revocation to access-client and w3up-client #975

Merged
merged 9 commits into from
Oct 18, 2023
31 changes: 31 additions & 0 deletions packages/access-client/src/agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
} from './delegations.js'
import { AgentData, getSessionProofs } from './agent-data.js'
import { addProviderAndDelegateToAccount } from './agent-use-cases.js'
import { UCAN } from '@web3-storage/capabilities'

export { AgentData }
export * from './agent-use-cases.js'
Expand Down Expand Up @@ -205,6 +206,36 @@ export class Agent {
}
}

/**
* Revoke a delegation by CID.
*
* If the delegation was issued by this agent (and therefore is stored in the
* delegation store) you can just pass the CID. If not, or if the current agent's
* delegation store no longer contains the delegation, you MUST pass a chain of
* proofs that proves your authority to revoke this delegation as `options.proofs`.
*
* @param {import('@ucanto/interface').UCANLink} delegationCID
* @param {object} [options]
* @param {import('@ucanto/interface').Delegation[]} [options.proofs]
*/
async revokeDelegation(delegationCID, options = {}) {
travis marked this conversation as resolved.
Show resolved Hide resolved
// look for the identified delegation in the delegation store and the passed proofs
const delegation = [...this.delegations(), ...(options.proofs ?? [])].find(
(delegation) => delegation.cid.equals(delegationCID)
)
if (!delegation) {
throw new Error(
`could not find delegation ${delegationCID.toString()} - please include the delegation in options.proofs`
)
}
return this.invokeAndExecute(UCAN.revoke, {
nb: {
ucan: delegation.cid,
},
proofs: options.proofs ?? [delegation],
travis marked this conversation as resolved.
Show resolved Hide resolved
})
}

/**
* Get all the proofs matching the capabilities.
*
Expand Down
6 changes: 6 additions & 0 deletions packages/access-client/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ import type {
AccessConfirm,
AccessConfirmSuccess,
AccessConfirmFailure,
UCANRevoke,
UCANRevokeSuccess,
UCANRevokeFailure,
} from '@web3-storage/capabilities/types'
import type { SetRequired } from 'type-fest'
import { Driver } from './drivers/types.js'
Expand Down Expand Up @@ -80,6 +83,9 @@ export interface Service {
space: {
info: ServiceMethod<SpaceInfo, SpaceInfoResult, Failure | SpaceUnknown>
}
ucan: {
revoke: ServiceMethod<UCANRevoke, UCANRevokeSuccess, UCANRevokeFailure>
}
}

/**
Expand Down
48 changes: 48 additions & 0 deletions packages/access-client/test/agent.test.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import assert from 'assert'
import { URI } from '@ucanto/validator'
import { Delegation, provide } from '@ucanto/server'
import { Agent, connection } from '../src/agent.js'
import * as Space from '@web3-storage/capabilities/space'
import * as UCAN from '@web3-storage/capabilities/ucan'
import { createServer } from './helpers/utils.js'
import * as fixtures from './helpers/fixtures.js'

Expand Down Expand Up @@ -249,4 +251,50 @@ describe('Agent', function () {
/cannot delegate capability store\/remove/
)
})

it('should revoke', async function () {
const server = createServer({
ucan: {
/**
*
* @type {import('@ucanto/interface').ServiceMethod<import('../src/types.js').UCANRevoke, import('../src/types.js').UCANRevokeSuccess, import('../src/types.js').UCANRevokeFailure>}
*/
revoke: provide(UCAN.revoke, async ({ capability, invocation }) => {
// copy a bit of the production revocation handler to do basic validation
const { nb: input } = capability
const ucan = Delegation.view(
{ root: input.ucan, blocks: invocation.blocks },
// eslint-disable-next-line unicorn/no-null
null
)
return ucan
? { ok: { time: Date.now() } }
travis marked this conversation as resolved.
Show resolved Hide resolved
: {
error: {
name: 'UCANNotFound',
message: 'Could not find delegation in invocation blocks',
},
}
}),
},
})
const agent = await Agent.create(undefined, {
connection: connection({ principal: server.id, channel: server }),
})

const space = await agent.createSpace('execute')
await agent.setCurrentSpace(space.did)

const delegation = await agent.delegate({
abilities: ['*'],
audience: fixtures.alice,
audienceMeta: {
name: 'sss',
type: 'app',
},
})

const receipt = await agent.revokeDelegation(delegation.cid)
assert(receipt.out.ok, `failed to revoke: ${receipt.out.error?.message}`)
})
travis marked this conversation as resolved.
Show resolved Hide resolved
})
10 changes: 10 additions & 0 deletions packages/capabilities/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,15 @@ export interface UploadListItem extends UploadAddSuccess {}

export type UCANRevoke = InferInvokedCapability<typeof UCANCaps.revoke>

export interface Timestamp {
/**
* Unix timestamp in seconds.
*/
time: number
}

export type UCANRevokeSuccess = Timestamp

/**
* Error is raised when `UCAN` being revoked is not supplied or it's proof chain
* leading to supplied `scope` is not supplied.
Expand Down Expand Up @@ -323,6 +332,7 @@ export type UCANRevokeFailure =
| UCANNotFound
| InvalidRevocationScope
| UnauthorizedRevocation
| Ucanto.Failure
travis marked this conversation as resolved.
Show resolved Hide resolved

// Admin
export type Admin = InferInvokedCapability<typeof AdminCaps.admin>
Expand Down
11 changes: 3 additions & 8 deletions packages/upload-api/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,6 @@ export type ValidationEmailSend = {
url: string
}

export interface Timestamp {
/**
* Unix timestamp in seconds.
*/
time: number
}

export type SpaceDID = DIDKey
export type ServiceDID = DID<'web'>
export type ServiceSigner = Signer<ServiceDID>
Expand Down Expand Up @@ -119,6 +112,8 @@ import {
UCANRevoke,
ListResponse,
CARLink,
UCANRevokeSuccess,
UCANRevokeFailure,
} from '@web3-storage/capabilities/types'
import * as Capabilities from '@web3-storage/capabilities'
import { RevocationsStorage } from './types/revocations'
Expand Down Expand Up @@ -205,7 +200,7 @@ export interface Service {
}

ucan: {
revoke: ServiceMethod<UCANRevoke, Timestamp, Failure>
revoke: ServiceMethod<UCANRevoke, UCANRevokeSuccess, UCANRevokeFailure>
}

admin: {
Expand Down
2 changes: 1 addition & 1 deletion packages/upload-api/src/ucan/revoke.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import * as API from '../types.js'

/**
* @param {API.RevocationServiceContext} context
* @returns {API.ServiceMethod<API.UCANRevoke, API.Timestamp, API.Failure>}
* @returns {API.ServiceMethod<API.UCANRevoke, API.UCANRevokeSuccess, API.UCANRevokeFailure>}
*/
export const ucanRevokeProvider = ({ revocationsStorage }) =>
provide(revoke, async ({ capability, invocation }) => {
Expand Down
18 changes: 18 additions & 0 deletions packages/w3up-client/src/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -238,4 +238,22 @@ export class Client extends Base {
})
return new AgentDelegation(root, blocks, { audience: audienceMeta })
}

/**
* Revoke a delegation by CID.
*
* If the delegation was issued by this agent (and therefore is stored in the
* delegation store) you can just pass the CID. If not, or if the current agent's
* delegation store no longer contains the delegation, you MUST pass a chain of
* proofs that proves your authority to revoke this delegation as `options.proofs`.
*
* @param {import('@ucanto/interface').UCANLink} delegationCID
* @param {object} [options]
* @param {import('@ucanto/interface').Delegation[]} [options.proofs]
*/
async revokeDelegation(delegationCID, options = {}) {
return this._agent.revokeDelegation(delegationCID, {
proofs: options.proofs,
})
}
}
70 changes: 69 additions & 1 deletion packages/w3up-client/test/client.test.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import assert from 'assert'
import { create as createServer, provide } from '@ucanto/server'
import { Delegation, create as createServer, provide } from '@ucanto/server'
import * as CAR from '@ucanto/transport/car'
import * as Signer from '@ucanto/principal/ed25519'
import * as StoreCapabilities from '@web3-storage/capabilities/store'
import * as UploadCapabilities from '@web3-storage/capabilities/upload'
import * as UCANCapabilities from '@web3-storage/capabilities/ucan'
import { AgentData } from '@web3-storage/access/agent'
import { randomBytes, randomCAR } from './helpers/random.js'
import { toCAR } from './helpers/car.js'
Expand Down Expand Up @@ -341,6 +342,73 @@ describe('Client', () => {
})
})

describe('revokeDelegation', () => {
it('should revoke a delegation by CID', async () => {
const service = mockService({
ucan: {
revoke: provide(
UCANCapabilities.revoke,
({ capability, invocation }) => {
// copy a bit of the production revocation handler to do basic validation
const { nb: input } = capability
const ucan = Delegation.view(
{ root: input.ucan, blocks: invocation.blocks },
null
)
return ucan
? { ok: { time: Date.now() } }
: {
error: {
name: 'UCANNotFound',
message: 'Could not find delegation in invocation blocks',
},
}
}
),
},
})

const server = createServer({
id: await Signer.generate(),
service,
codec: CAR.inbound,
validateAuthorization,
})
const alice = new Client(await AgentData.create(), {
// @ts-ignore
serviceConf: await mockServiceConf(server),
})
const bob = new Client(await AgentData.create(), {
// @ts-ignore
serviceConf: await mockServiceConf(server),
})

const space = await alice.createSpace()
await alice.setCurrentSpace(space.did())
const name = `delegation-${Date.now()}`
const delegation = await alice.createDelegation(bob.agent(), ['*'], {
audienceMeta: { type: 'device', name },
})

const result = await alice.revokeDelegation(delegation.cid)
assert.ok(result.out.ok)
})

it('should fail to revoke a delegation it does not know about', async () => {
const alice = new Client(await AgentData.create())
const bob = new Client(await AgentData.create())

const space = await alice.createSpace()
await alice.setCurrentSpace(space.did())
const name = `delegation-${Date.now()}`
const delegation = await alice.createDelegation(bob.agent(), ['*'], {
audienceMeta: { type: 'device', name },
})

assert.rejects(bob.revokeDelegation(delegation.cid))
})
})

describe('defaultProvider', () => {
it('should return the connection ID', async () => {
const alice = new Client(await AgentData.create())
Expand Down
4 changes: 4 additions & 0 deletions packages/w3up-client/test/helpers/mocks.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const notImplemented = () => {
* store: Partial<import('@web3-storage/upload-client/types').Service['store']>
* upload: Partial<import('@web3-storage/upload-client/types').Service['upload']>
* space: Partial<import('@web3-storage/access/types').Service['space']>
* ucan: Partial<import('@web3-storage/access/types').Service['ucan']>
* }>} impl
*/
export function mockService(impl) {
Expand All @@ -38,6 +39,9 @@ export function mockService(impl) {
provider: {
add: withCallCount(impl.provider?.add ?? notImplemented),
},
ucan: {
revoke: withCallCount(impl.ucan?.revoke ?? notImplemented),
},
}
}

Expand Down
Loading