@openeudi/openid4vp 0.3.0 → 0.4.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/README.md +98 -33
- package/dist/index.cjs +458 -264
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +73 -21
- package/dist/index.d.ts +73 -21
- package/dist/index.js +446 -265
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/dist/index.cjs
CHANGED
|
@@ -1,11 +1,34 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
var decode = require('@sd-jwt/decode');
|
|
4
|
-
var jose = require('jose');
|
|
5
|
-
var cborX = require('cbor-x');
|
|
6
3
|
var uuid = require('uuid');
|
|
4
|
+
var cborX = require('cbor-x');
|
|
5
|
+
var jose = require('jose');
|
|
6
|
+
var decode = require('@sd-jwt/decode');
|
|
7
|
+
var dcql = require('@openeudi/dcql');
|
|
7
8
|
|
|
8
|
-
// src/
|
|
9
|
+
// src/authorization.ts
|
|
10
|
+
function createAuthorizationRequest(input, query) {
|
|
11
|
+
if (!input.clientId) throw new TypeError("clientId is required");
|
|
12
|
+
if (!input.nonce) throw new TypeError("nonce is required");
|
|
13
|
+
if (!input.responseUri) throw new TypeError("responseUri is required");
|
|
14
|
+
const state = input.state ?? uuid.v4();
|
|
15
|
+
const responseMode = input.responseMode ?? "direct_post";
|
|
16
|
+
const params = new URLSearchParams({
|
|
17
|
+
response_type: "vp_token",
|
|
18
|
+
response_mode: responseMode,
|
|
19
|
+
response_uri: input.responseUri,
|
|
20
|
+
client_id: input.clientId,
|
|
21
|
+
nonce: input.nonce,
|
|
22
|
+
state,
|
|
23
|
+
dcql_query: JSON.stringify(query)
|
|
24
|
+
});
|
|
25
|
+
return {
|
|
26
|
+
uri: `openid4vp://authorize?${params.toString()}`,
|
|
27
|
+
dcqlQuery: query,
|
|
28
|
+
nonce: input.nonce,
|
|
29
|
+
state
|
|
30
|
+
};
|
|
31
|
+
}
|
|
9
32
|
|
|
10
33
|
// src/errors.ts
|
|
11
34
|
var InvalidSignatureError = class extends Error {
|
|
@@ -43,228 +66,131 @@ var NonceValidationError = class extends Error {
|
|
|
43
66
|
Object.setPrototypeOf(this, new.target.prototype);
|
|
44
67
|
}
|
|
45
68
|
};
|
|
69
|
+
var HaipValidationError = class extends Error {
|
|
70
|
+
code;
|
|
71
|
+
credentialId;
|
|
72
|
+
constructor(code, message, credentialId) {
|
|
73
|
+
super(message);
|
|
74
|
+
this.name = "HaipValidationError";
|
|
75
|
+
this.code = code;
|
|
76
|
+
this.credentialId = credentialId;
|
|
77
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
46
80
|
|
|
47
|
-
// src/
|
|
48
|
-
var
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const input = typeof data === "string" ? encoder2.encode(data) : data;
|
|
52
|
-
const hashBuffer = await crypto.subtle.digest("SHA-256", input);
|
|
53
|
-
return new Uint8Array(hashBuffer);
|
|
81
|
+
// src/haip.ts
|
|
82
|
+
var HAIP_DOCTYPE_NAMESPACES = {
|
|
83
|
+
"org.iso.18013.5.1.mDL": "org.iso.18013.5.1",
|
|
84
|
+
"eu.europa.ec.eudi.pid.1": "eu.europa.ec.eudi.pid.1"
|
|
54
85
|
};
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
73
|
-
function bytesEqual(a, b) {
|
|
74
|
-
if (a.length !== b.length) return false;
|
|
75
|
-
for (let i = 0; i < a.length; i++) {
|
|
76
|
-
if (a[i] !== b[i]) return false;
|
|
77
|
-
}
|
|
78
|
-
return true;
|
|
79
|
-
}
|
|
80
|
-
function derToPem(base64Der) {
|
|
81
|
-
const lines = base64Der.match(/.{1,64}/g) || [];
|
|
82
|
-
return `-----BEGIN CERTIFICATE-----
|
|
83
|
-
${lines.join("\n")}
|
|
84
|
-
-----END CERTIFICATE-----`;
|
|
85
|
-
}
|
|
86
|
-
function bytesToBase64url(bytes) {
|
|
87
|
-
let binary = "";
|
|
88
|
-
for (const byte of bytes) {
|
|
89
|
-
binary += String.fromCharCode(byte);
|
|
90
|
-
}
|
|
91
|
-
return globalThis.btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
92
|
-
}
|
|
93
|
-
function mapToCredentialClaims(raw) {
|
|
94
|
-
const claims = {};
|
|
95
|
-
if (typeof raw["age_over_18"] === "boolean") {
|
|
96
|
-
claims.age_over_18 = raw["age_over_18"];
|
|
97
|
-
}
|
|
98
|
-
if (typeof raw["age_over_21"] === "boolean") {
|
|
99
|
-
claims.age_over_21 = raw["age_over_21"];
|
|
100
|
-
}
|
|
101
|
-
if (typeof raw["resident_country"] === "string") {
|
|
102
|
-
claims.resident_country = raw["resident_country"];
|
|
103
|
-
}
|
|
104
|
-
if (typeof raw["nationality"] === "string") {
|
|
105
|
-
claims.nationality = raw["nationality"];
|
|
106
|
-
}
|
|
107
|
-
if (typeof raw["family_name_birth"] === "string") {
|
|
108
|
-
claims.family_name_birth = raw["family_name_birth"];
|
|
109
|
-
}
|
|
110
|
-
for (const [key, value] of Object.entries(raw)) {
|
|
111
|
-
if (!(key in claims) && !key.startsWith("_sd") && key !== "iss" && key !== "exp" && key !== "iat" && key !== "cnf" && key !== "vct") {
|
|
112
|
-
claims[key] = value;
|
|
86
|
+
function buildHaipQuery(input) {
|
|
87
|
+
if (input.claims.length === 0) {
|
|
88
|
+
throw new HaipValidationError(
|
|
89
|
+
"NO_CLAIMS",
|
|
90
|
+
`credential '${input.credentialId}' must declare at least one claim`,
|
|
91
|
+
input.credentialId
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
let meta;
|
|
95
|
+
let claims;
|
|
96
|
+
if (input.format === "dc+sd-jwt") {
|
|
97
|
+
if (!input.vctValues || input.vctValues.length === 0) {
|
|
98
|
+
throw new HaipValidationError(
|
|
99
|
+
"MISSING_SDJWT_META",
|
|
100
|
+
`credential '${input.credentialId}' with format 'dc+sd-jwt' must provide non-empty vctValues`,
|
|
101
|
+
input.credentialId
|
|
102
|
+
);
|
|
113
103
|
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
104
|
+
meta = { vct_values: input.vctValues };
|
|
105
|
+
claims = input.claims.map((c) => ({ path: [c] }));
|
|
106
|
+
} else {
|
|
107
|
+
if (!input.doctypeValue) {
|
|
108
|
+
throw new HaipValidationError(
|
|
109
|
+
"MISSING_MDOC_META",
|
|
110
|
+
`credential '${input.credentialId}' with format 'mso_mdoc' must provide doctypeValue`,
|
|
111
|
+
input.credentialId
|
|
112
|
+
);
|
|
123
113
|
}
|
|
124
|
-
|
|
114
|
+
meta = { doctype_value: input.doctypeValue };
|
|
115
|
+
const namespace = HAIP_DOCTYPE_NAMESPACES[input.doctypeValue] ?? input.doctypeValue;
|
|
116
|
+
claims = input.claims.map((c) => ({ path: [namespace, c] }));
|
|
125
117
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
118
|
+
const credential = {
|
|
119
|
+
id: input.credentialId,
|
|
120
|
+
format: input.format,
|
|
121
|
+
meta,
|
|
122
|
+
claims
|
|
123
|
+
};
|
|
124
|
+
if (input.trustedAuthorities && input.trustedAuthorities.length > 0) {
|
|
125
|
+
credential.trusted_authorities = input.trustedAuthorities;
|
|
132
126
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
}
|
|
157
|
-
const alg = header.alg;
|
|
158
|
-
if (typeof alg !== "string" || !allowedAlgorithms.includes(alg)) {
|
|
159
|
-
return invalidResult(`Unsupported algorithm: ${alg}`);
|
|
160
|
-
}
|
|
161
|
-
const x5cArray = header.x5c;
|
|
162
|
-
if (!Array.isArray(x5cArray) || x5cArray.length === 0 || typeof x5cArray[0] !== "string") {
|
|
163
|
-
throw new MalformedCredentialError("Missing or invalid x5c in JWT header");
|
|
164
|
-
}
|
|
165
|
-
let issuerKey;
|
|
166
|
-
let issuerCertBytes;
|
|
167
|
-
try {
|
|
168
|
-
issuerKey = await jose.importX509(derToPem(x5cArray[0]), alg);
|
|
169
|
-
issuerCertBytes = base64ToBytes(x5cArray[0]);
|
|
170
|
-
} catch (err) {
|
|
171
|
-
if (err instanceof MalformedCredentialError) throw err;
|
|
172
|
-
throw new MalformedCredentialError("Failed to import issuer certificate");
|
|
173
|
-
}
|
|
174
|
-
let payload;
|
|
175
|
-
try {
|
|
176
|
-
const result = await jose.jwtVerify(parts.jwt, issuerKey, { algorithms: allowedAlgorithms });
|
|
177
|
-
payload = result.payload;
|
|
178
|
-
} catch {
|
|
179
|
-
return invalidResult("Issuer JWT signature verification failed");
|
|
127
|
+
return { credentials: [credential] };
|
|
128
|
+
}
|
|
129
|
+
var HAIP_FORMATS = /* @__PURE__ */ new Set(["dc+sd-jwt", "mso_mdoc"]);
|
|
130
|
+
function validateHaipQuery(query) {
|
|
131
|
+
if (!query.credentials || query.credentials.length === 0) {
|
|
132
|
+
throw new HaipValidationError(
|
|
133
|
+
"EMPTY_QUERY",
|
|
134
|
+
"query.credentials must have at least one entry"
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
if (query.credential_sets !== void 0) {
|
|
138
|
+
throw new HaipValidationError(
|
|
139
|
+
"CREDENTIAL_SETS_DISALLOWED",
|
|
140
|
+
"credential_sets are not permitted in HAIP-minimal"
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
for (const credential of query.credentials) {
|
|
144
|
+
if (!HAIP_FORMATS.has(credential.format)) {
|
|
145
|
+
throw new HaipValidationError(
|
|
146
|
+
"UNSUPPORTED_FORMAT",
|
|
147
|
+
`credential '${credential.id}' uses unsupported format '${credential.format}' (allowed: dc+sd-jwt, mso_mdoc)`,
|
|
148
|
+
credential.id
|
|
149
|
+
);
|
|
180
150
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
throw new MalformedCredentialError(
|
|
189
|
-
"trustedCertificates must not be empty unless skipTrustCheck is true"
|
|
151
|
+
if (credential.format === "dc+sd-jwt") {
|
|
152
|
+
const vctValues = credential.meta?.vct_values;
|
|
153
|
+
if (!vctValues || vctValues.length === 0) {
|
|
154
|
+
throw new HaipValidationError(
|
|
155
|
+
"MISSING_SDJWT_META",
|
|
156
|
+
`credential '${credential.id}' with format 'dc+sd-jwt' must set meta.vct_values`,
|
|
157
|
+
credential.id
|
|
190
158
|
);
|
|
191
159
|
}
|
|
192
|
-
} else {
|
|
193
|
-
const isTrusted = options.trustedCertificates.some((trusted) => bytesEqual(trusted, issuerCertBytes));
|
|
194
|
-
if (!isTrusted) {
|
|
195
|
-
return invalidResult("Issuer certificate is not trusted");
|
|
196
|
-
}
|
|
197
160
|
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
const hashB64url = bytesToBase64url(hashBytes);
|
|
206
|
-
if (!sdArray.includes(hashB64url)) {
|
|
207
|
-
return invalidResult("Disclosure hash mismatch");
|
|
208
|
-
}
|
|
161
|
+
if (credential.format === "mso_mdoc") {
|
|
162
|
+
if (typeof credential.meta?.doctype_value !== "string") {
|
|
163
|
+
throw new HaipValidationError(
|
|
164
|
+
"MISSING_MDOC_META",
|
|
165
|
+
`credential '${credential.id}' with format 'mso_mdoc' must set meta.doctype_value`,
|
|
166
|
+
credential.id
|
|
167
|
+
);
|
|
209
168
|
}
|
|
210
169
|
}
|
|
211
|
-
if (
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
try {
|
|
218
|
-
const kbHeader = jose.decodeProtectedHeader(parts.kbJwt);
|
|
219
|
-
const kbAlg = kbHeader.alg;
|
|
220
|
-
if (typeof kbAlg !== "string" || !allowedAlgorithms.includes(kbAlg)) {
|
|
221
|
-
return invalidResult(`Unsupported KB-JWT algorithm: ${kbAlg}`);
|
|
222
|
-
}
|
|
223
|
-
holderKey = await jose.importJWK(cnf["jwk"], kbAlg);
|
|
224
|
-
} catch (err) {
|
|
225
|
-
if (err instanceof MalformedCredentialError) throw err;
|
|
226
|
-
throw new MalformedCredentialError("Failed to import holder key from cnf.jwk");
|
|
227
|
-
}
|
|
228
|
-
let kbPayload;
|
|
229
|
-
try {
|
|
230
|
-
const kbResult = await jose.jwtVerify(parts.kbJwt, holderKey, {
|
|
231
|
-
algorithms: allowedAlgorithms,
|
|
232
|
-
...options.audience ? { audience: options.audience } : {}
|
|
233
|
-
});
|
|
234
|
-
kbPayload = kbResult.payload;
|
|
235
|
-
} catch {
|
|
236
|
-
return invalidResult("Key binding JWT signature verification failed");
|
|
237
|
-
}
|
|
238
|
-
if (kbPayload["nonce"] !== options.nonce) {
|
|
239
|
-
return invalidResult("Key binding JWT nonce does not match");
|
|
240
|
-
}
|
|
241
|
-
const disclosurePart = parts.disclosures.length > 0 ? parts.disclosures.join("~") + "~" : "";
|
|
242
|
-
const sdJwtForHash = parts.jwt + "~" + disclosurePart;
|
|
243
|
-
const sdHashBytes = await sha256Hasher(sdJwtForHash);
|
|
244
|
-
const expectedSdHash = bytesToBase64url(sdHashBytes);
|
|
245
|
-
if (kbPayload["sd_hash"] !== expectedSdHash) {
|
|
246
|
-
return invalidResult("Key binding JWT sd_hash does not match");
|
|
247
|
-
}
|
|
170
|
+
if (!credential.claims || credential.claims.length === 0) {
|
|
171
|
+
throw new HaipValidationError(
|
|
172
|
+
"NO_CLAIMS",
|
|
173
|
+
`credential '${credential.id}' must declare at least one claim`,
|
|
174
|
+
credential.id
|
|
175
|
+
);
|
|
248
176
|
}
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
decoded.disclosures,
|
|
255
|
-
sha256Hasher
|
|
177
|
+
if (credential.claim_sets !== void 0) {
|
|
178
|
+
throw new HaipValidationError(
|
|
179
|
+
"CLAIM_SETS_DISALLOWED",
|
|
180
|
+
`credential '${credential.id}' uses claim_sets, not permitted in HAIP-minimal`,
|
|
181
|
+
credential.id
|
|
256
182
|
);
|
|
257
|
-
} catch {
|
|
258
|
-
throw new MalformedCredentialError("Failed to decode SD-JWT disclosures");
|
|
259
183
|
}
|
|
260
|
-
return {
|
|
261
|
-
valid: true,
|
|
262
|
-
format: this.format,
|
|
263
|
-
claims: mapToCredentialClaims(resolvedClaims),
|
|
264
|
-
issuer: issuerInfo
|
|
265
|
-
};
|
|
266
184
|
}
|
|
267
|
-
}
|
|
185
|
+
}
|
|
186
|
+
function isHaipQuery(query) {
|
|
187
|
+
try {
|
|
188
|
+
validateHaipQuery(query);
|
|
189
|
+
return true;
|
|
190
|
+
} catch {
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
268
194
|
var decoder = new cborX.Encoder({ mapsAsObjects: false, useRecords: false });
|
|
269
195
|
var HEADER_ALG = 1;
|
|
270
196
|
var HEADER_X5CHAIN = 33;
|
|
@@ -371,7 +297,7 @@ async function computeItemDigest(itemBytes, digestAlgorithm) {
|
|
|
371
297
|
const buf = await crypto.subtle.digest(digestAlgorithm, new Uint8Array(itemBytes));
|
|
372
298
|
return new Uint8Array(buf);
|
|
373
299
|
}
|
|
374
|
-
function
|
|
300
|
+
function bytesEqual(a, b) {
|
|
375
301
|
if (a.length !== b.length) return false;
|
|
376
302
|
for (let i = 0; i < a.length; i++) {
|
|
377
303
|
if (a[i] !== b[i]) return false;
|
|
@@ -417,7 +343,7 @@ async function verifyAllDigests(nameSpaces, mso) {
|
|
|
417
343
|
if (!expectedDigest) {
|
|
418
344
|
throw new MalformedCredentialError(`digestID ${digestID} missing from MSO valueDigests[${ns}]`);
|
|
419
345
|
}
|
|
420
|
-
if (!
|
|
346
|
+
if (!bytesEqual(hash, expectedDigest)) {
|
|
421
347
|
throw new MalformedCredentialError(`digest mismatch for ns=${ns} digestID=${digestID}`);
|
|
422
348
|
}
|
|
423
349
|
}
|
|
@@ -531,15 +457,15 @@ function validateMsoDocType(mso, expectedDocType) {
|
|
|
531
457
|
// src/parsers/mdoc.parser.ts
|
|
532
458
|
var decoder4 = new cborX.Encoder({ mapsAsObjects: false, useRecords: false });
|
|
533
459
|
var encoder = new cborX.Encoder({ mapsAsObjects: false, useRecords: false });
|
|
534
|
-
var
|
|
535
|
-
function
|
|
460
|
+
var DEFAULT_ALLOWED_ALGORITHMS = ["ES256", "ES384", "ES512"];
|
|
461
|
+
function bytesEqual2(a, b) {
|
|
536
462
|
if (a.length !== b.length) return false;
|
|
537
463
|
for (let i = 0; i < a.length; i++) {
|
|
538
464
|
if (a[i] !== b[i]) return false;
|
|
539
465
|
}
|
|
540
466
|
return true;
|
|
541
467
|
}
|
|
542
|
-
function
|
|
468
|
+
function derToPem(der) {
|
|
543
469
|
let binary = "";
|
|
544
470
|
for (const b of der) binary += String.fromCharCode(b);
|
|
545
471
|
const b64 = globalThis.btoa(binary);
|
|
@@ -564,8 +490,10 @@ function extractRawItemBytes(item) {
|
|
|
564
490
|
throw new MalformedCredentialError("IssuerSignedItem must be a tag-24 byte string");
|
|
565
491
|
}
|
|
566
492
|
function mapRawToClaims(nameSpaces) {
|
|
567
|
-
const
|
|
568
|
-
|
|
493
|
+
const flat = {};
|
|
494
|
+
const namespaced = {};
|
|
495
|
+
for (const [ns, items] of nameSpaces.entries()) {
|
|
496
|
+
const nsBucket = namespaced[ns] ?? {};
|
|
569
497
|
for (const itemBytes of items) {
|
|
570
498
|
let decoded;
|
|
571
499
|
try {
|
|
@@ -587,12 +515,14 @@ function mapRawToClaims(nameSpaces) {
|
|
|
587
515
|
const id = inner.get("elementIdentifier");
|
|
588
516
|
const val = inner.get("elementValue");
|
|
589
517
|
if (typeof id === "string") {
|
|
590
|
-
|
|
518
|
+
flat[id] = val;
|
|
519
|
+
nsBucket[id] = val;
|
|
591
520
|
}
|
|
592
521
|
}
|
|
593
522
|
}
|
|
523
|
+
namespaced[ns] = nsBucket;
|
|
594
524
|
}
|
|
595
|
-
return
|
|
525
|
+
return { flat, namespaced };
|
|
596
526
|
}
|
|
597
527
|
var MdocParser = class {
|
|
598
528
|
format = "mdoc";
|
|
@@ -614,7 +544,7 @@ var MdocParser = class {
|
|
|
614
544
|
if (!(vpToken instanceof Uint8Array)) {
|
|
615
545
|
throw new MalformedCredentialError("mDOC vpToken must be a Uint8Array");
|
|
616
546
|
}
|
|
617
|
-
const allowedAlgorithms = options.allowedAlgorithms ??
|
|
547
|
+
const allowedAlgorithms = options.allowedAlgorithms ?? DEFAULT_ALLOWED_ALGORITHMS;
|
|
618
548
|
let deviceResponse;
|
|
619
549
|
try {
|
|
620
550
|
const decoded = decoder4.decode(vpToken);
|
|
@@ -661,7 +591,7 @@ var MdocParser = class {
|
|
|
661
591
|
);
|
|
662
592
|
}
|
|
663
593
|
} else {
|
|
664
|
-
const trusted = options.trustedCertificates.some((t) =>
|
|
594
|
+
const trusted = options.trustedCertificates.some((t) => bytesEqual2(t, issuerCertBytes));
|
|
665
595
|
if (!trusted) {
|
|
666
596
|
return {
|
|
667
597
|
valid: false,
|
|
@@ -672,7 +602,7 @@ var MdocParser = class {
|
|
|
672
602
|
};
|
|
673
603
|
}
|
|
674
604
|
}
|
|
675
|
-
const publicKey = await jose.importX509(
|
|
605
|
+
const publicKey = await jose.importX509(derToPem(issuerCertBytes), cose.alg);
|
|
676
606
|
try {
|
|
677
607
|
await verifyCoseSign1(cose, publicKey, allowedAlgorithms);
|
|
678
608
|
} catch (err) {
|
|
@@ -730,52 +660,243 @@ var MdocParser = class {
|
|
|
730
660
|
nameSpaces.set(ns, items.map(extractRawItemBytes));
|
|
731
661
|
}
|
|
732
662
|
await verifyAllDigests(nameSpaces, mso);
|
|
733
|
-
const claims = mapRawToClaims(nameSpaces);
|
|
663
|
+
const { flat: claims, namespaced: namespacedClaims } = mapRawToClaims(nameSpaces);
|
|
734
664
|
const issuer = {
|
|
735
665
|
certificate: issuerCertBytes,
|
|
736
666
|
country: extractCountryHintFromCert()
|
|
737
667
|
};
|
|
738
|
-
return {
|
|
668
|
+
return {
|
|
669
|
+
valid: true,
|
|
670
|
+
format: this.format,
|
|
671
|
+
claims,
|
|
672
|
+
issuer,
|
|
673
|
+
docType: mso.docType,
|
|
674
|
+
namespacedClaims
|
|
675
|
+
};
|
|
739
676
|
}
|
|
740
677
|
};
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
const
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
678
|
+
var DEFAULT_ALLOWED_ALGORITHMS2 = ["ES256", "ES384", "ES512"];
|
|
679
|
+
var sha256Hasher = async (data, _alg) => {
|
|
680
|
+
const encoder2 = new TextEncoder();
|
|
681
|
+
const input = typeof data === "string" ? encoder2.encode(data) : data;
|
|
682
|
+
const hashBuffer = await crypto.subtle.digest("SHA-256", input);
|
|
683
|
+
return new Uint8Array(hashBuffer);
|
|
684
|
+
};
|
|
685
|
+
var BASE64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
686
|
+
function base64ToBytes(base64) {
|
|
687
|
+
const cleaned = base64.replace(/=+$/, "");
|
|
688
|
+
const byteLength = Math.floor(cleaned.length * 3 / 4);
|
|
689
|
+
const buffer = new ArrayBuffer(byteLength);
|
|
690
|
+
const bytes = new Uint8Array(buffer);
|
|
691
|
+
let byteIndex = 0;
|
|
692
|
+
for (let i = 0; i < cleaned.length; i += 4) {
|
|
693
|
+
const a = BASE64_CHARS.indexOf(cleaned[i]);
|
|
694
|
+
const b = i + 1 < cleaned.length ? BASE64_CHARS.indexOf(cleaned[i + 1]) : 0;
|
|
695
|
+
const c = i + 2 < cleaned.length ? BASE64_CHARS.indexOf(cleaned[i + 2]) : 0;
|
|
696
|
+
const d = i + 3 < cleaned.length ? BASE64_CHARS.indexOf(cleaned[i + 3]) : 0;
|
|
697
|
+
bytes[byteIndex++] = a << 2 | b >> 4;
|
|
698
|
+
if (byteIndex < byteLength) bytes[byteIndex++] = (b & 15) << 4 | c >> 2;
|
|
699
|
+
if (byteIndex < byteLength) bytes[byteIndex++] = (c & 3) << 6 | d;
|
|
700
|
+
}
|
|
701
|
+
return bytes;
|
|
702
|
+
}
|
|
703
|
+
function bytesEqual3(a, b) {
|
|
704
|
+
if (a.length !== b.length) return false;
|
|
705
|
+
for (let i = 0; i < a.length; i++) {
|
|
706
|
+
if (a[i] !== b[i]) return false;
|
|
707
|
+
}
|
|
708
|
+
return true;
|
|
709
|
+
}
|
|
710
|
+
function derToPem2(base64Der) {
|
|
711
|
+
const lines = base64Der.match(/.{1,64}/g) || [];
|
|
712
|
+
return `-----BEGIN CERTIFICATE-----
|
|
713
|
+
${lines.join("\n")}
|
|
714
|
+
-----END CERTIFICATE-----`;
|
|
715
|
+
}
|
|
716
|
+
function bytesToBase64url(bytes) {
|
|
717
|
+
let binary = "";
|
|
718
|
+
for (const byte of bytes) {
|
|
719
|
+
binary += String.fromCharCode(byte);
|
|
720
|
+
}
|
|
721
|
+
return globalThis.btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
778
722
|
}
|
|
723
|
+
function mapToCredentialClaims(raw) {
|
|
724
|
+
const claims = {};
|
|
725
|
+
if (typeof raw["age_over_18"] === "boolean") {
|
|
726
|
+
claims.age_over_18 = raw["age_over_18"];
|
|
727
|
+
}
|
|
728
|
+
if (typeof raw["age_over_21"] === "boolean") {
|
|
729
|
+
claims.age_over_21 = raw["age_over_21"];
|
|
730
|
+
}
|
|
731
|
+
if (typeof raw["resident_country"] === "string") {
|
|
732
|
+
claims.resident_country = raw["resident_country"];
|
|
733
|
+
}
|
|
734
|
+
if (typeof raw["nationality"] === "string") {
|
|
735
|
+
claims.nationality = raw["nationality"];
|
|
736
|
+
}
|
|
737
|
+
if (typeof raw["family_name_birth"] === "string") {
|
|
738
|
+
claims.family_name_birth = raw["family_name_birth"];
|
|
739
|
+
}
|
|
740
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
741
|
+
if (!(key in claims) && !key.startsWith("_sd") && key !== "iss" && key !== "exp" && key !== "iat" && key !== "cnf" && key !== "vct") {
|
|
742
|
+
claims[key] = value;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
return claims;
|
|
746
|
+
}
|
|
747
|
+
function extractCountryHint(issuer) {
|
|
748
|
+
try {
|
|
749
|
+
const url = new URL(issuer);
|
|
750
|
+
const tld = url.hostname.split(".").pop()?.toUpperCase();
|
|
751
|
+
if (tld && tld.length === 2) {
|
|
752
|
+
return tld;
|
|
753
|
+
}
|
|
754
|
+
} catch {
|
|
755
|
+
}
|
|
756
|
+
return "";
|
|
757
|
+
}
|
|
758
|
+
var SdJwtParser = class {
|
|
759
|
+
format = "sd-jwt-vc";
|
|
760
|
+
canParse(vpToken) {
|
|
761
|
+
return typeof vpToken === "string" && vpToken.includes("~");
|
|
762
|
+
}
|
|
763
|
+
async parse(vpToken, options) {
|
|
764
|
+
if (typeof vpToken !== "string") {
|
|
765
|
+
throw new MalformedCredentialError("VP token must be a string");
|
|
766
|
+
}
|
|
767
|
+
const allowedAlgorithms = options.allowedAlgorithms ?? DEFAULT_ALLOWED_ALGORITHMS2;
|
|
768
|
+
const invalidResult = (error) => ({
|
|
769
|
+
valid: false,
|
|
770
|
+
format: this.format,
|
|
771
|
+
claims: {},
|
|
772
|
+
issuer: { certificate: new Uint8Array(), country: "" },
|
|
773
|
+
error
|
|
774
|
+
});
|
|
775
|
+
let parts;
|
|
776
|
+
try {
|
|
777
|
+
parts = decode.splitSdJwt(vpToken);
|
|
778
|
+
} catch {
|
|
779
|
+
throw new MalformedCredentialError("Failed to split SD-JWT structure");
|
|
780
|
+
}
|
|
781
|
+
let header;
|
|
782
|
+
try {
|
|
783
|
+
header = jose.decodeProtectedHeader(parts.jwt);
|
|
784
|
+
} catch {
|
|
785
|
+
throw new MalformedCredentialError("Failed to decode SD-JWT issuer JWT header");
|
|
786
|
+
}
|
|
787
|
+
const alg = header.alg;
|
|
788
|
+
if (typeof alg !== "string" || !allowedAlgorithms.includes(alg)) {
|
|
789
|
+
return invalidResult(`Unsupported algorithm: ${alg}`);
|
|
790
|
+
}
|
|
791
|
+
const x5cArray = header.x5c;
|
|
792
|
+
if (!Array.isArray(x5cArray) || x5cArray.length === 0 || typeof x5cArray[0] !== "string") {
|
|
793
|
+
throw new MalformedCredentialError("Missing or invalid x5c in JWT header");
|
|
794
|
+
}
|
|
795
|
+
let issuerKey;
|
|
796
|
+
let issuerCertBytes;
|
|
797
|
+
try {
|
|
798
|
+
issuerKey = await jose.importX509(derToPem2(x5cArray[0]), alg);
|
|
799
|
+
issuerCertBytes = base64ToBytes(x5cArray[0]);
|
|
800
|
+
} catch (err) {
|
|
801
|
+
if (err instanceof MalformedCredentialError) throw err;
|
|
802
|
+
throw new MalformedCredentialError("Failed to import issuer certificate");
|
|
803
|
+
}
|
|
804
|
+
let payload;
|
|
805
|
+
try {
|
|
806
|
+
const result = await jose.jwtVerify(parts.jwt, issuerKey, { algorithms: allowedAlgorithms });
|
|
807
|
+
payload = result.payload;
|
|
808
|
+
} catch {
|
|
809
|
+
return invalidResult("Issuer JWT signature verification failed");
|
|
810
|
+
}
|
|
811
|
+
const issuerString = typeof payload["iss"] === "string" ? payload["iss"] : "";
|
|
812
|
+
const issuerInfo = {
|
|
813
|
+
certificate: issuerCertBytes,
|
|
814
|
+
country: extractCountryHint(issuerString)
|
|
815
|
+
};
|
|
816
|
+
if (options.trustedCertificates.length === 0) {
|
|
817
|
+
if (options.skipTrustCheck !== true) {
|
|
818
|
+
throw new MalformedCredentialError(
|
|
819
|
+
"trustedCertificates must not be empty unless skipTrustCheck is true"
|
|
820
|
+
);
|
|
821
|
+
}
|
|
822
|
+
} else {
|
|
823
|
+
const isTrusted = options.trustedCertificates.some((trusted) => bytesEqual3(trusted, issuerCertBytes));
|
|
824
|
+
if (!isTrusted) {
|
|
825
|
+
return invalidResult("Issuer certificate is not trusted");
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
const sdArray = payload["_sd"];
|
|
829
|
+
if (parts.disclosures.length > 0) {
|
|
830
|
+
if (!Array.isArray(sdArray) || sdArray.length === 0) {
|
|
831
|
+
return invalidResult("Disclosures present but no _sd array in issuer JWT");
|
|
832
|
+
}
|
|
833
|
+
for (const disclosure of parts.disclosures) {
|
|
834
|
+
const hashBytes = await sha256Hasher(disclosure);
|
|
835
|
+
const hashB64url = bytesToBase64url(hashBytes);
|
|
836
|
+
if (!sdArray.includes(hashB64url)) {
|
|
837
|
+
return invalidResult("Disclosure hash mismatch");
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
if (parts.kbJwt) {
|
|
842
|
+
const cnf = payload["cnf"];
|
|
843
|
+
if (!cnf || typeof cnf !== "object" || !cnf["jwk"]) {
|
|
844
|
+
throw new MalformedCredentialError("Missing cnf.jwk in issuer JWT for key binding");
|
|
845
|
+
}
|
|
846
|
+
let holderKey;
|
|
847
|
+
try {
|
|
848
|
+
const kbHeader = jose.decodeProtectedHeader(parts.kbJwt);
|
|
849
|
+
const kbAlg = kbHeader.alg;
|
|
850
|
+
if (typeof kbAlg !== "string" || !allowedAlgorithms.includes(kbAlg)) {
|
|
851
|
+
return invalidResult(`Unsupported KB-JWT algorithm: ${kbAlg}`);
|
|
852
|
+
}
|
|
853
|
+
holderKey = await jose.importJWK(cnf["jwk"], kbAlg);
|
|
854
|
+
} catch (err) {
|
|
855
|
+
if (err instanceof MalformedCredentialError) throw err;
|
|
856
|
+
throw new MalformedCredentialError("Failed to import holder key from cnf.jwk");
|
|
857
|
+
}
|
|
858
|
+
let kbPayload;
|
|
859
|
+
try {
|
|
860
|
+
const kbResult = await jose.jwtVerify(parts.kbJwt, holderKey, {
|
|
861
|
+
algorithms: allowedAlgorithms,
|
|
862
|
+
...options.audience ? { audience: options.audience } : {}
|
|
863
|
+
});
|
|
864
|
+
kbPayload = kbResult.payload;
|
|
865
|
+
} catch {
|
|
866
|
+
return invalidResult("Key binding JWT signature verification failed");
|
|
867
|
+
}
|
|
868
|
+
if (kbPayload["nonce"] !== options.nonce) {
|
|
869
|
+
return invalidResult("Key binding JWT nonce does not match");
|
|
870
|
+
}
|
|
871
|
+
const disclosurePart = parts.disclosures.length > 0 ? parts.disclosures.join("~") + "~" : "";
|
|
872
|
+
const sdJwtForHash = parts.jwt + "~" + disclosurePart;
|
|
873
|
+
const sdHashBytes = await sha256Hasher(sdJwtForHash);
|
|
874
|
+
const expectedSdHash = bytesToBase64url(sdHashBytes);
|
|
875
|
+
if (kbPayload["sd_hash"] !== expectedSdHash) {
|
|
876
|
+
return invalidResult("Key binding JWT sd_hash does not match");
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
let resolvedClaims;
|
|
880
|
+
try {
|
|
881
|
+
const decoded = await decode.decodeSdJwt(vpToken, sha256Hasher);
|
|
882
|
+
resolvedClaims = await decode.getClaims(
|
|
883
|
+
decoded.jwt.payload,
|
|
884
|
+
decoded.disclosures,
|
|
885
|
+
sha256Hasher
|
|
886
|
+
);
|
|
887
|
+
} catch {
|
|
888
|
+
throw new MalformedCredentialError("Failed to decode SD-JWT disclosures");
|
|
889
|
+
}
|
|
890
|
+
const vct = typeof payload["vct"] === "string" ? payload["vct"] : void 0;
|
|
891
|
+
return {
|
|
892
|
+
valid: true,
|
|
893
|
+
format: this.format,
|
|
894
|
+
claims: mapToCredentialClaims(resolvedClaims),
|
|
895
|
+
issuer: issuerInfo,
|
|
896
|
+
...vct !== void 0 ? { vct } : {}
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
};
|
|
779
900
|
|
|
780
901
|
// src/presentation.ts
|
|
781
902
|
var parsers = [new SdJwtParser(), new MdocParser()];
|
|
@@ -793,11 +914,80 @@ async function parsePresentation(vpToken, options) {
|
|
|
793
914
|
error: "Unsupported credential format"
|
|
794
915
|
};
|
|
795
916
|
}
|
|
917
|
+
var INTERNAL_TO_DCQL_FORMAT = {
|
|
918
|
+
"sd-jwt-vc": "dc+sd-jwt",
|
|
919
|
+
mdoc: "mso_mdoc"
|
|
920
|
+
};
|
|
921
|
+
function classifyMismatch(query, decoded, hasDetail) {
|
|
922
|
+
if (decoded.format !== query.format) return "format_mismatch";
|
|
923
|
+
if (query.format === "dc+sd-jwt") {
|
|
924
|
+
const allowed = query.meta?.vct_values;
|
|
925
|
+
if (allowed && allowed.length > 0 && !allowed.includes(decoded.vct ?? "")) {
|
|
926
|
+
return "vct_mismatch";
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
if (query.format === "mso_mdoc") {
|
|
930
|
+
const expected = query.meta?.doctype_value;
|
|
931
|
+
if (expected && expected !== decoded.doctype) return "doctype_mismatch";
|
|
932
|
+
}
|
|
933
|
+
if (query.trusted_authorities && query.trusted_authorities.length > 0) {
|
|
934
|
+
const decodedAuthIds = decoded.trusted_authority_ids ?? [];
|
|
935
|
+
const anyMatch = query.trusted_authorities.some(
|
|
936
|
+
(ta) => ta.values.some((v) => decodedAuthIds.includes(v))
|
|
937
|
+
);
|
|
938
|
+
if (!anyMatch) return "trusted_authority_mismatch";
|
|
939
|
+
}
|
|
940
|
+
if (hasDetail) return "missing_claims";
|
|
941
|
+
return "no_credential_found";
|
|
942
|
+
}
|
|
943
|
+
function refineUnmatched(query, decoded, match) {
|
|
944
|
+
if (match.unmatched.length === 0) return match;
|
|
945
|
+
const refined = match.unmatched.map((entry) => {
|
|
946
|
+
const credentialQuery = query.credentials.find((c) => c.id === entry.queryId);
|
|
947
|
+
if (!credentialQuery) return entry;
|
|
948
|
+
const specific = classifyMismatch(credentialQuery, decoded, entry.detail !== void 0);
|
|
949
|
+
return { ...entry, reason: specific };
|
|
950
|
+
});
|
|
951
|
+
return { ...match, unmatched: refined };
|
|
952
|
+
}
|
|
953
|
+
async function verifyPresentation(vpToken, query, options) {
|
|
954
|
+
const parsed = await parsePresentation(vpToken, options);
|
|
955
|
+
const dcqlFormat = INTERNAL_TO_DCQL_FORMAT[parsed.format] ?? parsed.format;
|
|
956
|
+
const decodedClaims = parsed.format === "mdoc" && parsed.namespacedClaims !== void 0 ? parsed.namespacedClaims : parsed.claims;
|
|
957
|
+
const decoded = {
|
|
958
|
+
id: query.credentials[0]?.id ?? "presented",
|
|
959
|
+
format: dcqlFormat,
|
|
960
|
+
claims: decodedClaims
|
|
961
|
+
};
|
|
962
|
+
if (parsed.format === "sd-jwt-vc" && typeof parsed.vct === "string") {
|
|
963
|
+
decoded.vct = parsed.vct;
|
|
964
|
+
}
|
|
965
|
+
if (parsed.format === "mdoc" && typeof parsed.docType === "string") {
|
|
966
|
+
decoded.doctype = parsed.docType;
|
|
967
|
+
}
|
|
968
|
+
const rawMatch = dcql.matchQuery(query, [decoded]);
|
|
969
|
+
const match = refineUnmatched(query, decoded, rawMatch);
|
|
970
|
+
const submission = match.satisfied ? dcql.buildSubmission(query, match) : null;
|
|
971
|
+
return {
|
|
972
|
+
parsed,
|
|
973
|
+
match,
|
|
974
|
+
submission,
|
|
975
|
+
valid: parsed.valid && match.satisfied
|
|
976
|
+
};
|
|
977
|
+
}
|
|
978
|
+
var VERSION = "0.4.0";
|
|
796
979
|
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
980
|
+
Object.defineProperty(exports, "DcqlMatchError", {
|
|
981
|
+
enumerable: true,
|
|
982
|
+
get: function () { return dcql.DcqlMatchError; }
|
|
983
|
+
});
|
|
984
|
+
Object.defineProperty(exports, "DcqlValidationError", {
|
|
985
|
+
enumerable: true,
|
|
986
|
+
get: function () { return dcql.DcqlValidationError; }
|
|
987
|
+
});
|
|
800
988
|
exports.ExpiredCredentialError = ExpiredCredentialError;
|
|
989
|
+
exports.HAIP_DOCTYPE_NAMESPACES = HAIP_DOCTYPE_NAMESPACES;
|
|
990
|
+
exports.HaipValidationError = HaipValidationError;
|
|
801
991
|
exports.InvalidSignatureError = InvalidSignatureError;
|
|
802
992
|
exports.MalformedCredentialError = MalformedCredentialError;
|
|
803
993
|
exports.MdocParser = MdocParser;
|
|
@@ -805,7 +995,11 @@ exports.NonceValidationError = NonceValidationError;
|
|
|
805
995
|
exports.SdJwtParser = SdJwtParser;
|
|
806
996
|
exports.UnsupportedFormatError = UnsupportedFormatError;
|
|
807
997
|
exports.VERSION = VERSION;
|
|
998
|
+
exports.buildHaipQuery = buildHaipQuery;
|
|
808
999
|
exports.createAuthorizationRequest = createAuthorizationRequest;
|
|
1000
|
+
exports.isHaipQuery = isHaipQuery;
|
|
809
1001
|
exports.parsePresentation = parsePresentation;
|
|
1002
|
+
exports.validateHaipQuery = validateHaipQuery;
|
|
1003
|
+
exports.verifyPresentation = verifyPresentation;
|
|
810
1004
|
//# sourceMappingURL=index.cjs.map
|
|
811
1005
|
//# sourceMappingURL=index.cjs.map
|