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
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}`
)
})
travis marked this conversation as resolved.
Show resolved Hide resolved
})
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