UNPKG

@bedrock/vc-delivery

Version:
286 lines (262 loc) 8.47 kB
/*! * Copyright (c) 2022-2025 Digital Bazaar, Inc. All rights reserved. */ import * as bedrock from '@bedrock/core'; import * as EcdsaMultikey from '@digitalbazaar/ecdsa-multikey'; import * as Ed25519Multikey from '@digitalbazaar/ed25519-multikey'; import { createVerifyOptions, getZcapClient, stripStacktrace } from './helpers.js'; import {importJWK, jwtVerify} from 'jose'; import {compile} from '@bedrock/validation'; import {didIo} from '@bedrock/did-io'; const {util: {BedrockError}} = bedrock; // supported JWT algs const ECDSA_ALGS = ['ES256', 'ES384']; const EDDSA_ALGS = ['Ed25519', 'EdDSA']; export async function createChallenge({workflow} = {}) { // create zcap client for creating challenges const {zcapClient, zcaps} = await getZcapClient({workflow}); // create challenge const capability = zcaps.createChallenge; const {data: {challenge}} = await zcapClient.write({capability, json: {}}); return {challenge}; } export async function verify({ workflow, verifyPresentationOptions, verifiablePresentationRequest, presentation, allowUnprotectedPresentation = false, expectedChallenge, verifyPresentationResultSchema } = {}) { // create zcap client for verifying const {zcapClient, zcaps} = await getZcapClient({workflow}); // verify presentation const hasProof = presentation?.proof || presentation?.type === 'EnvelopedVerifiablePresentation'; const checks = (!hasProof && allowUnprotectedPresentation) ? [] : ['proof']; if(!expectedChallenge) { // if no expected challenge, rely on verifier for challenge management checks.push('challenge'); } const capability = zcaps.verifyPresentation; const domain = verifiablePresentationRequest.domain ?? new URL(workflow.id).origin; let result; try { const options = createVerifyOptions({ verifyPresentationOptions, expectedChallenge, verifiablePresentationRequest, presentation, domain, checks }); result = await zcapClient.write({ capability, json: { options, verifiablePresentation: presentation } }); } catch(cause) { if(!cause.data?.error) { throw new BedrockError(cause.data?.message || cause.message, { name: 'OperationError', cause, details: { httpStatusCode: cause.status ?? 500, public: true } }); } // sanitize error stack traces const {verified, credentialResults, presentationResult} = cause.data; if(credentialResults) { credentialResults.forEach(result => { if(result.error) { result.error = stripStacktrace(result.error); } }); } if(presentationResult?.error) { presentationResult.error = stripStacktrace(presentationResult.error); } // generate useful error to return to client const {name, errors, message} = cause.data.error; const causeError = stripStacktrace({...cause.data.error}); delete causeError.errors; const error = new BedrockError(message ?? 'Verification error.', { name: (name === 'VerificationError' || name === 'DataError') ? 'DataError' : 'OperationError', details: { error: causeError, verified, credentialResults, presentationResult, httpStatusCode: cause.status ?? 500, public: true } }); if(Array.isArray(errors)) { error.details.errors = errors.map(stripStacktrace); } throw error; } const { data: { verified, challengeUses, credentialResults, presentationResult } } = result; // `presentationResult` is `undefined` when `proof` check is not run const verificationMethod = presentationResult?.results[0] .verificationMethod ?? null; // validate against the verify presentation result schema, if applicable if(verifyPresentationResultSchema) { const {jsonSchema: schema} = verifyPresentationResultSchema; const validate = compile({schema}); const {valid, error} = validate(result.data); if(!valid) { throw error; } } return { verified, challengeUses, verificationMethod, credentialResults, presentationResult }; } export async function verifyDidProofJwt({workflow, exchange, jwt} = {}) { // optional oauth2 options const {oauth2} = exchange.openId; const {maxClockSkew} = oauth2; // audience is always the `exchangeId` and cannot be configured; this // prevents attacks where access tokens could otherwise be generated // if the AS keys were compromised; the `exchangeId` must also be known const exchangeId = `${workflow.id}/exchanges/${exchange.id}`; const audience = exchangeId; let issuer; // `resolveKey` is passed `protectedHeader` const resolveKey = async ({alg, kid}) => { const isEcdsa = ECDSA_ALGS.includes(alg); const isEddsa = !isEcdsa && EDDSA_ALGS.includes(alg); if(!(isEcdsa || isEddsa)) { throw new BedrockError( `Unsupported JWT "alg": "${alg}".`, { name: 'DataError', details: { httpStatusCode: 400, public: true } }); } const vm = await didIo.get({url: kid}); if(!vm) { throw new BedrockError( `Verification method identified by "kid" (${kid}) could not be ` + 'retrieved.', { name: 'DataError', details: { public: true, httpStatusCode: 400 } }); } // `vm.controller` must be the issuer of the DID JWT; also ensure that // the specified controller authorized `vm` for the purpose of // authentication issuer = vm.controller; const didDoc = await didIo.get({url: issuer}); let match = didDoc?.authentication?.find?.( e => e === vm.id || e.id === vm.id); if(typeof match === 'string') { match = didDoc?.verificationMethod?.find?.(e => e.id === vm.id); } if(!(match && (Array.isArray(match.controller) ? match.controller.includes(vm.controller) : match.controller === vm.controller))) { throw new BedrockError( `Verification method controller "${issuer}" did not authorize ` + `verification method "${vm.id}" for the purpose of "authentication".`, { name: 'NotAllowedError', details: { public: true, httpStatusCode: 400 } }); } let jwk; if(isEcdsa) { const keyPair = await EcdsaMultikey.from(vm); jwk = await EcdsaMultikey.toJwk({keyPair}); jwk.alg = alg; } else { const keyPair = await Ed25519Multikey.from(vm); jwk = await Ed25519Multikey.toJwk({keyPair}); jwk.alg = 'EdDSA'; } return importJWK(jwk); }; // FIXME: indicate supported signatures // const allowedAlgorithms = []; // use `jose` lib (for now) to verify JWT and return `payload`; // pass optional supported algorithms as allow list ... note // that `jose` *always* prohibits the `none` algorithm let verifyResult; try { // `jwtVerify` checks claims: `aud`, `exp`, `nbf` const {payload, protectedHeader} = await jwtVerify(jwt, resolveKey, { //algorithms: allowedAlgorithms, audience, clockTolerance: maxClockSkew }); verifyResult = {payload, protectedHeader}; } catch(e) { const details = { httpStatusCode: 403, public: true, code: e.code, reason: e.message }; if(e.claim) { details.claim = e.claim; } throw new BedrockError('DID proof JWT validation failed.', { name: 'NotAllowedError', details }); } // check `iss` claim if(!(issuer && verifyResult?.payload?.iss === issuer)) { throw new BedrockError('DID proof JWT validation failed.', { name: 'NotAllowedError', details: { httpStatusCode: 403, public: true, code: 'ERR_JWT_CLAIM_VALIDATION_FAILED', reason: 'unexpected "iss" claim value.', claim: 'iss' } }); } // check `nonce` claim if(verifyResult?.payload?.nonce !== exchange.id) { throw new BedrockError('DID proof JWT validation failed.', { name: 'NotAllowedError', details: { httpStatusCode: 403, public: true, code: 'ERR_JWT_CLAIM_VALIDATION_FAILED', reason: 'unexpected "nonce" claim value.', claim: 'nonce' } }); } return {verified: true, did: issuer, verifyResult}; }