@bedrock/vc-delivery
Version:
Bedrock Verifiable Credential Delivery
615 lines (558 loc) • 24.6 kB
JavaScript
/*!
* Copyright (c) 2026 Digital Bazaar, Inc. All rights reserved.
*/
import * as bedrock from '@bedrock/core';
import * as exchanges from './storage/exchanges.js';
import {
buildPresentationFromResults,
buildVerifyPresentationResults,
emitExchangeUpdated,
evaluateExchangeStep,
validateVerifiablePresentation,
validateVerifiablePresentationRequest
} from './helpers.js';
import {createChallenge, verify as defaultVerify} from './verify.js';
import {issue as defaultIssue, getIssueRequestsParams} from './issue.js';
import {createPresentation} from '@digitalbazaar/vc';
import {logger} from './logger.js';
const {util: {BedrockError}} = bedrock;
// 15 minute default TTL for exchanges
const DEFAULT_TTL = 1000 * 60 * 60 * 15;
// maximum steps while looping
const MAXIMUM_STEP_COUNT = 100;
export class ExchangeProcessor {
/**
* Creates an `ExchangeProcessor`.
*
* @param {object} options - The options to use.
* @param {object} options.workflow - The workflow.
* @param {object} options.exchangeRecord - The exchange record.
* @param {Function} [options.prepareStep] - The `prepareStep` handler.
* @param {Function} [options.isStepComplete] - The `isStepComplete` handler.
* @param {Function} [options.issue] - The `issue` handler.
* @param {Function} [options.inputRequired] - The `inputRequired` handler.
* @param {Function} [options.verify] - The `verify` handler.
*
* @returns {ExchangeProcessor} An `ExchangeProcessor` instance.
*/
constructor({
workflow, exchangeRecord, prepareStep, inputRequired, isStepComplete,
issue, verify
} = {}) {
this.workflow = workflow;
this.exchangeRecord = exchangeRecord;
this.prepareStep = prepareStep?.bind(this);
this.inputRequired = inputRequired?.bind(this);
this.isStepComplete = isStepComplete?.bind(this);
this.issue = issue ?? defaultIssue.bind(this);
this.verify = verify ?? defaultVerify.bind(this);
this.canRetry = false;
}
/**
* A utility function for getting the current step of the exchange without
* performing any other processing.
*
* @returns {Promise<object>} An object with the step information.
*/
async getStep() {
const {
workflow,
exchangeRecord: {exchange}
} = this;
const currentStep = exchange.step;
if(!currentStep) {
// create default empty step
exchange.step = 'initial';
workflow.steps = {initial: {}};
}
const step = await evaluateExchangeStep({
workflow, exchange, stepName: currentStep
});
// if `step.nextStep` and `step.redirectUrl` and are both set, throw an
// error
if(step.nextStep && step.redirectUrl) {
throw new BedrockError(
'Only the last step of a workflow can use "redirectUrl".', {
name: 'DataError',
details: {httpStatusCode: 500, public: true}
});
}
return step;
}
/**
* Processes the exchange until either a response to be used with the
* exchange client is generated or input is required from the exchange client
* to continue.
*
* Note: An exchange is an particular running instantation of a workflow. The
* exchange processor will walkthrough the step(s) of the workflow to
* produce a respnose that can be used to communicate with the exchange
* client according to the protocol being used to execute the exchange. Not
* every step of a workflow produces a response and some steps produce a
* partial response, whereby the next step adds information to complete
* the response.
*
* It is up to the caller of `process()` to use the response generated to
* appropriately communicate with the exchange client according to the rules
* of the operating protocol.
*
* Sometimes errors may occur during an exchange. Not every possible error
* is recoverable. Some information that is generated on a workflow server
* may not be intended to be seen by a coordinator system that can poll the
* exchange state and some information may not be able to be regenerated
* without a new exchange.
*
* In rare cases, it is possible that a generated response will not reach the
* client due to a network connection error that is not perceived by the
* server. In this case, the expectation is that another exchange will have
* to be started to attempt the interaction again.
*
* @param {object} options - The options to use.
* @param {object} [options.receivedPresentation] - A verifiable
* presentation received from the exchange client in the most recent
* protocol message of choice.
* @param {object} [options.receivedPresentationRequest] - A verifiable
* presentation request received from the exchange client in the most
* recent protocol message of choice.
*
* @returns {Promise<object>} An object with processing information.
*/
async process({receivedPresentation, receivedPresentationRequest} = {}) {
const retryState = {};
while(true) {
try {
retryState.canRetry = false;
const response = await this._tryProcess({
receivedPresentation, receivedPresentationRequest, retryState
});
return response;
} catch(e) {
if(e.name === 'InvalidStateError' && retryState.canRetry) {
// get exchange record and loop to try again on `InvalidStateError`
const {workflow, exchangeRecord: {exchange}} = this;
this.exchangeRecord = await exchanges.get({
workflowId: workflow.id, id: exchange.id
});
continue;
}
// rethrow in all other cases
throw e;
}
}
}
/**
* A utility function for updating the exchange. This should only be called
* to help address differences in protocols or handle backwards compatibility
* behavior.
*
* @param {object} options - The options to use.
* @param {object} [options.step] - The current step.
*
* @returns {Promise<object>} An object with processing information.
*/
async updateExchange({step} = {}) {
const {workflow, exchangeRecord: {exchange, meta}} = this;
try {
exchange.referenceId = globalThis.crypto.randomUUID();
exchange.sequence++;
if(exchange.state === 'complete') {
await exchanges.complete({workflowId: workflow.id, exchange});
} else {
await exchanges.update({workflowId: workflow.id, exchange, meta});
}
meta.updated = Date.now();
await emitExchangeUpdated({workflow, exchange, step});
} catch(e) {
exchange.sequence--;
throw e;
}
}
async _validateReceivedPresentation({
workflow, exchange, step, receivedPresentation, verify
}) {
// 1. Set `isEnvelopedPresentation` to `true` if the received presentation's
// type is `EnvelopedVerifiablePresentation`, otherwise set it to `false`.
const isEnvelopedPresentation =
receivedPresentation?.type === 'EnvelopedVerifiablePresentation';
// 2. If `step.presentationSchema` is set and `isEnvelopedPresentation` is
// `false`, then use the presentation schema to validate
// `receivedPresentation`, throwing an error if validation fails.
const {presentationSchema} = step;
if(presentationSchema && !isEnvelopedPresentation) {
validateVerifiablePresentation({
schema: presentationSchema.jsonSchema,
presentation: receivedPresentation
});
}
// 3. Set `expectedChallenge` to the local exchange ID if the exchange is on
// the initial step, otherwise set it to the `challenge` property's value in
// `exchange.variables.results[exchange.step].responsePresentationRequest`
// if it is set, otherwise set it to `undefined` (for subsequent steps,
// an implementation may also set the challenge in an
// implementation-specific way).
const isInitialStep = _isInitialStep({workflow, exchange});
const responsePresentationRequest = exchange.variables
.results?.[exchange.step]?.responsePresentationRequest;
const expectedChallenge = isInitialStep ?
exchange.id : responsePresentationRequest?.challenge;
// 4. Set `expectedDomain` to the `domain` property's value in
// `exchange.variables.results[exchange.step].responsePresentationRequest`
// if it is set, otherwise set it to the `domain` property's value in
// `step.verifiablePresentationRequest` if it is set, otherwise set it to
// the origin value for `workflow.id`.
const expectedDomain = responsePresentationRequest?.domain ??
step.verifiablePresentationRequest?.domain ?? new URL(workflow.id).origin;
// 5. Verify the received presentation (e.g., using a configured VCALM
// verifier instance):
const {
allowUnprotectedPresentation = false,
verifyPresentationResultSchema
} = step;
const verifyPresentationOptions = structuredClone(
step.verifyPresentationOptions ?? {});
const verifyResult = await verify({
workflow, exchange, step,
verifyPresentationOptions,
verifyPresentationResultSchema,
verifiablePresentationRequest: responsePresentationRequest ??
step.verifiablePresentationRequest,
presentation: receivedPresentation,
allowUnprotectedPresentation,
expectedChallenge,
expectedDomain
});
// build unenveloped verifiable presentation from verification results
const verifiablePresentation = buildPresentationFromResults({
presentation: receivedPresentation,
verifyResult
});
// 4. If `step.presentationSchema` is set and `isEnvelopedPresentation` is
// `true`, then use the presentation schema to validate the unenveloped
// presentation returned from the verification process, throwing an error
// if validation fails.
if(presentationSchema && isEnvelopedPresentation) {
validateVerifiablePresentation({
schema: presentationSchema.jsonSchema,
presentation: verifiablePresentation
});
}
// FIXME: check the VP against "allowedIssuer" in VPR, if provided
// 5. Set the verification results in
// `exchange.variables.results[exchange.step]`.
const {verificationMethod} = verifyResult;
exchange.variables.results[exchange.step] = {
...exchange.variables.results[exchange.step],
// common use case of DID Authentication; provide `did` for ease
// of use in templates and consistency with OID4VCI which only
// receives `did` not verification method nor VP
did: verificationMethod?.controller || null,
verificationMethod,
verifiablePresentation,
verifyPresentationResults: buildVerifyPresentationResults({verifyResult})
};
}
async _validateReceivedPresentationRequest({
exchange, step, receivedPresentationRequest
}) {
// 1. If `step.presentationRequestSchema` is set, then use the presentation
// request schema to validate `receivedPresentationRequest`, throwing an
// error if validation fails.
if(step.presentationRequestSchema) {
validateVerifiablePresentationRequest({
schema: step.presentationRequestSchema.jsonSchema,
presentationRequest: receivedPresentationRequest
});
}
// 2. Set the presentation request in
// `exchange.variables.results[exchange.step].receivedPresentationRequest`
// so it can be used in subsequent steps.
exchange.variables.results[exchange.step] = {
...exchange.variables.results[exchange.step],
receivedPresentationRequest
};
}
async _tryProcess({
receivedPresentation, receivedPresentationRequest, retryState
} = {}) {
const {
workflow, exchangeRecord, prepareStep, inputRequired, isStepComplete
} = this;
const {exchange, meta} = exchangeRecord;
// initialize exchange results
if(!exchange.variables.results) {
exchange.variables.results = {};
}
// 1. Initialize `step` and `response` to `null`.
let step = null;
let response = null;
// track whether issuance has been triggered yet to set retry capability
let issuanceTriggered = false;
try {
// 2. If `exchange.state` is `complete` or `invalid`, throw a
// `NotAllowedError`.
if(exchange.state === 'complete' || exchange.state === 'invalid') {
throw new BedrockError(`Exchange is ${exchange.state}`, {
name: 'NotAllowedError',
details: {httpStatusCode: 403, public: true}
});
}
// 3. If `exchange.state` is `pending`, set it to `active`.
if(exchange.state === 'pending') {
exchange.state = 'active';
}
// 4. Continuously loop to process exchange steps, optionally saving any
// error thrown as `exchange.lastError`. Other algorithm steps will
// return out of the loop when a full response is generated, input from
// the exchange client is required, or the exchange times out. An
// implementation specific maximum step count be optionally enforced to
// prevent misconfigured workflows.
let stepCount = 0;
const signal = _createTimeoutSignal({exchange, meta});
while(true) {
if(signal.aborted) {
throw new BedrockError('Exchange has expired.', {
name: 'DataError',
details: {httpStatusCode: 500, public: true}
});
}
if(stepCount++ > MAXIMUM_STEP_COUNT) {
throw new BedrockError('Maximum step count exceeded.', {
name: 'DataError',
details: {httpStatusCode: 500, public: true}
});
}
// 4.1. Set `step` to the current step (evaluating a step template as
// needed).
step = await this.getStep();
// 4.2. Call subalgorithm `prepareStep`, passing `workflow`,
// `exchange`, `step`, `receivedPresentation`, and
// `receivedPresentationRequest` to perform any protocol-specific
// custom step preparation. If `prepareStep` returns a `prepareResult`
// with `receivedPresentation` and/or `receivedPresentationRequest` set,
// then update `receivedPresentation` and/or
// `receivedPresentationRequest` accordingly.
const prepareResult = await prepareStep?.({
workflow, exchange, step,
receivedPresentation, receivedPresentationRequest
});
if(prepareResult?.receivedPresentation !== undefined) {
receivedPresentation = prepareResult.receivedPresentation;
}
if(prepareResult?.receivedPresentationRequest) {
receivedPresentationRequest =
prepareResult.receivedPresentationRequest;
}
// 4.3. If `receivedPresentation` is set, then call the
// `validateReceivedPresentation` sub-algorithm, passing `workflow`,
// `exchange`, `step`, and `receivedPresentation`.
if(receivedPresentation) {
await this._validateReceivedPresentation({
workflow, exchange, step, receivedPresentation,
verify: this.verify
});
}
// 4.4. If `receivedPresentationRequest` is set, call the
// `validateReceivedPresentationRequest` sub-algorithm, passing
// `exchange`, `step`, and `receivedPresentationRequest`.
if(receivedPresentationRequest) {
await this._validateReceivedPresentationRequest({
exchange, step, receivedPresentationRequest
});
}
// 4.5. If the implementation supports blocking callbacks that can
// return results to be added to exchange variables (or return errors),
// call the callback and store its results in
// `exchange.variables.results[exchange.step].callbackResults` or
// throw any error received.
// FIXME: to be implemented
// 4.6. Set `isInputRequired` to the result of calling
// `inputRequired({step, receivedPresentation})`.
const isInputRequired = await inputRequired?.({
workflow, exchange, step, receivedPresentation
}) ?? false;
// 4.7. If `isInputRequired` is true:
if(isInputRequired) {
// 4.7.1. If `response` is `null`, set it to an empty object.
if(!response) {
response = {};
}
// 4.7.2. If `step.verifiablePresentationRequest` is set, call
// the `createVerifiablePresentationRequest` sub-algorithm, passing
// `workflow`, `exchange`, `step`, and `response`.
if(step.verifiablePresentationRequest) {
await _createVerifiablePresentationRequest({
workflow, exchange, step, response
});
}
// 4.7.3. Save the exchange (and call any non-blocking callback
// in the step) and return `response`.
await this.updateExchange({step});
return response;
}
// 4.8. Set `issueToClient` to `true` if `step.issueRequests` includes
// any issuer requests for VCs that are to be sent to the client
// (`issueRequest.result` is NOT set), otherwise set it to `false`.
const issueRequestsParams = getIssueRequestsParams({
workflow, exchange, step
});
const issueToClient = issueRequestsParams.some(p => !p.result);
// 4.9. If `step.verifiablePresentation` is set or `issueToClient` is
// `true`:
if(step.verifiablePresentation || issueToClient) {
// 4.9.1. If `response` is not `null`
if(response) {
// 4.9.1.1. If `response.verifiablePresentationRequest` is not set,
// set it to an empty object (to indicate that the exchange is
// not yet complete).
if(!response.verifiablePresentationRequest) {
response.verifiablePresentationRequest = {};
}
// 4.9.1.2. Save the exchange (and call any non-blocking callback
// in the step).
await this.updateExchange({step});
// 4.9.1.3. Return `response`.
return response;
}
// 4.9.2. Set `response` to an empty object.
response = {};
// 4.9.3. If `step.verifiablePresentation` is set, set
// `response.verifiablePresentation` to a copy of it, otherwise
// set `response.verifiablePresentation` to a new, empty,
// Verifiable Presentation (using VCDM 2.0 by default, but a custom
// configuration could specify another version).
response.verifiablePresentation =
structuredClone(step.verifiablePresentation) ??
createPresentation();
}
// issuance has been triggered
issuanceTriggered = true;
// 4.10. Perform every issue request (optionally in parallel),
// returning an error response to the client if any fails (note:
// implementations can optionally implement failure recovery or retry
// issue requests at their own discretion):
// 4.10.1. For each issue request where `result` is set to an exchange
// variable path or name, save the issued credential in the referenced
// exchange variable.
// 4.10.2. For each issue request where `result` is not specified, save
// the issued credential in `response.verifiablePresentation`, i.e.,
// for a VCDM presentation, append the issued credential to
// `response.verifiablePresentation.verifiableCredential`.
await this.issue({
workflow, exchange, step, issueRequestsParams,
verifiablePresentation: response?.verifiablePresentation
});
// 4.11. If `response.verifiablePresentation` is set and the step
// configuration indicates it should be signed, sign the presentation
// (e.g., by using a VCALM holder instance's `/presentations/create`
// endpoint).
// FIXME: implement
//if(response?.verifiablePresentation) {}
// 4.12. Call subalgorithm `isStepComplete`, passing `workflow`,
// `exchange`, `step`, `receivedPresentation`, and
// `receivedPresentationRequest` to perform any protocol-specific
// behavior to determine if the step is complete. Set `stepComplete`
// to the result of `isStepComplete`, defaulting to `true`.
const stepComplete = await isStepComplete?.({
workflow, exchange, step,
receivedPresentation, receivedPresentationRequest
}) ?? true;
// 4.13. If `stepComplete` is `true`:
if(stepComplete) {
// 4.13.1. If `step.redirectUrl` is set:
if(step.redirectUrl) {
// 4.13.1.1. If `response` is `null` then set it to an empty object.
if(!response) {
response = {};
}
// 4.13.1.2. Set `response.redirectUrl` to `step.redirectUrl`.
response.redirectUrl = step.redirectUrl;
}
// 4.13.2. If `step.nextStep` is not set then set `exchange.state` to
// `complete`.
if(!step.nextStep) {
exchange.state = 'complete';
} else {
// 4.13.3. Otherwise, delete
// `exchange.variables.results[step.nextStep]` if it exists, and
// set `exchange.step` to `step.nextStep`.
delete exchange.variables.results[step.nextStep];
exchange.step = step.nextStep;
}
}
// 4.14. Save the exchange (and call any non-blocking callback in
// the step).
await this.updateExchange({step});
// 4.15. If `exchange.state` is `complete`, return `response` if it is
// not `null`, otherwise return an empty object.
if(exchange.state === 'complete') {
return response ?? {};
}
// 4.16. Set `receivedPresentation` to `null`.
receivedPresentation = null;
}
} catch(e) {
if(e.name === 'InvalidStateError') {
// if issuance has not been triggered or the exchange processor's
// `canRetry` flag has been explicitly set, allow retry
retryState.canRetry = !issuanceTriggered || this.canRetry;
throw e;
}
// write last error if exchange hasn't been frequently updated
const {id: workflowId} = workflow;
const copy = {...exchange};
copy.sequence++;
copy.lastError = e;
await exchanges.setLastError({
workflowId, exchange: copy, lastUpdated: meta.updated
}).catch(error => logger.error(
'Could not set last exchange error: ' + error.message, {error}));
await emitExchangeUpdated({workflow, exchange, step});
throw e;
}
}
}
function _createTimeoutSignal({exchange, meta}) {
const expires = exchange.expires !== undefined ?
new Date(exchange.expires).getTime() :
new Date(meta.created + DEFAULT_TTL).getTime();
const timeout = Math.max(expires - Date.now(), 0);
const signal = AbortSignal.timeout(timeout);
return signal;
}
async function _createVerifiablePresentationRequest({
workflow, exchange, step, response
}) {
// 1. Set `response.verifiablePresentationRequest` to
// a copy of `step.verifiablePresentationRequest`.
response.verifiablePresentationRequest =
structuredClone(step.verifiablePresentationRequest);
// 2. If `step.createChallenge` is `false`, return.
if(!step.createChallenge) {
return;
}
// 3. Set `response.verifiablePresentationRequest.challenge` to an
// appropriate challenge value (e.g., use a configured VCALM verifier
// instance's `/challenges` endpoint).
/* Note: When setting the challenge, if the exchange on the initial step,
the challenge is the local exchange ID. Any subsequent step requires
a different challenge value. */
let challenge;
const isInitialStep = _isInitialStep({workflow, exchange});
if(isInitialStep) {
challenge = exchange.id;
} else {
// generate a new challenge using verifier API
({challenge} = await createChallenge({workflow}));
}
response.verifiablePresentationRequest.challenge = challenge;
// 4. Set `exchange.variables.results[exchange.step]` to an object, if it
// does not already exist, and set the property `responsePresentationRequest`
// to `response.verifiablePresentationRequest`.
exchange.variables.results[exchange.step] = {
...exchange.variables.results[exchange.step],
responsePresentationRequest: response.verifiablePresentationRequest
};
}
function _isInitialStep({workflow, exchange}) {
return !workflow.initialStep || exchange.step === workflow.initialStep;
}