diff --git a/packages/capabilities/package.json b/packages/capabilities/package.json index b4367c821..6835db1f0 100644 --- a/packages/capabilities/package.json +++ b/packages/capabilities/package.json @@ -23,8 +23,8 @@ }, "exports": { ".": "./src/index.js", - "./types": "./dist/src/types.d.ts", - "./*": "./src/*.js" + "./*": "./src/*.js", + "./types": "./dist/src/types.d.ts" }, "typesVersions": { "*": { @@ -34,20 +34,23 @@ "store": [ "dist/src/store" ], - "types": [ - "dist/src/types" - ], "top": [ "dist/src/top" ], "upload": [ "dist/src/upload" ], + "voucher": [ + "dist/src/voucher" + ], + "access": [ + "dist/src/access" + ], "utils": [ "dist/src/utils" ], - "voucher": [ - "dist/src/voucher" + "types": [ + "dist/src/types" ] } }, diff --git a/packages/capabilities/src/access.js b/packages/capabilities/src/access.js new file mode 100644 index 000000000..7452ff862 --- /dev/null +++ b/packages/capabilities/src/access.js @@ -0,0 +1,102 @@ +/** + * Access Capabilities + * + * These can be imported directly with: + * ```js + * import * as Access from '@web3-storage/capabilities/access' + * ``` + * + * @module + */ +import { capability, URI, DID } from '@ucanto/validator' +// @ts-ignore +// eslint-disable-next-line no-unused-vars +import * as Types from '@ucanto/interface' +import { equalWith, fail, equal } from './utils.js' +import { top } from './top.js' + +export { top } + +/** + * Account identifier. + */ +export const As = DID.match({ method: 'mailto' }) + +/** + * Capability can only be delegated (but not invoked) allowing audience to + * derived any `access/` prefixed capability for the agent identified + * by did:key in the `with` field. + */ +export const access = top.derive({ + to: capability({ + can: 'access/*', + with: URI.match({ protocol: 'did:' }), + derives: equalWith, + }), + derives: equalWith, +}) + +const base = top.or(access) + +/** + * Capability can be invoked by an agent to request a `./update` for an account. + * + * `with` field identifies requesting agent, which MAY be different from iss field identifying issuing agent. + */ +export const authorize = base.derive({ + to: capability({ + can: 'access/authorize', + with: URI.match({ protocol: 'did:' }), + nb: { + /** + * Value MUST be a did:mailto identifier of the account + * that the agent wishes to represent via did:key in the `with` field. + * It MUST be a valid did:mailto identifier. + */ + as: As, + }, + derives: (child, parent) => { + return ( + fail(equalWith(child, parent)) || + fail(equal(child.nb.as, parent.nb.as, 'as')) || + true + ) + }, + }), + /** + * `access/authorize` can be derived from the `access/*` & `*` capability + * as long as the `with` fields match. + */ + derives: equalWith, +}) + +/** + * 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. + * + * @see https://github.com/web3-storage/specs/blob/main/w3-account.md#update + * + * @example + * ```js + * { + iss: "did:web:web3.storage", + aud: "did:mailto:alice@web.mail", + att: [{ + with: "did:web:web3.storage", + can: "./update", + nb: { key: "did:key:zAgent" } + }], + exp: null + sig: "..." + } + * ``` + */ +export const session = capability({ + can: './update', + // Should be web3.storage DID + with: URI.match({ protocol: 'did:' }), + nb: { + // Agent DID so it can sign UCANs as did:mailto if it matches this delegation `aud` + key: DID.match({ method: 'key' }), + }, +}) diff --git a/packages/capabilities/src/index.js b/packages/capabilities/src/index.js index 9b846cb2d..3603ed34b 100644 --- a/packages/capabilities/src/index.js +++ b/packages/capabilities/src/index.js @@ -3,6 +3,7 @@ import * as Top from './top.js' import * as Store from './store.js' import * as Upload from './upload.js' import * as Voucher from './voucher.js' +import * as Access from './access.js' import * as Utils from './utils.js' export { Space, Top, Store, Upload, Voucher, Utils } @@ -24,4 +25,7 @@ export const abilitiesAsStrings = [ Store.list.can, Voucher.claim.can, Voucher.redeem.can, + Access.access.can, + Access.authorize.can, + Access.session.can, ] diff --git a/packages/capabilities/src/types.ts b/packages/capabilities/src/types.ts index 01b22ce83..933ee2246 100644 --- a/packages/capabilities/src/types.ts +++ b/packages/capabilities/src/types.ts @@ -5,6 +5,14 @@ import { top } from './top.js' import { add, list, remove, store } from './store.js' import * as UploadCaps from './upload.js' import { claim, redeem } from './voucher.js' +import * as AccessCaps from './access.js' + +// Access +export type Access = InferInvokedCapability +export type AccessAuthorize = InferInvokedCapability< + typeof AccessCaps.authorize +> +export type AccessSession = InferInvokedCapability // Space export type Space = InferInvokedCapability @@ -47,5 +55,8 @@ export type AbilitiesArray = [ StoreRemove['can'], StoreList['can'], VoucherClaim['can'], - VoucherRedeem['can'] + VoucherRedeem['can'], + Access['can'], + AccessAuthorize['can'], + AccessSession['can'] ] diff --git a/packages/capabilities/test/capabilities/access.test.js b/packages/capabilities/test/capabilities/access.test.js new file mode 100644 index 000000000..383227337 --- /dev/null +++ b/packages/capabilities/test/capabilities/access.test.js @@ -0,0 +1,225 @@ +import assert from 'assert' +import { access } from '@ucanto/validator' +import { Verifier } from '@ucanto/principal/ed25519' +import * as Access from '../../src/access.js' +import { alice, bob, service, mallory } from '../helpers/fixtures.js' + +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: { + as: 'did:mailto:web3.storage:test', + }, + }) + + 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, { + as: '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: { + as: 'did:mailto:web3.storage:test', + }, + proofs: [ + await Access.authorize.delegate({ + issuer: agent1, + audience: agent2, + with: agent1.did(), + nb: { + as: 'did:mailto:web3.storage:test', + }, + }), + ], + }) + + 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, { + as: '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: { + as: 'did:mailto:web3.storage:test', + }, + 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, + }) + + 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, { + as: '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: { + as: 'did:mailto:web3.storage:test', + }, + 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, { + as: 'did:mailto:web3.storage:test', + }) + } + }) + + it('should error auth to auth when caveats are different', async function () { + const agent1 = bob + const agent2 = mallory + const claim = Access.authorize.invoke({ + issuer: agent2, + audience: service, + with: agent1.did(), + nb: { + as: 'did:mailto:web3.storage:ANOTHER_TEST', + }, + proofs: [ + await Access.authorize.delegate({ + issuer: agent1, + audience: agent2, + with: agent1.did(), + nb: { + as: 'did:mailto:web3.storage:test', + }, + }), + ], + }) + + 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 error if with dont match', async function () { + const agent1 = bob + const agent2 = mallory + const claim = Access.authorize.invoke({ + issuer: agent2, + audience: service, + with: alice.did(), + nb: { + as: 'did:mailto:web3.storage:test', + }, + 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') + } + }) + + it('should fail validation if its not mailto', async function () { + assert.throws(() => { + Access.authorize.invoke({ + issuer: bob, + audience: service, + with: bob.did(), + nb: { + // @ts-expect-error + as: 'did:NOT_MAILTO:web3.storage:test', + }, + }) + }, /Expected a did:mailto: but got "did:NOT_MAILTO:web3.storage:test" instead/) + }) +})