@bedrock/vc-delivery 5.0.1 → 5.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/http.js +10 -73
- package/lib/oid4/http.js +327 -0
- package/lib/oid4/oid4vci.js +566 -0
- package/lib/oid4/oid4vp.js +330 -0
- package/lib/vcapi.js +77 -1
- package/package.json +1 -1
- package/lib/openId.js +0 -1057
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Copyright (c) 2022-2024 Digital Bazaar, Inc. All rights reserved.
|
|
3
|
+
*/
|
|
4
|
+
import * as bedrock from '@bedrock/core';
|
|
5
|
+
import * as exchanges from '../exchanges.js';
|
|
6
|
+
import {evaluateTemplate, unenvelopePresentation} from '../helpers.js';
|
|
7
|
+
import {
|
|
8
|
+
presentationSubmission as presentationSubmissionSchema,
|
|
9
|
+
verifiablePresentation as verifiablePresentationSchema
|
|
10
|
+
} from '../../schemas/bedrock-vc-workflow.js';
|
|
11
|
+
import {compile} from '@bedrock/validation';
|
|
12
|
+
import {klona} from 'klona';
|
|
13
|
+
import {oid4vp} from '@digitalbazaar/oid4-client';
|
|
14
|
+
import {verify} from '../verify.js';
|
|
15
|
+
|
|
16
|
+
const {util: {BedrockError}} = bedrock;
|
|
17
|
+
|
|
18
|
+
const VC_CONTEXT_2 = 'https://www.w3.org/ns/credentials/v2';
|
|
19
|
+
|
|
20
|
+
const VALIDATORS = {
|
|
21
|
+
presentation: null,
|
|
22
|
+
presentationSubmission: null
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
bedrock.events.on('bedrock.init', () => {
|
|
26
|
+
// create validators for x-www-form-urlencoded parsed data
|
|
27
|
+
VALIDATORS.presentation = compile({schema: verifiablePresentationSchema()});
|
|
28
|
+
VALIDATORS.presentationSubmission = compile({
|
|
29
|
+
schema: presentationSubmissionSchema
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export async function getAuthorizationRequest({req}) {
|
|
34
|
+
const {config: workflow} = req.serviceObject;
|
|
35
|
+
const exchangeRecord = await req.getExchange();
|
|
36
|
+
let {exchange} = exchangeRecord;
|
|
37
|
+
let step;
|
|
38
|
+
|
|
39
|
+
while(true) {
|
|
40
|
+
// exchange step required for OID4VP
|
|
41
|
+
const currentStep = exchange.step;
|
|
42
|
+
if(!currentStep) {
|
|
43
|
+
_throwUnsupportedProtocol();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
step = workflow.steps[exchange.step];
|
|
47
|
+
if(step.stepTemplate) {
|
|
48
|
+
// generate step from the template; assume the template type is
|
|
49
|
+
// `jsonata` per the JSON schema
|
|
50
|
+
step = await evaluateTemplate(
|
|
51
|
+
{workflow, exchange, typedTemplate: step.stepTemplate});
|
|
52
|
+
if(Object.keys(step).length === 0) {
|
|
53
|
+
throw new BedrockError('Could not create authorization request.', {
|
|
54
|
+
name: 'DataError',
|
|
55
|
+
details: {httpStatusCode: 500, public: true}
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// step must have `openId` to perform OID4VP
|
|
61
|
+
if(!step.openId) {
|
|
62
|
+
_throwUnsupportedProtocol();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let updateExchange = false;
|
|
66
|
+
|
|
67
|
+
if(exchange.state === 'pending') {
|
|
68
|
+
exchange.state = 'active';
|
|
69
|
+
updateExchange = true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// get authorization request
|
|
73
|
+
let authorizationRequest = step.openId.authorizationRequest;
|
|
74
|
+
if(!authorizationRequest) {
|
|
75
|
+
// create authorization request...
|
|
76
|
+
// get variable name for authorization request
|
|
77
|
+
const authzReqName = step.openId.createAuthorizationRequest;
|
|
78
|
+
if(authzReqName === undefined) {
|
|
79
|
+
_throwUnsupportedProtocol();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// create or get cached authorization request
|
|
83
|
+
authorizationRequest = exchange.variables?.[authzReqName];
|
|
84
|
+
if(!authorizationRequest) {
|
|
85
|
+
const {verifiablePresentationRequest} = step;
|
|
86
|
+
authorizationRequest = oid4vp.fromVpr({verifiablePresentationRequest});
|
|
87
|
+
|
|
88
|
+
// add / override params from step `openId` information
|
|
89
|
+
const {
|
|
90
|
+
client_id, client_id_scheme,
|
|
91
|
+
client_metadata, client_metadata_uri,
|
|
92
|
+
nonce, response_uri
|
|
93
|
+
} = step.openId || {};
|
|
94
|
+
if(client_id) {
|
|
95
|
+
authorizationRequest.client_id = client_id;
|
|
96
|
+
} else {
|
|
97
|
+
authorizationRequest.client_id =
|
|
98
|
+
`${workflow.id}/exchanges/${exchange.id}` +
|
|
99
|
+
'/openid/client/authorization/response';
|
|
100
|
+
}
|
|
101
|
+
if(client_id_scheme) {
|
|
102
|
+
authorizationRequest.client_id_scheme = client_id_scheme;
|
|
103
|
+
} else if(authorizationRequest.client_id_scheme === undefined) {
|
|
104
|
+
authorizationRequest.client_id_scheme = 'redirect_uri';
|
|
105
|
+
}
|
|
106
|
+
if(client_metadata) {
|
|
107
|
+
authorizationRequest.client_metadata = klona(client_metadata);
|
|
108
|
+
} else if(client_metadata_uri) {
|
|
109
|
+
authorizationRequest.client_metadata_uri = client_metadata_uri;
|
|
110
|
+
} else {
|
|
111
|
+
// auto-generate client_metadata
|
|
112
|
+
authorizationRequest.client_metadata = _createClientMetaData();
|
|
113
|
+
}
|
|
114
|
+
if(nonce) {
|
|
115
|
+
authorizationRequest.nonce = nonce;
|
|
116
|
+
} else if(authorizationRequest.nonce === undefined) {
|
|
117
|
+
// if no nonce has been set for the authorization request, use the
|
|
118
|
+
// exchange ID
|
|
119
|
+
authorizationRequest.nonce = exchange.id;
|
|
120
|
+
}
|
|
121
|
+
if(response_uri) {
|
|
122
|
+
authorizationRequest.response_uri = response_uri;
|
|
123
|
+
} else if(authorizationRequest.response_mode === 'direct_post' &&
|
|
124
|
+
authorizationRequest.client_id_scheme === 'redirect_uri') {
|
|
125
|
+
// `authorizationRequest` uses `direct_post` so force client ID to
|
|
126
|
+
// be the exchange response URL per "Note" here:
|
|
127
|
+
// eslint-disable-next-line max-len
|
|
128
|
+
// https://openid.github.io/OpenID4VP/openid-4-verifiable-presentations-wg-draft.html#section-6.2
|
|
129
|
+
authorizationRequest.response_uri = authorizationRequest.client_id;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// store generated authorization request
|
|
133
|
+
updateExchange = true;
|
|
134
|
+
if(!exchange.variables) {
|
|
135
|
+
exchange.variables = {};
|
|
136
|
+
}
|
|
137
|
+
exchange.variables[authzReqName] = authorizationRequest;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if(updateExchange) {
|
|
142
|
+
exchange.sequence++;
|
|
143
|
+
try {
|
|
144
|
+
await exchanges.update({workflowId: workflow.id, exchange});
|
|
145
|
+
} catch(e) {
|
|
146
|
+
if(e.name !== 'InvalidStateError') {
|
|
147
|
+
// unrecoverable error
|
|
148
|
+
throw e;
|
|
149
|
+
}
|
|
150
|
+
// get exchange and loop to try again on `InvalidStateError`
|
|
151
|
+
const record = await exchanges.get(
|
|
152
|
+
{workflowId: workflow.id, id: exchange.id});
|
|
153
|
+
({exchange} = record);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return {authorizationRequest, exchange, step};
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function processAuthorizationResponse({req}) {
|
|
163
|
+
const {
|
|
164
|
+
presentation, envelope, presentationSubmission
|
|
165
|
+
} = await _parseAuthorizationResponse({req});
|
|
166
|
+
|
|
167
|
+
const {config: workflow} = req.serviceObject;
|
|
168
|
+
const exchangeRecord = await req.getExchange();
|
|
169
|
+
let {exchange} = exchangeRecord;
|
|
170
|
+
|
|
171
|
+
// get authorization request and updated exchange associated with exchange
|
|
172
|
+
const arRequest = await getAuthorizationRequest({req});
|
|
173
|
+
const {authorizationRequest, step} = arRequest;
|
|
174
|
+
({exchange} = arRequest);
|
|
175
|
+
|
|
176
|
+
// FIXME: check the VP against the presentation submission if requested
|
|
177
|
+
// FIXME: check the VP against "trustedIssuer" in VPR, if provided
|
|
178
|
+
const {presentationSchema} = step;
|
|
179
|
+
if(presentationSchema) {
|
|
180
|
+
// if the VP is enveloped, validate the contents of the envelope
|
|
181
|
+
const toValidate = envelope ? envelope.contents : presentation;
|
|
182
|
+
|
|
183
|
+
// validate the received VP / envelope contents
|
|
184
|
+
const {jsonSchema: schema} = presentationSchema;
|
|
185
|
+
const validate = compile({schema});
|
|
186
|
+
const {valid, error} = validate(toValidate);
|
|
187
|
+
if(!valid) {
|
|
188
|
+
throw error;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// verify the received VP
|
|
193
|
+
const {verifiablePresentationRequest} = await oid4vp.toVpr(
|
|
194
|
+
{authorizationRequest});
|
|
195
|
+
const {allowUnprotectedPresentation = false} = step;
|
|
196
|
+
const verifyResult = await verify({
|
|
197
|
+
workflow,
|
|
198
|
+
verifiablePresentationRequest,
|
|
199
|
+
presentation,
|
|
200
|
+
allowUnprotectedPresentation,
|
|
201
|
+
expectedChallenge: authorizationRequest.nonce
|
|
202
|
+
});
|
|
203
|
+
const {verificationMethod} = verifyResult;
|
|
204
|
+
|
|
205
|
+
// store VP results in variables associated with current step
|
|
206
|
+
const currentStep = exchange.step;
|
|
207
|
+
if(!exchange.variables.results) {
|
|
208
|
+
exchange.variables.results = {};
|
|
209
|
+
}
|
|
210
|
+
const results = {
|
|
211
|
+
// common use case of DID Authentication; provide `did` for ease
|
|
212
|
+
// of use in template
|
|
213
|
+
did: verificationMethod?.controller || null,
|
|
214
|
+
verificationMethod,
|
|
215
|
+
verifiablePresentation: presentation,
|
|
216
|
+
openId: {
|
|
217
|
+
authorizationRequest,
|
|
218
|
+
presentationSubmission
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
if(envelope) {
|
|
222
|
+
// normalize VP from inside envelope to `verifiablePresentation`
|
|
223
|
+
results.envelopedPresentation = presentation;
|
|
224
|
+
results.verifiablePresentation = verifyResult
|
|
225
|
+
.presentationResult.presentation;
|
|
226
|
+
}
|
|
227
|
+
exchange.variables.results[currentStep] = results;
|
|
228
|
+
exchange.sequence++;
|
|
229
|
+
|
|
230
|
+
// if there is something to issue, update exchange, do not complete it
|
|
231
|
+
const {credentialTemplates = []} = workflow;
|
|
232
|
+
if(credentialTemplates?.length > 0 &&
|
|
233
|
+
(exchange.state === 'pending' || exchange.state === 'active')) {
|
|
234
|
+
// ensure exchange state is set to `active` (will be rejected as a
|
|
235
|
+
// conflict if the state in database at update time isn't `pending` or
|
|
236
|
+
// `active`)
|
|
237
|
+
exchange.state = 'active';
|
|
238
|
+
await exchanges.update({workflowId: workflow.id, exchange});
|
|
239
|
+
} else {
|
|
240
|
+
// mark exchange complete
|
|
241
|
+
await exchanges.complete({workflowId: workflow.id, exchange});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const result = {};
|
|
245
|
+
|
|
246
|
+
// include `redirect_uri` if specified in step
|
|
247
|
+
const redirect_uri = step.openId?.redirect_uri;
|
|
248
|
+
if(redirect_uri) {
|
|
249
|
+
result.redirect_uri = redirect_uri;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return result;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function _createClientMetaData() {
|
|
256
|
+
// return default supported `vp_formats`
|
|
257
|
+
return {
|
|
258
|
+
vp_formats: {
|
|
259
|
+
jwt_vp: {
|
|
260
|
+
alg: ['EdDSA', 'Ed25519', 'ES256', 'ES384']
|
|
261
|
+
},
|
|
262
|
+
ldp_vp: {
|
|
263
|
+
proof_type: [
|
|
264
|
+
'ecdsa-rdfc-2019',
|
|
265
|
+
'eddsa-rdfc-2022',
|
|
266
|
+
'Ed25519Signature2020'
|
|
267
|
+
]
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async function _parseAuthorizationResponse({req}) {
|
|
274
|
+
// get JSON `vp_token` and `presentation_submission`
|
|
275
|
+
const {vp_token, presentation_submission} = req.body;
|
|
276
|
+
|
|
277
|
+
// JSON parse and validate `vp_token` and `presentation_submission`
|
|
278
|
+
let presentation = _jsonParse(vp_token, 'vp_token');
|
|
279
|
+
const presentationSubmission = _jsonParse(
|
|
280
|
+
presentation_submission, 'presentation_submission');
|
|
281
|
+
_validate(VALIDATORS.presentationSubmission, presentationSubmission);
|
|
282
|
+
let envelope;
|
|
283
|
+
if(typeof presentation === 'string') {
|
|
284
|
+
// handle enveloped presentation
|
|
285
|
+
const {
|
|
286
|
+
envelope: raw, presentation: contents, format
|
|
287
|
+
} = await unenvelopePresentation({
|
|
288
|
+
envelopedPresentation: presentation,
|
|
289
|
+
// FIXME: check presentationSubmission for VP format
|
|
290
|
+
format: 'jwt_vc_json-ld'
|
|
291
|
+
});
|
|
292
|
+
_validate(VALIDATORS.presentation, contents);
|
|
293
|
+
presentation = {
|
|
294
|
+
'@context': VC_CONTEXT_2,
|
|
295
|
+
id: `data:${format},${raw}`,
|
|
296
|
+
type: 'EnvelopedVerifiablePresentation'
|
|
297
|
+
};
|
|
298
|
+
envelope = {raw, contents, format};
|
|
299
|
+
} else {
|
|
300
|
+
_validate(VALIDATORS.presentation, presentation);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return {presentation, envelope, presentationSubmission};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function _jsonParse(x, name) {
|
|
307
|
+
try {
|
|
308
|
+
return JSON.parse(x);
|
|
309
|
+
} catch(cause) {
|
|
310
|
+
throw new BedrockError(`Could not parse "${name}".`, {
|
|
311
|
+
name: 'DataError',
|
|
312
|
+
details: {httpStatusCode: 400, public: true},
|
|
313
|
+
cause
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function _throwUnsupportedProtocol() {
|
|
319
|
+
throw new BedrockError('OID4VP is not supported by this exchange.', {
|
|
320
|
+
name: 'NotSupportedError',
|
|
321
|
+
details: {httpStatusCode: 400, public: true}
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function _validate(validator, data) {
|
|
326
|
+
const result = validator(data);
|
|
327
|
+
if(!result.valid) {
|
|
328
|
+
throw result.error;
|
|
329
|
+
}
|
|
330
|
+
}
|
package/lib/vcapi.js
CHANGED
|
@@ -4,7 +4,10 @@
|
|
|
4
4
|
import * as bedrock from '@bedrock/core';
|
|
5
5
|
import * as exchanges from './exchanges.js';
|
|
6
6
|
import {createChallenge as _createChallenge, verify} from './verify.js';
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
evaluateTemplate, generateRandom, unenvelopePresentation
|
|
9
|
+
} from './helpers.js';
|
|
10
|
+
import {exportJWK, generateKeyPair, importJWK} from 'jose';
|
|
8
11
|
import {compile} from '@bedrock/validation';
|
|
9
12
|
import {issue} from './issue.js';
|
|
10
13
|
import {klona} from 'klona';
|
|
@@ -13,6 +16,79 @@ import {logger} from './logger.js';
|
|
|
13
16
|
const {util: {BedrockError}} = bedrock;
|
|
14
17
|
|
|
15
18
|
const MAXIMUM_STEPS = 100;
|
|
19
|
+
const FIFTEEN_MINUTES = 60 * 15;
|
|
20
|
+
|
|
21
|
+
export async function createExchange({workflow, exchange}) {
|
|
22
|
+
const {
|
|
23
|
+
ttl = FIFTEEN_MINUTES, openId, variables = {},
|
|
24
|
+
// allow steps to be skipped by creator as needed
|
|
25
|
+
step = workflow.initialStep
|
|
26
|
+
} = exchange;
|
|
27
|
+
|
|
28
|
+
// validate exchange step, if given
|
|
29
|
+
if(step && !(step in workflow.steps)) {
|
|
30
|
+
throw new BedrockError(`Undefined step "${step}".`, {
|
|
31
|
+
name: 'DataError',
|
|
32
|
+
details: {httpStatusCode: 400, public: true}
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if(openId) {
|
|
37
|
+
// either issuer instances or a single issuer zcap be given if
|
|
38
|
+
// any expected credential requests are given
|
|
39
|
+
const {expectedCredentialRequests} = openId;
|
|
40
|
+
if(expectedCredentialRequests &&
|
|
41
|
+
!(workflow.issuerInstances || workflow.zcaps.issue)) {
|
|
42
|
+
throw new BedrockError(
|
|
43
|
+
'Credential requests are not supported by this workflow.', {
|
|
44
|
+
name: 'DataError',
|
|
45
|
+
details: {httpStatusCode: 400, public: true}
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// perform key generation if requested
|
|
50
|
+
if(openId.oauth2?.generateKeyPair) {
|
|
51
|
+
const {oauth2} = openId;
|
|
52
|
+
const {algorithm} = oauth2.generateKeyPair;
|
|
53
|
+
const kp = await generateKeyPair(algorithm, {extractable: true});
|
|
54
|
+
const [privateKeyJwk, publicKeyJwk] = await Promise.all([
|
|
55
|
+
exportJWK(kp.privateKey),
|
|
56
|
+
exportJWK(kp.publicKey),
|
|
57
|
+
]);
|
|
58
|
+
oauth2.keyPair = {privateKeyJwk, publicKeyJwk};
|
|
59
|
+
delete oauth2.generateKeyPair;
|
|
60
|
+
} else {
|
|
61
|
+
// ensure key pair can be imported
|
|
62
|
+
try {
|
|
63
|
+
const {oauth2: {keyPair}} = openId;
|
|
64
|
+
await Promise.all([
|
|
65
|
+
importJWK(keyPair.privateKeyJwk),
|
|
66
|
+
importJWK(keyPair.publicKeyJwk)
|
|
67
|
+
]);
|
|
68
|
+
} catch(e) {
|
|
69
|
+
throw new BedrockError(
|
|
70
|
+
'Could not import OpenID OAuth2 key pair.', {
|
|
71
|
+
name: 'DataError',
|
|
72
|
+
details: {httpStatusCode: 400, public: true},
|
|
73
|
+
cause: e
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// insert exchange
|
|
80
|
+
const {id: workflowId} = workflow;
|
|
81
|
+
exchange = {
|
|
82
|
+
id: await generateRandom(),
|
|
83
|
+
ttl,
|
|
84
|
+
variables,
|
|
85
|
+
openId,
|
|
86
|
+
step
|
|
87
|
+
};
|
|
88
|
+
await exchanges.insert({workflowId, exchange});
|
|
89
|
+
// FIXME: run parallel process to pre-warm cache with new exchange record
|
|
90
|
+
return exchange;
|
|
91
|
+
}
|
|
16
92
|
|
|
17
93
|
export async function processExchange({req, res, workflow, exchange}) {
|
|
18
94
|
// get any `verifiablePresentation` from the body...
|