@bedrock/vc-delivery 7.14.2 → 7.16.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/ExchangeProcessor.js +1 -1
- package/lib/config.js +34 -1
- package/lib/constants.js +10 -1
- package/lib/helpers.js +1 -1
- package/lib/http.js +1 -13
- package/lib/oid4/oid4vci.js +22 -13
- package/lib/oid4/oid4vciDraft13.js +28 -19
- package/lib/storage/exchanges.js +63 -67
- package/lib/storage/variables.js +225 -0
- package/lib/storage/variablesGarbageCollector.js +83 -0
- package/lib/vcapi.js +4 -8
- package/lib/verify.js +47 -0
- package/package.json +4 -3
- package/schemas/bedrock-vc-workflow.js +52 -20
package/lib/ExchangeProcessor.js
CHANGED
|
@@ -168,7 +168,7 @@ export class ExchangeProcessor {
|
|
|
168
168
|
if(exchange.state === 'complete') {
|
|
169
169
|
await exchanges.complete({workflowId: workflow.id, exchange});
|
|
170
170
|
} else {
|
|
171
|
-
await exchanges.update({workflowId: workflow.id, exchange});
|
|
171
|
+
await exchanges.update({workflowId: workflow.id, exchange, meta});
|
|
172
172
|
}
|
|
173
173
|
meta.updated = Date.now();
|
|
174
174
|
await emitExchangeUpdated({workflow, exchange, step});
|
package/lib/config.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/*!
|
|
2
|
-
* Copyright (c) 2022-
|
|
2
|
+
* Copyright (c) 2022-2026 Digital Bazaar, Inc.
|
|
3
3
|
*/
|
|
4
4
|
import * as bedrock from '@bedrock/core';
|
|
5
|
+
import '@bedrock/express';
|
|
5
6
|
|
|
6
7
|
const c = bedrock.util.config.main;
|
|
7
8
|
const cc = c.computer();
|
|
@@ -11,6 +12,15 @@ const {config} = bedrock;
|
|
|
11
12
|
const namespace = 'vc-workflow';
|
|
12
13
|
config[namespace] = {};
|
|
13
14
|
|
|
15
|
+
config[namespace].exchanges = {
|
|
16
|
+
variablesGarbageCollector: {
|
|
17
|
+
// collect expired externalized exchange "variables" every 5 minutes; may
|
|
18
|
+
// be slightly randomized
|
|
19
|
+
// default: 5 minutes
|
|
20
|
+
interval: 5 * 60 * 1000
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
|
|
14
24
|
// create dev application identity for vc-workflow (must be overridden in
|
|
15
25
|
// deployments) ...and `ensureConfigOverride` has already been set via
|
|
16
26
|
// `bedrock-app-identity` so it doesn't have to be set here
|
|
@@ -29,3 +39,26 @@ cc('app-identity.seeds.services.vc-exchanger.id', () =>
|
|
|
29
39
|
config['app-identity'].seeds.services['vc-workflow'].id);
|
|
30
40
|
cc('app-identity.seeds.services.vc-exchanger.seedMultibase', () =>
|
|
31
41
|
config['app-identity'].seeds.services['vc-workflow'].seedMultibase);
|
|
42
|
+
|
|
43
|
+
// set body parser limits for workflow endpoints (and deprecated `/exchangers`)
|
|
44
|
+
const routePrefixes = ['/workflows', '/exchangers'];
|
|
45
|
+
const createBodyParserOptions = ({limit}) => ({
|
|
46
|
+
json: {
|
|
47
|
+
strict: false,
|
|
48
|
+
limit,
|
|
49
|
+
type: ['json', '+json']
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
const bodyParserRoutes = config.express.bodyParser.routes;
|
|
53
|
+
for(const routePrefix of routePrefixes) {
|
|
54
|
+
// exchange clients POST to this route to execute exchanges; limit indicates
|
|
55
|
+
// how large submitted VPs can be
|
|
56
|
+
bodyParserRoutes[
|
|
57
|
+
`${routePrefix}/:localWorkflowId/exchanges/:localExchangeId`
|
|
58
|
+
] = createBodyParserOptions({limit: '10MB'});
|
|
59
|
+
// exchanges are created using this route; limit indicates how large
|
|
60
|
+
// variables can be (in total)
|
|
61
|
+
bodyParserRoutes[
|
|
62
|
+
`${routePrefix}/:localWorkflowId/exchanges`
|
|
63
|
+
] = createBodyParserOptions({limit: '10MB'});
|
|
64
|
+
}
|
package/lib/constants.js
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
/*!
|
|
2
|
-
* Copyright (c) 2024-
|
|
2
|
+
* Copyright (c) 2024-2026 Digital Bazaar, Inc.
|
|
3
3
|
*/
|
|
4
|
+
// allow up to 3 days to resolve invalid exchange issues (which also more than
|
|
5
|
+
// covers large exchange `variables` download and decoding times, etc.)
|
|
6
|
+
// (86400 seconds in 24 hours)
|
|
7
|
+
export const EXCHANGE_EXPIRY_GRACE_PERIOD = 86400 * 3 * 1000;
|
|
8
|
+
// TTL is measured in minutes, default is 15 minutes
|
|
9
|
+
export const EXCHANGE_TTL_DEFAULT = 60 * 15;
|
|
10
|
+
// 48 hours
|
|
11
|
+
export const EXCHANGE_TTL_MAX_IN_MS = 1000 * 60 * 60 * 24 * 2;
|
|
12
|
+
|
|
4
13
|
// maximum # of issuer instances that can be associated with a workflow
|
|
5
14
|
export const MAX_ISSUER_INSTANCES = 10;
|
|
6
15
|
// maximum # of OID4VP client profiles that can be associated with a workflow
|
package/lib/helpers.js
CHANGED
|
@@ -304,7 +304,7 @@ export function createVerifyOptions({
|
|
|
304
304
|
|
|
305
305
|
// update `checks` with anything additional from `verifyPresentationOptions`
|
|
306
306
|
const checkSet = new Set(checks);
|
|
307
|
-
if(verifyPresentationOptions
|
|
307
|
+
if(verifyPresentationOptions?.checks) {
|
|
308
308
|
Object.entries(verifyPresentationOptions.checks)
|
|
309
309
|
.forEach(([check, enabled]) => enabled && checkSet.add(check));
|
|
310
310
|
}
|
package/lib/http.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/*!
|
|
2
|
-
* Copyright (c) 2018-2026 Digital Bazaar, Inc.
|
|
2
|
+
* Copyright (c) 2018-2026 Digital Bazaar, Inc.
|
|
3
3
|
*/
|
|
4
4
|
import * as bedrock from '@bedrock/core';
|
|
5
5
|
import * as exchanges from './storage/exchanges.js';
|
|
@@ -11,7 +11,6 @@ import {
|
|
|
11
11
|
} from '../schemas/bedrock-vc-workflow.js';
|
|
12
12
|
import {metering, middleware} from '@bedrock/service-core';
|
|
13
13
|
import {asyncHandler} from '@bedrock/express';
|
|
14
|
-
import bodyParser from 'body-parser';
|
|
15
14
|
import cors from 'cors';
|
|
16
15
|
import {getWorkflowId} from './helpers.js';
|
|
17
16
|
import {logger} from './logger.js';
|
|
@@ -19,17 +18,6 @@ import {createValidateMiddleware as validate} from '@bedrock/validation';
|
|
|
19
18
|
|
|
20
19
|
const {util: {BedrockError}} = bedrock;
|
|
21
20
|
|
|
22
|
-
// FIXME: remove and apply to specific routes via
|
|
23
|
-
// `bedrock.express.bodyParser.routes` + `@bedrock/express@8.4`
|
|
24
|
-
bedrock.events.on('bedrock-express.configure.bodyParser', app => {
|
|
25
|
-
app.use(bodyParser.json({
|
|
26
|
-
// allow json values that are not just objects or arrays
|
|
27
|
-
strict: false,
|
|
28
|
-
limit: '10MB',
|
|
29
|
-
type: ['json', '+json']
|
|
30
|
-
}));
|
|
31
|
-
});
|
|
32
|
-
|
|
33
21
|
export async function addRoutes({app, service} = {}) {
|
|
34
22
|
const {routePrefix} = service;
|
|
35
23
|
|
package/lib/oid4/oid4vci.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/*!
|
|
2
|
-
* Copyright (c) 2022-2026 Digital Bazaar, Inc.
|
|
2
|
+
* Copyright (c) 2022-2026 Digital Bazaar, Inc.
|
|
3
3
|
*/
|
|
4
4
|
import * as bedrock from '@bedrock/core';
|
|
5
5
|
import * as draft13 from './oid4vciDraft13.js';
|
|
@@ -14,7 +14,7 @@ import {checkAccessToken} from '@bedrock/oauth2-verifier';
|
|
|
14
14
|
import {compile} from '@bedrock/validation';
|
|
15
15
|
import {ExchangeProcessor} from '../ExchangeProcessor.js';
|
|
16
16
|
import {getStepAuthorizationRequest} from './oid4vp.js';
|
|
17
|
-
import {
|
|
17
|
+
import {verifyCredentialRequestProof} from '../verify.js';
|
|
18
18
|
|
|
19
19
|
const {util: {BedrockError}} = bedrock;
|
|
20
20
|
|
|
@@ -430,23 +430,32 @@ export async function processCredentialRequests({req, res, isBatchRequest}) {
|
|
|
430
430
|
}
|
|
431
431
|
|
|
432
432
|
// check to see if step requires a DID proof
|
|
433
|
-
if(step.jwtDidProofRequest) {
|
|
434
|
-
// handle OID4VCI specialized JWT DID Proof request...
|
|
433
|
+
if(step.divpDidProofRequest || step.jwtDidProofRequest) {
|
|
434
|
+
// handle OID4VCI specialized DI VP / JWT DID Proof request...
|
|
435
435
|
|
|
436
436
|
// `proof` must be in every credential request; if any request is
|
|
437
437
|
// missing `proof` then request a DID proof
|
|
438
|
-
|
|
438
|
+
const acceptableProofTypes = new Set();
|
|
439
|
+
if(step.divpDidProofRequest) {
|
|
440
|
+
acceptableProofTypes.add('di_vp');
|
|
441
|
+
}
|
|
442
|
+
if(step.jwtDidProofRequest) {
|
|
443
|
+
acceptableProofTypes.add('jwt');
|
|
444
|
+
}
|
|
445
|
+
const hasAcceptableProofType =
|
|
446
|
+
cr => Object.keys(cr.proofs ?? {}).some(
|
|
447
|
+
k => acceptableProofTypes.has(k));
|
|
448
|
+
if(credentialRequests.some(cr => !hasAcceptableProofType(cr))) {
|
|
439
449
|
didProofRequired = true;
|
|
440
450
|
return _requestDidProof({res, exchangeRecord});
|
|
441
451
|
}
|
|
442
452
|
|
|
443
453
|
// verify every DID proof and get resulting DIDs
|
|
444
454
|
const results = await Promise.all(
|
|
445
|
-
credentialRequests.map(async
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
return did;
|
|
455
|
+
credentialRequests.map(async credentialRequest => {
|
|
456
|
+
return verifyCredentialRequestProof({
|
|
457
|
+
credentialRequest, workflow, exchange
|
|
458
|
+
});
|
|
450
459
|
}));
|
|
451
460
|
// require `did` to be the same for every proof
|
|
452
461
|
// FIXME: determine if this needs to be more flexible
|
|
@@ -497,7 +506,7 @@ export async function processCredentialRequests({req, res, isBatchRequest}) {
|
|
|
497
506
|
}
|
|
498
507
|
|
|
499
508
|
// otherwise, input is required if:
|
|
500
|
-
// 1. a `jwtDidProofRequest` is required and hasn't been provided
|
|
509
|
+
// 1. a `divp|jwtDidProofRequest` is required and hasn't been provided
|
|
501
510
|
// 2. OID4VP is enabled and no OID4VP result has been stored yet
|
|
502
511
|
return didProofRequired || (step.openId && !exchange.variables
|
|
503
512
|
.results[exchange.step]?.openId?.authorizationRequest);
|
|
@@ -617,7 +626,7 @@ function _createSupportedCredentialRequests({
|
|
|
617
626
|
if(isDraft13) {
|
|
618
627
|
supportedCredentialRequests =
|
|
619
628
|
draft13.createSupportedCredentialRequests({
|
|
620
|
-
workflow, exchange, issueRequestsParams
|
|
629
|
+
workflow, exchange, issueRequestsParams, step
|
|
621
630
|
});
|
|
622
631
|
} else {
|
|
623
632
|
supportedCredentialRequests = [];
|
|
@@ -712,7 +721,7 @@ function _getSupportedCredentialConfigurations({workflow, exchange, step}) {
|
|
|
712
721
|
|
|
713
722
|
// no explicit IDs; create legacy supported credential configurations
|
|
714
723
|
return draft13.createSupportedCredentialConfigurations({
|
|
715
|
-
exchange, issuerInstances
|
|
724
|
+
exchange, issuerInstances, step
|
|
716
725
|
});
|
|
717
726
|
}
|
|
718
727
|
|
|
@@ -8,7 +8,7 @@ import {randomUUID as uuid} from 'node:crypto';
|
|
|
8
8
|
const {util: {BedrockError}} = bedrock;
|
|
9
9
|
|
|
10
10
|
export function createSupportedCredentialConfigurations({
|
|
11
|
-
exchange, issuerInstances
|
|
11
|
+
exchange, issuerInstances, step
|
|
12
12
|
} = {}) {
|
|
13
13
|
// get legacy `expectedCredentialRequests`
|
|
14
14
|
const {
|
|
@@ -27,7 +27,7 @@ export function createSupportedCredentialConfigurations({
|
|
|
27
27
|
// supported credential configuration
|
|
28
28
|
for(const credentialRequest of expectedCredentialRequests) {
|
|
29
29
|
const configurations = _createCredentialConfigurations({
|
|
30
|
-
credentialRequest, supportedFormats
|
|
30
|
+
credentialRequest, supportedFormats, step
|
|
31
31
|
});
|
|
32
32
|
for(const {id, configuration} of configurations) {
|
|
33
33
|
supported.set(id, configuration);
|
|
@@ -38,11 +38,11 @@ export function createSupportedCredentialConfigurations({
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
export function createSupportedCredentialRequests({
|
|
41
|
-
workflow, exchange, issueRequestsParams
|
|
41
|
+
workflow, exchange, issueRequestsParams, step
|
|
42
42
|
} = {}) {
|
|
43
43
|
const issuerInstances = getWorkflowIssuerInstances({workflow});
|
|
44
44
|
const supported = createSupportedCredentialConfigurations({
|
|
45
|
-
exchange, issuerInstances
|
|
45
|
+
exchange, issuerInstances, step
|
|
46
46
|
});
|
|
47
47
|
|
|
48
48
|
// for each `issueRequest` params, create a duplicate for each supported
|
|
@@ -130,12 +130,15 @@ function _matchCredentialRequests({
|
|
|
130
130
|
type: 'openid_credential',
|
|
131
131
|
credential_configuration_id
|
|
132
132
|
};
|
|
133
|
-
// only proof type supported for draft 13 is `jwt` per JSON schema that
|
|
134
|
-
// has already run
|
|
135
133
|
if(cr.proof) {
|
|
136
|
-
newRequest.proofs = {
|
|
137
|
-
|
|
138
|
-
|
|
134
|
+
newRequest.proofs = {};
|
|
135
|
+
for(const [key, value] of Object.entries(cr.proof)) {
|
|
136
|
+
if(key === 'proof_type') {
|
|
137
|
+
newRequest.proof_type = value;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
newRequest.proofs[key] = [value];
|
|
141
|
+
}
|
|
139
142
|
}
|
|
140
143
|
return newRequest;
|
|
141
144
|
}
|
|
@@ -149,7 +152,7 @@ function _matchCredentialRequests({
|
|
|
149
152
|
}
|
|
150
153
|
|
|
151
154
|
function _createCredentialConfigurations({
|
|
152
|
-
credentialRequest, supportedFormats
|
|
155
|
+
credentialRequest, supportedFormats, step
|
|
153
156
|
}) {
|
|
154
157
|
const configurations = [];
|
|
155
158
|
|
|
@@ -164,17 +167,23 @@ function _createCredentialConfigurations({
|
|
|
164
167
|
format, credential_definition
|
|
165
168
|
});
|
|
166
169
|
const configuration = {format, credential_definition};
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
170
|
+
if(step.divpDidProofRequest) {
|
|
171
|
+
configuration.proof_types_supported = {
|
|
172
|
+
di_vp: {
|
|
173
|
+
proof_signing_alg_values_supported: [
|
|
174
|
+
'ecdsa-rdfc-2019', 'eddsa-rdfc-2022'
|
|
175
|
+
]
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
if(step.jwtDidProofRequest) {
|
|
180
|
+
if(!configuration.proof_types_supported) {
|
|
181
|
+
configuration.proof_types_supported = {};
|
|
175
182
|
}
|
|
183
|
+
configuration.proof_types_supported.jwt = {
|
|
184
|
+
proof_signing_alg_values_supported: ['ES256']
|
|
185
|
+
};
|
|
176
186
|
}
|
|
177
|
-
*/
|
|
178
187
|
configurations.push({id, configuration});
|
|
179
188
|
}
|
|
180
189
|
|
package/lib/storage/exchanges.js
CHANGED
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
/*!
|
|
2
|
-
* Copyright (c) 2022-2026 Digital Bazaar, Inc.
|
|
2
|
+
* Copyright (c) 2022-2026 Digital Bazaar, Inc.
|
|
3
3
|
*/
|
|
4
4
|
import * as bedrock from '@bedrock/core';
|
|
5
5
|
import * as database from '@bedrock/mongodb';
|
|
6
|
+
import {decodeVariables, encodeVariables} from './variables.js';
|
|
7
|
+
import {
|
|
8
|
+
EXCHANGE_EXPIRY_GRACE_PERIOD, EXCHANGE_TTL_DEFAULT
|
|
9
|
+
} from '../constants.js';
|
|
6
10
|
import {parseLocalId, stripStacktrace} from '../helpers.js';
|
|
7
11
|
import assert from 'assert-plus';
|
|
8
12
|
import {logger} from '../logger.js';
|
|
9
13
|
import {serializeError} from 'serialize-error';
|
|
10
14
|
|
|
15
|
+
// ensure externalized `variables` garbage collector runs
|
|
16
|
+
import './variablesGarbageCollector.js';
|
|
17
|
+
|
|
11
18
|
const {util: {BedrockError}} = bedrock;
|
|
12
19
|
|
|
13
20
|
/* Note: Exchanges have default TTLs of 15 minutes and are always in one of
|
|
@@ -23,6 +30,7 @@ one or more steps, each that might issue, verify, or deliver VCs. Capabilities
|
|
|
23
30
|
must be provided to issue or verify VCs. */
|
|
24
31
|
|
|
25
32
|
const COLLECTION_NAME = 'vc-exchange';
|
|
33
|
+
const BUCKET_NAME = 'vc-exchange-variables';
|
|
26
34
|
|
|
27
35
|
// allow updates to the last error every 500ms
|
|
28
36
|
const LAST_ERROR_UPDATE_CONSTRAINTS = {
|
|
@@ -33,10 +41,10 @@ const LAST_ERROR_UPDATE_CONSTRAINTS = {
|
|
|
33
41
|
updateTimeLimit: 1000
|
|
34
42
|
};
|
|
35
43
|
|
|
36
|
-
const MONGODB_ILLEGAL_KEY_CHAR_REGEX = /[%$.]/;
|
|
37
|
-
|
|
38
44
|
bedrock.events.on('bedrock-mongodb.ready', async () => {
|
|
39
|
-
await database.openCollections([
|
|
45
|
+
await database.openCollections([
|
|
46
|
+
COLLECTION_NAME, `${BUCKET_NAME}.files`
|
|
47
|
+
]);
|
|
40
48
|
|
|
41
49
|
await database.createIndexes([{
|
|
42
50
|
// cover exchange queries by local workflow ID + exchange ID
|
|
@@ -84,32 +92,32 @@ export async function insert({workflowId, exchange}) {
|
|
|
84
92
|
assert.string(workflowId, 'workflowId');
|
|
85
93
|
assert.object(exchange, 'exchange');
|
|
86
94
|
assert.string(exchange.id, 'exchange.id');
|
|
95
|
+
assert.string(exchange.expires, 'exchange.expires');
|
|
87
96
|
// optional time to live in seconds
|
|
88
97
|
assert.optionalNumber(exchange.ttl, 'exchange.ttl');
|
|
89
98
|
// optional variables to use in VC templates
|
|
90
99
|
assert.optionalObject(exchange.variables, 'exchange.variables');
|
|
91
100
|
// optional current step in the exchange
|
|
92
101
|
assert.optionalString(exchange.step, 'exchange.step');
|
|
93
|
-
// optional expires in exchange
|
|
94
|
-
assert.optionalString(exchange.expires, 'exchange.expires');
|
|
95
102
|
// optional protocols in exchange
|
|
96
103
|
assert.optionalObject(exchange.protocols, 'exchange.protocols');
|
|
97
104
|
|
|
98
105
|
// build exchange record
|
|
99
106
|
const now = Date.now();
|
|
100
|
-
const meta = {
|
|
107
|
+
const meta = {
|
|
108
|
+
created: now,
|
|
109
|
+
updated: now,
|
|
110
|
+
expires: new Date(exchange.expires)
|
|
111
|
+
};
|
|
101
112
|
// possible states are: `pending`, `active`, `complete`, or `invalid`
|
|
102
113
|
exchange = {...exchange, sequence: 0, state: 'pending'};
|
|
103
|
-
if(exchange.expires !== undefined) {
|
|
104
|
-
meta.expires = new Date(exchange.expires);
|
|
105
|
-
}
|
|
106
114
|
const {localId: localWorkflowId} = parseLocalId({id: workflowId});
|
|
107
115
|
const record = {
|
|
108
116
|
localWorkflowId,
|
|
109
117
|
// backwards compatibility: enable existing systems to find record
|
|
110
118
|
localExchangerId: localWorkflowId,
|
|
111
119
|
meta,
|
|
112
|
-
exchange:
|
|
120
|
+
exchange: await encodeVariables({workflowId, exchange, meta})
|
|
113
121
|
};
|
|
114
122
|
|
|
115
123
|
// insert the exchange and get the updated record
|
|
@@ -175,11 +183,15 @@ export async function get({
|
|
|
175
183
|
}
|
|
176
184
|
|
|
177
185
|
let record = await collection.findOne(query, {projection});
|
|
178
|
-
if(
|
|
186
|
+
if(!allowExpired) {
|
|
179
187
|
// ensure `expires` is enforced programmatically even if background job
|
|
180
|
-
// has not yet removed the record
|
|
188
|
+
// has not yet removed the record; force unexpiring exchanges to be not
|
|
189
|
+
// found via this code path -- any exchanges without an expiration date
|
|
190
|
+
// are from very old software and they will need to be manually cleaned up
|
|
191
|
+
// in the database
|
|
181
192
|
const now = new Date();
|
|
182
|
-
|
|
193
|
+
// note: for undefined `expires`, this will be `NaN || now` => `now`
|
|
194
|
+
const expires = new Date(record.exchange.expires) || now;
|
|
183
195
|
if(now >= expires) {
|
|
184
196
|
record = null;
|
|
185
197
|
}
|
|
@@ -198,7 +210,7 @@ export async function get({
|
|
|
198
210
|
});
|
|
199
211
|
}
|
|
200
212
|
|
|
201
|
-
record.exchange =
|
|
213
|
+
record.exchange = await decodeVariables({workflowId, record});
|
|
202
214
|
|
|
203
215
|
// backwards compatibility; initialize `sequence`
|
|
204
216
|
if(record.exchange.sequence === undefined) {
|
|
@@ -228,21 +240,36 @@ export async function get({
|
|
|
228
240
|
* @param {string} options.workflowId - The ID of the workflow the exchange
|
|
229
241
|
* is associated with.
|
|
230
242
|
* @param {object} options.exchange - The exchange to update.
|
|
243
|
+
* @param {object} options.meta - The exchange meta to update.
|
|
231
244
|
* @param {boolean} [options.explain=false] - An optional explain boolean.
|
|
232
245
|
*
|
|
233
246
|
* @returns {Promise<boolean | ExplainObject>} Resolves with `true` on update
|
|
234
247
|
* success or an ExplainObject if `explain=true`.
|
|
235
248
|
*/
|
|
236
|
-
export async function update({
|
|
249
|
+
export async function update({
|
|
250
|
+
workflowId, exchange, meta, explain = false
|
|
251
|
+
} = {}) {
|
|
237
252
|
assert.string(workflowId, 'workflowId');
|
|
238
253
|
assert.object(exchange, 'exchange');
|
|
239
254
|
const {id} = exchange;
|
|
240
255
|
|
|
256
|
+
// force exchange to expire if this code has been called on an old exchange
|
|
257
|
+
// with no expiration date
|
|
258
|
+
let updateExpires = false;
|
|
259
|
+
if(exchange.expires === undefined) {
|
|
260
|
+
updateExpires = true;
|
|
261
|
+
const ttl = exchange.ttl ?? EXCHANGE_TTL_DEFAULT;
|
|
262
|
+
// TTL is in seconds, convert to milliseconds
|
|
263
|
+
const expires = new Date(Date.now() + ttl * 1000);
|
|
264
|
+
exchange.expires = expires.toISOString().replace(/\.\d+Z$/, 'Z');
|
|
265
|
+
}
|
|
266
|
+
|
|
241
267
|
// encode variable content for storage in mongoDB
|
|
242
|
-
|
|
268
|
+
meta = {...meta};
|
|
269
|
+
exchange = await encodeVariables({workflowId, exchange, meta});
|
|
243
270
|
|
|
244
271
|
// build update
|
|
245
|
-
const update = _buildUpdate({exchange});
|
|
272
|
+
const update = _buildUpdate({exchange, meta, updateExpires});
|
|
246
273
|
|
|
247
274
|
const {base, localId: localWorkflowId} = parseLocalId({id: workflowId});
|
|
248
275
|
|
|
@@ -516,10 +543,11 @@ async function _invalidateExchange({record}) {
|
|
|
516
543
|
`Could not mark exchange "${record.exchange.id}" invalid.`, {error});
|
|
517
544
|
}
|
|
518
545
|
|
|
519
|
-
/*
|
|
520
|
-
is specified in the exchange record). */
|
|
546
|
+
/* Consider perform auto-revocation of the VCs or notification (the action to
|
|
547
|
+
take is specified in the exchange record). */
|
|
521
548
|
// FIXME: handle auto-revocation / notification in background; do not throw
|
|
522
|
-
// errors to client
|
|
549
|
+
// errors to client; consider removing invalidation state as well and rely
|
|
550
|
+
// solely on TTL to manage exchange secrecy/abuse
|
|
523
551
|
}
|
|
524
552
|
|
|
525
553
|
async function _markExchangeInvalid({record}) {
|
|
@@ -539,9 +567,7 @@ async function _markExchangeInvalid({record}) {
|
|
|
539
567
|
$set: {
|
|
540
568
|
'exchange.state': 'invalid',
|
|
541
569
|
'meta.updated': now,
|
|
542
|
-
|
|
543
|
-
// (86400 seconds in 24 hours)
|
|
544
|
-
'meta.expires': new Date(now + 86400 * 3 * 1000)
|
|
570
|
+
'meta.expires': new Date(now + EXCHANGE_EXPIRY_GRACE_PERIOD)
|
|
545
571
|
}
|
|
546
572
|
};
|
|
547
573
|
const collection = database.collections[COLLECTION_NAME];
|
|
@@ -562,7 +588,7 @@ async function _markExchangeInvalid({record}) {
|
|
|
562
588
|
}
|
|
563
589
|
}
|
|
564
590
|
|
|
565
|
-
function _buildUpdate({exchange}) {
|
|
591
|
+
function _buildUpdate({exchange, meta, updateExpires = false}) {
|
|
566
592
|
// build update
|
|
567
593
|
const now = Date.now();
|
|
568
594
|
const update = {
|
|
@@ -579,15 +605,15 @@ function _buildUpdate({exchange}) {
|
|
|
579
605
|
if(exchange.step !== undefined) {
|
|
580
606
|
update.$set['exchange.step'] = exchange.step;
|
|
581
607
|
}
|
|
582
|
-
// only
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
608
|
+
// only update (fix) `expires` if it was not previously set (a very old
|
|
609
|
+
// exchange is being updated)
|
|
610
|
+
if(updateExpires) {
|
|
611
|
+
// TTL is in seconds, convert to milliseconds
|
|
612
|
+
const expires = new Date(exchange.expires);
|
|
613
|
+
// unset any previously set `ttl` and set `expires` instead
|
|
587
614
|
update.$unset['exchange.ttl'] = true;
|
|
588
615
|
update.$set['meta.expires'] = expires;
|
|
589
|
-
update.$set['exchange.expires'] =
|
|
590
|
-
expires.toISOString().replace(/\.\d+Z$/, 'Z');
|
|
616
|
+
update.$set['exchange.expires'] = exchange.expires;
|
|
591
617
|
}
|
|
592
618
|
if(exchange.lastError !== undefined) {
|
|
593
619
|
update.$set['exchange.lastError'] =
|
|
@@ -595,43 +621,13 @@ function _buildUpdate({exchange}) {
|
|
|
595
621
|
} else {
|
|
596
622
|
update.$unset['exchange.lastError'] = true;
|
|
597
623
|
}
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
function _encodeVariables({exchange}) {
|
|
603
|
-
// if any JSON object any variable uses a character that is not legal in
|
|
604
|
-
// a JSON key in mongoDB then stringify all the variables
|
|
605
|
-
if(_hasIllegalMongoDBKeyChar(exchange.variables)) {
|
|
606
|
-
return {...exchange, variables: JSON.stringify(exchange.variables)};
|
|
607
|
-
}
|
|
608
|
-
return exchange;
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
function _decodeVariables({exchange}) {
|
|
612
|
-
if(typeof exchange.variables === 'string') {
|
|
613
|
-
return {...exchange, variables: JSON.parse(exchange.variables)};
|
|
624
|
+
if(meta?.variablesFilename === false) {
|
|
625
|
+
update.$unset['meta.variablesFilename'] = true;
|
|
626
|
+
} else if(typeof meta?.variablesFilename === 'string') {
|
|
627
|
+
update.$set['meta.variablesFilename'] = meta.variablesFilename;
|
|
614
628
|
}
|
|
615
|
-
return exchange;
|
|
616
|
-
}
|
|
617
629
|
|
|
618
|
-
|
|
619
|
-
if(Array.isArray(value)) {
|
|
620
|
-
for(const e of value) {
|
|
621
|
-
if(_hasIllegalMongoDBKeyChar(e)) {
|
|
622
|
-
return true;
|
|
623
|
-
}
|
|
624
|
-
}
|
|
625
|
-
} else if(value && typeof value === 'object') {
|
|
626
|
-
const keys = Object.keys(value);
|
|
627
|
-
for(const key of keys) {
|
|
628
|
-
if(MONGODB_ILLEGAL_KEY_CHAR_REGEX.test(key) ||
|
|
629
|
-
_hasIllegalMongoDBKeyChar(value[key])) {
|
|
630
|
-
return true;
|
|
631
|
-
}
|
|
632
|
-
}
|
|
633
|
-
}
|
|
634
|
-
return false;
|
|
630
|
+
return update;
|
|
635
631
|
}
|
|
636
632
|
|
|
637
633
|
/**
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Copyright (c) 2022-2026 Digital Bazaar, Inc.
|
|
3
|
+
*/
|
|
4
|
+
import * as bedrock from '@bedrock/core';
|
|
5
|
+
import * as database from '@bedrock/mongodb';
|
|
6
|
+
import {createHash} from 'node:crypto';
|
|
7
|
+
import {EXCHANGE_EXPIRY_GRACE_PERIOD} from '../constants.js';
|
|
8
|
+
import {PassThrough} from 'node:stream';
|
|
9
|
+
import {pipeline} from 'node:stream/promises';
|
|
10
|
+
import {buffer as readIntoBuffer} from 'node:stream/consumers';
|
|
11
|
+
|
|
12
|
+
const {util: {BedrockError}} = bedrock;
|
|
13
|
+
|
|
14
|
+
/* Note: If the total size for `exchange.variables` is less than
|
|
15
|
+
`VARIABLES_SIZE_LIMIT` then the variables are stored in the exchange record.
|
|
16
|
+
Otherwise, the variables must be converted to JSON and externally stored, in
|
|
17
|
+
a gridfs bucket. Additionally, if the variables contain any JSON keys that
|
|
18
|
+
contain an invalid MongoDB key character, the variables must be stored as
|
|
19
|
+
JSON either within the exchange document or externally (where the JSON is
|
|
20
|
+
stored is determined by size limit mentioned above).
|
|
21
|
+
|
|
22
|
+
Importantly, all deployed workflow systems must be upgraded to enable reading
|
|
23
|
+
externalized variables prior to writing any externalized variables. So the
|
|
24
|
+
size limit must be at least 10MiB as old software prohibited submitting any
|
|
25
|
+
payloads larger than this over HTTP. That 10MiB HTTP limit must not be raised
|
|
26
|
+
until all systems have been upgraded to enable reading externalized variables,
|
|
27
|
+
ensuring no externalized variables will be written before then.
|
|
28
|
+
|
|
29
|
+
There still needs to be a cap on the maximum payload that will be accepted
|
|
30
|
+
on various HTTP endpoints to prevent DoS, but once systems support externally
|
|
31
|
+
stored variables, the limit can be raised beyond 10MiB. Additionally, a feature
|
|
32
|
+
to enable different limits per workflow could be implemented if desired (but it
|
|
33
|
+
will require different HTTP body parser setup). */
|
|
34
|
+
|
|
35
|
+
// very generous 1 hour grace period before externalized variables are deleted
|
|
36
|
+
// after an exchange expires to allow for various asynchronous behaviors
|
|
37
|
+
const VARIABLES_EXPIRY_GRACE_PERIOD = 1000 * 60 * 60;
|
|
38
|
+
|
|
39
|
+
// for gridfs storage of large exchange `variables`
|
|
40
|
+
export const VARIABLES_STORAGE = {
|
|
41
|
+
name: 'vc-exchange-variables',
|
|
42
|
+
bucket: null
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// limit to exchange `variables` size; at this size or larger, `variables`
|
|
46
|
+
// must be externalized and stored in a gridfs bucket
|
|
47
|
+
const VARIABLES_SIZE_LIMIT = 1024 * 1024 * 10;
|
|
48
|
+
|
|
49
|
+
// used to determine whether `variables` can be stored parsed as BSON or must
|
|
50
|
+
// be converted to JSON
|
|
51
|
+
const MONGODB_ILLEGAL_KEY_CHAR_REGEX = /[%$.]/;
|
|
52
|
+
|
|
53
|
+
bedrock.events.on('bedrock-mongodb.ready', async () => {
|
|
54
|
+
await database.openCollections([`${VARIABLES_STORAGE.name}.files`]);
|
|
55
|
+
|
|
56
|
+
VARIABLES_STORAGE.bucket = database.createGridFSBucket({
|
|
57
|
+
bucketName: VARIABLES_STORAGE.name
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
await database.createIndexes([{
|
|
61
|
+
// enables content-based ID lookups in `VARIABLES_STORAGE.name` bucket
|
|
62
|
+
collection: `${VARIABLES_STORAGE.name}.files`,
|
|
63
|
+
fields: {filename: 1},
|
|
64
|
+
options: {
|
|
65
|
+
unique: true
|
|
66
|
+
}
|
|
67
|
+
}, {
|
|
68
|
+
// enables an application worker (defined below) to find and delete expired
|
|
69
|
+
// `files` in the `VARIABLES_STORAGE.name` bucket; a TTL index is not used
|
|
70
|
+
// because `chunks` must also be removed and chunks do not contain a
|
|
71
|
+
// similar metadata field that would allow for robust atomic updates and
|
|
72
|
+
// clean up; so the GridFS file deletion API is used in the application
|
|
73
|
+
// worker
|
|
74
|
+
collection: `${VARIABLES_STORAGE.name}.files`,
|
|
75
|
+
// `metadata` is a built-in property for the `files` document schema; and
|
|
76
|
+
// `expires` will be added to it
|
|
77
|
+
fields: {'metadata.expires': 1},
|
|
78
|
+
options: {
|
|
79
|
+
partialFilterExpression: {
|
|
80
|
+
'metadata.expires': {$exists: true}
|
|
81
|
+
},
|
|
82
|
+
unique: false
|
|
83
|
+
}
|
|
84
|
+
}]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
export async function encodeVariables({workflowId, exchange, meta}) {
|
|
88
|
+
// express variables as JSON to determine total size; a future optimization
|
|
89
|
+
// might be able to avoid converting to JSON (in some cases)
|
|
90
|
+
const {variables, ...rest} = exchange;
|
|
91
|
+
const variablesJson = JSON.stringify(variables);
|
|
92
|
+
|
|
93
|
+
// any `variables` that are under the size limit can be stored in the
|
|
94
|
+
// exchange document itself
|
|
95
|
+
// FIXME: this version of the software includes `!meta.variablesFilename` in
|
|
96
|
+
// the conditional below to ensure that live updates can occur: it ensures
|
|
97
|
+
// this version of the software can run concurrently with either older
|
|
98
|
+
// versions of the software that have no understanding of externalized
|
|
99
|
+
// `variables` storage or with newer versions that do understand it (but not
|
|
100
|
+
// with both); this works because, until a new (likely major) release enables
|
|
101
|
+
// externalized `variables` storage (by setting `meta.variablesFilename`
|
|
102
|
+
// based on `variables` size), the following conditional will always execute;
|
|
103
|
+
// however, once such a release is made and this version encounters
|
|
104
|
+
// `meta.variablesFilename`, it will use externalized `variables` storage
|
|
105
|
+
// even if `variables` would fit in an exchange document, because this
|
|
106
|
+
// version defers the storage location decision to newer software; note that
|
|
107
|
+
// the new release will remove the `!meta.variablesFilename` check and simply
|
|
108
|
+
// use the size limit
|
|
109
|
+
if(!meta.variablesFilename || variablesJson.length < VARIABLES_SIZE_LIMIT) {
|
|
110
|
+
meta.variablesFilename = false;
|
|
111
|
+
// if any object has a key that uses a character that is not legal in
|
|
112
|
+
// a JSON key in mongoDB document, then stringify all the variables
|
|
113
|
+
return !_hasIllegalMongoDBKeyChar(variables) ?
|
|
114
|
+
exchange : {...rest, variables: variablesJson};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// generate content-based identifier for filename; noting that an local
|
|
118
|
+
// exchange ID MUST NOT (and currently does not) include a `_` character as
|
|
119
|
+
// it is used as a delimiter here
|
|
120
|
+
const buffer = Buffer.from(variablesJson, 'utf8');
|
|
121
|
+
const filename = `${exchange.id}_${_multibaseMultihashSha256(buffer)}`;
|
|
122
|
+
meta.variablesFilename = filename;
|
|
123
|
+
|
|
124
|
+
// create gridfs file metadata w/expiry; `exchange.expires` MUST be set
|
|
125
|
+
const expires = new Date(exchange.expires);
|
|
126
|
+
const metadata = {
|
|
127
|
+
// add exchange grace period to expiry to cover maximum exchange TTL and
|
|
128
|
+
// add a generous `variables` grace period as well
|
|
129
|
+
expires: new Date(
|
|
130
|
+
expires.getTime() +
|
|
131
|
+
EXCHANGE_EXPIRY_GRACE_PERIOD + VARIABLES_EXPIRY_GRACE_PERIOD)
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// store variables; any duplicate content-based identifier will throw a
|
|
135
|
+
// duplicate error which can be safely ignored
|
|
136
|
+
const stream = new PassThrough();
|
|
137
|
+
stream.end(buffer);
|
|
138
|
+
try {
|
|
139
|
+
await pipeline(
|
|
140
|
+
stream,
|
|
141
|
+
VARIABLES_STORAGE.bucket.openUploadStream(filename, {metadata}));
|
|
142
|
+
} catch(e) {
|
|
143
|
+
if(!database.isDuplicateError(e)) {
|
|
144
|
+
throw new BedrockError(`Could not store exchange variables.`, {
|
|
145
|
+
name: 'OperationError',
|
|
146
|
+
details: {
|
|
147
|
+
workflow: workflowId,
|
|
148
|
+
exchange: exchange.id,
|
|
149
|
+
public: true,
|
|
150
|
+
httpStatusCode: 500
|
|
151
|
+
},
|
|
152
|
+
cause: e
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return {...rest};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function decodeVariables({workflowId, record}) {
|
|
161
|
+
const {exchange, meta} = record;
|
|
162
|
+
|
|
163
|
+
// if `variables` are stored as a string, parse them from JSON
|
|
164
|
+
if(typeof exchange.variables === 'string') {
|
|
165
|
+
return {...exchange, variables: JSON.parse(exchange.variables)};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// if `meta` indicates that the variables are externalized, then read them
|
|
169
|
+
// from the gridfs variables bucket
|
|
170
|
+
if(meta.variablesFilename) {
|
|
171
|
+
try {
|
|
172
|
+
const {bucket} = VARIABLES_STORAGE;
|
|
173
|
+
const buffer = await readIntoBuffer(
|
|
174
|
+
bucket.openDownloadStreamByName(meta.variablesFilename));
|
|
175
|
+
exchange.variables = JSON.parse(buffer.toString('utf8'));
|
|
176
|
+
} catch(e) {
|
|
177
|
+
throw new BedrockError(`Could not load exchange variables.`, {
|
|
178
|
+
name: 'OperationError',
|
|
179
|
+
details: {
|
|
180
|
+
workflow: workflowId,
|
|
181
|
+
exchange: exchange.id,
|
|
182
|
+
public: true,
|
|
183
|
+
httpStatusCode: 500
|
|
184
|
+
},
|
|
185
|
+
cause: e
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return exchange;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function _hasIllegalMongoDBKeyChar(value) {
|
|
194
|
+
if(Array.isArray(value)) {
|
|
195
|
+
for(const e of value) {
|
|
196
|
+
if(_hasIllegalMongoDBKeyChar(e)) {
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
} else if(value && typeof value === 'object') {
|
|
201
|
+
const keys = Object.keys(value);
|
|
202
|
+
for(const key of keys) {
|
|
203
|
+
if(MONGODB_ILLEGAL_KEY_CHAR_REGEX.test(key) ||
|
|
204
|
+
_hasIllegalMongoDBKeyChar(value[key])) {
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function _multibaseMultihashSha256(buffer) {
|
|
213
|
+
// compute SHA-256 hash
|
|
214
|
+
const digest = createHash('sha256').update(buffer).digest();
|
|
215
|
+
|
|
216
|
+
// format as multihash digest
|
|
217
|
+
// sha2-256: 0x12, length: 32 (0x20), digest value
|
|
218
|
+
const mh = new Uint8Array(34);
|
|
219
|
+
mh[0] = 0x12;
|
|
220
|
+
mh[1] = 0x20;
|
|
221
|
+
mh.set(digest, 2);
|
|
222
|
+
|
|
223
|
+
// return as multibase-base64url-encoded value
|
|
224
|
+
return 'u' + Buffer.from(mh).toString('base64url');
|
|
225
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Copyright (c) 2022-2026 Digital Bazaar, Inc.
|
|
3
|
+
*/
|
|
4
|
+
import * as bedrock from '@bedrock/core';
|
|
5
|
+
import {logger} from '../logger.js';
|
|
6
|
+
import {rangeDelay} from 'delay';
|
|
7
|
+
import {VARIABLES_STORAGE} from './variables.js';
|
|
8
|
+
|
|
9
|
+
// state for running a garbage collector for expired externalized `variables`
|
|
10
|
+
const VARIABLES_GARBAGE_COLLECTOR = {
|
|
11
|
+
// used to abort variables garbage collector
|
|
12
|
+
abortController: new AbortController(),
|
|
13
|
+
// a Promise that resolves after the `variables` garbage collector has
|
|
14
|
+
// shutdown cleanly after receiving an abort signal
|
|
15
|
+
shutdownPromise: null
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
bedrock.events.on('bedrock.ready', () => {
|
|
19
|
+
// start the `variables` garbage collector, which runs continuously
|
|
20
|
+
VARIABLES_GARBAGE_COLLECTOR.shutdownPromise = _startGarbageCollector();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
bedrock.events.on('bedrock.exit', async () => {
|
|
24
|
+
try {
|
|
25
|
+
// abort variables garbage collector
|
|
26
|
+
VARIABLES_GARBAGE_COLLECTOR.abortController.abort();
|
|
27
|
+
logger.debug(
|
|
28
|
+
'Sent abort signal to "variables" garbage collector, ' +
|
|
29
|
+
'waiting for shutdown...');
|
|
30
|
+
await VARIABLES_GARBAGE_COLLECTOR.shutdownPromise;
|
|
31
|
+
logger.debug('"Variables" garbage collector shutdown was successful.');
|
|
32
|
+
} catch(error) {
|
|
33
|
+
logger.error(
|
|
34
|
+
'Error during "variables" garbage collector shutdown.', {error});
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
async function _deleteExpiredVariables({signal}) {
|
|
39
|
+
signal.throwIfAborted();
|
|
40
|
+
|
|
41
|
+
// delete all files found (limit=1000)
|
|
42
|
+
const {bucket} = VARIABLES_STORAGE;
|
|
43
|
+
const now = new Date(Date.now() + 86400 * 1000 * 365);
|
|
44
|
+
const projection = {_id: 1};
|
|
45
|
+
const cursor = bucket.find({
|
|
46
|
+
'metadata.expires': {$lte: now}
|
|
47
|
+
}, {projection}).limit(1000);
|
|
48
|
+
for await (const {_id} of cursor) {
|
|
49
|
+
try {
|
|
50
|
+
signal.throwIfAborted();
|
|
51
|
+
await bucket.delete(_id);
|
|
52
|
+
} catch(e) {
|
|
53
|
+
// ignore file not found errors and throw all others; note: there is
|
|
54
|
+
// currently not a better way to check for a `not found` error than to
|
|
55
|
+
// check the message text
|
|
56
|
+
if(!e.message.includes('not found')) {
|
|
57
|
+
throw e;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function _startGarbageCollector() {
|
|
64
|
+
const {
|
|
65
|
+
exchanges: {
|
|
66
|
+
variablesGarbageCollector: {interval}
|
|
67
|
+
}
|
|
68
|
+
} = bedrock.config['vc-workflow'];
|
|
69
|
+
const {signal} = VARIABLES_GARBAGE_COLLECTOR.abortController;
|
|
70
|
+
while(!signal.aborted) {
|
|
71
|
+
try {
|
|
72
|
+
// collect expired externalized exchange "variables" then delay for
|
|
73
|
+
// `interval` plus some fuzzing (up to 1 minute) to spread load
|
|
74
|
+
await _deleteExpiredVariables({signal});
|
|
75
|
+
await rangeDelay(interval, interval + 60000, {signal});
|
|
76
|
+
} catch(e) {
|
|
77
|
+
if(e.name === 'AbortError') {
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
logger.error('Error in "variables" garbage collector job.', {error: e});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
package/lib/vcapi.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/*!
|
|
2
|
-
* Copyright (c) 2018-2026 Digital Bazaar, Inc.
|
|
2
|
+
* Copyright (c) 2018-2026 Digital Bazaar, Inc.
|
|
3
3
|
*/
|
|
4
4
|
import * as bedrock from '@bedrock/core';
|
|
5
5
|
import * as exchanges from './storage/exchanges.js';
|
|
6
6
|
import {evaluateExchangeStep, generateRandom} from './helpers.js';
|
|
7
|
+
import {EXCHANGE_TTL_DEFAULT, EXCHANGE_TTL_MAX_IN_MS} from './constants.js';
|
|
7
8
|
import {exportJWK, generateKeyPair, importJWK} from 'jose';
|
|
8
9
|
import {ExchangeProcessor} from './ExchangeProcessor.js';
|
|
9
10
|
|
|
@@ -14,15 +15,10 @@ import * as oid4vp from './oid4/oid4vp.js';
|
|
|
14
15
|
|
|
15
16
|
const {util: {BedrockError}} = bedrock;
|
|
16
17
|
|
|
17
|
-
const FIFTEEN_MINUTES = 60 * 15;
|
|
18
|
-
|
|
19
|
-
// 48 hours; make configurable
|
|
20
|
-
const MAX_TTL_IN_MS = 1000 * 60 * 60 * 24 * 2;
|
|
21
|
-
|
|
22
18
|
export async function createExchange({workflow, exchange}) {
|
|
23
19
|
const {
|
|
24
20
|
expires,
|
|
25
|
-
ttl =
|
|
21
|
+
ttl = EXCHANGE_TTL_DEFAULT,
|
|
26
22
|
variables = {},
|
|
27
23
|
// allow steps to be skipped by creator as needed
|
|
28
24
|
step: stepName = workflow.initialStep,
|
|
@@ -54,7 +50,7 @@ export async function createExchange({workflow, exchange}) {
|
|
|
54
50
|
}
|
|
55
51
|
|
|
56
52
|
// should expires isn't too far into the future
|
|
57
|
-
const maxExpires = new Date(Date.now() +
|
|
53
|
+
const maxExpires = new Date(Date.now() + EXCHANGE_TTL_MAX_IN_MS);
|
|
58
54
|
if(new Date(exchange.expires) > maxExpires) {
|
|
59
55
|
throw new BedrockError(
|
|
60
56
|
'Maximum exchange expiration date is "' +
|
package/lib/verify.js
CHANGED
|
@@ -148,6 +148,53 @@ export async function verify({
|
|
|
148
148
|
};
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
export async function verifyCredentialRequestProof({
|
|
152
|
+
credentialRequest, workflow, exchange
|
|
153
|
+
} = {}) {
|
|
154
|
+
// FIXME: do not support more than one proof of each type at this time
|
|
155
|
+
const jwt = credentialRequest.proofs.jwt?.[0];
|
|
156
|
+
const diVp = credentialRequest.proofs.di_vp?.[0];
|
|
157
|
+
|
|
158
|
+
let _did;
|
|
159
|
+
const dids = [];
|
|
160
|
+
if(diVp) {
|
|
161
|
+
const {did} = await verifyDidProofDiVp({workflow, exchange, diVp});
|
|
162
|
+
dids.push(did);
|
|
163
|
+
_did = did;
|
|
164
|
+
}
|
|
165
|
+
if(jwt) {
|
|
166
|
+
const {did} = await verifyDidProofJwt({workflow, exchange, jwt});
|
|
167
|
+
dids.push(did);
|
|
168
|
+
if(_did === undefined) {
|
|
169
|
+
_did = did;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if(dids.some(d => d !== _did)) {
|
|
174
|
+
// FIXME: improve error
|
|
175
|
+
throw new Error('every DID must be the same');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return _did;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export async function verifyDidProofDiVp({workflow, exchange, diVp} = {}) {
|
|
182
|
+
// domain is always the `exchangeId` and cannot be configured; this
|
|
183
|
+
// prevents attacks where access tokens could otherwise be generated
|
|
184
|
+
// if the AS keys were compromised; the `exchangeId` must also be known
|
|
185
|
+
const exchangeId = `${workflow.id}/exchanges/${exchange.id}`;
|
|
186
|
+
const verifyResult = await verify({
|
|
187
|
+
workflow,
|
|
188
|
+
presentation: diVp,
|
|
189
|
+
// challenge is always local exchange ID; which is what is returned from
|
|
190
|
+
// nonce endpoint; VCALM exchanges are short-lived and are capability URLs
|
|
191
|
+
expectedChallenge: exchange.id,
|
|
192
|
+
expectedDomain: exchangeId
|
|
193
|
+
});
|
|
194
|
+
const did = verifyResult.verificationMethod.controller;
|
|
195
|
+
return {verified: true, did, verifyResult};
|
|
196
|
+
}
|
|
197
|
+
|
|
151
198
|
export async function verifyDidProofJwt({workflow, exchange, jwt} = {}) {
|
|
152
199
|
// optional oauth2 options
|
|
153
200
|
const {oauth2} = exchange.openId;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bedrock/vc-delivery",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.16.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Bedrock Verifiable Credential Delivery",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -40,13 +40,14 @@
|
|
|
40
40
|
"@digitalbazaar/ed25519-signature-2020": "^5.4.0",
|
|
41
41
|
"@digitalbazaar/ezcap": "^4.1.0",
|
|
42
42
|
"@digitalbazaar/http-client": "^4.2.0",
|
|
43
|
-
"@digitalbazaar/oid4-client": "^5.
|
|
43
|
+
"@digitalbazaar/oid4-client": "^5.12.1",
|
|
44
44
|
"@digitalbazaar/vc": "^7.2.0",
|
|
45
45
|
"@digitalbazaar/webkms-client": "^14.2.0",
|
|
46
46
|
"assert-plus": "^1.0.0",
|
|
47
47
|
"bnid": "^3.0.0",
|
|
48
48
|
"body-parser": "^1.20.3",
|
|
49
49
|
"cors": "^2.8.5",
|
|
50
|
+
"delay": "^7.0.0",
|
|
50
51
|
"jose": "^6.1.0",
|
|
51
52
|
"json-pointer": "^0.6.2",
|
|
52
53
|
"jsonata": "^2.0.6",
|
|
@@ -56,7 +57,7 @@
|
|
|
56
57
|
"@bedrock/app-identity": "^4.0.0",
|
|
57
58
|
"@bedrock/core": "^6.3.0",
|
|
58
59
|
"@bedrock/did-io": "^10.4.0",
|
|
59
|
-
"@bedrock/express": "^8.
|
|
60
|
+
"@bedrock/express": "^8.6.4",
|
|
60
61
|
"@bedrock/https-agent": "^4.1.0",
|
|
61
62
|
"@bedrock/mongodb": "^11.0.0",
|
|
62
63
|
"@bedrock/oauth2-verifier": "^2.3.1",
|
|
@@ -195,6 +195,20 @@ const credentialDefinition = {
|
|
|
195
195
|
}
|
|
196
196
|
};
|
|
197
197
|
|
|
198
|
+
const supportedProofTypeConfiguration = {
|
|
199
|
+
title: 'OID4VCI Supported Proof Type Configuration',
|
|
200
|
+
type: 'object',
|
|
201
|
+
required: ['proof_signing_alg_values_supported'],
|
|
202
|
+
additionalProperties: false,
|
|
203
|
+
properties: {
|
|
204
|
+
proof_signing_alg_values_supported: {
|
|
205
|
+
type: 'array',
|
|
206
|
+
minItems: 1,
|
|
207
|
+
items: {type: 'string'}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
|
|
198
212
|
function credentialConfiguration() {
|
|
199
213
|
return {
|
|
200
214
|
title: 'OID4VCI Credential Configuration',
|
|
@@ -209,14 +223,9 @@ function credentialConfiguration() {
|
|
|
209
223
|
},
|
|
210
224
|
proof_types_supported: {
|
|
211
225
|
type: 'object',
|
|
212
|
-
required: ['proof_signing_al_values_supported'],
|
|
213
|
-
additionalProperties: false,
|
|
214
226
|
properties: {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
minItems: 1,
|
|
218
|
-
items: {type: 'string'}
|
|
219
|
-
}
|
|
227
|
+
di_vp: supportedProofTypeConfiguration,
|
|
228
|
+
jwt: supportedProofTypeConfiguration
|
|
220
229
|
}
|
|
221
230
|
}
|
|
222
231
|
}
|
|
@@ -584,6 +593,31 @@ function computedStep() {
|
|
|
584
593
|
}
|
|
585
594
|
}
|
|
586
595
|
},
|
|
596
|
+
divpDidProofRequest: {
|
|
597
|
+
type: 'object',
|
|
598
|
+
additionalProperties: false,
|
|
599
|
+
properties: {
|
|
600
|
+
acceptedMethods: {
|
|
601
|
+
title: 'Accepted DID Methods',
|
|
602
|
+
type: 'array',
|
|
603
|
+
minItems: 1,
|
|
604
|
+
items: {
|
|
605
|
+
title: 'Accepted DID Method',
|
|
606
|
+
type: 'object',
|
|
607
|
+
additionalProperties: false,
|
|
608
|
+
properties: {
|
|
609
|
+
method: {type: 'string'}
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
},
|
|
613
|
+
allowedCryptosuites: {
|
|
614
|
+
title: 'Allowed DI Cryptosuites',
|
|
615
|
+
type: 'array',
|
|
616
|
+
minItems: 1,
|
|
617
|
+
items: {type: 'string'}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
},
|
|
587
621
|
nextStep: {type: 'string'},
|
|
588
622
|
// required to support OID4VP
|
|
589
623
|
openId: {
|
|
@@ -717,13 +751,17 @@ function openIdCredentialRequestDraft13() {
|
|
|
717
751
|
title: 'DID Authn Proof JWT',
|
|
718
752
|
type: 'object',
|
|
719
753
|
additionalProperties: false,
|
|
720
|
-
|
|
754
|
+
anyOf: [
|
|
755
|
+
{required: ['proof_type', 'jwt']},
|
|
756
|
+
{required: ['proof_type', 'di_vp']}
|
|
757
|
+
],
|
|
721
758
|
properties: {
|
|
722
759
|
proof_type: {
|
|
723
760
|
type: 'string',
|
|
724
|
-
enum: ['jwt']
|
|
761
|
+
enum: ['jwt', 'di_vp']
|
|
725
762
|
},
|
|
726
|
-
jwt: {type: 'string'}
|
|
763
|
+
jwt: {type: 'string'},
|
|
764
|
+
di_vp: verifiablePresentation()
|
|
727
765
|
}
|
|
728
766
|
}
|
|
729
767
|
}
|
|
@@ -735,21 +773,15 @@ function openIdCredentialRequestVersion1() {
|
|
|
735
773
|
title: 'OID4VCI-1.0 Credential Request',
|
|
736
774
|
type: 'object',
|
|
737
775
|
additionalProperties: false,
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
// which is not supported
|
|
742
|
-
{required: ['credential_identifier']}//,
|
|
743
|
-
//{required: ['credential_configuration_id']}
|
|
744
|
-
],
|
|
776
|
+
// `credential_configuration_id` is for scope-identified credentials,
|
|
777
|
+
// which is not supported
|
|
778
|
+
required: ['credential_identifier'],
|
|
745
779
|
properties: {
|
|
746
780
|
credential_identifier: {type: 'string'},
|
|
747
|
-
// FIXME: remove me
|
|
748
|
-
//credential_configuration_id: {type: 'string'},
|
|
749
781
|
proofs: {
|
|
750
782
|
type: 'object',
|
|
751
783
|
additionalProperties: false,
|
|
752
|
-
|
|
784
|
+
anyOf: [
|
|
753
785
|
{required: ['jwt']},
|
|
754
786
|
{required: ['di_vp']}
|
|
755
787
|
],
|