@openeudi/openid4vp 0.2.0 → 0.3.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 +29 -0
- package/dist/index.cjs +430 -121
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +22 -7
- package/dist/index.d.ts +22 -7
- package/dist/index.js +431 -122
- package/dist/index.js.map +1 -1
- package/package.json +7 -18
package/README.md
CHANGED
|
@@ -124,6 +124,7 @@ class MyCustomParser implements ICredentialParser {
|
|
|
124
124
|
| --------------------- | -------------- | ------------------------------------ |
|
|
125
125
|
| `trustedCertificates` | `Uint8Array[]` | Issuer certificates to trust |
|
|
126
126
|
| `nonce` | `string` | Expected nonce for replay protection |
|
|
127
|
+
| `skipTrustCheck` | `boolean?` | Explicit opt-in to skip the trust check. When omitted or `false`, `trustedCertificates` must be non-empty — otherwise parsing throws `MalformedCredentialError`. Use `true` for demo/mock environments. |
|
|
127
128
|
|
|
128
129
|
### PresentationResult
|
|
129
130
|
|
|
@@ -157,6 +158,34 @@ try {
|
|
|
157
158
|
}
|
|
158
159
|
```
|
|
159
160
|
|
|
161
|
+
## Scope and limitations
|
|
162
|
+
|
|
163
|
+
This library implements the **verifier side** of OpenID4VP for SD-JWT VC and mDOC credentials.
|
|
164
|
+
|
|
165
|
+
**What is implemented (v0.3.x):**
|
|
166
|
+
|
|
167
|
+
- SD-JWT VC: full cryptographic verification (issuer JWT signature via x5c, disclosure hashes, key binding JWT signature + sd_hash, nonce check)
|
|
168
|
+
- mDOC / ISO 18013-5 *mso_mdoc* format: CBOR decoding and claim extraction
|
|
169
|
+
- mDOC / COSE_Sign1 cryptographic signature verification
|
|
170
|
+
- mDOC MobileSecurityObject validity enforcement (strict ISO 18013-5)
|
|
171
|
+
- mDOC IssuerSignedItem digest verification
|
|
172
|
+
- `expectedDocType` ParseOptions to lock the credential type
|
|
173
|
+
- Algorithm allowlist (ES256/384/512 — ECDSA only per EUDI policy)
|
|
174
|
+
- Authorization request builder
|
|
175
|
+
- Certificate trust check via byte-equality against a caller-supplied trusted set
|
|
176
|
+
|
|
177
|
+
**What is NOT yet implemented** (planned for follow-up releases — do not assume compliance in production until present):
|
|
178
|
+
|
|
179
|
+
- X.509 certificate chain building and validation beyond leaf-byte-equality
|
|
180
|
+
- EU List of Trusted Lists (LOTL) / ETSI TL resolution
|
|
181
|
+
- Certificate revocation (CRL, OCSP)
|
|
182
|
+
- DCQL query / credential matching — see [@openeudi/dcql](https://www.npmjs.com/package/@openeudi/dcql)
|
|
183
|
+
- OpenID4VP HAIP (High Assurance Interoperability Profile) constraint validation
|
|
184
|
+
- OpenID Foundation conformance test suite integration
|
|
185
|
+
- SIOPv2 (Self-Issued OpenID Provider) identity flows
|
|
186
|
+
|
|
187
|
+
EUDI Architecture Reference Framework (ARF) alignment: tracks OpenID4VP 1.0 final. HAIP and ARF 1.4+ profile compliance will be added before a stable 1.0.
|
|
188
|
+
|
|
160
189
|
## Related packages
|
|
161
190
|
|
|
162
191
|
- **[@openeudi/core](https://www.npmjs.com/package/@openeudi/core)** -- Framework-agnostic EUDI Wallet verification protocol engine with session management and QR code generation.
|
package/dist/index.cjs
CHANGED
|
@@ -47,8 +47,8 @@ var NonceValidationError = class extends Error {
|
|
|
47
47
|
// src/parsers/sd-jwt.parser.ts
|
|
48
48
|
var DEFAULT_ALLOWED_ALGORITHMS = ["ES256", "ES384", "ES512"];
|
|
49
49
|
var sha256Hasher = async (data, _alg) => {
|
|
50
|
-
const
|
|
51
|
-
const input = typeof data === "string" ?
|
|
50
|
+
const encoder2 = new TextEncoder();
|
|
51
|
+
const input = typeof data === "string" ? encoder2.encode(data) : data;
|
|
52
52
|
const hashBuffer = await crypto.subtle.digest("SHA-256", input);
|
|
53
53
|
return new Uint8Array(hashBuffer);
|
|
54
54
|
};
|
|
@@ -183,7 +183,13 @@ var SdJwtParser = class {
|
|
|
183
183
|
certificate: issuerCertBytes,
|
|
184
184
|
country: extractCountryHint(issuerString)
|
|
185
185
|
};
|
|
186
|
-
if (options.trustedCertificates.length
|
|
186
|
+
if (options.trustedCertificates.length === 0) {
|
|
187
|
+
if (options.skipTrustCheck !== true) {
|
|
188
|
+
throw new MalformedCredentialError(
|
|
189
|
+
"trustedCertificates must not be empty unless skipTrustCheck is true"
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
} else {
|
|
187
193
|
const isTrusted = options.trustedCertificates.some((trusted) => bytesEqual(trusted, issuerCertBytes));
|
|
188
194
|
if (!isTrusted) {
|
|
189
195
|
return invalidResult("Issuer certificate is not trusted");
|
|
@@ -259,8 +265,111 @@ var SdJwtParser = class {
|
|
|
259
265
|
};
|
|
260
266
|
}
|
|
261
267
|
};
|
|
262
|
-
|
|
263
|
-
|
|
268
|
+
var decoder = new cborX.Encoder({ mapsAsObjects: false, useRecords: false });
|
|
269
|
+
var HEADER_ALG = 1;
|
|
270
|
+
var HEADER_X5CHAIN = 33;
|
|
271
|
+
var COSE_ALG_TO_NAME = {
|
|
272
|
+
[-7]: "ES256",
|
|
273
|
+
[-35]: "ES384",
|
|
274
|
+
[-36]: "ES512"
|
|
275
|
+
};
|
|
276
|
+
function decodeCoseSign1(bytes) {
|
|
277
|
+
let outer = decoder.decode(bytes);
|
|
278
|
+
if (outer instanceof cborX.Tag && outer.tag === 18) {
|
|
279
|
+
const tagValue = outer.value;
|
|
280
|
+
outer = tagValue instanceof Uint8Array ? decoder.decode(tagValue) : tagValue;
|
|
281
|
+
}
|
|
282
|
+
if (!Array.isArray(outer) || outer.length !== 4) {
|
|
283
|
+
throw new MalformedCredentialError("COSE_Sign1 must be a CBOR array of length 4");
|
|
284
|
+
}
|
|
285
|
+
const [protectedHeaderBytes, unprotectedHeader, payloadRaw, signature] = outer;
|
|
286
|
+
if (!(protectedHeaderBytes instanceof Uint8Array)) {
|
|
287
|
+
throw new MalformedCredentialError("COSE_Sign1 protected header must be a bstr");
|
|
288
|
+
}
|
|
289
|
+
if (!(unprotectedHeader instanceof Map)) {
|
|
290
|
+
throw new MalformedCredentialError("COSE_Sign1 unprotected header must be a Map");
|
|
291
|
+
}
|
|
292
|
+
if (!(signature instanceof Uint8Array)) {
|
|
293
|
+
throw new MalformedCredentialError("COSE_Sign1 signature must be a bstr");
|
|
294
|
+
}
|
|
295
|
+
let protectedHeader;
|
|
296
|
+
try {
|
|
297
|
+
const decoded = decoder.decode(protectedHeaderBytes);
|
|
298
|
+
if (!(decoded instanceof Map)) {
|
|
299
|
+
throw new Error("protected header not a Map");
|
|
300
|
+
}
|
|
301
|
+
protectedHeader = decoded;
|
|
302
|
+
} catch {
|
|
303
|
+
throw new MalformedCredentialError("COSE_Sign1 protected header is not decodable CBOR");
|
|
304
|
+
}
|
|
305
|
+
const algLabel = protectedHeader.get(HEADER_ALG);
|
|
306
|
+
const alg = typeof algLabel === "number" ? COSE_ALG_TO_NAME[algLabel] : void 0;
|
|
307
|
+
if (!alg) {
|
|
308
|
+
throw new MalformedCredentialError(`COSE_Sign1 uses unknown alg label: ${String(algLabel)}`);
|
|
309
|
+
}
|
|
310
|
+
let payload;
|
|
311
|
+
if (payloadRaw instanceof Uint8Array) {
|
|
312
|
+
payload = payloadRaw;
|
|
313
|
+
} else if (payloadRaw instanceof cborX.Tag && payloadRaw.tag === 24 && payloadRaw.value instanceof Uint8Array) {
|
|
314
|
+
payload = payloadRaw.value;
|
|
315
|
+
} else {
|
|
316
|
+
throw new MalformedCredentialError("COSE_Sign1 payload must be a byte string");
|
|
317
|
+
}
|
|
318
|
+
const x5raw = unprotectedHeader.get(HEADER_X5CHAIN);
|
|
319
|
+
let x5chain;
|
|
320
|
+
if (x5raw === void 0) {
|
|
321
|
+
throw new MalformedCredentialError("COSE_Sign1 missing x5chain in unprotected header (label 33)");
|
|
322
|
+
}
|
|
323
|
+
if (x5raw instanceof Uint8Array) {
|
|
324
|
+
x5chain = [x5raw];
|
|
325
|
+
} else if (Array.isArray(x5raw) && x5raw.every((v) => v instanceof Uint8Array)) {
|
|
326
|
+
x5chain = x5raw;
|
|
327
|
+
} else {
|
|
328
|
+
throw new MalformedCredentialError("COSE_Sign1 x5chain must be a bstr or array of bstr");
|
|
329
|
+
}
|
|
330
|
+
return {
|
|
331
|
+
protectedHeaderBytes,
|
|
332
|
+
protectedHeader,
|
|
333
|
+
unprotectedHeader,
|
|
334
|
+
payload,
|
|
335
|
+
signature,
|
|
336
|
+
x5chain,
|
|
337
|
+
alg
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
var ALG_TO_HASH = {
|
|
341
|
+
ES256: "SHA-256",
|
|
342
|
+
ES384: "SHA-384",
|
|
343
|
+
ES512: "SHA-512"
|
|
344
|
+
};
|
|
345
|
+
async function verifyCoseSign1(coseSign1, publicKey, allowedAlgorithms) {
|
|
346
|
+
if (!allowedAlgorithms.includes(coseSign1.alg)) {
|
|
347
|
+
throw new MalformedCredentialError(`COSE_Sign1 algorithm ${coseSign1.alg} not in allowlist`);
|
|
348
|
+
}
|
|
349
|
+
const hashAlg = ALG_TO_HASH[coseSign1.alg];
|
|
350
|
+
if (!hashAlg) {
|
|
351
|
+
throw new MalformedCredentialError(`no hash mapping for alg ${coseSign1.alg}`);
|
|
352
|
+
}
|
|
353
|
+
const sigStructure = ["Signature1", coseSign1.protectedHeaderBytes, new Uint8Array(0), coseSign1.payload];
|
|
354
|
+
const sigInput = decoder.encode(sigStructure);
|
|
355
|
+
const ok = await crypto.subtle.verify(
|
|
356
|
+
{ name: "ECDSA", hash: hashAlg },
|
|
357
|
+
publicKey,
|
|
358
|
+
new Uint8Array(coseSign1.signature),
|
|
359
|
+
sigInput
|
|
360
|
+
);
|
|
361
|
+
if (!ok) {
|
|
362
|
+
throw new MalformedCredentialError("COSE_Sign1 signature verification failed");
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
var decoder2 = new cborX.Encoder({ mapsAsObjects: false, useRecords: false });
|
|
366
|
+
var ALLOWED_HASH_ALGS = /* @__PURE__ */ new Set(["SHA-256", "SHA-384", "SHA-512"]);
|
|
367
|
+
async function computeItemDigest(itemBytes, digestAlgorithm) {
|
|
368
|
+
if (!ALLOWED_HASH_ALGS.has(digestAlgorithm)) {
|
|
369
|
+
throw new MalformedCredentialError(`Unknown digest algorithm: ${digestAlgorithm}`);
|
|
370
|
+
}
|
|
371
|
+
const buf = await crypto.subtle.digest(digestAlgorithm, new Uint8Array(itemBytes));
|
|
372
|
+
return new Uint8Array(buf);
|
|
264
373
|
}
|
|
265
374
|
function bytesEqual2(a, b) {
|
|
266
375
|
if (a.length !== b.length) return false;
|
|
@@ -269,96 +378,231 @@ function bytesEqual2(a, b) {
|
|
|
269
378
|
}
|
|
270
379
|
return true;
|
|
271
380
|
}
|
|
272
|
-
function
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
381
|
+
function unwrapItemMap(itemBytes) {
|
|
382
|
+
let decoded;
|
|
383
|
+
try {
|
|
384
|
+
decoded = decoder2.decode(itemBytes);
|
|
385
|
+
} catch {
|
|
386
|
+
throw new MalformedCredentialError("IssuerSignedItem is not decodable CBOR");
|
|
387
|
+
}
|
|
388
|
+
let inner;
|
|
389
|
+
if (decoded instanceof cborX.Tag && decoded.tag === 24 && decoded.value instanceof Uint8Array) {
|
|
390
|
+
inner = decoder2.decode(decoded.value);
|
|
391
|
+
} else if (decoded !== null && typeof decoded === "object" && !(decoded instanceof cborX.Tag) && !(decoded instanceof Map) && !(decoded instanceof Uint8Array) && "value" in decoded && decoded.value instanceof Uint8Array) {
|
|
392
|
+
inner = decoder2.decode(decoded.value);
|
|
393
|
+
} else if (decoded instanceof Map) {
|
|
394
|
+
inner = decoded;
|
|
395
|
+
} else {
|
|
396
|
+
throw new MalformedCredentialError("IssuerSignedItem must be a tag-24 wrapped CBOR Map");
|
|
397
|
+
}
|
|
398
|
+
if (!(inner instanceof Map)) {
|
|
399
|
+
throw new MalformedCredentialError("IssuerSignedItem must decode to a Map");
|
|
400
|
+
}
|
|
401
|
+
return inner;
|
|
402
|
+
}
|
|
403
|
+
async function verifyAllDigests(nameSpaces, mso) {
|
|
404
|
+
for (const [ns, items] of nameSpaces) {
|
|
405
|
+
const expected = mso.valueDigests.get(ns);
|
|
406
|
+
if (!expected) {
|
|
407
|
+
throw new MalformedCredentialError(`namespace ${ns} has no valueDigests entry in MSO`);
|
|
408
|
+
}
|
|
409
|
+
for (const itemBytes of items) {
|
|
410
|
+
const hash = await computeItemDigest(itemBytes, mso.digestAlgorithm);
|
|
411
|
+
const item = unwrapItemMap(itemBytes);
|
|
412
|
+
const digestID = item.get("digestID");
|
|
413
|
+
if (typeof digestID !== "number") {
|
|
414
|
+
throw new MalformedCredentialError("IssuerSignedItem missing digestID");
|
|
415
|
+
}
|
|
416
|
+
const expectedDigest = expected.get(digestID);
|
|
417
|
+
if (!expectedDigest) {
|
|
418
|
+
throw new MalformedCredentialError(`digestID ${digestID} missing from MSO valueDigests[${ns}]`);
|
|
419
|
+
}
|
|
420
|
+
if (!bytesEqual2(hash, expectedDigest)) {
|
|
421
|
+
throw new MalformedCredentialError(`digest mismatch for ns=${ns} digestID=${digestID}`);
|
|
285
422
|
}
|
|
286
|
-
} catch {
|
|
287
423
|
}
|
|
288
|
-
return new Uint8Array(protectedHeader);
|
|
289
424
|
}
|
|
290
|
-
return new Uint8Array();
|
|
291
425
|
}
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
426
|
+
var decoder3 = new cborX.Encoder({ mapsAsObjects: false, useRecords: false });
|
|
427
|
+
function decodeDateLike(value) {
|
|
428
|
+
if (value instanceof Date) return value;
|
|
429
|
+
if (value instanceof cborX.Tag) {
|
|
430
|
+
if (value.tag === 0 && typeof value.value === "string") {
|
|
431
|
+
return new Date(value.value);
|
|
432
|
+
}
|
|
433
|
+
if (value.tag === 1 && typeof value.value === "number") {
|
|
434
|
+
return new Date(value.value * 1e3);
|
|
297
435
|
}
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
const cert = rec[33] ?? rec["x5chain"] ?? rec["33"];
|
|
301
|
-
if (isBinaryData(cert)) {
|
|
302
|
-
return new Uint8Array(cert);
|
|
436
|
+
if (value.tag === 1004 && typeof value.value === "string") {
|
|
437
|
+
return new Date(value.value);
|
|
303
438
|
}
|
|
304
439
|
}
|
|
305
|
-
|
|
440
|
+
if (typeof value === "string") {
|
|
441
|
+
return new Date(value);
|
|
442
|
+
}
|
|
443
|
+
throw new MalformedCredentialError(`unsupported date encoding in MSO: ${typeof value}`);
|
|
306
444
|
}
|
|
307
|
-
function
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
break;
|
|
316
|
-
case "age_over_21":
|
|
317
|
-
if (typeof value === "boolean") claims.age_over_21 = value;
|
|
318
|
-
break;
|
|
319
|
-
case "resident_country":
|
|
320
|
-
if (typeof value === "string") claims.resident_country = value;
|
|
321
|
-
break;
|
|
322
|
-
case "nationality":
|
|
323
|
-
if (typeof value === "string") claims.nationality = value;
|
|
324
|
-
break;
|
|
325
|
-
case "family_name_birth":
|
|
326
|
-
if (typeof value === "string") claims.family_name_birth = value;
|
|
327
|
-
break;
|
|
328
|
-
default:
|
|
329
|
-
claims[key] = value;
|
|
330
|
-
break;
|
|
445
|
+
function decodeMso(payload) {
|
|
446
|
+
let raw;
|
|
447
|
+
try {
|
|
448
|
+
raw = decoder3.decode(payload);
|
|
449
|
+
if (raw instanceof cborX.Tag && raw.tag === 24 && raw.value instanceof Uint8Array) {
|
|
450
|
+
raw = decoder3.decode(raw.value);
|
|
451
|
+
} else if (raw !== null && typeof raw === "object" && !(raw instanceof cborX.Tag) && !(raw instanceof Map) && !(raw instanceof Uint8Array) && "value" in raw && raw.value instanceof Uint8Array) {
|
|
452
|
+
raw = decoder3.decode(raw.value);
|
|
331
453
|
}
|
|
454
|
+
} catch (err) {
|
|
455
|
+
if (err instanceof MalformedCredentialError) throw err;
|
|
456
|
+
throw new MalformedCredentialError("MSO payload is not decodable CBOR");
|
|
332
457
|
}
|
|
333
|
-
|
|
458
|
+
if (!(raw instanceof Map)) {
|
|
459
|
+
throw new MalformedCredentialError("MSO must be a CBOR Map");
|
|
460
|
+
}
|
|
461
|
+
const m = raw;
|
|
462
|
+
const version = m.get("version");
|
|
463
|
+
if (typeof version !== "string") {
|
|
464
|
+
throw new MalformedCredentialError("MSO.version must be a string");
|
|
465
|
+
}
|
|
466
|
+
const digestAlgorithm = m.get("digestAlgorithm");
|
|
467
|
+
if (typeof digestAlgorithm !== "string") {
|
|
468
|
+
throw new MalformedCredentialError("MSO.digestAlgorithm must be a string");
|
|
469
|
+
}
|
|
470
|
+
const docType = m.get("docType");
|
|
471
|
+
if (typeof docType !== "string") {
|
|
472
|
+
throw new MalformedCredentialError("MSO.docType must be a string");
|
|
473
|
+
}
|
|
474
|
+
const vdRaw = m.get("valueDigests");
|
|
475
|
+
if (!(vdRaw instanceof Map)) {
|
|
476
|
+
throw new MalformedCredentialError("MSO.valueDigests must be a Map");
|
|
477
|
+
}
|
|
478
|
+
const valueDigests = /* @__PURE__ */ new Map();
|
|
479
|
+
for (const [ns, inner] of vdRaw.entries()) {
|
|
480
|
+
if (typeof ns !== "string" || !(inner instanceof Map)) {
|
|
481
|
+
throw new MalformedCredentialError("MSO.valueDigests entry malformed");
|
|
482
|
+
}
|
|
483
|
+
const perNs = /* @__PURE__ */ new Map();
|
|
484
|
+
for (const [id, digest] of inner.entries()) {
|
|
485
|
+
if (typeof id !== "number" || !(digest instanceof Uint8Array)) {
|
|
486
|
+
throw new MalformedCredentialError("MSO digest entry must map number\u2192bstr");
|
|
487
|
+
}
|
|
488
|
+
perNs.set(id, digest);
|
|
489
|
+
}
|
|
490
|
+
valueDigests.set(ns, perNs);
|
|
491
|
+
}
|
|
492
|
+
const viRaw = m.get("validityInfo");
|
|
493
|
+
if (!(viRaw instanceof Map)) {
|
|
494
|
+
throw new MalformedCredentialError("MSO.validityInfo must be a Map");
|
|
495
|
+
}
|
|
496
|
+
const vi = viRaw;
|
|
497
|
+
const validityInfo = {
|
|
498
|
+
signed: decodeDateLike(vi.get("signed")),
|
|
499
|
+
validFrom: decodeDateLike(vi.get("validFrom")),
|
|
500
|
+
validUntil: decodeDateLike(vi.get("validUntil"))
|
|
501
|
+
};
|
|
502
|
+
if (vi.has("expectedUpdate")) {
|
|
503
|
+
validityInfo.expectedUpdate = decodeDateLike(vi.get("expectedUpdate"));
|
|
504
|
+
}
|
|
505
|
+
return { version, digestAlgorithm, valueDigests, docType, validityInfo };
|
|
334
506
|
}
|
|
335
|
-
function
|
|
336
|
-
const
|
|
337
|
-
if (
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
507
|
+
function validateMsoValidity(mso, now) {
|
|
508
|
+
const { signed, validFrom, validUntil } = mso.validityInfo;
|
|
509
|
+
if (signed.getTime() > now.getTime()) {
|
|
510
|
+
throw new MalformedCredentialError(`MSO signed=${signed.toISOString()} is in the future`);
|
|
511
|
+
}
|
|
512
|
+
if (validFrom.getTime() > now.getTime()) {
|
|
513
|
+
throw new MalformedCredentialError(`MSO validFrom=${validFrom.toISOString()} is in the future`);
|
|
514
|
+
}
|
|
515
|
+
if (validFrom.getTime() > validUntil.getTime()) {
|
|
516
|
+
throw new MalformedCredentialError("MSO validFrom is after validUntil");
|
|
517
|
+
}
|
|
518
|
+
if (signed.getTime() < validFrom.getTime() || signed.getTime() > validUntil.getTime()) {
|
|
519
|
+
throw new MalformedCredentialError("MSO signed is outside the validity window");
|
|
520
|
+
}
|
|
521
|
+
if (validUntil.getTime() < now.getTime()) {
|
|
522
|
+
throw new ExpiredCredentialError(`MSO validUntil=${validUntil.toISOString()} is in the past`);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
function validateMsoDocType(mso, expectedDocType) {
|
|
526
|
+
if (mso.docType !== expectedDocType) {
|
|
527
|
+
throw new MalformedCredentialError(`MSO docType=${mso.docType} does not match expected ${expectedDocType}`);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// src/parsers/mdoc.parser.ts
|
|
532
|
+
var decoder4 = new cborX.Encoder({ mapsAsObjects: false, useRecords: false });
|
|
533
|
+
var encoder = new cborX.Encoder({ mapsAsObjects: false, useRecords: false });
|
|
534
|
+
var DEFAULT_ALLOWED_ALGORITHMS2 = ["ES256", "ES384", "ES512"];
|
|
535
|
+
function bytesEqual3(a, b) {
|
|
536
|
+
if (a.length !== b.length) return false;
|
|
537
|
+
for (let i = 0; i < a.length; i++) {
|
|
538
|
+
if (a[i] !== b[i]) return false;
|
|
539
|
+
}
|
|
540
|
+
return true;
|
|
541
|
+
}
|
|
542
|
+
function derToPem2(der) {
|
|
543
|
+
let binary = "";
|
|
544
|
+
for (const b of der) binary += String.fromCharCode(b);
|
|
545
|
+
const b64 = globalThis.btoa(binary);
|
|
546
|
+
const lines = b64.match(/.{1,64}/g) || [];
|
|
547
|
+
return `-----BEGIN CERTIFICATE-----
|
|
548
|
+
${lines.join("\n")}
|
|
549
|
+
-----END CERTIFICATE-----`;
|
|
550
|
+
}
|
|
551
|
+
function extractCountryHintFromCert(_certDer) {
|
|
552
|
+
return "";
|
|
553
|
+
}
|
|
554
|
+
function extractRawItemBytes(item) {
|
|
555
|
+
if (item instanceof cborX.Tag && item.tag === 24 && item.value instanceof Uint8Array) {
|
|
556
|
+
return item.value;
|
|
557
|
+
}
|
|
558
|
+
if (item instanceof Uint8Array) {
|
|
559
|
+
return item;
|
|
560
|
+
}
|
|
561
|
+
if (item !== null && typeof item === "object" && !(item instanceof cborX.Tag) && !(item instanceof Map) && "value" in item && item.value instanceof Uint8Array) {
|
|
562
|
+
return item.value;
|
|
563
|
+
}
|
|
564
|
+
throw new MalformedCredentialError("IssuerSignedItem must be a tag-24 byte string");
|
|
565
|
+
}
|
|
566
|
+
function mapRawToClaims(nameSpaces) {
|
|
567
|
+
const out = {};
|
|
568
|
+
for (const items of nameSpaces.values()) {
|
|
569
|
+
for (const itemBytes of items) {
|
|
570
|
+
let decoded;
|
|
571
|
+
try {
|
|
572
|
+
decoded = decoder4.decode(itemBytes);
|
|
573
|
+
} catch {
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
576
|
+
let inner;
|
|
577
|
+
if (decoded instanceof cborX.Tag && decoded.tag === 24 && decoded.value instanceof Uint8Array) {
|
|
578
|
+
inner = decoder4.decode(decoded.value);
|
|
579
|
+
} else if (decoded !== null && typeof decoded === "object" && !(decoded instanceof cborX.Tag) && !(decoded instanceof Map) && !(decoded instanceof Uint8Array) && "value" in decoded && decoded.value instanceof Uint8Array) {
|
|
580
|
+
inner = decoder4.decode(decoded.value);
|
|
581
|
+
} else if (decoded instanceof Map) {
|
|
582
|
+
inner = decoded;
|
|
583
|
+
} else {
|
|
584
|
+
continue;
|
|
585
|
+
}
|
|
586
|
+
if (inner instanceof Map) {
|
|
587
|
+
const id = inner.get("elementIdentifier");
|
|
588
|
+
const val = inner.get("elementValue");
|
|
589
|
+
if (typeof id === "string") {
|
|
590
|
+
out[id] = val;
|
|
591
|
+
}
|
|
348
592
|
}
|
|
349
593
|
}
|
|
350
|
-
} catch {
|
|
351
594
|
}
|
|
352
|
-
return
|
|
595
|
+
return out;
|
|
353
596
|
}
|
|
354
597
|
var MdocParser = class {
|
|
355
598
|
format = "mdoc";
|
|
356
599
|
/**
|
|
357
600
|
* Returns true when the token is binary data (Uint8Array or Buffer),
|
|
358
601
|
* indicating a CBOR-encoded mDOC DeviceResponse.
|
|
602
|
+
* Buffer extends Uint8Array, so `instanceof Uint8Array` catches both.
|
|
359
603
|
*/
|
|
360
604
|
canParse(vpToken) {
|
|
361
|
-
return
|
|
605
|
+
return vpToken instanceof Uint8Array;
|
|
362
606
|
}
|
|
363
607
|
/**
|
|
364
608
|
* Parses and validates an mDOC DeviceResponse token.
|
|
@@ -367,66 +611,131 @@ var MdocParser = class {
|
|
|
367
611
|
* or does not match the expected DeviceResponse format
|
|
368
612
|
*/
|
|
369
613
|
async parse(vpToken, options) {
|
|
370
|
-
if (!
|
|
371
|
-
throw new MalformedCredentialError("
|
|
614
|
+
if (!(vpToken instanceof Uint8Array)) {
|
|
615
|
+
throw new MalformedCredentialError("mDOC vpToken must be a Uint8Array");
|
|
372
616
|
}
|
|
373
|
-
const
|
|
374
|
-
valid: false,
|
|
375
|
-
format: this.format,
|
|
376
|
-
claims: {},
|
|
377
|
-
issuer: { certificate: new Uint8Array(), country: "" },
|
|
378
|
-
error
|
|
379
|
-
});
|
|
617
|
+
const allowedAlgorithms = options.allowedAlgorithms ?? DEFAULT_ALLOWED_ALGORITHMS2;
|
|
380
618
|
let deviceResponse;
|
|
381
619
|
try {
|
|
382
|
-
|
|
620
|
+
const decoded = decoder4.decode(vpToken);
|
|
621
|
+
if (!(decoded instanceof Map)) throw new Error();
|
|
622
|
+
deviceResponse = decoded;
|
|
383
623
|
} catch {
|
|
384
|
-
throw new MalformedCredentialError("
|
|
624
|
+
throw new MalformedCredentialError("vpToken is not a CBOR DeviceResponse Map");
|
|
385
625
|
}
|
|
386
|
-
|
|
387
|
-
|
|
626
|
+
const docs = deviceResponse.get("documents");
|
|
627
|
+
if (!Array.isArray(docs) || docs.length === 0) {
|
|
628
|
+
throw new MalformedCredentialError("DeviceResponse has no documents");
|
|
388
629
|
}
|
|
389
|
-
const document =
|
|
390
|
-
if (!document
|
|
391
|
-
throw new MalformedCredentialError("
|
|
630
|
+
const document = docs[0];
|
|
631
|
+
if (!(document instanceof Map)) {
|
|
632
|
+
throw new MalformedCredentialError("document must be a Map");
|
|
392
633
|
}
|
|
393
|
-
const
|
|
394
|
-
if (!
|
|
395
|
-
throw new MalformedCredentialError("
|
|
634
|
+
const issuerSigned = document.get("issuerSigned");
|
|
635
|
+
if (!(issuerSigned instanceof Map)) {
|
|
636
|
+
throw new MalformedCredentialError("document.issuerSigned must be a Map");
|
|
396
637
|
}
|
|
397
|
-
const
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
638
|
+
const issuerAuth = issuerSigned.get("issuerAuth");
|
|
639
|
+
if (!Array.isArray(issuerAuth)) {
|
|
640
|
+
throw new MalformedCredentialError("issuerAuth must be a COSE_Sign1 array");
|
|
641
|
+
}
|
|
642
|
+
const issuerAuthBytes = encoder.encode(issuerAuth);
|
|
643
|
+
const cose = decodeCoseSign1(issuerAuthBytes);
|
|
644
|
+
if (!allowedAlgorithms.includes(cose.alg)) {
|
|
645
|
+
return {
|
|
646
|
+
valid: false,
|
|
647
|
+
format: this.format,
|
|
648
|
+
claims: {},
|
|
649
|
+
issuer: { certificate: new Uint8Array(), country: "" },
|
|
650
|
+
error: `Unsupported algorithm: ${cose.alg}`
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
const issuerCertBytes = cose.x5chain[0];
|
|
654
|
+
if (!issuerCertBytes) {
|
|
655
|
+
throw new MalformedCredentialError("Missing issuer certificate in x5chain");
|
|
656
|
+
}
|
|
657
|
+
if (options.trustedCertificates.length === 0) {
|
|
658
|
+
if (options.skipTrustCheck !== true) {
|
|
659
|
+
throw new MalformedCredentialError(
|
|
660
|
+
"trustedCertificates must not be empty unless skipTrustCheck is true"
|
|
661
|
+
);
|
|
406
662
|
}
|
|
407
|
-
} else
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
663
|
+
} else {
|
|
664
|
+
const trusted = options.trustedCertificates.some((t) => bytesEqual3(t, issuerCertBytes));
|
|
665
|
+
if (!trusted) {
|
|
666
|
+
return {
|
|
667
|
+
valid: false,
|
|
668
|
+
format: this.format,
|
|
669
|
+
claims: {},
|
|
670
|
+
issuer: { certificate: issuerCertBytes, country: "" },
|
|
671
|
+
error: "Issuer certificate is not trusted"
|
|
672
|
+
};
|
|
415
673
|
}
|
|
416
674
|
}
|
|
417
|
-
const
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
675
|
+
const publicKey = await jose.importX509(derToPem2(issuerCertBytes), cose.alg);
|
|
676
|
+
try {
|
|
677
|
+
await verifyCoseSign1(cose, publicKey, allowedAlgorithms);
|
|
678
|
+
} catch (err) {
|
|
679
|
+
if (err instanceof MalformedCredentialError) {
|
|
680
|
+
return {
|
|
681
|
+
valid: false,
|
|
682
|
+
format: this.format,
|
|
683
|
+
claims: {},
|
|
684
|
+
issuer: { certificate: issuerCertBytes, country: "" },
|
|
685
|
+
error: err.message
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
throw err;
|
|
423
689
|
}
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
690
|
+
const mso = decodeMso(cose.payload);
|
|
691
|
+
try {
|
|
692
|
+
validateMsoValidity(mso, /* @__PURE__ */ new Date());
|
|
693
|
+
} catch (err) {
|
|
694
|
+
if (err instanceof ExpiredCredentialError) {
|
|
695
|
+
return {
|
|
696
|
+
valid: false,
|
|
697
|
+
format: this.format,
|
|
698
|
+
claims: {},
|
|
699
|
+
issuer: { certificate: issuerCertBytes, country: "" },
|
|
700
|
+
error: "Credential expired"
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
throw err;
|
|
704
|
+
}
|
|
705
|
+
if (options.expectedDocType !== void 0) {
|
|
706
|
+
try {
|
|
707
|
+
validateMsoDocType(mso, options.expectedDocType);
|
|
708
|
+
} catch (err) {
|
|
709
|
+
if (err instanceof MalformedCredentialError) {
|
|
710
|
+
return {
|
|
711
|
+
valid: false,
|
|
712
|
+
format: this.format,
|
|
713
|
+
claims: {},
|
|
714
|
+
issuer: { certificate: issuerCertBytes, country: "" },
|
|
715
|
+
error: err.message
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
throw err;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
const nsRaw = issuerSigned.get("nameSpaces");
|
|
722
|
+
if (!(nsRaw instanceof Map)) {
|
|
723
|
+
throw new MalformedCredentialError("issuerSigned.nameSpaces must be a Map");
|
|
724
|
+
}
|
|
725
|
+
const nameSpaces = /* @__PURE__ */ new Map();
|
|
726
|
+
for (const [ns, items] of nsRaw.entries()) {
|
|
727
|
+
if (typeof ns !== "string" || !Array.isArray(items)) {
|
|
728
|
+
throw new MalformedCredentialError("nameSpaces entry malformed");
|
|
729
|
+
}
|
|
730
|
+
nameSpaces.set(ns, items.map(extractRawItemBytes));
|
|
731
|
+
}
|
|
732
|
+
await verifyAllDigests(nameSpaces, mso);
|
|
733
|
+
const claims = mapRawToClaims(nameSpaces);
|
|
734
|
+
const issuer = {
|
|
735
|
+
certificate: issuerCertBytes,
|
|
736
|
+
country: extractCountryHintFromCert()
|
|
429
737
|
};
|
|
738
|
+
return { valid: true, format: this.format, claims, issuer };
|
|
430
739
|
}
|
|
431
740
|
};
|
|
432
741
|
function createAuthorizationRequest(input) {
|
|
@@ -486,7 +795,7 @@ async function parsePresentation(vpToken, options) {
|
|
|
486
795
|
}
|
|
487
796
|
|
|
488
797
|
// src/index.ts
|
|
489
|
-
var VERSION = "0.
|
|
798
|
+
var VERSION = "0.3.0";
|
|
490
799
|
|
|
491
800
|
exports.ExpiredCredentialError = ExpiredCredentialError;
|
|
492
801
|
exports.InvalidSignatureError = InvalidSignatureError;
|