From 743a72f6a755425b9064ba8e3f1d70c92d711642 Mon Sep 17 00:00:00 2001 From: Irakli Gozalishvili Date: Thu, 2 Mar 2023 16:11:44 -0800 Subject: [PATCH] feat: includes proofs chains in the delegated authorization chain (#467) This pr cleans up bunch of mess in the access/authorize code path and starts including proof chains in the delegated authorization so agents can actually utilize delegations. --- .../access-api/src/routes/validate-email.js | 98 +- .../src/service/access-authorize.js | 76 +- packages/access-api/src/service/index.js | 4 +- .../access-api/test/access-authorize.test.js | 139 ++- .../test/helpers/ucanto-test-utils.js | 11 +- packages/capabilities/src/access.js | 51 +- packages/capabilities/src/types.ts | 1 + .../test/capabilities/access.test.js | 887 ++++++++++++------ 8 files changed, 884 insertions(+), 383 deletions(-) diff --git a/packages/access-api/src/routes/validate-email.js b/packages/access-api/src/routes/validate-email.js index 50d7226ce..0cb6a6607 100644 --- a/packages/access-api/src/routes/validate-email.js +++ b/packages/access-api/src/routes/validate-email.js @@ -1,4 +1,3 @@ -/* eslint-disable no-unused-vars */ import { stringToDelegation, delegationsToString, @@ -15,6 +14,7 @@ import { import * as ucanto from '@ucanto/core' import * as validator from '@ucanto/validator' import { Verifier, Absentee } from '@ucanto/principal' +import { collect } from 'streaming-iterables' /** * @param {import('@web3-storage/worker-utils/router').ParsedRequest} req @@ -39,9 +39,10 @@ export async function validateEmail(req, env) { return recover(req, env) } - if (req.query && req.query.ucan && req.query.mode === 'session') { - return session(req, env) + if (req.query && req.query.ucan && req.query.mode === 'authorize') { + return authorize(req, env) } + if (req.query && req.query.ucan) { try { const delegation = await env.models.validations.put( @@ -134,36 +135,29 @@ async function recover(req, env) { * @param {import('@web3-storage/worker-utils/router').ParsedRequest} req * @param {import('../bindings.js').RouteContext} env */ -async function session(req, env) { - /** @type {import('@ucanto/interface').Delegation<[import('@web3-storage/capabilities/src/types.js').AccessAuthorize]>} */ - const delegation = stringToDelegation(req.query.ucan) - - // ⚠️ This is not an ideal solution but we do need to ensure that attacker - // cannot simply send a valid `access/authorize` delegation to the service - // and get an attested session. - if (delegation.issuer.did() !== env.signer.did()) { - throw new Error('Delegation MUST be issued by the service') - } +async function authorize(req, env) { + try { + /** + * @type {import('@ucanto/interface').Delegation<[import('@web3-storage/capabilities/src/types.js').AccessConfirm]>} + */ + const request = stringToDelegation(req.query.ucan) - // TODO: Figure when do we go through a post vs get request. WebSocket message - // was send regardless of the method, but delegations were only stored on post - // requests. - if (req.method.toLowerCase() === 'post') { - const accessSessionResult = await validator.access(delegation, { - capability: Access.authorize, + const confirmation = await validator.access(request, { + capability: Access.confirm, principal: Verifier, authority: env.signer, }) - if (accessSessionResult.error) { - throw new Error( - `unable to validate access session: ${accessSessionResult.error}` - ) + if (confirmation.error) { + throw new Error(`unable to validate access session: ${confirmation}`) + } + if (confirmation.capability.with !== env.signer.did()) { + throw new Error(`Not a valid access/confirm delegation`) } // Create a absentee signer for the account that authorized the delegation - const account = Absentee.from({ id: accessSessionResult.capability.nb.iss }) - const agent = Verifier.parse(accessSessionResult.capability.with) + const account = Absentee.from({ id: confirmation.capability.nb.iss }) + const agent = Verifier.parse(confirmation.capability.nb.aud) // It the future we should instead render a page and allow a user to select // which delegations they wish to re-delegate. Right now we just re-delegate @@ -171,56 +165,56 @@ async function session(req, env) { const capabilities = /** @type {ucanto.UCAN.Capabilities} */ ( - accessSessionResult.capability.nb.att.map(({ can }) => ({ + confirmation.capability.nb.att.map(({ can }) => ({ can, with: /** @type {ucanto.UCAN.Resource} */ ('ucan:*'), })) ) - // create an authorization on behalf of the account with an absent - // signature. - const authorization = await ucanto.delegate({ + // create an delegation on behalf of the account with an absent signature. + const delegation = await ucanto.delegate({ issuer: account, audience: agent, capabilities, expiration: Infinity, - // We should also include proofs with all the delegations we have for - // the account. + // 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. + proofs: await collect( + env.models.delegations.find({ + audience: account.did(), + }) + ), }) - const attestation = await ucanto.delegate({ + const attestation = await Access.session.delegate({ issuer: env.signer, audience: agent, - capabilities: [ - { - with: env.signer.did(), - can: 'ucan/attest', - nb: { proof: authorization.cid }, - }, - ], + with: env.signer.did(), + nb: { proof: delegation.cid }, expiration: Infinity, }) // Store the delegations so that they can be pulled with access/claim - await env.models.delegations.putMany(authorization, attestation) + // The fact that we're storing proofs chains that we pulled from the + // database is not great, but it's a tradeoff we're making for now. + await env.models.delegations.putMany(delegation, attestation) + const authorization = delegationsToString([delegation, attestation]) // Send delegations to the client through a websocket - await env.models.validations.putSession( - delegationsToString([authorization, attestation]), - agent.did() - ) - } + await env.models.validations.putSession(authorization, agent.did()) - // TODO: We clearly should not render that access/delegate in the QR code, but - // I'm not sure what this QR code is used for. - try { + // We render HTML page explaining to the user what has happened and providing + // a QR code in the details if they want to drill down. return new HtmlResponse( ( { - /** - * We re-delegate the capability to the account DID and limit it's - * lifetime to 15 minutes which should be enough time for the user to - * complete the authorization. We don't want to allow authorization for - * long time because it could be used by an attacker to gain authorization - * by sending second request misleading a user to click a wrong one. - */ - const authorization = await Access.authorize - .invoke({ - issuer: ctx.signer, - audience: DID.parse(capability.nb.iss), - with: capability.with, - lifetimeInSeconds: 60 * 15, // 15 minutes - nb: capability.nb, - proofs: [invocation], - }) - .delegate() + return Server.provide(Access.authorize, async ({ capability }) => { + /** + * We delegate to the account DID `access/confirm` capability which will + * get embedded in the URL that we send to the user. When user clicks the + * link we'll get this delegation back in the `/validate-email` endpoint + * which will allow us to verify that it was the user who clicked the link + * and not some attacker impersonating the user. We will know that because + * the `with` field is our service DID and only private key holder is able + * to issue such delegation. + * + * We limit lifetime of this UCAN to 15 minutes to reduce the attack + * surface where an attacker could attempt concurrent authorization + * request in attempt confuse a user into clicking the wrong link. + */ + const confirmation = await Access.confirm + .invoke({ + issuer: ctx.signer, + audience: DID.parse(capability.nb.iss), + // Because with is set to our DID no other actor will be able to issue + // this delegation without our private key. + with: ctx.signer.did(), + lifetimeInSeconds: 60 * 15, // 15 minutes + // We link to the authorization request so that this attestation can + // not be used to authorize a different request. + nb: { + // we copy request details and set the `aud` field to the agent DID + // that requested the authorization. + ...capability.nb, + aud: capability.with, + }, + }) + .delegate() - const encoded = delegationToString(authorization) + await ctx.models.accounts.create(capability.nb.iss) - await ctx.models.accounts.create(capability.nb.iss) + // Encode authorization request and our attestation as string so that it + // can be passed as a query parameter in the URL. + const encoded = delegationToString(confirmation) - const url = `${ctx.url.protocol}//${ctx.url.host}/validate-email?ucan=${encoded}&mode=session` + const url = `${ctx.url.protocol}//${ctx.url.host}/validate-email?ucan=${encoded}&mode=authorize` - await ctx.email.sendValidation({ - to: Mailto.toEmail(capability.nb.iss), - url, - }) + await ctx.email.sendValidation({ + to: Mailto.toEmail(capability.nb.iss), + url, + }) - return {} - } - ) + return {} + }) } diff --git a/packages/access-api/src/service/index.js b/packages/access-api/src/service/index.js index 3ab9813b1..14b8425ba 100644 --- a/packages/access-api/src/service/index.js +++ b/packages/access-api/src/service/index.js @@ -31,7 +31,7 @@ export function service(ctx) { claim: (...args) => { // disable until hardened in test/staging if (ctx.config.ENV === 'production') { - throw new Error(`acccess/claim invocation handling is not enabled`) + throw new Error(`access/claim invocation handling is not enabled`) } return accessClaimProvider({ delegations: ctx.models.delegations, @@ -41,7 +41,7 @@ export function service(ctx) { delegate: (...args) => { // disable until hardened in test/staging if (ctx.config.ENV === 'production') { - throw new Error(`acccess/delegate invocation handling is not enabled`) + throw new Error(`access/delegate invocation handling is not enabled`) } return accessDelegateProvider({ delegations: ctx.models.delegations, diff --git a/packages/access-api/test/access-authorize.test.js b/packages/access-api/test/access-authorize.test.js index d0ffaa69a..9a5230ba8 100644 --- a/packages/access-api/test/access-authorize.test.js +++ b/packages/access-api/test/access-authorize.test.js @@ -11,7 +11,12 @@ import { context } from './helpers/context.js' // @ts-ignore import isSubset from 'is-subset' import { toEmail } from '../src/utils/did-mailto.js' -import { warnOnErrorResult } from './helpers/ucanto-test-utils.js' +import { + warnOnErrorResult, + registerSpaces, +} from './helpers/ucanto-test-utils.js' +import { ed25519, Absentee } from '@ucanto/principal' +import { delegate } from '@ucanto/core' /** @type {typeof assert} */ const t = assert @@ -37,7 +42,7 @@ describe('access/authorize', function () { }) }) - it('should issue ./update', async function () { + it('should issue access/confirm', async function () { const { issuer, service, conn, d1 } = ctx const accountDID = 'did:mailto:dag.house:hello' @@ -70,10 +75,11 @@ describe('access/authorize', function () { t.deepEqual(delegation.audience.did(), accountDID) t.deepEqual(delegation.capabilities, [ { - with: issuer.did(), - can: 'access/authorize', + with: conn.id.did(), + can: 'access/confirm', nb: { iss: accountDID, + aud: issuer.did(), att: [{ can: '*' }], }, }, @@ -114,15 +120,12 @@ describe('access/authorize', function () { } const url = new URL(email.url) - const encoded = - /** @type {import('@web3-storage/access/types').EncodedDelegation<[import('@web3-storage/capabilities/types').AccessAuthorize]>} */ ( - url.searchParams.get('ucan') - ) const rsp = await mf.dispatchFetch(url, { method: 'POST' }) const html = await rsp.text() - assert(html.includes(encoded)) + assert(html.includes('Email Validated')) assert(html.includes(toEmail(accountDID))) + assert(html.includes(issuer.did())) }) // this relies on ./update that is no longer in ucanto @@ -304,4 +307,122 @@ describe('access/authorize', function () { assert.fail('should have ws') } }) + + it('should receive account delegations', async () => { + const space = await ed25519.generate() + const w3 = ctx.service + + await registerSpaces([space], ctx) + const account = Absentee.from({ id: 'did:mailto:dag.house:test' }) + + // delegate all space capabilities to the account + const delegation = await delegate({ + issuer: space, + audience: account, + capabilities: [ + { + with: space.did(), + can: '*', + }, + ], + }) + + // send above delegation to the service so it can be claimed. + const delegateResult = await Access.delegate + .invoke({ + issuer: space, + audience: w3, + with: space.did(), + nb: { + delegations: { + [delegation.cid.toString()]: delegation.cid, + }, + }, + proofs: [delegation], + }) + .execute(ctx.conn) + + assert.equal(delegateResult.error, undefined, 'delegation succeeded') + + // Now generate an agent and try to authorize with the account + const agent = await ed25519.generate() + const auth = await Access.authorize + .invoke({ + issuer: agent, + audience: w3, + with: agent.did(), + nb: { + iss: account.did(), + att: [{ can: '*' }], + }, + }) + .execute(ctx.conn) + + assert.equal(auth.error, undefined, 'authorize succeeded') + + // now we are going to complete authorization flow following the email link + const [email] = outbox + assert.notEqual(email, undefined, 'email was sent') + const confirmEmailPostUrl = new URL(email.url) + const confirmEmailPostResponse = await ctx.mf.dispatchFetch( + confirmEmailPostUrl, + { method: 'POST' } + ) + assert.deepEqual( + confirmEmailPostResponse.status, + 200, + 'confirmEmailPostResponse status is 200' + ) + + // we can use delegations to invoke access/claim with=accountDID + const claim = await Access.claim + .invoke({ + issuer: agent, + audience: w3, + with: agent.did(), + }) + .execute(ctx.conn) + + if (claim.error) { + assert.fail('claim succeeded') + } + + const delegations = Object.values(claim.delegations).map((bytes) => { + return bytesToDelegations( + /** @type {import('@web3-storage/access/src/types.js').BytesDelegation} */ ( + bytes + ) + )[0] + }) + + const [attestation, authorization] = + delegations[0].issuer.did() === w3.did() + ? delegations + : delegations.reverse() + + assert.deepEqual(attestation.capabilities, [ + { + can: 'ucan/attest', + with: w3.did(), + nb: { + proof: authorization.cid, + }, + }, + ]) + + assert.equal(authorization.issuer.did(), account.did()) + assert.deepEqual(authorization.capabilities, [ + { + can: '*', + with: 'ucan:*', + }, + ]) + + assert.deepEqual( + // @ts-expect-error - it could be a link but we know it's delegation + authorization.proofs[0].cid, + delegation.cid, + 'delegation to an account is included' + ) + }) }) diff --git a/packages/access-api/test/helpers/ucanto-test-utils.js b/packages/access-api/test/helpers/ucanto-test-utils.js index 9fa716bda..4abb338fa 100644 --- a/packages/access-api/test/helpers/ucanto-test-utils.js +++ b/packages/access-api/test/helpers/ucanto-test-utils.js @@ -11,7 +11,7 @@ import * as assert from 'assert' */ export function createTesterFromContext(createContext, options) { const context = createContext().then(async (ctx) => { - await registerSpaces(options?.registerSpaces ?? [], ctx.service, ctx.conn) + await registerSpaces(options?.registerSpaces ?? [], ctx) return ctx }) const issuer = context.then(({ issuer }) => issuer) @@ -38,13 +38,14 @@ export function createTesterFromContext(createContext, options) { * using a service-issued voucher/redeem invocation * * @param {Iterable>} spaces - * @param {Ucanto.Signer} issuer - * @param {Ucanto.ConnectionView>} conn + * @param {object} options + * @param {Ucanto.Signer} options.service + * @param {Ucanto.ConnectionView>} options.conn */ -export async function registerSpaces(spaces, issuer, conn) { +export async function registerSpaces(spaces, { service, conn }) { for (const spacePromise of spaces) { const space = await spacePromise - const redeem = await spaceRegistrationInvocation(issuer, space.did()) + const redeem = await spaceRegistrationInvocation(service, space.did()) const results = await conn.execute(redeem) assert.deepEqual( results.length, diff --git a/packages/capabilities/src/access.js b/packages/capabilities/src/access.js index c50c124de..61ab3e1cf 100644 --- a/packages/capabilities/src/access.js +++ b/packages/capabilities/src/access.js @@ -8,7 +8,7 @@ * * @module */ -import { capability, URI, DID, Schema, Failure } from '@ucanto/validator' +import { capability, URI, DID, Link, Schema, Failure } from '@ucanto/validator' import * as Types from '@ucanto/interface' import { equalWith, fail, equal } from './utils.js' export { top } from './top.js' @@ -74,20 +74,49 @@ export const authorize = capability({ }) /** - * Issued by trusted authority (usually the one handling invocation that contains this proof) - * to the account (aud) to update invocation local state of the document. + * Capability is delegated by us to the user allowing them to complete the + * authorization flow. It allows us to ensure that user clicks the link and + * we don't have some rogue agent trying to impersonate user clicking the link + * in order to get access to their account. + */ +export const confirm = capability({ + can: 'access/confirm', + with: DID, + nb: Schema.struct({ + iss: Account, + aud: Schema.did(), + att: CapabilityRequest.array(), + }), + derives: (claim, proof) => { + return ( + fail(equalWith(claim, proof)) || + fail(equal(claim.nb.iss, proof.nb.iss, 'iss')) || + fail(equal(claim.nb.aud, proof.nb.aud, 'aud')) || + fail(subsetCapabilities(claim.nb.att, proof.nb.att)) || + true + ) + }, +}) + +/** + * Issued by trusted authority (usually the one handling invocation) that attest + * that specific UCAN delegation has been considered authentic. * - * @see https://github.com/web3-storage/specs/blob/main/w3-account.md#update + * @see https://github.com/web3-storage/specs/blob/main/w3-session.md#authorization-session * * @example * ```js * { iss: "did:web:web3.storage", - aud: "did:mailto:alice@web.mail", + aud: "did:key:z6Mkk89bC3JrVqKie71YEcc5M1SMVxuCgNx6zLZ8SYJsxALi", att: [{ - with: "did:web:web3.storage", - can: "./update", - nb: { key: "did:key:zAgent" } + "with": "did:web:web3.storage", + "can": "ucan/attest", + "nb": { + "proof": { + "/": "bafyreifer23oxeyamllbmrfkkyvcqpujevuediffrpvrxmgn736f4fffui" + } + } }], exp: null sig: "..." @@ -95,12 +124,12 @@ export const authorize = capability({ * ``` */ export const session = capability({ - can: './update', + can: 'ucan/attest', // Should be web3.storage DID with: URI.match({ protocol: 'did:' }), nb: Schema.struct({ - // Agent DID so it can sign UCANs as did:mailto if it matches this delegation `aud` - key: DID.match({ method: 'key' }), + // UCAN delegation that is being attested. + proof: Link, }), }) diff --git a/packages/capabilities/src/types.ts b/packages/capabilities/src/types.ts index 8bb1ff72a..1ef089700 100644 --- a/packages/capabilities/src/types.ts +++ b/packages/capabilities/src/types.ts @@ -40,6 +40,7 @@ export type AccessDelegateSuccess = unknown export type AccessDelegateFailure = { error: true } | InsufficientStorage export type AccessSession = InferInvokedCapability +export type AccessConfirm = InferInvokedCapability // Space export type Space = InferInvokedCapability diff --git a/packages/capabilities/test/capabilities/access.test.js b/packages/capabilities/test/capabilities/access.test.js index 1958e1e73..f8069db30 100644 --- a/packages/capabilities/test/capabilities/access.test.js +++ b/packages/capabilities/test/capabilities/access.test.js @@ -7,331 +7,676 @@ import * as Ucanto from '@ucanto/interface' import { delegate, invoke, parseLink } from '@ucanto/core' describe('access capabilities', function () { - it('should self issue', async function () { - const agent = mallory - const auth = Access.authorize.invoke({ - issuer: agent, - audience: service, - with: agent.did(), - nb: { - iss: 'did:mailto:web3.storage:test', - att: [{ can: '*' }], - }, - }) + describe('access/authorize', function () { + it('should self issue', async function () { + const agent = mallory + const auth = Access.authorize.invoke({ + issuer: agent, + audience: service, + with: agent.did(), + nb: { + iss: 'did:mailto:web3.storage:test', + att: [{ can: '*' }], + }, + }) - const result = await access(await auth.delegate(), { - capability: Access.authorize, - principal: Verifier, - authority: service, + const result = await access(await auth.delegate(), { + capability: Access.authorize, + principal: Verifier, + authority: service, + }) + if (result.error) { + assert.fail('error in self issue') + } else { + assert.deepEqual(result.audience.did(), service.did()) + assert.equal(result.capability.can, 'access/authorize') + assert.deepEqual(result.capability.nb, { + iss: 'did:mailto:web3.storage:test', + att: [{ can: '*' }], + }) + } }) - if (result.error) { - assert.fail('error in self issue') - } else { - assert.deepEqual(result.audience.did(), service.did()) - assert.equal(result.capability.can, 'access/authorize') - assert.deepEqual(result.capability.nb, { - iss: 'did:mailto:web3.storage:test', - att: [{ can: '*' }], + + it('should delegate from authorize to authorize', async function () { + const agent1 = bob + const agent2 = mallory + const claim = Access.authorize.invoke({ + issuer: agent2, + audience: service, + with: agent1.did(), + nb: { + iss: 'did:mailto:web3.storage:test', + att: [{ can: '*' }], + }, + proofs: [ + await Access.authorize.delegate({ + issuer: agent1, + audience: agent2, + with: agent1.did(), + nb: { + iss: 'did:mailto:web3.storage:test', + }, + }), + ], }) - } - }) - it('should delegate from authorize to authorize', async function () { - const agent1 = bob - const agent2 = mallory - const claim = Access.authorize.invoke({ - issuer: agent2, - audience: service, - with: agent1.did(), - nb: { - iss: 'did:mailto:web3.storage:test', - att: [{ can: '*' }], - }, - proofs: [ - await Access.authorize.delegate({ - issuer: agent1, - audience: agent2, - with: agent1.did(), - nb: { - iss: 'did:mailto:web3.storage:test', - }, - }), - ], - }) + const result = await access(await claim.delegate(), { + capability: Access.authorize, + principal: Verifier, + authority: service, + }) - const result = await access(await claim.delegate(), { - capability: Access.authorize, - principal: Verifier, - authority: service, + if (result.error) { + assert.fail('should not error') + } else { + assert.deepEqual(result.audience.did(), service.did()) + assert.equal(result.capability.can, 'access/authorize') + assert.deepEqual(result.capability.nb, { + iss: 'did:mailto:web3.storage:test', + att: [{ can: '*' }], + }) + } }) - if (result.error) { - assert.fail('should not error') - } else { - assert.deepEqual(result.audience.did(), service.did()) - assert.equal(result.capability.can, 'access/authorize') - assert.deepEqual(result.capability.nb, { - iss: 'did:mailto:web3.storage:test', - att: [{ can: '*' }], + it('should delegate from authorize/* to authorize', async function () { + const agent1 = bob + const agent2 = mallory + const claim = Access.authorize.invoke({ + issuer: agent2, + audience: service, + with: agent1.did(), + nb: { + iss: 'did:mailto:web3.storage:test', + att: [{ can: '*' }], + }, + proofs: [ + await Access.access.delegate({ + issuer: agent1, + audience: agent2, + with: agent1.did(), + }), + ], + }) + + const result = await access(await claim.delegate(), { + capability: Access.authorize, + principal: Verifier, + authority: service, }) - } - }) - it('should delegate from authorize/* to authorize', async function () { - const agent1 = bob - const agent2 = mallory - const claim = Access.authorize.invoke({ - issuer: agent2, - audience: service, - with: agent1.did(), - nb: { - iss: 'did:mailto:web3.storage:test', - att: [{ can: '*' }], - }, - proofs: [ - await Access.access.delegate({ - issuer: agent1, - audience: agent2, - with: agent1.did(), - }), - ], + if (result.error) { + assert.fail('should not error') + } else { + assert.deepEqual(result.audience.did(), service.did()) + assert.equal(result.capability.can, 'access/authorize') + assert.deepEqual(result.capability.nb, { + iss: 'did:mailto:web3.storage:test', + att: [{ can: '*' }], + }) + } }) - const result = await access(await claim.delegate(), { - capability: Access.authorize, - principal: Verifier, - authority: service, + it('should delegate from * to authorize', async function () { + const agent1 = bob + const agent2 = mallory + const claim = Access.authorize.invoke({ + issuer: agent2, + audience: service, + with: agent1.did(), + nb: { + iss: 'did:mailto:web3.storage:test', + att: [{ can: '*' }], + }, + proofs: [ + await Access.top.delegate({ + issuer: agent1, + audience: agent2, + with: agent1.did(), + }), + ], + }) + + const result = await access(await claim.delegate(), { + capability: Access.authorize, + principal: Verifier, + authority: service, + }) + + if (result.error) { + assert.fail('should not error') + } else { + assert.deepEqual(result.audience.did(), service.did()) + assert.equal(result.capability.can, 'access/authorize') + assert.deepEqual(result.capability.nb, { + iss: 'did:mailto:web3.storage:test', + att: [{ can: '*' }], + }) + } }) - if (result.error) { - assert.fail('should not error') - } else { - assert.deepEqual(result.audience.did(), service.did()) - assert.equal(result.capability.can, 'access/authorize') - assert.deepEqual(result.capability.nb, { - iss: 'did:mailto:web3.storage:test', - att: [{ can: '*' }], + it('should error auth to auth when `iss` is different', async function () { + const agent1 = bob + const agent2 = mallory + const claim = Access.authorize.invoke({ + issuer: agent2, + audience: service, + with: agent1.did(), + nb: { + iss: 'did:mailto:web3.storage:ANOTHER_TEST', + att: [{ can: '*' }], + }, + proofs: [ + await Access.authorize.delegate({ + issuer: agent1, + audience: agent2, + with: agent1.did(), + nb: { + iss: 'did:mailto:web3.storage:test', + }, + }), + ], }) - } - }) - it('should delegate from * to authorize', async function () { - const agent1 = bob - const agent2 = mallory - const claim = Access.authorize.invoke({ - issuer: agent2, - audience: service, - with: agent1.did(), - nb: { - iss: 'did:mailto:web3.storage:test', - att: [{ can: '*' }], - }, - proofs: [ - await Access.top.delegate({ - issuer: agent1, - audience: agent2, - with: agent1.did(), - }), - ], + const result = await access(await claim.delegate(), { + capability: Access.authorize, + principal: Verifier, + authority: service, + }) + + if (result.error) { + assert.ok(result.message.includes('- Can not derive')) + } else { + assert.fail('should error') + } }) - const result = await access(await claim.delegate(), { - capability: Access.authorize, - principal: Verifier, - authority: service, + it('should be able to derive from * scope', async function () { + const claim = Access.authorize.invoke({ + issuer: bob, + audience: service, + with: alice.did(), + nb: { + iss: 'did:mailto:web.mail:alice', + att: [{ can: 'store/*' }], + }, + proofs: [ + await Access.authorize.delegate({ + issuer: alice, + audience: bob, + with: alice.did(), + nb: { + iss: 'did:mailto:web.mail:alice', + att: [{ can: '*' }], + }, + }), + ], + }) + + const result = await access(await claim.delegate(), { + capability: Access.authorize, + principal: Verifier, + authority: service, + }) + + assert.equal(result.error, undefined, 'should be authorized') }) - if (result.error) { - assert.fail('should not error') - } else { - assert.deepEqual(result.audience.did(), service.did()) - assert.equal(result.capability.can, 'access/authorize') - assert.deepEqual(result.capability.nb, { - iss: 'did:mailto:web3.storage:test', - att: [{ can: '*' }], + it('should be able to reduce scope', async function () { + const claim = Access.authorize.invoke({ + issuer: bob, + audience: service, + with: alice.did(), + nb: { + iss: 'did:mailto:web.mail:alice', + att: [{ can: 'store/add' }], + }, + proofs: [ + await Access.authorize.delegate({ + issuer: alice, + audience: bob, + with: alice.did(), + nb: { + iss: 'did:mailto:web.mail:alice', + att: [{ can: 'store/add' }, { can: 'store/remove' }], + }, + }), + ], + }) + + const result = await access(await claim.delegate(), { + capability: Access.authorize, + principal: Verifier, + authority: service, }) - } - }) - it('should error auth to auth when `iss` is different', async function () { - const agent1 = bob - const agent2 = mallory - const claim = Access.authorize.invoke({ - issuer: agent2, - audience: service, - with: agent1.did(), - nb: { - iss: 'did:mailto:web3.storage:ANOTHER_TEST', - att: [{ can: '*' }], - }, - proofs: [ - await Access.authorize.delegate({ - issuer: agent1, - audience: agent2, - with: agent1.did(), - nb: { - iss: 'did:mailto:web3.storage:test', - }, - }), - ], + assert.equal(result.error, undefined, 'should be authorized') }) - const result = await access(await claim.delegate(), { - capability: Access.authorize, - principal: Verifier, - authority: service, + it('should error on escalation', async function () { + const claim = Access.authorize.invoke({ + issuer: bob, + audience: service, + with: alice.did(), + nb: { + iss: 'did:mailto:web.mail:alice', + att: [{ can: '*' }], + }, + proofs: [ + await Access.authorize.delegate({ + issuer: alice, + audience: bob, + with: alice.did(), + nb: { + iss: 'did:mailto:web.mail:alice', + att: [{ can: 'store/*' }], + }, + }), + ], + }) + + const result = await access(await claim.delegate(), { + capability: Access.authorize, + principal: Verifier, + authority: service, + }) + + if (result.error) { + assert.ok(result.message.includes('unauthorized nb.att.can *')) + } else { + assert.fail('should error') + } }) - if (result.error) { - assert.ok(result.message.includes('- Can not derive')) - } else { - assert.fail('should error') - } - }) + it('should error on principal misalignment', async function () { + const agent1 = bob + const agent2 = mallory + const claim = Access.authorize.invoke({ + issuer: agent2, + audience: service, + with: alice.did(), + nb: { + iss: 'did:mailto:web3.storage:test', + att: [{ can: '*' }], + }, + proofs: [ + await Access.top.delegate({ + issuer: agent1, + audience: agent2, + with: agent1.did(), + }), + ], + }) - it('should be able to derive from * scope', async function () { - const claim = Access.authorize.invoke({ - issuer: bob, - audience: service, - with: alice.did(), - nb: { - iss: 'did:mailto:web.mail:alice', - att: [{ can: 'store/*' }], - }, - proofs: [ - await Access.authorize.delegate({ - issuer: alice, - audience: bob, - with: alice.did(), + const result = await access(await claim.delegate(), { + capability: Access.authorize, + principal: Verifier, + authority: service, + }) + + if (result.error) { + assert.ok(result.message.includes('- Can not derive')) + } else { + assert.fail('should error') + } + }) + + it('should fail validation if its not mailto', async function () { + assert.throws(() => { + Access.authorize.invoke({ + issuer: bob, + audience: service, + with: bob.did(), nb: { - iss: 'did:mailto:web.mail:alice', + // @ts-expect-error + iss: 'did:NOT_MAILTO:web3.storage:test', att: [{ can: '*' }], }, - }), - ], + }) + }, /Expected a did:mailto: but got "did:NOT_MAILTO:web3.storage:test" instead/) }) + }) - const result = await access(await claim.delegate(), { - capability: Access.authorize, - principal: Verifier, - authority: service, + describe('access/confirm', function () { + it('should self issue', async function () { + const agent = mallory + const ucan = Access.confirm.invoke({ + issuer: agent, + audience: service, + with: agent.did(), + nb: { + iss: 'did:mailto:web3.storage:test', + aud: agent.did(), + att: [{ can: '*' }], + }, + }) + + const result = await access(await ucan.delegate(), { + capability: Access.confirm, + principal: Verifier, + authority: service, + }) + if (result.error) { + assert.fail('error in self issue') + } else { + assert.deepEqual(result.audience.did(), service.did()) + assert.equal(result.capability.can, 'access/confirm') + assert.deepEqual(result.capability.nb, { + iss: 'did:mailto:web3.storage:test', + aud: agent.did(), + att: [{ can: '*' }], + }) + } }) - assert.equal(result.error, undefined, 'should be authorized') - }) + it('should delegate from confirm to confirm', async function () { + const agent1 = bob + const agent2 = mallory + const ucan = Access.confirm.invoke({ + issuer: agent2, + audience: service, + with: agent1.did(), + nb: { + iss: 'did:mailto:web3.storage:test', + aud: agent2.did(), + att: [{ can: '*' }], + }, + proofs: [ + await Access.confirm.delegate({ + issuer: agent1, + audience: agent2, + with: agent1.did(), + nb: { + iss: 'did:mailto:web3.storage:test', + }, + }), + ], + }) - it('should be able to reduce scope', async function () { - const claim = Access.authorize.invoke({ - issuer: bob, - audience: service, - with: alice.did(), - nb: { - iss: 'did:mailto:web.mail:alice', - att: [{ can: 'store/add' }], - }, - proofs: [ - await Access.authorize.delegate({ - issuer: alice, - audience: bob, - with: alice.did(), - nb: { - iss: 'did:mailto:web.mail:alice', - att: [{ can: 'store/add' }, { can: 'store/remove' }], - }, - }), - ], - }) + const result = await access(await ucan.delegate(), { + capability: Access.confirm, + principal: Verifier, + authority: service, + }) - const result = await access(await claim.delegate(), { - capability: Access.authorize, - principal: Verifier, - authority: service, + if (result.error) { + assert.fail('should not error') + } else { + assert.deepEqual(result.audience.did(), service.did()) + assert.equal(result.capability.can, 'access/confirm') + assert.deepEqual(result.capability.nb, { + iss: 'did:mailto:web3.storage:test', + aud: agent2.did(), + att: [{ can: '*' }], + }) + } }) - assert.equal(result.error, undefined, 'should be authorized') - }) + it('should delegate from access/* to access/confirm', async function () { + const agent1 = bob + const agent2 = mallory + const ucan = Access.confirm.invoke({ + issuer: agent2, + audience: service, + with: agent1.did(), + nb: { + iss: 'did:mailto:web3.storage:test', + aud: agent2.did(), + att: [{ can: '*' }], + }, + proofs: [ + await Access.access.delegate({ + issuer: agent1, + audience: agent2, + with: agent1.did(), + }), + ], + }) - it('should error on escalation', async function () { - const claim = Access.authorize.invoke({ - issuer: bob, - audience: service, - with: alice.did(), - nb: { - iss: 'did:mailto:web.mail:alice', - att: [{ can: '*' }], - }, - proofs: [ - await Access.authorize.delegate({ - issuer: alice, - audience: bob, - with: alice.did(), - nb: { - iss: 'did:mailto:web.mail:alice', - att: [{ can: 'store/*' }], - }, - }), - ], + const result = await access(await ucan.delegate(), { + capability: Access.confirm, + principal: Verifier, + authority: service, + }) + + if (result.error) { + assert.fail('should not error') + } else { + assert.deepEqual(result.audience.did(), service.did()) + assert.equal(result.capability.can, 'access/confirm') + assert.deepEqual(result.capability.nb, { + iss: 'did:mailto:web3.storage:test', + aud: agent2.did(), + att: [{ can: '*' }], + }) + } }) - const result = await access(await claim.delegate(), { - capability: Access.authorize, - principal: Verifier, - authority: service, + it('should delegate from * to access/confirm', async function () { + const agent1 = bob + const agent2 = mallory + const ucan = Access.confirm.invoke({ + issuer: agent2, + audience: service, + with: agent1.did(), + nb: { + iss: 'did:mailto:web3.storage:test', + aud: agent2.did(), + att: [{ can: '*' }], + }, + proofs: [ + await Access.top.delegate({ + issuer: agent1, + audience: agent2, + with: agent1.did(), + }), + ], + }) + + const result = await access(await ucan.delegate(), { + capability: Access.confirm, + principal: Verifier, + authority: service, + }) + + if (result.error) { + assert.fail('should not error') + } else { + assert.deepEqual(result.audience.did(), service.did()) + assert.equal(result.capability.can, 'access/confirm') + assert.deepEqual(result.capability.nb, { + iss: 'did:mailto:web3.storage:test', + aud: agent2.did(), + att: [{ can: '*' }], + }) + } }) - if (result.error) { - assert.ok(result.message.includes('unauthorized nb.att.can *')) - } else { - assert.fail('should error') - } - }) + it('should error when `iss` is different', async function () { + const agent1 = bob + const agent2 = mallory + const ucan = Access.confirm.invoke({ + issuer: agent2, + audience: service, + with: agent1.did(), + nb: { + iss: 'did:mailto:web3.storage:ANOTHER_TEST', + aud: agent2.did(), + att: [{ can: '*' }], + }, + proofs: [ + await Access.confirm.delegate({ + issuer: agent1, + audience: agent2, + with: agent1.did(), + nb: { + iss: 'did:mailto:web3.storage:test', + }, + }), + ], + }) - it('should error on principal misalignment', async function () { - const agent1 = bob - const agent2 = mallory - const claim = Access.authorize.invoke({ - issuer: agent2, - audience: service, - with: alice.did(), - nb: { - iss: 'did:mailto:web3.storage:test', - att: [{ can: '*' }], - }, - proofs: [ - await Access.top.delegate({ - issuer: agent1, - audience: agent2, - with: agent1.did(), - }), - ], + const result = await access(await ucan.delegate(), { + capability: Access.confirm, + principal: Verifier, + authority: service, + }) + + if (result.error) { + assert.ok(result.message.includes('- Can not derive')) + } else { + assert.fail('should error') + } }) - const result = await access(await claim.delegate(), { - capability: Access.authorize, - principal: Verifier, - authority: service, + it('should be able to derive from * scope', async function () { + const ucan = Access.confirm.invoke({ + issuer: bob, + audience: service, + with: alice.did(), + nb: { + iss: 'did:mailto:web.mail:alice', + aud: bob.did(), + att: [{ can: 'store/*' }], + }, + proofs: [ + await Access.confirm.delegate({ + issuer: alice, + audience: bob, + with: alice.did(), + nb: { + iss: 'did:mailto:web.mail:alice', + att: [{ can: '*' }], + }, + }), + ], + }) + + const result = await access(await ucan.delegate(), { + capability: Access.confirm, + principal: Verifier, + authority: service, + }) + + assert.equal(result.error, undefined, 'should be authorized') }) - if (result.error) { - assert.ok(result.message.includes('- Can not derive')) - } else { - assert.fail('should error') - } - }) + it('should be able to reduce scope', async function () { + const ucan = Access.confirm.invoke({ + issuer: bob, + audience: service, + with: alice.did(), + nb: { + iss: 'did:mailto:web.mail:alice', + aud: bob.did(), + att: [{ can: 'store/add' }], + }, + proofs: [ + await Access.confirm.delegate({ + issuer: alice, + audience: bob, + with: alice.did(), + nb: { + iss: 'did:mailto:web.mail:alice', + att: [{ can: 'store/add' }, { can: 'store/remove' }], + }, + }), + ], + }) - it('should fail validation if its not mailto', async function () { - assert.throws(() => { - Access.authorize.invoke({ + const result = await access(await ucan.delegate(), { + capability: Access.confirm, + principal: Verifier, + authority: service, + }) + + assert.equal(result.error, undefined, 'should be authorized') + }) + + it('should error on escalation', async function () { + const ucan = Access.confirm.invoke({ issuer: bob, audience: service, - with: bob.did(), + with: alice.did(), nb: { - // @ts-expect-error - iss: 'did:NOT_MAILTO:web3.storage:test', + iss: 'did:mailto:web.mail:alice', + aud: bob.did(), att: [{ can: '*' }], }, + proofs: [ + await Access.confirm.delegate({ + issuer: alice, + audience: bob, + with: alice.did(), + nb: { + iss: 'did:mailto:web.mail:alice', + att: [{ can: 'store/*' }], + }, + }), + ], + }) + + const result = await access(await ucan.delegate(), { + capability: Access.confirm, + principal: Verifier, + authority: service, }) - }, /Expected a did:mailto: but got "did:NOT_MAILTO:web3.storage:test" instead/) + + if (result.error) { + assert.ok(result.message.includes('unauthorized nb.att.can *')) + } else { + assert.fail('should error') + } + }) + + it('should error on principal misalignment', async function () { + const agent1 = bob + const agent2 = mallory + const ucan = Access.confirm.invoke({ + issuer: agent2, + audience: service, + with: alice.did(), + nb: { + iss: 'did:mailto:web3.storage:test', + aud: agent2.did(), + att: [{ can: '*' }], + }, + proofs: [ + await Access.top.delegate({ + issuer: agent1, + audience: agent2, + with: agent1.did(), + }), + ], + }) + + const result = await access(await ucan.delegate(), { + capability: Access.confirm, + principal: Verifier, + authority: service, + }) + + if (result.error) { + assert.ok(result.message.includes('- Can not derive')) + } else { + assert.fail('should error') + } + }) + + it('should fail validation if its not mailto', async function () { + assert.throws(() => { + Access.confirm.invoke({ + issuer: bob, + audience: service, + with: bob.did(), + nb: { + // @ts-expect-error + iss: 'did:NOT_MAILTO:web3.storage:test', + aud: bob.did(), + att: [{ can: '*' }], + }, + }) + }, /Expected a did:mailto: but got "did:NOT_MAILTO:web3.storage:test" instead/) + }) }) describe('access/claim', () => {