From 4f59eafa76d8c4c791fdce920e11b7503c74c7ee Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Thu, 18 Jul 2024 15:20:01 -0400 Subject: [PATCH 1/2] Add `presentationSchema` feature for a workflow step to use a JSON schema. --- CHANGELOG.md | 6 ++ lib/vcapi.js | 12 +++ schemas/bedrock-vc-workflow.js | 13 +++ test/mocha/22-vcapi-verify-vc-issue-vc.js | 56 ++++++++++++- test/mocha/mock.data.js | 97 +++++++++++++++++++++++ 5 files changed, 183 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dd5d68..599b87f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # bedrock-vc-delivery ChangeLog +## 4.8.0 - 2024-07-dd + +### Added +- Add `presentationSchema` option to workflow step to enable passing + a JSON schema to be run against a submitted presentation. + ## 4.7.0 - 2024-07-15 ### Added diff --git a/lib/vcapi.js b/lib/vcapi.js index af631a3..282c767 100644 --- a/lib/vcapi.js +++ b/lib/vcapi.js @@ -4,6 +4,7 @@ import * as bedrock from '@bedrock/core'; import * as exchanges from './exchanges.js'; import {createChallenge as _createChallenge, verify} from './verify.js'; +import {compile} from '@bedrock/validation'; import {evaluateTemplate} from './helpers.js'; import {issue} from './issue.js'; import {klona} from 'klona'; @@ -95,6 +96,17 @@ export async function processExchange({req, res, workflow, exchange}) { return; } + const {presentationSchema} = step; + if(presentationSchema) { + // validate the received VP + const {jsonSchema: schema} = presentationSchema; + const validate = compile({schema}); + const {valid, error} = validate(receivedPresentation); + if(!valid) { + throw error; + } + } + // verify the received VP const expectedChallenge = isInitialStep ? exchange.id : undefined; const {allowUnprotectedPresentation = false} = step; diff --git a/schemas/bedrock-vc-workflow.js b/schemas/bedrock-vc-workflow.js index 19b1ea7..349c357 100644 --- a/schemas/bedrock-vc-workflow.js +++ b/schemas/bedrock-vc-workflow.js @@ -228,6 +228,19 @@ const step = { verifiablePresentationRequest: { type: 'object' }, + presentationSchema: { + type: 'object', + required: ['type', 'jsonSchema'], + additionalProperties: false, + properties: { + type: { + type: 'string' + }, + jsonSchema: { + type: 'object' + } + } + }, jwtDidProofRequest: { type: 'object', additionalProperties: false, diff --git a/test/mocha/22-vcapi-verify-vc-issue-vc.js b/test/mocha/22-vcapi-verify-vc-issue-vc.js index 3400553..8d58626 100644 --- a/test/mocha/22-vcapi-verify-vc-issue-vc.js +++ b/test/mocha/22-vcapi-verify-vc-issue-vc.js @@ -4,11 +4,12 @@ import * as helpers from './helpers.js'; import {agent} from '@bedrock/https-agent'; import {httpClient} from '@digitalbazaar/http-client'; +import {klona} from 'klona'; import {mockData} from './mock.data.js'; import {v4 as uuid} from 'uuid'; const { - baseUrl, didAuthnCredentialTemplate + baseUrl, didAuthnCredentialTemplate, strictDegreePresentationSchema } = mockData; describe('exchange w/ VC-API delivery + DID authn + VC request', () => { @@ -115,6 +116,12 @@ describe('exchange w/ VC-API delivery + DID authn + VC request', () => { type: 'jsonata', template: didAuthnCredentialTemplate }]; + const jsonSchema = klona(strictDegreePresentationSchema); + // FIXME: create a function to inject required `issuer` value + jsonSchema.properties.verifiableCredential.oneOf[0] + .properties.issuer = {const: verifiableCredential.issuer}; + jsonSchema.properties.verifiableCredential.oneOf[1].items + .properties.issuer = {const: verifiableCredential.issuer}; // require semantically-named workflow steps const steps = { // DID Authn step, additionally require VC that was issued from @@ -139,6 +146,10 @@ describe('exchange w/ VC-API delivery + DID authn + VC request', () => { }] }], domain: baseUrl + }, + presentationSchema: { + type: 'JsonSchema', + jsonSchema } } }; @@ -207,6 +218,49 @@ describe('exchange w/ VC-API delivery + DID authn + VC request', () => { } }); + it('should fail when sending VC w/unacceptable issuer', async () => { + const credentialId = `urn:uuid:${uuid()}`; + const {exchangeId} = await helpers.createCredentialOffer({ + // local target user + userId: 'urn:uuid:01cc3771-7c51-47ab-a3a3-6d34b47ae3c4', + credentialDefinition: mockData.credentialDefinition, + credentialId, + preAuthorized: true, + userPinRequired: false, + capabilityAgent, + workflowId, + workflowRootZcap + }); + + const invalidVerifiableCredential = klona(verifiableCredential); + invalidVerifiableCredential.issuer = 'invalid:issuer'; + + // generate VP + const {verifiablePresentation} = await helpers.createDidAuthnVP({ + domain: baseUrl, + challenge: exchangeId.slice(exchangeId.lastIndexOf('/') + 1), + did, signer, verifiableCredential: invalidVerifiableCredential + }); + + // post VP to get VP in response, should produce a validation error on + // the "issuer" field + let err; + let response; + try { + response = await httpClient.post( + exchangeId, {agent, json: {verifiablePresentation}}); + } catch(e) { + err = e; + } + should.not.exist(response); + should.exist(err); + err.status.should.equal(400); + err.data.name.should.equal('ValidationError'); + const issuerError = err.data.details.errors[0]; + issuerError.name.should.equal('ValidationError'); + issuerError.details.path.should.equal('.verifiableCredential.issuer'); + }); + it('should pass when sending VP in second call', async () => { const credentialId = `urn:uuid:${uuid()}`; const {exchangeId} = await helpers.createCredentialOffer({ diff --git a/test/mocha/mock.data.js b/test/mocha/mock.data.js index 5c26c7b..d73d0ee 100644 --- a/test/mocha/mock.data.js +++ b/test/mocha/mock.data.js @@ -342,4 +342,101 @@ mockData.prcCredentialContext = { "Person": "http://schema.org/Person" } }; + +mockData.strictDegreeCredentialSchema = { + title: 'Strict Degree Credential', + type: 'object', + required: ['@context', 'type', 'issuer', 'credentialSubject'], + additionalProperties: false, + properties: { + '@context': { + type: 'array', + items: [{ + const: 'https://www.w3.org/2018/credentials/v1' + }, { + const: 'https://www.w3.org/2018/credentials/examples/v1' + }] + }, + id: { + type: 'string' + }, + // a real system would make `issuer` value a very specific requirement + issuer: { + type: 'string' + }, + type: { + type: 'array', + items: [{ + const: 'VerifiableCredential' + }, { + const: 'UniversityDegreeCredential' + }] + }, + issuanceDate: { + type: 'string' + }, + credentialSubject: { + type: 'object', + required: ['degree'], + additionalProperties: false, + properties: { + id: { + type: 'string' + }, + degree: { + type: 'object', + required: ['type', 'name'], + additionalProperties: false, + properties: { + type: { + const: 'BachelorDegree' + }, + name: { + const: 'Bachelor of Science and Arts' + } + } + } + } + }, + proof: { + oneOf: [{ + type: 'object', + }, { + type: 'array' + }] + } + } +}; + +mockData.strictDegreePresentationSchema = { + title: 'Presentation', + type: 'object', + required: ['@context', 'type', 'verifiableCredential'], + additionalProperties: false, + properties: { + '@context': { + type: 'array' + }, + holder: { + type: 'string' + }, + type: { + type: 'array' + }, + proof: { + oneOf: [{ + type: 'object', + }, { + type: 'array' + }] + }, + verifiableCredential: { + oneOf: [mockData.strictDegreeCredentialSchema, { + type: 'array', + minItems: 1, + items: mockData.strictDegreeCredentialSchema + }] + } + } +}; /* eslint-enable */ From 8f293cb1db57a09ac21a97512e91dd33e4360f02 Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Thu, 18 Jul 2024 15:30:30 -0400 Subject: [PATCH 2/2] Add presentation schema check if OID4VP is used. --- lib/openId.js | 20 +++++++++++++++++--- lib/vcapi.js | 3 +++ test/mocha/35-oid4vci-oid4vp.js | 18 +++++++++++++++++- 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/lib/openId.js b/lib/openId.js index cb521be..b03f6dd 100644 --- a/lib/openId.js +++ b/lib/openId.js @@ -916,6 +916,23 @@ 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 + console.log('run presentation schema'); + const {jsonSchema: schema} = presentationSchema; + const validate = compile({schema}); + const {valid, error} = validate(presentation); + if(!valid) { + throw error; + } + } + // verify the received VP const {verifiablePresentationRequest} = await oid4vp.toVpr( {authorizationRequest}); @@ -928,9 +945,6 @@ async function _processAuthorizationResponse({ expectedChallenge: authorizationRequest.nonce }); - // FIXME: check the VP against the presentation submission if requested - // FIXME: check the VP against "trustedIssuer" in VPR, if provided - // store VP results in variables associated with current step const currentStep = exchange.step; if(!exchange.variables.results) { diff --git a/lib/vcapi.js b/lib/vcapi.js index 282c767..1289959 100644 --- a/lib/vcapi.js +++ b/lib/vcapi.js @@ -96,6 +96,9 @@ 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) { // validate the received VP diff --git a/test/mocha/35-oid4vci-oid4vp.js b/test/mocha/35-oid4vci-oid4vp.js index 284dc4e..46bcdbf 100644 --- a/test/mocha/35-oid4vci-oid4vp.js +++ b/test/mocha/35-oid4vci-oid4vp.js @@ -7,10 +7,13 @@ import { } from '@digitalbazaar/oid4-client'; import {agent} from '@bedrock/https-agent'; import {httpClient} from '@digitalbazaar/http-client'; +import {klona} from 'klona'; import {mockData} from './mock.data.js'; import {v4 as uuid} from 'uuid'; -const {baseUrl, didAuthnCredentialTemplate} = mockData; +const { + baseUrl, didAuthnCredentialTemplate, strictDegreePresentationSchema +} = mockData; describe('exchange w/OID4VCI delivery + OID4VP VC requirement', () => { let capabilityAgent; @@ -126,6 +129,7 @@ describe('exchange w/OID4VCI delivery + OID4VP VC requirement', () => { { "createChallenge": true, "verifiablePresentationRequest": verifiablePresentationRequest, + "presentationSchema": presentationSchema, "openId": { "createAuthorizationRequest": "authorizationRequest", "client_id_scheme": "redirect_uri", @@ -148,6 +152,8 @@ describe('exchange w/OID4VCI delivery + OID4VP VC requirement', () => { workflowRootZcap = `urn:zcap:root:${encodeURIComponent(workflowId)}`; }); + // FIXME: add invalid issuer test that will fail against `presentationSchema` + it('should pass w/ pre-authorized code flow', async () => { // pre-authorized flow, issuer-initiated const credentialId = `urn:uuid:${uuid()}`; @@ -171,6 +177,12 @@ describe('exchange w/OID4VCI delivery + OID4VP VC requirement', () => { }], domain: baseUrl }; + const jsonSchema = klona(strictDegreePresentationSchema); + // FIXME: create a function to inject required `issuer` value + jsonSchema.properties.verifiableCredential.oneOf[0] + .properties.issuer = {const: verifiableCredential.issuer}; + jsonSchema.properties.verifiableCredential.oneOf[1].items + .properties.issuer = {const: verifiableCredential.issuer}; const { exchangeId, openIdUrl: issuanceUrl @@ -187,6 +199,10 @@ describe('exchange w/OID4VCI delivery + OID4VP VC requirement', () => { variables: { credentialId, verifiablePresentationRequest: vpr, + presentationSchema: { + type: 'JsonSchema', + jsonSchema + }, openId: { createAuthorizationRequest: 'authorizationRequest' }