From b0dd187513b3169325ab076be3817cdbd5e34099 Mon Sep 17 00:00:00 2001 From: Irakli Gozalishvili Date: Wed, 8 Feb 2023 00:00:49 -0800 Subject: [PATCH] feat!: support for wildcard cap like store/* fixes: #87 --- packages/validator/src/capability.js | 131 ++++- packages/validator/test/auth.spec.js | 83 --- .../validator/test/capability-access.spec.js | 556 ++++++++++++++++++ packages/validator/test/capability.spec.js | 2 +- packages/validator/test/lib.spec.js | 12 +- 5 files changed, 681 insertions(+), 103 deletions(-) delete mode 100644 packages/validator/test/auth.spec.js 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..b64ae898 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 = parseInvokedCapability(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 = parseDelegatedCapability(this, this.value, capability) if (!result.error) { const claim = this.descriptor.derives(this.value, result) if (claim.error) { @@ -635,29 +635,52 @@ 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. + * @template {string} T + * @param {T} match + * @param {string} pattern + * @returns {T|null} + */ +const matchAbility = (match, pattern) => + match === pattern + ? match + : pattern === '*' + ? match + : pattern.endsWith('/*') && match.startsWith(pattern.slice(0, -1)) + ? match + : null + +/** + * @template {string} T + * @param {T} match + * @param {string} pattern + * @returns {T|null} + */ +const matchURI = (match, pattern) => + match === pattern + ? match + : (pattern.endsWith('/*') || pattern.endsWith(':*')) && + match.startsWith(pattern.slice(0, -1)) + ? match + : null + +/** + * Parses capability `source` using a provided capability `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 parseInvokedCapability = (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 +689,105 @@ 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 + ) +} + +/** + * Takes capability `parser`, `parsed` capability and a `source` capability from + * which `parsed` capability supposed be derived. Unlike `parseInvokedCapability` + * all `nb` fields are optional and `can` and `with` fields are treated as + * patters. If `can` / `with` of the source capability are patterns parsed + * capability will inherit value from the `parsed` capability. + * + * @template {API.Ability} A + * @template {API.URI} R + * @template {API.Caveats} C + * @param {{descriptor: API.Descriptor}} parser + * @param {API.ParsedCapability>} parsed + * @param {API.Source} source + * @returns {API.Result>, API.InvalidCapability>} + */ + +const parseDelegatedCapability = ( + { descriptor }, + parsed, + { capability, delegation } +) => { + // If capability uses pattern like `*` or `store/*` we just use the `can` of + // the parser. + const can = matchAbility(parsed.can, capability.can) + if (can == null) { + return new UnknownCapability(capability) + } + + // If we are parsing capability from the proof we have a parsed capability. + // In such case we match original `with` against the one in the proof, which + // may be `*` or a `did:*`. If we have a match we use `with` from the original + // capability otherwise we parse whatever's in the proof. + const matchedURI = matchURI(parsed.with, capability.with) + const uri = descriptor.with.read(matchedURI || capability.with) + if (uri.error) { + return new MalformedCapability(capability, uri) + } + + const nb = parseNB(capability, descriptor.nb, { ...parsed.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/auth.spec.js b/packages/validator/test/auth.spec.js deleted file mode 100644 index 5625cc15..00000000 --- a/packages/validator/test/auth.spec.js +++ /dev/null @@ -1,83 +0,0 @@ -import { test, assert } from './test.js' -import { access, 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 access = Schema.struct({ -// can: Schema.string(), -// with: Schema.string(), -// }) - -// const update = capability({ -// can: './update', -// with: DID, -// nb: { -// aud: DID.match({ method: 'key' }), -// att: access.array(), -// exp: Schema.integer().optional(), -// nbf: Schema.integer().optional(), -// }, -// }) - -const any = capability({ - can: '*', - with: DID, -}) - -const capabilities = { - dev: { - ping: any.derive({ - to: capability({ - can: 'dev/ping', - with: DID, - nb: { - message: Schema.string(), - }, - }), - derives: (claim, proof) => { - return ( - claim.with.startsWith(proof.with) || new Failure('Can not derive') - ) - }, - }), - }, -} - -test('check validation', async () => { - const proof = await Core.delegate({ - issuer: alice, - audience: bob, - capabilities: [ - { - with: 'did:', - 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, - }) - - console.log(result) - - assert.equal(result.error, undefined) -}) diff --git a/packages/validator/test/capability-access.spec.js b/packages/validator/test/capability-access.spec.js new file mode 100644 index 00000000..d41992d2 --- /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 patters', async () => { + const proof = await Core.delegate({ + issuer: alice, + audience: bob, + capabilities: [ + { + with: 'did:*', + 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 patters 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: 'did:*', + 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: 'did:*', + 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: 'did:*', + can: '*', + }, + ], + }) + + const middle = await Core.delegate({ + issuer: bob, + audience: mallory, + capabilities: [ + { + with: 'did:*', + can: 'dev/*', + }, + ], + proofs: [top], + }) + + const proof = await Core.delegate({ + issuer: mallory, + audience: jordan, + capabilities: [ + { + with: 'did:*', + 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: 'did:*', + 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: 'did:*', + 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/capability.spec.js b/packages/validator/test/capability.spec.js index d5e69cdc..755b8e4c 100644 --- a/packages/validator/test/capability.spec.js +++ b/packages/validator/test/capability.spec.js @@ -1981,7 +1981,7 @@ test('default derive', () => { can: 'test/a', value: { can: 'test/a', - with: 'file:///home/bob/*', + with: 'file:///home/bob/photo', nb: {}, }, }, 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`, })