@bedrock/vc-delivery 5.2.0 → 5.3.1
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/exchanges.js +185 -48
- package/lib/helpers.js +16 -0
- package/lib/http.js +2 -2
- package/lib/oid4/oid4vci.js +143 -112
- package/lib/oid4/oid4vp.js +101 -77
- package/lib/vcapi.js +176 -146
- package/lib/verify.js +24 -19
- package/package.json +3 -2
package/lib/exchanges.js
CHANGED
|
@@ -3,18 +3,16 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import * as bedrock from '@bedrock/core';
|
|
5
5
|
import * as database from '@bedrock/mongodb';
|
|
6
|
+
import {parseLocalId, stripStacktrace} from './helpers.js';
|
|
6
7
|
import assert from 'assert-plus';
|
|
7
8
|
import {logger} from './logger.js';
|
|
8
|
-
import {
|
|
9
|
+
import {serializeError} from 'serialize-error';
|
|
9
10
|
|
|
10
11
|
const {util: {BedrockError}} = bedrock;
|
|
11
12
|
|
|
12
|
-
/* Note: Exchanges
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
Exchanges are always in one of three states: `pending`, `complete`, or
|
|
16
|
-
`invalid`. They can only transistion from `pending` to `complete` or from
|
|
17
|
-
`complete` to `invalid`.
|
|
13
|
+
/* Note: Exchanges have default TTLs of 15 minutes and are always in one of
|
|
14
|
+
four states: `pending`, `active`, `complete`, or `invalid`. They can only
|
|
15
|
+
transition from `pending` to `complete` or from `complete` to `invalid`.
|
|
18
16
|
|
|
19
17
|
If an exchange is marked as complete, any attempt to mark it complete again
|
|
20
18
|
will result in an action, as specified in the exchange record, being taken
|
|
@@ -28,6 +26,15 @@ provided during the exchange a capability to verify them must be provided. */
|
|
|
28
26
|
|
|
29
27
|
const COLLECTION_NAME = 'vc-exchange';
|
|
30
28
|
|
|
29
|
+
// allow updates to the last error every 500ms
|
|
30
|
+
const LAST_ERROR_UPDATE_CONSTRAINTS = {
|
|
31
|
+
// if the exchange has been updated 5 or more times, apply the time limit
|
|
32
|
+
sequenceThreshold: 5,
|
|
33
|
+
// 1 second must expire before updating last error once sequence threshold
|
|
34
|
+
// has been hit
|
|
35
|
+
updateTimeLimit: 1000
|
|
36
|
+
};
|
|
37
|
+
|
|
31
38
|
bedrock.events.on('bedrock-mongodb.ready', async () => {
|
|
32
39
|
await database.openCollections([COLLECTION_NAME]);
|
|
33
40
|
|
|
@@ -88,9 +95,14 @@ export async function insert({workflowId, exchange}) {
|
|
|
88
95
|
// build exchange record
|
|
89
96
|
const now = Date.now();
|
|
90
97
|
const meta = {created: now, updated: now};
|
|
98
|
+
// possible states are: `pending`, `active`, `complete`, or `invalid`
|
|
99
|
+
exchange = {...exchange, sequence: 0, state: 'pending'};
|
|
91
100
|
if(exchange.ttl !== undefined) {
|
|
92
|
-
// TTL is in seconds
|
|
93
|
-
|
|
101
|
+
// TTL is in seconds, convert to `expires`
|
|
102
|
+
const expires = new Date(now + exchange.ttl * 1000);
|
|
103
|
+
meta.expires = expires;
|
|
104
|
+
exchange.expires = expires.toISOString().replace(/\.\d+Z$/, 'Z');
|
|
105
|
+
delete exchange.ttl;
|
|
94
106
|
}
|
|
95
107
|
const {localId: localWorkflowId} = parseLocalId({id: workflowId});
|
|
96
108
|
const record = {
|
|
@@ -98,8 +110,7 @@ export async function insert({workflowId, exchange}) {
|
|
|
98
110
|
// backwards compatibility: enable existing systems to find record
|
|
99
111
|
localExchangerId: localWorkflowId,
|
|
100
112
|
meta,
|
|
101
|
-
|
|
102
|
-
exchange: {...exchange, sequence: 0, state: 'pending'}
|
|
113
|
+
exchange
|
|
103
114
|
};
|
|
104
115
|
|
|
105
116
|
// insert the exchange and get the updated record
|
|
@@ -129,12 +140,16 @@ export async function insert({workflowId, exchange}) {
|
|
|
129
140
|
* @param {string} options.workflowId - The ID of the workflow that the
|
|
130
141
|
* exchange is associated with.
|
|
131
142
|
* @param {string} options.id - The ID of the exchange to retrieve.
|
|
143
|
+
* @param {boolean} [options.allowExpired=false] - Controls whether an expired
|
|
144
|
+
* exchange that is still in the database can be retrieved or not.
|
|
132
145
|
* @param {boolean} [options.explain=false] - An optional explain boolean.
|
|
133
146
|
*
|
|
134
147
|
* @returns {Promise<object | ExplainObject>} Resolves with the record that
|
|
135
148
|
* matches the query or an ExplainObject if `explain=true`.
|
|
136
149
|
*/
|
|
137
|
-
export async function get({
|
|
150
|
+
export async function get({
|
|
151
|
+
workflowId, id, allowExpired = false, explain = false
|
|
152
|
+
} = {}) {
|
|
138
153
|
assert.string(workflowId, 'workflowId');
|
|
139
154
|
assert.string(id, 'id');
|
|
140
155
|
|
|
@@ -160,7 +175,16 @@ export async function get({workflowId, id, explain = false} = {}) {
|
|
|
160
175
|
return cursor.explain('executionStats');
|
|
161
176
|
}
|
|
162
177
|
|
|
163
|
-
|
|
178
|
+
let record = await collection.findOne(query, {projection});
|
|
179
|
+
if(record?.exchange.expires && !allowExpired) {
|
|
180
|
+
// ensure `expires` is enforced programmatically even if background job
|
|
181
|
+
// has not yet removed the record
|
|
182
|
+
const now = new Date();
|
|
183
|
+
const expires = new Date(record.exchange.expires);
|
|
184
|
+
if(now >= expires) {
|
|
185
|
+
record = null;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
164
188
|
if(!record) {
|
|
165
189
|
throw new BedrockError('Exchange not found.', {
|
|
166
190
|
name: 'NotFoundError',
|
|
@@ -196,8 +220,8 @@ export async function get({workflowId, id, explain = false} = {}) {
|
|
|
196
220
|
}
|
|
197
221
|
|
|
198
222
|
/**
|
|
199
|
-
* Updates a pending exchange with new state, variables, step, and
|
|
200
|
-
* information.
|
|
223
|
+
* Updates a pending or active exchange with new state, variables, step, and
|
|
224
|
+
* TTL, and error information.
|
|
201
225
|
*
|
|
202
226
|
* @param {object} options - The options to use.
|
|
203
227
|
* @param {string} options.workflowId - The ID of the workflow the exchange
|
|
@@ -214,21 +238,7 @@ export async function update({workflowId, exchange, explain = false} = {}) {
|
|
|
214
238
|
const {id} = exchange;
|
|
215
239
|
|
|
216
240
|
// build update
|
|
217
|
-
const
|
|
218
|
-
const update = {
|
|
219
|
-
$inc: {'exchange.sequence': 1},
|
|
220
|
-
$set: {'exchange.state': exchange.state, 'meta.updated': now}
|
|
221
|
-
};
|
|
222
|
-
// update exchange `variables`, `step`, and `ttl`
|
|
223
|
-
if(exchange.variables) {
|
|
224
|
-
update.$set['exchange.variables'] = exchange.variables;
|
|
225
|
-
}
|
|
226
|
-
if(exchange.step !== undefined) {
|
|
227
|
-
update.$set['exchange.step'] = exchange.step;
|
|
228
|
-
}
|
|
229
|
-
if(exchange.ttl !== undefined) {
|
|
230
|
-
update.$set['exchange.ttl'] = exchange.ttl;
|
|
231
|
-
}
|
|
241
|
+
const update = _buildUpdate({exchange, complete: false});
|
|
232
242
|
|
|
233
243
|
const {base, localId: localWorkflowId} = parseLocalId({id: workflowId});
|
|
234
244
|
|
|
@@ -304,27 +314,13 @@ export async function update({workflowId, exchange, explain = false} = {}) {
|
|
|
304
314
|
export async function complete({workflowId, exchange, explain = false} = {}) {
|
|
305
315
|
assert.string(workflowId, 'workflowId');
|
|
306
316
|
assert.object(exchange, 'exchange');
|
|
317
|
+
if(exchange.state !== 'complete') {
|
|
318
|
+
throw new Error('"exchange.state" must be set to "complete".');
|
|
319
|
+
}
|
|
307
320
|
const {id} = exchange;
|
|
308
321
|
|
|
309
322
|
// build update
|
|
310
|
-
const
|
|
311
|
-
const update = {
|
|
312
|
-
$inc: {'exchange.sequence': 1},
|
|
313
|
-
$set: {
|
|
314
|
-
'exchange.state': 'complete',
|
|
315
|
-
'meta.updated': now
|
|
316
|
-
}
|
|
317
|
-
};
|
|
318
|
-
// update exchange `variables.results[step]`, `step`, and `ttl`
|
|
319
|
-
if(exchange.variables?.results) {
|
|
320
|
-
update.$set['exchange.variables.results'] = exchange.variables.results;
|
|
321
|
-
}
|
|
322
|
-
if(exchange.step !== undefined) {
|
|
323
|
-
update.$set['exchange.step'] = exchange.step;
|
|
324
|
-
}
|
|
325
|
-
if(exchange.ttl !== undefined) {
|
|
326
|
-
update.$set['exchange.ttl'] = exchange.ttl;
|
|
327
|
-
}
|
|
323
|
+
const update = _buildUpdate({exchange, complete: true});
|
|
328
324
|
|
|
329
325
|
const {base, localId: localWorkflowId} = parseLocalId({id: workflowId});
|
|
330
326
|
|
|
@@ -408,6 +404,105 @@ export async function complete({workflowId, exchange, explain = false} = {}) {
|
|
|
408
404
|
});
|
|
409
405
|
}
|
|
410
406
|
|
|
407
|
+
/**
|
|
408
|
+
* Sets the last error associated with an exchange, provided that the exchange
|
|
409
|
+
* has not been recently or frequently updated.
|
|
410
|
+
*
|
|
411
|
+
* @param {object} options - The options to use.
|
|
412
|
+
* @param {string} options.workflowId - The ID of the workflow the exchange
|
|
413
|
+
* is associated with.
|
|
414
|
+
* @param {object} options.exchange - The exchange to update with `lastError`
|
|
415
|
+
* set.
|
|
416
|
+
* @param {object} options.lastUpdated - The last update time (in milliseconds).
|
|
417
|
+
* @param {boolean} [options.explain=false] - An optional explain boolean.
|
|
418
|
+
*
|
|
419
|
+
* @returns {Promise<boolean | ExplainObject>} Resolves with `true` on update
|
|
420
|
+
* success or an ExplainObject if `explain=true`.
|
|
421
|
+
*/
|
|
422
|
+
export async function setLastError({
|
|
423
|
+
workflowId, exchange, lastUpdated, explain = false
|
|
424
|
+
} = {}) {
|
|
425
|
+
assert.string(workflowId, 'workflowId');
|
|
426
|
+
assert.object(exchange, 'exchange');
|
|
427
|
+
assert.object(exchange.lastError, 'exchange.lastError');
|
|
428
|
+
assert.number(lastUpdated, 'lastUpdate');
|
|
429
|
+
|
|
430
|
+
// prevent too many updates to an exchange to write the last error to it
|
|
431
|
+
// by limiting to a few
|
|
432
|
+
const now = Date.now();
|
|
433
|
+
if(exchange.sequence > LAST_ERROR_UPDATE_CONSTRAINTS.sequenceThreshold &&
|
|
434
|
+
now < (lastUpdated + LAST_ERROR_UPDATE_CONSTRAINTS.updateTimeLimit)) {
|
|
435
|
+
// deny update, too many last error updates
|
|
436
|
+
return false;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// build update
|
|
440
|
+
const update = {
|
|
441
|
+
$inc: {'exchange.sequence': 1},
|
|
442
|
+
$set: {
|
|
443
|
+
'meta.updated': now,
|
|
444
|
+
'exchange.lastError': serializeError(stripStacktrace(exchange.lastError))
|
|
445
|
+
}
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
const {base, localId: localWorkflowId} = parseLocalId({id: workflowId});
|
|
449
|
+
|
|
450
|
+
const {id} = exchange;
|
|
451
|
+
const collection = database.collections[COLLECTION_NAME];
|
|
452
|
+
const query = {
|
|
453
|
+
localWorkflowId,
|
|
454
|
+
'exchange.id': id,
|
|
455
|
+
// exchange sequence must match previous sequence
|
|
456
|
+
'exchange.sequence': exchange.sequence - 1
|
|
457
|
+
};
|
|
458
|
+
// backwards compatibility: query on `localExchangerId`
|
|
459
|
+
if(base.endsWith('/exchangers')) {
|
|
460
|
+
query.localWorkflowId = {$in: [null, localWorkflowId]};
|
|
461
|
+
query.localExchangerId = localWorkflowId;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if(explain) {
|
|
465
|
+
// 'find().limit(1)' is used here because 'updateOne()' doesn't return a
|
|
466
|
+
// cursor which allows the use of the explain function.
|
|
467
|
+
const cursor = await collection.find(query).limit(1);
|
|
468
|
+
return cursor.explain('executionStats');
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
try {
|
|
472
|
+
const result = await collection.updateOne(query, update);
|
|
473
|
+
if(result.result.n > 0) {
|
|
474
|
+
// document modified: success
|
|
475
|
+
return true;
|
|
476
|
+
}
|
|
477
|
+
} catch(e) {
|
|
478
|
+
throw new BedrockError('Could not update exchange.', {
|
|
479
|
+
name: 'OperationError',
|
|
480
|
+
details: {
|
|
481
|
+
public: true,
|
|
482
|
+
httpStatusCode: 500
|
|
483
|
+
},
|
|
484
|
+
cause: e
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// if no document was matched, try to get an existing exchange; if the
|
|
489
|
+
// exchange does not exist, a not found error will be automatically thrown
|
|
490
|
+
await get({workflowId, id});
|
|
491
|
+
|
|
492
|
+
/* Note: Here the exchange *does* exist, but the step or state did not
|
|
493
|
+
match which is a conflict error. */
|
|
494
|
+
|
|
495
|
+
// throw duplicate completed exchange error
|
|
496
|
+
throw new BedrockError('Could not update exchange; conflict error.', {
|
|
497
|
+
name: 'InvalidStateError',
|
|
498
|
+
details: {
|
|
499
|
+
public: true,
|
|
500
|
+
// this is a client-side conflict error
|
|
501
|
+
httpStatusCode: 409
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
|
|
411
506
|
async function _invalidateExchange({record}) {
|
|
412
507
|
try {
|
|
413
508
|
// mark exchange invalid, but do not throw any error to client; only log it
|
|
@@ -463,6 +558,48 @@ async function _markExchangeInvalid({record}) {
|
|
|
463
558
|
}
|
|
464
559
|
}
|
|
465
560
|
|
|
561
|
+
function _buildUpdate({exchange, complete}) {
|
|
562
|
+
// build update
|
|
563
|
+
const now = Date.now();
|
|
564
|
+
const update = {
|
|
565
|
+
$inc: {'exchange.sequence': 1},
|
|
566
|
+
$set: {'exchange.state': exchange.state, 'meta.updated': now},
|
|
567
|
+
$unset: {}
|
|
568
|
+
};
|
|
569
|
+
if(complete) {
|
|
570
|
+
// exchange complete, only update results
|
|
571
|
+
if(exchange.variables?.results) {
|
|
572
|
+
update.$set['exchange.variables.results'] = exchange.variables.results;
|
|
573
|
+
}
|
|
574
|
+
} else {
|
|
575
|
+
// exchange not complete, update all variables
|
|
576
|
+
if(exchange.variables) {
|
|
577
|
+
update.$set['exchange.variables'] = exchange.variables;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
if(exchange.step !== undefined) {
|
|
581
|
+
update.$set['exchange.step'] = exchange.step;
|
|
582
|
+
}
|
|
583
|
+
// only set `ttl` if expires not previously set / has been cleared
|
|
584
|
+
if(exchange.ttl !== undefined && exchange.expires === undefined) {
|
|
585
|
+
// TTL is in seconds, convert to expires
|
|
586
|
+
const expires = new Date(now + exchange.ttl * 1000);
|
|
587
|
+
// unset and previously set `ttl`
|
|
588
|
+
update.$unset['exchange.ttl'] = true;
|
|
589
|
+
update.$set['meta.expires'] = expires;
|
|
590
|
+
update.$set['exchange.expires'] =
|
|
591
|
+
expires.toISOString().replace(/\.\d+Z$/, 'Z');
|
|
592
|
+
}
|
|
593
|
+
if(exchange.lastError !== undefined) {
|
|
594
|
+
update.$set['exchange.lastError'] =
|
|
595
|
+
serializeError(stripStacktrace(exchange.lastError));
|
|
596
|
+
} else {
|
|
597
|
+
update.$unset['exchange.lastError'] = true;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
return update;
|
|
601
|
+
}
|
|
602
|
+
|
|
466
603
|
/**
|
|
467
604
|
* An object containing information on the query plan.
|
|
468
605
|
*
|
package/lib/helpers.js
CHANGED
|
@@ -7,6 +7,7 @@ import {decodeId, generateId} from 'bnid';
|
|
|
7
7
|
import {Ed25519Signature2020} from '@digitalbazaar/ed25519-signature-2020';
|
|
8
8
|
import {httpsAgent} from '@bedrock/https-agent';
|
|
9
9
|
import jsonata from 'jsonata';
|
|
10
|
+
import {serializeError} from 'serialize-error';
|
|
10
11
|
import {serviceAgents} from '@bedrock/service-agent';
|
|
11
12
|
import {ZcapClient} from '@digitalbazaar/ezcap';
|
|
12
13
|
|
|
@@ -104,6 +105,21 @@ export function decodeLocalId({localId} = {}) {
|
|
|
104
105
|
}));
|
|
105
106
|
}
|
|
106
107
|
|
|
108
|
+
export function stripStacktrace(error) {
|
|
109
|
+
error = serializeError(error);
|
|
110
|
+
delete error.stack;
|
|
111
|
+
if(error.errors) {
|
|
112
|
+
error.errors = error.errors.map(stripStacktrace);
|
|
113
|
+
}
|
|
114
|
+
if(Array.isArray(error.details?.errors)) {
|
|
115
|
+
error.details.errors = error.details.errors.map(stripStacktrace);
|
|
116
|
+
}
|
|
117
|
+
if(error.cause) {
|
|
118
|
+
error.cause = stripStacktrace(error.cause);
|
|
119
|
+
}
|
|
120
|
+
return error;
|
|
121
|
+
}
|
|
122
|
+
|
|
107
123
|
export async function unenvelopeCredential({
|
|
108
124
|
envelopedCredential, format
|
|
109
125
|
} = {}) {
|
package/lib/http.js
CHANGED
|
@@ -113,8 +113,8 @@ export async function addRoutes({app, service} = {}) {
|
|
|
113
113
|
getConfigMiddleware,
|
|
114
114
|
asyncHandler(async (req, res) => {
|
|
115
115
|
const {config: workflow} = req.serviceObject;
|
|
116
|
-
const
|
|
117
|
-
await processExchange({req, res, workflow,
|
|
116
|
+
const exchangeRecord = await req.getExchange();
|
|
117
|
+
await processExchange({req, res, workflow, exchangeRecord});
|
|
118
118
|
}));
|
|
119
119
|
|
|
120
120
|
// create OID4* routes to be used with each individual exchange
|
package/lib/oid4/oid4vci.js
CHANGED
|
@@ -10,6 +10,7 @@ import {importJWK, SignJWT} from 'jose';
|
|
|
10
10
|
import {checkAccessToken} from '@bedrock/oauth2-verifier';
|
|
11
11
|
import {getAuthorizationRequest} from './oid4vp.js';
|
|
12
12
|
import {issue} from '../issue.js';
|
|
13
|
+
import {logger} from '../logger.js';
|
|
13
14
|
import {timingSafeEqual} from 'node:crypto';
|
|
14
15
|
import {verifyDidProofJwt} from '../verify.js';
|
|
15
16
|
|
|
@@ -145,118 +146,7 @@ export async function processCredentialRequests({req, res, isBatchRequest}) {
|
|
|
145
146
|
// ensure oauth2 access token is valid
|
|
146
147
|
await _checkAuthz({req, workflow, exchange});
|
|
147
148
|
|
|
148
|
-
|
|
149
|
-
const {openId: {expectedCredentialRequests}} = exchange;
|
|
150
|
-
let credentialRequests;
|
|
151
|
-
if(isBatchRequest) {
|
|
152
|
-
({credential_requests: credentialRequests} = req.body);
|
|
153
|
-
} else {
|
|
154
|
-
if(expectedCredentialRequests.length > 1) {
|
|
155
|
-
// FIXME: it is no longer the case that the batch endpoint must be used
|
|
156
|
-
// for multiple requests; determine if the request has changed
|
|
157
|
-
|
|
158
|
-
// clients interacting with exchanges with more than one VC to be
|
|
159
|
-
// delivered must use the "batch credential" endpoint
|
|
160
|
-
// FIXME: improve error
|
|
161
|
-
throw new Error('batch_credential_endpoint must be used');
|
|
162
|
-
}
|
|
163
|
-
credentialRequests = [req.body];
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
// before asserting, normalize credential requests to use `type` instead of
|
|
167
|
-
// `types`; this is to allow for OID4VCI draft implementers that followed
|
|
168
|
-
// the non-normative examples
|
|
169
|
-
_normalizeCredentialDefinitionTypes({credentialRequests});
|
|
170
|
-
const {format} = _assertCredentialRequests({
|
|
171
|
-
workflow, credentialRequests, expectedCredentialRequests
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
// process exchange step if present
|
|
175
|
-
const currentStep = exchange.step;
|
|
176
|
-
if(currentStep) {
|
|
177
|
-
let step = workflow.steps[exchange.step];
|
|
178
|
-
if(step.stepTemplate) {
|
|
179
|
-
// generate step from the template; assume the template type is
|
|
180
|
-
// `jsonata` per the JSON schema
|
|
181
|
-
step = await evaluateTemplate(
|
|
182
|
-
{workflow, exchange, typedTemplate: step.stepTemplate});
|
|
183
|
-
if(Object.keys(step).length === 0) {
|
|
184
|
-
throw new BedrockError('Could not create exchange step.', {
|
|
185
|
-
name: 'DataError',
|
|
186
|
-
details: {httpStatusCode: 500, public: true}
|
|
187
|
-
});
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
// do late workflow configuration validation
|
|
192
|
-
const {jwtDidProofRequest, openId} = step;
|
|
193
|
-
// use of `jwtDidProofRequest` and `openId` together is prohibited
|
|
194
|
-
if(jwtDidProofRequest && openId) {
|
|
195
|
-
throw new BedrockError(
|
|
196
|
-
'Invalid workflow configuration; only one of ' +
|
|
197
|
-
'"jwtDidProofRequest" and "openId" is permitted in a step.', {
|
|
198
|
-
name: 'DataError',
|
|
199
|
-
details: {httpStatusCode: 500, public: true}
|
|
200
|
-
});
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
// check to see if step supports OID4VP during OID4VCI
|
|
204
|
-
if(step.openId) {
|
|
205
|
-
// if there is no `presentationSubmission`, request one
|
|
206
|
-
const {results} = exchange.variables;
|
|
207
|
-
if(!results?.[exchange.step]?.openId?.presentationSubmission) {
|
|
208
|
-
// FIXME: optimize away double step-template processing that currently
|
|
209
|
-
// occurs when calling `_getAuthorizationRequest`
|
|
210
|
-
const {
|
|
211
|
-
authorizationRequest
|
|
212
|
-
} = await getAuthorizationRequest({req});
|
|
213
|
-
return _requestOID4VP({authorizationRequest, res});
|
|
214
|
-
}
|
|
215
|
-
// otherwise drop down below to complete exchange...
|
|
216
|
-
} else if(jwtDidProofRequest) {
|
|
217
|
-
// handle OID4VCI specialized JWT DID Proof request...
|
|
218
|
-
|
|
219
|
-
// `proof` must be in every credential request; if any request is missing
|
|
220
|
-
// `proof` then request a DID proof
|
|
221
|
-
if(credentialRequests.some(cr => !cr.proof?.jwt)) {
|
|
222
|
-
return _requestDidProof({res, exchangeRecord});
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
// verify every DID proof and get resulting DIDs
|
|
226
|
-
const results = await Promise.all(
|
|
227
|
-
credentialRequests.map(async cr => {
|
|
228
|
-
const {proof: {jwt}} = cr;
|
|
229
|
-
const {did} = await verifyDidProofJwt({workflow, exchange, jwt});
|
|
230
|
-
return did;
|
|
231
|
-
}));
|
|
232
|
-
// require `did` to be the same for every proof
|
|
233
|
-
// FIXME: determine if this needs to be more flexible
|
|
234
|
-
const did = results[0];
|
|
235
|
-
if(results.some(d => did !== d)) {
|
|
236
|
-
// FIXME: improve error
|
|
237
|
-
throw new Error('every DID must be the same');
|
|
238
|
-
}
|
|
239
|
-
// store did results in variables associated with current step
|
|
240
|
-
if(!exchange.variables.results) {
|
|
241
|
-
exchange.variables.results = {};
|
|
242
|
-
}
|
|
243
|
-
exchange.variables.results[currentStep] = {
|
|
244
|
-
// common use case of DID Authentication; provide `did` for ease
|
|
245
|
-
// of use in templates
|
|
246
|
-
did
|
|
247
|
-
};
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
// mark exchange complete
|
|
252
|
-
exchange.sequence++;
|
|
253
|
-
await exchanges.complete({workflowId: workflow.id, exchange});
|
|
254
|
-
|
|
255
|
-
// FIXME: decide what the best recovery path is if delivery fails (but no
|
|
256
|
-
// replay attack detected) after exchange has been marked complete
|
|
257
|
-
|
|
258
|
-
// issue VCs
|
|
259
|
-
return issue({workflow, exchange, format});
|
|
149
|
+
return _processExchange({req, res, workflow, exchangeRecord, isBatchRequest});
|
|
260
150
|
}
|
|
261
151
|
|
|
262
152
|
function _assertCredentialRequests({
|
|
@@ -499,6 +389,147 @@ function _normalizeCredentialDefinitionTypes({credentialRequests}) {
|
|
|
499
389
|
}
|
|
500
390
|
}
|
|
501
391
|
|
|
392
|
+
async function _processExchange({
|
|
393
|
+
req, res, workflow, exchangeRecord, isBatchRequest
|
|
394
|
+
}) {
|
|
395
|
+
const {id: workflowId} = workflow;
|
|
396
|
+
const {exchange, meta} = exchangeRecord;
|
|
397
|
+
let {updated: lastUpdated} = meta;
|
|
398
|
+
try {
|
|
399
|
+
// validate body against expected credential requests
|
|
400
|
+
const {openId: {expectedCredentialRequests}} = exchange;
|
|
401
|
+
let credentialRequests;
|
|
402
|
+
if(isBatchRequest) {
|
|
403
|
+
({credential_requests: credentialRequests} = req.body);
|
|
404
|
+
} else {
|
|
405
|
+
if(expectedCredentialRequests.length > 1) {
|
|
406
|
+
// FIXME: it is no longer the case that the batch endpoint must be used
|
|
407
|
+
// for multiple requests; determine if the request has changed
|
|
408
|
+
|
|
409
|
+
// clients interacting with exchanges with more than one VC to be
|
|
410
|
+
// delivered must use the "batch credential" endpoint
|
|
411
|
+
// FIXME: improve error
|
|
412
|
+
throw new Error('batch_credential_endpoint must be used');
|
|
413
|
+
}
|
|
414
|
+
credentialRequests = [req.body];
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// before asserting, normalize credential requests to use `type` instead of
|
|
418
|
+
// `types`; this is to allow for OID4VCI draft implementers that followed
|
|
419
|
+
// the non-normative examples
|
|
420
|
+
_normalizeCredentialDefinitionTypes({credentialRequests});
|
|
421
|
+
const {format} = _assertCredentialRequests({
|
|
422
|
+
workflow, credentialRequests, expectedCredentialRequests
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
// process exchange step if present
|
|
426
|
+
const currentStep = exchange.step;
|
|
427
|
+
if(currentStep) {
|
|
428
|
+
let step = workflow.steps[exchange.step];
|
|
429
|
+
if(step.stepTemplate) {
|
|
430
|
+
// generate step from the template; assume the template type is
|
|
431
|
+
// `jsonata` per the JSON schema
|
|
432
|
+
step = await evaluateTemplate(
|
|
433
|
+
{workflow, exchange, typedTemplate: step.stepTemplate});
|
|
434
|
+
if(Object.keys(step).length === 0) {
|
|
435
|
+
throw new BedrockError('Could not create exchange step.', {
|
|
436
|
+
name: 'DataError',
|
|
437
|
+
details: {httpStatusCode: 500, public: true}
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// do late workflow configuration validation
|
|
443
|
+
const {jwtDidProofRequest, openId} = step;
|
|
444
|
+
// use of `jwtDidProofRequest` and `openId` together is prohibited
|
|
445
|
+
if(jwtDidProofRequest && openId) {
|
|
446
|
+
throw new BedrockError(
|
|
447
|
+
'Invalid workflow configuration; only one of ' +
|
|
448
|
+
'"jwtDidProofRequest" and "openId" is permitted in a step.', {
|
|
449
|
+
name: 'DataError',
|
|
450
|
+
details: {httpStatusCode: 500, public: true}
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// check to see if step supports OID4VP during OID4VCI
|
|
455
|
+
if(step.openId) {
|
|
456
|
+
// if there is no `presentationSubmission`, request one
|
|
457
|
+
const {results} = exchange.variables;
|
|
458
|
+
if(!results?.[exchange.step]?.openId?.presentationSubmission) {
|
|
459
|
+
// FIXME: optimize away double step-template processing that
|
|
460
|
+
// currently occurs when calling `_getAuthorizationRequest`
|
|
461
|
+
const {
|
|
462
|
+
authorizationRequest
|
|
463
|
+
} = await getAuthorizationRequest({req});
|
|
464
|
+
return _requestOID4VP({authorizationRequest, res});
|
|
465
|
+
}
|
|
466
|
+
// otherwise drop down below to complete exchange...
|
|
467
|
+
} else if(jwtDidProofRequest) {
|
|
468
|
+
// handle OID4VCI specialized JWT DID Proof request...
|
|
469
|
+
|
|
470
|
+
// `proof` must be in every credential request; if any request is
|
|
471
|
+
// missing `proof` then request a DID proof
|
|
472
|
+
if(credentialRequests.some(cr => !cr.proof?.jwt)) {
|
|
473
|
+
return _requestDidProof({res, exchangeRecord});
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// verify every DID proof and get resulting DIDs
|
|
477
|
+
const results = await Promise.all(
|
|
478
|
+
credentialRequests.map(async cr => {
|
|
479
|
+
const {proof: {jwt}} = cr;
|
|
480
|
+
const {did} = await verifyDidProofJwt({workflow, exchange, jwt});
|
|
481
|
+
return did;
|
|
482
|
+
}));
|
|
483
|
+
// require `did` to be the same for every proof
|
|
484
|
+
// FIXME: determine if this needs to be more flexible
|
|
485
|
+
const did = results[0];
|
|
486
|
+
if(results.some(d => did !== d)) {
|
|
487
|
+
// FIXME: improve error
|
|
488
|
+
throw new Error('every DID must be the same');
|
|
489
|
+
}
|
|
490
|
+
// store did results in variables associated with current step
|
|
491
|
+
if(!exchange.variables.results) {
|
|
492
|
+
exchange.variables.results = {};
|
|
493
|
+
}
|
|
494
|
+
exchange.variables.results[currentStep] = {
|
|
495
|
+
// common use case of DID Authentication; provide `did` for ease
|
|
496
|
+
// of use in templates
|
|
497
|
+
did
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// mark exchange complete
|
|
503
|
+
exchange.state = 'complete';
|
|
504
|
+
try {
|
|
505
|
+
exchange.sequence++;
|
|
506
|
+
await exchanges.complete({workflowId, exchange});
|
|
507
|
+
lastUpdated = Date.now();
|
|
508
|
+
} catch(e) {
|
|
509
|
+
exchange.sequence--;
|
|
510
|
+
throw e;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// FIXME: decide what the best recovery path is if delivery fails (but no
|
|
514
|
+
// replay attack detected) after exchange has been marked complete
|
|
515
|
+
|
|
516
|
+
// issue VCs
|
|
517
|
+
return issue({workflow, exchange, format});
|
|
518
|
+
} catch(e) {
|
|
519
|
+
if(e.name === 'InvalidStateError') {
|
|
520
|
+
throw e;
|
|
521
|
+
}
|
|
522
|
+
// write last error if exchange hasn't been frequently updated
|
|
523
|
+
const copy = {...exchange};
|
|
524
|
+
copy.sequence++;
|
|
525
|
+
copy.lastError = e;
|
|
526
|
+
exchanges.setLastError({workflowId, exchange: copy, lastUpdated})
|
|
527
|
+
.catch(error => logger.error(
|
|
528
|
+
'Could not set last exchange error: ' + error.message, {error}));
|
|
529
|
+
throw e;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
502
533
|
async function _requestDidProof({res, exchangeRecord}) {
|
|
503
534
|
/* `9.4 Credential Issuer-provided nonce` allows the credential
|
|
504
535
|
issuer infrastructure to provide the nonce via an error:
|
package/lib/oid4/oid4vp.js
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
} from '../../schemas/bedrock-vc-workflow.js';
|
|
11
11
|
import {compile} from '@bedrock/validation';
|
|
12
12
|
import {klona} from 'klona';
|
|
13
|
+
import {logger} from '../logger.js';
|
|
13
14
|
import {oid4vp} from '@digitalbazaar/oid4-client';
|
|
14
15
|
import {verify} from '../verify.js';
|
|
15
16
|
|
|
@@ -139,10 +140,11 @@ export async function getAuthorizationRequest({req}) {
|
|
|
139
140
|
}
|
|
140
141
|
|
|
141
142
|
if(updateExchange) {
|
|
142
|
-
exchange.sequence++;
|
|
143
143
|
try {
|
|
144
|
+
exchange.sequence++;
|
|
144
145
|
await exchanges.update({workflowId: workflow.id, exchange});
|
|
145
146
|
} catch(e) {
|
|
147
|
+
exchange.sequence--;
|
|
146
148
|
if(e.name !== 'InvalidStateError') {
|
|
147
149
|
// unrecoverable error
|
|
148
150
|
throw e;
|
|
@@ -167,89 +169,111 @@ export async function processAuthorizationResponse({req}) {
|
|
|
167
169
|
const {config: workflow} = req.serviceObject;
|
|
168
170
|
const exchangeRecord = await req.getExchange();
|
|
169
171
|
let {exchange} = exchangeRecord;
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
172
|
+
let {meta: {updated: lastUpdated}} = exchangeRecord;
|
|
173
|
+
try {
|
|
174
|
+
// get authorization request and updated exchange associated with exchange
|
|
175
|
+
const arRequest = await getAuthorizationRequest({req});
|
|
176
|
+
const {authorizationRequest, step} = arRequest;
|
|
177
|
+
({exchange} = arRequest);
|
|
178
|
+
|
|
179
|
+
// FIXME: check the VP against the presentation submission if requested
|
|
180
|
+
// FIXME: check the VP against "trustedIssuer" in VPR, if provided
|
|
181
|
+
const {presentationSchema} = step;
|
|
182
|
+
if(presentationSchema) {
|
|
183
|
+
// if the VP is enveloped, validate the contents of the envelope
|
|
184
|
+
const toValidate = envelope ? envelope.contents : presentation;
|
|
185
|
+
|
|
186
|
+
// validate the received VP / envelope contents
|
|
187
|
+
const {jsonSchema: schema} = presentationSchema;
|
|
188
|
+
const validate = compile({schema});
|
|
189
|
+
const {valid, error} = validate(toValidate);
|
|
190
|
+
if(!valid) {
|
|
191
|
+
throw error;
|
|
192
|
+
}
|
|
189
193
|
}
|
|
190
|
-
}
|
|
191
194
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
195
|
+
// verify the received VP
|
|
196
|
+
const {verifiablePresentationRequest} = await oid4vp.toVpr(
|
|
197
|
+
{authorizationRequest});
|
|
198
|
+
const {allowUnprotectedPresentation = false} = step;
|
|
199
|
+
const verifyResult = await verify({
|
|
200
|
+
workflow,
|
|
201
|
+
verifiablePresentationRequest,
|
|
202
|
+
presentation,
|
|
203
|
+
allowUnprotectedPresentation,
|
|
204
|
+
expectedChallenge: authorizationRequest.nonce
|
|
205
|
+
});
|
|
206
|
+
const {verificationMethod} = verifyResult;
|
|
204
207
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
208
|
+
// store VP results in variables associated with current step
|
|
209
|
+
const currentStep = exchange.step;
|
|
210
|
+
if(!exchange.variables.results) {
|
|
211
|
+
exchange.variables.results = {};
|
|
212
|
+
}
|
|
213
|
+
const results = {
|
|
214
|
+
// common use case of DID Authentication; provide `did` for ease
|
|
215
|
+
// of use in template
|
|
216
|
+
did: verificationMethod?.controller || null,
|
|
217
|
+
verificationMethod,
|
|
218
|
+
verifiablePresentation: presentation,
|
|
219
|
+
openId: {
|
|
220
|
+
authorizationRequest,
|
|
221
|
+
presentationSubmission
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
if(envelope) {
|
|
225
|
+
// normalize VP from inside envelope to `verifiablePresentation`
|
|
226
|
+
results.envelopedPresentation = presentation;
|
|
227
|
+
results.verifiablePresentation = verifyResult
|
|
228
|
+
.presentationResult.presentation;
|
|
229
|
+
}
|
|
230
|
+
exchange.variables.results[currentStep] = results;
|
|
231
|
+
try {
|
|
232
|
+
exchange.sequence++;
|
|
233
|
+
|
|
234
|
+
// if there is something to issue, update exchange, do not complete it
|
|
235
|
+
const {credentialTemplates = []} = workflow;
|
|
236
|
+
if(credentialTemplates?.length > 0 &&
|
|
237
|
+
(exchange.state === 'pending' || exchange.state === 'active')) {
|
|
238
|
+
// ensure exchange state is set to `active` (will be rejected as a
|
|
239
|
+
// conflict if the state in database at update time isn't `pending` or
|
|
240
|
+
// `active`)
|
|
241
|
+
exchange.state = 'active';
|
|
242
|
+
await exchanges.update({workflowId: workflow.id, exchange});
|
|
243
|
+
} else {
|
|
244
|
+
// mark exchange complete
|
|
245
|
+
exchange.state = 'complete';
|
|
246
|
+
await exchanges.complete({workflowId: workflow.id, exchange});
|
|
247
|
+
}
|
|
248
|
+
lastUpdated = Date.now();
|
|
249
|
+
} catch(e) {
|
|
250
|
+
exchange.sequence--;
|
|
251
|
+
throw e;
|
|
219
252
|
}
|
|
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
253
|
|
|
244
|
-
|
|
254
|
+
const result = {};
|
|
245
255
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
256
|
+
// include `redirect_uri` if specified in step
|
|
257
|
+
const redirect_uri = step.openId?.redirect_uri;
|
|
258
|
+
if(redirect_uri) {
|
|
259
|
+
result.redirect_uri = redirect_uri;
|
|
260
|
+
}
|
|
251
261
|
|
|
252
|
-
|
|
262
|
+
return result;
|
|
263
|
+
} catch(e) {
|
|
264
|
+
if(e.name === 'InvalidStateError') {
|
|
265
|
+
throw e;
|
|
266
|
+
}
|
|
267
|
+
// write last error if exchange hasn't been frequently updated
|
|
268
|
+
const {id: workflowId} = workflow;
|
|
269
|
+
const copy = {...exchange};
|
|
270
|
+
copy.sequence++;
|
|
271
|
+
copy.lastError = e;
|
|
272
|
+
exchanges.setLastError({workflowId, exchange: copy, lastUpdated})
|
|
273
|
+
.catch(error => logger.error(
|
|
274
|
+
'Could not set last exchange error: ' + error.message, {error}));
|
|
275
|
+
throw e;
|
|
276
|
+
}
|
|
253
277
|
}
|
|
254
278
|
|
|
255
279
|
function _createClientMetaData() {
|
package/lib/vcapi.js
CHANGED
|
@@ -90,179 +90,209 @@ export async function createExchange({workflow, exchange}) {
|
|
|
90
90
|
return exchange;
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
export async function processExchange({req, res, workflow,
|
|
94
|
-
|
|
95
|
-
let
|
|
93
|
+
export async function processExchange({req, res, workflow, exchangeRecord}) {
|
|
94
|
+
const {exchange, meta} = exchangeRecord;
|
|
95
|
+
let {updated: lastUpdated} = meta;
|
|
96
|
+
try {
|
|
97
|
+
// get any `verifiablePresentation` from the body...
|
|
98
|
+
let receivedPresentation = req?.body?.verifiablePresentation;
|
|
96
99
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
100
|
+
// process exchange step(s)
|
|
101
|
+
let i = 0;
|
|
102
|
+
let currentStep = exchange.step;
|
|
103
|
+
let step;
|
|
104
|
+
while(true) {
|
|
105
|
+
if(i++ > MAXIMUM_STEPS) {
|
|
106
|
+
throw new BedrockError('Maximum steps exceeded.', {
|
|
107
|
+
name: 'DataError',
|
|
108
|
+
details: {httpStatusCode: 500, public: true}
|
|
109
|
+
});
|
|
110
|
+
}
|
|
108
111
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
112
|
+
// no step present, break out to complete exchange
|
|
113
|
+
if(!currentStep) {
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// get current step details
|
|
118
|
+
step = workflow.steps[currentStep];
|
|
119
|
+
if(step.stepTemplate) {
|
|
120
|
+
// generate step from the template; assume the template type is
|
|
121
|
+
// `jsonata` per the JSON schema
|
|
122
|
+
step = await evaluateTemplate(
|
|
123
|
+
{workflow, exchange, typedTemplate: step.stepTemplate});
|
|
124
|
+
if(Object.keys(step).length === 0) {
|
|
125
|
+
throw new BedrockError('Empty step detected.', {
|
|
126
|
+
name: 'DataError',
|
|
127
|
+
details: {httpStatusCode: 500, public: true}
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
113
131
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
// generate step from the template; assume the template type is
|
|
118
|
-
// `jsonata` per the JSON schema
|
|
119
|
-
step = await evaluateTemplate(
|
|
120
|
-
{workflow, exchange, typedTemplate: step.stepTemplate});
|
|
121
|
-
if(Object.keys(step).length === 0) {
|
|
122
|
-
throw new BedrockError('Empty step detected.', {
|
|
132
|
+
// if next step is the same as the current step, throw an error
|
|
133
|
+
if(step.nextStep === currentStep) {
|
|
134
|
+
throw new BedrockError('Cyclical step detected.', {
|
|
123
135
|
name: 'DataError',
|
|
124
136
|
details: {httpStatusCode: 500, public: true}
|
|
125
137
|
});
|
|
126
138
|
}
|
|
127
|
-
}
|
|
128
139
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
});
|
|
135
|
-
}
|
|
140
|
+
// handle VPR: if step requires it, then `verifiablePresentation` must
|
|
141
|
+
// be in the request
|
|
142
|
+
if(step.verifiablePresentationRequest) {
|
|
143
|
+
const {createChallenge} = step;
|
|
144
|
+
const isInitialStep = exchange.step === workflow.initialStep;
|
|
136
145
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
146
|
+
// if no presentation was received in the body...
|
|
147
|
+
if(!receivedPresentation) {
|
|
148
|
+
const verifiablePresentationRequest = klona(
|
|
149
|
+
step.verifiablePresentationRequest);
|
|
150
|
+
if(createChallenge) {
|
|
151
|
+
/* Note: When creating a challenge, the initial step always
|
|
152
|
+
uses the local exchange ID because the initial step itself
|
|
153
|
+
is one-time use. Subsequent steps, which only VC-API (as opposed
|
|
154
|
+
to other protocols) supports creating additional challenges via
|
|
155
|
+
the VC-API verifier API. */
|
|
156
|
+
let challenge;
|
|
157
|
+
if(isInitialStep) {
|
|
158
|
+
challenge = exchange.id;
|
|
159
|
+
} else {
|
|
160
|
+
// generate a new challenge using verifier API
|
|
161
|
+
({challenge} = await _createChallenge({workflow}));
|
|
162
|
+
}
|
|
163
|
+
verifiablePresentationRequest.challenge = challenge;
|
|
164
|
+
}
|
|
165
|
+
// send VPR and return
|
|
166
|
+
res.json({verifiablePresentationRequest});
|
|
167
|
+
// if exchange is pending, mark it as active out-of-band
|
|
168
|
+
if(exchange.state === 'pending') {
|
|
169
|
+
exchange.state = 'active';
|
|
170
|
+
exchange.sequence++;
|
|
171
|
+
exchanges.update({workflowId: workflow.id, exchange}).catch(
|
|
172
|
+
error => logger.error(
|
|
173
|
+
'Could not mark exchange active: ' + error.message, {error}));
|
|
174
|
+
}
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
142
177
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
to other protocols) supports creating additional challenges via
|
|
152
|
-
the VC-API verifier API. */
|
|
153
|
-
let challenge;
|
|
154
|
-
if(isInitialStep) {
|
|
155
|
-
challenge = exchange.id;
|
|
178
|
+
const {presentationSchema} = step;
|
|
179
|
+
if(presentationSchema) {
|
|
180
|
+
// if the VP is enveloped, get the presentation from the envelope
|
|
181
|
+
let presentation;
|
|
182
|
+
if(receivedPresentation?.type === 'EnvelopedVerifiablePresentation') {
|
|
183
|
+
({presentation} = await unenvelopePresentation({
|
|
184
|
+
envelopedPresentation: receivedPresentation
|
|
185
|
+
}));
|
|
156
186
|
} else {
|
|
157
|
-
|
|
158
|
-
({challenge} = await _createChallenge({workflow}));
|
|
187
|
+
presentation = receivedPresentation;
|
|
159
188
|
}
|
|
160
|
-
verifiablePresentationRequest.challenge = challenge;
|
|
161
|
-
}
|
|
162
|
-
// send VPR and return
|
|
163
|
-
res.json({verifiablePresentationRequest});
|
|
164
|
-
// if exchange is pending, mark it as active out-of-band
|
|
165
|
-
if(exchange.state === 'pending') {
|
|
166
|
-
exchange.state = 'active';
|
|
167
|
-
exchange.sequence++;
|
|
168
|
-
exchanges.update({workflowId: workflow.id, exchange}).catch(
|
|
169
|
-
error => logger.error(
|
|
170
|
-
'Could not mark exchange active: ' + error.message, {error}));
|
|
171
|
-
}
|
|
172
|
-
return;
|
|
173
|
-
}
|
|
174
189
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
}));
|
|
183
|
-
} else {
|
|
184
|
-
presentation = receivedPresentation;
|
|
190
|
+
// validate the received VP
|
|
191
|
+
const {jsonSchema: schema} = presentationSchema;
|
|
192
|
+
const validate = compile({schema});
|
|
193
|
+
const {valid, error} = validate(presentation);
|
|
194
|
+
if(!valid) {
|
|
195
|
+
throw error;
|
|
196
|
+
}
|
|
185
197
|
}
|
|
186
198
|
|
|
187
|
-
//
|
|
188
|
-
const
|
|
189
|
-
const
|
|
190
|
-
const {
|
|
191
|
-
|
|
192
|
-
|
|
199
|
+
// verify the received VP
|
|
200
|
+
const expectedChallenge = isInitialStep ? exchange.id : undefined;
|
|
201
|
+
const {allowUnprotectedPresentation = false} = step;
|
|
202
|
+
const {verificationMethod} = await verify({
|
|
203
|
+
workflow,
|
|
204
|
+
verifiablePresentationRequest: step.verifiablePresentationRequest,
|
|
205
|
+
presentation: receivedPresentation,
|
|
206
|
+
allowUnprotectedPresentation,
|
|
207
|
+
expectedChallenge
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// store VP results in variables associated with current step
|
|
211
|
+
if(!exchange.variables.results) {
|
|
212
|
+
exchange.variables.results = {};
|
|
193
213
|
}
|
|
194
|
-
|
|
214
|
+
exchange.variables.results[currentStep] = {
|
|
215
|
+
// common use case of DID Authentication; provide `did` for ease
|
|
216
|
+
// of use in templates and consistency with OID4VCI which only
|
|
217
|
+
// receives `did` not verification method nor VP
|
|
218
|
+
did: verificationMethod?.controller || null,
|
|
219
|
+
verificationMethod,
|
|
220
|
+
verifiablePresentation: receivedPresentation
|
|
221
|
+
};
|
|
195
222
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
const {allowUnprotectedPresentation = false} = step;
|
|
199
|
-
const {verificationMethod} = await verify({
|
|
200
|
-
workflow,
|
|
201
|
-
verifiablePresentationRequest: step.verifiablePresentationRequest,
|
|
202
|
-
presentation: receivedPresentation,
|
|
203
|
-
allowUnprotectedPresentation,
|
|
204
|
-
expectedChallenge
|
|
205
|
-
});
|
|
223
|
+
// clear received presentation as it has been processed
|
|
224
|
+
receivedPresentation = null;
|
|
206
225
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
exchange.variables.results[currentStep] = {
|
|
212
|
-
// common use case of DID Authentication; provide `did` for ease
|
|
213
|
-
// of use in templates and consistency with OID4VCI which only
|
|
214
|
-
// receives `did` not verification method nor VP
|
|
215
|
-
did: verificationMethod?.controller || null,
|
|
216
|
-
verificationMethod,
|
|
217
|
-
verifiablePresentation: receivedPresentation
|
|
218
|
-
};
|
|
226
|
+
// if there is no next step, break out to complete exchange
|
|
227
|
+
if(!step.nextStep) {
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
219
230
|
|
|
220
|
-
|
|
221
|
-
|
|
231
|
+
// update the exchange to go to the next step, then loop to send
|
|
232
|
+
// next VPR
|
|
233
|
+
currentStep = exchange.step = step.nextStep;
|
|
234
|
+
// ensure exchange state is active
|
|
235
|
+
if(exchange.state === 'pending') {
|
|
236
|
+
exchange.state = 'active';
|
|
237
|
+
}
|
|
238
|
+
try {
|
|
239
|
+
exchange.sequence++;
|
|
240
|
+
await exchanges.update({workflowId: workflow.id, exchange});
|
|
241
|
+
lastUpdated = Date.now();
|
|
242
|
+
} catch(e) {
|
|
243
|
+
exchange.sequence--;
|
|
244
|
+
throw e;
|
|
245
|
+
}
|
|
222
246
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
247
|
+
// FIXME: there may be VCs to issue during this step, do so before
|
|
248
|
+
// sending the VPR above
|
|
249
|
+
} else if(step.nextStep) {
|
|
250
|
+
// next steps without VPRs are prohibited
|
|
251
|
+
throw new BedrockError(
|
|
252
|
+
'Invalid step detected; continuing exchanges must include VPRs.', {
|
|
253
|
+
name: 'DataError',
|
|
254
|
+
details: {httpStatusCode: 500, public: true}
|
|
255
|
+
});
|
|
226
256
|
}
|
|
257
|
+
}
|
|
227
258
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
// ensure exchange state is active
|
|
232
|
-
if(exchange.state === 'pending') {
|
|
233
|
-
exchange.state = 'active';
|
|
234
|
-
}
|
|
259
|
+
// mark exchange complete
|
|
260
|
+
exchange.state = 'complete';
|
|
261
|
+
try {
|
|
235
262
|
exchange.sequence++;
|
|
236
|
-
await exchanges.
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
} else if(step.nextStep) {
|
|
241
|
-
// next steps without VPRs are prohibited
|
|
242
|
-
throw new BedrockError(
|
|
243
|
-
'Invalid step detected; continuing exchanges must include VPRs.', {
|
|
244
|
-
name: 'DataError',
|
|
245
|
-
details: {httpStatusCode: 500, public: true}
|
|
246
|
-
});
|
|
263
|
+
await exchanges.complete({workflowId: workflow.id, exchange});
|
|
264
|
+
} catch(e) {
|
|
265
|
+
exchange.sequence--;
|
|
266
|
+
throw e;
|
|
247
267
|
}
|
|
248
|
-
|
|
268
|
+
lastUpdated = Date.now();
|
|
249
269
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
await exchanges.complete({workflowId: workflow.id, exchange});
|
|
270
|
+
// FIXME: decide what the best recovery path is if delivery fails (but no
|
|
271
|
+
// replay attack detected) after exchange has been marked complete
|
|
253
272
|
|
|
254
|
-
|
|
255
|
-
|
|
273
|
+
// issue any VCs; may return an empty response if the step defines no
|
|
274
|
+
// VCs to issue
|
|
275
|
+
const {response} = await issue({workflow, exchange});
|
|
256
276
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
277
|
+
// if last `step` has a redirect URL, include it in the response
|
|
278
|
+
if(step?.redirectUrl) {
|
|
279
|
+
response.redirectUrl = step.redirectUrl;
|
|
280
|
+
}
|
|
260
281
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
282
|
+
// send response
|
|
283
|
+
res.json(response);
|
|
284
|
+
} catch(e) {
|
|
285
|
+
if(e.name === 'InvalidStateError') {
|
|
286
|
+
throw e;
|
|
287
|
+
}
|
|
288
|
+
// write last error if exchange hasn't been frequently updated
|
|
289
|
+
const {id: workflowId} = workflow;
|
|
290
|
+
const copy = {...exchange};
|
|
291
|
+
copy.sequence++;
|
|
292
|
+
copy.lastError = e;
|
|
293
|
+
exchanges.setLastError({workflowId, exchange: copy, lastUpdated})
|
|
294
|
+
.catch(error => logger.error(
|
|
295
|
+
'Could not set last exchange error: ' + error.message, {error}));
|
|
296
|
+
throw e;
|
|
264
297
|
}
|
|
265
|
-
|
|
266
|
-
// send response
|
|
267
|
-
res.json(response);
|
|
268
298
|
}
|
package/lib/verify.js
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
import * as bedrock from '@bedrock/core';
|
|
5
5
|
import * as EcdsaMultikey from '@digitalbazaar/ecdsa-multikey';
|
|
6
6
|
import * as Ed25519Multikey from '@digitalbazaar/ed25519-multikey';
|
|
7
|
+
import {getZcapClient, stripStacktrace} from './helpers.js';
|
|
7
8
|
import {importJWK, jwtVerify} from 'jose';
|
|
8
9
|
import {didIo} from '@bedrock/did-io';
|
|
9
|
-
import {getZcapClient} from './helpers.js';
|
|
10
10
|
|
|
11
11
|
const {util: {BedrockError}} = bedrock;
|
|
12
12
|
|
|
@@ -77,17 +77,17 @@ export async function verify({
|
|
|
77
77
|
if(credentialResults) {
|
|
78
78
|
credentialResults.forEach(result => {
|
|
79
79
|
if(result.error) {
|
|
80
|
-
result.error =
|
|
80
|
+
result.error = stripStacktrace(result.error);
|
|
81
81
|
}
|
|
82
82
|
});
|
|
83
83
|
}
|
|
84
84
|
if(presentationResult.error) {
|
|
85
|
-
presentationResult.error =
|
|
85
|
+
presentationResult.error = stripStacktrace(presentationResult.error);
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
// generate useful error to return to client
|
|
89
89
|
const {name, errors, message} = cause.data.error;
|
|
90
|
-
const causeError =
|
|
90
|
+
const causeError = stripStacktrace({...cause.data.error});
|
|
91
91
|
delete causeError.errors;
|
|
92
92
|
const error = new BedrockError(message ?? 'Verification error.', {
|
|
93
93
|
name: (name === 'VerificationError' || name === 'DataError') ?
|
|
@@ -102,7 +102,7 @@ export async function verify({
|
|
|
102
102
|
}
|
|
103
103
|
});
|
|
104
104
|
if(Array.isArray(errors)) {
|
|
105
|
-
error.details.errors = errors.map(
|
|
105
|
+
error.details.errors = errors.map(stripStacktrace);
|
|
106
106
|
}
|
|
107
107
|
throw error;
|
|
108
108
|
}
|
|
@@ -159,6 +159,18 @@ export async function verifyDidProofJwt({workflow, exchange, jwt} = {}) {
|
|
|
159
159
|
}
|
|
160
160
|
|
|
161
161
|
const vm = await didIo.get({url: kid});
|
|
162
|
+
if(!vm) {
|
|
163
|
+
throw new BedrockError(
|
|
164
|
+
`Verification method identified by "kid" (${kid}) could not be ` +
|
|
165
|
+
'retrieved.', {
|
|
166
|
+
name: 'DataError',
|
|
167
|
+
details: {
|
|
168
|
+
public: true,
|
|
169
|
+
httpStatusCode: 400
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
162
174
|
// `vm.controller` must be the issuer of the DID JWT; also ensure that
|
|
163
175
|
// the specified controller authorized `vm` for the purpose of
|
|
164
176
|
// authentication
|
|
@@ -174,8 +186,13 @@ export async function verifyDidProofJwt({workflow, exchange, jwt} = {}) {
|
|
|
174
186
|
match.controller === vm.controller)) {
|
|
175
187
|
throw new BedrockError(
|
|
176
188
|
`Verification method controller "${issuer}" did not authorize ` +
|
|
177
|
-
`verification method "${vm.id}" for the purpose of "authentication".`,
|
|
178
|
-
|
|
189
|
+
`verification method "${vm.id}" for the purpose of "authentication".`, {
|
|
190
|
+
name: 'NotAllowedError',
|
|
191
|
+
details: {
|
|
192
|
+
public: true,
|
|
193
|
+
httpStatusCode: 400
|
|
194
|
+
}
|
|
195
|
+
});
|
|
179
196
|
}
|
|
180
197
|
let jwk;
|
|
181
198
|
if(isEcdsa) {
|
|
@@ -251,15 +268,3 @@ export async function verifyDidProofJwt({workflow, exchange, jwt} = {}) {
|
|
|
251
268
|
|
|
252
269
|
return {verified: true, did: issuer, verifyResult};
|
|
253
270
|
}
|
|
254
|
-
|
|
255
|
-
function _stripStacktrace(error) {
|
|
256
|
-
error = {...error};
|
|
257
|
-
delete error.stack;
|
|
258
|
-
if(error.errors) {
|
|
259
|
-
error.errors = error.errors.map(_stripStacktrace);
|
|
260
|
-
}
|
|
261
|
-
if(error.cause) {
|
|
262
|
-
error.cause = _stripStacktrace(error.cause);
|
|
263
|
-
}
|
|
264
|
-
return error;
|
|
265
|
-
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bedrock/vc-delivery",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.3.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Bedrock Verifiable Credential Delivery",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -47,7 +47,8 @@
|
|
|
47
47
|
"cors": "^2.8.5",
|
|
48
48
|
"jose": "^5.6.3",
|
|
49
49
|
"jsonata": "^2.0.5",
|
|
50
|
-
"klona": "^2.0.6"
|
|
50
|
+
"klona": "^2.0.6",
|
|
51
|
+
"serialize-error": "^11.0.3"
|
|
51
52
|
},
|
|
52
53
|
"peerDependencies": {
|
|
53
54
|
"@bedrock/app-identity": "4.0.0",
|