@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.
- package/CHANGELOG.md +4 -0
- package/index.js +1 -0
- package/lib/auth/sd-jwt-vc-disclosure.js +95 -0
- package/lib/auth/sd-jwt-vc-holder.js +203 -0
- package/lib/auth/sd-jwt-vc-issuer.js +197 -0
- package/lib/auth/sd-jwt-vc.js +526 -0
- package/lib/guard-html-wcag-aria.js +164 -0
- package/lib/guard-html-wcag-forms.js +144 -0
- package/lib/guard-html-wcag-tables.js +154 -0
- package/lib/guard-html-wcag-tagwalk.js +44 -0
- package/lib/guard-html-wcag.js +470 -0
- package/lib/guard-html.js +4 -0
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ 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
|
+
|
|
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.
|
|
14
|
+
|
|
11
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.
|
|
12
16
|
|
|
13
17
|
- **0.7.104** (2026-05-06) — `b.dsr` Data Subject Rights workflow primitive (~2000 LoC). End-to-end coordinator for GDPR Article 15-22 / CCPA / CPRA / LGPD / PIPEDA / UK-GDPR data-subject requests. **`b.dsr.create({ ticketStore, posture, identityResolver, sources, ... })`** returns a workflow instance with full ticket lifecycle: `submit(input)` resolves subject identity via the operator-supplied `identityResolver`, computes a posture-aware deadline (gdpr 30d / ccpa 45d / lgpd-br 15d / pipl-cn 15d / pipeda-ca 30d / appi-jp 30d / pdpa-sg 30d / uk-gdpr 30d), and persists a pending ticket. `process(ticketId, opts)` orchestrates per-source `query` (for access / portability / rectification) or `erase` (for erasure) callbacks; partial source failures land the ticket in `partially_completed` state with per-source error capture. `cancel` / `reject` (with required reason per GDPR) advance to terminal states. `expireOverdue()` sweep marks deadline-overdue tickets as `expired`. Seven request types: `access` / `erasure` / `portability` / `rectification` / `restriction` / `object` / `automated-decision`. **Verification ladder** (`minimal` / `secondary` / `strong`) per GDPR Art. 12(6) — minimum required level by request type with operator override; erasure / portability / rectification require `secondary` by default. **Receipt builder** (`buildReceipt(ticketId)`) — emits a canonical `blamejs.dsr.receipt/1` JSON envelope for completed/cancelled/rejected/expired tickets with optional operator-side `receiptSigner` hook for cryptographic attestation. **Portability bundle builder** (`buildPortabilityBundle(ticket)`) — `blamejs.dsr.portability/1` JSON shape with per-source data for access / portability requests. **Two ticket-store backends** ship: `memoryTicketStore()` for development / tests, `dbTicketStore({ db, table })` for production (auto-provisions a SQLite table with subject_email + status indexes, includes a `purgeExpired()` retention sweep). Audit emissions on every state transition (`dsr.ticket.submitted` / `in_progress` / `completed` / `partial` / `cancelled` / `rejected` / `expired` plus per-source `dsr.source.queried` / `erased` / `failed`). Test coverage: 38 cases across submit / process / cancel / reject / list / expire / portability / verification ladder / receipt / store backends.
|
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
|
+
};
|