@blamejs/core 0.7.106 → 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.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.7.x
10
10
 
11
+ - **0.7.107** (2026-05-06) — `b.auth.sdJwtVc` — Selective Disclosure JWT for Verifiable Credentials per [draft-ietf-oauth-sd-jwt-vc](https://datatracker.ietf.org/doc/draft-ietf-oauth-sd-jwt-vc/), aligned with the EU Digital Identity Wallet (EUDI) roll-out and EU AI Act Art. 50 disclosure requirements. **`b.auth.sdJwtVc.issue({ issuer, vct, claims, selectivelyDisclosed, issuerKey, ... })`** mints an SD-JWT VC: per-claim disclosures (base64url-encoded `[salt, name, value]` JSON arrays) with their SHA-256 (or SHA3-256 / SHA-512 / SHA3-512) digests pinned in the issuer-signed `_sd` array; selectively-disclosed claims hidden from the issuer payload, plain claims passthrough; optional `cnf` claim pins the holder's public JWK for key-binding. Supported algorithms: ES256 (default per spec), ES384, EdDSA, ML-DSA-87 (PQC, framework's default), ML-DSA-65. **`present({ sdJwt, disclosedClaimNames, audience, nonce, holderKey })`** builds a holder presentation by selecting which disclosures to reveal; signs an optional Key Binding JWT (typ=`kb+jwt`) carrying the audience, nonce, iat, and an sd_hash binding it to the specific presentation. **`verify(presentation, { issuerKeyResolver, audience, nonce, expectedVct, requireKeyBinding })`** runs the full verification pipeline: issuer JWT signature, iat / exp / vct, per-disclosure digest match against the issuer's `_sd` array, optional KB-JWT signature + audience + nonce + sd_hash check. **`b.auth.sdJwtVc.issuer.create({ issuerUrl, keys, activeKid })`** — operator-side issuer factory with key-management + kid stamping + per-issuance audit emission + `rotateKey` support. **`b.auth.sdJwtVc.holder.create({ storage, holderKey })`** — wallet helper with operator-pluggable storage (production wires `b.db` / `b.objectStore`; built-in `memoryStorage()` for dev / tests); `store` / `present` / `list` / `get` / `delete` covers the full wallet lifecycle. **`b.auth.sdJwtVc.disclosure.encode / decode`** — pure helpers for the SD-JWT disclosure format. Audit emissions on every state transition (`auth.sdJwtVc.issued` / `auth.sdJwtVc.holder.stored` / `presented` / `deleted` / `auth.sdJwtVc.key_rotated`). 32 test cases covering disclosure round-trip, issue/verify happy path, signature tamper, expiration, vct mismatch, disclosure tamper, present subset, key binding (audience + nonce + sd_hash + replay defense), issuer factory + key rotation, holder factory + storage round-trip, hash-algorithm switching, plain-only credential.
12
+
11
13
  - **0.7.106** (2026-05-06) — `b.guardHtml.wcag` — WCAG 2.2 audit-only accessibility scanner. Pure static analysis (no rendering, no JS execution) emitting structured findings the operator wires into a CI gate / audit log / dev warning. **`b.guardHtml.wcag.audit(html, { level, ignore, allowedRoles, allowedAutocomplete, ... })`** returns `{ findings: [{ sc, level, severity, element, line, column, message, remediation }], summary: { error, warning, info }, score, totalFindings, scopeUrl, scannedAt }`. Page-level checks: `<html lang>` (3.1.1), `<title>` (2.4.2), skip-link (2.4.1). Element-level checks: `<img alt>` (1.1.1), input labels (3.3.2), input names (4.1.2), button text (4.1.2), anchor accessible name (2.4.4), heading order + empty heading (1.3.1). **`b.guardHtml.wcag.aria.audit(html)`** — WAI-ARIA 1.2 validation: unknown role values, missing required ARIA properties (e.g. `role="checkbox"` without `aria-checked`), aria-* values outside the spec value set (`aria-checked` ∉ `{true, false, mixed}`), unresolved `aria-labelledby` / `aria-controls` / `aria-describedby` references, and `aria-hidden="true"` on interactive elements. Operators with custom design systems extend the role registry via `allowedRoles`. **`b.guardHtml.wcag.tables.audit(html)`** — Table semantics: `<table>` without `<caption>` (data tables only — layout tables with `role="presentation"` skip the check), `<th>` without scope or with invalid scope value, `<tr>` outside table-context wrappers. **`b.guardHtml.wcag.forms.audit(html)`** — Form-specific: `<fieldset>` without `<legend>` (1.3.1), input `autocomplete=` value against the HTML 5.3 token registry (1.3.5), password input with `autocomplete="off"` (3.3.8 blocks password managers), input/email without autocomplete (3.3.7 redundant entry), `<textarea>` without label. Conformance level filter (`A` / `AA` / `AAA`); `ignore: ["1.4.3"]` for SC-by-SC opt-out; `skipAria` / `skipTables` / `skipForms` for module-level opt-out. Heuristic score (1 - weighted-violations / heuristic-max) for quick gauge. 51 test cases.
12
14
 
13
15
  - **0.7.105** (2026-05-06) — `b.compliance.sanctions` — sanctions-list screening primitive. Operators handling KYC / payment / customer-onboarding flows screen names against the U.S. Treasury OFAC SDN list, EU CSL, UK HMT consolidated list, UN 1267 list, or operator-defined lists. The framework owns indexing + match algorithm; the operator owns the daily fetch + format-specific parsing (the framework intentionally does not vendor the list — it changes daily and has legal-distribution implications). **`b.compliance.sanctions.create({ entries, algorithm, fuzzy, ... })`** returns a screener with `screen(input)` (single record), `screenBulk(inputs)` (batch), `snapshot()` (rule-version digest for audit trails), `reload(newEntries)` (atomic index swap with diff), `entryById(id)` (lookup), and `size()`. Three match strategies: `exact` (fastest, no fuzz), `jaro-winkler` (default, threshold 0.85), `levenshtein` (edit-distance with cap). Match output: `{ match: bool, hits: [{ entryId, name, matchedOn, score, reason, listed, programs }], algorithm, ruleVersion, screenedAt }`. **`b.compliance.sanctions.fuzzy`** — pure algorithmic core: `normalize` (Unicode diacritic strip + lowercase + whitespace collapse), `tokenize`, `levenshtein` (cap + early-exit), `jaro` / `jaroWinkler`, `tokenSetSimilarity` (order-invariant bag-of-tokens), `substringContains` (token-bounded), `initialsMatch`. **`b.compliance.sanctions.aliases.expand(name, opts)`** — alias-expansion helper covering nicknames (Bill ↔ William, Mike ↔ Michael), transliteration variants (Mohamed ↔ Mohammed), reverse-order forms (Smith John / Smith, John), and initials (J. Smith). 32 built-in name pairs plus operator-extensible `extraPairs`. **`b.compliance.sanctions.fetcher.create({ screener, fetch, intervalMs, onRefreshed, onError })`** — periodic refresh worker that runs the operator's `fetch` callback, validates a non-empty result, and atomically reloads the screener via `screener.reload`. Audit emissions on every refresh state (`compliance.sanctions.refresh.started` / `completed` / `skipped` / `failed`). **Parser shims** for the canonical public list formats: `parseOfacCsvRow` / `parseOfacAliasRow` / `mergeAliases` (OFAC SDN), `parseEuCslEntry` (EU Consolidated Sanctions List XML), `parseUn1267Entry` (UN Security Council XML). Audit emissions: `compliance.sanctions.screened` (every screen call), `compliance.sanctions.matched` (when hits > 0). Test coverage: 39 cases across normalize / tokenize / Levenshtein / Jaro-Winkler / token-set / substring / initials / screen exact + jw + levenshtein / type filter / bulk / snapshot / reload / alias expansion / fetcher tick + failure modes.
package/index.js CHANGED
@@ -140,6 +140,7 @@ var auth = {
140
140
  dpop: require("./lib/auth/dpop"),
141
141
  aal: require("./lib/auth/aal"),
142
142
  statusList: require("./lib/auth/status-list"),
143
+ sdJwtVc: require("./lib/auth/sd-jwt-vc"),
143
144
  };
144
145
  var template = require("./lib/template");
145
146
  var render = require("./lib/render");
@@ -0,0 +1,95 @@
1
+ "use strict";
2
+ /**
3
+ * SD-JWT VC disclosure encoding/decoding helper.
4
+ *
5
+ * Per draft-ietf-oauth-sd-jwt-vc §4.1, a disclosure is the
6
+ * base64url-encoded JSON serialisation of one of:
7
+ *
8
+ * For object members: [<salt>, <claim_name>, <claim_value>]
9
+ * For array elements: [<salt>, <array_element>]
10
+ *
11
+ * The salt is a 128-bit random value (base64url) preventing dictionary
12
+ * attacks on the disclosure digests. Operators that need deterministic
13
+ * salt for testing pass opts.saltSource.
14
+ */
15
+
16
+ var nodeCrypto = require("node:crypto");
17
+ var safeJson = require("../safe-json");
18
+ var { AuthError } = require("../framework-error");
19
+
20
+ var DEFAULT_SALT_BYTES = 16; // allow:raw-byte-literal — 128-bit salt per spec recommendation
21
+
22
+ function _newSalt(opts) {
23
+ if (opts && opts.saltSource && typeof opts.saltSource === "function") {
24
+ var s = opts.saltSource();
25
+ if (typeof s !== "string" || s.length === 0) {
26
+ throw new AuthError("auth-sd-jwt-vc/bad-salt",
27
+ "saltSource must return a non-empty string");
28
+ }
29
+ return s;
30
+ }
31
+ var bytes = nodeCrypto.randomBytes(DEFAULT_SALT_BYTES);
32
+ return bytes.toString("base64url");
33
+ }
34
+
35
+ function encode(claimName, claimValue, opts) {
36
+ if (typeof claimName !== "string" || claimName.length === 0) {
37
+ throw new AuthError("auth-sd-jwt-vc/bad-claim-name",
38
+ "encode: claimName must be a non-empty string");
39
+ }
40
+ if (claimValue === undefined) {
41
+ throw new AuthError("auth-sd-jwt-vc/bad-claim-value",
42
+ "encode: claimValue must not be undefined");
43
+ }
44
+ var salt = _newSalt(opts);
45
+ var arr = [salt, claimName, claimValue];
46
+ var jsonStr = safeJson.stringify(arr);
47
+ return Buffer.from(jsonStr, "utf8").toString("base64url");
48
+ }
49
+
50
+ function encodeArrayElement(elementValue, opts) {
51
+ if (elementValue === undefined) {
52
+ throw new AuthError("auth-sd-jwt-vc/bad-element-value",
53
+ "encodeArrayElement: elementValue must not be undefined");
54
+ }
55
+ var salt = _newSalt(opts);
56
+ var arr = [salt, elementValue];
57
+ var jsonStr = safeJson.stringify(arr);
58
+ return Buffer.from(jsonStr, "utf8").toString("base64url");
59
+ }
60
+
61
+ function decode(disclosureStr) {
62
+ if (typeof disclosureStr !== "string" || disclosureStr.length === 0) {
63
+ return null;
64
+ }
65
+ var jsonStr;
66
+ try { jsonStr = Buffer.from(disclosureStr, "base64url").toString("utf8"); }
67
+ catch (_e) { return null; }
68
+ var parsed;
69
+ try { parsed = safeJson.parse(jsonStr, { maxBytes: 64 * 1024 }); } // allow:raw-byte-literal — disclosure cap (64 KB)
70
+ catch (_e) { return null; }
71
+ if (!Array.isArray(parsed)) return null;
72
+ if (parsed.length === 3) {
73
+ return {
74
+ salt: parsed[0],
75
+ name: parsed[1],
76
+ value: parsed[2],
77
+ isElement: false,
78
+ };
79
+ }
80
+ if (parsed.length === 2) {
81
+ return {
82
+ salt: parsed[0],
83
+ value: parsed[1],
84
+ isElement: true,
85
+ };
86
+ }
87
+ return null;
88
+ }
89
+
90
+ module.exports = {
91
+ encode: encode,
92
+ encodeArrayElement: encodeArrayElement,
93
+ decode: decode,
94
+ DEFAULT_SALT_BYTES: DEFAULT_SALT_BYTES,
95
+ };
@@ -0,0 +1,203 @@
1
+ "use strict";
2
+ /**
3
+ * b.auth.sdJwtVc.holder — operator-side holder/wallet helper.
4
+ *
5
+ * Wraps the lower-level present() function with key-management +
6
+ * stored-credential lookup + per-presentation audit emission.
7
+ * Operators running a wallet (mobile app backend, OIDC4VP relying
8
+ * party with holder role) instantiate one of these per holder.
9
+ *
10
+ * var holder = b.auth.sdJwtVc.holder.create({
11
+ * storage: holderStorageBackend, // operator-side persistence
12
+ * holderKey: keyPemOrJwk,
13
+ * algorithm: "ES256",
14
+ * auditOn: true,
15
+ * });
16
+ *
17
+ * // Save a credential the wallet just received from an issuer
18
+ * await holder.store({
19
+ * id: "cred-1",
20
+ * sdJwt: receivedFromIssuer.token,
21
+ * vct: "https://example.com/vct/identity",
22
+ * issuer: "https://issuer.example.com",
23
+ * });
24
+ *
25
+ * // Build a presentation for a verifier request
26
+ * var presentation = await holder.present({
27
+ * credentialId: "cred-1",
28
+ * disclosedClaimNames: ["given_name"],
29
+ * audience: "https://verifier.example.com",
30
+ * nonce: nonceFromVerifier,
31
+ * });
32
+ *
33
+ * // List stored credentials
34
+ * var creds = await holder.list();
35
+ *
36
+ * // Delete a credential (on revocation / user request / DSR erasure)
37
+ * await holder.delete("cred-1");
38
+ *
39
+ * Storage shape (operator implements):
40
+ * { put(id, record), get(id), list(), delete(id) }
41
+ *
42
+ * The framework also ships `memoryStorage()` for development /
43
+ * tests — production operators wire b.db / b.objectStore.
44
+ */
45
+
46
+ var lazyRequire = require("../lazy-require");
47
+ var validateOpts = require("../validate-opts");
48
+ var { AuthError } = require("../framework-error");
49
+
50
+ var sdJwtVcCore = lazyRequire(function () { return require("./sd-jwt-vc"); });
51
+ var audit = lazyRequire(function () { return require("../audit"); });
52
+ var observability = lazyRequire(function () { return require("../observability"); });
53
+
54
+ function _validateStorage(storage) {
55
+ if (!storage || typeof storage !== "object") return false;
56
+ return ["put", "get", "list", "delete"].every(function (m) {
57
+ return typeof storage[m] === "function";
58
+ });
59
+ }
60
+
61
+ function memoryStorage() {
62
+ var byId = new Map();
63
+ return {
64
+ put: async function (id, record) {
65
+ byId.set(id, Object.assign({}, record, { id: id }));
66
+ },
67
+ get: async function (id) {
68
+ var r = byId.get(id);
69
+ return r ? Object.assign({}, r) : null;
70
+ },
71
+ list: async function () {
72
+ return Array.from(byId.values()).map(function (r) {
73
+ return Object.assign({}, r);
74
+ });
75
+ },
76
+ delete: async function (id) {
77
+ var existed = byId.has(id);
78
+ byId.delete(id);
79
+ return existed;
80
+ },
81
+ _size: function () { return byId.size; },
82
+ };
83
+ }
84
+
85
+ function create(opts) {
86
+ validateOpts.requireObject(opts, "auth.sdJwtVc.holder.create", AuthError);
87
+ validateOpts(opts, [
88
+ "storage", "holderKey", "algorithm", "auditOn",
89
+ ], "auth.sdJwtVc.holder.create");
90
+
91
+ if (!_validateStorage(opts.storage)) {
92
+ throw new AuthError("auth-sd-jwt-vc/bad-storage",
93
+ "holder.create: storage must implement { put, get, list, delete }");
94
+ }
95
+ if (!opts.holderKey) {
96
+ throw new AuthError("auth-sd-jwt-vc/no-key",
97
+ "holder.create: holderKey required");
98
+ }
99
+ var algorithm = opts.algorithm || "ES256";
100
+ var auditOn = opts.auditOn !== false;
101
+
102
+ function _emitAudit(action, outcome, metadata) {
103
+ if (!auditOn) return;
104
+ try {
105
+ audit().safeEmit({
106
+ action: action,
107
+ outcome: outcome,
108
+ metadata: metadata || {},
109
+ });
110
+ } catch (_e) { /* drop-silent */ }
111
+ }
112
+
113
+ function _emitMetric(verb) {
114
+ try { observability().safeEvent("auth.sdJwtVc.holder." + verb, 1, {}); }
115
+ catch (_e) { /* drop-silent */ }
116
+ }
117
+
118
+ async function store(spec) {
119
+ if (!spec || typeof spec !== "object") {
120
+ throw new AuthError("auth-sd-jwt-vc/bad-spec",
121
+ "holder.store: spec must be an object");
122
+ }
123
+ if (typeof spec.id !== "string" || spec.id.length === 0) {
124
+ throw new AuthError("auth-sd-jwt-vc/bad-id",
125
+ "holder.store: id is required");
126
+ }
127
+ if (typeof spec.sdJwt !== "string") {
128
+ throw new AuthError("auth-sd-jwt-vc/bad-token",
129
+ "holder.store: sdJwt is required");
130
+ }
131
+ var record = {
132
+ id: spec.id,
133
+ sdJwt: spec.sdJwt,
134
+ vct: spec.vct || null,
135
+ issuer: spec.issuer || null,
136
+ receivedAt: Date.now(),
137
+ };
138
+ await opts.storage.put(spec.id, record);
139
+ _emitAudit("auth.sdJwtVc.holder.stored", "success", {
140
+ id: spec.id, vct: record.vct, issuer: record.issuer,
141
+ });
142
+ _emitMetric("stored");
143
+ return record;
144
+ }
145
+
146
+ async function present(spec) {
147
+ if (!spec || typeof spec !== "object") {
148
+ throw new AuthError("auth-sd-jwt-vc/bad-spec",
149
+ "holder.present: spec must be an object");
150
+ }
151
+ var record = await opts.storage.get(spec.credentialId);
152
+ if (!record) {
153
+ throw new AuthError("auth-sd-jwt-vc/credential-not-found",
154
+ "holder.present: credentialId \"" + spec.credentialId + "\" not found in storage");
155
+ }
156
+ var presentation = sdJwtVcCore().present({
157
+ sdJwt: record.sdJwt,
158
+ disclosedClaimNames: spec.disclosedClaimNames || [],
159
+ audience: spec.audience || null,
160
+ nonce: spec.nonce || null,
161
+ holderKey: opts.holderKey,
162
+ algorithm: algorithm,
163
+ });
164
+ _emitAudit("auth.sdJwtVc.holder.presented", "success", {
165
+ credentialId: spec.credentialId,
166
+ audience: spec.audience || null,
167
+ disclosed: (spec.disclosedClaimNames || []).length,
168
+ });
169
+ _emitMetric("presented");
170
+ return presentation;
171
+ }
172
+
173
+ async function list() {
174
+ var rows = await opts.storage.list();
175
+ return Array.isArray(rows) ? rows : [];
176
+ }
177
+
178
+ async function get(id) {
179
+ return await opts.storage.get(id);
180
+ }
181
+
182
+ async function _delete(id) {
183
+ var existed = await opts.storage.delete(id);
184
+ if (existed) {
185
+ _emitAudit("auth.sdJwtVc.holder.deleted", "success", { id: id });
186
+ _emitMetric("deleted");
187
+ }
188
+ return existed;
189
+ }
190
+
191
+ return {
192
+ store: store,
193
+ present: present,
194
+ list: list,
195
+ get: get,
196
+ delete: _delete,
197
+ };
198
+ }
199
+
200
+ module.exports = {
201
+ create: create,
202
+ memoryStorage: memoryStorage,
203
+ };
@@ -0,0 +1,197 @@
1
+ "use strict";
2
+ /**
3
+ * b.auth.sdJwtVc.issuer — operator-side SD-JWT VC issuer factory.
4
+ *
5
+ * Wraps the lower-level issue() function with key-management + key-id
6
+ * (kid) + per-issuance audit emission. Operators running an issuer
7
+ * service (EUDI Wallet provider, OIDC4VCI issuer, internal credential
8
+ * service) instantiate one of these per signing key.
9
+ *
10
+ * var issuer = b.auth.sdJwtVc.issuer.create({
11
+ * issuerUrl: "https://issuer.example.com",
12
+ * keys: [
13
+ * { kid: "issuer-2026-q2", privateKey: pem, algorithm: "ES256" },
14
+ * ],
15
+ * activeKid: "issuer-2026-q2",
16
+ * defaultTtlMs: C.TIME.days(90),
17
+ * auditOn: true,
18
+ * });
19
+ *
20
+ * var sdJwt = await issuer.issue({
21
+ * vct: "https://example.com/vct/identity",
22
+ * subject: "did:web:alice",
23
+ * claims: {
24
+ * given_name: "Alice",
25
+ * family_name: "Smith",
26
+ * birthdate: "1990-01-15",
27
+ * },
28
+ * selectivelyDisclosed: ["given_name", "family_name", "birthdate"],
29
+ * holderKey: holderJwk,
30
+ * });
31
+ *
32
+ * // Key rollover (rotate to a new signing key)
33
+ * issuer.rotateKey({ kid: "issuer-2026-q3", privateKey: pem2 });
34
+ *
35
+ * // Operator-side audit / metering
36
+ * var stats = issuer.stats(); // { issued, lastIssuedAt, keys: [...] }
37
+ *
38
+ * Audit emissions (audit namespace `auth`):
39
+ * auth.sdJwtVc.issued — every successful issue() call
40
+ * auth.sdJwtVc.key_rotated — every rotateKey() invocation
41
+ */
42
+
43
+ var C = require("../constants");
44
+ var lazyRequire = require("../lazy-require");
45
+ var validateOpts = require("../validate-opts");
46
+ var { AuthError } = require("../framework-error");
47
+
48
+ // Lazy-required to avoid the issuer ↔ core circular load: sd-jwt-vc.js
49
+ // requires sd-jwt-vc-issuer.js for its module.exports surface, and
50
+ // the issuer needs sd-jwt-vc.js's issue() function for the actual
51
+ // signing path.
52
+ var sdJwtVcCore = lazyRequire(function () { return require("./sd-jwt-vc"); });
53
+
54
+ var audit = lazyRequire(function () { return require("../audit"); });
55
+ var observability = lazyRequire(function () { return require("../observability"); });
56
+
57
+ function create(opts) {
58
+ validateOpts.requireObject(opts, "auth.sdJwtVc.issuer.create", AuthError);
59
+ validateOpts(opts, [
60
+ "issuerUrl", "keys", "activeKid",
61
+ "defaultTtlMs", "defaultHashAlg", "auditOn",
62
+ ], "auth.sdJwtVc.issuer.create");
63
+
64
+ validateOpts.requireNonEmptyString(opts.issuerUrl,
65
+ "issuer.create: issuerUrl", AuthError, "auth-sd-jwt-vc/bad-opts");
66
+ if (!Array.isArray(opts.keys) || opts.keys.length === 0) {
67
+ throw new AuthError("auth-sd-jwt-vc/no-keys",
68
+ "issuer.create: keys must be a non-empty array");
69
+ }
70
+ for (var i = 0; i < opts.keys.length; i++) {
71
+ var k = opts.keys[i];
72
+ if (!k || typeof k !== "object") {
73
+ throw new AuthError("auth-sd-jwt-vc/bad-key",
74
+ "issuer.create: keys[" + i + "] must be an object");
75
+ }
76
+ if (typeof k.kid !== "string" || k.kid.length === 0) {
77
+ throw new AuthError("auth-sd-jwt-vc/bad-key",
78
+ "issuer.create: keys[" + i + "].kid is required");
79
+ }
80
+ if (!k.privateKey) {
81
+ throw new AuthError("auth-sd-jwt-vc/bad-key",
82
+ "issuer.create: keys[" + i + "].privateKey is required");
83
+ }
84
+ }
85
+ validateOpts.optionalPositiveFinite(opts.defaultTtlMs,
86
+ "issuer.create: defaultTtlMs", AuthError, "auth-sd-jwt-vc/bad-opts");
87
+
88
+ var keysByKid = Object.create(null);
89
+ for (var j = 0; j < opts.keys.length; j++) {
90
+ keysByKid[opts.keys[j].kid] = opts.keys[j];
91
+ }
92
+ var activeKid = opts.activeKid || opts.keys[0].kid;
93
+ if (!keysByKid[activeKid]) {
94
+ throw new AuthError("auth-sd-jwt-vc/bad-active-kid",
95
+ "issuer.create: activeKid \"" + activeKid + "\" is not in the keys array");
96
+ }
97
+ var defaultTtlMs = opts.defaultTtlMs || C.TIME.days(90);
98
+ var defaultHashAlg = opts.defaultHashAlg || "sha-256";
99
+ var auditOn = opts.auditOn !== false;
100
+
101
+ var stats = {
102
+ issued: 0,
103
+ lastIssuedAt: null,
104
+ keysRotated: 0,
105
+ };
106
+
107
+ function _emitAudit(action, outcome, metadata) {
108
+ if (!auditOn) return;
109
+ try {
110
+ audit().safeEmit({
111
+ action: action,
112
+ outcome: outcome,
113
+ metadata: metadata || {},
114
+ });
115
+ } catch (_e) { /* drop-silent */ }
116
+ }
117
+
118
+ function _emitMetric(verb) {
119
+ try { observability().safeEvent("auth.sdJwtVc.issuer." + verb, 1, {}); }
120
+ catch (_e) { /* drop-silent */ }
121
+ }
122
+
123
+ async function issue(spec) {
124
+ if (!spec || typeof spec !== "object") {
125
+ throw new AuthError("auth-sd-jwt-vc/bad-spec",
126
+ "issuer.issue: spec must be an object");
127
+ }
128
+ if (typeof spec.vct !== "string") {
129
+ throw new AuthError("auth-sd-jwt-vc/bad-spec",
130
+ "issuer.issue: vct is required");
131
+ }
132
+ var key = keysByKid[activeKid];
133
+ var issued = sdJwtVcCore().issue({
134
+ issuer: opts.issuerUrl,
135
+ subject: spec.subject || null,
136
+ vct: spec.vct,
137
+ claims: spec.claims || {},
138
+ selectivelyDisclosed: spec.selectivelyDisclosed || [],
139
+ issuerKey: key.privateKey,
140
+ algorithm: key.algorithm || "ES256",
141
+ hashAlg: spec.hashAlg || defaultHashAlg,
142
+ ttlMs: spec.ttlMs || defaultTtlMs,
143
+ holderKey: spec.holderKey || null,
144
+ issuedAt: spec.issuedAt,
145
+ extraHeader: { kid: activeKid },
146
+ });
147
+ stats.issued += 1;
148
+ stats.lastIssuedAt = Date.now();
149
+ _emitAudit("auth.sdJwtVc.issued", "success", {
150
+ kid: activeKid,
151
+ vct: spec.vct,
152
+ subject: spec.subject || null,
153
+ disclosed: (spec.selectivelyDisclosed || []).length,
154
+ });
155
+ _emitMetric("issued");
156
+ return issued;
157
+ }
158
+
159
+ function rotateKey(newKey) {
160
+ if (!newKey || typeof newKey !== "object" ||
161
+ typeof newKey.kid !== "string" || !newKey.privateKey) {
162
+ throw new AuthError("auth-sd-jwt-vc/bad-key",
163
+ "rotateKey: must pass { kid, privateKey, algorithm? }");
164
+ }
165
+ keysByKid[newKey.kid] = newKey;
166
+ activeKid = newKey.kid;
167
+ stats.keysRotated += 1;
168
+ _emitAudit("auth.sdJwtVc.key_rotated", "success", {
169
+ newKid: newKey.kid,
170
+ });
171
+ _emitMetric("key_rotated");
172
+ }
173
+
174
+ function listKids() { return Object.keys(keysByKid); }
175
+
176
+ function statsSnapshot() {
177
+ return {
178
+ issued: stats.issued,
179
+ lastIssuedAt: stats.lastIssuedAt,
180
+ keysRotated: stats.keysRotated,
181
+ activeKid: activeKid,
182
+ kids: listKids(),
183
+ };
184
+ }
185
+
186
+ return {
187
+ issue: issue,
188
+ rotateKey: rotateKey,
189
+ listKids: listKids,
190
+ stats: statsSnapshot,
191
+ issuerUrl: opts.issuerUrl,
192
+ };
193
+ }
194
+
195
+ module.exports = {
196
+ create: create,
197
+ };
@@ -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
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.7.106",
3
+ "version": "0.7.107",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.5",
5
- "serialNumber": "urn:uuid:7d9604c0-8d11-4b2c-bc59-69f6fcf1acf6",
5
+ "serialNumber": "urn:uuid:31469bcd-ab0f-4150-bfe2-c7d79a72a1c7",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-06T11:52:08.561Z",
8
+ "timestamp": "2026-05-06T12:31:16.570Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/core@0.7.106",
22
+ "bom-ref": "@blamejs/core@0.7.107",
23
23
  "type": "library",
24
24
  "name": "blamejs",
25
- "version": "0.7.106",
25
+ "version": "0.7.107",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "The Node framework that owns its stack.",
29
- "purl": "pkg:npm/%40blamejs/core@0.7.106",
29
+ "purl": "pkg:npm/%40blamejs/core@0.7.107",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/core@0.7.106",
57
+ "ref": "@blamejs/core@0.7.107",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]