diff --git a/packages/capabilities/src/top.js b/packages/capabilities/src/top.js index 06a075ff9..3134de58f 100644 --- a/packages/capabilities/src/top.js +++ b/packages/capabilities/src/top.js @@ -9,7 +9,7 @@ * @module */ -import { capability, URI } from '@ucanto/validator' +import { capability, Schema } from '@ucanto/validator' import { equalWith } from './utils.js' /** @@ -20,6 +20,6 @@ import { equalWith } from './utils.js' */ export const top = capability({ can: '*', - with: URI.match({ protocol: 'did:' }), + with: Schema.or(Schema.did(), Schema.literal('ucan:*')), derives: equalWith, }) diff --git a/packages/upload-api/test/access-client-agent.js b/packages/upload-api/test/access-client-agent.js index ad332925a..7f56a589b 100644 --- a/packages/upload-api/test/access-client-agent.js +++ b/packages/upload-api/test/access-client-agent.js @@ -2,7 +2,7 @@ import * as API from './types.js' import { Absentee } from '@ucanto/principal' import * as delegationsResponse from '../src/utils/delegations-response.js' import * as DidMailto from '@web3-storage/did-mailto' -import { Access, Space } from '@web3-storage/capabilities' +import { Access, Space, Top } from '@web3-storage/capabilities' import { AgentData } from '@web3-storage/access' import { alice } from './helpers/utils.js' import { stringToDelegations } from '@web3-storage/access/encoding' @@ -24,6 +24,80 @@ import { } from '@web3-storage/access/agent' import * as Provider from '@web3-storage/access/provider' +/** + * Create and return a space, delegated to the device agent and to the account, + * and provisioned for the account. + * + * @param {Agent} device + * @param {API.Principal>} account + * @param {API.Assert} assert + */ +const createSpace = async (device, account, assert) => { + const space = await device.createSpace( + `space-test-${Math.random().toString().slice(2)}` + ) + + assert.ok(space.did()) + // provision space with an account so it can store delegations + const provisionResult = await Provider.add(device, { + account: account.did(), + consumer: space.did(), + }) + assert.ok(provisionResult.ok) + + // authorize device + const auth = await space.createAuthorization(device, { + access: AgentAccess.spaceAccess, + expiration: Infinity, + }) + await device.importSpaceFromDelegation(auth) + + // make space current + await device.setCurrentSpace(space.did()) + + const recovery = await space.createRecovery(account.did()) + const delegateResult = await AgentAccess.delegate(device, { + delegations: [recovery], + }) + assert.ok(delegateResult.ok) + return space +} + +/** + * Claim (`access/claim`) delegations for the device agent, and add them to the + * agent's proofs. + * + * @param {Agent} device + */ +const claimDelegations = async (device) => { + await claimAccess(device, device.issuer.did(), { + addProofs: true, + nonce: Math.random().toString(), + }) +} + +/** + * Assert that the device agent can invoke `space/info` on the given space. + * + * @param {Agent} device + * @param {import('@web3-storage/access/agent').OwnedSpace} space + * @param {API.Assert} assert + */ +async function assertCanSpaceInfo(device, space, assert) { + const spaceInfoResult = await device.invokeAndExecute(Space.info, { + with: space.did(), + }) + + assert.equal(spaceInfoResult.out.error, undefined) + + assert.ok(spaceInfoResult.out.ok) + const result = + /** @type {import('@web3-storage/access/types').SpaceInfoResult} */ ( + spaceInfoResult.out.ok + ) + assert.deepEqual(result.did, space.did()) +} + /** * @type {API.Tests} */ @@ -240,104 +314,107 @@ export const test = { const account = Absentee.from({ id: DidMailto.fromEmail(email) }) // first device - const deviceAAgentData = await AgentData.create() - const deviceA = await Agent.create(deviceAAgentData, { + const deviceA = await Agent.create(await AgentData.create(), { connection, }) + await requestAccess(deviceA, account, [{ can: '*' }]) + await confirmConfirmationUrl(deviceA.connection, await mail.take()) + await claimDelegations(deviceA) - /** - * @param {Agent} device - */ - const createSpace = async (device) => { - const space = await device.createSpace( - `space-test-${Math.random().toString().slice(2)}` - ) - - assert.ok(space.did()) - // provision space with an account so it can store delegations - const provisionResult = await Provider.add(device, { - account: account.did(), - consumer: space.did(), - }) - assert.ok(provisionResult.ok) - - // authorize device - const auth = await space.createAuthorization(device, { - access: AgentAccess.spaceAccess, - expiration: Infinity, - }) - await device.importSpaceFromDelegation(auth) - - // make space current - await device.setCurrentSpace(space.did()) + // deviceA creates a space + const space1 = await createSpace(deviceA, account, assert) - const recovery = await space.createRecovery(account.did()) - const delegateResult = await AgentAccess.delegate(device, { - delegations: [recovery], - }) - assert.ok(delegateResult.ok) - return space - } + // second device + const deviceB = await Agent.create(await AgentData.create(), { + connection, + }) + await requestAccess(deviceB, account, [{ can: '*' }]) + await confirmConfirmationUrl(deviceB.connection, await mail.take()) + await claimDelegations(deviceB) - /** - * @param {Agent} device - */ - const claimDelegations = async (device) => { - await claimAccess(device, device.issuer.did(), { - addProofs: true, - nonce: Math.random().toString(), - }) - } + // issuer + account proofs should authorize deviceB to invoke space/info + await assertCanSpaceInfo(deviceB, space1, assert) - /** - * @param {import('@web3-storage/access/agent').OwnedSpace} space - */ - async function assertDeviceBCanSpaceInfo(space) { - const spaceInfoResult = await deviceB.invokeAndExecute(Space.info, { - with: space.did(), - }) + // deviceA creates another space + const space2 = await createSpace(deviceA, account, assert) - assert.equal(spaceInfoResult.out.error, undefined) + // deviceB claims delegations again + await claimDelegations(deviceB) - assert.ok(spaceInfoResult.out.ok) - const result = - /** @type {import('@web3-storage/access/types').SpaceInfoResult} */ ( - spaceInfoResult.out.ok - ) - assert.deepEqual(result.did, space.did()) - } + // now deviceB should be able to invoke space/info on space2 + await assertCanSpaceInfo(deviceB, space2, assert) + }, + 'cannot gain unattested access': async (assert, context) => { + const { connection, mail } = context + const email = 'example@dag.house' + const account = Absentee.from({ id: DidMailto.fromEmail(email) }) - // deviceA authorization + // first device + const deviceA = await Agent.create(await AgentData.create(), { + connection, + }) await requestAccess(deviceA, account, [{ can: '*' }]) await confirmConfirmationUrl(deviceA.connection, await mail.take()) await claimDelegations(deviceA) // deviceA creates a space - const space = await createSpace(deviceA) + const space = await createSpace(deviceA, account, assert) - // second device - deviceB - const deviceBData = await AgentData.create() + const accountCheater = Absentee.from({ + id: DidMailto.fromEmail('cheater@example.com'), + }) - const deviceB = await Agent.create(deviceBData, { + // second device is unrelated + const deviceCheater = await Agent.create(await AgentData.create(), { connection, }) - // authorize deviceB - await requestAccess(deviceB, account, [{ can: '*' }]) - await confirmConfirmationUrl(deviceB.connection, await mail.take()) - await claimDelegations(deviceB) + await requestAccess(deviceCheater, accountCheater, [{ can: '*' }]) + await confirmConfirmationUrl(deviceCheater.connection, await mail.take()) + await claimDelegations(deviceCheater) - // issuer + account proofs should authorize deviceB to invoke space/info - await assertDeviceBCanSpaceInfo(space) + const spaceCheater = await createSpace( + deviceCheater, + accountCheater, + assert + ) - // deviceA creates another space - const space2 = await createSpace(deviceA) + // deviceCheater shouldn't have access to the space + deviceCheater.setCurrentSpace(space.did()) - // deviceB claims delegations again - await claimDelegations(deviceB) + // deviceCheater tries to craft a delegation to gain access to the account + const unattestedDelegation = await Top.top.delegate({ + issuer: Absentee.from({ id: account.did() }), + audience: deviceCheater, + with: 'ucan:*', + expiration: Infinity, + proofs: [], + }) - // now deviceB should be able to invoke space/info on space2 - await assertDeviceBCanSpaceInfo(space2) + // Then they store the delegation on the server + const delegateResult = await AgentAccess.delegate(deviceCheater, { + delegations: [unattestedDelegation], + space: spaceCheater.did(), + }) + + assert.ok( + delegateResult.ok, + `delegateResult.error: ${delegateResult.error}` + ) + + // Then they claim their delegations. If their attack is successful, they, + // now have access to the account and all its spaces. + await claimDelegations(deviceCheater) + + // Assert that they still can't do anything to the space. + await assert.rejects( + deviceCheater.invokeAndExecute(Space.info, { + with: space.did(), + }), + { + message: `no proofs available for resource ${space.did()} and ability space/info`, + } + ) }, 'can addSpacesFromDelegations': async (assert, context) => { const { agent } = await setup(context) diff --git a/packages/upload-api/test/types.ts b/packages/upload-api/test/types.ts index 6544bac7c..83c3eebbf 100644 --- a/packages/upload-api/test/types.ts +++ b/packages/upload-api/test/types.ts @@ -1,4 +1,5 @@ import * as API from '../src/types.js' +import assert from 'node:assert' export * from '../src/types.js' @@ -19,13 +20,14 @@ export interface Assert { actual: Actual, expected: Expected, message?: string - ) => unknown + ) => void deepEqual: ( actual: Actual, expected: Expected, message?: string - ) => unknown - ok: (actual: Actual, message?: string) => unknown + ) => void + ok: (actual: Actual, message?: string) => void + rejects: typeof assert.rejects } export type Test = (assert: Assert, context: TestContext) => unknown