diff --git a/packages/client/test/fixtures.js b/packages/client/test/fixtures.js index 678e9516..abfe0df8 100644 --- a/packages/client/test/fixtures.js +++ b/packages/client/test/fixtures.js @@ -1,6 +1,6 @@ import * as ed25519 from '@ucanto/principal/ed25519' -/** did:key:z6Mkqa4oY9Z5Pf5tUcjLHLUsDjKwMC95HGXdE1j22jkbhz6r */ +/** did:key:z6Mkk89bC3JrVqKie71YEcc5M1SMVxuCgNx6zLZ8SYJsxALi */ export const alice = ed25519.parse( 'MgCZT5vOnYZoVAeyjnzuJIVY9J4LNtJ+f8Js0cTPuKUpFne0BVEDJjEu6quFIU8yp91/TY/+MYK8GvlKoTDnqOCovCVM=' ) diff --git a/packages/core/src/delegation.js b/packages/core/src/delegation.js index b9a63f92..fe944c6e 100644 --- a/packages/core/src/delegation.js +++ b/packages/core/src/delegation.js @@ -1,6 +1,10 @@ import * as UCAN from '@ipld/dag-ucan' +import * as Signature from '@ipld/dag-ucan/signature' +import { from as toPrincipal } from '@ipld/dag-ucan/did' import * as API from '@ucanto/interface' import * as Link from './link.js' +import * as CBOR from '@ipld/dag-cbor' +import { sha256 } from 'multiformats/hashes/sha2' /** * @deprecated diff --git a/packages/core/test/fixtures.js b/packages/core/test/fixtures.js index 678e9516..abfe0df8 100644 --- a/packages/core/test/fixtures.js +++ b/packages/core/test/fixtures.js @@ -1,6 +1,6 @@ import * as ed25519 from '@ucanto/principal/ed25519' -/** did:key:z6Mkqa4oY9Z5Pf5tUcjLHLUsDjKwMC95HGXdE1j22jkbhz6r */ +/** did:key:z6Mkk89bC3JrVqKie71YEcc5M1SMVxuCgNx6zLZ8SYJsxALi */ export const alice = ed25519.parse( 'MgCZT5vOnYZoVAeyjnzuJIVY9J4LNtJ+f8Js0cTPuKUpFne0BVEDJjEu6quFIU8yp91/TY/+MYK8GvlKoTDnqOCovCVM=' ) diff --git a/packages/interface/src/capability.ts b/packages/interface/src/capability.ts index be8fad31..a816002f 100644 --- a/packages/interface/src/capability.ts +++ b/packages/interface/src/capability.ts @@ -18,7 +18,6 @@ import { export interface Source { capability: { can: Ability; with: URI; nb?: Caveats } delegation: Delegation - index: number } export interface Match @@ -207,13 +206,13 @@ export type InferCreateOptions = export type InferInvokeOptions< R extends Resource, C extends {} | undefined -> = UCANOptions & { issuer: Signer } & InferCreateOptions +> = UCANOptions & { issuer: UCAN.Signer } & InferCreateOptions export type InferDelegationOptions< R extends Resource, C extends {} | undefined > = UCANOptions & { - issuer: Signer + issuer: UCAN.Signer with: R nb?: Partial['nb']> } @@ -414,7 +413,7 @@ export interface DIDKeyResolutionError extends Failure { readonly name: 'DIDKeyResolutionError' readonly did: UCAN.DID - readonly cause?: Unauthorized + readonly cause?: Failure } export interface Expired extends Failure { @@ -436,6 +435,12 @@ export interface InvalidSignature extends Failure { readonly delegation: Delegation } +export interface SessionEscalation extends Failure { + readonly name: 'SessionEscalation' + readonly delegation: Delegation + readonly cause: Failure +} + /** * Error produces by invalid proof */ @@ -444,6 +449,7 @@ export type InvalidProof = | NotValidBefore | InvalidSignature | InvalidAudience + | SessionEscalation | DIDKeyResolutionError | UnavailableProof diff --git a/packages/interface/src/lib.ts b/packages/interface/src/lib.ts index 310694cc..53bb3fab 100644 --- a/packages/interface/src/lib.ts +++ b/packages/interface/src/lib.ts @@ -107,7 +107,7 @@ export interface DelegationOptions extends UCANOptions { * the `audience` {@link Principal}. * */ - issuer: Signer + issuer: UCAN.Signer /** * The `audience` for a {@link Delegation} is the party being delegated to, or the @@ -224,7 +224,7 @@ export interface Invocation export interface InvocationOptions extends UCANOptions { /** The `issuer` of an invocation is the "caller" of the RPC method and the party that signs the invocation UCAN token. */ - issuer: Signer + issuer: UCAN.Signer /** The {@link Capability} that is being invoked. */ capability: C diff --git a/packages/principal/package.json b/packages/principal/package.json index 75a6b7f0..7039e722 100644 --- a/packages/principal/package.json +++ b/packages/principal/package.json @@ -56,6 +56,9 @@ ], "rsa": [ "dist/src/rsa.d.ts" + ], + "account": [ + "dist/src/account.d.ts" ] } }, @@ -71,6 +74,10 @@ "./rsa": { "types": "./dist/src/rsa.d.ts", "import": "./src/rsa.js" + }, + "./account": { + "types": "./dist/src/account.d.ts", + "import": "./src/account.js" } }, "c8": { diff --git a/packages/principal/src/absentee.js b/packages/principal/src/absentee.js new file mode 100644 index 00000000..db550554 --- /dev/null +++ b/packages/principal/src/absentee.js @@ -0,0 +1,41 @@ +import * as Signature from '@ipld/dag-ucan/signature' +import * as UCAN from '@ipld/dag-ucan' + +/** + * @template {UCAN.DID} ID + * @param {{id: ID }} id + * @returns {UCAN.Signer} + */ +export const from = ({ id }) => new Absentee(id) + +/** + * An absentee is a special type of signer that produces an absent signature, + * which signals that verifier needs to verify authorization interactively. + * + * @template {UCAN.DID} ID + * @implements {UCAN.Signer} + */ +class Absentee { + /** + * @param {ID} id + */ + constructor(id) { + this.id = id + } + did() { + return this.id + } + /* c8 ignore next 3 */ + get signatureCode() { + return Signature.NON_STANDARD + } + get signatureAlgorithm() { + return '' + } + sign() { + return Signature.createNonStandard( + this.signatureAlgorithm, + new Uint8Array(0) + ) + } +} diff --git a/packages/principal/src/ed25519/signer.js b/packages/principal/src/ed25519/signer.js index 08769eee..ed188de8 100644 --- a/packages/principal/src/ed25519/signer.js +++ b/packages/principal/src/ed25519/signer.js @@ -65,7 +65,6 @@ export const from = ({ id, keys }) => { throw new TypeError(`Unsupported archive format`) } -from /** * @template {API.SignerImporter} O * @param {O} other diff --git a/packages/principal/src/lib.js b/packages/principal/src/lib.js index 91403ef4..53450ee7 100644 --- a/packages/principal/src/lib.js +++ b/packages/principal/src/lib.js @@ -1,7 +1,8 @@ import * as ed25519 from './ed25519.js' import * as RSA from './rsa.js' +import * as Absentee from './absentee.js' export const Verifier = ed25519.Verifier.or(RSA.Verifier) export const Signer = ed25519.or(RSA) -export { ed25519, RSA } +export { ed25519, RSA, Absentee } diff --git a/packages/principal/test/absentee.spec.js b/packages/principal/test/absentee.spec.js new file mode 100644 index 00000000..ce562940 --- /dev/null +++ b/packages/principal/test/absentee.spec.js @@ -0,0 +1,17 @@ +import { Absentee } from '../src/lib.js' +import { assert } from 'chai' + +export const utf8 = new TextEncoder() +describe('Absentee', async () => { + it('it can sign', async () => { + const absentee = Absentee.from({ id: 'did:mailto:web.mail:alice' }) + assert.deepEqual(absentee.did(), 'did:mailto:web.mail:alice') + assert.deepEqual(absentee.signatureAlgorithm, '') + assert.deepEqual(absentee.signatureCode, 0xd000) + + const signature = await absentee.sign(utf8.encode('hello world')) + assert.deepEqual(signature.code, 0xd000) + assert.deepEqual(signature.algorithm, '') + assert.deepEqual(signature.raw, new Uint8Array()) + }) +}) diff --git a/packages/server/test/fixtures.js b/packages/server/test/fixtures.js index 378348c1..357cce41 100644 --- a/packages/server/test/fixtures.js +++ b/packages/server/test/fixtures.js @@ -1,6 +1,6 @@ import * as ed25519 from '@ucanto/principal/ed25519' -/** did:key:z6Mkqa4oY9Z5Pf5tUcjLHLUsDjKwMC95HGXdE1j22jkbhz6r */ +/** did:key:z6Mkk89bC3JrVqKie71YEcc5M1SMVxuCgNx6zLZ8SYJsxALi */ export const alice = ed25519.parse( 'MgCZT5vOnYZoVAeyjnzuJIVY9J4LNtJ+f8Js0cTPuKUpFne0BVEDJjEu6quFIU8yp91/TY/+MYK8GvlKoTDnqOCovCVM=' ) diff --git a/packages/transport/test/fixtures.js b/packages/transport/test/fixtures.js index 678e9516..abfe0df8 100644 --- a/packages/transport/test/fixtures.js +++ b/packages/transport/test/fixtures.js @@ -1,6 +1,6 @@ import * as ed25519 from '@ucanto/principal/ed25519' -/** did:key:z6Mkqa4oY9Z5Pf5tUcjLHLUsDjKwMC95HGXdE1j22jkbhz6r */ +/** did:key:z6Mkk89bC3JrVqKie71YEcc5M1SMVxuCgNx6zLZ8SYJsxALi */ export const alice = ed25519.parse( 'MgCZT5vOnYZoVAeyjnzuJIVY9J4LNtJ+f8Js0cTPuKUpFne0BVEDJjEu6quFIU8yp91/TY/+MYK8GvlKoTDnqOCovCVM=' ) diff --git a/packages/validator/src/error.js b/packages/validator/src/error.js index 82d2c000..4eb94b5d 100644 --- a/packages/validator/src/error.js +++ b/packages/validator/src/error.js @@ -79,6 +79,30 @@ export class DelegationError extends Failure { } } +/** + * @implements {API.SessionEscalation} + */ +export class SessionEscalation extends Failure { + /** + * @param {object} source + * @param {API.Delegation} source.delegation + * @param {API.Failure} source.cause + */ + constructor({ delegation, cause }) { + super() + this.name = the('SessionEscalation') + this.delegation = delegation + this.cause = cause + } + describe() { + const issuer = this.delegation.issuer.did() + return [ + `Delegation ${this.delegation.cid} issued by ${issuer} has an invalid session`, + li(this.cause.message), + ].join('\n') + } +} + /** * @implements {API.InvalidSignature} */ @@ -145,7 +169,7 @@ export class UnavailableProof extends Failure { export class DIDKeyResolutionError extends Failure { /** * @param {API.UCAN.DID} did - * @param {API.Unauthorized} [cause] + * @param {API.Failure} [cause] */ constructor(did, cause) { super() @@ -154,10 +178,7 @@ export class DIDKeyResolutionError extends Failure { this.cause = cause } describe() { - return [ - `Unable to resolve '${this.did}' key`, - ...(this.cause ? [li(`Resolution failed: ${this.cause.message}`)] : []), - ].join('\n') + return `Unable to resolve '${this.did}' key` } } diff --git a/packages/validator/src/lib.js b/packages/validator/src/lib.js index f261a89f..a86a82c1 100644 --- a/packages/validator/src/lib.js +++ b/packages/validator/src/lib.js @@ -1,5 +1,5 @@ import * as API from '@ucanto/interface' -import { isDelegation, UCAN } from '@ucanto/core' +import { isDelegation, Delegation, UCAN } from '@ucanto/core' import { capability } from './capability.js' import * as Schema from './schema.js' import { @@ -8,6 +8,7 @@ import { Expired, NotValidBefore, InvalidSignature, + SessionEscalation, DelegationError, Failure, MalformedCapability, @@ -23,7 +24,6 @@ export { } export { capability } from './capability.js' -import { DID } from './schema.js' export * from './schema.js' export { Schema } @@ -65,38 +65,62 @@ const resolveMatch = async (match, config) => { } /** + * Takes `proofs` from the delegation which may contain `Delegation` or a link + * to one and attempts to resolve links by side loading them. Returns set of + * resolved `Delegation`s and errors for the proofs that could not be resolved. + * * @param {API.Proof[]} proofs * @param {Required} config */ const resolveProofs = async (proofs, config) => { - /** @type {API.Result[]} */ + /** @type {API.Delegation[]} */ const delegations = [] + /** @type {API.UnavailableProof[]} */ + const errors = [] const promises = [] - for (const [index, proof] of proofs.entries()) { - if (!isDelegation(proof)) { + for (const proof of proofs) { + // If it is a delegation we can just add it to the resolved set. + if (isDelegation(proof)) { + delegations.push(proof) + } + // otherwise we attempt to resolve the link asynchronously. To avoid doing + // sequential requests we create promise for each link and then wait for + // all of them at the end. + else { promises.push( new Promise(async resolve => { + // config.resolve is not supposed to throw, but we catch it just in + // case it does and consider proof resolution failed. try { - delegations[index] = await config.resolve(proof) + const result = await config.resolve(proof) + if (result.error) { + errors.push(result) + } else { + delegations.push(result) + } } catch (error) { - delegations[index] = new UnavailableProof( - proof, - /** @type {Error} */ (error) + errors.push( + new UnavailableProof(proof, /** @type {Error} */ (error)) ) } + + // we don't care about the result, we just need to signal that we are + // done with this promise. resolve(null) }) ) - } else { - delegations[index] = proof } } + // Wait for all the promises to resolve. At this point we have collected all + // the resolved delegations and errors. await Promise.all(promises) - return delegations + return { delegations, errors } } /** + * Takes a delegation source and attempts to resolve all the linked proofs. + * * @param {API.Source} from * @param {Required} config * @return {Promise<{sources:API.Source[], errors:ProofError[]}>} @@ -104,41 +128,48 @@ const resolveProofs = async (proofs, config) => { const resolveSources = async ({ delegation }, config) => { const errors = [] const sources = [] - // resolve all the proofs that can be side-loaded - 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)) + const proofs = [] + // First we attempt to resolve all the linked proofs. + const { delegations, errors: failedProofs } = await resolveProofs( + delegation.proofs, + config + ) + + // All the proofs that failed to resolve are saved as proof errors. + for (const error of failedProofs) { + errors.push(new ProofError(error.link, error)) + } + + // All the proofs that resolved are checked for principal alignment. Ones that + // do not align are saved as proof errors. + for (const proof of delegations) { + // If proof does not delegate to a matching audience save an proof error. + if (delegation.issuer.did() !== proof.audience.did()) { + errors.push( + new ProofError(proof.cid, new InvalidAudience(delegation.issuer, proof)) + ) } else { - // If proof does not delegate to a matching audience save an proof error. - if (delegation.issuer.did() !== proof.audience.did()) { - errors.push( - new ProofError( - proof.cid, - index, - new InvalidAudience(delegation.issuer, proof) - ) + proofs.push(proof) + } + } + + // In the second pass we attempt to proofs that were resolved and are aligned. + for (const proof of proofs) { + // If proof is not valid (expired, not active yet or has incorrect + // signature) save a corresponding proof error. + const validation = await validate(proof, proofs, config) + if (validation.error) { + errors.push(new ProofError(proof.cid, validation)) + } else { + // otherwise create source objects for it's capabilities, so we could + // track which proof in which capability the are from. + for (const capability of proof.capabilities) { + sources.push( + /** @type {API.Source} */ ({ + capability, + delegation: proof, + }) ) - } else { - // If proof is not valid (expired, not active yet or has incorrect - // signature) save a corresponding proof error. - const validation = await validate(proof, config) - if (validation.error) { - errors.push(new ProofError(proof.cid, index, validation)) - } else { - // otherwise create source objects for it's capabilities, so we could - // track which proof in which capability the are from. - for (const capability of proof.capabilities) { - sources.push( - /** @type {API.Source} */ ({ - capability, - delegation: proof, - index, - }) - ) - } - } } } } @@ -207,9 +238,14 @@ export const claim = async ( /** @type {API.Source[]} */ const sources = [] - for (const proof of await resolveProofs(proofs, config)) { - const delegation = proof.error ? proof : await validate(proof, config) + const { delegations, errors } = await resolveProofs(proofs, config) + invalidProofs.push(...errors) + + for (const proof of delegations) { + // Validate each proof if valid add ech capability to the list of sources. + // otherwise collect the error. + const delegation = await validate(proof, delegations, config) if (!delegation.error) { for (const [index, capability] of delegation.capabilities.entries()) { sources.push( @@ -324,19 +360,17 @@ export const authorize = async (match, config) => { class ProofError extends Failure { /** * @param {API.UCANLink} proof - * @param {number} index * @param {API.Failure} cause */ - constructor(proof, index, cause) { + constructor(proof, cause) { super() this.name = 'ProofError' this.proof = proof - this.index = index this.cause = cause } describe() { return [ - `Capability can not be derived from prf:${this.index} - ${this.proof} because:`, + `Capability can not be derived from prf:${this.proof} because:`, li(this.cause.message), ].join(`\n`) } @@ -446,12 +480,16 @@ class Unauthorized extends Failure { } /** + * Validate a delegation to check it is within the time bound and that it is + * authorized by the issuer. + * * @template {API.Delegation} T * @param {T} delegation + * @param {API.Delegation[]} proofs * @param {Required} config - * @returns {Promise>} + * @returns {Promise>} */ -const validate = async (delegation, config) => { +const validate = async (delegation, proofs, config) => { if (UCAN.isExpired(delegation.data)) { return new Expired( /** @type {API.Delegation & {expiration: number}} */ (delegation) @@ -464,87 +502,95 @@ const validate = async (delegation, config) => { ) } - return await verifySignature(delegation, config) + return await verifyAuthorization(delegation, proofs, config) } /** + * Verifies that delegation has been authorized by the issuer. If issued by the + * did:key principal checks that the signature is valid. If issued by the root + * authority checks that the signature is valid. If issued by the principal + * identified by other DID method attempts to resolve a valid `ucan/attest` + * attestation from the authority, if attestation is not found falls back to + * resolving did:key for the issuer and verifying its signature. + * * @template {API.Delegation} T * @param {T} delegation + * @param {API.Delegation[]} proofs * @param {Required} config - * @returns {Promise>} + * @returns {Promise>} */ -const verifySignature = async (delegation, config) => { - const did = delegation.issuer.did() - const verifier = await resolveVerifier(did, delegation, config) - - if (verifier.error) { - return verifier +const verifyAuthorization = async (delegation, proofs, config) => { + const issuer = delegation.issuer.did() + // If the issuer is a did:key we just verify a signature + if (issuer.startsWith('did:key:')) { + return verifySignature(delegation, config.principal.parse(issuer)) + } + // If the issuer is the root authority we use authority itself to verify + else if (issuer === config.authority.did()) { + return verifySignature(delegation, config.authority) + } else { + // If issuer is not a did:key principal nor configured authority, we + // attempt to resolve embedded authorization session from the authority. + const session = await verifySession(delegation, proofs, config) + // If we have valid session we consider authorization valid + if (!session.error) { + return delegation + } else if (session.failedProofs.length > 0) { + return new SessionEscalation({ delegation, cause: session }) + } + // Otherwise we try to resolve did:key from the DID instead + // and use that to verify the signature + else { + const verifier = await config.resolveDIDKey(issuer) + if (verifier.error) { + return verifier + } else { + return verifySignature( + delegation, + config.principal.parse(verifier).withDID(issuer) + ) + } + } } - - const valid = await UCAN.verifySignature(delegation.data, verifier) - return valid ? delegation : new InvalidSignature(delegation, verifier) } /** - * @param {API.DID} did - * @param {API.Delegation} delegation - * @param {Required} config - * @returns {Promise>} + * @template {API.Delegation} T + * @param {T} delegation + * @param {API.Verifier} verifier + * @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) - } +const verifySignature = async (delegation, verifier) => { + const valid = await UCAN.verifySignature(delegation.data, verifier) + return valid ? delegation : new InvalidSignature(delegation, verifier) } /** - * @param {API.DID} did + * Attempts to find an authorization session - an `ucan/attest` capability + * delegation where `with` matches `config.authority` and `nb.proof` + * matches given delegation. + * @see https://github.com/web3-storage/specs/blob/feat/auth+account/w3-session.md#authorization-session + * * @param {API.Delegation} delegation + * @param {API.Delegation[]} proofs * @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: Schema.struct({ key: DID.match({ method: 'key' }) }), +const verifySession = async (delegation, proofs, config) => { + // Create a schema that will match an authorization for this exact delegation + const attestation = capability({ + with: Schema.literal(config.authority.did()), + can: 'ucan/attest', + nb: Schema.struct({ + proof: Schema.link(delegation.cid), }), - derives: equalWith, }) - const result = await claim(update, delegation.proofs, config) - return !result.error - ? result.match.value.nb.key - : new DIDKeyResolutionError(did, result) + return await claim( + attestation, + // We omit the delegation otherwise we may end up in an infinite loop + proofs.filter(proof => proof != delegation), + config + ) } -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/src/schema.js b/packages/validator/src/schema.js index e2a5e547..0bfecbfe 100644 --- a/packages/validator/src/schema.js +++ b/packages/validator/src/schema.js @@ -3,3 +3,7 @@ export * as Link from './schema/link.js' export * as DID from './schema/did.js' export * as Text from './schema/text.js' export * from './schema/schema.js' +export { match as link } from './schema/link.js' +export { match as did } from './schema/did.js' +export { match as uri } from './schema/uri.js' +export { match as text } from './schema/text.js' diff --git a/packages/validator/src/schema/did.js b/packages/validator/src/schema/did.js index 42ee8bff..752f0f9f 100644 --- a/packages/validator/src/schema/did.js +++ b/packages/validator/src/schema/did.js @@ -33,7 +33,7 @@ export const read = input => schema.read(input) * @template {string} Method * @param {{method?: Method}} options */ -export const match = options => +export const match = (options = {}) => /** @type {Schema.Schema & API.URI<"did:">>} */ ( Schema.string().refine(new DIDSchema(options.method)) ) diff --git a/packages/validator/src/schema/link.js b/packages/validator/src/schema/link.js index 80dd34c1..2d91d351 100644 --- a/packages/validator/src/schema/link.js +++ b/packages/validator/src/schema/link.js @@ -1,5 +1,6 @@ import * as API from '@ucanto/interface' import { create, createLegacy, isLink, parse } from '@ucanto/core/link' +import { base32 } from 'multiformats/bases/base32' import * as Schema from './schema.js' export { create, createLegacy, isLink, parse } @@ -8,7 +9,11 @@ export { create, createLegacy, isLink, parse } * @template {number} [Code=number] * @template {number} [Alg=number] * @template {1|0} [Version=0|1] - * @typedef {{code?:Code, algorithm?:Alg, version?:Version}} Settings + * @typedef {{ + * code?:Code, + * version?:Version + * multihash?: {code?: Alg, digest?: Uint8Array} + * }} Settings */ /** @@ -24,7 +29,7 @@ class LinkSchema extends Schema.API { * @param {Settings} settings * @returns {Schema.ReadResult>} */ - readWith(cid, { code, algorithm, version }) { + readWith(cid, { code, multihash = {}, version }) { if (cid == null) { return Schema.error(`Expected link but got ${cid} instead`) } else { @@ -36,13 +41,13 @@ class LinkSchema extends Schema.API { `Expected link to be CID with 0x${code.toString(16)} codec` ) } - if (algorithm != null && cid.multihash.code !== algorithm) { + + if (multihash.code != null && cid.multihash.code !== multihash.code) return Schema.error( - `Expected link to be CID with 0x${algorithm.toString( + `Expected link to be CID with 0x${multihash.code.toString( 16 )} hashing algorithm` ) - } if (version != null && cid.version !== version) { return Schema.error( @@ -50,6 +55,20 @@ class LinkSchema extends Schema.API { ) } + const [expectDigest, actualDigest] = + multihash.digest != null + ? [ + base32.baseEncode(multihash.digest), + base32.baseEncode(cid.multihash.digest), + ] + : ['', ''] + + if (expectDigest !== actualDigest) { + return Schema.error( + `Expected link with "${expectDigest}" hash digest instead of "${actualDigest}"` + ) + } + // @ts-expect-error - can't infer version, code etc. return cid } diff --git a/packages/validator/src/schema/text.js b/packages/validator/src/schema/text.js index 546950bb..78cd1cab 100644 --- a/packages/validator/src/schema/text.js +++ b/packages/validator/src/schema/text.js @@ -2,12 +2,13 @@ import * as Schema from './schema.js' const schema = Schema.string() -export const text = () => schema - /** - * @param {{pattern: RegExp}} options + * @param {{pattern: RegExp}} [options] */ -export const match = ({ pattern }) => schema.refine(new Match(pattern)) +export const match = options => + options ? schema.refine(new Match(options.pattern)) : schema + +export const text = match /** * @param {unknown} input diff --git a/packages/validator/test/extra-schema.spec.js b/packages/validator/test/extra-schema.spec.js index c722b05a..a71005fb 100644 --- a/packages/validator/test/extra-schema.spec.js +++ b/packages/validator/test/extra-schema.spec.js @@ -158,7 +158,7 @@ test('URI.from', () => { }) test(`Link.match({ algorithm: 0x12 }).read(${input})`, () => { - const link = Link.match({ algorithm: 0x12 }) + const link = Link.match({ multihash: { code: 0x12 } }) assert.containSubset(link.read(input), out3 || input) }) diff --git a/packages/validator/test/fixtures.js b/packages/validator/test/fixtures.js index 678e9516..abfe0df8 100644 --- a/packages/validator/test/fixtures.js +++ b/packages/validator/test/fixtures.js @@ -1,6 +1,6 @@ import * as ed25519 from '@ucanto/principal/ed25519' -/** did:key:z6Mkqa4oY9Z5Pf5tUcjLHLUsDjKwMC95HGXdE1j22jkbhz6r */ +/** did:key:z6Mkk89bC3JrVqKie71YEcc5M1SMVxuCgNx6zLZ8SYJsxALi */ export const alice = ed25519.parse( 'MgCZT5vOnYZoVAeyjnzuJIVY9J4LNtJ+f8Js0cTPuKUpFne0BVEDJjEu6quFIU8yp91/TY/+MYK8GvlKoTDnqOCovCVM=' ) diff --git a/packages/validator/test/lib.spec.js b/packages/validator/test/lib.spec.js index 9f239cf1..6d12bb69 100644 --- a/packages/validator/test/lib.spec.js +++ b/packages/validator/test/lib.spec.js @@ -352,7 +352,7 @@ test('invalid claim / expired', async () => { { 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: + - Capability can not be derived from prf:${delegation.cid} because: - Proof ${delegation.cid} has expired on ${new Date(expiration * 1000)}`, }) }) @@ -391,7 +391,7 @@ test('invalid claim / not valid before', async () => { { 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: + - Capability can not be derived from prf:${proof.cid} because: - Proof ${proof.cid} is not valid before ${new Date(notBefore * 1000)}`, }) }) @@ -430,7 +430,7 @@ test('invalid claim / invalid signature', async () => { { 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: + - Capability can not be derived from prf:${proof.cid} because: - Proof ${proof.cid} does not has a valid signature from ${alice.did()}`, }) }) @@ -553,7 +553,7 @@ test('invalid claim / unavailable proof', async () => { 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: + - Capability can not be derived from prf:${delegation.cid} because: - Linked proof '${ delegation.cid }' is not included and could not be resolved`, @@ -593,7 +593,7 @@ test('invalid claim / failed to resolve', async () => { 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: + - Capability can not be derived from prf:${delegation.cid} because: - Linked proof '${ delegation.cid }' is not included and could not be resolved @@ -631,7 +631,7 @@ test('invalid claim / invalid audience', async () => { 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: + - Capability can not be derived from prf:${delegation.cid} because: - Delegation audience is '${bob.did()}' instead of '${mallory.did()}'`, }) }) @@ -805,7 +805,7 @@ test('invalid claim / principal alignment', async () => { 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: + - Capability can not be derived from prf:${proof.cid} because: - Delegation audience is '${bob.did()}' instead of '${mallory.did()}'`, }) }) @@ -842,7 +842,7 @@ test('invalid claim / invalid delegation chain', async () => { 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: + - Capability can not be derived from prf:${proof.cid} because: - Delegation audience is '${w3.did()}' instead of '${bob.did()}'`, }) }) diff --git a/packages/validator/test/link-schema.spec.js b/packages/validator/test/link-schema.spec.js new file mode 100644 index 00000000..b9daa885 --- /dev/null +++ b/packages/validator/test/link-schema.spec.js @@ -0,0 +1,89 @@ +import * as Schema from '../src/schema.js' +import { base36 } from 'multiformats/bases/base36' +import { test, assert } from './test.js' + +const fixtures = { + pb: Schema.Link.parse('QmTgnQBKj7eTV7ohraBCmh1DLwerUd2X9Rxzgf3gyMJbC8'), + cbor: Schema.Link.parse( + 'bafyreieuo63r3y2nuycaq4b3q2xvco3nprlxiwzcfp4cuupgaywat3z6mq' + ), + rawIdentity: Schema.Link.parse('bafkqaaa'), + ipns: Schema.Link.parse( + 'k2k4r8kuj2bs2l996lhjx8rc727xlvthtak8o6eia3qm5adxvs5k84gf', + base36 + ), + sha512: Schema.Link.parse( + 'kgbuwaen1jrbjip6iwe9mqg54spvuucyz7f5jho2tkc2o0c7xzqwpxtogbyrwck57s9is6zqlwt9rsxbuvszym10nbaxt9jn7sf4eksqd', + base36 + ), +} + +const links = Object.values(fixtures) +const versions = new Set(links.map(link => link.version)) +const codes = new Set(links.map(link => link.code)) +const algs = new Set(links.map(link => link.multihash.code)) +const digests = new Set(links.map(link => link.multihash.digest)) + +for (const link of links) { + test(`${link} ➡ Schema.link()`, () => { + assert.deepEqual(Schema.link().read(link), link, `${link}`) + }) + + for (const version of versions) { + test(`${link} ➡ Schema.link({ version: ${version}})`, () => { + const schema = Schema.link({ version }) + if (link.version === version) { + assert.deepEqual(schema.read(link), link) + } else { + assert.match( + schema.read(link).toString(), + /Expected link to be CID version/ + ) + } + }) + } + + for (const code of codes) { + test(`${link} ➡ Schema.link({ code: ${code}})`, () => { + const schema = Schema.link({ code }) + if (link.code === code) { + assert.deepEqual(schema.read(link), link) + } else { + assert.match( + schema.read(link).toString(), + /Expected link to be CID with .* codec/ + ) + } + }) + } + + for (const code of algs) { + test(`${link} ➡ Schema.link({ multihash: {code: ${code}} })`, () => { + const schema = Schema.link({ multihash: { code } }) + if (link.multihash.code === code) { + assert.deepEqual(schema.read(link), link) + } else { + assert.match( + schema.read(link).toString(), + /Expected link to be CID with .* hashing algorithm/ + ) + } + }) + } + + for (const digest of digests) { + test(`${link} ➡ Schema.link({ multihash: {digest} })`, () => { + const schema = Schema.link({ + multihash: { digest: new Uint8Array(digest) }, + }) + if (link.multihash.digest === digest) { + assert.deepEqual(schema.read(link), link) + } else { + assert.match( + schema.read(link).toString(), + /Expected link with .* hash digest/ + ) + } + }) + } +} diff --git a/packages/validator/test/mailto.spec.js b/packages/validator/test/mailto.spec.js deleted file mode 100644 index 34dade05..00000000 --- a/packages/validator/test/mailto.spec.js +++ /dev/null @@ -1,195 +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 claim = capability({ - can: 'access/claim', - with: DID.match({ method: 'mailto' }), -}) - -const update = capability({ - can: './update', - with: DID, - nb: Schema.struct({ - 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/ - ) -}) diff --git a/packages/validator/test/session.spec.js b/packages/validator/test/session.spec.js new file mode 100644 index 00000000..ec24be39 --- /dev/null +++ b/packages/validator/test/session.spec.js @@ -0,0 +1,312 @@ +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, Absentee } from '@ucanto/principal' +import * as Client from '@ucanto/client' +import * as Core from '@ucanto/core' +import * as CBOR from '@ipld/dag-cbor' +import { Delegation } from '@ucanto/core' +import { base64 } from 'multiformats/bases/base64' + +import { alice, bob, mallory, service } from './fixtures.js' +const w3 = service.withDID('did:web:web3.storage') + +const echo = capability({ + can: 'debug/echo', + with: DID.match({ method: 'mailto' }), + nb: Schema.struct({ + message: Schema.string().optional(), + }), +}) + +const attest = capability({ + can: 'ucan/attest', + with: DID, + nb: Schema.struct({ + proof: Schema.link(), + }), +}) + +test('validate mailto', async () => { + const agent = alice + const account = Absentee.from({ id: 'did:mailto:web.mail:alice' }) + + const proof = await Delegation.delegate({ + issuer: account, + audience: agent, + capabilities: [echo.create({ with: account.did(), nb: {} })], + expiration: Infinity, + }) + + const session = await attest.delegate({ + issuer: w3, + audience: agent, + with: w3.did(), + nb: { proof: proof.cid }, + expiration: Infinity, + }) + + const task = echo.invoke({ + issuer: agent, + audience: w3, + with: account.did(), + nb: { message: 'hello world' }, + proofs: [proof, session], + expiration: Infinity, + }) + + const result = await access(await task.delegate(), { + authority: w3, + capability: echo, + principal: Verifier, + }) + + assert.containSubset(result, { + match: { + value: { + can: 'debug/echo', + with: account.did(), + nb: { + message: 'hello world', + }, + }, + }, + }) +}) + +test('delegated ucan/attest', async () => { + const account = Absentee.from({ id: 'did:mailto:web.mail:alice' }) + const agent = alice + 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 proof = await Delegation.delegate({ + issuer: account, + audience: agent, + capabilities: [echo.create({ with: account.did(), nb: {} })], + expiration: Infinity, + }) + + assert.deepEqual( + proof.signature, + base64.baseDecode('gKADAA'), + 'should have blank signature' + ) + + const session = await Delegation.delegate({ + issuer: worker, + audience: agent, + capabilities: [ + { + with: w3.did(), + can: 'ucan/attest', + nb: { proof: proof.cid }, + }, + ], + proofs: [authority], + }) + + const request = echo.invoke({ + audience: w3, + issuer: agent, + with: account.did(), + nb: { message: 'hello world' }, + proofs: [session, proof], + }) + + const result = await access(await request.delegate(), { + authority: w3, + capability: echo, + principal: Verifier, + }) + + assert.containSubset(result, { + match: { + value: { + can: 'debug/echo', + with: account.did(), + nb: { + message: 'hello world', + }, + }, + }, + }) +}) + +test('fail without proofs', async () => { + const account = Absentee.from({ id: 'did:mailto:web.mail:alice' }) + + const inv = echo.invoke({ + audience: w3, + issuer: account, + with: account.did(), + nb: { message: 'hello world' }, + }) + + const result = await access(await inv.delegate(), { + authority: w3, + capability: echo, + principal: Verifier, + }) + + assert.containSubset(result, { + error: true, + name: 'Unauthorized', + }) + + assert.match( + result.toString(), + /Unable to resolve 'did:mailto:web.mail:alice'/ + ) +}) + +test('fail without session', async () => { + const account = Absentee.from({ id: 'did:mailto:web.mail:alice' }) + const agent = alice + + const proof = await Delegation.delegate({ + issuer: account, + audience: agent, + capabilities: [echo.create({ with: account.did(), nb: {} })], + expiration: Infinity, + }) + + const inv = echo.invoke({ + audience: w3, + issuer: account, + with: account.did(), + nb: { message: 'hello world' }, + proofs: [proof], + }) + + const result = await access(await inv.delegate(), { + authority: w3, + capability: echo, + principal: Verifier, + }) + + assert.containSubset(result, { + error: true, + name: 'Unauthorized', + }) + + assert.match( + result.toString(), + /Unable to resolve 'did:mailto:web.mail:alice'/ + ) +}) + +test('fail invalid ucan/attest proof', async () => { + const account = Absentee.from({ id: 'did:mailto:web.mail:alice' }) + const agent = alice + const service = await ed25519.generate() + + const proof = await Delegation.delegate({ + issuer: account, + audience: agent, + capabilities: [echo.create({ with: account.did(), nb: {} })], + expiration: Infinity, + }) + + const session = await Delegation.delegate({ + issuer: service, + audience: agent, + capabilities: [ + { + with: w3.did(), + can: 'ucan/attest', + nb: { proof: proof.cid }, + }, + ], + proofs: [ + await Core.delegate({ + issuer: w3, + audience: service, + capabilities: [ + { + // Noting that this is a DID key, not did:web:web3.storage + // which is why session is invalid + with: w3.toDIDKey(), + can: '*', + }, + ], + }), + ], + }) + + const request = echo.invoke({ + audience: w3, + issuer: agent, + with: account.did(), + nb: { message: 'hello world' }, + proofs: [proof, session], + }) + + const result = await access(await request.delegate(), { + authority: w3, + capability: echo, + principal: Verifier, + }) + + assert.containSubset(result, { + error: true, + name: 'Unauthorized', + }) + + assert.match(result.toString(), /has an invalid session/) +}) + +test('resolve key', async () => { + const account = alice.withDID('did:mailto:web.mail:alice') + + const inv = echo.invoke({ + audience: w3, + issuer: account, + with: account.did(), + nb: { message: 'hello world' }, + }) + + const result = await access(await inv.delegate(), { + authority: w3, + capability: echo, + resolveDIDKey: _ => alice.did(), + principal: Verifier, + }) + + assert.containSubset(result, { + match: { + value: { + can: 'debug/echo', + with: account.did(), + nb: { + message: 'hello world', + }, + }, + }, + }) +})