diff --git a/packages/access-client/src/agent-use-cases.js b/packages/access-client/src/agent-use-cases.js index 7006935b4..fe8cc4f75 100644 --- a/packages/access-client/src/agent-use-cases.js +++ b/packages/access-client/src/agent-use-cases.js @@ -40,7 +40,6 @@ export async function requestAccess(access, account, capabilities) { * @param {object} opts * @param {string} [opts.nonce] - nonce to use for the claim * @param {boolean} [opts.addProofs] - whether to addProof to access agent - * @returns */ export async function claimAccess( access, 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/src/access/claim.js b/packages/upload-api/src/access/claim.js index 57e4be75d..6f5e2d513 100644 --- a/packages/upload-api/src/access/claim.js +++ b/packages/upload-api/src/access/claim.js @@ -1,7 +1,9 @@ import * as Server from '@ucanto/server' import * as Access from '@web3-storage/capabilities/access' +import * as UCAN from '@ipld/dag-ucan' import * as API from '../types.js' import * as delegationsResponse from '../utils/delegations-response.js' +import { createSessionProofs } from './confirm.js' /** * @param {API.AccessClaimContext} ctx @@ -9,29 +11,154 @@ import * as delegationsResponse from '../utils/delegations-response.js' export const provide = (ctx) => Server.provide(Access.claim, (input) => claim(input, ctx)) +/** + * Checks if the given Principal is an Account. + * @param {API.Principal} principal + * @returns {principal is API.Principal>} + */ +const isAccount = (principal) => principal.did().startsWith('did:mailto:') + +/** + * Returns true when the delegation has a `ucan:*` capability. + * @param {API.Delegation} delegation + * @returns boolean + */ +const isUCANStar = (delegation) => + delegation.capabilities.some((capability) => capability.with === 'ucan:*') + +/** + * Returns true when the capability is a `ucan/attest` capability for the given + * signer. + * + * @param {API.Capability} capability + * @returns {capability is API.UCANAttest} + */ +const isUCANAttest = (capability) => capability.can === 'ucan/attest' + /** * @param {API.Input} input * @param {API.AccessClaimContext} ctx * @returns {Promise>} */ -export const claim = async ( - { invocation }, - { delegationsStorage: delegations } -) => { +export const claim = async ({ invocation }, { delegationsStorage, signer }) => { const claimedAudience = invocation.capabilities[0].with - const claimedResult = await delegations.find({ audience: claimedAudience }) - if (claimedResult.error) { + const storedDelegationsResult = await delegationsStorage.find({ + audience: claimedAudience, + }) + + if (storedDelegationsResult.error) { return { error: { name: 'AccessClaimFailure', message: 'error finding delegations', - cause: claimedResult.error, + cause: storedDelegationsResult.error, }, } } + + const delegationsToReturnByCid = Object.fromEntries( + storedDelegationsResult.ok.map((delegation) => [delegation.cid, delegation]) + ) + + // Find any attested ucan:* delegations and replace them with fresh ones. + for (const delegation of storedDelegationsResult.ok) { + // Ignore delegations that aren't attestations, and ours. + const attestCap = delegation.capabilities.find(isUCANAttest) + if (!(attestCap && attestCap.with === signer.did())) continue + + // Ignore invalid attestations. + const valid = + (await UCAN.verifySignature(delegation.data, signer)) && + !UCAN.isTooEarly(delegation.data) && + !UCAN.isExpired(delegation.data) + if (!valid) continue + + // Ignore attestations of delegations we don't have. + const attestedCid = attestCap.nb.proof + const attestedDelegation = delegationsToReturnByCid[attestedCid.toString()] + if (!(attestedDelegation && isUCANStar(attestedDelegation))) continue + + // Create new session proofs for the attested delegation. + const sessionProofsResult = await createSessionProofsForLogin( + attestedDelegation, + delegationsStorage, + signer + ) + + // If something went wrong, bail on the entire invocation with the error. + // NB: This breaks out of the loop, because if this fails at all, we don't + // need to keep looking. + if (sessionProofsResult.error) { + return { + error: { + name: 'AccessClaimFailure', + message: 'error creating session proofs', + cause: sessionProofsResult.error, + }, + } + } + + // Delete the ones we're replacing... + delete delegationsToReturnByCid[delegation.cid.toString()] + delete delegationsToReturnByCid[attestedCid.toString()] + + // ...and add the new ones. + for (const proof of sessionProofsResult.ok) { + delegationsToReturnByCid[proof.cid.toString()] = proof + } + } + return { ok: { - delegations: delegationsResponse.encode(claimedResult.ok), + delegations: delegationsResponse.encode( + Object.values(delegationsToReturnByCid) + ), }, } } + +/** + * @param {API.Delegation} loginDelegation + * @param {API.DelegationsStorage} delegationsStorage + * @param {API.Signer} signer + * @returns {Promise>} + */ +async function createSessionProofsForLogin( + loginDelegation, + delegationsStorage, + signer +) { + // These should always be Accounts (did:mailto:), but if one's not, skip it. + if (!isAccount(loginDelegation.issuer)) return { ok: [] } + + const accountDelegationsResult = await delegationsStorage.find({ + audience: loginDelegation.issuer.did(), + }) + + if (accountDelegationsResult.error) { + return { + error: { + name: 'AccessClaimFailure', + message: 'error finding delegations', + cause: accountDelegationsResult.error, + }, + } + } + + return { + ok: await createSessionProofs({ + service: signer, + account: loginDelegation.issuer, + agent: loginDelegation.audience, + facts: loginDelegation.facts, + capabilities: loginDelegation.capabilities, + // We include all the delegations to the account so that the agent will + // have delegation chains to all the delegated resources. + // We should actually filter out only delegations that support delegated + // capabilities, but for now we just include all of them since we only + // implement sudo access anyway. + delegationProofs: accountDelegationsResult.ok, + expiration: Infinity, + }), + } +} diff --git a/packages/upload-api/src/access/confirm.js b/packages/upload-api/src/access/confirm.js index f7e344a41..717f08ef8 100644 --- a/packages/upload-api/src/access/confirm.js +++ b/packages/upload-api/src/access/confirm.js @@ -57,6 +57,8 @@ export async function confirm({ capability, invocation }, ctx) { return delegationsResult } + // Create session proofs, but containing no Space proofs. We'll store these, + // and generate the Space proofs on access/claim. const [delegation, attestation] = await createSessionProofs({ service: ctx.signer, account, @@ -72,16 +74,11 @@ export async function confirm({ capability, invocation }, ctx) { }, ], capabilities, - // We include all the delegations to the account so that the agent will - // have delegation chains to all the delegated resources. - // We should actually filter out only delegations that support delegated - // capabilities, but for now we just include all of them since we only - // implement sudo access anyway. - delegationProofs: delegationsResult.ok, + delegationProofs: [], expiration: Infinity, }) - // Store the delegations so that they can be pulled with access/claim. + // Store the delegations so that they can be pulled during access/claim. // Since there is no invocation that contains these delegations, don't pass // a `cause` parameter. // TODO: we should invoke access/delegate here rather than interacting with diff --git a/packages/upload-api/src/types.ts b/packages/upload-api/src/types.ts index 9329503ae..c56d9a893 100644 --- a/packages/upload-api/src/types.ts +++ b/packages/upload-api/src/types.ts @@ -31,7 +31,6 @@ import type { } from '@ucanto/interface' import type { ProviderInput, ConnectionView } from '@ucanto/server' -import { Signer as EdSigner } from '@ucanto/principal/ed25519' import { StorefrontService } from '@web3-storage/filecoin-api/types' import { ServiceContext as FilecoinServiceContext } from '@web3-storage/filecoin-api/storefront/api' import { DelegationsStorage as Delegations } from './types/delegations.js' @@ -398,16 +397,16 @@ export type UploadServiceContext = ConsumerServiceContext & SpaceServiceContext & RevocationServiceContext & ConcludeServiceContext & { - signer: EdSigner.Signer + signer: Signer uploadTable: UploadTable } export interface AccessClaimContext { + signer: Signer delegationsStorage: Delegations } export interface AccessServiceContext extends AccessClaimContext, AgentContext { - signer: EdSigner.Signer email: Email url: URL provisionsStorage: Provisions @@ -415,17 +414,17 @@ export interface AccessServiceContext extends AccessClaimContext, AgentContext { } export interface ConsumerServiceContext { - signer: EdSigner.Signer + signer: Signer provisionsStorage: Provisions } export interface CustomerServiceContext { - signer: EdSigner.Signer + signer: Signer provisionsStorage: Provisions } export interface AdminServiceContext { - signer: EdSigner.Signer + signer: Signer uploadTable: UploadTable storeTable: StoreTable } @@ -446,7 +445,7 @@ export interface ProviderServiceContext { } export interface SubscriptionServiceContext { - signer: EdSigner.Signer + signer: Signer provisionsStorage: Provisions subscriptionsStorage: SubscriptionsStorage } diff --git a/packages/upload-api/test/access-client-agent.js b/packages/upload-api/test/access-client-agent.js index d7c042e41..a97ade0aa 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, UCAN } 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,85 +314,144 @@ 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, }) - - // deviceA authorization await requestAccess(deviceA, account, [{ can: '*' }]) - await confirmConfirmationUrl(deviceA.connection, await mail.take()) + await claimDelegations(deviceA) - await claimAccess(deviceA, deviceA.issuer.did(), { - addProofs: true, + // deviceA creates a space + const space1 = await createSpace(deviceA, account, assert) + + // 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) + + // issuer + account proofs should authorize deviceB to invoke space/info + await assertCanSpaceInfo(deviceB, space1, assert) + + // deviceA creates another space + const space2 = await createSpace(deviceA, account, assert) + + // deviceB claims delegations again + await claimDelegations(deviceB) + + // 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, service } = context + const email = 'example@dag.house' + const account = Absentee.from({ id: DidMailto.fromEmail(email) }) + + // 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 spaceCreation = await deviceA.createSpace( - `space-test-${Math.random().toString().slice(2)}` - ) + const space = await createSpace(deviceA, account, assert) - assert.ok(spaceCreation.did()) - // provision space with an account so it can store delegations - const provisionResult = await Provider.add(deviceA, { - account: account.did(), - consumer: spaceCreation.did(), + const accountCheater = Absentee.from({ + id: DidMailto.fromEmail('cheater@example.com'), }) - assert.ok(provisionResult.ok) - // authorize deviceA - const auth = await spaceCreation.createAuthorization(deviceA, { - access: AgentAccess.spaceAccess, - expiration: Infinity, + // second device is unrelated + const deviceCheater = await Agent.create(await AgentData.create(), { + connection, }) - await deviceA.importSpaceFromDelegation(auth) - // make space current - await deviceA.setCurrentSpace(spaceCreation.did()) + await requestAccess(deviceCheater, accountCheater, [{ can: '*' }]) + await confirmConfirmationUrl(deviceCheater.connection, await mail.take()) + await claimDelegations(deviceCheater) - const recovery = await spaceCreation.createRecovery(account.did()) - const delegateResult = await AgentAccess.delegate(deviceA, { - delegations: [recovery], + const spaceCheater = await createSpace( + deviceCheater, + accountCheater, + assert + ) + + // deviceCheater shouldn't have access to the space + deviceCheater.setCurrentSpace(space.did()) + + // 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: [], }) - assert.ok(delegateResult.ok) - // second device - deviceB - const deviceBData = await AgentData.create() - const deviceB = await Agent.create(deviceBData, { - connection, + // Then they store the delegation on the server + const delegateResult = await AgentAccess.delegate(deviceCheater, { + delegations: [unattestedDelegation], + space: spaceCheater.did(), }) - // authorize deviceB - await requestAccess(deviceB, account, [{ can: '*' }]) - await confirmConfirmationUrl(deviceB.connection, await mail.take()) - // claim delegations aud=deviceB.issuer - const deviceBIssuerClaimed = await claimAccess( - deviceB, - deviceB.issuer.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(), + }), { - addProofs: true, + message: `no proofs available for resource ${space.did()} and ability space/info`, } ) - assert.equal( - deviceBIssuerClaimed.length, - 2, - 'deviceBIssuerClaimed delegations' - ) - // issuer + account proofs should authorize deviceB to invoke space/info - const spaceInfoResult = await deviceB.invokeAndExecute(Space.info, { - with: spaceCreation.did(), + // But what if they create a fake attestation? Will we notice before we + // recreate it, giving them access to everything? + const fakeAttestation = await UCAN.attest.delegate({ + issuer: Absentee.from({ id: service.did() }), + audience: deviceCheater, + with: service.did(), + nb: { proof: unattestedDelegation.cid }, + expiration: Infinity, }) - assert.equal(spaceInfoResult.out.error, undefined) + const fakeAttestationDelegateResult = await AgentAccess.delegate( + deviceCheater, + { + delegations: [fakeAttestation], + space: spaceCheater.did(), + } + ) - assert.ok(spaceInfoResult.out.ok) - const result = - /** @type {import('@web3-storage/access/types').SpaceInfoResult} */ ( - spaceInfoResult.out.ok - ) - assert.deepEqual(result.did, spaceCreation.did()) + // They can store the fake attestation on the server + assert.ok( + fakeAttestationDelegateResult.ok, + `delegateResult2.error: ${fakeAttestationDelegateResult.error}` + ) + + // And they'll get it back, but we won't recreate it and sign it + await claimDelegations(deviceCheater) + + // ...so they still won't have access 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