@bedrock/vc-delivery 1.0.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/index.js ADDED
@@ -0,0 +1,118 @@
1
+ /*!
2
+ * Copyright (c) 2022 Digital Bazaar, Inc. All rights reserved.
3
+ */
4
+ import * as bedrock from '@bedrock/core';
5
+ import * as exchangerSchemas from '../schemas/bedrock-vc-exchanger.js';
6
+ import {createService, schemas} from '@bedrock/service-core';
7
+ import {addRoutes} from './http.js';
8
+ import {initializeServiceAgent} from '@bedrock/service-agent';
9
+ import {klona} from 'klona';
10
+ import '@bedrock/express';
11
+
12
+ // load config defaults
13
+ import './config.js';
14
+
15
+ const serviceType = 'vc-exchanger';
16
+ const {util: {BedrockError}} = bedrock;
17
+
18
+ bedrock.events.on('bedrock.init', async () => {
19
+ // add customizations to config validators...
20
+ const createConfigBody = klona(schemas.createConfigBody);
21
+ const updateConfigBody = klona(schemas.updateConfigBody);
22
+ const schemasToUpdate = [createConfigBody, updateConfigBody];
23
+ const {credentialTemplates, steps, initialStep} = exchangerSchemas;
24
+ for(const schema of schemasToUpdate) {
25
+ // add config requirements to exchanger configs
26
+ schema.properties.credentialTemplates = credentialTemplates;
27
+ schema.properties.steps = steps;
28
+ schema.properties.initialStep = initialStep;
29
+ // note: credential templates are not required; if any other properties
30
+ // become required, add them here
31
+ // schema.required.push('credentialTemplates');
32
+ }
33
+
34
+ // create `vc-exchanger` service
35
+ const service = await createService({
36
+ serviceType,
37
+ routePrefix: '/exchangers',
38
+ storageCost: {
39
+ config: 1,
40
+ revocation: 1
41
+ },
42
+ validation: {
43
+ createConfigBody,
44
+ updateConfigBody,
45
+ validateConfigFn,
46
+ // these zcaps are optional (by reference ID)
47
+ zcapReferenceIds: [{
48
+ referenceId: 'issue',
49
+ required: false
50
+ }, {
51
+ referenceId: 'credentialStatus',
52
+ required: false
53
+ }, {
54
+ referenceId: 'createChallenge',
55
+ required: false
56
+ }, {
57
+ referenceId: 'verifyPresentation',
58
+ required: false
59
+ }]
60
+ },
61
+ usageAggregator
62
+ });
63
+
64
+ bedrock.events.on('bedrock-express.configure.routes', async app => {
65
+ await addRoutes({app, service});
66
+ });
67
+
68
+ // initialize vc-exchanger service agent early (after database is ready) if
69
+ // KMS system is externalized; otherwise we must wait until KMS system
70
+ // is ready
71
+ const externalKms = !bedrock.config['service-agent'].kms.baseUrl.startsWith(
72
+ bedrock.config.server.baseUri);
73
+ const event = externalKms ? 'bedrock-mongodb.ready' : 'bedrock.ready';
74
+ bedrock.events.on(event, async () => {
75
+ await initializeServiceAgent({serviceType});
76
+ });
77
+ });
78
+
79
+ async function usageAggregator({meter, signal, service} = {}) {
80
+ const {id: meterId} = meter;
81
+ // FIXME: add `exchanges` storage
82
+ return service.configStorage.getUsage({meterId, signal});
83
+ }
84
+
85
+ async function validateConfigFn({config} = {}) {
86
+ try {
87
+ // if credential templates are specified, then `zcaps` MUST include at
88
+ // least `issue`
89
+ const {credentialTemplates = [], zcaps = {}} = config;
90
+ if(credentialTemplates.length > 0 && !zcaps.issue) {
91
+ throw new BedrockError(
92
+ 'A capability to issue credentials is required when credential ' +
93
+ 'templates are provided.', {
94
+ name: 'DataError',
95
+ details: {
96
+ httpStatusCode: 400,
97
+ public: true
98
+ }
99
+ });
100
+ }
101
+
102
+ // if `steps` are specified, then `initialStep` MUST be included
103
+ const {steps = [], initialStep} = config;
104
+ if(steps.length > 0 && initialStep === undefined) {
105
+ throw new BedrockError(
106
+ '"initialStep" is required when "steps" are provided.', {
107
+ name: 'DataError',
108
+ details: {
109
+ httpStatusCode: 400,
110
+ public: true
111
+ }
112
+ });
113
+ }
114
+ } catch(error) {
115
+ return {valid: false, error};
116
+ }
117
+ return {valid: true};
118
+ }
package/lib/issue.js ADDED
@@ -0,0 +1,59 @@
1
+ /*!
2
+ * Copyright (c) 2022 Digital Bazaar, Inc. All rights reserved.
3
+ */
4
+ import {createPresentation} from '@digitalbazaar/vc';
5
+ import {getZcapClient} from './helpers.js';
6
+ import jsonata from 'jsonata';
7
+
8
+ export async function issue({exchanger, exchange} = {}) {
9
+ // use any templates from exchanger and variables from exchange to produce
10
+ // credentials to be issued; issue via the configured issuer instance
11
+ const verifiableCredential = [];
12
+ const {credentialTemplates = []} = exchanger;
13
+ if(credentialTemplates) {
14
+ const {variables = {}} = exchange;
15
+ // run jsonata compiler; only `jsonata` template type is supported and this
16
+ // was validated when the exchanger was created
17
+ const credentials = credentialTemplates.map(
18
+ ({template: t}) => jsonata(t).evaluate(variables));
19
+
20
+ // issue all VCs
21
+ const vcs = await _issue({exchanger, credentials});
22
+ verifiableCredential.push(...vcs);
23
+ }
24
+
25
+ // generate VP to return VCs
26
+ const verifiablePresentation = createPresentation();
27
+ // FIXME: add any encrypted VCs to VP
28
+
29
+ // add any issued VCs to VP
30
+ if(verifiableCredential.length > 0) {
31
+ verifiablePresentation.verifiableCredential = verifiableCredential;
32
+ }
33
+ return {verifiablePresentation};
34
+ }
35
+
36
+ async function _issue({exchanger, credentials} = {}) {
37
+ // create zcap client for issuing VCs
38
+ const {zcapClient, zcaps} = await getZcapClient({exchanger});
39
+
40
+ // issue VCs in parallel
41
+ const capability = zcaps.issue;
42
+ // specify URL to `/credentials/issue` to handle case that capability
43
+ // is not specific to it
44
+ let url = capability.invocationTarget;
45
+ if(!capability.invocationTarget.endsWith('/credentials/issue')) {
46
+ if(!capability.invocationTarget.endsWith('/credentials')) {
47
+ url += '/credentials/issue';
48
+ } else {
49
+ url += '/issue';
50
+ }
51
+ }
52
+ const results = await Promise.all(credentials.map(
53
+ credential => zcapClient.write({url, capability, json: {credential}})));
54
+
55
+ // parse VCs from results
56
+ const verifiableCredentials = results.map(
57
+ ({data: {verifiableCredential}}) => verifiableCredential);
58
+ return verifiableCredentials;
59
+ }
package/lib/logger.js ADDED
@@ -0,0 +1,6 @@
1
+ /*!
2
+ * Copyright (c) 2020-2022 Digital Bazaar, Inc. All rights reserved.
3
+ */
4
+ import {loggers} from '@bedrock/core';
5
+
6
+ export const logger = loggers.get('app').child('bedrock-vc-exchanger');
@@ -0,0 +1,345 @@
1
+ /*!
2
+ * Copyright (c) 2022 Digital Bazaar, Inc. All rights reserved.
3
+ */
4
+ import * as exchanges from './exchanges.js';
5
+ import {importJWK, SignJWT} from 'jose';
6
+ import {
7
+ oidc4vciCredentialBody, oidc4vciTokenBody
8
+ } from '../schemas/bedrock-vc-exchanger.js';
9
+ import {asyncHandler} from '@bedrock/express';
10
+ import bodyParser from 'body-parser';
11
+ import {checkAccessToken} from '@bedrock/oauth2-verifier';
12
+ import cors from 'cors';
13
+ import {issue} from './issue.js';
14
+ import {timingSafeEqual} from 'node:crypto';
15
+ import {createValidateMiddleware as validate} from '@bedrock/validation';
16
+ import {verifyDidProofJwt} from './verify.js';
17
+
18
+ /* NOTE: Parts of the OIDC4VCI design imply tight integration between the
19
+ authorization server and the credential issuance / delivery server. This
20
+ file provides the routes for both and treats them as integrated; supporting
21
+ the OIDC4VCI pre-authz code flow only as a result. However, we also try to
22
+ avoid tight-coupling where possible to enable the non-pre-authz code flow
23
+ that would use, somehow, a separate authorization server.
24
+
25
+ One tight coupling we try to avoid involves the option where the authorization
26
+ server generates the challenge nonce to be signed in a DID proof, but the
27
+ credential delivery server is the system responsible for checking and tracking
28
+ this challenge. The Credential Delivery server cannot know the challenge is
29
+ authentic without breaking some abstraction around how the Authorization
30
+ Server is implemented behind its API. Here we do not implement this option,
31
+ instead, if a challenge is required, the credential delivery server will send
32
+ an error with the challenge nonce if one was not provided in the payload to the
33
+ credential endpoint. This error follows the OIDC4VCI spec and avoids this
34
+ particular tight coupling.
35
+
36
+ Other tight couplings cannot be avoided at this time -- such as the fact that
37
+ the credential endpoint is specified in the authorization server's metadata;
38
+ this creates challenges for SaaS based solutions and for issuers that want to
39
+ use multiple different Issuance / Delivery server backends. We solve these
40
+ challenges by using the "pre-authorized code" flows and effectively
41
+ instantiating a new authorization server instance per VC exchange. */
42
+
43
+ const PRE_AUTH_GRANT_TYPE =
44
+ 'urn:ietf:params:oauth:grant-type:pre-authorized_code';
45
+
46
+ // creates OIDC4VCI Authorization Server + Credential Delivery Server
47
+ // endpoints for each individual exchange
48
+ export async function createRoutes({
49
+ app, exchangeRoute, getConfigMiddleware, getExchange
50
+ } = {}) {
51
+ const oidc4vciRoute = `${exchangeRoute}/oidc4vci`;
52
+ const routes = {
53
+ asMetadata: `/.well-known/oauth-authorization-server${exchangeRoute}`,
54
+ credential: `${oidc4vciRoute}/credential`,
55
+ token: `${oidc4vciRoute}/token`,
56
+ jwks: `${oidc4vciRoute}/jwks`
57
+ };
58
+
59
+ // urlencoded body parser (extended=true for rich JSON-like representation)
60
+ const urlencoded = bodyParser.urlencoded({extended: true});
61
+
62
+ // an authorization server endpoint
63
+ // serves `.well-known` oauth2 AS config for each exchange; each config is
64
+ // based on the exchanger used to create the exchange
65
+ app.get(
66
+ routes.asMetadata,
67
+ cors(),
68
+ getConfigMiddleware,
69
+ asyncHandler(async (req, res) => {
70
+ // generate well-known oauth2 issuer config
71
+ const {config: exchanger} = req.serviceObject;
72
+ const exchangeId = `${exchanger.id}/exchanges/${req.params.exchangeId}`;
73
+ const oauth2Config = {
74
+ issuer: exchangeId,
75
+ jwks_uri: `${exchangeId}/oidc4vci/jwks`,
76
+ token_endpoint: `${exchangeId}/oidc4vci/token`,
77
+ credential_endpoint: `${exchangeId}/oidc4vci/credential`
78
+ };
79
+ res.json(oauth2Config);
80
+ }));
81
+
82
+ // an authorization server endpoint
83
+ // serves JWKs associated with each exchange; JWKs are stored with the
84
+ // exchanger used to create the exchange
85
+ app.get(
86
+ routes.jwks,
87
+ cors(),
88
+ getExchange,
89
+ asyncHandler(async (req, res) => {
90
+ const {exchange} = await req.exchange;
91
+ if(!exchange.oidc4vci) {
92
+ // FIXME: improve error
93
+ // unsupported protocol for the exchange
94
+ throw new Error('unsupported protocol');
95
+ }
96
+ // serve exchange's public key
97
+ res.json({keys: [exchange.oidc4vci.oauth2.keyPair.publicKeyJwk]});
98
+ }));
99
+
100
+ // an authorization server endpoint
101
+ // handles pre-authorization code exchange for access token; only supports
102
+ // pre-authorization code grant type
103
+ app.options(routes.token, cors());
104
+ app.post(
105
+ routes.token,
106
+ cors(),
107
+ urlencoded,
108
+ validate({bodySchema: oidc4vciTokenBody}),
109
+ getConfigMiddleware,
110
+ getExchange,
111
+ asyncHandler(async (req, res) => {
112
+ const exchangeRecord = await req.exchange;
113
+ const {exchange} = exchangeRecord;
114
+ if(!exchange.oidc4vci) {
115
+ // FIXME: improve error
116
+ // unsupported protocol for the exchange
117
+ throw new Error('unsupported protocol');
118
+ }
119
+
120
+ /* Examples of types of token requests:
121
+ pre-authz code:
122
+ grant_type=urn:ietf:params:oauth:grant-type:pre-authorized_code
123
+ &pre-authorized_code=SplxlOBeZQQYbYS6WxSbIA
124
+ &user_pin=493536
125
+
126
+ authz code:
127
+ grant_type=authorization_code
128
+ &code=SplxlOBeZQQYbYS6WxSbIA
129
+ &code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
130
+ &redirect_uri=https%3A%2F%2FWallet.example.org%2Fcb */
131
+
132
+ const {
133
+ grant_type: grantType,
134
+ 'pre-authorized_code': preAuthorizedCode,
135
+ //user_pin: userPin
136
+ } = req.body;
137
+
138
+ if(grantType !== PRE_AUTH_GRANT_TYPE) {
139
+ // unsupported grant type
140
+ // FIXME: throw proper oauth2 formatted error
141
+ throw new Error('unsupported grant type');
142
+ }
143
+
144
+ // validate grant type
145
+ const {oidc4vci: {preAuthorizedCode: expectedCode}} = exchange;
146
+ if(expectedCode) {
147
+ // ensure expected pre-authz code matches
148
+ if(!timingSafeEqual(
149
+ Buffer.from(expectedCode, 'utf8'),
150
+ Buffer.from(preAuthorizedCode, 'utf8'))) {
151
+ // FIXME: throw proper oauth2 formatted error
152
+ throw new Error('invalid pre-authorized-code or user pin');
153
+ }
154
+ }
155
+
156
+ // create access token
157
+ const {config: exchanger} = req.serviceObject;
158
+ const {accessToken, ttl} = await _createExchangeAccessToken(
159
+ {exchanger, exchangeRecord});
160
+
161
+ // send response
162
+ const body = {
163
+ access_token: accessToken,
164
+ token_type: 'bearer',
165
+ expires_in: ttl
166
+ };
167
+ res.json(body);
168
+ }));
169
+
170
+ // a credential delivery server endpoint
171
+ // receives a credential request and returns VCs
172
+ app.options(routes.credential, cors());
173
+ app.post(
174
+ routes.credential,
175
+ cors(),
176
+ validate({bodySchema: oidc4vciCredentialBody}),
177
+ getConfigMiddleware,
178
+ getExchange,
179
+ asyncHandler(async (req, res) => {
180
+ /* Clients must POST, e.g.:
181
+ POST /credential HTTP/1.1
182
+ Host: server.example.com
183
+ Content-Type: application/json
184
+ Authorization: BEARER czZCaGRSa3F0MzpnWDFmQmF0M2JW
185
+
186
+ {
187
+ "type": "https://did.example.org/healthCard"
188
+ "format": "ldp_vc",
189
+ "did": "did:example:ebfeb1f712ebc6f1c276e12ec21",
190
+ "proof": {
191
+ "proof_type": "jwt",
192
+ "jwt": "eyJra...nOzM"
193
+ }
194
+ }
195
+ */
196
+ const {config: exchanger} = req.serviceObject;
197
+ const exchangeRecord = await req.exchange;
198
+ const {exchange} = exchangeRecord;
199
+ if(!exchange.oidc4vci) {
200
+ // FIXME: improve error
201
+ // unsupported protocol for the exchange
202
+ throw new Error('unsupported protocol');
203
+ }
204
+
205
+ // ensure oauth2 access token is valid
206
+ await _checkAuthz({req, exchanger, exchange});
207
+
208
+ // process exchange step if present
209
+ if(exchange.step) {
210
+ const step = exchanger.steps[exchange.step];
211
+
212
+ // handle JWT DID Proof request; if step requires it, then `proof` must
213
+ // be in the credential request
214
+ if(step.jwtDidProofRequest) {
215
+ // if no proof is in the body...
216
+ if(!req?.body?.proof?.jwt) {
217
+ return _requestDidProof({res, exchangeRecord});
218
+ }
219
+ // verify the DID proof
220
+ const {body: {proof: {jwt}}} = req;
221
+ const {did} = await verifyDidProofJwt({exchanger, exchange, jwt});
222
+ // add `did` to exchange variables
223
+ exchange.variables[exchange.step] = {did};
224
+ }
225
+ }
226
+
227
+ // mark exchange complete
228
+ await exchanges.complete({exchangerId: exchanger.id, id: exchange.id});
229
+
230
+ // FIXME: decide what the best recovery path is if delivery fails (but no
231
+ // replay attack detected) after exchange has been marked complete
232
+
233
+ // issue VCs
234
+ const {verifiablePresentation} = await issue({exchanger, exchange});
235
+
236
+ // send VC
237
+ res.json({
238
+ format: 'ldp_vc',
239
+ /* Note: OIDC4VCI only supports sending a single VC; assume here that
240
+ the exchanger is configured to only allow OIDC4VCI when a single
241
+ VC is being issued. */
242
+ credential: verifiablePresentation.verifiableCredential[0]
243
+ });
244
+ }));
245
+ }
246
+
247
+ async function _createExchangeAccessToken({exchanger, exchangeRecord}) {
248
+ // FIXME: set `exp` to max of 15 minutes / configured max minutes
249
+ const expires = exchangeRecord.meta.expires;
250
+ const exp = Math.floor(expires.getTime() / 1000);
251
+
252
+ // create access token
253
+ // FIXME: allow per-service-object-instance agent via `signer` and custom
254
+ // JWT signer code instead?
255
+ const {exchange} = exchangeRecord;
256
+ const {oidc4vci: {oauth2: {keyPair: {privateKeyJwk}}}} = exchange;
257
+ const exchangeId = `${exchanger.id}/exchanges/${exchange.id}`;
258
+ const {accessToken, ttl} = await _createOAuth2AccessToken({
259
+ privateKeyJwk, audience: exchangeId, action: 'write', target: exchangeId,
260
+ exp, iss: exchangeId
261
+ });
262
+ return {accessToken, ttl};
263
+ }
264
+
265
+ async function _createOAuth2AccessToken({
266
+ privateKeyJwk, audience, action, target, exp, iss, nbf, typ = 'at+jwt'
267
+ }) {
268
+ const scope = `${action}:${target}`;
269
+ const builder = new SignJWT({scope})
270
+ .setProtectedHeader({alg: 'EdDSA', typ})
271
+ .setIssuer(iss)
272
+ .setAudience(audience);
273
+ let ttl;
274
+ if(exp !== undefined) {
275
+ builder.setExpirationTime(exp);
276
+ ttl = Math.max(0, exp - Math.floor(Date.now() / 1000));
277
+ } else {
278
+ // default to 15 minute expiration time
279
+ builder.setExpirationTime('15m');
280
+ ttl = Math.floor(Date.now() / 1000) + 15 * 60;
281
+ }
282
+ if(nbf !== undefined) {
283
+ builder.setNotBefore(nbf);
284
+ }
285
+ const key = await importJWK({...privateKeyJwk, alg: 'EdDSA'});
286
+ const accessToken = await builder.sign(key);
287
+ return {accessToken, ttl};
288
+ }
289
+
290
+ async function _checkAuthz({req, exchanger, exchange}) {
291
+ // optional oauth2 options
292
+ const {oauth2} = exchange.oidc4vci;
293
+ const {maxClockSkew} = oauth2;
294
+
295
+ // audience is always the `exchangeId` and cannot be configured; this
296
+ // prevents attacks where access tokens could otherwise be generated
297
+ // if the AS keys were compromised; the `exchangeId` must also be known
298
+ const exchangeId = `${exchanger.id}/exchanges/${req.params.exchangeId}`;
299
+ const audience = exchangeId;
300
+
301
+ // `issuerConfigUrl` is always based off of the `exchangeId` as well
302
+ const parsedIssuer = new URL(exchangeId);
303
+ const issuerConfigUrl =
304
+ `${parsedIssuer.origin}/.well-known/oauth-authorization-server` +
305
+ parsedIssuer.pathname;
306
+
307
+ // FIXME: `allowedAlgorithms` should be computed from `oauth2.keyPair`
308
+ // const allowedAlgorithms =
309
+
310
+ // ensure access token is valid
311
+ await checkAccessToken({req, issuerConfigUrl, maxClockSkew, audience});
312
+ }
313
+
314
+ async function _requestDidProof({res, exchangeRecord}) {
315
+ /* `9.4 Credential Issuer-provided nonce` allows the credential
316
+ issuer infrastructure to provide the nonce via an error:
317
+
318
+ HTTP/1.1 400 Bad Request
319
+ Content-Type: application/json
320
+ Cache-Control: no-store
321
+
322
+ {
323
+ "error": "invalid_or_missing_proof"
324
+ "error_description":
325
+ "Credential issuer requires proof element in Credential Request"
326
+ "c_nonce": "8YE9hCnyV2",
327
+ "c_nonce_expires_in": 86400
328
+ }*/
329
+
330
+ /* OIDC4VCI exchanges themselves are not replayable and single-step, so the
331
+ challenge to be signed is just the exchange ID itself. An exchange cannot
332
+ be reused and neither can a challenge. */
333
+ const {exchange, meta: {expires}} = exchangeRecord;
334
+ const ttl = Math.floor((expires.getTime() - Date.now()) / 1000);
335
+
336
+ res.status(400).json({
337
+ error: 'invalid_or_missing_proof',
338
+ error_description:
339
+ 'Credential issuer requires proof element in Credential Request',
340
+ // use exchange ID
341
+ c_nonce: exchange.id,
342
+ // use exchange expiration period
343
+ c_nonce_expires_in: ttl
344
+ });
345
+ }
package/lib/verify.js ADDED
@@ -0,0 +1,153 @@
1
+ /*!
2
+ * Copyright (c) 2022 Digital Bazaar, Inc. All rights reserved.
3
+ */
4
+ import * as bedrock from '@bedrock/core';
5
+ import {importJWK, jwtVerify} from 'jose';
6
+ import {didIo} from '@bedrock/did-io';
7
+ import {
8
+ Ed25519VerificationKey2020
9
+ } from '@digitalbazaar/ed25519-verification-key-2020';
10
+ import {getZcapClient} from './helpers.js';
11
+
12
+ const {util: {BedrockError}} = bedrock;
13
+
14
+ export async function createChallenge({exchanger} = {}) {
15
+ // create zcap client for creating challenges
16
+ const {zcapClient, zcaps} = await getZcapClient({exchanger});
17
+
18
+ // create challenge
19
+ const capability = zcaps.createChallenge;
20
+ const {data: {challenge}} = await zcapClient.write({capability, json: {}});
21
+ return {challenge};
22
+ }
23
+
24
+ export async function verify({
25
+ exchanger, presentation, expectedChallenge
26
+ } = {}) {
27
+ // create zcap client for verifying
28
+ const {zcapClient, zcaps} = await getZcapClient({exchanger});
29
+
30
+ // verify presentation
31
+ const checks = ['proof'];
32
+ if(!expectedChallenge) {
33
+ // if no expected challenge, rely on verifier for challenge management
34
+ checks.push('challenge');
35
+ }
36
+ const capability = zcaps.verifyPresentation;
37
+ const domain = new URL(exchanger.id).origin;
38
+ const result = await zcapClient.write({
39
+ capability,
40
+ json: {
41
+ options: {
42
+ // FIXME: support multi-proof presentations?
43
+ challenge: expectedChallenge ?? presentation?.proof?.challenge,
44
+ domain,
45
+ checks
46
+ },
47
+ verifiablePresentation: presentation
48
+ }
49
+ });
50
+
51
+ const {
52
+ data: {
53
+ verified, challengeUses,
54
+ presentationResult: {results: [{verificationMethod}]}
55
+ }
56
+ } = result;
57
+
58
+ return {verified, challengeUses, verificationMethod};
59
+ }
60
+
61
+ export async function verifyDidProofJwt({exchanger, exchange, jwt} = {}) {
62
+ // optional oauth2 options
63
+ const {oauth2} = exchange.oidc4vci;
64
+ const {maxClockSkew} = oauth2;
65
+
66
+ // audience is always the `exchangeId` and cannot be configured; this
67
+ // prevents attacks where access tokens could otherwise be generated
68
+ // if the AS keys were compromised; the `exchangeId` must also be known
69
+ const exchangeId = `${exchanger.id}/exchanges/${exchange.id}`;
70
+ const audience = exchangeId;
71
+
72
+ let issuer;
73
+ const resolveKey = async protectedHeader => {
74
+ const vm = await didIo.get({url: protectedHeader.kid});
75
+ // `vm.controller` must be the issuer of the DID JWT; also ensure that
76
+ // the specified controller authorized `vm` for the purpose of
77
+ // authentication
78
+ issuer = vm.controller;
79
+ const didDoc = await didIo.get({url: issuer});
80
+ if(!(didDoc?.authentication?.some(e => e === vm.id || e.id === vm.id))) {
81
+ throw new BedrockError(
82
+ `Verification method controller "${issuer}" did not authorize ` +
83
+ `verification method "${vm.id}" for the purpose of "authentication".`,
84
+ {name: 'NotAllowedError'});
85
+ }
86
+ // FIXME: support other key types
87
+ const publicKey = await Ed25519VerificationKey2020.from(vm);
88
+ const jwk = publicKey.toJwk();
89
+ jwk.alg = 'EdDSA';
90
+ return importJWK(jwk);
91
+ };
92
+
93
+ // FIXME: indicate supported signatures
94
+ // const allowedAlgorithms = [];
95
+
96
+ // use `jose` lib (for now) to verify JWT and return `payload`;
97
+ // pass optional supported algorithms as allow list ... note
98
+ // that `jose` *always* prohibits the `none` algorithm
99
+ let verifyResult;
100
+ try {
101
+ // `jwtVerify` checks claims: `aud`, `exp`, `nbf`
102
+ const {payload, protectedHeader} = await jwtVerify(jwt, resolveKey, {
103
+ //algorithms: allowedAlgorithms,
104
+ audience,
105
+ clockTolerance: maxClockSkew
106
+ });
107
+ verifyResult = {payload, protectedHeader};
108
+ } catch(e) {
109
+ const details = {
110
+ httpStatusCode: 403,
111
+ public: true,
112
+ code: e.code,
113
+ reason: e.message
114
+ };
115
+ if(e.claim) {
116
+ details.claim = e.claim;
117
+ }
118
+ throw new BedrockError('DID proof JWT validation failed.', {
119
+ name: 'NotAllowedError',
120
+ details
121
+ });
122
+ }
123
+
124
+ // check `iss` claim
125
+ if(!(verifyResult?.payload?.iss === issuer)) {
126
+ throw new BedrockError('DID proof JWT validation failed.', {
127
+ name: 'NotAllowedError',
128
+ details: {
129
+ httpStatusCode: 403,
130
+ public: true,
131
+ code: 'ERR_JWT_CLAIM_VALIDATION_FAILED',
132
+ reason: 'unexpected "iss" claim value.',
133
+ claim: 'iss'
134
+ }
135
+ });
136
+ }
137
+
138
+ // check `nonce` claim
139
+ if(!(verifyResult?.payload?.nonce === exchange.id)) {
140
+ throw new BedrockError('DID proof JWT validation failed.', {
141
+ name: 'NotAllowedError',
142
+ details: {
143
+ httpStatusCode: 403,
144
+ public: true,
145
+ code: 'ERR_JWT_CLAIM_VALIDATION_FAILED',
146
+ reason: 'unexpected "nonce" claim value.',
147
+ claim: 'nonce'
148
+ }
149
+ });
150
+ }
151
+
152
+ return {verified: true, did: issuer, verifyResult};
153
+ }