From 7834cf271a856c594f1dd4a02854de39ac4f76e6 Mon Sep 17 00:00:00 2001 From: Benjamin Goering <171782+gobengo@users.noreply.github.com> Date: Tue, 7 Feb 2023 14:25:36 -0800 Subject: [PATCH] feat: add access/delegate capability parser exported from @web3-storage/capabilities (#420) Motivation: * https://github.com/web3-storage/w3protocol/issues/414 --------- Co-authored-by: Irakli Gozalishvili --- packages/capabilities/package.json | 9 +- packages/capabilities/src/access.js | 99 +++++- .../test/capabilities/access.test.js | 281 +++++++++++++++++- 3 files changed, 386 insertions(+), 3 deletions(-) diff --git a/packages/capabilities/package.json b/packages/capabilities/package.json index da57c07df..57966888e 100644 --- a/packages/capabilities/package.json +++ b/packages/capabilities/package.json @@ -89,7 +89,14 @@ "unicorn/prefer-number-properties": "off", "unicorn/prefer-export-from": "off", "unicorn/no-array-reduce": "off", - "jsdoc/no-undefined-types": "error" + "jsdoc/no-undefined-types": [ + "error", + { + "definedTypes": [ + "Iterable" + ] + } + ] }, "env": { "mocha": true diff --git a/packages/capabilities/src/access.js b/packages/capabilities/src/access.js index dc18d3dab..92ad5064b 100644 --- a/packages/capabilities/src/access.js +++ b/packages/capabilities/src/access.js @@ -8,7 +8,7 @@ * * @module */ -import { capability, URI, DID } from '@ucanto/validator' +import { capability, URI, DID, Schema, Failure } from '@ucanto/validator' // @ts-ignore // eslint-disable-next-line no-unused-vars import * as Types from '@ucanto/interface' @@ -108,3 +108,100 @@ export const claim = base.derive({ }), derives: equalWith, }) + +// https://github.com/web3-storage/specs/blob/main/w3-access.md#accessdelegate +export const delegate = base.derive({ + to: capability({ + can: 'access/delegate', + /** + * Field MUST be a space DID with a storage provider. Delegation will be stored just like any other DAG stored using store/add capability. + * + * @see https://github.com/web3-storage/specs/blob/main/w3-access.md#delegate-with + */ + with: DID.match({ method: 'key' }), + nb: { + // keys SHOULD be CIDs, but we won't require it in the schema + /** + * @type {Schema.Schema} + */ + delegations: Schema.dictionary({ + value: Schema.Link.match(), + }), + }, + derives: (claim, proof) => { + return ( + fail(equalWith(claim, proof)) || + fail(subsetsNbDelegations(claim, proof)) || + true + ) + }, + }), + derives: (claim, proof) => { + // no need to check claim.nb.delegations is subset of proof + // because the proofs types here never include constraints on the nb.delegations set + return fail(equalWith(claim, proof)) || true + }, +}) + +/** + * @typedef {Schema.Dictionary>} AccessDelegateDelegations + */ + +/** + * Parsed Capability for access/delegate + * + * @typedef {object} ParsedAccessDelegate + * @property {string} can + * @property {object} nb + * @property {AccessDelegateDelegations} [nb.delegations] + */ + +/** + * returns whether the claimed ucan is proves by the proof ucan. + * both are access/delegate, or at least have same semantics for `nb.delegations`, which is a set of delegations. + * checks that the claimed delegation set is equal to or less than the proven delegation set. + * usable with {import('@ucanto/interface').Derives}. + * + * @param {ParsedAccessDelegate} claim + * @param {ParsedAccessDelegate} proof + */ +function subsetsNbDelegations(claim, proof) { + const missingProofs = setDifference( + delegatedCids(claim), + new Set(delegatedCids(proof)) + ) + if (missingProofs.size > 0) { + return new Failure( + `unauthorized nb.delegations ${[...missingProofs].join(', ')}` + ) + } + return true +} + +/** + * iterate delegated UCAN CIDs from an access/delegate capability.nb.delegations value. + * + * @param {ParsedAccessDelegate} delegate + * @returns {Iterable} + */ +function* delegatedCids(delegate) { + for (const d of Object.values(delegate.nb.delegations || {})) { + yield d.toString() + } +} + +/** + * @template S + * @param {Iterable} minuend - set to subtract from + * @param {Set} subtrahend - subtracted from minuend + */ +function setDifference(minuend, subtrahend) { + /** @type {Set} */ + const difference = new Set() + for (const e of minuend) { + if (!subtrahend.has(e)) { + difference.add(e) + } + } + return difference +} diff --git a/packages/capabilities/test/capabilities/access.test.js b/packages/capabilities/test/capabilities/access.test.js index c12f1e2c4..7b9b1c5bc 100644 --- a/packages/capabilities/test/capabilities/access.test.js +++ b/packages/capabilities/test/capabilities/access.test.js @@ -4,7 +4,7 @@ import { Verifier } from '@ucanto/principal/ed25519' import * as Access from '../../src/access.js' import { alice, bob, service, mallory } from '../helpers/fixtures.js' import * as Ucanto from '@ucanto/interface' -import { delegate, invoke } from '@ucanto/core' +import { delegate, invoke, parseLink } from '@ucanto/core' describe('access capabilities', function () { it('should self issue', async function () { @@ -371,3 +371,282 @@ describe('access capabilities', function () { }) }) }) + +describe('access/delegate', () => { + it('authorizes self issued invocation', async () => { + const invocation = await Access.delegate + .invoke({ + issuer: alice, + audience: service, + with: alice.did(), + nb: { + delegations: {}, + }, + }) + .delegate() + const accessResult = await access(invocation, { + capability: Access.delegate, + principal: Verifier, + authority: service, + }) + assert.ok( + accessResult.error !== true, + 'result of access(invocation) is not an error' + ) + }) + + /** + * Assert can parse various valid ways of expressing '.nb.delegations` delegations as a dict. + * The property names SHOULD be CIDs of the value links, but this invariant is not enforced. + */ + for (const [variantName, { entry }] of Object.entries( + nbDelegationsEntryVariants( + delegate({ + issuer: alice, + audience: bob, + capabilities: [{ can: '*', with: alice.did() }], + }) + ) + )) { + it(`authorizes .nb.delegations dict key variant ${variantName}`, async () => { + const invocation = await Access.delegate + .invoke({ + issuer: alice, + audience: service, + with: alice.did(), + nb: { + delegations: Object.fromEntries([await entry]), + }, + }) + .delegate() + const accessResult = await access(invocation, { + capability: Access.delegate, + principal: Verifier, + authority: service, + }) + assert.ok( + accessResult.error !== true, + 'result of access(invocation) is not an error' + ) + }) + } + + /** + * Assert can derive access/delegate from these UCAN proof.can + */ + const expectCanDeriveDelegateFromCans = /** @type {const} */ ([ + '*', + 'access/*', + 'access/delegate', + ]) + for (const deriveFromCan of expectCanDeriveDelegateFromCans) { + it(`derives from can=${deriveFromCan} and matching cap.with`, async () => { + const invocation = await Access.delegate + .invoke({ + issuer: bob, + audience: service, + with: alice.did(), + nb: { + delegations: {}, + }, + proofs: [ + await delegate({ + issuer: alice, + audience: bob, + capabilities: [ + { + can: deriveFromCan, + with: alice.did(), + }, + ], + }), + ], + }) + .delegate() + const accessResult = await access(invocation, { + capability: Access.delegate, + principal: Verifier, + authority: service, + }) + assert.ok( + accessResult.error !== true, + 'result of access(invocation) is not an error' + ) + }) + } + + it('cannot delegate a superset of nb.delegations', async () => { + const audience = service + /** @param {string} methodName */ + const createTestDelegation = (methodName) => + delegate({ + issuer: alice, + audience: bob, + capabilities: [{ can: `test/${methodName}`, with: alice.did() }], + }) + /** @param {number} length */ + const createTestDelegations = (length) => + Promise.all( + Array.from({ length }).map((_, i) => createTestDelegation(i.toString())) + ) + const allDelegations = await createTestDelegations(2) + const [firstDelegation, ...someDelegations] = allDelegations + const bobCanDelegateSomeWithAlice = await Access.delegate.delegate({ + issuer: alice, + audience: bob, + with: alice.did(), + nb: { + // note: only 'some' + delegations: toDelegationsDict(someDelegations), + }, + }) + const invocation = await Access.delegate + .invoke({ + issuer: bob, + audience, + with: alice.did(), + nb: { + // note: 'all' (more than 'some') - this isn't allowed by the proof + delegations: toDelegationsDict(allDelegations), + }, + proofs: [bobCanDelegateSomeWithAlice], + }) + .delegate() + const result = await access(invocation, { + capability: Access.delegate, + principal: Verifier, + authority: audience, + }) + assert.ok(result.error === true, 'result of access(invocation) is an error') + assert.deepEqual(result.failedProofs.length, 1) + assert.ok( + result.message.match(`unauthorized nb.delegations ${firstDelegation.cid}`) + ) + }) + + it('cannot invoke if proof.with does not match', async () => { + const invocation = await Access.delegate + .invoke({ + issuer: bob, + audience: service, + // with mallory, but proof is with alice + with: mallory.did(), + nb: { + delegations: {}, + }, + proofs: [ + await Access.delegate.delegate({ + issuer: alice, + audience: bob, + // with alice, but invocation is with mallory + with: alice.did(), + }), + ], + }) + .delegate() + const result = await access(invocation, { + capability: Access.delegate, + principal: Verifier, + authority: service, + }) + assert.ok(result.error, 'result is error') + assert.ok( + result.message.includes( + `Can not derive access/delegate with ${mallory.did()} from ${alice.did()}` + ) + ) + }) + + it('does not parse malformed delegations', async () => { + const invalidAccessDelegateCapabilities = /** @type {const} */ ([ + { + can: 'access/delegate', + with: alice.did(), + // schema requires nb.delegations + }, + { + can: 'access/delegate', + with: alice.did(), + // schema requires nb.delegations + nb: {}, + }, + { + can: 'access/delegate', + with: alice.did(), + nb: { + delegations: { + // schema requires value to be a Link, not number + foo: 1, + }, + }, + }, + { + can: 'access/delegate', + // with must be a did:key + with: 'https://dag.house', + }, + { + can: 'access/delegate', + // with must be a did:key + with: 'did:web:dag.house', + }, + ]) + for (const cap of invalidAccessDelegateCapabilities) { + const accessResult = await access( + // @ts-ignore - tsc doesn't like the invalid capability types, + // but we want to ensure there is a runtime error too + await delegate({ + issuer: alice, + audience: bob, + capabilities: [cap], + }), + { + capability: Access.delegate, + principal: Verifier, + authority: service, + } + ) + assert.ok(accessResult.error, 'accessResult is error') + assert.ok( + accessResult.message.includes( + `Encountered malformed 'access/delegate' capability` + ) + ) + } + }) +}) + +/** + * Given array of delegations, return a valid value for access/delegate nb.delegations + * + * @param {Array} delegations + */ +function toDelegationsDict(delegations) { + return Object.fromEntries(delegations.map((d) => [d.cid.toString(), d.cid])) +} + +/** + * Create named variants of ways that .nb.delegations dict could represent a delegation entry in its dict + * + * @template {Ucanto.Capabilities} Caps + * @param {Promise>} delegation + */ +function nbDelegationsEntryVariants(delegation) { + return { + 'correct cid': { + entry: delegation.then((delegation) => [ + delegation.cid.toString(), + delegation.cid, + ]), + }, + 'property not a cid': { + entry: delegation.then((delegation) => ['thisIsNotACid', delegation.cid]), + }, + 'property name is a cid but does not correspond to value': { + entry: delegation.then((delegation) => [ + parseLink('bafkqaaa').toString(), + delegation.cid, + ]), + }, + } +}