UNPKG

@bedrock/vc-delivery

Version:
427 lines (394 loc) 11.9 kB
/*! * Copyright (c) 2022-2026 Digital Bazaar, Inc. All rights reserved. */ import * as bedrock from '@bedrock/core'; import {decodeId, generateId} from 'bnid'; import {compile} from '@bedrock/validation'; import {Ed25519Signature2020} from '@digitalbazaar/ed25519-signature-2020'; import {httpClient} from '@digitalbazaar/http-client'; import {httpsAgent} from '@bedrock/https-agent'; import jsonata from 'jsonata'; import jsonpointer from 'json-pointer'; import {logger} from './logger.js'; import {serializeError} from 'serialize-error'; import {serviceAgents} from '@bedrock/service-agent'; import {ZcapClient} from '@digitalbazaar/ezcap'; const {config, util: {BedrockError}} = bedrock; const ALLOWED_ERROR_KEYS = [ 'message', 'name', 'type', 'data', 'errors', 'error', 'details', 'cause', 'status' ]; // media type and format support constants export const SUPPORTED_FORMAT_TO_MEDIA_TYPE = new Map([ ['application/vc', 'application/vc'], ['ldp_vc', 'application/vc'], ['jwt_vc_json', 'application/jwt'] ]); // create reverse map export const SUPPORTED_MEDIA_TYPE_TO_FORMAT = new Map([ ['application/vc', 'ldp_vc'], ['application/jwt', 'jwt_vc_json'] ]); export function buildPresentationFromResults({ presentation, verifyResult }) { // build VP w/all envelopes removed (if any) const vp = { ...(verifyResult?.presentationResult?.presentation ?? presentation) }; let credentials = vp.verifiableCredential; const {credentialResults} = verifyResult; if(credentials && credentialResults) { if(!Array.isArray(credentials)) { credentials = [credentials]; } const hasEnvelopedVC = credentials.some( vc => vc.type === 'EnvelopedVerifiableCredential'); if(!hasEnvelopedVC) { // no enveloped VCs to update, return early return vp; } // walk credential results and produce unenveloped output vp.verifiableCredential = credentialResults.map(r => r.credential); } return vp; } export function buildVerifyPresentationResults({verifyResult}) { /*if(verifyResult.results) { // VCALM-compliant result already available, use it or a minimized version // of it to avoid hitting exchange storage capacity limits }*/ // backwards-compatibility: construct VCALM-compliant result return { verified: verifyResult.verified, results: { presentation: { verified: verifyResult.presentationResult?.verified, proof: verifyResult.presentationResult?.results?.map(r => ({ verified: r.verified, input: r.proof })), }, credentials: verifyResult.credentialResults?.map(r => ({ verified: r.verified, results: { proof: r.results?.map(r => ({ verified: r.verified, input: r.proof })), credentialStatus: r.statusResult?.results?.map(r => ({ verified: r.verified, input: r.credentialStatus, value: r.status })) } })) } }; } export function emitExchangeUpdated({workflow, exchange, step}) { if(!step?.callback?.url) { // no-op when there is no callback to notify return; } const {url} = step.callback; const exchangeId = `${workflow.id}/exchanges/${exchange.id}`; return httpClient.post(url, { agent: httpsAgent, json: { event: { data: {exchangeId} } } }).catch( error => logger.error( 'Could not send "exchangeUpdated" push notification: ' + error.message, {error})); } export async function evaluateTemplate({ workflow, exchange, typedTemplate, variables } = {}) { // run jsonata compiler; only `jsonata` template type is supported and this // assumes only this template type will be passed in const {template} = typedTemplate; variables = variables ?? getTemplateVariables({workflow, exchange}); return jsonata(template).evaluate(variables, variables); } export async function evaluateExchangeStep({ workflow, exchange, stepName = exchange.step }) { let step = workflow.steps[stepName] ?? {}; if(step.stepTemplate) { step = await evaluateTemplate({ workflow, exchange, typedTemplate: step.stepTemplate }); } else { // ensure step is cloned to allow for changes during exchange processing // that will not persist step = structuredClone(step); } await validateStep({step}); return step; } export function getTemplateVariables({workflow, exchange} = {}) { const {variables = {}} = exchange; // always include `globals` as keyword for self-referencing exchange info variables.globals = { workflow: { id: workflow.id }, exchange: { id: exchange.id }, localExchangeId: exchange.id, exchangeId: `${workflow.id}/exchanges/${exchange.id}`, // backwards compatibility exchanger: { id: workflow.id } }; return variables; } export function getWorkflowId({routePrefix, localId} = {}) { return `${config.server.baseUri}${routePrefix}/${localId}`; } export async function generateRandom() { // 128-bit random number, base58 multibase + multihash encoded return generateId({ bitLength: 128, encoding: 'base58', multibase: true, multihash: true }); } export function getWorkflowIssuerInstances({workflow} = {}) { let {issuerInstances} = workflow; if(!issuerInstances && workflow.zcaps.issue) { // generate dynamic issuer instance config issuerInstances = [{ // provide both media types and (deprecated) formats for backwards compat supportedMediaTypes: ['application/vc'], supportedFormats: ['application/vc', 'ldp_vc'], zcapReferenceIds: { issue: 'issue' } }]; } // normalize `supportedFormats` to `supportedMediaTypes` and add // `supportedFormats` for backwards compatibility issuerInstances = issuerInstances.map(ii => { if(ii.supportedMediaTypes && ii.supportedFormats) { return ii; } if(ii.supportedFormats) { return { ...ii, supportedMediaTypes: ii.supportedFormats.map( format => SUPPORTED_FORMAT_TO_MEDIA_TYPE.get(format) ?? format) }; } if(ii.supportedMediaTypes) { return { ...ii, supportedFormats: ii.supportedMediaTypes.map( mt => SUPPORTED_MEDIA_TYPE_TO_FORMAT.get(mt) ?? mt) }; } }); return issuerInstances; } export async function getZcapClient({workflow} = {}) { // get service agent for communicating with the issuer instance const {pathname} = new URL(workflow.id); // backwards-compatibility: support deprecated `vc-exchanger` const serviceType = pathname.startsWith('/workflows/') ? 'vc-workflow' : 'vc-exchanger'; const {serviceAgent} = await serviceAgents.get({serviceType}); const {capabilityAgent, zcaps} = await serviceAgents.getEphemeralAgent( {config: workflow, serviceAgent}); // create zcap client for issuing VCs const zcapClient = new ZcapClient({ agent: httpsAgent, invocationSigner: capabilityAgent.getSigner(), SuiteClass: Ed25519Signature2020 }); return {zcapClient, zcaps}; } export function parseLocalId({id}) { // format: <base>/<localId> const idx = id.lastIndexOf('/'); const localId = id.slice(idx + 1); return { base: id.slice(0, idx), localId: decodeLocalId({localId}) }; } export function decodeLocalId({localId} = {}) { // convert to `Buffer` for database storage savings return Buffer.from(decodeId({ id: localId, encoding: 'base58', multibase: true, multihash: true, expectedSize: 16 })); } export function deepEqual(obj1, obj2) { const isObject1 = obj1 && typeof obj1 === 'object'; const isObject2 = obj2 && typeof obj2 === 'object'; if(isObject1 !== isObject2) { return false; } if(!isObject1) { return obj1 === obj2; } const isArray1 = Array.isArray(obj1); const isArray2 = Array.isArray(obj2); if(isArray1 !== isArray2) { return false; } if(isArray1) { if(obj1.length !== obj2.length) { return false; } for(const [i, e] of obj1.entries()) { if(!deepEqual(e, obj2[i])) { return false; } } return true; } const keys1 = Object.keys(obj1); const keys2 = Object.keys(obj2); if(keys1.length !== keys2.length) { return false; } for(const k of keys1) { if(!deepEqual(obj1[k], obj2[k])) { return false; } } return true; } export function createVerifyOptions({ verifyPresentationOptions, expectedChallenge, verifiablePresentationRequest, presentation, domain, checks }) { // start with `verifyPresentationOptions`, then overwrite as needed const options = {...verifyPresentationOptions}; // update `checks` with anything additional from `verifyPresentationOptions` const checkSet = new Set(checks); if(verifyPresentationOptions?.checks) { Object.entries(verifyPresentationOptions.checks) .forEach(([check, enabled]) => enabled && checkSet.add(check)); } options.checks = [...checkSet]; // update `challenge` if(options.challenge === undefined) { options.challenge = expectedChallenge ?? verifiablePresentationRequest?.challenge ?? presentation?.proof?.challenge; } // update `domain` if(options.domain === undefined) { options.domain = domain; } return options; } export function resolvePointer(obj, pointer) { if(pointer === '/') { return obj; } try { return jsonpointer.get(obj, pointer); } catch(e) { return undefined; } } export function resolveVariableName({variables, name} = {}) { if(!name.startsWith('/')) { return variables[name]; } if(name === '/') { return variables; } try { return jsonpointer.get(variables, name); } catch(e) { return undefined; } } export function setVariable({variables, name, value} = {}) { if(!name.startsWith('/')) { variables[name] = value; return; } if(name === '/') { throw new BedrockError( `Invalid variable name "${name}".`, { name: 'NotSupportedError', details: {httpStatusCode: 500, public: true} }); } jsonpointer.set(variables, name, value); } export function stripStacktrace(error) { // serialize error and allow-list specific properties const serialized = serializeError(error); error = {}; for(const key of ALLOWED_ERROR_KEYS) { if(serialized[key] !== undefined) { error[key] = serialized[key]; } } if(error.errors) { error.errors = error.errors.map(stripStacktrace); } if(Array.isArray(error.details?.errors)) { error.details.errors = error.details.errors.map(stripStacktrace); } if(error.cause) { error.cause = stripStacktrace(error.cause); } return error; } export async function validateStep({step} = {}) { // FIXME: use `ajv` and do JSON schema check if(step.issueRequests !== undefined && !Array.isArray(step.issueRequests)) { throw new BedrockError( 'Invalid "issueRequests" in step.', { name: 'DataError', details: {httpStatusCode: 500, public: true} }); } // use of `jwtDidProofRequest` and `openId` together is prohibited const {jwtDidProofRequest, openId} = step; if(jwtDidProofRequest && openId) { throw new BedrockError( 'Invalid workflow configuration; only one of ' + '"jwtDidProofRequest" and "openId" is permitted in a step.', { name: 'DataError', details: {httpStatusCode: 500, public: true} }); } } export function validateVerifiablePresentation({schema, presentation}) { const validate = compile({schema}); const {valid, error} = validate(presentation); if(!valid) { throw error; } } export function validateVerifiablePresentationRequest({ schema, presentationRequest }) { const validate = compile({schema}); const {valid, error} = validate(presentationRequest); if(!valid) { throw error; } }