Skip to content

Commit

Permalink
feat: add revocation to access-client and w3up-client (#975)
Browse files Browse the repository at this point in the history
Add a new method on `access-client`'s `Agent` to make this work and call
into it from `w3up-client`'s `Client`.
  • Loading branch information
travis authored Oct 18, 2023
1 parent 9f59720 commit 6c877aa
Show file tree
Hide file tree
Showing 9 changed files with 249 additions and 11 deletions.
35 changes: 35 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,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.
*
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
86 changes: 86 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,88 @@ 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() / 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}`
)
})
})
18 changes: 18 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 All @@ -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<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
11 changes: 9 additions & 2 deletions 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 Expand Up @@ -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() } }
})

/**
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.revoke(delegationCID, {
proofs: options.proofs,
})
}
}
71 changes: 70 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,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())
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

0 comments on commit 6c877aa

Please sign in to comment.