@blamejs/core 0.7.105 → 0.7.107

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.
@@ -0,0 +1,526 @@
1
+ "use strict";
2
+ /**
3
+ * b.auth.sdJwtVc — Selective Disclosure JWT for Verifiable Credentials
4
+ * (draft-ietf-oauth-sd-jwt-vc).
5
+ *
6
+ * SD-JWT VC is the IETF format aligned with the EU Digital Identity
7
+ * Wallet (EUDI Wallet) roll-out and the EU AI Act Article 50
8
+ * disclosure requirements. It allows an issuer to mint a credential
9
+ * containing selectively-disclosable claims; the holder presents only
10
+ * the subset they choose to a verifier; the verifier validates the
11
+ * issuer signature + the cryptographic binding between disclosures
12
+ * and the issuer's `_sd` digest array.
13
+ *
14
+ * // Issuer side
15
+ * var sdJwt = b.auth.sdJwtVc.issue({
16
+ * issuer: "https://issuer.example.com",
17
+ * subject: "did:web:alice.example.com",
18
+ * vct: "https://credentials.example.com/identity_credential",
19
+ * claims: {
20
+ * given_name: "Alice",
21
+ * family_name: "Smith",
22
+ * birthdate: "1990-01-15",
23
+ * nationality: "US",
24
+ * },
25
+ * selectivelyDisclosed: ["given_name", "family_name", "birthdate"],
26
+ * issuerKey: issuerPrivKeyPem,
27
+ * algorithm: "ES256",
28
+ * ttlMs: C.TIME.days(30),
29
+ * holderKey: holderPubJwk, // optional cnf binding
30
+ * });
31
+ *
32
+ * // Holder presents subset
33
+ * var presentation = b.auth.sdJwtVc.present({
34
+ * sdJwt: sdJwt.token,
35
+ * disclosedClaimNames: ["given_name"], // selective release
36
+ * audience: "https://verifier.example.com",
37
+ * nonce: nonceFromVerifier,
38
+ * holderKey: holderPrivKeyPem, // for KB-JWT signing
39
+ * algorithm: "ES256",
40
+ * });
41
+ *
42
+ * // Verifier validates
43
+ * var result = await b.auth.sdJwtVc.verify(presentation, {
44
+ * issuerKeyResolver: async function (header) { return issuerPubKeyPem; },
45
+ * audience: "https://verifier.example.com",
46
+ * nonce: nonceForReplayDefense,
47
+ * });
48
+ * // → { valid: true, claims: { vct, given_name }, holderKey, kbValidated }
49
+ *
50
+ * Supported signature algorithms (issuer + KB-JWT):
51
+ * - ES256 (ECDSA + P-256 + SHA-256) — default per spec
52
+ * - ES384 (ECDSA + P-384 + SHA-384)
53
+ * - EdDSA (Ed25519)
54
+ * - ML-DSA-87 (PQC; framework's default) — draft IETF allocation
55
+ * - ML-DSA-65 (PQC; lighter)
56
+ *
57
+ * Hash algorithm for `_sd` digests: SHA-256 (spec default). Operators
58
+ * with PQC strict deployments specify "sha3-256" or "sha-512" via
59
+ * opts.hashAlg at issue time; verify() reads `_sd_alg` from the
60
+ * issuer payload to know how to recompute digests.
61
+ */
62
+
63
+ var nodeCrypto = require("node:crypto");
64
+ var safeBuffer = require("../safe-buffer");
65
+ var safeJson = require("../safe-json");
66
+ var validateOpts = require("../validate-opts");
67
+ var disclosure = require("./sd-jwt-vc-disclosure");
68
+ var sdJwtVcIssuer = require("./sd-jwt-vc-issuer");
69
+ var sdJwtVcHolder = require("./sd-jwt-vc-holder");
70
+ var { AuthError } = require("../framework-error");
71
+
72
+ var SUPPORTED_ALGS = Object.freeze([
73
+ "ES256", "ES384", "EdDSA", "ML-DSA-87", "ML-DSA-65",
74
+ ]);
75
+
76
+ var SUPPORTED_HASH_ALGS = Object.freeze({
77
+ "sha-256": "sha256",
78
+ "sha-512": "sha512",
79
+ "sha3-256": "sha3-256",
80
+ "sha3-512": "sha3-512",
81
+ });
82
+
83
+ var DEFAULT_ALG = "ES256";
84
+ var DEFAULT_HASH_ALG = "sha-256";
85
+
86
+ function _b64uEncode(str) {
87
+ return Buffer.from(str, "utf8").toString("base64url");
88
+ }
89
+
90
+ function _b64uEncodeBuf(buf) {
91
+ return buf.toString("base64url");
92
+ }
93
+
94
+ function _b64uDecodeStr(s) {
95
+ return Buffer.from(s, "base64url").toString("utf8");
96
+ }
97
+
98
+ function _b64uDecodeBuf(s) {
99
+ return Buffer.from(s, "base64url");
100
+ }
101
+
102
+ function _hashDisclosure(disclosureStr, hashAlg) {
103
+ var nodeAlg = SUPPORTED_HASH_ALGS[hashAlg];
104
+ if (!nodeAlg) {
105
+ throw new AuthError("auth-sd-jwt-vc/bad-hash",
106
+ "Unsupported hash algorithm: " + hashAlg);
107
+ }
108
+ var h = nodeCrypto.createHash(nodeAlg);
109
+ h.update(disclosureStr, "ascii");
110
+ return h.digest().toString("base64url");
111
+ }
112
+
113
+ function _signJwt(header, payload, privateKey, algorithm) {
114
+ var headerStr = _b64uEncode(safeJson.stringify(header));
115
+ var payloadStr = _b64uEncode(safeJson.stringify(payload));
116
+ var signingInput = headerStr + "." + payloadStr;
117
+ var sigAlgo = _resolveSigAlgo(algorithm);
118
+ var sig = nodeCrypto.sign(sigAlgo, Buffer.from(signingInput, "ascii"), privateKey);
119
+ return signingInput + "." + sig.toString("base64url");
120
+ }
121
+
122
+ function _verifyJwt(token, publicKey, algorithm) {
123
+ var parts = token.split(".");
124
+ if (parts.length !== 3) {
125
+ throw new AuthError("auth-sd-jwt-vc/malformed-jwt",
126
+ "JWT must have 3 dot-separated parts");
127
+ }
128
+ var signingInput = parts[0] + "." + parts[1];
129
+ var sig = _b64uDecodeBuf(parts[2]);
130
+ var sigAlgo = _resolveSigAlgo(algorithm);
131
+ var ok = nodeCrypto.verify(sigAlgo, Buffer.from(signingInput, "ascii"), publicKey, sig);
132
+ if (!ok) {
133
+ throw new AuthError("auth-sd-jwt-vc/bad-signature",
134
+ "JWT signature verification failed");
135
+ }
136
+ var headerStr = _b64uDecodeStr(parts[0]);
137
+ var payloadStr = _b64uDecodeStr(parts[1]);
138
+ return {
139
+ header: safeJson.parse(headerStr, { maxBytes: 64 * 1024 }), // allow:bare-json-parse — header from cryptographically-verified JWT; signature verifies the bytes // allow:raw-byte-literal — JWT header cap (64 KB)
140
+ payload: safeJson.parse(payloadStr, { maxBytes: 1024 * 1024 }), // allow:bare-json-parse — payload from cryptographically-verified JWT; signature verifies the bytes // allow:raw-byte-literal — JWT payload cap (1 MB)
141
+ };
142
+ }
143
+
144
+ function _resolveSigAlgo(algorithm) {
145
+ // Node 24+ accepts these algorithm hints; ES256 / ES384 use the
146
+ // EC private key's curve to dispatch. EdDSA + ML-DSA-* are
147
+ // signature-algorithm hints handled directly by Node.js crypto.
148
+ switch (algorithm) {
149
+ case "ES256": return "sha256";
150
+ case "ES384": return "sha384";
151
+ case "EdDSA": return null; // pass-through, Node infers
152
+ case "ML-DSA-87": return null;
153
+ case "ML-DSA-65": return null;
154
+ default:
155
+ throw new AuthError("auth-sd-jwt-vc/unsupported-alg",
156
+ "Unsupported algorithm: " + algorithm);
157
+ }
158
+ }
159
+
160
+ // ---- issue ----
161
+
162
+ function issue(opts) {
163
+ validateOpts.requireObject(opts, "auth.sdJwtVc.issue", AuthError);
164
+ validateOpts(opts, [
165
+ "issuer", "subject", "vct", "claims",
166
+ "selectivelyDisclosed", "issuerKey", "algorithm",
167
+ "hashAlg", "ttlMs", "issuedAt",
168
+ "holderKey", "extraHeader",
169
+ ], "auth.sdJwtVc.issue");
170
+
171
+ validateOpts.requireNonEmptyString(opts.issuer,
172
+ "issue: issuer", AuthError, "auth-sd-jwt-vc/bad-opts");
173
+ validateOpts.requireNonEmptyString(opts.vct,
174
+ "issue: vct", AuthError, "auth-sd-jwt-vc/bad-opts");
175
+ if (!opts.claims || typeof opts.claims !== "object" || Array.isArray(opts.claims)) {
176
+ throw new AuthError("auth-sd-jwt-vc/bad-opts",
177
+ "issue: claims must be a plain object");
178
+ }
179
+
180
+ var algorithm = opts.algorithm || DEFAULT_ALG;
181
+ if (SUPPORTED_ALGS.indexOf(algorithm) === -1) {
182
+ throw new AuthError("auth-sd-jwt-vc/unsupported-alg",
183
+ "issue: algorithm must be one of " + SUPPORTED_ALGS.join(", "));
184
+ }
185
+ var hashAlg = opts.hashAlg || DEFAULT_HASH_ALG;
186
+ if (!SUPPORTED_HASH_ALGS[hashAlg]) {
187
+ throw new AuthError("auth-sd-jwt-vc/bad-hash",
188
+ "issue: hashAlg must be one of " + Object.keys(SUPPORTED_HASH_ALGS).join(", "));
189
+ }
190
+
191
+ if (!opts.issuerKey) {
192
+ throw new AuthError("auth-sd-jwt-vc/no-key", "issue: issuerKey required");
193
+ }
194
+
195
+ var sdNames = Array.isArray(opts.selectivelyDisclosed)
196
+ ? opts.selectivelyDisclosed.slice() : [];
197
+ var unknownSdNames = sdNames.filter(function (n) {
198
+ return !(n in opts.claims);
199
+ });
200
+ if (unknownSdNames.length > 0) {
201
+ throw new AuthError("auth-sd-jwt-vc/unknown-claim",
202
+ "issue: selectivelyDisclosed includes claim(s) not present in claims: " +
203
+ unknownSdNames.join(", "));
204
+ }
205
+
206
+ // Build disclosures + digest array
207
+ var disclosures = [];
208
+ var sdDigests = [];
209
+ var plainClaims = {};
210
+ var allClaimNames = Object.keys(opts.claims);
211
+ for (var i = 0; i < allClaimNames.length; i++) {
212
+ var name = allClaimNames[i];
213
+ var value = opts.claims[name];
214
+ if (sdNames.indexOf(name) !== -1) {
215
+ var d = disclosure.encode(name, value);
216
+ disclosures.push(d);
217
+ sdDigests.push(_hashDisclosure(d, hashAlg));
218
+ } else {
219
+ plainClaims[name] = value;
220
+ }
221
+ }
222
+ // Spec: shuffle digests so order doesn't leak claim order
223
+ sdDigests.sort();
224
+
225
+ var now = (typeof opts.issuedAt === "number" && isFinite(opts.issuedAt))
226
+ ? Math.floor(opts.issuedAt / 1000) : Math.floor(Date.now() / 1000); // allow:raw-byte-literal — ms→s conversion factor
227
+ var ttlSec = opts.ttlMs ? Math.floor(opts.ttlMs / 1000) : 30 * 24 * 60 * 60; // allow:raw-byte-literal — ms→s conversion + 30-day default in seconds
228
+
229
+ var payload = Object.assign({}, plainClaims, {
230
+ iss: opts.issuer,
231
+ iat: now,
232
+ exp: now + ttlSec,
233
+ vct: opts.vct,
234
+ _sd: sdDigests,
235
+ _sd_alg: hashAlg,
236
+ });
237
+ if (opts.subject) payload.sub = opts.subject;
238
+ if (opts.holderKey) {
239
+ // Holder binding via cnf claim per draft §4.2.2
240
+ if (typeof opts.holderKey !== "object" || !opts.holderKey.kty) {
241
+ throw new AuthError("auth-sd-jwt-vc/bad-cnf",
242
+ "issue: holderKey must be a JWK with kty");
243
+ }
244
+ payload.cnf = { jwk: opts.holderKey };
245
+ }
246
+ var header = Object.assign({}, opts.extraHeader || {}, {
247
+ alg: algorithm,
248
+ typ: "vc+sd-jwt",
249
+ });
250
+
251
+ var jwt = _signJwt(header, payload, opts.issuerKey, algorithm);
252
+ var token = jwt + "~" + disclosures.join("~") + "~";
253
+ return {
254
+ token: token,
255
+ jwt: jwt,
256
+ disclosures: disclosures,
257
+ payload: payload,
258
+ header: header,
259
+ };
260
+ }
261
+
262
+ // ---- present (holder side) ----
263
+
264
+ function present(opts) {
265
+ validateOpts.requireObject(opts, "auth.sdJwtVc.present", AuthError);
266
+ validateOpts(opts, [
267
+ "sdJwt", "disclosedClaimNames", "audience",
268
+ "nonce", "holderKey", "algorithm", "issuedAt",
269
+ ], "auth.sdJwtVc.present");
270
+
271
+ validateOpts.requireNonEmptyString(opts.sdJwt,
272
+ "present: sdJwt", AuthError, "auth-sd-jwt-vc/no-token");
273
+ var parts = opts.sdJwt.split("~");
274
+ if (parts.length < 2) {
275
+ throw new AuthError("auth-sd-jwt-vc/malformed",
276
+ "present: sdJwt must contain at least one ~-separator");
277
+ }
278
+ var jwt = parts[0];
279
+ var allDisclosures = parts.slice(1).filter(function (p) { return p.length > 0; });
280
+
281
+ // Decode disclosures + filter by name
282
+ var disclosedNames = Array.isArray(opts.disclosedClaimNames)
283
+ ? opts.disclosedClaimNames.slice() : [];
284
+ var releasedDisclosures = [];
285
+ for (var i = 0; i < allDisclosures.length; i++) {
286
+ try {
287
+ var decoded = disclosure.decode(allDisclosures[i]);
288
+ if (decoded && disclosedNames.indexOf(decoded.name) !== -1) {
289
+ releasedDisclosures.push(allDisclosures[i]);
290
+ }
291
+ } catch (_e) { /* malformed — skip */ }
292
+ }
293
+
294
+ // Build presentation
295
+ var presentation = jwt + "~";
296
+ if (releasedDisclosures.length > 0) {
297
+ presentation += releasedDisclosures.join("~") + "~";
298
+ }
299
+
300
+ // Optional Key Binding JWT
301
+ if (opts.audience && opts.nonce && opts.holderKey) {
302
+ var algorithm = opts.algorithm || DEFAULT_ALG;
303
+ if (SUPPORTED_ALGS.indexOf(algorithm) === -1) {
304
+ throw new AuthError("auth-sd-jwt-vc/unsupported-alg",
305
+ "present: algorithm must be one of " + SUPPORTED_ALGS.join(", "));
306
+ }
307
+ var now = (typeof opts.issuedAt === "number" && isFinite(opts.issuedAt))
308
+ ? Math.floor(opts.issuedAt / 1000) : Math.floor(Date.now() / 1000); // allow:raw-byte-literal — ms→s conversion factor
309
+ // The KB-JWT's hash binds it to the specific SD-JWT + presentation
310
+ var kbHashInput = presentation; // jwt~d1~d2~ (without KB)
311
+ var sdHash = nodeCrypto.createHash("sha256")
312
+ .update(kbHashInput, "ascii")
313
+ .digest()
314
+ .toString("base64url");
315
+ var kbPayload = {
316
+ nonce: opts.nonce,
317
+ aud: opts.audience,
318
+ iat: now,
319
+ sd_hash: sdHash,
320
+ };
321
+ var kbHeader = { alg: algorithm, typ: "kb+jwt" };
322
+ var kbJwt = _signJwt(kbHeader, kbPayload, opts.holderKey, algorithm);
323
+ presentation += kbJwt;
324
+ }
325
+
326
+ return {
327
+ presentation: presentation,
328
+ jwt: jwt,
329
+ disclosures: releasedDisclosures,
330
+ };
331
+ }
332
+
333
+ // ---- verify ----
334
+
335
+ async function verify(presentation, opts) {
336
+ validateOpts.requireObject(opts, "auth.sdJwtVc.verify", AuthError);
337
+ validateOpts(opts, [
338
+ "issuerKeyResolver", "audience", "nonce",
339
+ "now", "expectedVct", "maxClockSkewSec",
340
+ "requireKeyBinding",
341
+ ], "auth.sdJwtVc.verify");
342
+
343
+ if (typeof presentation !== "string" || presentation.length === 0) {
344
+ throw new AuthError("auth-sd-jwt-vc/no-token",
345
+ "verify: presentation must be a non-empty string");
346
+ }
347
+ if (typeof opts.issuerKeyResolver !== "function") {
348
+ throw new AuthError("auth-sd-jwt-vc/no-resolver",
349
+ "verify: issuerKeyResolver must be an async function");
350
+ }
351
+ var parts = presentation.split("~");
352
+ if (parts.length < 2) {
353
+ throw new AuthError("auth-sd-jwt-vc/malformed",
354
+ "verify: presentation must contain at least one ~-separator");
355
+ }
356
+ var jwt = parts[0];
357
+ // Last part is empty (trailing ~) or KB-JWT (3-dot-separated)
358
+ var maybeKbJwt = null;
359
+ var lastPart = parts[parts.length - 1];
360
+ if (lastPart && lastPart.split(".").length === 3) {
361
+ maybeKbJwt = lastPart;
362
+ }
363
+ var disclosureParts = parts.slice(1, parts.length - (maybeKbJwt ? 1 : 0))
364
+ .filter(function (p) { return p.length > 0; });
365
+
366
+ // 1. Verify issuer JWT signature
367
+ var jwtParts = jwt.split(".");
368
+ if (jwtParts.length !== 3) {
369
+ throw new AuthError("auth-sd-jwt-vc/malformed-jwt",
370
+ "verify: JWT must have 3 dot-separated parts");
371
+ }
372
+ var headerObj;
373
+ try { headerObj = safeJson.parse(_b64uDecodeStr(jwtParts[0]), { maxBytes: 64 * 1024 }); } // allow:bare-json-parse — pre-verify header parse to look up the key resolver; checked again post-signature // allow:raw-byte-literal — JWT header cap (64 KB)
374
+ catch (e) {
375
+ throw new AuthError("auth-sd-jwt-vc/bad-header",
376
+ "verify: malformed JWT header: " + e.message);
377
+ }
378
+ var alg = headerObj.alg;
379
+ if (SUPPORTED_ALGS.indexOf(alg) === -1) {
380
+ throw new AuthError("auth-sd-jwt-vc/unsupported-alg",
381
+ "verify: header alg \"" + alg + "\" not in supported set");
382
+ }
383
+ var typ = headerObj.typ;
384
+ if (typ && typ !== "vc+sd-jwt" && typ !== "JWT") {
385
+ throw new AuthError("auth-sd-jwt-vc/bad-typ",
386
+ "verify: header typ must be \"vc+sd-jwt\" (got \"" + typ + "\")");
387
+ }
388
+
389
+ var issuerKey = await opts.issuerKeyResolver(headerObj);
390
+ if (!issuerKey) {
391
+ throw new AuthError("auth-sd-jwt-vc/key-not-found",
392
+ "verify: issuerKeyResolver returned no key");
393
+ }
394
+ var jwtParsed = _verifyJwt(jwt, issuerKey, alg);
395
+
396
+ // 2. Validate iss / iat / exp / vct
397
+ var nowSec = (typeof opts.now === "number" && isFinite(opts.now))
398
+ ? Math.floor(opts.now / 1000) : Math.floor(Date.now() / 1000); // allow:raw-byte-literal — ms→s conversion factor
399
+ var skew = (typeof opts.maxClockSkewSec === "number") ? opts.maxClockSkewSec : 60; // allow:raw-time-literal — default 60s clock-skew tolerance
400
+ if (typeof jwtParsed.payload.iat === "number" && jwtParsed.payload.iat > nowSec + skew) {
401
+ throw new AuthError("auth-sd-jwt-vc/iat-future",
402
+ "verify: iat is in the future (clock skew?)");
403
+ }
404
+ if (typeof jwtParsed.payload.exp === "number" && jwtParsed.payload.exp < nowSec - skew) {
405
+ throw new AuthError("auth-sd-jwt-vc/expired",
406
+ "verify: token is expired");
407
+ }
408
+ if (opts.expectedVct && jwtParsed.payload.vct !== opts.expectedVct) {
409
+ throw new AuthError("auth-sd-jwt-vc/wrong-vct",
410
+ "verify: vct mismatch (got \"" + jwtParsed.payload.vct +
411
+ "\", expected \"" + opts.expectedVct + "\")");
412
+ }
413
+
414
+ // 3. Reconstruct disclosed claims from disclosures
415
+ var hashAlg = jwtParsed.payload._sd_alg || DEFAULT_HASH_ALG;
416
+ if (!SUPPORTED_HASH_ALGS[hashAlg]) {
417
+ throw new AuthError("auth-sd-jwt-vc/bad-hash",
418
+ "verify: _sd_alg \"" + hashAlg + "\" not supported");
419
+ }
420
+ var sdDigests = Array.isArray(jwtParsed.payload._sd) ? jwtParsed.payload._sd : [];
421
+ var disclosedClaims = {};
422
+ for (var i = 0; i < disclosureParts.length; i++) {
423
+ var d = disclosure.decode(disclosureParts[i]);
424
+ if (!d) continue;
425
+ var digest = _hashDisclosure(disclosureParts[i], hashAlg);
426
+ if (sdDigests.indexOf(digest) === -1) {
427
+ throw new AuthError("auth-sd-jwt-vc/disclosure-mismatch",
428
+ "verify: disclosure for claim \"" + d.name + "\" does not match any _sd digest");
429
+ }
430
+ disclosedClaims[d.name] = d.value;
431
+ }
432
+
433
+ // 4. Optionally verify Key Binding JWT
434
+ var kbValidated = false;
435
+ var holderKey = null;
436
+ if (jwtParsed.payload.cnf && jwtParsed.payload.cnf.jwk) {
437
+ holderKey = jwtParsed.payload.cnf.jwk;
438
+ }
439
+ if (maybeKbJwt) {
440
+ if (!holderKey) {
441
+ throw new AuthError("auth-sd-jwt-vc/no-cnf",
442
+ "verify: KB-JWT present but issuer payload has no cnf.jwk");
443
+ }
444
+ // Verify KB-JWT signature
445
+ var kbHeaderObj;
446
+ try { kbHeaderObj = safeJson.parse(_b64uDecodeStr(maybeKbJwt.split(".")[0]), { maxBytes: 4096 }); } // allow:bare-json-parse — kb header from validated KB-JWT; signature verifies // allow:raw-byte-literal — kb-header cap (4 KB)
447
+ catch (e) {
448
+ throw new AuthError("auth-sd-jwt-vc/bad-kb-header",
449
+ "verify: malformed KB-JWT header: " + e.message);
450
+ }
451
+ if (kbHeaderObj.typ !== "kb+jwt") {
452
+ throw new AuthError("auth-sd-jwt-vc/bad-kb-typ",
453
+ "verify: KB-JWT typ must be \"kb+jwt\"");
454
+ }
455
+ var kbAlg = kbHeaderObj.alg;
456
+ if (SUPPORTED_ALGS.indexOf(kbAlg) === -1) {
457
+ throw new AuthError("auth-sd-jwt-vc/unsupported-alg",
458
+ "verify: KB-JWT alg unsupported");
459
+ }
460
+ var holderKeyObj = nodeCrypto.createPublicKey({ key: holderKey, format: "jwk" });
461
+ var kbParsed = _verifyJwt(maybeKbJwt, holderKeyObj, kbAlg);
462
+ if (opts.audience && kbParsed.payload.aud !== opts.audience) {
463
+ throw new AuthError("auth-sd-jwt-vc/wrong-audience",
464
+ "verify: KB-JWT aud mismatch");
465
+ }
466
+ if (opts.nonce && kbParsed.payload.nonce !== opts.nonce) {
467
+ throw new AuthError("auth-sd-jwt-vc/wrong-nonce",
468
+ "verify: KB-JWT nonce mismatch (replay defense)");
469
+ }
470
+ // Validate KB-JWT sd_hash matches the presentation
471
+ var kbHashInput = jwt + "~";
472
+ if (disclosureParts.length > 0) kbHashInput += disclosureParts.join("~") + "~";
473
+ var expectedSdHash = nodeCrypto.createHash("sha256")
474
+ .update(kbHashInput, "ascii")
475
+ .digest()
476
+ .toString("base64url");
477
+ if (kbParsed.payload.sd_hash !== expectedSdHash) {
478
+ throw new AuthError("auth-sd-jwt-vc/sd-hash-mismatch",
479
+ "verify: KB-JWT sd_hash does not match the presentation hash (presentation tampered with?)");
480
+ }
481
+ if (typeof kbParsed.payload.iat === "number" && kbParsed.payload.iat > nowSec + skew) {
482
+ throw new AuthError("auth-sd-jwt-vc/kb-iat-future",
483
+ "verify: KB-JWT iat is in the future");
484
+ }
485
+ kbValidated = true;
486
+ } else if (opts.requireKeyBinding) {
487
+ throw new AuthError("auth-sd-jwt-vc/missing-kb",
488
+ "verify: KB-JWT required (requireKeyBinding=true) but not present");
489
+ }
490
+
491
+ // 5. Build the resolved-claim set (issuer claims + disclosed)
492
+ var resolved = Object.assign({}, jwtParsed.payload);
493
+ delete resolved._sd;
494
+ delete resolved._sd_alg;
495
+ Object.keys(disclosedClaims).forEach(function (k) {
496
+ resolved[k] = disclosedClaims[k];
497
+ });
498
+
499
+ return {
500
+ valid: true,
501
+ claims: resolved,
502
+ disclosedClaims: disclosedClaims,
503
+ issuerHeader: jwtParsed.header,
504
+ issuerPayload: jwtParsed.payload,
505
+ holderKey: holderKey,
506
+ kbValidated: kbValidated,
507
+ };
508
+ }
509
+
510
+ module.exports = {
511
+ issue: issue,
512
+ present: present,
513
+ verify: verify,
514
+ disclosure: disclosure,
515
+ issuer: sdJwtVcIssuer,
516
+ holder: sdJwtVcHolder,
517
+ SUPPORTED_ALGS: SUPPORTED_ALGS,
518
+ SUPPORTED_HASH_ALGS: Object.freeze(Object.keys(SUPPORTED_HASH_ALGS)),
519
+ DEFAULT_ALG: DEFAULT_ALG,
520
+ DEFAULT_HASH_ALG: DEFAULT_HASH_ALG,
521
+ // Test hooks
522
+ _hashDisclosure: _hashDisclosure,
523
+ // unused-token tag so safeBuffer module isn't dropped from the
524
+ // bundle — we keep it imported for future signature-input bound checks.
525
+ _safeBufferRef: safeBuffer,
526
+ };
@@ -0,0 +1,164 @@
1
+ "use strict";
2
+
3
+ var validateOpts = require("./validate-opts");
4
+ var tagwalk = require("./guard-html-wcag-tagwalk");
5
+
6
+ var KNOWN_ROLES = Object.freeze([
7
+ "banner","complementary","contentinfo","form","main","navigation","region","search",
8
+ "alert","alertdialog","log","marquee","status","timer",
9
+ "article","definition","directory","document","feed","figure","group","heading","img",
10
+ "list","listitem","math","note","presentation","none","row","rowgroup","rowheader",
11
+ "separator","table","term","toolbar","tooltip",
12
+ "button","checkbox","combobox","dialog","grid","gridcell","link","listbox","menu",
13
+ "menubar","menuitem","menuitemcheckbox","menuitemradio","option","progressbar","radio",
14
+ "radiogroup","scrollbar","searchbox","slider","spinbutton","switch","tab","tablist",
15
+ "tabpanel","textbox","tree","treegrid","treeitem","application",
16
+ ]);
17
+
18
+ var ROLE_REQUIRED_PROPS = Object.freeze({
19
+ "checkbox": ["aria-checked"],
20
+ "switch": ["aria-checked"],
21
+ "radio": ["aria-checked"],
22
+ "menuitemradio": ["aria-checked"],
23
+ "menuitemcheckbox": ["aria-checked"],
24
+ "combobox": ["aria-expanded"],
25
+ "scrollbar": ["aria-valuenow"],
26
+ "slider": ["aria-valuenow"],
27
+ "spinbutton": ["aria-valuenow"],
28
+ "heading": ["aria-level"],
29
+ "option": ["aria-selected"],
30
+ });
31
+
32
+ var ARIA_VALUE_SETS = Object.freeze({
33
+ "aria-checked": ["true","false","mixed"],
34
+ "aria-expanded": ["true","false"],
35
+ "aria-pressed": ["true","false","mixed"],
36
+ "aria-selected": ["true","false"],
37
+ "aria-disabled": ["true","false"],
38
+ "aria-hidden": ["true","false"],
39
+ "aria-haspopup": ["false","true","menu","listbox","tree","grid","dialog"],
40
+ "aria-orientation": ["horizontal","vertical"],
41
+ "aria-current": ["page","step","location","date","time","true","false"],
42
+ "aria-live": ["off","polite","assertive"],
43
+ "aria-sort": ["ascending","descending","none","other"],
44
+ "aria-autocomplete":["inline","list","both","none"],
45
+ });
46
+
47
+ var _TAG_RE = tagwalk.TAG_RE;
48
+ var _parseAttrs = tagwalk.parseAttrs;
49
+ var _lineColAt = tagwalk.lineColAt;
50
+
51
+ function audit(html, opts) {
52
+ opts = opts || {};
53
+ validateOpts(opts, ["allowedRoles", "scopeUrl"], "guardHtml.wcag.aria.audit");
54
+ if (typeof html !== "string") {
55
+ throw new TypeError("aria.audit: html must be a string");
56
+ }
57
+ var allowedRoles = Array.isArray(opts.allowedRoles)
58
+ ? KNOWN_ROLES.concat(opts.allowedRoles)
59
+ : KNOWN_ROLES;
60
+
61
+ var findings = [];
62
+ function _add(f) { findings.push(f); }
63
+
64
+ var declaredIds = Object.create(null);
65
+ var idRe = /\bid\s*=\s*["']([^"']+)["']/gi;
66
+ var im;
67
+ while ((im = idRe.exec(html))) { // RegExp.prototype.exec
68
+ declaredIds[im[1]] = true;
69
+ }
70
+
71
+ _TAG_RE.lastIndex = 0;
72
+ var m;
73
+ while ((m = _TAG_RE.exec(html))) { // RegExp.prototype.exec
74
+ if (m[0].charAt(1) === "/") continue;
75
+ var tagName = m[1].toLowerCase();
76
+ var attrs = _parseAttrs(m[2]);
77
+ var offset = m.index;
78
+ var pos = _lineColAt(html, offset);
79
+
80
+ if ("role" in attrs) {
81
+ var roles = attrs.role.split(/\s+/).filter(Boolean);
82
+ for (var ri = 0; ri < roles.length; ri++) {
83
+ if (allowedRoles.indexOf(roles[ri]) === -1) {
84
+ _add({
85
+ sc: "4.1.2", level: "A", severity: "warning",
86
+ element: tagName, line: pos.line, column: pos.column,
87
+ message: "Unknown ARIA role \"" + roles[ri] + "\" (typo? unsupported by AT?)",
88
+ remediation: "Use a known WAI-ARIA 1.2 role or remove the role attribute",
89
+ });
90
+ }
91
+ }
92
+ for (var rj = 0; rj < roles.length; rj++) {
93
+ var required = ROLE_REQUIRED_PROPS[roles[rj]];
94
+ if (!Array.isArray(required) || required.length === 0) continue;
95
+ for (var ai = 0; ai < required.length; ai++) {
96
+ if (!(required[ai] in attrs)) {
97
+ _add({
98
+ sc: "4.1.2", level: "A", severity: "error",
99
+ element: tagName, line: pos.line, column: pos.column,
100
+ message: "ARIA role=\"" + roles[rj] + "\" requires attribute \"" + required[ai] + "\"",
101
+ remediation: "Add " + required[ai] + "=\"<valid-value>\" to the element",
102
+ });
103
+ }
104
+ }
105
+ }
106
+ }
107
+
108
+ var attrNames = Object.keys(attrs);
109
+ for (var ki = 0; ki < attrNames.length; ki++) {
110
+ var key = attrNames[ki];
111
+ if (key.indexOf("aria-") !== 0) continue;
112
+ var allowedValues = ARIA_VALUE_SETS[key];
113
+ if (!Array.isArray(allowedValues)) continue;
114
+ var v = String(attrs[key]).trim();
115
+ if (allowedValues.indexOf(v) === -1) {
116
+ _add({
117
+ sc: "4.1.2", level: "A", severity: "error",
118
+ element: tagName, line: pos.line, column: pos.column,
119
+ message: key + "=\"" + v + "\" is not in the allowed value set [" + allowedValues.join(", ") + "]",
120
+ remediation: "Set " + key + " to one of the allowed values",
121
+ });
122
+ }
123
+ }
124
+
125
+ var refAttrs = ["aria-labelledby", "aria-controls", "aria-describedby"];
126
+ for (var rai = 0; rai < refAttrs.length; rai++) {
127
+ var refKey = refAttrs[rai];
128
+ if (!(refKey in attrs)) continue;
129
+ var idsRefd = attrs[refKey].split(/\s+/).filter(Boolean);
130
+ for (var idi = 0; idi < idsRefd.length; idi++) {
131
+ if (!declaredIds[idsRefd[idi]]) {
132
+ _add({
133
+ sc: "4.1.2", level: "A", severity: "error",
134
+ element: tagName, line: pos.line, column: pos.column,
135
+ message: refKey + "=\"" + idsRefd[idi] + "\" references id that is not declared in the document",
136
+ remediation: "Either declare an element with id=\"" + idsRefd[idi] + "\" or remove the reference",
137
+ });
138
+ }
139
+ }
140
+ }
141
+
142
+ if (attrs["aria-hidden"] === "true") {
143
+ var interactive = ["a", "button", "input", "select", "textarea"].indexOf(tagName) !== -1;
144
+ var hasTabindex = "tabindex" in attrs && attrs.tabindex !== "-1";
145
+ if (interactive || hasTabindex) {
146
+ _add({
147
+ sc: "4.1.2", level: "A", severity: "error",
148
+ element: tagName, line: pos.line, column: pos.column,
149
+ message: "aria-hidden=\"true\" on interactive element",
150
+ remediation: "Remove aria-hidden, or remove from focus order via tabindex=\"-1\" (and disable interactivity)",
151
+ });
152
+ }
153
+ }
154
+ }
155
+
156
+ return findings;
157
+ }
158
+
159
+ module.exports = {
160
+ audit: audit,
161
+ KNOWN_ROLES: KNOWN_ROLES,
162
+ ROLE_REQUIRED_PROPS: ROLE_REQUIRED_PROPS,
163
+ ARIA_VALUE_SETS: ARIA_VALUE_SETS,
164
+ };