diff --git a/packages/interface/src/capability.ts b/packages/interface/src/capability.ts index 85812373..bee368a1 100644 --- a/packages/interface/src/capability.ts +++ b/packages/interface/src/capability.ts @@ -5,12 +5,16 @@ import { Result, Failure, PrincipalParser, + PrincipalResolver, Signer, URI, UCANLink, Await, IssuedInvocationView, UCANOptions, + DIDKey, + Verifier, + API, } from './lib.js' export interface Source { @@ -358,18 +362,40 @@ export interface ProofResolver extends PrincipalOptions { /** * You can provide a proof resolver that validator will call when UCAN * links to external proof. If resolver is not provided validator may not - * be able to explore correesponding path within a proof chain. + * be able to explore corresponding path within a proof chain. */ resolve?: (proof: Link) => Await> } -export interface ValidationOptions - extends Partial, +export interface Validator { + /** + * Validator must be provided a `Verifier` corresponding to local authority. + * Capability provider service will use one corresponding to own DID or it's + * supervisor's DID if it acts under it's authority. + * + * This allows service identified by non did:key e.g. did:web or did:dns to + * pass resolved key so it does not need to be resolved at runtime. + */ + authority: Verifier +} + +export interface ValidationOptions< + C extends ParsedCapability = ParsedCapability +> extends Partial, + Validator, PrincipalOptions, + PrincipalResolver, ProofResolver { capability: CapabilityParser> } +export interface ClaimOptions + extends Partial, + Validator, + PrincipalOptions, + PrincipalResolver, + ProofResolver {} + export interface DelegationError extends Failure { name: 'InvalidClaim' causes: (InvalidCapability | EscalatedDelegation | DelegationError)[] @@ -405,6 +431,13 @@ export interface UnavailableProof extends Failure { readonly link: UCANLink } +export interface DIDKeyResolutionError extends Failure { + readonly name: 'DIDKeyResolutionError' + readonly did: UCAN.DID + + readonly cause?: Unauthorized +} + export interface Expired extends Failure { readonly name: 'Expired' readonly delegation: Delegation @@ -432,16 +465,21 @@ export type InvalidProof = | NotValidBefore | InvalidSignature | InvalidAudience + | DIDKeyResolutionError + | UnavailableProof export interface Unauthorized extends Failure { name: 'Unauthorized' - cause: InvalidCapability | InvalidProof | InvalidClaim + + delegationErrors: DelegationError[] + unknownCapabilities: Capability[] + invalidProofs: InvalidProof[] + failedProofs: InvalidClaim[] } export interface InvalidClaim extends Failure { issuer: UCAN.Principal name: 'InvalidClaim' - capability: ParsedCapability delegation: Delegation message: string diff --git a/packages/interface/src/lib.ts b/packages/interface/src/lib.ts index fcb0df55..0997d4cb 100644 --- a/packages/interface/src/lib.ts +++ b/packages/interface/src/lib.ts @@ -26,6 +26,7 @@ import { InvalidAudience, Unauthorized, UnavailableProof, + DIDKeyResolutionError, ParsedCapability, CapabilityParser, } from './capability.js' @@ -239,7 +240,7 @@ export type InvocationError = | Unauthorized export interface InvocationContext extends CanIssue { - id: Principal + id: Verifier my?: (issuer: DID) => Capability[] resolve?: (proof: UCANLink) => Await> @@ -425,7 +426,7 @@ export interface ServerOptions * Service DID which will be used to verify that received invocation * audience matches it. */ - readonly id: Principal + readonly id: Verifier } /** @@ -491,6 +492,10 @@ export type URI

= `${P}${string}` & protocol: P }> +export interface ComposedDIDParser extends PrincipalParser { + or(parser: PrincipalParser): ComposedDIDParser +} + /** * A `PrincipalParser` provides {@link Verifier} instances that can validate UCANs issued * by a given {@link Principal}. @@ -499,6 +504,17 @@ export interface PrincipalParser { parse(did: UCAN.DID): Verifier } +/** + * A `PrincipalResolver` is used to resolve a key of the principal that is + * identified by DID different from did:key method. It can be passed into a + * UCAN validator in order to augmented it with additional DID methods support. + */ +export interface PrincipalResolver { + resolveDIDKey?: ( + did: UCAN.DID + ) => Await> +} + /** * Represents component that can create a signer from it's archive. Usually * signer module would provide `from` function and therefor be an implementation @@ -517,6 +533,23 @@ export interface SignerImporter< from(archive: SignerArchive): Signer } +export interface CompositeImporter< + Variants extends [SignerImporter, ...SignerImporter[]] +> { + from: Intersection + or( + other: Other + ): CompositeImporter<[Other, ...Variants]> +} + +export interface Importer { + from(archive: Archive): Self +} + +export interface Archive { + id: ReturnType + keys: { [Key: DIDKey]: KeyArchive } +} /** * Principal that can issue UCANs (and sign payloads). While it's primary role * is to sign payloads it also extends `Verifier` interface so it could be used @@ -598,6 +631,10 @@ export interface Signer */ export interface Verifier extends UCAN.Verifier { + /** + * Returns unwrapped did:key of this principal. + */ + toDIDKey(): DIDKey /** * Wraps key of this verifier into a verifier with a different DID. This is * primarily used to wrap {@link VerifierKey} into a {@link Verifier} that has diff --git a/packages/principal/src/did.js b/packages/principal/src/did.js deleted file mode 100644 index 93fcc53c..00000000 --- a/packages/principal/src/did.js +++ /dev/null @@ -1,153 +0,0 @@ -import * as API from '@ucanto/interface' -import * as Key from './key.js' - -/** - * Takes `SignerArchive` and restores it into a `Signer` that can be used - * for signing & verifying payloads. - * - * @template {API.DID} ID - DID that can be imported, which may be a type union. - * @template {API.SigAlg} Alg - Multicodec code corresponding to signature algorithm. - * @param {API.SignerArchive} archive - * @param {API.SignerImporter} importer - * @returns {API.Signer} - */ -export const from = (archive, importer = Key.Signer) => { - if (archive.id.startsWith('did:key:')) { - return /** @type {API.Signer} */ (importer.from(archive)) - } else { - for (const [name, key] of Object.entries(archive.keys)) { - const id = /** @type {API.DID<'key'>} */ (name) - const signer = /** @type {API.Signer, Alg>} */ ( - importer.from({ - id, - keys: { [id]: key }, - }) - ) - - return signer.withDID(archive.id) - } - - throw new Error(`Archive ${archive.id} constaints no keys`) - } -} - -/** - * - * @param {API.DID} did - */ -const notFound = did => { - throw new RangeError(`Unable to reslove ${did}`) -} - -/** - * @param {API.DID} did - * @param {Partial} options - * @returns {API.Verifier} - */ -export const parse = ( - did, - { parser = Key.Verifier, resolve = notFound } = {} -) => { - if (did.startsWith('did:key:')) { - return parser.parse(did) - } else if (did.startsWith('did:')) { - return new Verifier(did, null, { - parser, - resolve, - }) - } else { - throw new Error(`Expected did instead got ${did}`) - } -} - -/** - * @typedef {{ - * parser: API.PrincipalParser - * resolve: (did:API.DID) => API.Await> - * }} VerifierOptions - */ - -/** - * Implementation of `Verifier` that lazily resolves it's DID to corresponding - * did:key verifier. For more details on methods and type parameters please see - * `Verifier` interface. - * - * @template {API.DID} ID - * @template {API.SigAlg} Alg - * @implements {API.Verifier} - */ -class Verifier { - /** - * @param {ID} id - * @param {API.VerifierKey|null} key - * @param {VerifierOptions} options - */ - constructor(id, key, options) { - this.id = id - this.options = options - this.key = key - } - - did() { - return this.id - } - - /** - * @template {API.DID} ID - * @param {ID} id - */ - withDID(id) { - return new Verifier(id, this.key, this.options) - } - - /** - * @template T - * @param {API.ByteView} payload - * @param {API.Signature} signature - * @returns {API.Await} - */ - verify(payload, signature) { - if (this.key) { - return this.key.verify(payload, signature) - } else { - return this.resolveAndVerify(payload, signature) - } - } - - /** - * @private - */ - async resolve() { - // Please note that this is not meant to perform live DID document - // resolution nor instance is supposed to be reused across UCAN validation - // sessions. Key is only ever resolved once to make this verifier - // referentially transparent. - if (!this.key) { - const did = await this.options.resolve(this.id) - this.key = /** @type {API.Verifier, Alg>} */ ( - this.options.parser.parse(did) - ) - } - return this.key - } - - /** - * @private - * @template T - * @param {API.ByteView} payload - * @param {API.Signature} signature - * @returns {Promise} - */ - async resolveAndVerify(payload, signature) { - try { - const key = await this.resolve() - return key.verify(payload, signature) - } catch (_) { - // It may swallow resolution error which is not ideal, but throwing - // is not really a good solution here instead we should change return - // type to result from boolean as per issue - // @see https://github.com/web3-storage/ucanto/issues/150 - return false - } - } -} diff --git a/packages/principal/src/ed25519/signer.js b/packages/principal/src/ed25519/signer.js index 1d6c3626..08769eee 100644 --- a/packages/principal/src/ed25519/signer.js +++ b/packages/principal/src/ed25519/signer.js @@ -4,7 +4,7 @@ import * as API from './type.js' import * as Verifier from './verifier.js' import { base64pad } from 'multiformats/bases/base64' import * as Signature from '@ipld/dag-ucan/signature' -import { withDID } from '../signer.js' +import * as Signer from '../signer.js' export * from './type.js' export const code = 0x1300 @@ -52,18 +52,26 @@ export const derive = async secret => { } /** - * @param {API.SignerArchive} archive + * @param {API.SignerArchive} archive * @returns {API.EdSigner} */ export const from = ({ id, keys }) => { - const key = keys[id] - if (key instanceof Uint8Array) { - return decode(key) - } else { - throw new Error(`Unsupported archive format`) + if (id.startsWith('did:key:')) { + const key = keys[/** @type {API.DIDKey} */ (id)] + if (key instanceof Uint8Array) { + return decode(key) + } } + throw new TypeError(`Unsupported archive format`) } +from +/** + * @template {API.SignerImporter} O + * @param {O} other + */ +export const or = other => Signer.or({ from }, other) + /** * @param {Uint8Array} bytes * @returns {API.EdSigner} @@ -163,13 +171,17 @@ class Ed25519Signer extends Uint8Array { return this.verifier.did() } + toDIDKey() { + return this.verifier.toDIDKey() + } + /** * @template {API.DID} ID * @param {ID} id * @returns {API.Signer} */ withDID(id) { - return withDID(this, id) + return Signer.withDID(this, id) } /** diff --git a/packages/principal/src/ed25519/verifier.js b/packages/principal/src/ed25519/verifier.js index 34466960..e15018eb 100644 --- a/packages/principal/src/ed25519/verifier.js +++ b/packages/principal/src/ed25519/verifier.js @@ -4,7 +4,7 @@ import { varint } from 'multiformats' import * as API from './type.js' import * as Signature from '@ipld/dag-ucan/signature' import { base58btc } from 'multiformats/bases/base58' -import { withDID } from '../verifier.js' +import * as Verifier from '../verifier.js' /** @type {API.EdVerifier['code']} */ export const code = 0xed @@ -24,6 +24,7 @@ const SIZE = 32 + PUBLIC_TAG_SIZE * Parses `did:key:` string as a VerifyingPrincipal. * * @param {API.DID|string} did + * @returns {API.Verifier} */ export const parse = did => decode(DID.parse(did)) @@ -52,7 +53,7 @@ export const decode = bytes => { /** * Formats given Principal into `did:key:` format. * - * @param {API.Principal>} principal + * @param {API.Principal} principal */ export const format = principal => DID.format(principal) @@ -120,6 +121,15 @@ class Ed25519Verifier extends Uint8Array { * @returns {API.Verifier} */ withDID(id) { - return withDID(this, id) + return Verifier.withDID(this, id) + } + + toDIDKey() { + return this.did() } } + +/** + * @param {API.PrincipalParser} other + */ +export const or = other => Verifier.or({ parse }, other) diff --git a/packages/principal/src/key.js b/packages/principal/src/key.js deleted file mode 100644 index b82b9dd3..00000000 --- a/packages/principal/src/key.js +++ /dev/null @@ -1,7 +0,0 @@ -import * as ed25519 from './ed25519.js' -import * as RSA from './rsa.js' -import { create as createVerifier } from './verifier.js' -import { create as createSigner } from './signer.js' - -export const Verifier = createVerifier([ed25519.Verifier, RSA.Verifier]) -export const Signer = createSigner([ed25519, RSA]) diff --git a/packages/principal/src/lib.js b/packages/principal/src/lib.js index ed41fba6..91403ef4 100644 --- a/packages/principal/src/lib.js +++ b/packages/principal/src/lib.js @@ -1,10 +1,7 @@ import * as ed25519 from './ed25519.js' import * as RSA from './rsa.js' -import * as DID from './did.js' -import * as Key from './key.js' -export { from, parse } from './did.js' -export const Verifier = DID -export const Signer = DID +export const Verifier = ed25519.Verifier.or(RSA.Verifier) +export const Signer = ed25519.or(RSA) -export { ed25519, RSA, Key, DID } +export { ed25519, RSA } diff --git a/packages/principal/src/rsa.js b/packages/principal/src/rsa.js index 21613459..08407ac8 100644 --- a/packages/principal/src/rsa.js +++ b/packages/principal/src/rsa.js @@ -8,8 +8,8 @@ import * as SPKI from './rsa/spki.js' import * as PKCS8 from './rsa/pkcs8.js' import * as PrivateKey from './rsa/private-key.js' import * as PublicKey from './rsa/public-key.js' -import { withDID as setVerifierDID } from './verifier.js' -import { withDID } from './signer.js' +import * as Verifier from './verifier.js' +import * as Signer from './signer.js' export * from './rsa/type.js' export const name = 'RSA' @@ -94,21 +94,34 @@ export const generate = async ({ } /** - * @param {API.SignerArchive} archive + * @param {API.SignerArchive} archive * @returns {API.RSASigner} */ export const from = ({ id, keys }) => { - const key = keys[id] - if (key instanceof Uint8Array) { - return decode(key) + if (id.startsWith('did:key:')) { + const did = /** @type {API.DIDKey} */ (id) + const key = keys[did] + if (key instanceof Uint8Array) { + return decode(key) + } else { + return new UnextractableRSASigner({ + privateKey: key, + verifier: RSAVerifier.parse(did), + }) + } } else { - return new UnextractableRSASigner({ - privateKey: key, - verifier: RSAVerifier.parse(id), - }) + throw new TypeError( + `RSA can not import from ${id} archive, try generic Signer instead` + ) } } +/** + * @template {API.SignerImporter} Other + * @param {Other} other + */ +export const or = other => Signer.or({ from }, other) + /** * @param {EncodedSigner} bytes * @returns {API.RSASigner} @@ -155,7 +168,11 @@ class RSAVerifier { * @returns {API.Verifier} */ withDID(id) { - return setVerifierDID(this, id) + return Verifier.withDID(this, id) + } + + toDIDKey() { + return this.did() } /** @@ -175,13 +192,20 @@ class RSAVerifier { }) } /** - * @param {API.DID<"key">} did + * @param {API.DIDKey} did * @returns {API.RSAVerifier} */ static parse(did) { return RSAVerifier.decode(/** @type {Uint8Array} */ (DID.parse(did))) } + /** + * @param {API.PrincipalParser} other + */ + static or(other) { + return Verifier.or(this, other) + } + /** @type {typeof verifierCode} */ get code() { return verifierCode @@ -228,8 +252,8 @@ class RSAVerifier { } } -/** @type {API.PrincipalParser} */ -export const Verifier = RSAVerifier +const RSAVerifier$ = /** @type {API.ComposedDIDParser} */ (RSAVerifier) +export { RSAVerifier as Verifier } /** * @typedef {API.ByteView, typeof signatureCode> & CryptoKey>} EncodedSigner @@ -273,6 +297,11 @@ class RSASigner { did() { return this.verifier.did() } + + toDIDKey() { + return this.verifier.toDIDKey() + } + /** * @template T * @param {API.ByteView} payload @@ -318,7 +347,7 @@ class ExtractableRSASigner extends RSASigner { * @returns {API.Signer} */ withDID(id) { - return withDID(this, id) + return Signer.withDID(this, id) } toArchive() { @@ -350,7 +379,7 @@ class UnextractableRSASigner extends RSASigner { * @returns {API.Signer} */ withDID(id) { - return withDID(this, id) + return Signer.withDID(this, id) } toArchive() { diff --git a/packages/principal/src/signer.js b/packages/principal/src/signer.js index 447bf9a1..1c089e20 100644 --- a/packages/principal/src/signer.js +++ b/packages/principal/src/signer.js @@ -1,28 +1,87 @@ import * as API from '@ucanto/interface' +/** + * @template {API.SignerImporter} L + * @template {API.SignerImporter} R + * @param {L} left + * @param {R} right + * @returns {API.CompositeImporter<[L, R]>} + */ +export const or = (left, right) => new Importer([left, right]) + +/** + * @template {[API.SignerImporter, ...API.SignerImporter[]]} Importers + * @implements {API.CompositeImporter} + */ +class Importer { + /** + * @param {Importers} variants + */ + constructor(variants) { + this.variants = variants + this.from = create(variants) + } + + /** + * @template {API.SignerImporter} Other + * @param {Other} other + * @returns {API.CompositeImporter<[Other, ...Importers]>} + */ + or(other) { + return new Importer([other, ...this.variants]) + } +} + /** * @template {[API.SignerImporter, ...API.SignerImporter[]]} Importers * @param {Importers} importers */ -export const create = importers => { - const from = /** @type {API.Intersection} */ ( - /** - * @param {API.SignerArchive} archive - * @returns {API.Signer} - */ - archive => { - for (const importer of importers) { - try { - return importer.from(archive) - } catch (_) {} +const create = importers => { + /** + * @template {API.DID} ID - DID that can be imported, which may be a type union. + * @template {API.SigAlg} Alg - Multicodec code corresponding to signature algorithm. + * @param {API.SignerArchive} archive + * @returns {API.Signer} + */ + const from = archive => { + if (archive.id.startsWith('did:key:')) { + return /** @type {API.Signer} */ (importWith(archive, importers)) + } else { + for (const [name, key] of Object.entries(archive.keys)) { + const id = /** @type {API.DIDKey} */ (name) + const signer = /** @type {API.Signer} */ ( + importWith( + { + id, + keys: { [id]: key }, + }, + importers + ) + ) + + return signer.withDID(archive.id) } - throw new Error(`Unsupported signer`) + + throw new Error(`Archive ${archive.id} contains no keys`) } - ) + } - return { create, from } + return /** @type {API.Intersection} */ (from) } +/** + * @param {API.SignerArchive} archive + * @param {API.SignerImporter[]} importers + * @returns {API.Signer} + */ +const importWith = (archive, importers) => { + for (const importer of importers) { + try { + return importer.from(archive) + } catch (_) {} + } + throw new Error(`Unsupported signer`) +} /** * @template {number} Code * @template {API.DID} ID @@ -31,14 +90,14 @@ export const create = importers => { * @returns {API.Signer} */ export const withDID = ({ signer, verifier }, id) => - new Signer(signer, verifier.withDID(id)) + new SignerWithDID(signer, verifier.withDID(id)) /** * @template {API.DID} ID * @template {number} Code * @implements {API.Signer} */ -class Signer { +class SignerWithDID { /** * @param {API.Signer, Code>} key * @param {API.Verifier} verifier @@ -66,6 +125,10 @@ class Signer { return this.verifier.did() } + toDIDKey() { + return this.verifier.toDIDKey() + } + /** * @template {API.DID} ID * @param {ID} id diff --git a/packages/principal/src/verifier.js b/packages/principal/src/verifier.js index 72e34e40..766515d5 100644 --- a/packages/principal/src/verifier.js +++ b/packages/principal/src/verifier.js @@ -1,23 +1,55 @@ import * as API from '@ucanto/interface' /** - * @param {API.PrincipalParser[]} options + * @param {API.DID} did + * @param {API.PrincipalParser[]} parsers + * @return {API.Verifier} */ -export const create = options => ({ - create, - /** - * @param {API.DID} did - * @return {API.Verifier} - */ - parse: did => { - for (const option of options) { +const parseWith = (did, parsers) => { + if (did.startsWith('did:')) { + for (const parser of parsers) { try { - return option.parse(did) + return parser.parse(did) } catch (_) {} } throw new Error(`Unsupported did ${did}`) - }, -}) + } else { + throw new Error(`Expected did instead got ${did}`) + } +} + +/** + * @param {API.PrincipalParser} left + * @param {API.PrincipalParser} right + * @returns {API.ComposedDIDParser} + */ +export const or = (left, right) => new Parser([left, right]) + +/** + * @implements {API.ComposedDIDParser} + */ +class Parser { + /** + * @param {API.PrincipalParser[]} variants + */ + constructor(variants) { + this.variants = variants + } + + /** + * @param {API.DID} did + */ + parse(did) { + return parseWith(did, this.variants) + } + + /** + * @param {API.PrincipalParser} parser + */ + or(parser) { + return new Parser([...this.variants, parser]) + } +} /** * @template {API.DID} ID @@ -26,14 +58,14 @@ export const create = options => ({ * @param {ID} id * @returns {API.Verifier} */ -export const withDID = (key, id) => new Verifier(id, key) +export const withDID = (key, id) => new VerifierWithDID(id, key) /** * @template {API.DID} ID * @template {API.MulticodecCode} SigAlg * @implements {API.Verifier} */ -class Verifier { +class VerifierWithDID { /** * @param {ID} id * @param {API.VerifierKey} key @@ -46,6 +78,10 @@ class Verifier { return this.id } + toDIDKey() { + return this.key.toDIDKey() + } + /** * @template T * @param {API.ByteView} payload diff --git a/packages/principal/test/did.spec.js b/packages/principal/test/did.spec.js index 31cccf6c..bda349a3 100644 --- a/packages/principal/test/did.spec.js +++ b/packages/principal/test/did.spec.js @@ -1,4 +1,4 @@ -import { ed25519, RSA, DID } from '../src/lib.js' +import { ed25519, RSA, Verifier, Signer } from '../src/lib.js' import { assert } from 'chai' import { sha256 } from 'multiformats/hashes/sha2' @@ -8,7 +8,9 @@ describe('did', () => { const key = await ed25519.generate() const signer = key.withDID('did:dns:api.web3.storage') - assert.ok(signer.did().startsWith('did:dns:api.web3.storage')) + assert.equal(signer.did().startsWith('did:dns:api.web3.storage'), true) + assert.equal(signer.toDIDKey().startsWith('did:key:'), true) + assert.equal(signer.toDIDKey(), key.toDIDKey()) assert.equal(signer.signatureCode, 0xd0ed) assert.equal(signer.signatureAlgorithm, 'EdDSA') assert.equal(signer.signer, signer) @@ -28,11 +30,24 @@ describe('did', () => { assert.equal(signer.did(), signer.verifier.did()) }) + it('withDID RSA', async () => { + const key = await RSA.generate() + const signer = key.withDID('did:web:api.web3.storage') + + assert.equal(signer.did(), 'did:web:api.web3.storage') + assert.equal(key.did(), signer.toDIDKey()) + assert.equal(key.toDIDKey(), key.did()) + + const { verifier } = signer + assert.equal(verifier.did(), signer.did()) + assert.equal(verifier.toDIDKey(), key.did()) + }) + it('can archive 🔁 restore rsa unextractable', async () => { const key = await RSA.generate() const original = key.withDID('did:dns:api.web3.storage') const archive = original.toArchive() - const restored = DID.from(archive) + const restored = Signer.from(archive) const payload = utf8.encode('hello world') assert.equal( @@ -50,7 +65,7 @@ describe('did', () => { const key = await RSA.generate() const original = key.withDID('did:web:api.web3.storage') const archive = original.toArchive() - const restored = DID.from(archive) + const restored = Signer.from(archive) const payload = utf8.encode('hello world') assert.equal( @@ -67,7 +82,7 @@ describe('did', () => { it('can archive 🔁 restore ed25519', async () => { const key = await ed25519.generate() const original = key.withDID('did:web:api.web3.storage') - const restored = DID.from(original.toArchive()) + const restored = Signer.from(original.toArchive()) const payload = utf8.encode('hello world') assert.equal( @@ -92,7 +107,7 @@ describe('did', () => { assert.equal(id, 'did:dns:api.web3.storage') assert.equal(Object.keys(keys)[0].startsWith('did:key:'), true) - assert.throws(() => DID.from({ id, keys: {} }), /constaints no keys/) + assert.throws(() => Signer.from({ id, keys: {} })) }) it('can sign & verify', async () => { @@ -109,15 +124,11 @@ describe('did', () => { assert.equal(await signer.verify(payload, signature), true) }) - it('can parse verifier', async () => { + it.skip('can parse verifier', async () => { const key = await ed25519.generate() const principal = key.withDID('did:dns:api.web3.storage') const payload = utf8.encode('hello world') - const verifier = DID.parse(principal.did(), { - resolve: async _dns => { - return key.did() - }, - }) + const verifier = Verifier.parse(principal.did()) assert.equal(verifier.did(), 'did:dns:api.web3.storage') const signature = await principal.sign(payload) @@ -132,12 +143,15 @@ describe('did', () => { it('fails to verify without resolver', async () => { const key = await ed25519.generate() - const principal = key.withDID('did:dns:api.web3.storage') + const principal = key.withDID('did:web:api.web3.storage') const payload = utf8.encode('hello world') - const verifier = DID.parse('did:dns:api.web3.storage') const signature = await principal.sign(payload) + assert.throws(() => Verifier.parse(principal.did())) + const verifier = Verifier.parse(principal.toDIDKey()) - assert.equal(await verifier.verify(payload, signature), false) + assert.equal(await principal.verifier.verify(payload, signature), true) + + assert.equal(await verifier.verify(payload, signature), true) }) it('verifier can resolve', async () => { @@ -149,7 +163,7 @@ describe('did', () => { it('verifier does not wrap if it is key', async () => { const key = await ed25519.generate() - const verifier = DID.parse(key.did()) + const verifier = Verifier.parse(key.did()) assert.deepEqual(key.verifier, verifier) }) diff --git a/packages/principal/test/lib.spec.js b/packages/principal/test/lib.spec.js index 2bc2a2ec..352e8b30 100644 --- a/packages/principal/test/lib.spec.js +++ b/packages/principal/test/lib.spec.js @@ -97,4 +97,43 @@ describe('PrincipalParser', () => { /Unsupported signer/ ) }) + + it('RSA.Verifier.or(ed25519.Verifier)', async () => { + const ed = await ed25519.generate() + const rsa = await RSA.generate() + const Verifier = RSA.Verifier.or(ed25519.Verifier) + + assert.deepEqual(Verifier.parse(ed.did()), ed.verifier) + assert.deepEqual(Verifier.parse(rsa.did()).did(), rsa.did()) + }) + + it('Verifier.or', async () => { + const ed = await ed25519.generate() + + const Verifier = RSA.Verifier.or(ed25519.Verifier).or({ + parse(did) { + return ed.verifier.withDID(did) + }, + }) + + const did = Verifier.parse('did:web:ucan.space') + assert.deepEqual(did.did(), 'did:web:ucan.space') + assert.deepEqual(did.toDIDKey(), ed.did()) + }) + + it('Signer.or', async () => { + const ed = await ed25519.generate() + + const Signer = RSA.or({ + /** + * @param {API.SignerArchive} _archive + */ + from(_archive) { + throw new Error('Can not do it') + }, + }).or(ed25519) + + const signer = Signer.from(ed.toArchive()) + assert.deepEqual(signer.did(), ed.did()) + }) }) diff --git a/packages/principal/test/rsa.spec.js b/packages/principal/test/rsa.spec.js index 4fba1422..146446fb 100644 --- a/packages/principal/test/rsa.spec.js +++ b/packages/principal/test/rsa.spec.js @@ -9,7 +9,7 @@ import { webcrypto } from 'one-webcrypto' export const utf8 = new TextEncoder() describe('RSA', () => { - it('can generate non extractabel keypair', async () => { + it('can generate non extractable keypair', async () => { const signer = await RSA.generate() assert.equal(signer.code, 0x1305) @@ -45,6 +45,17 @@ describe('RSA', () => { assert.deepEqual(key.usages, ['sign']) }) + it('can not restore non did:key archive', async () => { + const signer = await RSA.generate() + const principal = signer.withDID('did:web:web3.storage') + + const archive = principal.toArchive() + assert.throws( + () => RSA.from(archive), + /can not import from did:web:web3.storage/ + ) + }) + it('can archive 🔁 restore unextractable', async () => { const original = await RSA.generate() const archive = original.toArchive() diff --git a/packages/server/src/handler.js b/packages/server/src/handler.js index 87fa5952..5f1bcbf3 100644 --- a/packages/server/src/handler.js +++ b/packages/server/src/handler.js @@ -20,6 +20,7 @@ export const provide = async (invocation, options) => { const authorization = await access(invocation, { ...options, + authority: options.id, capability, }) if (authorization.error) { diff --git a/packages/server/test/handler.spec.js b/packages/server/test/handler.spec.js index 7b750e9c..62606a35 100644 --- a/packages/server/test/handler.spec.js +++ b/packages/server/test/handler.spec.js @@ -38,15 +38,14 @@ test('invocation', async () => { ], }) const result = await Access.link(invocation, context) + assert.containSubset(result, { error: true, name: 'Unauthorized', - cause: { - name: 'InvalidClaim', - message: `Claimed capability {"can":"identity/link","with":"mailto:alice@web.mail"} is invalid - - Capability can not be (self) issued by '${alice.did()}' - - Delegated capability not found`, - }, + message: `Claim {"can":"identity/link"} is not authorized + - Capability {"can":"identity/link","with":"mailto:alice@web.mail"} is not authorized because: + - Capability can not be (self) issued by '${alice.did()}' + - Delegated capability not found`, }) }) diff --git a/packages/validator/src/error.js b/packages/validator/src/error.js index c5d847d6..80903460 100644 --- a/packages/validator/src/error.js +++ b/packages/validator/src/error.js @@ -1,5 +1,6 @@ import * as API from '@ucanto/interface' import { the } from './util.js' +import { isLink } from 'multiformats/link' /** * @implements {API.Failure} @@ -97,7 +98,7 @@ export class InvalidSignature extends Failure { return this.delegation.audience } describe() { - return [`Signature is invalid`].join('\n') + return [`Proof ${this.delegation.cid} signature is invalid`].join('\n') } } @@ -117,14 +118,33 @@ export class UnavailableProof extends Failure { } describe() { return [ - `Linked proof '${this.link}' is not included nor could be resolved`, + `Linked proof '${this.link}' is not included and could not be resolved`, ...(this.cause - ? [li(`Provided resolve failed: ${this.cause.message}`)] + ? [li(`Proof resolution failed with: ${this.cause.message}`)] : []), ].join('\n') } } +export class DIDKeyResolutionError extends Failure { + /** + * @param {API.UCAN.DID} did + * @param {API.Unauthorized} [cause] + */ + constructor(did, cause) { + super() + this.name = the('DIDKeyResolutionError') + this.did = did + this.cause = cause + } + describe() { + return [ + `Unable to resolve '${this.did}' key`, + ...(this.cause ? [li(`Resolution failed: ${this.cause.message}`)] : []), + ].join('\n') + } +} + /** * @implements {API.InvalidAudience} */ @@ -140,7 +160,7 @@ export class InvalidAudience extends Failure { this.delegation = delegation } describe() { - return `Delegates to '${this.delegation.audience.did()}' instead of '${this.audience.did()}'` + return `Delegation audience is '${this.delegation.audience.did()}' instead of '${this.audience.did()}'` } toJSON() { const { error, name, audience, message, stack } = this @@ -188,6 +208,7 @@ export class UnknownCapability extends Failure { this.name = the('UnknownCapability') this.capability = capability } + /* c8 ignore next 3 */ describe() { return `Encountered unknown capability: ${format(this.capability)}` } @@ -203,7 +224,9 @@ export class Expired extends Failure { this.delegation = delegation } describe() { - return `Expired on ${new Date(this.delegation.expiration * 1000)}` + return `Proof ${this.delegation.cid} has expired on ${new Date( + this.delegation.expiration * 1000 + )}` } get expiredAt() { return this.delegation.expiration @@ -230,11 +253,23 @@ export class NotValidBefore extends Failure { this.delegation = delegation } describe() { - return `Not valid before ${new Date(this.delegation.notBefore * 1000)}` + return `Proof ${this.delegation.cid} is not valid before ${new Date( + this.delegation.notBefore * 1000 + )}` } get validAt() { return this.delegation.notBefore } + toJSON() { + const { error, name, validAt, message, stack } = this + return { + error, + name, + message, + validAt, + stack, + } + } } /** @@ -245,9 +280,9 @@ export class NotValidBefore extends Failure { const format = (capability, space) => JSON.stringify( capability, - (key, value) => { + (_key, value) => { /* c8 ignore next 2 */ - if (value && value.asCID === value) { + if (isLink(value)) { return value.toString() } else { return value diff --git a/packages/validator/src/lib.js b/packages/validator/src/lib.js index 3d08c936..1e37ce78 100644 --- a/packages/validator/src/lib.js +++ b/packages/validator/src/lib.js @@ -1,5 +1,7 @@ import * as API from '@ucanto/interface' import { isDelegation, UCAN } from '@ucanto/core' +import { capability } from './capability.js' +import * as Schema from './schema.js' import { UnavailableProof, InvalidAudience, @@ -9,14 +11,21 @@ import { DelegationError, Failure, MalformedCapability, + DIDKeyResolutionError, li, } from './error.js' -export { Failure, UnavailableProof, MalformedCapability } +export { + Failure, + UnavailableProof, + MalformedCapability, + DIDKeyResolutionError as DIDResolutionError, +} export { capability } from './capability.js' +import { DID } from './schema.js' export * from './schema.js' -export * as Schema from './schema.js' +export { Schema } /** * @param {UCAN.Link} proof @@ -24,7 +33,14 @@ export * as Schema from './schema.js' const unavailable = proof => new UnavailableProof(proof) /** - * @param {Required} config + * + * @param {UCAN.DID} did + * @returns {API.DIDKeyResolutionError} + */ +const failDIDKeyResolution = did => new DIDKeyResolutionError(did) + +/** + * @param {Required} config * @param {API.Match} match */ @@ -49,21 +65,21 @@ const resolveMatch = async (match, config) => { } /** - * @param {API.Delegation} delegation + * @param {API.Proof[]} proofs * @param {Required} config */ -const resolveProofs = async (delegation, config) => { +const resolveProofs = async (proofs, config) => { /** @type {API.Result[]} */ - const proofs = [] + const delegations = [] const promises = [] - for (const [index, proof] of delegation.proofs.entries()) { + for (const [index, proof] of proofs.entries()) { if (!isDelegation(proof)) { promises.push( new Promise(async resolve => { try { - proofs[index] = await config.resolve(proof) + delegations[index] = await config.resolve(proof) } catch (error) { - proofs[index] = new UnavailableProof( + delegations[index] = new UnavailableProof( proof, /** @type {Error} */ (error) ) @@ -72,30 +88,30 @@ const resolveProofs = async (delegation, config) => { }) ) } else { - proofs[index] = proof + delegations[index] = proof } } await Promise.all(promises) - return proofs + return delegations } /** * @param {API.Source} from - * @param {Required} config + * @param {Required} config * @return {Promise<{sources:API.Source[], errors:ProofError[]}>} */ const resolveSources = async ({ delegation }, config) => { const errors = [] const sources = [] // resolve all the proofs that can be side-loaded - const proofs = await resolveProofs(delegation, config) + const proofs = await resolveProofs(delegation.proofs, config) for (const [index, proof] of proofs.entries()) { // if proof can not be side-loaded save a proof errors. if (proof.error) { errors.push(new ProofError(proof.link, index, proof)) } else { - // If proof does not delegate to a matchig audience save an proof error. + // If proof does not delegate to a matching audience save an proof error. if (delegation.issuer.did() !== proof.audience.did()) { errors.push( new ProofError( @@ -106,7 +122,7 @@ const resolveSources = async ({ delegation }, config) => { ) } else { // If proof is not valid (expired, not active yet or has incorrect - // signature) save a correspondig proof error. + // signature) save a corresponding proof error. const validation = await validate(proof, config) if (validation.error) { errors.push(new ProofError(proof.cid, index, validation)) @@ -135,45 +151,101 @@ const resolveSources = async ({ delegation }, config) => { const isSelfIssued = (capability, issuer) => capability.with === issuer /** + * Finds a valid path in a proof chain of the given `invocation` by exploring + * every possible option. On success an `Authorization` object is returned that + * illustrates the valid path. If no valid path is found `Unauthorized` error + * is returned detailing all explored paths and where they proved to fail. + * * @template {API.Ability} A * @template {API.URI} R * @template {R} URI * @template {API.Caveats} C * @param {API.Invocation>>} invocation - * @param {API.ValidationOptions>>} config + * @param {API.ValidationOptions>>} options + * @returns {Promise>>, API.Unauthorized>>} + */ +export const access = async (invocation, { capability, ...config }) => + claim(capability, [invocation], config) + +/** + * Attempts to find a valid proof chain for the claimed `capability` given set + * of `proofs`. On success an `Authorization` object with detailed proof chain + * is returned and on failure `Unauthorized` error is returned with details on + * paths explored and why they have failed. + * + * @template {API.Ability} A + * @template {API.URI} R + * @template {API.Caveats} C + * @param {API.CapabilityParser>>>} capability + * @param {API.Proof[]} proofs + * @param {API.ClaimOptions} config * @returns {Promise>>, API.Unauthorized>>} */ -export const access = async ( - invocation, - { canIssue = isSelfIssued, principal, resolve = unavailable, capability } +export const claim = async ( + capability, + proofs, + { + authority, + principal, + resolveDIDKey = failDIDKeyResolution, + canIssue = isSelfIssued, + resolve = unavailable, + } ) => { - const config = { canIssue, resolve, principal, capability } + const config = { + canIssue, + resolve, + principal, + capability, + authority, + resolveDIDKey, + } - const claim = capability.match({ - capability: invocation.capabilities[0], - delegation: invocation, - index: 0, - }) + const invalidProofs = [] - if (claim.error) { - return new Unauthorized(claim) - } - const check = await validate(invocation, config) - if (check.error) { - return new Unauthorized(check) + /** @type {API.Source[]} */ + const sources = [] + for (const proof of await resolveProofs(proofs, config)) { + const delegation = proof.error ? proof : await validate(proof, config) + + if (!delegation.error) { + for (const [index, capability] of delegation.capabilities.entries()) { + sources.push({ + capability, + delegation, + index, + }) + } + } else { + invalidProofs.push(delegation) + } } + // look for the matching capability + const selection = capability.select(sources) - const match = claim.prune(config) - if (match == null) { - return new Authorization(claim, []) - } else { - const result = await authorize(match, config) - if (result.error) { - return new Unauthorized(result) + const { errors: delegationErrors, unknown: unknownCapabilities } = selection + const failedProofs = [] + for (const matched of selection.matches) { + const selector = matched.prune(config) + if (selector == null) { + return new Authorization(matched, []) } else { - return new Authorization(claim, [result]) + const result = await authorize(selector, config) + if (result.error) { + failedProofs.push(result) + } else { + return new Authorization(matched, [result]) + } } } + + return new Unauthorized({ + capability, + delegationErrors, + unknownCapabilities, + invalidProofs, + failedProofs, + }) } /** @@ -202,12 +274,11 @@ class Authorization { } } /** - * Verifies whether any of the delegated proofs grant give capabality. + * Verifies whether any of the delegated proofs grant give capability. * - * @template {API.ParsedCapability} C * @template {API.Match} Match * @param {Match} match - * @param {Required>} config + * @param {Required} config * @returns {Promise, API.InvalidClaim>>} */ @@ -216,14 +287,14 @@ export const authorize = async (match, config) => { const { sources, errors: invalidProofs } = await resolveMatch(match, config) const selection = match.select(sources) - const { errors: delegationErrors, unknown: unknownCapaibilities } = selection + const { errors: delegationErrors, unknown: unknownCapabilities } = selection const failedProofs = [] for (const matched of selection.matches) { const selector = matched.prune(config) if (selector == null) { // @ts-expect-error - it may not be a parsed capability but rather a - // group of capabilites but we can deal with that in the future. + // group of capabilities but we can deal with that in the future. return new Authorization(matched, []) } else { const result = await authorize(selector, config) @@ -231,7 +302,7 @@ export const authorize = async (match, config) => { failedProofs.push(result) } else { // @ts-expect-error - it may not be a parsed capability but rather a - // group of capabilites but we can deal with that in the future. + // group of capabilities but we can deal with that in the future. return new Authorization(matched, [result]) } } @@ -240,7 +311,7 @@ export const authorize = async (match, config) => { return new InvalidClaim({ match, delegationErrors, - unknownCapaibilities, + unknownCapabilities, invalidProofs, failedProofs, }) @@ -261,7 +332,7 @@ class ProofError extends Failure { } describe() { return [ - `Can not derive from prf:${this.index} - ${this.proof} because:`, + `Capability can not be derived from prf:${this.index} - ${this.proof} because:`, li(this.cause.message), ].join(`\n`) } @@ -275,7 +346,7 @@ class InvalidClaim extends Failure { * @param {{ * match: API.Match * delegationErrors: API.DelegationError[] - * unknownCapaibilities: API.Capability[] + * unknownCapabilities: API.Capability[] * invalidProofs: ProofError[] * failedProofs: API.InvalidClaim[] * }} info @@ -289,9 +360,6 @@ class InvalidClaim extends Failure { get issuer() { return this.delegation.issuer } - get capability() { - return this.info.match.value - } get delegation() { return this.info.match.source[0].delegation } @@ -302,12 +370,12 @@ class InvalidClaim extends Failure { ...this.info.invalidProofs.map(error => li(error.message)), ] - const unknown = this.info.unknownCapaibilities.map(c => + const unknown = this.info.unknownCapabilities.map(c => li(JSON.stringify(c)) ) return [ - `Claimed capability ${this.info.match} is invalid`, + `Capability ${this.info.match} is not authorized because:`, li(`Capability can not be (self) issued by '${this.issuer.did()}'`), ...(errors.length > 0 ? errors : [li(`Delegated capability not found`)]), ...(unknown.length > 0 @@ -320,30 +388,64 @@ class InvalidClaim extends Failure { /** * @implements {API.Unauthorized} */ + class Unauthorized extends Failure { /** - * @param {API.InvalidCapability | API.InvalidProof | API.InvalidClaim} cause + * @param {{ + * capability: API.CapabilityParser + * delegationErrors: API.DelegationError[] + * unknownCapabilities: API.Capability[] + * invalidProofs: API.InvalidProof[] + * failedProofs: API.InvalidClaim[] + * }} cause */ - constructor(cause) { + constructor({ + capability, + delegationErrors, + unknownCapabilities, + invalidProofs, + failedProofs, + }) { super() /** @type {"Unauthorized"} */ this.name = 'Unauthorized' - this.cause = cause + this.capability = capability + this.delegationErrors = delegationErrors + this.unknownCapabilities = unknownCapabilities + this.invalidProofs = invalidProofs + this.failedProofs = failedProofs } - get message() { - return this.cause.message + + describe() { + const errors = [ + ...this.failedProofs.map(error => li(error.message)), + ...this.delegationErrors.map(error => li(error.message)), + ...this.invalidProofs.map(error => li(error.message)), + ] + + const unknown = this.unknownCapabilities.map(c => li(JSON.stringify(c))) + + return [ + `Claim ${this.capability} is not authorized`, + ...(errors.length > 0 + ? errors + : [li(`No matching delegated capability found`)]), + ...(unknown.length > 0 + ? [li(`Encountered unknown capabilities\n${unknown.join('\n')}`)] + : []), + ].join('\n') } toJSON() { - const { error, name, message, cause, stack } = this - return { error, name, message, cause, stack } + const { error, name, message, stack } = this + return { error, name, message, stack } } } /** * @template {API.Delegation} T * @param {T} delegation - * @param {API.PrincipalOptions} config - * @returns {Promise>} + * @param {Required} config + * @returns {Promise>} */ const validate = async (delegation, config) => { if (UCAN.isExpired(delegation.data)) { @@ -364,14 +466,81 @@ const validate = async (delegation, config) => { /** * @template {API.Delegation} T * @param {T} delegation - * @param {API.PrincipalOptions} config - * @returns {Promise>} + * @param {Required} config + * @returns {Promise>} */ -const verifySignature = async (delegation, { principal }) => { - const issuer = principal.parse(delegation.issuer.did()) - const valid = await UCAN.verifySignature(delegation.data, issuer) +const verifySignature = async (delegation, config) => { + const did = delegation.issuer.did() + const issuer = await resolveVerifier(did, delegation, config) + + if (issuer.error) { + return issuer + } + const valid = await UCAN.verifySignature(delegation.data, issuer) return valid ? delegation : new InvalidSignature(delegation) } +/** + * @param {API.DID} did + * @param {API.Delegation} delegation + * @param {Required} config + * @returns {Promise>} + */ +const resolveVerifier = async (did, delegation, config) => { + if (did === config.authority.did()) { + return config.authority + } else if (did.startsWith('did:key:')) { + return config.principal.parse(did) + } else { + // First we attempt to resolve key from the embedded proofs + const local = await resolveDIDFromProofs(did, delegation, config) + const result = !local?.error + ? local + : // If failed to resolve because there is an invalid proof propagate error + (local?.cause?.failedProofs?.length || 0) > 0 + ? local + : // otherwise either use resolved key or if not found attempt to resolve + // did externally + await config.resolveDIDKey(did) + return result.error ? result : config.principal.parse(result).withDID(did) + } +} + +/** + * @param {API.DID} did + * @param {API.Delegation} delegation + * @param {Required} config + * @returns {Promise>} + */ +const resolveDIDFromProofs = async (did, delegation, config) => { + const update = Top.derive({ + to: capability({ + with: Schema.literal(config.authority.did()), + can: './update', + nb: { key: DID.match({ method: 'key' }) }, + }), + derives: equalWith, + }) + + const result = await claim(update, delegation.proofs, config) + return !result.error + ? result.match.value.nb.key + : new DIDKeyResolutionError(did, result) +} + +const Top = capability({ + can: '*', + with: DID, +}) + +/** + * @param {API.Capability} to + * @param {API.Capability} from + */ + +const equalWith = (to, from) => + to.with === from.with || + new Failure(`Claimed ${to.with} can not be derived from ${from.with}`) + export { InvalidAudience } diff --git a/packages/validator/test/error.spec.js b/packages/validator/test/error.spec.js index ae176b4b..e120d160 100644 --- a/packages/validator/test/error.spec.js +++ b/packages/validator/test/error.spec.js @@ -1,7 +1,14 @@ import { test, assert } from './test.js' -import { Failure, InvalidAudience } from '../src/error.js' +import * as API from '@ucanto/interface' +import { + Failure, + InvalidAudience, + InvalidSignature, + Expired, + NotValidBefore, +} from '../src/error.js' import { alice, bob, mallory, service as w3 } from './fixtures.js' -import { delegate } from '@ucanto/core' +import { delegate, UCAN } from '@ucanto/core' test('Failure', () => { const error = new Failure('boom!') @@ -36,7 +43,101 @@ test('InvalidAudience', async () => { name: 'InvalidAudience', audience: bob.did(), delegation: { audience: w3.did() }, - message: `Delegates to '${w3.did()}' instead of '${bob.did()}'`, + message: `Delegation audience is '${w3.did()}' instead of '${bob.did()}'`, stack: error.stack, }) }) + +test('InvalidSignature', async () => { + const delegation = await delegate({ + issuer: alice, + audience: w3, + capabilities: [ + { + can: 'store/write', + with: alice.did(), + }, + ], + proofs: [], + }) + + const error = new InvalidSignature(delegation) + assert.deepEqual(error.issuer.did(), alice.did()) + assert.deepEqual(error.audience.did(), w3.verifier.did()) +}) + +test('Expired', async () => { + const expiration = UCAN.now() + const delegation = await delegate({ + issuer: alice, + audience: w3, + capabilities: [ + { + can: 'store/write', + with: alice.did(), + }, + ], + proofs: [], + expiration, + }) + + const error = new Expired( + /** @type {API.Delegation & { expiration: number }} */ (delegation) + ) + assert.deepEqual(error.expiredAt, expiration) + + assert.equal( + JSON.stringify(error, null, 2), + JSON.stringify( + { + error: true, + name: 'Expired', + message: `Proof ${delegation.cid} has expired on ${new Date( + expiration * 1000 + )}`, + expiredAt: expiration, + stack: error.stack, + }, + null, + 2 + ) + ) +}) + +test('NotValidBefore', async () => { + const time = UCAN.now() + const delegation = await delegate({ + issuer: alice, + audience: w3, + capabilities: [ + { + can: 'store/write', + with: alice.did(), + }, + ], + proofs: [], + notBefore: time, + }) + + const error = new NotValidBefore( + /** @type {API.Delegation & { notBefore: number }} */ (delegation) + ) + assert.deepEqual(error.validAt, time) + + assert.equal( + JSON.stringify(error, null, 2), + JSON.stringify( + { + error: true, + name: 'NotValidBefore', + message: `Proof ${delegation.cid} is not valid before ${new Date( + time * 1000 + )}`, + validAt: time, + stack: error.stack, + }, + null, + 2 + ) + ) +}) diff --git a/packages/validator/test/lib.spec.js b/packages/validator/test/lib.spec.js index 5d721cb6..6ac7497b 100644 --- a/packages/validator/test/lib.spec.js +++ b/packages/validator/test/lib.spec.js @@ -1,5 +1,5 @@ import { test, assert } from './test.js' -import { access } from '../src/lib.js' +import { access, claim } from '../src/lib.js' import { capability, URI, Link } from '../src/lib.js' import { Failure } from '../src/error.js' import * as ed25519 from '@ucanto/principal/ed25519' @@ -47,6 +47,7 @@ test('authorize self-issued invocation', async () => { }) const result = await access(await invocation.delegate(), { + authority: w3, capability: storeAdd, principal: ed25519.Verifier, }) @@ -65,17 +66,20 @@ test('authorize self-issued invocation', async () => { test('unauthorized / expired invocation', async () => { const expiration = UCAN.now() - 5 - const invocation = storeAdd.invoke({ - issuer: alice, - audience: w3, - with: alice.did(), - nb: { - link: Link.parse('bafkqaaa'), - }, - expiration, - }) + const invocation = await storeAdd + .invoke({ + issuer: alice, + audience: w3, + with: alice.did(), + nb: { + link: Link.parse('bafkqaaa'), + }, + expiration, + }) + .delegate() - const result = await access(await invocation.delegate(), { + const result = await access(invocation, { + authority: w3, capability: storeAdd, principal: ed25519.Verifier, }) @@ -83,12 +87,8 @@ test('unauthorized / expired invocation', async () => { assert.containSubset(result, { error: true, name: 'Unauthorized', - message: `Expired on ${new Date(expiration * 1000)}`, - cause: { - name: 'Expired', - expiredAt: expiration, - message: `Expired on ${new Date(expiration * 1000)}`, - }, + message: `Claim ${storeAdd} is not authorized + - Proof ${invocation.cid} has expired on ${new Date(expiration * 1000)}`, }) assert.deepEqual( @@ -96,30 +96,27 @@ test('unauthorized / expired invocation', async () => { JSON.stringify({ error: true, name: 'Unauthorized', - message: `Expired on ${new Date(expiration * 1000)}`, - cause: { - error: true, - name: 'Expired', - message: `Expired on ${new Date(expiration * 1000)}`, - expiredAt: expiration, - stack: result.error ? result.cause.stack : undefined, - }, + message: `Claim ${storeAdd} is not authorized + - Proof ${invocation.cid} has expired on ${new Date(expiration * 1000)}`, stack: result.error ? result.stack : undefined, }) ) }) -test('unauthorized / not vaid before invocation', async () => { +test('unauthorized / not valid before invocation', async () => { const notBefore = UCAN.now() + 500 - const invocation = await storeAdd.invoke({ - issuer: alice, - audience: w3, - with: alice.did(), - nb: { link: Link.parse('bafkqaaa') }, - notBefore, - }) + const invocation = await storeAdd + .invoke({ + issuer: alice, + audience: w3, + with: alice.did(), + nb: { link: Link.parse('bafkqaaa') }, + notBefore, + }) + .delegate() - const result = await access(await invocation.delegate(), { + const result = await access(invocation, { + authority: w3, capability: storeAdd, principal: ed25519.Verifier, }) @@ -127,27 +124,25 @@ test('unauthorized / not vaid before invocation', async () => { assert.containSubset(result, { error: true, name: 'Unauthorized', - cause: { - name: 'NotValidBefore', - validAt: notBefore, - message: `Not valid before ${new Date(notBefore * 1000)}`, - }, + message: `Claim ${storeAdd} is not authorized + - Proof ${invocation.cid} is not valid before ${new Date(notBefore * 1000)}`, }) }) test('unauthorized / invalid signature', async () => { - const invocation = await storeAdd.invoke({ - issuer: alice, - audience: w3, - with: alice.did(), - nb: { link: Link.parse('bafkqaaa') }, - }) - - const delegation = await invocation.delegate() + const invocation = await storeAdd + .invoke({ + issuer: alice, + audience: w3, + with: alice.did(), + nb: { link: Link.parse('bafkqaaa') }, + }) + .delegate() - delegation.data.signature.set(await bob.sign(delegation.bytes)) + invocation.data.signature.set(await bob.sign(invocation.bytes)) - const result = await access(delegation, { + const result = await access(invocation, { + authority: w3, capability: storeAdd, principal: ed25519.Verifier, }) @@ -155,12 +150,8 @@ test('unauthorized / invalid signature', async () => { assert.containSubset(result, { error: true, name: 'Unauthorized', - cause: { - name: 'InvalidSignature', - message: `Signature is invalid`, - issuer: delegation.issuer, - audience: delegation.audience, - }, + message: `Claim ${storeAdd} is not authorized + - Proof ${invocation.cid} signature is invalid`, }) }) @@ -178,6 +169,7 @@ test('unauthorized / unknown capability', async () => { }) const result = await access(invocation, { + authority: w3, // @ts-ignore capability: storeAdd, principal: ed25519.Verifier, @@ -186,10 +178,10 @@ test('unauthorized / unknown capability', async () => { assert.containSubset(result, { name: 'Unauthorized', error: true, - cause: { - name: 'UnknownCapability', - message: `Encountered unknown capability: {"can":"store/write","with":"${alice.did()}"}`, - }, + message: `Claim ${storeAdd} is not authorized + - No matching delegated capability found + - Encountered unknown capabilities + - {"can":"store/write","with":"${alice.did()}"}`, }) }) @@ -211,6 +203,7 @@ test('authorize / delegated invocation', async () => { }) const result = await access(await invocation.delegate(), { + authority: w3, capability: storeAdd, principal: ed25519.Verifier, }) @@ -261,6 +254,7 @@ test('authorize / delegation chain', async () => { }) const result = await access(await invocation.delegate(), { + authority: w3, capability: storeAdd, principal: ed25519.Verifier, }) @@ -309,27 +303,19 @@ test('invalid claim / no proofs', async () => { }) const result = await access(await invocation.delegate(), { + authority: w3, principal: ed25519.Verifier, capability: storeAdd, }) assert.containSubset(result, { name: 'Unauthorized', - cause: { - name: 'InvalidClaim', - message: `Claimed capability {"can":"store/add","with":"${bob.did()}","nb":${JSON.stringify( - { link } - )}} is invalid - - Capability can not be (self) issued by '${alice.did()}' - - Delegated capability not found`, - capability: { - can: 'store/add', - with: bob.did(), - nb: { - link, - }, - }, - }, + message: `Claim ${storeAdd} is not authorized + - Capability {"can":"store/add","with":"${bob.did()}","nb":${JSON.stringify({ + link, + })}} is not authorized because: + - Capability can not be (self) issued by '${alice.did()}' + - Delegated capability not found`, }) }) @@ -354,27 +340,20 @@ test('invalid claim / expired', async () => { .delegate() const result = await access(invocation, { + authority: w3, principal: ed25519.Verifier, capability: storeAdd, }) assert.containSubset(result, { name: 'Unauthorized', - cause: { - name: 'InvalidClaim', - message: `Claimed capability {"can":"store/add","with":"${alice.did()}","nb":${JSON.stringify( - { link } - )}} is invalid - - Capability can not be (self) issued by '${bob.did()}' - - Can not derive from prf:0 - ${delegation.cid} because: - - Expired on ${new Date(expiration * 1000)}`, - capability: { - can: 'store/add', - with: alice.did(), - nb: {}, - }, - delegation: invocation, - }, + message: `Claim ${storeAdd} is not authorized + - Capability {"can":"store/add","with":"${alice.did()}","nb":${JSON.stringify( + { link } + )}} is not authorized because: + - Capability can not be (self) issued by '${bob.did()}' + - Capability can not be derived from prf:0 - ${delegation.cid} because: + - Proof ${delegation.cid} has expired on ${new Date(expiration * 1000)}`, }) }) @@ -399,27 +378,21 @@ test('invalid claim / not valid before', async () => { .delegate() const result = await access(invocation, { + authority: w3, principal: ed25519.Verifier, capability: storeAdd, }) assert.containSubset(result, { name: 'Unauthorized', - cause: { - name: 'InvalidClaim', - message: `Claimed capability {"can":"store/add","with":"${alice.did()}","nb":${JSON.stringify( - { link } - )}} is invalid - - Capability can not be (self) issued by '${bob.did()}' - - Can not derive from prf:0 - ${proof.cid} because: - - Not valid before ${new Date(notBefore * 1000)}`, - capability: { - can: 'store/add', - with: alice.did(), - nb: {}, - }, - delegation: invocation, - }, + + message: `Claim ${storeAdd} is not authorized + - Capability {"can":"store/add","with":"${alice.did()}","nb":${JSON.stringify( + { link } + )}} is not authorized because: + - Capability can not be (self) issued by '${bob.did()}' + - Capability can not be derived from prf:0 - ${proof.cid} because: + - Proof ${proof.cid} is not valid before ${new Date(notBefore * 1000)}`, }) }) @@ -444,27 +417,21 @@ test('invalid claim / invalid signature', async () => { .delegate() const result = await access(invocation, { + authority: w3, principal: ed25519.Verifier, capability: storeAdd, }) assert.containSubset(result, { name: 'Unauthorized', - cause: { - name: 'InvalidClaim', - message: `Claimed capability {"can":"store/add","with":"${alice.did()}","nb":${JSON.stringify( - { link } - )}} is invalid - - Capability can not be (self) issued by '${bob.did()}' - - Can not derive from prf:0 - ${proof.cid} because: - - Signature is invalid`, - capability: { - can: 'store/add', - with: alice.did(), - nb: {}, - }, - delegation: invocation, - }, + + message: `Claim ${storeAdd} is not authorized + - Capability {"can":"store/add","with":"${alice.did()}","nb":${JSON.stringify( + { link } + )}} is not authorized because: + - Capability can not be (self) issued by '${bob.did()}' + - Capability can not be derived from prf:0 - ${proof.cid} because: + - Proof ${proof.cid} signature is invalid`, }) }) @@ -490,22 +457,21 @@ test('invalid claim / unknown capability', async () => { }) const result = await access(await invocation.delegate(), { + authority: w3, principal: ed25519.Verifier, capability: storeAdd, }) assert.containSubset(result, { name: 'Unauthorized', - cause: { - name: 'InvalidClaim', - message: `Claimed capability {"can":"store/add","with":"${alice.did()}","nb":${JSON.stringify( - { link } - )}} is invalid - - Capability can not be (self) issued by '${bob.did()}' - - Delegated capability not found - - Encountered unknown capabilities - - {"can":"store/pin","with":"${alice.did()}"}`, - }, + message: `Claim ${storeAdd} is not authorized + - Capability {"can":"store/add","with":"${alice.did()}","nb":${JSON.stringify( + { link } + )}} is not authorized because: + - Capability can not be (self) issued by '${bob.did()}' + - Delegated capability not found + - Encountered unknown capabilities + - {"can":"store/pin","with":"${alice.did()}"}`, }) }) @@ -537,24 +503,23 @@ test('invalid claim / malformed capability', async () => { }) const result = await access(invocation, { + authority: w3, principal: ed25519.Verifier, capability: storeAdd, }) assert.containSubset(result, { name: 'Unauthorized', - cause: { - name: 'InvalidClaim', - message: `Claimed capability {"can":"store/add","with":"${alice.did()}","nb":${JSON.stringify( - nb - )}} is invalid - - Capability can not be (self) issued by '${bob.did()}' - - Can not derive {"can":"store/add","with":"${alice.did()}","nb":${JSON.stringify( - nb - )}} from delegated capabilities: - - Encountered malformed 'store/add' capability: {"can":"store/add","with":"${badDID}"} - - Expected did: URI instead got ${badDID}`, - }, + message: `Claim ${storeAdd} is not authorized + - Capability {"can":"store/add","with":"${alice.did()}","nb":${JSON.stringify( + nb + )}} is not authorized because: + - Capability can not be (self) issued by '${bob.did()}' + - Can not derive {"can":"store/add","with":"${alice.did()}","nb":${JSON.stringify( + nb + )}} from delegated capabilities: + - Encountered malformed 'store/add' capability: {"can":"store/add","with":"${badDID}"} + - Expected did: URI instead got ${badDID}`, }) }) @@ -575,21 +540,23 @@ test('invalid claim / unavailable proof', async () => { }) const result = await access(await invocation.delegate(), { + authority: w3, principal: ed25519.Verifier, capability: storeAdd, }) assert.containSubset(result, { name: 'Unauthorized', - cause: { - name: 'InvalidClaim', - message: `Claimed capability {"can":"store/add","with":"${alice.did()}","nb":${JSON.stringify( - nb - )}} is invalid - - Capability can not be (self) issued by '${bob.did()}' - - Can not derive from prf:0 - ${delegation.cid} because: - - Linked proof '${delegation.cid}' is not included nor could be resolved`, - }, + + message: `Claim ${storeAdd} is not authorized + - Capability {"can":"store/add","with":"${alice.did()}","nb":${JSON.stringify( + nb + )}} is not authorized because: + - Capability can not be (self) issued by '${bob.did()}' + - Capability can not be derived from prf:0 - ${delegation.cid} because: + - Linked proof '${ + delegation.cid + }' is not included and could not be resolved`, }) }) @@ -610,6 +577,7 @@ test('invalid claim / failed to resolve', async () => { }) const result = await access(await invocation.delegate(), { + authority: w3, principal: ed25519.Verifier, resolve() { throw new Error('Boom!') @@ -619,16 +587,17 @@ test('invalid claim / failed to resolve', async () => { assert.containSubset(result, { name: 'Unauthorized', - cause: { - name: 'InvalidClaim', - message: `Claimed capability {"can":"store/add","with":"${alice.did()}","nb":${JSON.stringify( - nb - )}} is invalid - - Capability can not be (self) issued by '${bob.did()}' - - Can not derive from prf:0 - ${delegation.cid} because: - - Linked proof '${delegation.cid}' is not included nor could be resolved - - Provided resolve failed: Boom!`, - }, + + message: `Claim ${storeAdd} is not authorized + - Capability {"can":"store/add","with":"${alice.did()}","nb":${JSON.stringify( + nb + )}} is not authorized because: + - Capability can not be (self) issued by '${bob.did()}' + - Capability can not be derived from prf:0 - ${delegation.cid} because: + - Linked proof '${ + delegation.cid + }' is not included and could not be resolved + - Proof resolution failed with: Boom!`, }) }) @@ -649,21 +618,21 @@ test('invalid claim / invalid audience', async () => { }) const result = await access(await invocation.delegate(), { + authority: w3, principal: ed25519.Verifier, capability: storeAdd, }) assert.containSubset(result, { name: 'Unauthorized', - cause: { - name: 'InvalidClaim', - message: `Claimed capability {"can":"store/add","with":"${alice.did()}","nb":${JSON.stringify( - nb - )}} is invalid - - Capability can not be (self) issued by '${mallory.did()}' - - Can not derive from prf:0 - ${delegation.cid} because: - - Delegates to '${bob.did()}' instead of '${mallory.did()}'`, - }, + + message: `Claim ${storeAdd} is not authorized + - Capability {"can":"store/add","with":"${alice.did()}","nb":${JSON.stringify( + nb + )}} is not authorized because: + - Capability can not be (self) issued by '${mallory.did()}' + - Capability can not be derived from prf:0 - ${delegation.cid} because: + - Delegation audience is '${bob.did()}' instead of '${mallory.did()}'`, }) }) @@ -684,23 +653,23 @@ test('invalid claim / invalid claim', async () => { }) const result = await access(await invocation.delegate(), { + authority: w3, principal: ed25519.Verifier, capability: storeAdd, }) assert.containSubset(result, { name: 'Unauthorized', - cause: { - name: 'InvalidClaim', - message: `Claimed capability {"can":"store/add","with":"${alice.did()}","nb":${JSON.stringify( - nb - )}} is invalid - - Capability can not be (self) issued by '${bob.did()}' - - Can not derive {"can":"store/add","with":"${alice.did()}","nb":${JSON.stringify( - nb - )}} from delegated capabilities: - - Constraint violation: Expected 'with: "${mallory.did()}"' instead got '${alice.did()}'`, - }, + + message: `Claim ${storeAdd} is not authorized + - Capability {"can":"store/add","with":"${alice.did()}","nb":${JSON.stringify( + nb + )}} is not authorized because: + - Capability can not be (self) issued by '${bob.did()}' + - Can not derive {"can":"store/add","with":"${alice.did()}","nb":${JSON.stringify( + nb + )}} from delegated capabilities: + - Constraint violation: Expected 'with: "${mallory.did()}"' instead got '${alice.did()}'`, }) }) @@ -728,24 +697,23 @@ test('invalid claim / invalid sub delegation', async () => { }) const result = await access(await invocation.delegate(), { + authority: w3, principal: ed25519.Verifier, capability: storeAdd, }) assert.containSubset(result, { name: 'Unauthorized', - cause: { - name: 'InvalidClaim', - message: `Claimed capability {"can":"store/add","with":"${w3.did()}","nb":${JSON.stringify( - nb - )}} is invalid - - Capability can not be (self) issued by '${mallory.did()}' - - Claimed capability {"can":"store/add","with":"${w3.did()}"} is invalid - - Capability can not be (self) issued by '${bob.did()}' - - Claimed capability {"can":"store/add","with":"${w3.did()}"} is invalid - - Capability can not be (self) issued by '${alice.did()}' - - Delegated capability not found`, - }, + message: `Claim ${storeAdd} is not authorized + - Capability {"can":"store/add","with":"${w3.did()}","nb":${JSON.stringify( + nb + )}} 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 can not be (self) issued by '${bob.did()}' + - Capability {"can":"store/add","with":"${w3.did()}"} is not authorized because: + - Capability can not be (self) issued by '${alice.did()}' + - Delegated capability not found`, }) }) @@ -768,6 +736,7 @@ test('authorize / resolve external proof', async () => { }) const result = await access(await invocation.delegate(), { + authority: w3, principal: ed25519.Verifier, resolve: async link => { if (link.toString() === delegation.cid.toString()) { @@ -804,7 +773,7 @@ test('authorize / resolve external proof', async () => { }) }) -test('invalid claim / principal aligment', async () => { +test('invalid claim / principal alignment', async () => { const proof = await storeAdd.delegate({ issuer: alice, audience: bob, @@ -821,21 +790,21 @@ test('invalid claim / principal aligment', async () => { }) const result = await access(await invocation.delegate(), { + authority: w3, principal: ed25519.Verifier, capability: storeAdd, }) assert.containSubset(result, { name: 'Unauthorized', - cause: { - name: 'InvalidClaim', - message: `Claimed capability {"can":"store/add","with":"${alice.did()}","nb":${JSON.stringify( - nb - )}} is invalid - - Capability can not be (self) issued by '${mallory.did()}' - - Can not derive from prf:0 - ${proof.cid} because: - - Delegates to '${bob.did()}' instead of '${mallory.did()}'`, - }, + + message: `Claim ${storeAdd} is not authorized + - Capability {"can":"store/add","with":"${alice.did()}","nb":${JSON.stringify( + nb + )}} is not authorized because: + - Capability can not be (self) issued by '${mallory.did()}' + - Capability can not be derived from prf:0 - ${proof.cid} because: + - Delegation audience is '${bob.did()}' instead of '${mallory.did()}'`, }) }) @@ -858,20 +827,40 @@ test('invalid claim / invalid delegation chain', async () => { }) const result = await access(await invocation.delegate(), { + authority: w3, principal: ed25519.Verifier, capability: storeAdd, }) assert.containSubset(result, { + error: true, name: 'Unauthorized', - cause: { - name: 'InvalidClaim', - message: `Claimed capability {"can":"store/add","with":"${space.did()}","nb":${JSON.stringify( - nb - )}} is invalid - - Capability can not be (self) issued by '${bob.did()}' - - Can not derive from prf:0 - ${proof.cid} because: - - Delegates to '${w3.did()}' instead of '${bob.did()}'`, - }, + message: `Claim ${storeAdd} is not authorized + - Capability {"can":"store/add","with":"${space.did()}","nb":${JSON.stringify( + nb + )}} is not authorized because: + - Capability can not be (self) issued by '${bob.did()}' + - Capability can not be derived from prf:0 - ${proof.cid} because: + - Delegation audience is '${w3.did()}' instead of '${bob.did()}'`, + }) +}) + +test('claim without a proof', async () => { + const delegation = await storeAdd.delegate({ + issuer: alice, + audience: bob, + with: alice.did(), + }) + + const result = await claim(storeAdd, [delegation.cid], { + authority: w3, + principal: ed25519.Verifier, + }) + + assert.containSubset(result, { + name: 'Unauthorized', + + message: `Claim ${storeAdd} is not authorized + - Linked proof '${delegation.cid}' is not included and could not be resolved`, }) }) diff --git a/packages/validator/test/mailto.spec.js b/packages/validator/test/mailto.spec.js new file mode 100644 index 00000000..2a9f22e9 --- /dev/null +++ b/packages/validator/test/mailto.spec.js @@ -0,0 +1,195 @@ +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 claim = capability({ + can: 'access/claim', + with: DID.match({ method: 'mailto' }), +}) + +const update = capability({ + can: './update', + with: DID, + nb: { + key: DID.match({ method: 'key' }), + }, +}) + +test('validate mailto', async () => { + const account = alice.withDID('did:mailto:alice@web.mail') + + const auth = await update.delegate({ + issuer: w3, + audience: account, + with: w3.did(), + nb: { key: alice.did() }, + expiration: Infinity, + }) + + const inv = claim.invoke({ + audience: w3, + issuer: account, + with: account.did(), + expiration: Infinity, + proofs: [auth], + }) + + const result = await access(await inv.delegate(), { + authority: w3, + capability: claim, + principal: Verifier, + }) + + assert.containSubset(result, { + match: { + value: { + can: 'access/claim', + with: account.did(), + nb: {}, + }, + }, + }) +}) + +test('delegated ./update', async () => { + const account = alice.withDID('did:mailto:alice@web.mail') + const manager = await ed25519.generate() + const worker = await ed25519.generate() + + const authority = await Core.delegate({ + issuer: manager, + audience: worker, + capabilities: [ + { + with: w3.did(), + can: '*', + }, + ], + expiration: Infinity, + proofs: [ + await Core.delegate({ + issuer: w3, + audience: manager, + capabilities: [ + { + with: w3.did(), + can: '*', + }, + ], + }), + ], + }) + + const auth = await update.delegate({ + issuer: worker, + audience: account, + with: w3.did(), + nb: { key: alice.did() }, + proofs: [authority], + }) + + const request = claim.invoke({ + audience: w3, + issuer: account, + with: account.did(), + expiration: Infinity, + proofs: [auth], + }) + + const result = await access(await request.delegate(), { + authority: w3, + capability: claim, + principal: Verifier, + }) + + assert.containSubset(result, { + match: { + value: { + can: 'access/claim', + with: account.did(), + nb: {}, + }, + }, + }) +}) + +test('fail without ./update proof', async () => { + const account = alice.withDID('did:mailto:alice@web.mail') + + const inv = claim.invoke({ + audience: w3, + issuer: account, + with: account.did(), + }) + + const result = await access(await inv.delegate(), { + authority: w3, + capability: claim, + principal: Verifier, + }) + + assert.containSubset(result, { + error: true, + name: 'Unauthorized', + }) + + assert.match( + result.toString(), + /Unable to resolve 'did:mailto:alice@web.mail'/ + ) +}) + +test('fail invalid ./update proof', async () => { + const account = alice.withDID('did:mailto:alice@web.mail') + const service = await ed25519.generate() + + const auth = await update.delegate({ + issuer: service, + audience: account, + with: w3.did(), + nb: { key: alice.did() }, + proofs: [ + await Core.delegate({ + issuer: w3, + audience: service, + capabilities: [ + { + with: w3.toDIDKey(), + can: '*', + }, + ], + }), + ], + }) + + const request = claim.invoke({ + audience: w3, + issuer: account, + with: account.did(), + expiration: Infinity, + proofs: [auth], + }) + + const result = await access(await request.delegate(), { + authority: w3, + capability: claim, + principal: Verifier, + }) + + assert.containSubset(result, { + error: true, + name: 'Unauthorized', + }) + + assert.match( + result.toString(), + /did:web:web3.storage can not be derived from did:key/ + ) +})