@openeudi/openid4vp 0.2.1 → 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 +102 -34
- package/dist/index.cjs +721 -228
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +87 -27
- package/dist/index.d.ts +87 -27
- package/dist/index.js +709 -229
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
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,12 +66,619 @@ 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/
|
|
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"
|
|
85
|
+
};
|
|
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
|
+
);
|
|
103
|
+
}
|
|
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
|
+
);
|
|
113
|
+
}
|
|
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] }));
|
|
117
|
+
}
|
|
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;
|
|
126
|
+
}
|
|
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
|
+
);
|
|
150
|
+
}
|
|
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
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
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
|
+
);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
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
|
+
);
|
|
176
|
+
}
|
|
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
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
function isHaipQuery(query) {
|
|
187
|
+
try {
|
|
188
|
+
validateHaipQuery(query);
|
|
189
|
+
return true;
|
|
190
|
+
} catch {
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
var decoder = new cborX.Encoder({ mapsAsObjects: false, useRecords: false });
|
|
195
|
+
var HEADER_ALG = 1;
|
|
196
|
+
var HEADER_X5CHAIN = 33;
|
|
197
|
+
var COSE_ALG_TO_NAME = {
|
|
198
|
+
[-7]: "ES256",
|
|
199
|
+
[-35]: "ES384",
|
|
200
|
+
[-36]: "ES512"
|
|
201
|
+
};
|
|
202
|
+
function decodeCoseSign1(bytes) {
|
|
203
|
+
let outer = decoder.decode(bytes);
|
|
204
|
+
if (outer instanceof cborX.Tag && outer.tag === 18) {
|
|
205
|
+
const tagValue = outer.value;
|
|
206
|
+
outer = tagValue instanceof Uint8Array ? decoder.decode(tagValue) : tagValue;
|
|
207
|
+
}
|
|
208
|
+
if (!Array.isArray(outer) || outer.length !== 4) {
|
|
209
|
+
throw new MalformedCredentialError("COSE_Sign1 must be a CBOR array of length 4");
|
|
210
|
+
}
|
|
211
|
+
const [protectedHeaderBytes, unprotectedHeader, payloadRaw, signature] = outer;
|
|
212
|
+
if (!(protectedHeaderBytes instanceof Uint8Array)) {
|
|
213
|
+
throw new MalformedCredentialError("COSE_Sign1 protected header must be a bstr");
|
|
214
|
+
}
|
|
215
|
+
if (!(unprotectedHeader instanceof Map)) {
|
|
216
|
+
throw new MalformedCredentialError("COSE_Sign1 unprotected header must be a Map");
|
|
217
|
+
}
|
|
218
|
+
if (!(signature instanceof Uint8Array)) {
|
|
219
|
+
throw new MalformedCredentialError("COSE_Sign1 signature must be a bstr");
|
|
220
|
+
}
|
|
221
|
+
let protectedHeader;
|
|
222
|
+
try {
|
|
223
|
+
const decoded = decoder.decode(protectedHeaderBytes);
|
|
224
|
+
if (!(decoded instanceof Map)) {
|
|
225
|
+
throw new Error("protected header not a Map");
|
|
226
|
+
}
|
|
227
|
+
protectedHeader = decoded;
|
|
228
|
+
} catch {
|
|
229
|
+
throw new MalformedCredentialError("COSE_Sign1 protected header is not decodable CBOR");
|
|
230
|
+
}
|
|
231
|
+
const algLabel = protectedHeader.get(HEADER_ALG);
|
|
232
|
+
const alg = typeof algLabel === "number" ? COSE_ALG_TO_NAME[algLabel] : void 0;
|
|
233
|
+
if (!alg) {
|
|
234
|
+
throw new MalformedCredentialError(`COSE_Sign1 uses unknown alg label: ${String(algLabel)}`);
|
|
235
|
+
}
|
|
236
|
+
let payload;
|
|
237
|
+
if (payloadRaw instanceof Uint8Array) {
|
|
238
|
+
payload = payloadRaw;
|
|
239
|
+
} else if (payloadRaw instanceof cborX.Tag && payloadRaw.tag === 24 && payloadRaw.value instanceof Uint8Array) {
|
|
240
|
+
payload = payloadRaw.value;
|
|
241
|
+
} else {
|
|
242
|
+
throw new MalformedCredentialError("COSE_Sign1 payload must be a byte string");
|
|
243
|
+
}
|
|
244
|
+
const x5raw = unprotectedHeader.get(HEADER_X5CHAIN);
|
|
245
|
+
let x5chain;
|
|
246
|
+
if (x5raw === void 0) {
|
|
247
|
+
throw new MalformedCredentialError("COSE_Sign1 missing x5chain in unprotected header (label 33)");
|
|
248
|
+
}
|
|
249
|
+
if (x5raw instanceof Uint8Array) {
|
|
250
|
+
x5chain = [x5raw];
|
|
251
|
+
} else if (Array.isArray(x5raw) && x5raw.every((v) => v instanceof Uint8Array)) {
|
|
252
|
+
x5chain = x5raw;
|
|
253
|
+
} else {
|
|
254
|
+
throw new MalformedCredentialError("COSE_Sign1 x5chain must be a bstr or array of bstr");
|
|
255
|
+
}
|
|
256
|
+
return {
|
|
257
|
+
protectedHeaderBytes,
|
|
258
|
+
protectedHeader,
|
|
259
|
+
unprotectedHeader,
|
|
260
|
+
payload,
|
|
261
|
+
signature,
|
|
262
|
+
x5chain,
|
|
263
|
+
alg
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
var ALG_TO_HASH = {
|
|
267
|
+
ES256: "SHA-256",
|
|
268
|
+
ES384: "SHA-384",
|
|
269
|
+
ES512: "SHA-512"
|
|
270
|
+
};
|
|
271
|
+
async function verifyCoseSign1(coseSign1, publicKey, allowedAlgorithms) {
|
|
272
|
+
if (!allowedAlgorithms.includes(coseSign1.alg)) {
|
|
273
|
+
throw new MalformedCredentialError(`COSE_Sign1 algorithm ${coseSign1.alg} not in allowlist`);
|
|
274
|
+
}
|
|
275
|
+
const hashAlg = ALG_TO_HASH[coseSign1.alg];
|
|
276
|
+
if (!hashAlg) {
|
|
277
|
+
throw new MalformedCredentialError(`no hash mapping for alg ${coseSign1.alg}`);
|
|
278
|
+
}
|
|
279
|
+
const sigStructure = ["Signature1", coseSign1.protectedHeaderBytes, new Uint8Array(0), coseSign1.payload];
|
|
280
|
+
const sigInput = decoder.encode(sigStructure);
|
|
281
|
+
const ok = await crypto.subtle.verify(
|
|
282
|
+
{ name: "ECDSA", hash: hashAlg },
|
|
283
|
+
publicKey,
|
|
284
|
+
new Uint8Array(coseSign1.signature),
|
|
285
|
+
sigInput
|
|
286
|
+
);
|
|
287
|
+
if (!ok) {
|
|
288
|
+
throw new MalformedCredentialError("COSE_Sign1 signature verification failed");
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
var decoder2 = new cborX.Encoder({ mapsAsObjects: false, useRecords: false });
|
|
292
|
+
var ALLOWED_HASH_ALGS = /* @__PURE__ */ new Set(["SHA-256", "SHA-384", "SHA-512"]);
|
|
293
|
+
async function computeItemDigest(itemBytes, digestAlgorithm) {
|
|
294
|
+
if (!ALLOWED_HASH_ALGS.has(digestAlgorithm)) {
|
|
295
|
+
throw new MalformedCredentialError(`Unknown digest algorithm: ${digestAlgorithm}`);
|
|
296
|
+
}
|
|
297
|
+
const buf = await crypto.subtle.digest(digestAlgorithm, new Uint8Array(itemBytes));
|
|
298
|
+
return new Uint8Array(buf);
|
|
299
|
+
}
|
|
300
|
+
function bytesEqual(a, b) {
|
|
301
|
+
if (a.length !== b.length) return false;
|
|
302
|
+
for (let i = 0; i < a.length; i++) {
|
|
303
|
+
if (a[i] !== b[i]) return false;
|
|
304
|
+
}
|
|
305
|
+
return true;
|
|
306
|
+
}
|
|
307
|
+
function unwrapItemMap(itemBytes) {
|
|
308
|
+
let decoded;
|
|
309
|
+
try {
|
|
310
|
+
decoded = decoder2.decode(itemBytes);
|
|
311
|
+
} catch {
|
|
312
|
+
throw new MalformedCredentialError("IssuerSignedItem is not decodable CBOR");
|
|
313
|
+
}
|
|
314
|
+
let inner;
|
|
315
|
+
if (decoded instanceof cborX.Tag && decoded.tag === 24 && decoded.value instanceof Uint8Array) {
|
|
316
|
+
inner = decoder2.decode(decoded.value);
|
|
317
|
+
} else if (decoded !== null && typeof decoded === "object" && !(decoded instanceof cborX.Tag) && !(decoded instanceof Map) && !(decoded instanceof Uint8Array) && "value" in decoded && decoded.value instanceof Uint8Array) {
|
|
318
|
+
inner = decoder2.decode(decoded.value);
|
|
319
|
+
} else if (decoded instanceof Map) {
|
|
320
|
+
inner = decoded;
|
|
321
|
+
} else {
|
|
322
|
+
throw new MalformedCredentialError("IssuerSignedItem must be a tag-24 wrapped CBOR Map");
|
|
323
|
+
}
|
|
324
|
+
if (!(inner instanceof Map)) {
|
|
325
|
+
throw new MalformedCredentialError("IssuerSignedItem must decode to a Map");
|
|
326
|
+
}
|
|
327
|
+
return inner;
|
|
328
|
+
}
|
|
329
|
+
async function verifyAllDigests(nameSpaces, mso) {
|
|
330
|
+
for (const [ns, items] of nameSpaces) {
|
|
331
|
+
const expected = mso.valueDigests.get(ns);
|
|
332
|
+
if (!expected) {
|
|
333
|
+
throw new MalformedCredentialError(`namespace ${ns} has no valueDigests entry in MSO`);
|
|
334
|
+
}
|
|
335
|
+
for (const itemBytes of items) {
|
|
336
|
+
const hash = await computeItemDigest(itemBytes, mso.digestAlgorithm);
|
|
337
|
+
const item = unwrapItemMap(itemBytes);
|
|
338
|
+
const digestID = item.get("digestID");
|
|
339
|
+
if (typeof digestID !== "number") {
|
|
340
|
+
throw new MalformedCredentialError("IssuerSignedItem missing digestID");
|
|
341
|
+
}
|
|
342
|
+
const expectedDigest = expected.get(digestID);
|
|
343
|
+
if (!expectedDigest) {
|
|
344
|
+
throw new MalformedCredentialError(`digestID ${digestID} missing from MSO valueDigests[${ns}]`);
|
|
345
|
+
}
|
|
346
|
+
if (!bytesEqual(hash, expectedDigest)) {
|
|
347
|
+
throw new MalformedCredentialError(`digest mismatch for ns=${ns} digestID=${digestID}`);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
var decoder3 = new cborX.Encoder({ mapsAsObjects: false, useRecords: false });
|
|
353
|
+
function decodeDateLike(value) {
|
|
354
|
+
if (value instanceof Date) return value;
|
|
355
|
+
if (value instanceof cborX.Tag) {
|
|
356
|
+
if (value.tag === 0 && typeof value.value === "string") {
|
|
357
|
+
return new Date(value.value);
|
|
358
|
+
}
|
|
359
|
+
if (value.tag === 1 && typeof value.value === "number") {
|
|
360
|
+
return new Date(value.value * 1e3);
|
|
361
|
+
}
|
|
362
|
+
if (value.tag === 1004 && typeof value.value === "string") {
|
|
363
|
+
return new Date(value.value);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
if (typeof value === "string") {
|
|
367
|
+
return new Date(value);
|
|
368
|
+
}
|
|
369
|
+
throw new MalformedCredentialError(`unsupported date encoding in MSO: ${typeof value}`);
|
|
370
|
+
}
|
|
371
|
+
function decodeMso(payload) {
|
|
372
|
+
let raw;
|
|
373
|
+
try {
|
|
374
|
+
raw = decoder3.decode(payload);
|
|
375
|
+
if (raw instanceof cborX.Tag && raw.tag === 24 && raw.value instanceof Uint8Array) {
|
|
376
|
+
raw = decoder3.decode(raw.value);
|
|
377
|
+
} else if (raw !== null && typeof raw === "object" && !(raw instanceof cborX.Tag) && !(raw instanceof Map) && !(raw instanceof Uint8Array) && "value" in raw && raw.value instanceof Uint8Array) {
|
|
378
|
+
raw = decoder3.decode(raw.value);
|
|
379
|
+
}
|
|
380
|
+
} catch (err) {
|
|
381
|
+
if (err instanceof MalformedCredentialError) throw err;
|
|
382
|
+
throw new MalformedCredentialError("MSO payload is not decodable CBOR");
|
|
383
|
+
}
|
|
384
|
+
if (!(raw instanceof Map)) {
|
|
385
|
+
throw new MalformedCredentialError("MSO must be a CBOR Map");
|
|
386
|
+
}
|
|
387
|
+
const m = raw;
|
|
388
|
+
const version = m.get("version");
|
|
389
|
+
if (typeof version !== "string") {
|
|
390
|
+
throw new MalformedCredentialError("MSO.version must be a string");
|
|
391
|
+
}
|
|
392
|
+
const digestAlgorithm = m.get("digestAlgorithm");
|
|
393
|
+
if (typeof digestAlgorithm !== "string") {
|
|
394
|
+
throw new MalformedCredentialError("MSO.digestAlgorithm must be a string");
|
|
395
|
+
}
|
|
396
|
+
const docType = m.get("docType");
|
|
397
|
+
if (typeof docType !== "string") {
|
|
398
|
+
throw new MalformedCredentialError("MSO.docType must be a string");
|
|
399
|
+
}
|
|
400
|
+
const vdRaw = m.get("valueDigests");
|
|
401
|
+
if (!(vdRaw instanceof Map)) {
|
|
402
|
+
throw new MalformedCredentialError("MSO.valueDigests must be a Map");
|
|
403
|
+
}
|
|
404
|
+
const valueDigests = /* @__PURE__ */ new Map();
|
|
405
|
+
for (const [ns, inner] of vdRaw.entries()) {
|
|
406
|
+
if (typeof ns !== "string" || !(inner instanceof Map)) {
|
|
407
|
+
throw new MalformedCredentialError("MSO.valueDigests entry malformed");
|
|
408
|
+
}
|
|
409
|
+
const perNs = /* @__PURE__ */ new Map();
|
|
410
|
+
for (const [id, digest] of inner.entries()) {
|
|
411
|
+
if (typeof id !== "number" || !(digest instanceof Uint8Array)) {
|
|
412
|
+
throw new MalformedCredentialError("MSO digest entry must map number\u2192bstr");
|
|
413
|
+
}
|
|
414
|
+
perNs.set(id, digest);
|
|
415
|
+
}
|
|
416
|
+
valueDigests.set(ns, perNs);
|
|
417
|
+
}
|
|
418
|
+
const viRaw = m.get("validityInfo");
|
|
419
|
+
if (!(viRaw instanceof Map)) {
|
|
420
|
+
throw new MalformedCredentialError("MSO.validityInfo must be a Map");
|
|
421
|
+
}
|
|
422
|
+
const vi = viRaw;
|
|
423
|
+
const validityInfo = {
|
|
424
|
+
signed: decodeDateLike(vi.get("signed")),
|
|
425
|
+
validFrom: decodeDateLike(vi.get("validFrom")),
|
|
426
|
+
validUntil: decodeDateLike(vi.get("validUntil"))
|
|
427
|
+
};
|
|
428
|
+
if (vi.has("expectedUpdate")) {
|
|
429
|
+
validityInfo.expectedUpdate = decodeDateLike(vi.get("expectedUpdate"));
|
|
430
|
+
}
|
|
431
|
+
return { version, digestAlgorithm, valueDigests, docType, validityInfo };
|
|
432
|
+
}
|
|
433
|
+
function validateMsoValidity(mso, now) {
|
|
434
|
+
const { signed, validFrom, validUntil } = mso.validityInfo;
|
|
435
|
+
if (signed.getTime() > now.getTime()) {
|
|
436
|
+
throw new MalformedCredentialError(`MSO signed=${signed.toISOString()} is in the future`);
|
|
437
|
+
}
|
|
438
|
+
if (validFrom.getTime() > now.getTime()) {
|
|
439
|
+
throw new MalformedCredentialError(`MSO validFrom=${validFrom.toISOString()} is in the future`);
|
|
440
|
+
}
|
|
441
|
+
if (validFrom.getTime() > validUntil.getTime()) {
|
|
442
|
+
throw new MalformedCredentialError("MSO validFrom is after validUntil");
|
|
443
|
+
}
|
|
444
|
+
if (signed.getTime() < validFrom.getTime() || signed.getTime() > validUntil.getTime()) {
|
|
445
|
+
throw new MalformedCredentialError("MSO signed is outside the validity window");
|
|
446
|
+
}
|
|
447
|
+
if (validUntil.getTime() < now.getTime()) {
|
|
448
|
+
throw new ExpiredCredentialError(`MSO validUntil=${validUntil.toISOString()} is in the past`);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
function validateMsoDocType(mso, expectedDocType) {
|
|
452
|
+
if (mso.docType !== expectedDocType) {
|
|
453
|
+
throw new MalformedCredentialError(`MSO docType=${mso.docType} does not match expected ${expectedDocType}`);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// src/parsers/mdoc.parser.ts
|
|
458
|
+
var decoder4 = new cborX.Encoder({ mapsAsObjects: false, useRecords: false });
|
|
459
|
+
var encoder = new cborX.Encoder({ mapsAsObjects: false, useRecords: false });
|
|
48
460
|
var DEFAULT_ALLOWED_ALGORITHMS = ["ES256", "ES384", "ES512"];
|
|
461
|
+
function bytesEqual2(a, b) {
|
|
462
|
+
if (a.length !== b.length) return false;
|
|
463
|
+
for (let i = 0; i < a.length; i++) {
|
|
464
|
+
if (a[i] !== b[i]) return false;
|
|
465
|
+
}
|
|
466
|
+
return true;
|
|
467
|
+
}
|
|
468
|
+
function derToPem(der) {
|
|
469
|
+
let binary = "";
|
|
470
|
+
for (const b of der) binary += String.fromCharCode(b);
|
|
471
|
+
const b64 = globalThis.btoa(binary);
|
|
472
|
+
const lines = b64.match(/.{1,64}/g) || [];
|
|
473
|
+
return `-----BEGIN CERTIFICATE-----
|
|
474
|
+
${lines.join("\n")}
|
|
475
|
+
-----END CERTIFICATE-----`;
|
|
476
|
+
}
|
|
477
|
+
function extractCountryHintFromCert(_certDer) {
|
|
478
|
+
return "";
|
|
479
|
+
}
|
|
480
|
+
function extractRawItemBytes(item) {
|
|
481
|
+
if (item instanceof cborX.Tag && item.tag === 24 && item.value instanceof Uint8Array) {
|
|
482
|
+
return item.value;
|
|
483
|
+
}
|
|
484
|
+
if (item instanceof Uint8Array) {
|
|
485
|
+
return item;
|
|
486
|
+
}
|
|
487
|
+
if (item !== null && typeof item === "object" && !(item instanceof cborX.Tag) && !(item instanceof Map) && "value" in item && item.value instanceof Uint8Array) {
|
|
488
|
+
return item.value;
|
|
489
|
+
}
|
|
490
|
+
throw new MalformedCredentialError("IssuerSignedItem must be a tag-24 byte string");
|
|
491
|
+
}
|
|
492
|
+
function mapRawToClaims(nameSpaces) {
|
|
493
|
+
const flat = {};
|
|
494
|
+
const namespaced = {};
|
|
495
|
+
for (const [ns, items] of nameSpaces.entries()) {
|
|
496
|
+
const nsBucket = namespaced[ns] ?? {};
|
|
497
|
+
for (const itemBytes of items) {
|
|
498
|
+
let decoded;
|
|
499
|
+
try {
|
|
500
|
+
decoded = decoder4.decode(itemBytes);
|
|
501
|
+
} catch {
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
let inner;
|
|
505
|
+
if (decoded instanceof cborX.Tag && decoded.tag === 24 && decoded.value instanceof Uint8Array) {
|
|
506
|
+
inner = decoder4.decode(decoded.value);
|
|
507
|
+
} else if (decoded !== null && typeof decoded === "object" && !(decoded instanceof cborX.Tag) && !(decoded instanceof Map) && !(decoded instanceof Uint8Array) && "value" in decoded && decoded.value instanceof Uint8Array) {
|
|
508
|
+
inner = decoder4.decode(decoded.value);
|
|
509
|
+
} else if (decoded instanceof Map) {
|
|
510
|
+
inner = decoded;
|
|
511
|
+
} else {
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
if (inner instanceof Map) {
|
|
515
|
+
const id = inner.get("elementIdentifier");
|
|
516
|
+
const val = inner.get("elementValue");
|
|
517
|
+
if (typeof id === "string") {
|
|
518
|
+
flat[id] = val;
|
|
519
|
+
nsBucket[id] = val;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
namespaced[ns] = nsBucket;
|
|
524
|
+
}
|
|
525
|
+
return { flat, namespaced };
|
|
526
|
+
}
|
|
527
|
+
var MdocParser = class {
|
|
528
|
+
format = "mdoc";
|
|
529
|
+
/**
|
|
530
|
+
* Returns true when the token is binary data (Uint8Array or Buffer),
|
|
531
|
+
* indicating a CBOR-encoded mDOC DeviceResponse.
|
|
532
|
+
* Buffer extends Uint8Array, so `instanceof Uint8Array` catches both.
|
|
533
|
+
*/
|
|
534
|
+
canParse(vpToken) {
|
|
535
|
+
return vpToken instanceof Uint8Array;
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Parses and validates an mDOC DeviceResponse token.
|
|
539
|
+
*
|
|
540
|
+
* @throws {MalformedCredentialError} when the CBOR structure cannot be decoded
|
|
541
|
+
* or does not match the expected DeviceResponse format
|
|
542
|
+
*/
|
|
543
|
+
async parse(vpToken, options) {
|
|
544
|
+
if (!(vpToken instanceof Uint8Array)) {
|
|
545
|
+
throw new MalformedCredentialError("mDOC vpToken must be a Uint8Array");
|
|
546
|
+
}
|
|
547
|
+
const allowedAlgorithms = options.allowedAlgorithms ?? DEFAULT_ALLOWED_ALGORITHMS;
|
|
548
|
+
let deviceResponse;
|
|
549
|
+
try {
|
|
550
|
+
const decoded = decoder4.decode(vpToken);
|
|
551
|
+
if (!(decoded instanceof Map)) throw new Error();
|
|
552
|
+
deviceResponse = decoded;
|
|
553
|
+
} catch {
|
|
554
|
+
throw new MalformedCredentialError("vpToken is not a CBOR DeviceResponse Map");
|
|
555
|
+
}
|
|
556
|
+
const docs = deviceResponse.get("documents");
|
|
557
|
+
if (!Array.isArray(docs) || docs.length === 0) {
|
|
558
|
+
throw new MalformedCredentialError("DeviceResponse has no documents");
|
|
559
|
+
}
|
|
560
|
+
const document = docs[0];
|
|
561
|
+
if (!(document instanceof Map)) {
|
|
562
|
+
throw new MalformedCredentialError("document must be a Map");
|
|
563
|
+
}
|
|
564
|
+
const issuerSigned = document.get("issuerSigned");
|
|
565
|
+
if (!(issuerSigned instanceof Map)) {
|
|
566
|
+
throw new MalformedCredentialError("document.issuerSigned must be a Map");
|
|
567
|
+
}
|
|
568
|
+
const issuerAuth = issuerSigned.get("issuerAuth");
|
|
569
|
+
if (!Array.isArray(issuerAuth)) {
|
|
570
|
+
throw new MalformedCredentialError("issuerAuth must be a COSE_Sign1 array");
|
|
571
|
+
}
|
|
572
|
+
const issuerAuthBytes = encoder.encode(issuerAuth);
|
|
573
|
+
const cose = decodeCoseSign1(issuerAuthBytes);
|
|
574
|
+
if (!allowedAlgorithms.includes(cose.alg)) {
|
|
575
|
+
return {
|
|
576
|
+
valid: false,
|
|
577
|
+
format: this.format,
|
|
578
|
+
claims: {},
|
|
579
|
+
issuer: { certificate: new Uint8Array(), country: "" },
|
|
580
|
+
error: `Unsupported algorithm: ${cose.alg}`
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
const issuerCertBytes = cose.x5chain[0];
|
|
584
|
+
if (!issuerCertBytes) {
|
|
585
|
+
throw new MalformedCredentialError("Missing issuer certificate in x5chain");
|
|
586
|
+
}
|
|
587
|
+
if (options.trustedCertificates.length === 0) {
|
|
588
|
+
if (options.skipTrustCheck !== true) {
|
|
589
|
+
throw new MalformedCredentialError(
|
|
590
|
+
"trustedCertificates must not be empty unless skipTrustCheck is true"
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
} else {
|
|
594
|
+
const trusted = options.trustedCertificates.some((t) => bytesEqual2(t, issuerCertBytes));
|
|
595
|
+
if (!trusted) {
|
|
596
|
+
return {
|
|
597
|
+
valid: false,
|
|
598
|
+
format: this.format,
|
|
599
|
+
claims: {},
|
|
600
|
+
issuer: { certificate: issuerCertBytes, country: "" },
|
|
601
|
+
error: "Issuer certificate is not trusted"
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
const publicKey = await jose.importX509(derToPem(issuerCertBytes), cose.alg);
|
|
606
|
+
try {
|
|
607
|
+
await verifyCoseSign1(cose, publicKey, allowedAlgorithms);
|
|
608
|
+
} catch (err) {
|
|
609
|
+
if (err instanceof MalformedCredentialError) {
|
|
610
|
+
return {
|
|
611
|
+
valid: false,
|
|
612
|
+
format: this.format,
|
|
613
|
+
claims: {},
|
|
614
|
+
issuer: { certificate: issuerCertBytes, country: "" },
|
|
615
|
+
error: err.message
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
throw err;
|
|
619
|
+
}
|
|
620
|
+
const mso = decodeMso(cose.payload);
|
|
621
|
+
try {
|
|
622
|
+
validateMsoValidity(mso, /* @__PURE__ */ new Date());
|
|
623
|
+
} catch (err) {
|
|
624
|
+
if (err instanceof ExpiredCredentialError) {
|
|
625
|
+
return {
|
|
626
|
+
valid: false,
|
|
627
|
+
format: this.format,
|
|
628
|
+
claims: {},
|
|
629
|
+
issuer: { certificate: issuerCertBytes, country: "" },
|
|
630
|
+
error: "Credential expired"
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
throw err;
|
|
634
|
+
}
|
|
635
|
+
if (options.expectedDocType !== void 0) {
|
|
636
|
+
try {
|
|
637
|
+
validateMsoDocType(mso, options.expectedDocType);
|
|
638
|
+
} catch (err) {
|
|
639
|
+
if (err instanceof MalformedCredentialError) {
|
|
640
|
+
return {
|
|
641
|
+
valid: false,
|
|
642
|
+
format: this.format,
|
|
643
|
+
claims: {},
|
|
644
|
+
issuer: { certificate: issuerCertBytes, country: "" },
|
|
645
|
+
error: err.message
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
throw err;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
const nsRaw = issuerSigned.get("nameSpaces");
|
|
652
|
+
if (!(nsRaw instanceof Map)) {
|
|
653
|
+
throw new MalformedCredentialError("issuerSigned.nameSpaces must be a Map");
|
|
654
|
+
}
|
|
655
|
+
const nameSpaces = /* @__PURE__ */ new Map();
|
|
656
|
+
for (const [ns, items] of nsRaw.entries()) {
|
|
657
|
+
if (typeof ns !== "string" || !Array.isArray(items)) {
|
|
658
|
+
throw new MalformedCredentialError("nameSpaces entry malformed");
|
|
659
|
+
}
|
|
660
|
+
nameSpaces.set(ns, items.map(extractRawItemBytes));
|
|
661
|
+
}
|
|
662
|
+
await verifyAllDigests(nameSpaces, mso);
|
|
663
|
+
const { flat: claims, namespaced: namespacedClaims } = mapRawToClaims(nameSpaces);
|
|
664
|
+
const issuer = {
|
|
665
|
+
certificate: issuerCertBytes,
|
|
666
|
+
country: extractCountryHintFromCert()
|
|
667
|
+
};
|
|
668
|
+
return {
|
|
669
|
+
valid: true,
|
|
670
|
+
format: this.format,
|
|
671
|
+
claims,
|
|
672
|
+
issuer,
|
|
673
|
+
docType: mso.docType,
|
|
674
|
+
namespacedClaims
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
};
|
|
678
|
+
var DEFAULT_ALLOWED_ALGORITHMS2 = ["ES256", "ES384", "ES512"];
|
|
49
679
|
var sha256Hasher = async (data, _alg) => {
|
|
50
|
-
const
|
|
51
|
-
const input = typeof data === "string" ?
|
|
680
|
+
const encoder2 = new TextEncoder();
|
|
681
|
+
const input = typeof data === "string" ? encoder2.encode(data) : data;
|
|
52
682
|
const hashBuffer = await crypto.subtle.digest("SHA-256", input);
|
|
53
683
|
return new Uint8Array(hashBuffer);
|
|
54
684
|
};
|
|
@@ -70,14 +700,14 @@ function base64ToBytes(base64) {
|
|
|
70
700
|
}
|
|
71
701
|
return bytes;
|
|
72
702
|
}
|
|
73
|
-
function
|
|
703
|
+
function bytesEqual3(a, b) {
|
|
74
704
|
if (a.length !== b.length) return false;
|
|
75
705
|
for (let i = 0; i < a.length; i++) {
|
|
76
706
|
if (a[i] !== b[i]) return false;
|
|
77
707
|
}
|
|
78
708
|
return true;
|
|
79
709
|
}
|
|
80
|
-
function
|
|
710
|
+
function derToPem2(base64Der) {
|
|
81
711
|
const lines = base64Der.match(/.{1,64}/g) || [];
|
|
82
712
|
return `-----BEGIN CERTIFICATE-----
|
|
83
713
|
${lines.join("\n")}
|
|
@@ -134,7 +764,7 @@ var SdJwtParser = class {
|
|
|
134
764
|
if (typeof vpToken !== "string") {
|
|
135
765
|
throw new MalformedCredentialError("VP token must be a string");
|
|
136
766
|
}
|
|
137
|
-
const allowedAlgorithms = options.allowedAlgorithms ??
|
|
767
|
+
const allowedAlgorithms = options.allowedAlgorithms ?? DEFAULT_ALLOWED_ALGORITHMS2;
|
|
138
768
|
const invalidResult = (error) => ({
|
|
139
769
|
valid: false,
|
|
140
770
|
format: this.format,
|
|
@@ -165,7 +795,7 @@ var SdJwtParser = class {
|
|
|
165
795
|
let issuerKey;
|
|
166
796
|
let issuerCertBytes;
|
|
167
797
|
try {
|
|
168
|
-
issuerKey = await jose.importX509(
|
|
798
|
+
issuerKey = await jose.importX509(derToPem2(x5cArray[0]), alg);
|
|
169
799
|
issuerCertBytes = base64ToBytes(x5cArray[0]);
|
|
170
800
|
} catch (err) {
|
|
171
801
|
if (err instanceof MalformedCredentialError) throw err;
|
|
@@ -190,7 +820,7 @@ var SdJwtParser = class {
|
|
|
190
820
|
);
|
|
191
821
|
}
|
|
192
822
|
} else {
|
|
193
|
-
const isTrusted = options.trustedCertificates.some((trusted) =>
|
|
823
|
+
const isTrusted = options.trustedCertificates.some((trusted) => bytesEqual3(trusted, issuerCertBytes));
|
|
194
824
|
if (!isTrusted) {
|
|
195
825
|
return invalidResult("Issuer certificate is not trusted");
|
|
196
826
|
}
|
|
@@ -257,226 +887,16 @@ var SdJwtParser = class {
|
|
|
257
887
|
} catch {
|
|
258
888
|
throw new MalformedCredentialError("Failed to decode SD-JWT disclosures");
|
|
259
889
|
}
|
|
890
|
+
const vct = typeof payload["vct"] === "string" ? payload["vct"] : void 0;
|
|
260
891
|
return {
|
|
261
892
|
valid: true,
|
|
262
893
|
format: this.format,
|
|
263
894
|
claims: mapToCredentialClaims(resolvedClaims),
|
|
264
|
-
issuer: issuerInfo
|
|
265
|
-
|
|
266
|
-
}
|
|
267
|
-
};
|
|
268
|
-
function isBinaryData(value) {
|
|
269
|
-
return value instanceof Uint8Array;
|
|
270
|
-
}
|
|
271
|
-
function bytesEqual2(a, b) {
|
|
272
|
-
if (a.length !== b.length) return false;
|
|
273
|
-
for (let i = 0; i < a.length; i++) {
|
|
274
|
-
if (a[i] !== b[i]) return false;
|
|
275
|
-
}
|
|
276
|
-
return true;
|
|
277
|
-
}
|
|
278
|
-
function extractCertificateFromIssuerAuth(issuerAuth) {
|
|
279
|
-
const unprotected = issuerAuth[1];
|
|
280
|
-
if (unprotected && typeof unprotected === "object") {
|
|
281
|
-
const certFromUnprotected = extractX5Chain(unprotected);
|
|
282
|
-
if (certFromUnprotected) return certFromUnprotected;
|
|
283
|
-
}
|
|
284
|
-
const protectedHeader = issuerAuth[0];
|
|
285
|
-
if (isBinaryData(protectedHeader)) {
|
|
286
|
-
try {
|
|
287
|
-
const decoded = cborX.decode(protectedHeader);
|
|
288
|
-
if (decoded && typeof decoded === "object") {
|
|
289
|
-
const certFromProtected = extractX5Chain(decoded);
|
|
290
|
-
if (certFromProtected) return certFromProtected;
|
|
291
|
-
}
|
|
292
|
-
} catch {
|
|
293
|
-
}
|
|
294
|
-
return new Uint8Array(protectedHeader);
|
|
295
|
-
}
|
|
296
|
-
return new Uint8Array();
|
|
297
|
-
}
|
|
298
|
-
function extractX5Chain(header) {
|
|
299
|
-
if (header instanceof Map) {
|
|
300
|
-
const cert = header.get(33);
|
|
301
|
-
if (isBinaryData(cert)) {
|
|
302
|
-
return new Uint8Array(cert);
|
|
303
|
-
}
|
|
304
|
-
} else if (typeof header === "object" && header !== null) {
|
|
305
|
-
const rec = header;
|
|
306
|
-
const cert = rec[33] ?? rec["x5chain"] ?? rec["33"];
|
|
307
|
-
if (isBinaryData(cert)) {
|
|
308
|
-
return new Uint8Array(cert);
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
return null;
|
|
312
|
-
}
|
|
313
|
-
function mapToCredentialClaims2(items) {
|
|
314
|
-
const claims = {};
|
|
315
|
-
for (const item of items) {
|
|
316
|
-
const key = item.elementIdentifier;
|
|
317
|
-
const value = item.elementValue;
|
|
318
|
-
switch (key) {
|
|
319
|
-
case "age_over_18":
|
|
320
|
-
if (typeof value === "boolean") claims.age_over_18 = value;
|
|
321
|
-
break;
|
|
322
|
-
case "age_over_21":
|
|
323
|
-
if (typeof value === "boolean") claims.age_over_21 = value;
|
|
324
|
-
break;
|
|
325
|
-
case "resident_country":
|
|
326
|
-
if (typeof value === "string") claims.resident_country = value;
|
|
327
|
-
break;
|
|
328
|
-
case "nationality":
|
|
329
|
-
if (typeof value === "string") claims.nationality = value;
|
|
330
|
-
break;
|
|
331
|
-
case "family_name_birth":
|
|
332
|
-
if (typeof value === "string") claims.family_name_birth = value;
|
|
333
|
-
break;
|
|
334
|
-
default:
|
|
335
|
-
claims[key] = value;
|
|
336
|
-
break;
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
return claims;
|
|
340
|
-
}
|
|
341
|
-
function extractValidityPeriod(issuerAuth) {
|
|
342
|
-
const payload = issuerAuth[2];
|
|
343
|
-
if (!payload) return {};
|
|
344
|
-
try {
|
|
345
|
-
const decoded = isBinaryData(payload) ? cborX.decode(payload) : payload;
|
|
346
|
-
if (decoded && typeof decoded === "object") {
|
|
347
|
-
const obj = decoded;
|
|
348
|
-
const validityInfo = obj["validityInfo"];
|
|
349
|
-
if (validityInfo) {
|
|
350
|
-
return {
|
|
351
|
-
validFrom: validityInfo["validFrom"] instanceof Date ? validityInfo["validFrom"] : typeof validityInfo["validFrom"] === "string" ? new Date(validityInfo["validFrom"]) : void 0,
|
|
352
|
-
validUntil: validityInfo["validUntil"] instanceof Date ? validityInfo["validUntil"] : typeof validityInfo["validUntil"] === "string" ? new Date(validityInfo["validUntil"]) : void 0
|
|
353
|
-
};
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
} catch {
|
|
357
|
-
}
|
|
358
|
-
return {};
|
|
359
|
-
}
|
|
360
|
-
var MdocParser = class {
|
|
361
|
-
format = "mdoc";
|
|
362
|
-
/**
|
|
363
|
-
* Returns true when the token is binary data (Uint8Array or Buffer),
|
|
364
|
-
* indicating a CBOR-encoded mDOC DeviceResponse.
|
|
365
|
-
*/
|
|
366
|
-
canParse(vpToken) {
|
|
367
|
-
return isBinaryData(vpToken);
|
|
368
|
-
}
|
|
369
|
-
/**
|
|
370
|
-
* Parses and validates an mDOC DeviceResponse token.
|
|
371
|
-
*
|
|
372
|
-
* @throws {MalformedCredentialError} when the CBOR structure cannot be decoded
|
|
373
|
-
* or does not match the expected DeviceResponse format
|
|
374
|
-
*/
|
|
375
|
-
async parse(vpToken, options) {
|
|
376
|
-
if (!isBinaryData(vpToken)) {
|
|
377
|
-
throw new MalformedCredentialError("VP token must be a Uint8Array or Buffer");
|
|
378
|
-
}
|
|
379
|
-
const invalidResult = (error) => ({
|
|
380
|
-
valid: false,
|
|
381
|
-
format: this.format,
|
|
382
|
-
claims: {},
|
|
383
|
-
issuer: { certificate: new Uint8Array(), country: "" },
|
|
384
|
-
error
|
|
385
|
-
});
|
|
386
|
-
let deviceResponse;
|
|
387
|
-
try {
|
|
388
|
-
deviceResponse = cborX.decode(vpToken);
|
|
389
|
-
} catch {
|
|
390
|
-
throw new MalformedCredentialError("Failed to decode CBOR DeviceResponse");
|
|
391
|
-
}
|
|
392
|
-
if (!deviceResponse || typeof deviceResponse !== "object" || !Array.isArray(deviceResponse.documents) || deviceResponse.documents.length === 0) {
|
|
393
|
-
throw new MalformedCredentialError("Invalid DeviceResponse structure: missing or empty documents array");
|
|
394
|
-
}
|
|
395
|
-
const document = deviceResponse.documents[0];
|
|
396
|
-
if (!document.issuerSigned || !document.issuerSigned.nameSpaces || !document.issuerSigned.issuerAuth) {
|
|
397
|
-
throw new MalformedCredentialError("Invalid mDOC document: missing issuerSigned data");
|
|
398
|
-
}
|
|
399
|
-
const issuerAuth = document.issuerSigned.issuerAuth;
|
|
400
|
-
if (!Array.isArray(issuerAuth) || issuerAuth.length < 4) {
|
|
401
|
-
throw new MalformedCredentialError("Invalid issuerAuth: expected COSE_Sign1 array with 4 elements");
|
|
402
|
-
}
|
|
403
|
-
const issuerCertBytes = extractCertificateFromIssuerAuth(issuerAuth);
|
|
404
|
-
const issuerInfo = {
|
|
405
|
-
certificate: issuerCertBytes,
|
|
406
|
-
country: ""
|
|
407
|
-
};
|
|
408
|
-
if (options.trustedCertificates.length === 0) {
|
|
409
|
-
if (options.skipTrustCheck !== true) {
|
|
410
|
-
throw new MalformedCredentialError(
|
|
411
|
-
"trustedCertificates must not be empty unless skipTrustCheck is true"
|
|
412
|
-
);
|
|
413
|
-
}
|
|
414
|
-
} else {
|
|
415
|
-
const isTrusted = options.trustedCertificates.some((trusted) => bytesEqual2(trusted, issuerCertBytes));
|
|
416
|
-
if (!isTrusted) {
|
|
417
|
-
return invalidResult("Issuer certificate is not trusted");
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
const { validUntil } = extractValidityPeriod(issuerAuth);
|
|
421
|
-
if (validUntil) {
|
|
422
|
-
const now = /* @__PURE__ */ new Date();
|
|
423
|
-
if (validUntil < now) {
|
|
424
|
-
return invalidResult("Credential has expired");
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
const pidNamespace = "eu.europa.ec.eudi.pid.1";
|
|
428
|
-
const nameSpaces = document.issuerSigned.nameSpaces;
|
|
429
|
-
const items = nameSpaces[pidNamespace] ?? nameSpaces[document.docType] ?? Object.values(nameSpaces)[0] ?? [];
|
|
430
|
-
const claims = mapToCredentialClaims2(items);
|
|
431
|
-
if (claims.resident_country && typeof claims.resident_country === "string") {
|
|
432
|
-
issuerInfo.country = claims.resident_country;
|
|
433
|
-
}
|
|
434
|
-
return {
|
|
435
|
-
valid: true,
|
|
436
|
-
format: this.format,
|
|
437
|
-
claims,
|
|
438
|
-
issuer: issuerInfo
|
|
895
|
+
issuer: issuerInfo,
|
|
896
|
+
...vct !== void 0 ? { vct } : {}
|
|
439
897
|
};
|
|
440
898
|
}
|
|
441
899
|
};
|
|
442
|
-
function createAuthorizationRequest(input) {
|
|
443
|
-
const state = input.state ?? uuid.v4();
|
|
444
|
-
const presentationDefinition = {
|
|
445
|
-
id: uuid.v4(),
|
|
446
|
-
input_descriptors: input.requestedAttributes.map((attr, index) => ({
|
|
447
|
-
id: `descriptor_${index}`,
|
|
448
|
-
format: Object.fromEntries(
|
|
449
|
-
input.acceptedFormats.map((fmt) => {
|
|
450
|
-
if (fmt === "sd-jwt-vc") return ["vc+sd-jwt", { "sd-jwt_alg_values": ["ES256"] }];
|
|
451
|
-
return ["mso_mdoc", { alg: ["ES256"] }];
|
|
452
|
-
})
|
|
453
|
-
),
|
|
454
|
-
constraints: {
|
|
455
|
-
fields: [
|
|
456
|
-
{
|
|
457
|
-
path: [`$.${attr}`, `$['${attr}']`],
|
|
458
|
-
filter: { type: attr.startsWith("age_over") ? "boolean" : "string" }
|
|
459
|
-
}
|
|
460
|
-
]
|
|
461
|
-
}
|
|
462
|
-
}))
|
|
463
|
-
};
|
|
464
|
-
const params = new URLSearchParams({
|
|
465
|
-
response_type: "vp_token",
|
|
466
|
-
response_mode: "direct_post",
|
|
467
|
-
response_uri: input.responseUri,
|
|
468
|
-
client_id: input.clientId,
|
|
469
|
-
nonce: input.nonce,
|
|
470
|
-
state,
|
|
471
|
-
presentation_definition: JSON.stringify(presentationDefinition)
|
|
472
|
-
});
|
|
473
|
-
return {
|
|
474
|
-
uri: `openid4vp://authorize?${params.toString()}`,
|
|
475
|
-
presentationDefinition,
|
|
476
|
-
nonce: input.nonce,
|
|
477
|
-
state
|
|
478
|
-
};
|
|
479
|
-
}
|
|
480
900
|
|
|
481
901
|
// src/presentation.ts
|
|
482
902
|
var parsers = [new SdJwtParser(), new MdocParser()];
|
|
@@ -494,11 +914,80 @@ async function parsePresentation(vpToken, options) {
|
|
|
494
914
|
error: "Unsupported credential format"
|
|
495
915
|
};
|
|
496
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";
|
|
497
979
|
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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
|
+
});
|
|
501
988
|
exports.ExpiredCredentialError = ExpiredCredentialError;
|
|
989
|
+
exports.HAIP_DOCTYPE_NAMESPACES = HAIP_DOCTYPE_NAMESPACES;
|
|
990
|
+
exports.HaipValidationError = HaipValidationError;
|
|
502
991
|
exports.InvalidSignatureError = InvalidSignatureError;
|
|
503
992
|
exports.MalformedCredentialError = MalformedCredentialError;
|
|
504
993
|
exports.MdocParser = MdocParser;
|
|
@@ -506,7 +995,11 @@ exports.NonceValidationError = NonceValidationError;
|
|
|
506
995
|
exports.SdJwtParser = SdJwtParser;
|
|
507
996
|
exports.UnsupportedFormatError = UnsupportedFormatError;
|
|
508
997
|
exports.VERSION = VERSION;
|
|
998
|
+
exports.buildHaipQuery = buildHaipQuery;
|
|
509
999
|
exports.createAuthorizationRequest = createAuthorizationRequest;
|
|
1000
|
+
exports.isHaipQuery = isHaipQuery;
|
|
510
1001
|
exports.parsePresentation = parsePresentation;
|
|
1002
|
+
exports.validateHaipQuery = validateHaipQuery;
|
|
1003
|
+
exports.verifyPresentation = verifyPresentation;
|
|
511
1004
|
//# sourceMappingURL=index.cjs.map
|
|
512
1005
|
//# sourceMappingURL=index.cjs.map
|