diff --git a/CHANGELOG.md b/CHANGELOG.md index 4aca0c2..5efe1ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # bedrock-vc-delivery ChangeLog +## 5.0.1 - 2024-08-dd + +### Fixed +- Fix processing of VC-JWT VPs/VCs in OID4* combined workflows. + ## 5.0.0 - 2024-08-05 ### Added diff --git a/lib/helpers.js b/lib/helpers.js index ef22d85..17bb379 100644 --- a/lib/helpers.js +++ b/lib/helpers.js @@ -2,15 +2,15 @@ * Copyright (c) 2022-2024 Digital Bazaar, Inc. All rights reserved. */ import * as bedrock from '@bedrock/core'; +import * as vcjwt from './vcjwt.js'; import {decodeId, generateId} from 'bnid'; -import {decodeJwt} from 'jose'; import {Ed25519Signature2020} from '@digitalbazaar/ed25519-signature-2020'; import {httpsAgent} from '@bedrock/https-agent'; import jsonata from 'jsonata'; import {serviceAgents} from '@bedrock/service-agent'; import {ZcapClient} from '@digitalbazaar/ezcap'; -const {config} = bedrock; +const {config, util: {BedrockError}} = bedrock; export async function evaluateTemplate({ workflow, exchange, typedTemplate @@ -104,29 +104,66 @@ export function decodeLocalId({localId} = {}) { })); } -export async function unenvelopeCredential({envelopedCredential} = {}) { - let credential; - const {id} = envelopedCredential; - if(id?.startsWith('data:application/jwt,')) { - const format = 'application/jwt'; - const jwt = id.slice('data:application/jwt,'.length); - const claimset = decodeJwt(jwt); - // FIXME: perform various field mappings as needed - console.log('VC-JWT claimset', credential); - return {credential: claimset.vc, format}; +export async function unenvelopeCredential({ + envelopedCredential, format +} = {}) { + const result = _getEnvelope({envelope: envelopedCredential, format}); + + // only supported format is VC-JWT at this time + const credential = vcjwt.decodeVCJWTCredential({jwt: result.envelope}); + return {credential, ...result}; +} + +export async function unenvelopePresentation({ + envelopedPresentation, format +} = {}) { + const result = _getEnvelope({envelope: envelopedPresentation, format}); + + // only supported format is VC-JWT at this time + const presentation = vcjwt.decodeVCJWTPresentation({jwt: result.envelope}); + + // unenvelope any VCs in the presentation + let {verifiableCredential = []} = presentation; + if(!Array.isArray(verifiableCredential)) { + verifiableCredential = [verifiableCredential]; } - throw new Error('Not implemented.'); + if(verifiableCredential.length > 0) { + presentation.verifiableCredential = await Promise.all( + verifiableCredential.map(async vc => { + if(vc?.type !== 'EnvelopedVerifiableCredential') { + return vc; + } + const {credential} = await unenvelopeCredential({ + envelopedCredential: vc + }); + return credential; + })); + } + return {presentation, ...result}; } -export async function unenvelopePresentation({envelopedPresentation} = {}) { - const {id} = envelopedPresentation; - if(id?.startsWith('data:application/jwt,')) { - const format = 'application/jwt'; - const jwt = id.slice('data:application/jwt,'.length); - const claimset = decodeJwt(jwt); - // FIXME: perform various field mappings as needed - console.log('VC-JWT claimset', claimset); - return {presentation: claimset.vp, format}; +function _getEnvelope({envelope, format}) { + const isString = typeof envelope === 'string'; + if(isString) { + // supported formats + if(format === 'application/jwt' || format === 'jwt_vc_json-ld') { + format = 'application/jwt'; + } + } else { + const {id} = envelope; + if(id?.startsWith('data:application/jwt,')) { + format = 'application/jwt'; + envelope = id.slice('data:application/jwt,'.length); + } + } + + if(format === 'application/jwt' && envelope !== undefined) { + return {envelope, format}; } - throw new Error('Not implemented.'); + + throw new BedrockError( + `Unsupported credential or presentation envelope format "${format}".`, { + name: 'NotSupportedError', + details: {httpStatusCode: 400, public: true} + }); } diff --git a/lib/openId.js b/lib/openId.js index 0f174a2..e891d2c 100644 --- a/lib/openId.js +++ b/lib/openId.js @@ -6,7 +6,9 @@ import * as exchanges from './exchanges.js'; import { compile, createValidateMiddleware as validate } from '@bedrock/validation'; -import {evaluateTemplate, getWorkflowIssuerInstances} from './helpers.js'; +import { + evaluateTemplate, getWorkflowIssuerInstances, unenvelopePresentation +} from './helpers.js'; import {importJWK, SignJWT} from 'jose'; import { openIdAuthorizationResponseBody, @@ -57,6 +59,8 @@ instantiating a new authorization server instance per VC exchange. */ const PRE_AUTH_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:pre-authorized_code'; +const VC_CONTEXT_2 = 'https://www.w3.org/ns/credentials/v2'; + // creates OID4VCI Authorization Server + Credential Delivery Server // endpoints for each individual exchange export async function createRoutes({ @@ -379,14 +383,33 @@ export async function createRoutes({ const {vp_token, presentation_submission} = req.body; // JSON parse and validate `vp_token` and `presentation_submission` - const presentation = _jsonParse(vp_token, 'vp_token'); + let presentation = _jsonParse(vp_token, 'vp_token'); const presentationSubmission = _jsonParse( presentation_submission, 'presentation_submission'); _validate(validatePresentationSubmission, presentationSubmission); - _validate(validatePresentation, presentation); - - const result = await _processAuthorizationResponse( - {req, presentation, presentationSubmission}); + let envelope; + if(typeof presentation === 'string') { + // handle enveloped presentation + const { + envelope: raw, presentation: contents, format + } = await unenvelopePresentation({ + envelopedPresentation: presentation, + // FIXME: check presentationSubmission for VP format + format: 'jwt_vc_json-ld' + }); + _validate(validatePresentation, contents); + presentation = { + '@context': VC_CONTEXT_2, + id: `data:${format},${raw}`, + type: 'EnvelopedVerifiablePresentation' + }; + envelope = {raw, contents, format}; + } else { + _validate(validatePresentation, presentation); + } + const result = await _processAuthorizationResponse({ + req, presentation, envelope, presentationSubmission + }); res.json(result); })); @@ -906,7 +929,7 @@ function _matchCredentialRequest(expected, cr) { } async function _processAuthorizationResponse({ - req, presentation, presentationSubmission + req, presentation, envelope, presentationSubmission }) { const {config: workflow} = req.serviceObject; const exchangeRecord = await req.getExchange(); @@ -917,17 +940,17 @@ async function _processAuthorizationResponse({ const {authorizationRequest, step} = arRequest; ({exchange} = arRequest); - // FIXME: if the VP is enveloped, remove the envelope to validate or - // run validation code after verification if necessary - // FIXME: check the VP against the presentation submission if requested // FIXME: check the VP against "trustedIssuer" in VPR, if provided const {presentationSchema} = step; if(presentationSchema) { - // validate the received VP + // if the VP is enveloped, validate the contents of the envelope + const toValidate = envelope ? envelope.contents : presentation; + + // validate the received VP / envelope contents const {jsonSchema: schema} = presentationSchema; const validate = compile({schema}); - const {valid, error} = validate(presentation); + const {valid, error} = validate(toValidate); if(!valid) { throw error; } @@ -937,20 +960,21 @@ async function _processAuthorizationResponse({ const {verifiablePresentationRequest} = await oid4vp.toVpr( {authorizationRequest}); const {allowUnprotectedPresentation = false} = step; - const {verificationMethod} = await verify({ + const verifyResult = await verify({ workflow, verifiablePresentationRequest, presentation, allowUnprotectedPresentation, expectedChallenge: authorizationRequest.nonce }); + const {verificationMethod} = verifyResult; // store VP results in variables associated with current step const currentStep = exchange.step; if(!exchange.variables.results) { exchange.variables.results = {}; } - exchange.variables.results[currentStep] = { + const results = { // common use case of DID Authentication; provide `did` for ease // of use in template did: verificationMethod?.controller || null, @@ -961,6 +985,13 @@ async function _processAuthorizationResponse({ presentationSubmission } }; + if(envelope) { + // normalize VP from inside envelope to `verifiablePresentation` + results.envelopedPresentation = presentation; + results.verifiablePresentation = verifyResult + .presentationResult.presentation; + } + exchange.variables.results[currentStep] = results; exchange.sequence++; // if there is something to issue, update exchange, do not complete it diff --git a/lib/vcapi.js b/lib/vcapi.js index 1289959..c7f7cd4 100644 --- a/lib/vcapi.js +++ b/lib/vcapi.js @@ -4,8 +4,8 @@ import * as bedrock from '@bedrock/core'; import * as exchanges from './exchanges.js'; import {createChallenge as _createChallenge, verify} from './verify.js'; +import {evaluateTemplate, unenvelopePresentation} from './helpers.js'; import {compile} from '@bedrock/validation'; -import {evaluateTemplate} from './helpers.js'; import {issue} from './issue.js'; import {klona} from 'klona'; import {logger} from './logger.js'; @@ -96,15 +96,22 @@ export async function processExchange({req, res, workflow, exchange}) { return; } - // FIXME: if the VP is enveloped, remove the envelope to validate or - // run validation code after verification if necessary - const {presentationSchema} = step; if(presentationSchema) { + // if the VP is enveloped, get the presentation from the envelope + let presentation; + if(receivedPresentation?.type === 'EnvelopedVerifiablePresentation') { + ({presentation} = await unenvelopePresentation({ + envelopedPresentation: receivedPresentation + })); + } else { + presentation = receivedPresentation; + } + // validate the received VP const {jsonSchema: schema} = presentationSchema; const validate = compile({schema}); - const {valid, error} = validate(receivedPresentation); + const {valid, error} = validate(presentation); if(!valid) { throw error; } diff --git a/lib/vcjwt.js b/lib/vcjwt.js new file mode 100644 index 0000000..3e291dc --- /dev/null +++ b/lib/vcjwt.js @@ -0,0 +1,375 @@ +/*! + * Copyright (c) 2022-2024 Digital Bazaar, Inc. All rights reserved. + */ +import * as bedrock from '@bedrock/core'; +import {decodeJwt} from 'jose'; + +const {util: {BedrockError}} = bedrock; + +const VC_CONTEXT_1 = 'https://www.w3.org/2018/credentials/v1'; +const VC_CONTEXT_2 = 'https://www.w3.org/ns/credentials/v2'; + +export function decodeVCJWTCredential({jwt} = {}) { + const payload = decodeJwt(jwt); + + /* Example: + { + "alg": , + "kid": + }. + { + "iss": , + "jti": + "sub": + "nbf": + "exp": + "vc": + } + */ + const {vc} = payload; + if(!(vc && typeof vc === 'object')) { + throw new BedrockError('JWT validation failed.', { + name: 'DataError', + details: { + httpStatusCode: 400, + public: true, + code: 'ERR_JWT_CLAIM_VALIDATION_FAILED', + reason: 'missing or unexpected "vc" claim value.', + claim: 'vc' + } + }); + } + + let {'@context': context = []} = vc; + if(!Array.isArray(context)) { + context = [context]; + } + const isVersion1 = context.includes(VC_CONTEXT_1); + const isVersion2 = context.includes(VC_CONTEXT_2); + if(!(isVersion1 ^ isVersion2)) { + throw new BedrockError( + 'Verifiable credential is neither version "1.x" nor "2.x".', { + name: 'DataError', + details: { + httpStatusCode: 400, + public: true + } + }); + } + + const credential = {...vc}; + const {iss, jti, sub, nbf, exp} = payload; + + // inject `issuer` value + if(vc.issuer === undefined) { + vc.issuer = iss; + } else if(vc.issuer && typeof vc.issuer === 'object' && + vc.issuer.id === undefined) { + vc.issuer = {id: iss, ...vc.issuer}; + } else if(iss !== vc.issuer && iss !== vc.issuer?.id) { + throw new BedrockError( + 'VC-JWT "iss" claim does not equal nor does it exclusively ' + + 'provide verifiable credential "issuer" / "issuer.id".', { + name: 'DataError', + details: { + httpStatusCode: 400, + public: true + } + }); + } + + if(jti !== undefined && jti !== vc.id) { + // inject `id` value + if(vc.id === undefined) { + vc.id = jti; + } else { + throw new BedrockError( + 'VC-JWT "jti" claim does not equal nor does it exclusively ' + + 'provide verifiable credential "id".', { + name: 'DataError', + details: { + httpStatusCode: 400, + public: true + } + }); + } + } + + if(sub !== undefined && sub !== vc.credentialSubject?.id) { + // inject `credentialSubject.id` value + if(!vc.credentialSubject) { + throw new BedrockError( + 'Verifiable credential has no "credentialSubject".', { + name: 'DataError', + details: { + httpStatusCode: 400, + public: true + } + }); + } + if(Array.isArray(vc.credentialSubject)) { + throw new BedrockError( + 'Verifiable credential has multiple credential subjects, which is ' + + 'not supported in VC-JWT.', { + name: 'DataError', + details: { + httpStatusCode: 400, + public: true + } + }); + } + if(vc.credentialSubject?.id === undefined) { + vc.credentialSubject = {id: sub, ...vc.credentialSubject}; + } else { + throw new BedrockError( + 'VC-JWT "sub" claim does not equal nor does it exclusively ' + + 'provide verifiable credential "credentialSubject.id".', { + name: 'DataError', + details: { + httpStatusCode: 400, + public: true + } + }); + } + } + + if(nbf === undefined && isVersion1) { + throw new BedrockError('JWT validation failed.', { + name: 'DataError', + details: { + httpStatusCode: 400, + public: true, + code: 'ERR_JWT_CLAIM_VALIDATION_FAILED', + reason: 'missing "nbf" claim value.', + claim: 'nbf' + } + }); + } + + if(nbf !== undefined) { + // fuzzy convert `nbf` into `issuanceDate` / `validFrom`, only require + // second-level precision + const dateString = new Date(nbf * 1000).toISOString().slice(0, -5); + const dateProperty = isVersion1 ? 'issuanceDate' : 'validFrom'; + // inject dateProperty value + if(vc[dateProperty] === undefined) { + vc[dateProperty] = dateString + 'Z'; + } else if(!(vc[dateProperty].startsWith(dateString) && + vc[dateProperty].endsWith('Z'))) { + throw new BedrockError( + 'VC-JWT "nbf" claim does not equal nor does it exclusively provide ' + + `verifiable credential "${dateProperty}".`, { + name: 'DataError', + details: { + httpStatusCode: 400, + public: true + } + }); + } + } + + if(exp !== undefined) { + // fuzzy convert `exp` into `expirationDate` / `validUntil`, only require + // second-level precision + const dateString = new Date(exp * 1000).toISOString().slice(0, -5); + const dateProperty = isVersion1 ? 'expirationDate' : 'validUntil'; + // inject dateProperty value + if(vc[dateProperty] === undefined) { + vc[dateProperty] = dateString + 'Z'; + } else if(!(vc[dateProperty].startsWith(dateString) && + vc[dateProperty].endsWith('Z'))) { + throw new BedrockError( + 'VC-JWT "exp" claim does not equal nor does it exclusively provide ' + + `verifiable credential "${dateProperty}".`, { + name: 'DataError', + details: { + httpStatusCode: 400, + public: true + } + }); + } + } + + return credential; +} + +export function decodeVCJWTPresentation({jwt, challenge} = {}) { + /* Example: + { + "alg": , + "kid": + }. + { + "iss": , + "aud": , + "nonce": , + "jti": + "nbf": + "exp": + "vp": + } + */ + const payload = decodeJwt(jwt); + + const {vp} = payload; + if(!(vp && typeof vp === 'object')) { + throw new BedrockError('JWT validation failed.', { + name: 'DataError', + details: { + httpStatusCode: 400, + public: true, + code: 'ERR_JWT_CLAIM_VALIDATION_FAILED', + reason: 'missing or unexpected "vp" claim value.', + claim: 'vp' + } + }); + } + + let {'@context': context = []} = vp; + if(!Array.isArray(context)) { + context = [context]; + } + const isVersion1 = context.includes(VC_CONTEXT_1); + const isVersion2 = context.includes(VC_CONTEXT_2); + if(!(isVersion1 ^ isVersion2)) { + throw new BedrockError( + 'Verifiable presentation is not either version "1.x" or "2.x".', { + name: 'DataError', + details: { + httpStatusCode: 400, + public: true + } + }); + } + + const presentation = {...vp}; + const {iss, nonce, jti, nbf, exp} = payload; + + // inject `holder` value + if(vp.holder === undefined) { + vp.holder = iss; + } else if(vp.holder && typeof vp.holder === 'object' && + vp.holder.id === undefined) { + vp.holder = {id: iss, ...vp.holder}; + } else if(iss !== vp.holder && iss !== vp.holder?.id) { + throw new BedrockError( + 'VC-JWT "iss" claim does not equal nor does it exclusively ' + + 'provide verifiable presentation "holder" / "holder.id".', { + name: 'DataError', + details: { + httpStatusCode: 400, + public: true + } + }); + } + + if(jti !== undefined && jti !== vp.id) { + // inject `id` value + if(vp.id === undefined) { + vp.id = jti; + } else { + throw new BedrockError( + 'VC-JWT "jti" claim does not equal nor does it exclusively ' + + 'provide verifiable presentation "id".', { + name: 'DataError', + details: { + httpStatusCode: 400, + public: true + } + }); + } + } + + // version 1.x VPs do not support `validFrom`/`validUntil` + if(nbf !== undefined && isVersion2) { + // fuzzy convert `nbf` into `validFrom`, only require + // second-level precision + const dateString = new Date(nbf * 1000).toISOString().slice(0, -5); + + // inject `validFrom` value + if(vp.validFrom === undefined) { + vp.validFrom = dateString + 'Z'; + } else if(!(vp.validFrom?.startsWith(dateString) && + vp.validFrom.endsWith('Z'))) { + throw new BedrockError( + 'VC-JWT "nbf" claim does not equal nor does it exclusively provide ' + + 'verifiable presentation "validFrom".', { + name: 'DataError', + details: { + httpStatusCode: 400, + public: true + } + }); + } + } + if(exp !== undefined && isVersion2) { + // fuzzy convert `exp` into `validUntil`, only require + // second-level precision + const dateString = new Date(exp * 1000).toISOString().slice(0, -5); + + // inject `validUntil` value + if(vp.validUntil === undefined) { + vp.validUntil = dateString + 'Z'; + } else if(!(vp.validUntil?.startsWith(dateString) && + vp.validUntil?.endsWith('Z'))) { + throw new BedrockError( + 'VC-JWT "exp" claim does not equal nor does it exclusively provide ' + + 'verifiable presentation "validUntil".', { + name: 'DataError', + details: { + httpStatusCode: 400, + public: true + } + }); + } + } + + if(challenge !== undefined && nonce !== challenge) { + throw new BedrockError('JWT validation failed.', { + name: 'DataError', + details: { + httpStatusCode: 400, + public: true, + code: 'ERR_JWT_CLAIM_VALIDATION_FAILED', + reason: 'missing or unexpected "nonce" claim value.', + claim: 'nonce' + } + }); + } + + // do some validation on `verifiableCredential` + let {verifiableCredential = []} = presentation; + if(!Array.isArray(verifiableCredential)) { + verifiableCredential = [verifiableCredential]; + } + + // ensure version 2 VPs only have objects in `verifiableCredential` + const hasVCJWTs = verifiableCredential.some(vc => typeof vc !== 'object'); + if(isVersion2 && hasVCJWTs) { + throw new BedrockError( + 'Version 2.x verifiable presentations must only use objects in the ' + + '"verifiableCredential" field.', { + name: 'DataError', + details: { + httpStatusCode: 400, + public: true + } + }); + } + + // transform any VC-JWT VCs to enveloped VCs + if(presentation.verifiableCredential && hasVCJWTs) { + presentation.verifiableCredential = verifiableCredential.map(vc => { + if(typeof vc !== 'string') { + return vc; + } + return { + '@context': VC_CONTEXT_2, + id: `data:application/jwt,${vc}`, + type: 'EnvelopedVerifiableCredential', + }; + }); + } + + return presentation; +} diff --git a/lib/verify.js b/lib/verify.js index f7e3e55..c88fceb 100644 --- a/lib/verify.js +++ b/lib/verify.js @@ -87,9 +87,13 @@ export async function verify({ // 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' ? 'DataError' : 'OperationError', + name: (name === 'VerificationError' || name === 'DataError') ? + 'DataError' : 'OperationError', details: { + error: causeError, verified, credentialResults, presentationResult, diff --git a/schemas/bedrock-vc-workflow.js b/schemas/bedrock-vc-workflow.js index 618c434..c536e95 100644 --- a/schemas/bedrock-vc-workflow.js +++ b/schemas/bedrock-vc-workflow.js @@ -303,7 +303,7 @@ const vcFormats = { const issuerInstance = { title: 'Issuer Instance', type: 'object', - required: ['zcapReferenceIds'], + required: ['supportedFormats', 'zcapReferenceIds'], additionalProperties: false, properties: { id: { diff --git a/test/mocha/37-oid4vci-oid4vp-vc-jwt.js b/test/mocha/37-oid4vci-oid4vp-vc-jwt.js index 867204a..5934dca 100644 --- a/test/mocha/37-oid4vci-oid4vp-vc-jwt.js +++ b/test/mocha/37-oid4vci-oid4vp-vc-jwt.js @@ -6,6 +6,7 @@ import { OID4Client, oid4vp, parseCredentialOfferUrl } from '@digitalbazaar/oid4-client'; import {agent} from '@bedrock/https-agent'; +import {createPresentation} from '@digitalbazaar/vc'; import {httpClient} from '@digitalbazaar/http-client'; import {klona} from 'klona'; import {mockData} from './mock.data.js'; @@ -20,6 +21,8 @@ const { } = mockData; const credentialFormat = 'jwt_vc_json-ld'; +const VC_CONTEXT_1 = 'https://www.w3.org/2018/credentials/v1'; + describe('exchange w/OID4VCI + OID4VP VC with VC-JWT', () => { let capabilityAgent; @@ -104,6 +107,7 @@ describe('exchange w/OID4VCI + OID4VP VC with VC-JWT', () => { // generate VP ({did, signer} = await helpers.createDidProofSigner()); + signer.algorithm = 'Ed25519'; const {verifiablePresentation} = await helpers.createDidAuthnVP({ domain: baseUrl, challenge: exchangeId.slice(exchangeId.lastIndexOf('/') + 1), @@ -163,10 +167,10 @@ describe('exchange w/OID4VCI + OID4VP VC with VC-JWT', () => { // DID Authn step didAuthn: { stepTemplate: { - //"presentationSchema": presentationSchema, type: 'jsonata', template: ` { + "presentationSchema": presentationSchema, "createChallenge": true, "verifiablePresentationRequest": verifiablePresentationRequest, "openId": { @@ -183,9 +187,17 @@ describe('exchange w/OID4VCI + OID4VP VC with VC-JWT', () => { }; // set initial step const initialStep = 'didAuthn'; + const configOptions = { + credentialTemplates, steps, initialStep, + issuerInstances: [{ + supportedFormats: ['jwt_vc_json-ld'], + zcapReferenceIds: { + issue: 'issue' + } + }] + }; const workflowConfig = await helpers.createWorkflowConfig({ - capabilityAgent, zcaps, credentialTemplates, steps, initialStep, - oauth2: true + capabilityAgent, zcaps, configOptions, oauth2: true }); workflowId = workflowConfig.id; workflowRootZcap = `urn:zcap:root:${encodeURIComponent(workflowId)}`; @@ -193,7 +205,7 @@ describe('exchange w/OID4VCI + OID4VP VC with VC-JWT', () => { // FIXME: add invalid issuer test that will fail against `presentationSchema` - it.skip('should pass w/ pre-authorized code flow', async () => { + it('should pass w/ pre-authorized code flow', async () => { // pre-authorized flow, issuer-initiated const credentialId = `urn:uuid:${uuid()}`; const vpr = { @@ -206,10 +218,10 @@ describe('exchange w/OID4VCI + OID4VP VC with VC-JWT', () => { credentialQuery: [{ reason: 'We require a name verifiable credential to pass this test', example: { - '@context': 'https://www.w3.org/ns/credentials/v2', + '@context': 'https://www.w3.org/2018/credentials/v1', type: 'VerifiableCredential', credentialSubject: { - name: '' + 'ex:name': '' } } }] @@ -270,7 +282,8 @@ describe('exchange w/OID4VCI + OID4VP VC with VC-JWT', () => { credentialDefinition: nameCredentialDefinition, did, didProofSigner: signer, - agent + agent, + format: credentialFormat }); } catch(e) { error = e; @@ -284,7 +297,7 @@ describe('exchange w/OID4VCI + OID4VP VC with VC-JWT', () => { // wallet / client responds to `authorization_request` by performing // OID4VP: - let verifiablePresentation; + let envelopedPresentation; { // generate VPR from authorization request const { @@ -307,10 +320,10 @@ describe('exchange w/OID4VCI + OID4VP VC with VC-JWT', () => { credentialQuery: [{ reason: 'We require a name verifiable credential to pass this test', example: { - '@context': 'https://www.w3.org/ns/credentials/v2', + '@context': 'https://www.w3.org/2018/credentials/v1', type: 'VerifiableCredential', credentialSubject: { - name: '' + 'ex:name': '' } } }] @@ -322,21 +335,36 @@ describe('exchange w/OID4VCI + OID4VP VC with VC-JWT', () => { }; verifiablePresentationRequest.should.deep.equal(expectedVpr); - // generate VP - console.log('generate VP with VC', verifiableCredential); + // generate enveloped VP const {domain, challenge} = verifiablePresentationRequest; - ({verifiablePresentation} = await helpers.createDidAuthnVP({ - domain, challenge, - did, signer, verifiableCredential - })); - console.log('VP', verifiablePresentation); + const presentation = createPresentation({holder: did}); + // force VC-JWT 1.1 mode with `verifiableCredential` as a string + presentation['@context'] = [VC_CONTEXT_1]; + const credentialJwt = verifiableCredential.id.slice( + 'data:application/jwt,'.length); + presentation.verifiableCredential = [credentialJwt]; + const envelopeResult = await helpers.envelopePresentation({ + verifiablePresentation: presentation, + challenge, + domain, + signer + }); + ({envelopedPresentation} = envelopeResult); + const {jwt} = envelopeResult; // send authorization response - console.log('send authz response'); + // FIXME: auto-generate proper presentation submission + const presentationSubmission = { + id: 'ex:example', + definition_id: 'ex:definition', + descriptor_map: [] + }; const { - result, presentationSubmission + result/*, presentationSubmission*/ } = await oid4vp.sendAuthorizationResponse({ - verifiablePresentation, authorizationRequest, agent + verifiablePresentation: presentation, authorizationRequest, + vpToken: JSON.stringify(jwt), agent, + presentationSubmission }); should.exist(result); @@ -352,8 +380,10 @@ describe('exchange w/OID4VCI + OID4VP VC with VC-JWT', () => { should.exist( exchange?.variables?.results?.didAuthn?.verifiablePresentation); exchange?.variables?.results?.didAuthn.did.should.equal(did); - exchange.variables.results.didAuthn.verifiablePresentation - .should.deep.equal(verifiablePresentation); + exchange.variables.results.didAuthn.envelopedPresentation + .should.deep.equal(envelopedPresentation); + exchange.variables.results.didAuthn.verifiablePresentation.holder + .should.equal(did); should.exist(exchange.variables.results.didAuthn.openId); exchange.variables.results.didAuthn.openId.authorizationRequest .should.deep.equal(authorizationRequest); @@ -362,7 +392,7 @@ describe('exchange w/OID4VCI + OID4VP VC with VC-JWT', () => { } catch(error) { err = error; } - should.not.exist(err); + should.not.exist(err, err?.message); } } @@ -374,7 +404,8 @@ describe('exchange w/OID4VCI + OID4VP VC with VC-JWT', () => { credentialDefinition: nameCredentialDefinition, did, didProofSigner: signer, - agent + agent, + format: credentialFormat }); } catch(e) { error = e; @@ -384,10 +415,10 @@ describe('exchange w/OID4VCI + OID4VP VC with VC-JWT', () => { result.should.include.keys(['format', 'credential']); result.format.should.equal(credentialFormat); result.credential.should.be.a('string'); - const credential = await unenvelopeCredential({ - envelopedCredential: result.credential + const {credential} = await unenvelopeCredential({ + envelopedCredential: result.credential, + format: credentialFormat }); - console.log('credential', credential); // ensure credential subject ID matches generated DID should.exist(credential?.credentialSubject?.id); credential.credentialSubject.id.should.equal(did); @@ -407,12 +438,14 @@ describe('exchange w/OID4VCI + OID4VP VC with VC-JWT', () => { should.exist( exchange?.variables?.results?.didAuthn?.verifiablePresentation); exchange?.variables?.results?.didAuthn.did.should.equal(did); - exchange.variables.results.didAuthn.verifiablePresentation - .should.deep.equal(verifiablePresentation); + exchange.variables.results.didAuthn.verifiablePresentation.holder + .should.deep.equal(did); + exchange.variables.results.didAuthn.envelopedPresentation + .should.deep.equal(envelopedPresentation); } catch(error) { err = error; } - should.not.exist(err); + should.not.exist(err, err?.message); } }); }); diff --git a/test/mocha/helpers.js b/test/mocha/helpers.js index 2c60a71..96482bc 100644 --- a/test/mocha/helpers.js +++ b/test/mocha/helpers.js @@ -1,6 +1,7 @@ /*! * Copyright (c) 2019-2024 Digital Bazaar, Inc. All rights reserved. */ +import * as base64url from 'base64url-universal'; import * as bedrock from '@bedrock/core'; import * as Ed25519Multikey from '@digitalbazaar/ed25519-multikey'; import { @@ -30,6 +31,10 @@ import {ZcapClient} from '@digitalbazaar/ezcap'; import {mockData} from './mock.data.js'; +const VC_CONTEXT_2 = 'https://www.w3.org/ns/credentials/v2'; +const TEXT_ENCODER = new TextEncoder(); +const ENCODED_PERIOD = TEXT_ENCODER.encode('.'); + const didKeyDriver = driver(); const edvBaseUrl = `${mockData.baseUrl}/edvs`; const kmsBaseUrl = `${mockData.baseUrl}/kms`; @@ -476,6 +481,69 @@ export async function delegate({ }); } +export async function envelopePresentation({ + verifiablePresentation, challenge, domain, signer, options = {} +} = {}) { + /* Example: + { + "alg": , + "kid": + }. + { + "iss": , + "aud": , + "nonce": , + "jti": + "nbf": + "exp": + "vp": + } + */ + const {id, holder, validFrom, validUntil} = verifiablePresentation; + + const payload = { + iss: holder?.id ?? holder, + aud: domain, + nonce: challenge + }; + + if(id !== undefined) { + payload.jti = id; + } + + let nbf = validFrom; + if(nbf !== undefined) { + nbf = Date.parse(nbf); + if(!isNaN(nbf)) { + payload.nbf = Math.floor(nbf / 1000); + } + } + + let exp = validUntil; + if(exp !== undefined) { + exp = Date.parse(exp); + if(!isNaN(exp)) { + payload.exp = Math.floor(exp / 1000); + } + } + + payload.vp = verifiablePresentation; + + const {id: kid} = signer; + const alg = options.alg ?? _curveToAlg(signer.algorithm); + const protectedHeader = {alg, kid}; + + const jwt = await _signJWT({payload, protectedHeader, signer}); + return { + envelopedPresentation: { + '@context': VC_CONTEXT_2, + id: `data:application/jwt,${jwt}`, + type: 'EnvelopedVerifiablePresentation' + }, + jwt + }; +} + export function generateRandom() { // 128-bit random number, base58 multibase + multihash encoded return generateId({ @@ -644,10 +712,13 @@ export async function provisionIssuer({ let configOptions; if(issueOptions) { const keyDescription = await assertionMethodKey.getKeyDescription(); + const keyController = keyDescription.id.startsWith('did:key:') ? + keyDescription.id.slice(0, keyDescription.id.indexOf('#')) : + keyDescription.controller; const {issuer, cryptosuites, envelope} = issueOptions; configOptions = { issueOptions: { - issuer: issuer ?? keyDescription.controller + issuer: issuer ?? keyController } }; if(cryptosuites) { @@ -889,3 +960,44 @@ async function _generateMultikey({ id, kmsId: keyId, type, invocationSigner, kmsClient, keyDescription }); } + +async function _signJWT({payload, protectedHeader, signer} = {}) { + // encode payload and protected header + const b64Payload = base64url.encode(JSON.stringify(payload)); + const b64ProtectedHeader = base64url.encode(JSON.stringify(protectedHeader)); + payload = TEXT_ENCODER.encode(b64Payload); + protectedHeader = TEXT_ENCODER.encode(b64ProtectedHeader); + + // concatenate + const data = new Uint8Array( + protectedHeader.length + ENCODED_PERIOD.length + payload.length); + data.set(protectedHeader); + data.set(ENCODED_PERIOD, protectedHeader.length); + data.set(payload, protectedHeader.length + ENCODED_PERIOD.length); + + // sign + const signature = await signer.sign({data}); + + // create JWS + const jws = { + signature: base64url.encode(signature), + payload: b64Payload, + protected: b64ProtectedHeader + }; + + // create compact JWT + return `${jws.protected}.${jws.payload}.${jws.signature}`; +} + +function _curveToAlg(crv) { + if(crv === 'Ed25519' || crv === 'Ed448') { + return 'EdDSA'; + } + if(crv?.startsWith('P-')) { + return `ES${crv.slice(2)}`; + } + if(crv === 'secp256k1') { + return 'ES256K'; + } + return crv; +} diff --git a/test/mocha/mock.data.js b/test/mocha/mock.data.js index b18f68f..e797f71 100644 --- a/test/mocha/mock.data.js +++ b/test/mocha/mock.data.js @@ -232,22 +232,22 @@ mockData.prcCredentialDefinition = { mockData.nameCredentialTemplate = ` { "@context": [ - "https://www.w3.org/ns/credentials/v2" + "https://www.w3.org/2018/credentials/v1" ], "id": credentialId, "type": [ "VerifiableCredential" ], "credentialSubject": { - "id": "did:example:ebfeb1f712ebc6f1c276e12ec21", - "name": "Jane Doe" + "id": results.didAuthn.did, + "ex:name": "Jane Doe" } } `; mockData.nameCredentialDefinition = { '@context': [ - 'https://www.w3.org/ns/credentials/v2' + 'https://www.w3.org/2018/credentials/v1' ], type: [ 'VerifiableCredential' @@ -474,7 +474,7 @@ mockData.nameCredentialSchema = { '@context': { type: 'array', items: [{ - const: 'https://www.w3.org/ns/credentials/v2' + const: 'https://www.w3.org/2018/credentials/v1' }] }, id: { @@ -490,15 +490,18 @@ mockData.nameCredentialSchema = { const: 'VerifiableCredential' }] }, + issuanceDate: { + type: 'string' + }, credentialSubject: { type: 'object', - required: ['name'], + required: ['ex:name'], additionalProperties: false, properties: { id: { type: 'string' }, - degree: { + 'ex:name': { type: 'string', name: { const: 'Jane Doe' diff --git a/test/package.json b/test/package.json index 06ec38a..9e860c1 100644 --- a/test/package.json +++ b/test/package.json @@ -43,7 +43,7 @@ "@bedrock/vc-issuer": "^26.0.1", "@bedrock/vc-revocation-list-context": "^5.0.0", "@bedrock/vc-status-list-context": "^6.0.2", - "@bedrock/vc-verifier": "^20.1.0", + "@bedrock/vc-verifier": "^20.1.2", "@bedrock/veres-one-context": "^16.0.0", "@bedrock/zcap-storage": "^8.0.1", "@digitalbazaar/did-method-key": "^4.0.0", @@ -52,10 +52,11 @@ "@digitalbazaar/edv-client": "^16.1.0", "@digitalbazaar/ezcap": "^4.1.0", "@digitalbazaar/http-client": "^4.1.1", - "@digitalbazaar/oid4-client": "^3.4.1", + "@digitalbazaar/oid4-client": "^3.5.0", "@digitalbazaar/vc": "^7.0.0", "@digitalbazaar/vc-status-list": "^8.0.0", "@digitalbazaar/webkms-client": "^14.1.1", + "base64url-universal": "^2.0.0", "bnid": "^3.0.0", "c8": "^10.1.2", "cross-env": "^7.0.3",