From 545d9c25d9bd8000eaff4978432bc1bbba14654e Mon Sep 17 00:00:00 2001 From: Irakli Gozalishvili Date: Wed, 8 Feb 2023 15:32:11 -0800 Subject: [PATCH] feat!: wildcard support in capabilies (#218) * stash * feat!: support for wildcard cap like store/* fixes: #87 * chore: delete file added by mistake * chore: revert unecessary changes * fix: typos Co-authored-by: Benjamin Goering <171782+gobengo@users.noreply.github.com> * chore: add some doc comments * fix: use ucan:* to imply everything --------- Co-authored-by: Benjamin Goering <171782+gobengo@users.noreply.github.com> --- packages/validator/src/capability.js | 164 +++++- .../validator/test/capability-access.spec.js | 556 ++++++++++++++++++ packages/validator/test/lib.spec.js | 12 +- 3 files changed, 713 insertions(+), 19 deletions(-) create mode 100644 packages/validator/test/capability-access.spec.js diff --git a/packages/validator/src/capability.js b/packages/validator/src/capability.js index 68154db2..b32009f6 100644 --- a/packages/validator/src/capability.js +++ b/packages/validator/src/capability.js @@ -221,7 +221,7 @@ class Capability extends Unit { * @returns {API.MatchResult>>>} */ match(source) { - const result = parse(this, source) + const result = parseCapability(this, source) return result.error ? result : new Match(source, result, this.descriptor) } toString() { @@ -432,7 +432,7 @@ class Match { const errors = [] const matches = [] for (const capability of capabilities) { - const result = parse(this, capability, true) + const result = resolveCapability(this, this.value, capability) if (!result.error) { const claim = this.descriptor.derives(this.value, result) if (claim.error) { @@ -635,29 +635,86 @@ class AndMatch { } /** - * Parses capability `source` using a provided capability `parser`. By default - * invocation parsing occurs, which respects a capability schema, failing if - * any non-optional field is missing. If `optional` argument is `true` it will - * parse capability as delegation, in this case all `nb` fields are considered - * optional. + * Resolves ability `pattern` of the delegated capability from the ability + * of the claimed capability. If pattern matches returns claimed ability + * otherwise returns given `fallback`. + * + * @example + * ```js + * resolveAbility('*', 'store/add', null) // => 'store/add' + * resolveAbility('store/*', 'store/add', null) // => 'store/add' + * resolveAbility('store/add', 'store/add', null) // => 'store/add' + * resolveAbility('store/', 'store/add', null) // => null + * resolveAbility('store/a*', 'store/add', null) // => null + * resolveAbility('store/list', 'store/add', null) // => null + * ``` + * + * @template {API.Ability} T + * @template U + * @param {string} pattern + * @param {T} can + * @param {U} fallback + * @returns {T|U} + */ +const resolveAbility = (pattern, can, fallback) => { + switch (pattern) { + case can: + case '*': + return can + default: + return pattern.endsWith('/*') && can.startsWith(pattern.slice(0, -1)) + ? can + : fallback + } +} + +/** + * Resolves `source` resource of the delegated capability from the resource + * `uri` of the claimed capability. If `source` is `"ucan:*""` or matches `uri` + * then it returns `uri` back otherwise it returns `fallback`. + * + * @example + * ```js + * resolveResource('ucan:*', 'did:key:zAlice', null) // => 'did:key:zAlice' + * resolveAbility('ucan:*', 'https://example.com', null) // => 'https://example.com' + * resolveAbility('did:*', 'did:key:zAlice', null) // => null + * resolveAbility('did:key:zAlice', 'did:key:zAlice', null) // => did:key:zAlice + * ``` + * @template {string} T + * @template U + * @param {T} uri + * @param {string} source + * @param {U} fallback + * @returns {T|U} + */ +const resolveResource = (source, uri, fallback) => { + switch (source) { + case uri: + case 'ucan:*': + return uri + default: + return fallback + } +} + +/** + * Parses capability from the `source` using a provided `parser`. * * @template {API.Ability} A * @template {API.URI} R * @template {API.Caveats} C * @param {{descriptor: API.Descriptor}} parser * @param {API.Source} source - * @param {boolean} [optional=false] * @returns {API.Result>, API.InvalidCapability>} */ - -const parse = (parser, source, optional = false) => { +const parseCapability = (parser, source) => { const { can, with: withReader, nb: readers } = parser.descriptor const { delegation } = source const capability = /** @type {API.Capability>} */ ( source.capability ) - if (capability.can !== can) { + if (can !== capability.can) { return new UnknownCapability(capability) } @@ -666,25 +723,104 @@ const parse = (parser, source, optional = false) => { return new MalformedCapability(capability, uri) } - const nb = /** @type {API.InferCaveats} */ ({}) + const nb = parseNB(capability, readers) + if (nb.error) { + return nb + } + + return new CapabilityView( + can, + uri, + /** @type {API.InferCaveats} */ (nb), + delegation + ) +} +/** + * Resolves delegated capability `source` from the `claimed` capability using + * provided capability `parser`. It is similar to `parseCapability` except + * `source` here is treated as capability pattern which is matched against the + * `claimed` capability. This means we resolve `can` and `with` fields from the + * `claimed` capability and inherit all missing `nb` fields from the claimed + * capability. + * + * @template {API.Ability} A + * @template {API.URI} R + * @template {API.Caveats} C + * @param {{descriptor: API.Descriptor}} parser + * @param {API.ParsedCapability>} claimed + * @param {API.Source} source + * @returns {API.Result>, API.InvalidCapability>} + */ + +const resolveCapability = ( + { descriptor: schema }, + claimed, + { capability, delegation } +) => { + const can = resolveAbility(capability.can, claimed.can, null) + if (can == null) { + return new UnknownCapability(capability) + } + + const resource = resolveResource( + capability.with, + claimed.with, + capability.with + ) + const uri = schema.with.read(resource) + if (uri.error) { + return new MalformedCapability(capability, uri) + } + + const nb = parseNB(capability, schema.nb, { ...claimed.nb }) + if (nb.error) { + return nb + } + + return new CapabilityView( + can, + uri, + /** @type {API.InferCaveats} */ (nb), + delegation + ) +} + +/** + * Parses `nb` field of the provided `capability` with given set of `readers`. + * If `implicit` argument is provided it will treat all fields as optional and + * fall back to an implicit field. If `implicit` is not provided it will fail + * if any non-optional field is missing. + * + * @template {API.Ability} A + * @template {API.URI} R + * @template {API.Caveats} C + * @param {API.Capability} capability + * @param {C|undefined} readers + * @param {Partial>} [implicit] + * @returns {API.Result, API.MalformedCapability>} + */ +const parseNB = (capability, readers, implicit) => { + const nb = /** @type {API.InferCaveats} */ ({}) if (readers) { /** @type {Partial>} */ const caveats = capability.nb || {} for (const [name, reader] of entries(readers)) { const key = /** @type {keyof caveats & keyof nb & string} */ (name) - if (key in caveats || !optional) { + if (key in caveats || !implicit) { const result = reader.read(caveats[key]) if (result?.error) { return new MalformedCapability(capability, result) } else if (result != null) { nb[key] = /** @type {any} */ (result) } + } else if (key in implicit) { + nb[key] = /** @type {nb[key]} */ (implicit[key]) } } } - return new CapabilityView(can, capability.with, nb, delegation) + return nb } /** diff --git a/packages/validator/test/capability-access.spec.js b/packages/validator/test/capability-access.spec.js new file mode 100644 index 00000000..7e324c77 --- /dev/null +++ b/packages/validator/test/capability-access.spec.js @@ -0,0 +1,556 @@ +import { test, assert } from './test.js' +import { access, claim, DID } from '../src/lib.js' +import { capability, URI, Link, Schema } from '../src/lib.js' +import { Failure } from '../src/error.js' +import { ed25519, Verifier } from '@ucanto/principal' +import * as Client from '@ucanto/client' +import * as Core from '@ucanto/core' + +import { alice, bob, mallory, service } from './fixtures.js' +const w3 = service.withDID('did:web:web3.storage') + +const capabilities = { + store: { + add: capability({ + can: 'store/add', + with: DID, + nb: { + link: Link, + size: Schema.integer().optional(), + }, + derives: (claim, proof) => { + if (claim.with !== proof.with) { + return new Failure('with field does not match') + } else if (proof.nb.size != null) { + if ((claim.nb.size || Infinity) > proof.nb.size) { + return new Failure('Escalates size constraint') + } + } + return true + }, + }), + list: capability({ + can: 'store/list', + with: DID, + }), + }, + dev: { + ping: capability({ + can: 'dev/ping', + with: DID, + nb: { + message: Schema.string(), + }, + }), + }, +} + +test('validates with patterns', async () => { + const proof = await Core.delegate({ + issuer: alice, + audience: bob, + capabilities: [ + { + with: 'ucan:*', + can: '*', + }, + ], + }) + + const ping = capabilities.dev.ping.invoke({ + issuer: bob, + audience: w3, + with: alice.did(), + nb: { + message: 'hello', + }, + proofs: [proof], + }) + + const result = await access(await ping.delegate(), { + authority: w3, + capability: capabilities.dev.ping, + principal: Verifier, + }) + + assert.equal(result.error, undefined) +}) + +test('validates with patterns in chain', async () => { + const top = await Core.delegate({ + issuer: alice, + audience: bob, + capabilities: [ + { + with: alice.did(), + can: 'store/add', + }, + ], + }) + + const proof = await Core.delegate({ + issuer: bob, + audience: mallory, + capabilities: [ + { + with: 'ucan:*', + can: '*', + }, + ], + proofs: [top], + }) + + const r1 = await access( + await Client.delegate({ + issuer: mallory, + audience: w3, + capabilities: [ + { + with: alice.did(), + can: 'store/list', + nb: { + link: Link.parse('bafkqaaa'), + }, + }, + ], + proofs: [proof], + }), + { + authority: w3, + capability: capabilities.store.add, + principal: Verifier, + } + ) + + assert.match(r1.toString(), /Encountered unknown capabilities/) + + const r2 = await access( + await Client.delegate({ + issuer: mallory, + audience: w3, + capabilities: [ + { + with: alice.did(), + can: 'store/add', + nb: { + link: Link.parse('bafkqaaa'), + }, + }, + ], + proofs: [proof], + }), + { + authority: w3, + capability: capabilities.store.add, + principal: Verifier, + } + ) + + assert.equal(r2.error, undefined) +}) + +test('invalid proof chain', async () => { + const top = await Core.delegate({ + issuer: alice, + audience: bob, + capabilities: [ + { + with: alice.did(), + can: 'store/add', + }, + ], + }) + + const proof = await Core.delegate({ + issuer: bob, + audience: mallory, + capabilities: [ + { + with: 'ucan:*', + can: '*', + nb: { + link: '*', + }, + }, + ], + proofs: [top], + }) + + const result = await access( + await Client.delegate({ + issuer: mallory, + audience: w3, + capabilities: [ + { + with: alice.did(), + can: 'store/add', + nb: { + link: Link.parse('bafkqaaa'), + }, + }, + ], + proofs: [proof], + }), + { + authority: w3, + capability: capabilities.store.add, + principal: Verifier, + } + ) + + assert.match(result.toString(), /Expected link to be a CID instead of \*/) +}) + +test('restrictions in chain are respected', async () => { + const jordan = await ed25519.generate() + const top = await Core.delegate({ + issuer: alice, + audience: bob, + capabilities: [ + { + with: 'ucan:*', + can: '*', + }, + ], + }) + + const middle = await Core.delegate({ + issuer: bob, + audience: mallory, + capabilities: [ + { + with: 'ucan:*', + can: 'dev/*', + }, + ], + proofs: [top], + }) + + const proof = await Core.delegate({ + issuer: mallory, + audience: jordan, + capabilities: [ + { + with: 'ucan:*', + can: '*', + }, + ], + proofs: [middle], + }) + + const boom = await access( + await Client.delegate({ + issuer: jordan, + audience: w3, + capabilities: [ + { + with: alice.did(), + can: 'store/add', + nb: { + link: Link.parse('bafkqaaa'), + }, + }, + ], + proofs: [proof], + }), + { + authority: w3, + // @ts-expect-error - tries to unify incompatible capabilities + capability: capabilities.store.add.or(capabilities.dev.ping), + principal: Verifier, + } + ) + + assert.match( + boom.toString(), + /Unauthorized/, + 'should only allow dev/* capabilities' + ) + + const ping = capabilities.dev.ping.invoke({ + issuer: jordan, + audience: w3, + with: alice.did(), + nb: { + message: 'hello', + }, + proofs: [proof], + }) + + const result = await access(await ping.delegate(), { + authority: w3, + // @ts-expect-error - tries to unify incompatible capabilities + capability: capabilities.store.add.or(capabilities.dev.ping), + principal: Verifier, + }) + + assert.equal(result.error, undefined, 'should allow dev/* capabilities') +}) + +test('unknown caveats do not apply', async () => { + const proof = await Core.delegate({ + issuer: alice, + audience: bob, + capabilities: [ + { + with: 'ucan:*', + can: '*', + nb: { + message: 'hello', + }, + }, + ], + }) + + const badPing = capabilities.dev.ping.invoke({ + issuer: bob, + audience: w3, + with: alice.did(), + nb: { + message: 'hello world', + }, + proofs: [proof], + }) + + const boom = await access(await badPing.delegate(), { + authority: w3, + capability: capabilities.dev.ping, + principal: Verifier, + }) + + assert.match( + boom.toString(), + /Constraint violation: message/, + 'message caveat applies' + ) + + const add = capabilities.store.add.invoke({ + issuer: bob, + audience: w3, + with: alice.did(), + nb: { + link: Link.parse('bafkqaaa'), + }, + proofs: [proof], + }) + + const result = await access(await add.delegate(), { + authority: w3, + capability: capabilities.store.add, + principal: Verifier, + }) + + assert.equal(result.error, undefined, 'message caveat does not apply') +}) + +test('with pattern requires delimiter', async () => { + const proof = await Core.delegate({ + issuer: alice, + audience: bob, + capabilities: [ + { + with: 'did:key:z6*', + can: '*', + }, + ], + }) + + const ping = capabilities.dev.ping.invoke({ + issuer: bob, + audience: w3, + with: alice.did(), + nb: { + message: 'hello', + }, + proofs: [proof], + }) + + const result = await access(await ping.delegate(), { + authority: w3, + capability: capabilities.dev.ping, + principal: Verifier, + }) + + assert.match(result.toString(), /capability not found/) +}) + +test('can pattern requires delimiter', async () => { + const proof = await Core.delegate({ + issuer: alice, + audience: bob, + capabilities: [ + { + with: 'ucan:*', + can: 'dev/p*', + }, + ], + }) + + const ping = capabilities.dev.ping.invoke({ + issuer: bob, + audience: w3, + with: alice.did(), + nb: { + message: 'hello', + }, + proofs: [proof], + }) + + const result = await access(await ping.delegate(), { + authority: w3, + capability: capabilities.dev.ping, + principal: Verifier, + }) + + assert.match( + result.toString(), + /capability not found/, + 'can without delimiter is not allowed' + ) +}) + +test('patterns do not escalate', async () => { + const top = await capabilities.store.add.delegate({ + issuer: alice, + audience: bob, + with: alice.did(), + nb: { + size: 200, + }, + }) + + const proof = await Client.delegate({ + issuer: bob, + audience: mallory, + capabilities: [ + { + with: alice.did(), + can: 'store/*', + }, + ], + proofs: [top], + }) + + const escalate = capabilities.store.add.invoke({ + issuer: mallory, + audience: service, + with: alice.did(), + nb: { + link: Link.parse('bafkqaaa'), + size: 500, + }, + proofs: [proof], + }) + + const error = await access(await escalate.delegate(), { + authority: w3, + capability: capabilities.store.add, + principal: Verifier, + }) + + assert.match(error.toString(), /Escalates size constraint/) + + const implicitEscalate = capabilities.store.add.invoke({ + issuer: mallory, + audience: service, + with: alice.did(), + nb: { + link: Link.parse('bafkqaaa'), + }, + proofs: [proof], + }) + const stillError = await access(await implicitEscalate.delegate(), { + authority: w3, + capability: capabilities.store.add, + principal: Verifier, + }) + + assert.match(stillError.toString(), /Escalates size constraint/) + + const add = capabilities.store.add.invoke({ + issuer: mallory, + audience: service, + with: alice.did(), + nb: { + link: Link.parse('bafkqaaa'), + size: 100, + }, + proofs: [proof], + }) + + const ok = await access(await add.delegate(), { + authority: w3, + capability: capabilities.store.add, + principal: Verifier, + }) + + assert.equal(ok.error, undefined) +}) + +test('without nb', async () => { + const proof = await Client.delegate({ + issuer: alice, + audience: bob, + capabilities: [ + { + can: 'store/*', + with: alice.did(), + nb: { + size: 200, + }, + }, + ], + }) + + const add = capabilities.store.add.invoke({ + issuer: bob, + audience: service, + with: alice.did(), + nb: { + link: Link.parse('bafkqaaa'), + size: 100, + }, + proofs: [proof], + }) + + const addOk = await access(await add.delegate(), { + authority: w3, + capability: capabilities.store.add, + principal: Verifier, + }) + + assert.equal(addOk.error, undefined) + + const addEscalate = capabilities.store.add.invoke({ + issuer: bob, + audience: service, + with: alice.did(), + nb: { + link: Link.parse('bafkqaaa'), + size: 201, + }, + proofs: [proof], + }) + + const addEscalateError = await access(await addEscalate.delegate(), { + authority: w3, + capability: capabilities.store.add, + principal: Verifier, + }) + assert.match(addEscalateError.toString(), /Escalates size constraint/) + + const list = capabilities.store.list.invoke({ + issuer: bob, + audience: service, + with: alice.did(), + proofs: [proof], + }) + + const listOk = await access(await list.delegate(), { + authority: w3, + capability: capabilities.store.list, + principal: Verifier, + }) + + assert.equal(listOk.error, undefined) +}) diff --git a/packages/validator/test/lib.spec.js b/packages/validator/test/lib.spec.js index 224c184d..3fd741d3 100644 --- a/packages/validator/test/lib.spec.js +++ b/packages/validator/test/lib.spec.js @@ -702,16 +702,18 @@ test('invalid claim / invalid sub delegation', async () => { capability: storeAdd, }) + const capability = `{"can":"store/add","with":"${w3.did()}","nb":${JSON.stringify( + nb + )}}` + assert.containSubset(result, { name: 'Unauthorized', message: `Claim ${storeAdd} is not authorized - - Capability {"can":"store/add","with":"${w3.did()}","nb":${JSON.stringify( - nb - )}} is not authorized because: + - Capability ${capability} is not authorized because: - Capability can not be (self) issued by '${mallory.did()}' - - Capability {"can":"store/add","with":"${w3.did()}"} is not authorized because: + - Capability ${capability} is not authorized because: - Capability can not be (self) issued by '${bob.did()}' - - Capability {"can":"store/add","with":"${w3.did()}"} is not authorized because: + - Capability ${capability} is not authorized because: - Capability can not be (self) issued by '${alice.did()}' - Delegated capability not found`, })