diff --git a/packages/access-client/src/agent.js b/packages/access-client/src/agent.js index 157eca854..3968e1314 100644 --- a/packages/access-client/src/agent.js +++ b/packages/access-client/src/agent.js @@ -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' @@ -205,6 +206,40 @@ 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 revoke(delegationCID, options = {}) { + const additionalProofs = options.proofs ?? [] + // look for the identified delegation in the delegation store and the passed proofs + const delegation = [...this.delegations(), ...additionalProofs].find( + (delegation) => delegation.cid.equals(delegationCID) + ) + if (!delegation) { + return { + error: new Error( + `could not find delegation ${delegationCID.toString()} - please include the delegation in options.proofs` + ), + } + } + const receipt = await this.invokeAndExecute(UCAN.revoke, { + nb: { + ucan: delegation.cid, + }, + proofs: [delegation, ...additionalProofs], + }) + return receipt.out + } + /** * Get all the proofs matching the capabilities. * diff --git a/packages/access-client/src/types.ts b/packages/access-client/src/types.ts index e4d2dd8c5..682c0272d 100644 --- a/packages/access-client/src/types.ts +++ b/packages/access-client/src/types.ts @@ -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' @@ -80,6 +83,9 @@ export interface Service { space: { info: ServiceMethod } + ucan: { + revoke: ServiceMethod + } } /** diff --git a/packages/access-client/test/agent.test.js b/packages/access-client/test/agent.test.js index 30e7298ab..b3ec54196 100644 --- a/packages/access-client/test/agent.test.js +++ b/packages/access-client/test/agent.test.js @@ -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' @@ -249,4 +251,88 @@ describe('Agent', function () { /cannot delegate capability store\/remove/ ) }) + + it('should revoke', async function () { + const server = createServer({ + ucan: { + /** + * + * @type {import('@ucanto/interface').ServiceMethod} + */ + 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() / 1000 } } + : { + error: { + name: 'UCANNotFound', + message: 'Could not find delegation in invocation blocks', + }, + } + }), + }, + }) + const alice = await Agent.create(undefined, { + connection: connection({ principal: server.id, channel: server }), + }) + const bob = await Agent.create(undefined, { + connection: connection({ principal: server.id, channel: server }), + }) + + const space = await alice.createSpace('alice') + await alice.setCurrentSpace(space.did) + + const delegation = await alice.delegate({ + abilities: ['*'], + audience: fixtures.alice, + audienceMeta: { + name: 'sss', + type: 'app', + }, + }) + + // revocation should work without a list of proofs + const result = await alice.revoke(delegation.cid) + assert(result.ok, `failed to revoke: ${result.error?.message}`) + + // and it should not fail if you pass additional proofs + const result2 = await alice.revoke(delegation.cid, { proofs: [] }) + assert( + result2.ok, + `failed to revoke when proofs passed: ${result2.error?.message}` + ) + + const bobSpace = await bob.createSpace('bob') + await bob.setCurrentSpace(bobSpace.did) + const bobDelegation = await bob.delegate({ + abilities: ['*'], + audience: fixtures.alice, + audienceMeta: { + name: 'sss', + type: 'app', + }, + }) + + // if the delegation wasn't generated by the agent and isn't passed, revoke will throw + const result3 = await alice.revoke(bobDelegation.cid) + assert( + result3.error, + `revoke resolved but should have rejected because delegation is not passed` + ) + + // + const result4 = await alice.revoke(bobDelegation.cid, { + proofs: [bobDelegation], + }) + assert( + result4.ok, + `failed to revoke even though proof was passed: ${result4.error?.message}` + ) + }) }) diff --git a/packages/capabilities/src/types.ts b/packages/capabilities/src/types.ts index 613b4dd96..779472c1f 100644 --- a/packages/capabilities/src/types.ts +++ b/packages/capabilities/src/types.ts @@ -295,6 +295,15 @@ export interface UploadListItem extends UploadAddSuccess {} export type UCANRevoke = InferInvokedCapability +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. @@ -319,10 +328,19 @@ export interface UnauthorizedRevocation extends Ucanto.Failure { name: 'UnauthorizedRevocation' } +/** + * Error is raised when `UCAN` revocation cannot be stored. This + * is usually not a client error. + */ +export interface RevocationsStoreFailure extends Ucanto.Failure { + name: 'RevocationsStoreFailure' +} + export type UCANRevokeFailure = | UCANNotFound | InvalidRevocationScope | UnauthorizedRevocation + | RevocationsStoreFailure // Admin export type Admin = InferInvokedCapability diff --git a/packages/upload-api/src/types.ts b/packages/upload-api/src/types.ts index 89595340a..d25634f13 100644 --- a/packages/upload-api/src/types.ts +++ b/packages/upload-api/src/types.ts @@ -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 @@ -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' @@ -205,7 +200,7 @@ export interface Service { } ucan: { - revoke: ServiceMethod + revoke: ServiceMethod } admin: { diff --git a/packages/upload-api/src/ucan/revoke.js b/packages/upload-api/src/ucan/revoke.js index 15f75d0e2..8b8636a2d 100644 --- a/packages/upload-api/src/ucan/revoke.js +++ b/packages/upload-api/src/ucan/revoke.js @@ -4,7 +4,7 @@ import * as API from '../types.js' /** * @param {API.RevocationServiceContext} context - * @returns {API.ServiceMethod} + * @returns {API.ServiceMethod} */ export const ucanRevokeProvider = ({ revocationsStorage }) => provide(revoke, async ({ capability, invocation }) => { @@ -34,7 +34,14 @@ export const ucanRevokeProvider = ({ revocationsStorage }) => cause: invocation.cid, }) - return result.error ? result : { ok: { time: Date.now() } } + return result.error + ? { + error: { + name: 'RevocationsStoreFailure', + message: result.error.message, + }, + } + : { ok: { time: Date.now() } } }) /** diff --git a/packages/w3up-client/src/client.js b/packages/w3up-client/src/client.js index 78f906f8c..570ba9670 100644 --- a/packages/w3up-client/src/client.js +++ b/packages/w3up-client/src/client.js @@ -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.revoke(delegationCID, { + proofs: options.proofs, + }) + } } diff --git a/packages/w3up-client/test/client.test.js b/packages/w3up-client/test/client.test.js index 25999f7e2..79b18951c 100644 --- a/packages/w3up-client/test/client.test.js +++ b/packages/w3up-client/test/client.test.js @@ -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' @@ -341,6 +342,74 @@ 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.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 }, + }) + + const result = await bob.revokeDelegation(delegation.cid) + assert.ok(result.error, 'revoke succeeded when it should not have') + }) + }) + describe('defaultProvider', () => { it('should return the connection ID', async () => { const alice = new Client(await AgentData.create()) diff --git a/packages/w3up-client/test/helpers/mocks.js b/packages/w3up-client/test/helpers/mocks.js index 63d3b85ea..7f98a3544 100644 --- a/packages/w3up-client/test/helpers/mocks.js +++ b/packages/w3up-client/test/helpers/mocks.js @@ -13,6 +13,7 @@ const notImplemented = () => { * store: Partial * upload: Partial * space: Partial + * ucan: Partial * }>} impl */ export function mockService(impl) { @@ -38,6 +39,9 @@ export function mockService(impl) { provider: { add: withCallCount(impl.provider?.add ?? notImplemented), }, + ucan: { + revoke: withCallCount(impl.ucan?.revoke ?? notImplemented), + }, } }