@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/LICENSE.md +115 -0
- package/README.md +1 -0
- package/lib/config.js +10 -0
- package/lib/exchanges.js +295 -0
- package/lib/helpers.js +63 -0
- package/lib/http.js +195 -0
- package/lib/index.js +118 -0
- package/lib/issue.js +59 -0
- package/lib/logger.js +6 -0
- package/lib/oidc4vci.js +345 -0
- package/lib/verify.js +153 -0
- package/package.json +66 -0
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
package/lib/oidc4vci.js
ADDED
|
@@ -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
|
+
}
|