UNPKG

@bedrock/vc-delivery

Version:
601 lines (530 loc) 20.1 kB
/*! * Copyright (c) 2022-2025 Digital Bazaar, Inc. All rights reserved. */ import * as bedrock from '@bedrock/core'; import * as exchanges from '../exchanges.js'; import { deepEqual, emitExchangeUpdated, evaluateTemplate, getWorkflowIssuerInstances, validateStep } from '../helpers.js'; import {importJWK, SignJWT} from 'jose'; import {checkAccessToken} from '@bedrock/oauth2-verifier'; import {getAuthorizationRequest} from './oid4vp.js'; import {issue} from '../issue.js'; import {logger} from '../logger.js'; import {timingSafeEqual} from 'node:crypto'; import {verifyDidProofJwt} from '../verify.js'; const {util: {BedrockError}} = bedrock; const PRE_AUTH_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:pre-authorized_code'; export async function getAuthorizationServerConfig({req}) { // note that technically, we should not need to serve any credential // issuer metadata, but we do for backwards compatibility purposes as // previous versions of OID4VCI required it return getCredentialIssuerConfig({req}); } export async function getCredentialIssuerConfig({req}) { const {config: workflow} = req.serviceObject; const {exchange} = await req.getExchange(); _assertOID4VCISupported({exchange}); const credential_configurations_supported = _createCredentialConfigurationsSupported({workflow, exchange}); const exchangeId = `${workflow.id}/exchanges/${exchange.id}`; return { batch_credential_endpoint: `${exchangeId}/openid/batch_credential`, credential_configurations_supported, credential_endpoint: `${exchangeId}/openid/credential`, credential_issuer: exchangeId, issuer: exchangeId, jwks_uri: `${exchangeId}/openid/jwks`, 'pre-authorized_grant_anonymous_access_supported': true, nonce_endpoint: `${exchangeId}/openid/nonce`, token_endpoint: `${exchangeId}/openid/token` }; } export async function getCredentialOffer({req}) { const {config: workflow} = req.serviceObject; const {exchange} = await req.getExchange(); _assertOID4VCISupported({exchange}); // start building OID4VCI credential offer const exchangeId = `${workflow.id}/exchanges/${exchange.id}`; const offer = { credential_issuer: exchangeId, grants: { 'urn:ietf:params:oauth:grant-type:pre-authorized_code': { 'pre-authorized_code': exchange.openId.preAuthorizedCode } } }; const supported = _createCredentialConfigurationsSupported({ workflow, exchange }); // offer all configuration IDs and support both spec version ID-1 with // `credentials` and draft 14 with `credential_configuration_ids` const configurationIds = Object.keys(supported); offer.credentials = configurationIds; offer.credential_configuration_ids = configurationIds; return offer; } export async function getJwks({req}) { const {exchange} = await req.getExchange(); _assertOID4VCISupported({exchange}); return [exchange.openId.oauth2.keyPair.publicKeyJwk]; } export async function processAccessTokenRequest({req}) { const exchangeRecord = await req.getExchange(); const {exchange} = exchangeRecord; _assertOID4VCISupported({exchange}); /* Examples of types of token requests: pre-authz code: grant_type=urn:ietf:params:oauth:grant-type:pre-authorized_code &pre-authorized_code=SplxlOBeZQQYbYS6WxSbIA &user_pin=493536 authz code: grant_type=authorization_code &code=SplxlOBeZQQYbYS6WxSbIA &code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk &redirect_uri=https%3A%2F%2FWallet.example.org%2Fcb */ const {config: workflow} = req.serviceObject; const { grant_type: grantType, 'pre-authorized_code': preAuthorizedCode, // FIXME: `user_pin` now called `tx_code` //user_pin: userPin } = req.body; if(grantType !== PRE_AUTH_GRANT_TYPE) { // unsupported grant type // FIXME: throw proper oauth2 formatted error throw new Error('Unsupported grant type.'); } // validate grant type const {openId: {preAuthorizedCode: expectedCode}} = exchange; if(expectedCode) { // ensure expected pre-authz code matches if(!timingSafeEqual( Buffer.from(expectedCode, 'utf8'), Buffer.from(preAuthorizedCode, 'utf8'))) { // FIXME: throw proper oauth2 formatted error throw new Error('invalid pre-authorized-code or user pin'); } } // create access token const {accessToken, ttl} = await _createExchangeAccessToken({ workflow, exchangeRecord }); return { access_token: accessToken, token_type: 'bearer', expires_in: ttl }; } export async function processCredentialRequests({req, res, isBatchRequest}) { const {config: workflow} = req.serviceObject; const exchangeRecord = await req.getExchange(); const {exchange} = exchangeRecord; _assertOID4VCISupported({exchange}); // ensure oauth2 access token is valid await _checkAuthz({req, workflow, exchange}); return _processExchange({req, res, workflow, exchangeRecord, isBatchRequest}); } export function supportsOID4VCI({exchange}) { return exchange.openId?.expectedCredentialRequests !== undefined; } function _assertCredentialRequests({ workflow, credentialRequests, expectedCredentialRequests }) { // ensure that every credential request is for the same format /* credential requests look like: { format: 'ldp_vc', credential_definition: { '@context': [Array], type: [Array] } } */ let sharedFormat; if(!credentialRequests.every(({format}) => { if(sharedFormat === undefined) { sharedFormat = format; } return sharedFormat === format; })) { throw new BedrockError( 'Credential requests must all use the same format in this workflow.', { name: 'DataError', details: {httpStatusCode: 400, public: true} }); } // ensure that the shared format is supported by the workflow const supportedFormats = _getSupportedFormats({workflow}); if(!supportedFormats.has(sharedFormat)) { throw new BedrockError( `Credential request format "${sharedFormat}" is not supported ` + 'by this workflow.', { name: 'DataError', details: {httpStatusCode: 400, public: true} }); } // ensure every credential request matches against an expected one and none // are missing; `expectedCredentialRequests` formats are ignored based on the // issuer instance supported formats and have already been checked if(!(credentialRequests.length === expectedCredentialRequests.length && credentialRequests.every(cr => expectedCredentialRequests.some( expected => _matchCredentialRequest(expected, cr))))) { throw new BedrockError( 'Unexpected credential request.', { name: 'DataError', details: {httpStatusCode: 400, public: true} }); } return {format: sharedFormat}; } function _assertOID4VCISupported({exchange}) { if(!supportsOID4VCI({exchange})) { throw new BedrockError('OID4VCI is not supported by this exchange.', { name: 'NotSupportedError', details: {httpStatusCode: 400, public: true} }); } } async function _checkAuthz({req, workflow, exchange}) { // 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/${req.params.exchangeId}`; const audience = exchangeId; // `issuerConfigUrl` is always based off of the `exchangeId` as well const parsedIssuer = new URL(exchangeId); const issuerConfigUrl = `${parsedIssuer.origin}/.well-known/oauth-authorization-server` + parsedIssuer.pathname; // FIXME: `allowedAlgorithms` should be computed from `oauth2.keyPair` // const allowedAlgorithms = // ensure access token is valid await checkAccessToken({req, issuerConfigUrl, maxClockSkew, audience}); } async function _createExchangeAccessToken({workflow, exchangeRecord}) { // FIXME: set `exp` to max of 15 minutes / configured max minutes const expires = exchangeRecord.meta.expires; const exp = Math.floor(expires.getTime() / 1000); // create access token const {exchange} = exchangeRecord; const {openId: {oauth2: {keyPair: {privateKeyJwk}}}} = exchange; const exchangeId = `${workflow.id}/exchanges/${exchange.id}`; const {accessToken, ttl} = await _createOAuth2AccessToken({ privateKeyJwk, audience: exchangeId, action: 'write', target: exchangeId, exp, iss: exchangeId }); return {accessToken, ttl}; } async function _createOAuth2AccessToken({ privateKeyJwk, audience, action, target, exp, iss, nbf, typ = 'at+jwt' }) { const alg = _getAlgFromPrivateKey({privateKeyJwk}); const scope = `${action}:${target}`; const builder = new SignJWT({scope}) .setProtectedHeader({alg, typ}) .setIssuer(iss) .setAudience(audience); let ttl; if(exp !== undefined) { builder.setExpirationTime(exp); ttl = Math.max(0, exp - Math.floor(Date.now() / 1000)); } else { // default to 15 minute expiration time builder.setExpirationTime('15m'); ttl = Math.floor(Date.now() / 1000) + 15 * 60; } if(nbf !== undefined) { builder.setNotBefore(nbf); } const key = await importJWK({...privateKeyJwk, alg}); const accessToken = await builder.sign(key); return {accessToken, ttl}; } function _createCredentialConfigurationId({format, credential_definition}) { let types = (credential_definition.type ?? credential_definition.types); if(types.length > 1) { types = types.filter(t => t !== 'VerifiableCredential'); } return types.join('_') + '_' + format; } function _createCredentialConfigurations({ credentialRequest, supportedFormats }) { const configurations = []; let {format: formats = supportedFormats} = credentialRequest; if(!Array.isArray(formats)) { formats = [formats]; } for(const format of formats) { const {credential_definition} = credentialRequest; const id = _createCredentialConfigurationId({ format, credential_definition }); const configuration = {format, credential_definition}; // FIXME: if `jwtDidProofRequest` exists in (any) step in the exchange, // then must include: /* "proof_types_supported": { "jwt": { "proof_signing_alg_values_supported": [ "ES256" ] } } */ configurations.push({id, configuration}); } return configurations; } function _createCredentialConfigurationsSupported({workflow, exchange}) { // build `credential_configurations_supported`... const {openId: {expectedCredentialRequests}} = exchange; const supportedFormats = [..._getSupportedFormats({workflow})]; // for every expected credential definition, set `format` default to // `supportedFormats` and for every format, generate a new supported // credential configuration const credential_configurations_supported = {}; for(const credentialRequest of expectedCredentialRequests) { const configurations = _createCredentialConfigurations({ credentialRequest, supportedFormats }); for(const {id, configuration} of configurations) { credential_configurations_supported[id] = configuration; } } return credential_configurations_supported; } function _getAlgFromPrivateKey({privateKeyJwk}) { if(privateKeyJwk.alg) { return privateKeyJwk.alg; } if(privateKeyJwk.kty === 'EC' && privateKeyJwk.crv) { if(privateKeyJwk.crv.startsWith('P-')) { return `ES${privateKeyJwk.crv.slice(2)}`; } if(privateKeyJwk.crv === 'secp256k1') { return 'ES256K'; } } if(privateKeyJwk.kty === 'OKP' && privateKeyJwk.crv?.startsWith('Ed')) { return 'EdDSA'; } if(privateKeyJwk.kty === 'RSA') { return 'PS256'; } return 'invalid'; } function _getSupportedFormats({workflow}) { // get all supported formats from available issuer instances; for simple // workflow configs, a single issuer instance is used const supportedFormats = new Set(); const issuerInstances = getWorkflowIssuerInstances({workflow}); issuerInstances.forEach( instance => instance.supportedFormats.forEach( supportedFormats.add, supportedFormats)); return supportedFormats; } function _matchCredentialRequest(expected, cr) { const {credential_definition: {'@context': c1, type: t1}} = expected; const {credential_definition: {'@context': c2, type: t2}} = cr; // contexts must match exactly but types can have different order return (c1.length === c2.length && t1.length === t2.length && deepEqual(c1, c2) && t1.every(t => t2.some(x => t === x))); } function _normalizeCredentialDefinitionTypes({credentialRequests}) { // normalize credential requests to use `type` instead of `types` for(const cr of credentialRequests) { if(cr?.credential_definition?.types) { if(!cr?.credential_definition?.type) { cr.credential_definition.type = cr.credential_definition.types; } delete cr.credential_definition.types; } } } async function _processExchange({ req, res, workflow, exchangeRecord, isBatchRequest }) { let step; const {id: workflowId} = workflow; const {exchange, meta} = exchangeRecord; let {updated: lastUpdated} = meta; try { // validate body against expected credential requests const {openId: {expectedCredentialRequests}} = exchange; let credentialRequests; if(isBatchRequest) { ({credential_requests: credentialRequests} = req.body); } else { if(expectedCredentialRequests.length > 1) { // FIXME: it is no longer the case that the batch endpoint must be used // for multiple requests; determine if the request has changed // clients interacting with exchanges with more than one VC to be // delivered must use the "batch credential" endpoint // FIXME: improve error throw new Error('batch_credential_endpoint must be used'); } credentialRequests = [req.body]; } // before asserting, normalize credential requests to use `type` instead of // `types`; this is to allow for OID4VCI draft implementers that followed // the non-normative examples _normalizeCredentialDefinitionTypes({credentialRequests}); const {format} = _assertCredentialRequests({ workflow, credentialRequests, expectedCredentialRequests }); // process exchange step if present const currentStep = exchange.step; if(currentStep) { step = workflow.steps[exchange.step]; if(step.stepTemplate) { // generate step from the template; assume the template type is // `jsonata` per the JSON schema step = await evaluateTemplate( {workflow, exchange, typedTemplate: step.stepTemplate}); } await validateStep({step}); const {jwtDidProofRequest} = step; // check to see if step supports OID4VP during OID4VCI if(step.openId) { // if there is no `presentationSubmission`, request one const {results} = exchange.variables; if(!results?.[exchange.step]?.openId?.presentationSubmission) { // FIXME: optimize away double step-template processing that // currently occurs when calling `_getAuthorizationRequest` const { authorizationRequest } = await getAuthorizationRequest({req}); return _requestOID4VP({authorizationRequest, res}); } // otherwise drop down below to complete exchange... } else if(jwtDidProofRequest) { // handle OID4VCI specialized JWT DID Proof request... // `proof` must be in every credential request; if any request is // missing `proof` then request a DID proof if(credentialRequests.some(cr => !cr.proof?.jwt)) { return _requestDidProof({res, exchangeRecord}); } // verify every DID proof and get resulting DIDs const results = await Promise.all( credentialRequests.map(async cr => { const {proof: {jwt}} = cr; const {did} = await verifyDidProofJwt({workflow, exchange, jwt}); return did; })); // require `did` to be the same for every proof // FIXME: determine if this needs to be more flexible const did = results[0]; if(results.some(d => did !== d)) { // FIXME: improve error throw new Error('every DID must be the same'); } // store did results in variables associated with current step if(!exchange.variables.results) { exchange.variables.results = {}; } exchange.variables.results[currentStep] = { // common use case of DID Authentication; provide `did` for ease // of use in templates did }; } } // mark exchange complete exchange.state = 'complete'; try { exchange.sequence++; await exchanges.complete({workflowId, exchange}); emitExchangeUpdated({workflow, exchange, step}); lastUpdated = Date.now(); } catch(e) { exchange.sequence--; throw e; } // FIXME: decide what the best recovery path is if delivery fails (but no // replay attack detected) after exchange has been marked complete // issue VCs return issue({workflow, exchange, step, format}); } catch(e) { if(e.name === 'InvalidStateError') { throw e; } // write last error if exchange hasn't been frequently updated const copy = {...exchange}; copy.sequence++; copy.lastError = e; exchanges.setLastError({workflowId, exchange: copy, lastUpdated}) .catch(error => logger.error( 'Could not set last exchange error: ' + error.message, {error})); emitExchangeUpdated({workflow, exchange, step}); throw e; } } async function _requestDidProof({res, exchangeRecord}) { /* `9.4 Credential Issuer-provided nonce` allows the credential issuer infrastructure to provide the nonce via an error: HTTP/1.1 400 Bad Request Content-Type: application/json Cache-Control: no-store { "error": "invalid_or_missing_proof" "error_description": "Credential issuer requires proof element in Credential Request" "c_nonce": "8YE9hCnyV2", "c_nonce_expires_in": 86400 }*/ /* OID4VCI exchanges themselves are not replayable and single-step, so the challenge to be signed is just the exchange ID itself. An exchange cannot be reused and neither can a challenge. */ const {exchange, meta: {expires}} = exchangeRecord; const ttl = Math.floor((expires.getTime() - Date.now()) / 1000); _sendOID4Error({ res, error: 'invalid_proof', description: 'Credential issuer requires proof element in Credential Request', // use exchange ID c_nonce: exchange.id, // use exchange expiration period c_nonce_expires_in: ttl }); } async function _requestOID4VP({authorizationRequest, res}) { /* Error thrown when OID4VP is required to complete OID4VCI: HTTP/1.1 400 Bad Request Content-Type: application/json Cache-Control: no-store { "error": "presentation_required" "error_description": "Credential issuer requires presentation before Credential Request" "authorization_request": { "response_type": "vp_token", "presentation_definition": { id: "<urn:uuid>", input_descriptors: {...} }, "response_mode": "direct_post" } }*/ /* OID4VCI exchanges themselves are not replayable and single-step, so the challenge to be signed is just the exchange ID itself. An exchange cannot be reused and neither can a challenge. */ _sendOID4Error({ res, error: 'presentation_required', description: 'Credential issuer requires presentation before Credential Request', authorization_request: authorizationRequest }); } function _sendOID4Error({res, error, description, status = 400, ...rest}) { res.status(status).json({ error, error_description: description, ...rest }); }