@blamejs/core 0.12.51 → 0.12.53

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,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.12.x
10
10
 
11
+ - v0.12.53 (2026-05-25) — **`b.contentDigest` — HTTP Content-Digest / Repr-Digest fields (RFC 9530).** Emit and verify the Content-Digest / Repr-Digest HTTP fields so a recipient can detect a corrupted or tampered message body. b.contentDigest.create builds the RFC 8941 dictionary value (sha-256=:base64:, sha-512=:base64:) over a body; b.contentDigest.verify recomputes each modern digest over the body and compares it in constant time. Only SHA-256 and SHA-512 are computed — the legacy algorithms RFC 9530 §6 marks insecure (MD5, SHA-1, the unix checksums) are ignored on verify, and a field carrying no modern digest is refused, so an attacker cannot downgrade integrity to an MD5-only digest. Content-Digest is the integrity companion to HTTP Message Signatures (b.httpSig, RFC 9421): sign the digest rather than the whole body. Verified against the RFC 9530 Appendix D worked examples. **Added:** *`b.contentDigest.create(body, opts?)` / `b.contentDigest.verify(fieldValue, body, opts?)`* — `create` returns a Content-Digest / Repr-Digest field value over the body — SHA-256 by default, or any subset of `["sha-256","sha-512"]` via `opts.algorithms` — and refuses insecure or unknown algorithms. `verify` parses the field, recomputes each SHA-256 / SHA-512 entry over the body, and compares constant-time; it throws `content-digest/mismatch` on any mismatch, ignores legacy / unknown entries, throws `content-digest/no-modern-digest` if the field has no SHA-256 / SHA-512 entry at all, and honours `opts.required` to force specific algorithms to be present and match. Composes the framework's structured-field helpers and constant-time compare; Repr-Digest is the same machinery over the selected representation (RFC 9110).
12
+
13
+ - v0.12.52 (2026-05-25) — **`b.privacyPass` — Privacy Pass origin-side token verification (RFC 9577 / 9578).** Anonymous, publicly verifiable authorization: an origin issues a WWW-Authenticate: PrivateToken challenge and verifies a presented token cryptographically, without learning who the client is and without a callback to the issuer. b.privacyPass implements the publicly verifiable token type 0x0002 (Blind RSA, 2048-bit): the token's authenticator is an RSA Blind Signature (RFC 9474) checked as RSASSA-PSS (SHA-384, 48-byte salt) over token_input = token_type ‖ nonce ‖ challenge_digest ‖ token_key_id, using only the issuer's public key. The token is bound to that key (token_key_id) and, optionally, to the challenge it answers, so a token minted for another origin is refused. Blind RSA is the algorithm Privacy Pass defines on the wire — like the DNSSEC / DANE verifiers it validates an external protocol's signatures rather than introducing classical crypto as a framework default. Verified against the RFC 9578 §8.2 test vector. **Added:** *`b.privacyPass.verifyToken(opts)` / `parseToken` / `buildChallenge`* — `buildChallenge` builds a TokenChallenge (RFC 9577 §2.1) and the matching `WWW-Authenticate: PrivateToken challenge=…, token-key=…` header an origin returns to request a token, scoped to an issuer (and optionally an origin and a 32-byte redemption context). `parseToken` splits a token into its fields (type / nonce / challenge_digest / token_key_id / authenticator). `verifyToken` verifies a type 0x0002 (Blind RSA) token: it confirms the token's `token_key_id` is the SHA-256 of the supplied issuer public key, optionally that its `challenge_digest` matches `opts.challenge`, and that the authenticator is a valid RSASSA-PSS signature over the token input. Refuses unknown / privately verifiable token types (the VOPRF type 0x0001 needs the issuer secret and is an issuer-side operation), key-id and challenge mismatches, and tampered authenticators. Marked experimental while the issuance protocols see deployment.
14
+
11
15
  - v0.12.51 (2026-05-25) — **`b.network.dns.dane.matchCertificate` — DANE / TLSA certificate matching (RFC 6698 / 7671).** Pin a service's certificate through DNS instead of a public CA. matchCertificate checks a server certificate against a set of TLSA records: the selected data — the full certificate (selector 0) or its subjectPublicKeyInfo (selector 1) — is hashed per the matching type (exact / SHA-256 / SHA-512) and compared in constant time to the record's association data. For a DANE-EE (usage 3) record a match is self-authenticating — the TLSA pins the key, so no public-CA path is needed (the common SMTP-DANE case, RFC 7672); for the PKIX usages a match is reported as necessary-but-not-sufficient so the caller still runs PKIX. This is the payoff of the DNSSEC verifier: verify the TLSA RRset with b.network.dns.dnssec, then match the certificate. Verified against a live DNSSEC-signed TLSA record and the matching server certificate. **Added:** *`b.network.dns.dane.matchCertificate(opts)`* — Matches a leaf certificate (and optional `chain`) against a TLSA RRset (`{ usage, selector, matchingType, data }`). Selector 0 hashes the full certificate DER, selector 1 the subjectPublicKeyInfo; matching type 0 is an exact comparison, 1 SHA-256, 2 SHA-512 (SHA-1 and any other type are refused, not guessed). End-entity usages (PKIX-EE 1, DANE-EE 3) match the leaf; trust-anchor usages (PKIX-TA 0, DANE-TA 2) match the leaf or any supplied chain certificate. Returns `{ ok, matched, daneAuthenticated, trustAnchorMatch, pkixRequired, matchedCertIndex }` — `daneAuthenticated` is true only for a DANE-EE match (the key is pinned, no CA needed); `pkixRequired` flags the PKIX usages. Throws `dane/no-match` when nothing matches, and refuses unknown usage / selector / matching values and unparseable certificates. Verify the TLSA RRset with `b.network.dns.dnssec` first — an unauthenticated TLSA record proves nothing.
12
16
 
13
17
  - v0.12.50 (2026-05-25) — **`b.network.dns.dnssec.verifyChain` — validate a DNSSEC delegation chain to a pinned root anchor.** Completes local DNSSEC verification: validate a full delegation chain from the root down to a zone against a pinned trust anchor (RFC 4035 §5), instead of trusting any single resolver. For each link, the zone's DNSKEY RRset must be self-signed by one of its keys, and that key must be vouched for either by a pinned anchor (at the root) or by a DS record served + signed by the already-trusted parent — so trust flows root → TLD → zone with no gap. The IANA root KSKs (KSK-2017 tag 20326, KSK-2024 tag 38696) ship as the default anchors; override with opts.trustAnchors for a private root. verifyChain returns the leaf zone's trusted DNSKEY set, which you then hand to verifyRrset / verifyDenial for the actual answer. Composes verifyRrset + verifyDs + the key tag; verified end-to-end against a live root→org chain. **Added:** *`b.network.dns.dnssec.verifyChain(opts)`* — Walks an ordered, root-first list of `links` ({ zone, dnskeys, dnskeyRrsig, dsRdatas?, dsRrsig? }). At each link it verifies the DNSKEY RRset's self-signature (composing `verifyRrset`), then establishes trust in the signing key: at the root by matching a pinned anchor's DS digest (`verifyDs`), at every delegation by verifying the parent-served DS RRset's signature with the already-trusted parent key and confirming the signing KSK matches one of those DS records. Returns `{ ok, zone, keys, path }` with the leaf zone's trusted DNSKEY set. Refuses a root key that matches no anchor (`dnssec/chain-anchor-mismatch`), a KSK that matches no parent DS (`dnssec/chain-ds-mismatch`), and a missing parent key (`dnssec/chain-no-parent-key`). The default `DEFAULT_ROOT_ANCHORS` are the published IANA root KSK DS records; `opts.trustAnchors` overrides them for a private or test root.
package/README.md CHANGED
@@ -89,6 +89,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
89
89
  - **Financial / Open Banking** — FAPI 2.0 Final composite posture (PAR + PKCE-S256 + DPoP-or-mTLS + RFC 9207); runtime enforcement helpers `b.fapi2.assertCallback` (refuses missing iss + bare-param under message-signing) and `b.fapi2.assertAuthzRequest` (refuses non-JAR); CFPB §1033 / FDX 6.0 consumer-financial-data-sharing wrapper (`b.fdx`)
90
90
  - **Data-subject coordination** — cross-table export / rectify / erase / restrict / objection (`b.subject`, `b.subject.eraseHard`); subject-level legal-hold registry consulted by erase + retention paths (FRCP Rule 26/37(e), GDPR Art 17(3)(e), SEC Rule 17a-4, HIPAA §164.530(j)(2)) (`b.legalHold`)
91
91
  - **Account safety** — adaptive bot-challenge staircase (`b.authBotChallenge`); session-to-device-posture binding with fail-closed verify (`b.sessionDeviceBinding`)
92
+ - **Anonymous authorization** — Privacy Pass origin side (RFC 9577/9578 — `b.privacyPass`): issue a `WWW-Authenticate: PrivateToken` challenge and verify a presented Blind-RSA (type 0x0002) token against the issuer public key, with no issuer callback and no client identity
92
93
  ### Crypto
93
94
 
94
95
  - **At-rest envelope** — envelope-versioned PQC (ML-KEM-1024 + P-384 hybrid, XChaCha20-Poly1305, SHAKE256); vault sealing (`b.crypto`, `b.vault`)
@@ -96,7 +97,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
96
97
  - **Field-level + crypto-shred** — `b.cryptoField.eraseRow`; per-column data residency tagging + per-row keys (`K_row = HKDF(K_table, rowId)`) so erasing the per-row key makes WAL / replica residuals undecryptable (`b.cryptoField.declareColumnResidency`, `b.cryptoField.declarePerRowKey`)
97
98
  - **AAD-bound sealed columns** — AEAD tag tied to `(table, rowId, column, schemaVersion)`; copy-paste between rows or schema-version replay surfaces as refused decrypt (`b.vault.aad`)
98
99
  - **Signed webhooks + API encryption** — SLH-DSA-SHAKE-256f default; ML-DSA-65 opt-in; ECIES API encryption (`b.webhook`, `b.crypto`)
99
- - **HPKE / HTTP signatures** — RFC 9180 HPKE with ML-KEM-1024 + HKDF-SHA3-512 + ChaCha20-Poly1305 (`b.crypto.hpke`); RFC 9421 HTTP Message Signatures with derived components and ed25519 / ML-DSA-65 (`b.crypto.httpSig`)
100
+ - **HPKE / HTTP signatures** — RFC 9180 HPKE with ML-KEM-1024 + HKDF-SHA3-512 + ChaCha20-Poly1305 (`b.crypto.hpke`); RFC 9421 HTTP Message Signatures with derived components and ed25519 / ML-DSA-65 (`b.crypto.httpSig`); RFC 9530 Content-Digest / Repr-Digest body-integrity fields (SHA-256 / SHA-512, legacy algorithms refused — `b.contentDigest`) to sign the digest rather than the whole body
100
101
  - **CMS codec** — RFC 5652 Cryptographic Message Syntax encoder + decoder with PQC signers (ML-DSA-65 / ML-DSA-87 / SLH-DSA-SHAKE-256f; RFC 9909 + 9881) and KEMRecipientInfo recipients (ML-KEM-1024; RFC 9629 + 9936); ChaCha20-Poly1305 content encryption (RFC 8103) so Efail-class malleability cannot apply (`b.cms`)
101
102
  - **Stream throttle** — shared token-bucket bandwidth limiter (RFC 2697 srTCM shape); N concurrent `node:stream` pipelines draw from one operator-configured `bytesPerSec` budget (`b.streamThrottle`)
102
103
  - **TLS-RPT receiver** — RFC 8460 inbound aggregate-report ingest; HTTPS POST handler + §4.4 schema parser with gzip-bomb / ratio-bomb / depth-bomb defenses (`b.mail.deploy.parseTlsRptReport` / `b.mail.deploy.tlsRptIngestHttp`)
package/index.js CHANGED
@@ -394,6 +394,8 @@ var webPush = require("./lib/web-push-vapid");
394
394
  var fedcm = require("./lib/fedcm");
395
395
  var dbsc = require("./lib/dbsc");
396
396
  var importmapIntegrity = require("./lib/importmap-integrity");
397
+ var privacyPass = require("./lib/privacy-pass");
398
+ var contentDigest = require("./lib/content-digest");
397
399
  var standardWebhooks = require("./lib/standard-webhooks");
398
400
  var lro = require("./lib/lro");
399
401
  var jsonApi = require("./lib/jsonapi");
@@ -409,6 +411,8 @@ module.exports = {
409
411
  fedcm: fedcm,
410
412
  dbsc: dbsc,
411
413
  importmapIntegrity: importmapIntegrity,
414
+ privacyPass: privacyPass,
415
+ contentDigest: contentDigest,
412
416
  standardWebhooks: standardWebhooks,
413
417
  lro: lro,
414
418
  jsonApi: jsonApi,
@@ -0,0 +1,189 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.contentDigest
4
+ * @nav HTTP
5
+ * @title Content-Digest
6
+ *
7
+ * @intro
8
+ * HTTP Digest Fields (RFC 9530) — emit and verify the
9
+ * <code>Content-Digest</code> / <code>Repr-Digest</code> fields that
10
+ * carry a hash of a message body so a recipient can detect corruption
11
+ * or tampering in transit. The field is an RFC 8941 dictionary of
12
+ * <code>algorithm=:base64-digest:</code> entries; this module computes
13
+ * and checks the modern algorithms (SHA-256, SHA-512) and ignores the
14
+ * legacy ones (MD5, SHA-1, the unix checksums) that RFC 9530 §6 marks
15
+ * insecure — refusing to accept a body whose only digest is a legacy
16
+ * algorithm.
17
+ *
18
+ * Content-Digest is the integrity companion to HTTP Message Signatures
19
+ * (<code>b.httpSig</code>, RFC 9421): rather than signing a whole body,
20
+ * sign its <code>Content-Digest</code> and let this module bind the
21
+ * digest to the bytes.
22
+ *
23
+ * @card
24
+ * HTTP Content-Digest / Repr-Digest (RFC 9530). Emit and verify a
25
+ * SHA-256 / SHA-512 digest of a message body; legacy algorithms are
26
+ * ignored and a body with no modern digest is refused. Pairs with
27
+ * <code>b.httpSig</code> — sign the digest, not the bytes.
28
+ */
29
+
30
+ var nodeCrypto = require("node:crypto");
31
+ var bCrypto = require("./crypto");
32
+ var structuredFields = require("./structured-fields");
33
+ var validateOpts = require("./validate-opts");
34
+ var { defineClass } = require("./framework-error");
35
+
36
+ var ContentDigestError = defineClass("ContentDigestError", { alwaysPermanent: true });
37
+
38
+ // RFC 9530 IANA "Hash Algorithms for HTTP Digest Fields": Active vs
39
+ // (insecure) deprecated. Active algorithms map to a Node hash name.
40
+ var ACTIVE = { "sha-256": "sha256", "sha-512": "sha512" };
41
+ var DEPRECATED = { "md5": 1, "sha": 1, "unixsum": 1, "unixcksum": 1, "adler": 1, "crc32c": 1 };
42
+
43
+ // Decode an RFC 8941 Byte Sequence payload as STRICT, canonical base64.
44
+ // Node's base64 decoder silently drops invalid characters and tolerates
45
+ // bad padding, so `:<digest>!!!!:` or non-canonical padding would decode
46
+ // to the same bytes and wrongly verify — a real risk when the
47
+ // Content-Digest field is itself covered by an HTTP Message Signature.
48
+ // Decoding then re-encoding and requiring the exact input back rejects
49
+ // stray characters, whitespace, wrong padding, and non-zero trailing
50
+ // bits in one canonical check (Node always re-emits canonical base64).
51
+ function _strictBase64(s, what) {
52
+ if (typeof s !== "string" || s.length === 0) {
53
+ throw new ContentDigestError("content-digest/bad-field", "contentDigest: " + what + " is empty");
54
+ }
55
+ var buf = Buffer.from(s, "base64");
56
+ if (buf.length === 0 || buf.toString("base64") !== s) {
57
+ throw new ContentDigestError("content-digest/bad-field", "contentDigest: " + what + " is not canonical base64");
58
+ }
59
+ return buf;
60
+ }
61
+
62
+ function _bodyBytes(body, what) {
63
+ if (Buffer.isBuffer(body)) return body;
64
+ if (body instanceof Uint8Array) return Buffer.from(body);
65
+ if (typeof body === "string") return Buffer.from(body, "utf8");
66
+ throw new ContentDigestError("content-digest/bad-body", "contentDigest: " + what + " must be a Buffer / Uint8Array / string");
67
+ }
68
+
69
+ /**
70
+ * @primitive b.contentDigest.create
71
+ * @signature b.contentDigest.create(body, opts?)
72
+ * @since 0.12.53
73
+ * @status stable
74
+ * @compliance soc2
75
+ * @related b.contentDigest.verify, b.httpSig
76
+ *
77
+ * Build a <code>Content-Digest</code> (or <code>Repr-Digest</code>) field
78
+ * value over a message body (RFC 9530 §2): an RFC 8941 dictionary of
79
+ * <code>algorithm=:base64(digest):</code> members. Defaults to SHA-256;
80
+ * pass <code>algorithms</code> to emit several. Only the modern
81
+ * algorithms are offered — the digest is over the exact body bytes.
82
+ *
83
+ * @opts
84
+ * {
85
+ * algorithms: string[], // subset of ["sha-256","sha-512"]; default ["sha-256"]
86
+ * }
87
+ *
88
+ * @example
89
+ * b.contentDigest.create('{"hello": "world"}');
90
+ * // → "sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:"
91
+ */
92
+ function create(body, opts) {
93
+ opts = opts || {};
94
+ validateOpts.requireObject(opts, "contentDigest.create", ContentDigestError);
95
+ validateOpts(opts, ["algorithms"], "contentDigest.create");
96
+ var bytes = _bodyBytes(body, "body");
97
+ var algos = opts.algorithms === undefined ? ["sha-256"] : opts.algorithms;
98
+ if (!Array.isArray(algos) || algos.length === 0) throw new ContentDigestError("content-digest/bad-arg", "contentDigest.create: opts.algorithms must be a non-empty array");
99
+ var members = algos.map(function (a) {
100
+ var name = String(a).toLowerCase();
101
+ var nodeAlg = ACTIVE[name];
102
+ if (!nodeAlg) {
103
+ if (DEPRECATED[name]) throw new ContentDigestError("content-digest/insecure-algorithm", "contentDigest.create: '" + name + "' is a deprecated/insecure digest algorithm (RFC 9530 §6); use sha-256 or sha-512");
104
+ throw new ContentDigestError("content-digest/unsupported-algorithm", "contentDigest.create: unsupported digest algorithm '" + name + "'");
105
+ }
106
+ var digest = nodeCrypto.createHash(nodeAlg).update(bytes).digest("base64");
107
+ return name + "=:" + digest + ":"; // RFC 8941 Byte Sequence value
108
+ });
109
+ return members.join(", ");
110
+ }
111
+
112
+ /**
113
+ * @primitive b.contentDigest.verify
114
+ * @signature b.contentDigest.verify(fieldValue, body, opts?)
115
+ * @since 0.12.53
116
+ * @status stable
117
+ * @compliance soc2
118
+ * @related b.contentDigest.create, b.httpSig
119
+ *
120
+ * Verify a <code>Content-Digest</code> / <code>Repr-Digest</code> field
121
+ * value against a body (RFC 9530). Every modern (SHA-256 / SHA-512) entry
122
+ * is recomputed over the body and compared in constant time; a mismatch
123
+ * is refused. Legacy / unknown algorithms are ignored, but a field that
124
+ * carries <em>no</em> modern digest is refused (so an attacker cannot
125
+ * downgrade to an MD5-only digest). <code>opts.required</code> forces
126
+ * specific algorithms to be present and to match.
127
+ *
128
+ * @opts
129
+ * {
130
+ * required: string[], // algorithms that MUST be present and match (e.g. ["sha-256"])
131
+ * }
132
+ *
133
+ * @example
134
+ * b.contentDigest.verify("sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:", '{"hello": "world"}');
135
+ * // → { ok: true, verified: ["sha-256"] }
136
+ */
137
+ function verify(fieldValue, body, opts) {
138
+ opts = opts || {};
139
+ validateOpts.requireObject(opts, "contentDigest.verify", ContentDigestError);
140
+ validateOpts(opts, ["required"], "contentDigest.verify");
141
+ if (typeof fieldValue !== "string" || fieldValue.trim() === "") throw new ContentDigestError("content-digest/bad-field", "contentDigest.verify: fieldValue must be a non-empty string");
142
+ structuredFields.refuseControlBytes(fieldValue, { ErrorClass: ContentDigestError, code: "content-digest/bad-field", label: "contentDigest fieldValue" });
143
+ var bytes = _bodyBytes(body, "body");
144
+
145
+ var members = structuredFields.splitTopLevel(fieldValue, ",");
146
+ var seen = Object.create(null);
147
+ var verified = [];
148
+ for (var i = 0; i < members.length; i++) {
149
+ var m = members[i].trim();
150
+ if (m === "") continue;
151
+ var eq = m.indexOf("=");
152
+ if (eq < 1) throw new ContentDigestError("content-digest/bad-field", "contentDigest.verify: malformed dictionary member");
153
+ var name = m.slice(0, eq).trim().toLowerCase();
154
+ var raw = m.slice(eq + 1).trim();
155
+ var nodeAlg = ACTIVE[name];
156
+ if (!nodeAlg) continue; // ignore legacy / unknown entries
157
+ if (raw.length < 2 || raw.charAt(0) !== ":" || raw.charAt(raw.length - 1) !== ":") {
158
+ throw new ContentDigestError("content-digest/bad-field", "contentDigest.verify: '" + name + "' value is not an RFC 8941 byte sequence (:base64:)");
159
+ }
160
+ var claimed = _strictBase64(raw.slice(1, -1), name + " digest");
161
+ var actual = nodeCrypto.createHash(nodeAlg).update(bytes).digest();
162
+ if (!bCrypto.timingSafeEqual(actual, claimed)) {
163
+ throw new ContentDigestError("content-digest/mismatch", "contentDigest.verify: " + name + " digest does not match the body");
164
+ }
165
+ seen[name] = 1;
166
+ verified.push(name);
167
+ }
168
+
169
+ if (opts.required !== undefined && opts.required !== null) {
170
+ if (!Array.isArray(opts.required)) throw new ContentDigestError("content-digest/bad-arg", "contentDigest.verify: opts.required must be an array");
171
+ for (var r = 0; r < opts.required.length; r++) {
172
+ var req = String(opts.required[r]).toLowerCase();
173
+ if (!ACTIVE[req]) throw new ContentDigestError("content-digest/unsupported-algorithm", "contentDigest.verify: required algorithm '" + req + "' is not a modern digest");
174
+ if (!seen[req]) throw new ContentDigestError("content-digest/missing-algorithm", "contentDigest.verify: required digest '" + req + "' is not present");
175
+ }
176
+ }
177
+
178
+ if (verified.length === 0) {
179
+ throw new ContentDigestError("content-digest/no-modern-digest", "contentDigest.verify: no modern (sha-256 / sha-512) digest present — refusing to trust a legacy-only digest");
180
+ }
181
+ return { ok: true, verified: verified };
182
+ }
183
+
184
+ module.exports = {
185
+ create: create,
186
+ verify: verify,
187
+ ACTIVE_ALGORITHMS: Object.keys(ACTIVE),
188
+ ContentDigestError: ContentDigestError,
189
+ };
@@ -0,0 +1,267 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.privacyPass
4
+ * @nav Identity
5
+ * @title Privacy Pass
6
+ *
7
+ * @intro
8
+ * Origin / relying-party side of Privacy Pass (RFC 9577 HTTP
9
+ * authentication scheme, RFC 9578 issuance protocols) — issue a token
10
+ * challenge and verify a presented token without learning who the
11
+ * client is. An origin asks for a token with a
12
+ * <code>WWW-Authenticate: PrivateToken</code> challenge; the client
13
+ * obtains a token from an issuer and presents it; the origin verifies
14
+ * it cryptographically.
15
+ *
16
+ * This implements the publicly verifiable token type
17
+ * <strong>0x0002 (Blind RSA, 2048-bit)</strong>: the token's
18
+ * authenticator is an RSA Blind Signature (RFC 9474) that any party
19
+ * holding the issuer's public key can verify with RSASSA-PSS — so the
20
+ * origin verifies tokens itself, with no issuer secret and no callback.
21
+ * The privately verifiable VOPRF type (0x0001) requires the issuer's
22
+ * secret key and is an issuer-side operation, not implemented here.
23
+ *
24
+ * Blind RSA is the algorithm Privacy Pass defines on the wire; like
25
+ * the framework's DNSSEC / DANE verifiers it validates an external
26
+ * protocol's signatures (RSASSA-PSS, SHA-384) rather than introducing
27
+ * classical crypto as a framework default.
28
+ *
29
+ * @card
30
+ * Privacy Pass origin side (RFC 9577 / 9578). Issue a
31
+ * <code>WWW-Authenticate: PrivateToken</code> challenge and verify a
32
+ * presented Blind-RSA (type 0x0002) token against the issuer public
33
+ * key — anonymous, publicly verifiable authorization with no issuer
34
+ * callback.
35
+ */
36
+
37
+ var nodeCrypto = require("node:crypto");
38
+ var bCrypto = require("./crypto");
39
+ var validateOpts = require("./validate-opts");
40
+ var { defineClass } = require("./framework-error");
41
+
42
+ var PrivacyPassError = defineClass("PrivacyPassError", { alwaysPermanent: true });
43
+
44
+ var TOKEN_TYPE_BLIND_RSA = 0x0002;
45
+ // RFC 9578 §5.3 token type 0x0002: RSABSSA-SHA384-PSS, salt length 48.
46
+ var PSS_HASH = "sha384";
47
+ var PSS_SALT_LEN = 48; // allow:raw-byte-literal — RFC 9578 §5.3 PSS salt length (= SHA-384 digest size)
48
+ // Fixed-size token fields (RFC 9577 §2.2): type(2) nonce(32)
49
+ // challenge_digest(32) token_key_id(32), then the authenticator.
50
+ var TOKEN_PREFIX_LEN = 98; // allow:raw-byte-literal — 2 + 32 + 32 + 32 (token_input length)
51
+
52
+ // RFC 9577 §2.1 sends the challenge / token-key auth-params as base64url
53
+ // WITH padding; Node's "base64url" output is unpadded, so pad to a
54
+ // multiple of 4 so strict clients / proxies accept the header.
55
+ function _b64urlPadded(buf) {
56
+ var s = Buffer.from(buf).toString("base64url");
57
+ while (s.length % 4 !== 0) s += "="; // allow:raw-byte-literal — base64 quantum is 4 chars
58
+ return s;
59
+ }
60
+
61
+ function _bytes(x, what) {
62
+ if (Buffer.isBuffer(x)) return x;
63
+ if (x instanceof Uint8Array) return Buffer.from(x);
64
+ if (typeof x === "string") return Buffer.from(x, "base64");
65
+ throw new PrivacyPassError("privacy-pass/bad-bytes", "privacyPass: " + what + " must be a Buffer / Uint8Array / base64 string");
66
+ }
67
+
68
+ // Import the issuer public key and capture the SubjectPublicKeyInfo
69
+ // bytes used to derive token_key_id. When the caller supplies the
70
+ // published SPKI DER directly, hash THOSE bytes — re-exporting an
71
+ // rsa-pss KeyObject can re-encode the AlgorithmIdentifier and change the
72
+ // digest. token_key_id is SHA-256 of the issuer's distributed key
73
+ // (RFC 9577 §2.2), which is the SPKI as published.
74
+ function _importIssuerKey(k) {
75
+ if (k && typeof k === "object" && typeof k.export === "function" && k.type === "public") {
76
+ return { key: k, spki: k.export({ format: "der", type: "spki" }) };
77
+ }
78
+ try {
79
+ if (Buffer.isBuffer(k) || k instanceof Uint8Array) {
80
+ var der = Buffer.from(k);
81
+ return { key: nodeCrypto.createPublicKey({ key: der, format: "der", type: "spki" }), spki: der };
82
+ }
83
+ // A "PUBLIC KEY" PEM body IS the SubjectPublicKeyInfo DER — decode it
84
+ // directly so token_key_id is SHA-256 of the issuer's exact bytes,
85
+ // not a re-encoding (Node can re-emit rsa-pss AlgorithmIdentifier
86
+ // parameters differently on export).
87
+ if (typeof k === "string" && /-----BEGIN PUBLIC KEY-----/.test(k)) {
88
+ var body = k.replace(/-----BEGIN PUBLIC KEY-----/, "").replace(/-----END PUBLIC KEY-----/, "").replace(/\s+/g, "");
89
+ var pemDer = Buffer.from(body, "base64");
90
+ return { key: nodeCrypto.createPublicKey(k), spki: pemDer };
91
+ }
92
+ var key = nodeCrypto.createPublicKey(k); // other key spec (best-effort SPKI export)
93
+ return { key: key, spki: key.export({ format: "der", type: "spki" }) };
94
+ } catch (e) {
95
+ throw new PrivacyPassError("privacy-pass/bad-key", "privacyPass: could not import issuerPublicKey: " + ((e && e.message) || e));
96
+ }
97
+ }
98
+
99
+ /**
100
+ * @primitive b.privacyPass.parseToken
101
+ * @signature b.privacyPass.parseToken(token)
102
+ * @since 0.12.52
103
+ * @status experimental
104
+ * @related b.privacyPass.verifyToken, b.privacyPass.buildChallenge
105
+ *
106
+ * Parse a Privacy Pass token (RFC 9577 §2.2) into its fields: the
107
+ * <code>tokenType</code>, the client <code>nonce</code>, the
108
+ * <code>challengeDigest</code> (SHA-256 of the TokenChallenge the token
109
+ * answers), the <code>tokenKeyId</code> (SHA-256 of the issuer public
110
+ * key), and the <code>authenticator</code>. Structural only — call
111
+ * <code>verifyToken</code> to check the signature.
112
+ *
113
+ * @example
114
+ * var t = b.privacyPass.parseToken(tokenBytes);
115
+ * // → { tokenType: 2, nonce, challengeDigest, tokenKeyId, authenticator }
116
+ */
117
+ function parseToken(token) {
118
+ var b = _bytes(token, "token");
119
+ if (b.length < TOKEN_PREFIX_LEN + 1) throw new PrivacyPassError("privacy-pass/bad-token", "privacyPass.parseToken: token too short");
120
+ return {
121
+ tokenType: b.readUInt16BE(0),
122
+ nonce: b.slice(2, 34),
123
+ challengeDigest: b.slice(34, 66),
124
+ tokenKeyId: b.slice(66, 98),
125
+ authenticator: b.slice(98),
126
+ tokenInput: b.slice(0, TOKEN_PREFIX_LEN),
127
+ };
128
+ }
129
+
130
+ /**
131
+ * @primitive b.privacyPass.verifyToken
132
+ * @signature b.privacyPass.verifyToken(opts)
133
+ * @since 0.12.52
134
+ * @status experimental
135
+ * @compliance soc2
136
+ * @related b.privacyPass.buildChallenge, b.privacyPass.parseToken
137
+ *
138
+ * Verify a publicly verifiable Privacy Pass token (type 0x0002, Blind
139
+ * RSA — RFC 9578 §8.2). The authenticator is checked as an RSASSA-PSS
140
+ * (SHA-384, MGF1-SHA-384, 48-byte salt) signature over
141
+ * <code>token_input = token_type ‖ nonce ‖ challenge_digest ‖
142
+ * token_key_id</code> using the issuer's public key. The token is bound
143
+ * to that key — its <code>token_key_id</code> must equal the SHA-256 of
144
+ * the supplied key's SubjectPublicKeyInfo — and, when
145
+ * <code>opts.challenge</code> is given, to that challenge (its SHA-256
146
+ * must equal the token's <code>challenge_digest</code>), so a token
147
+ * minted for a different origin's challenge is refused.
148
+ *
149
+ * @opts
150
+ * {
151
+ * token: Buffer|base64, // the presented token
152
+ * issuerPublicKey: KeyObject|Buffer(SPKI DER)|PEM,
153
+ * challenge?: Buffer|base64, // the TokenChallenge this token must answer
154
+ * }
155
+ *
156
+ * @example
157
+ * var r = b.privacyPass.verifyToken({ token: tok, issuerPublicKey: issuerSpki });
158
+ * // → { ok: true, tokenType: 2, nonce, challengeDigest, tokenKeyId }
159
+ */
160
+ function verifyToken(opts) {
161
+ validateOpts.requireObject(opts, "privacyPass.verifyToken", PrivacyPassError);
162
+ validateOpts(opts, ["token", "issuerPublicKey", "challenge"], "privacyPass.verifyToken");
163
+ if (opts.issuerPublicKey === undefined || opts.issuerPublicKey === null) throw new PrivacyPassError("privacy-pass/bad-arg", "privacyPass.verifyToken: opts.issuerPublicKey is required");
164
+
165
+ var parsed = parseToken(opts.token);
166
+ if (parsed.tokenType !== TOKEN_TYPE_BLIND_RSA) {
167
+ throw new PrivacyPassError("privacy-pass/unsupported-token-type", "privacyPass.verifyToken: only token type 0x0002 (Blind RSA) is verifiable by the origin; got 0x" + parsed.tokenType.toString(16).padStart(4, "0")); // allow:raw-byte-literal — base-16 radix + 4-hex-digit pad, not a size
168
+ }
169
+
170
+ var imported = _importIssuerKey(opts.issuerPublicKey);
171
+ var key = imported.key;
172
+ if (key.asymmetricKeyType !== "rsa" && key.asymmetricKeyType !== "rsa-pss") {
173
+ throw new PrivacyPassError("privacy-pass/bad-key", "privacyPass.verifyToken: issuerPublicKey must be an RSA key for token type 0x0002");
174
+ }
175
+
176
+ // Bind the token to the issuer key: token_key_id = SHA-256(SPKI).
177
+ var keyId = nodeCrypto.createHash("sha256").update(imported.spki).digest();
178
+ if (!bCrypto.timingSafeEqual(keyId, parsed.tokenKeyId)) {
179
+ throw new PrivacyPassError("privacy-pass/key-id-mismatch", "privacyPass.verifyToken: token_key_id does not match the issuer public key");
180
+ }
181
+
182
+ // Bind the token to the challenge, when supplied.
183
+ if (opts.challenge !== undefined && opts.challenge !== null) {
184
+ var cd = nodeCrypto.createHash("sha256").update(_bytes(opts.challenge, "challenge")).digest();
185
+ if (!bCrypto.timingSafeEqual(cd, parsed.challengeDigest)) {
186
+ throw new PrivacyPassError("privacy-pass/challenge-mismatch", "privacyPass.verifyToken: challenge_digest does not match opts.challenge");
187
+ }
188
+ }
189
+
190
+ var ok;
191
+ try {
192
+ ok = nodeCrypto.verify(PSS_HASH, parsed.tokenInput, { key: key, padding: nodeCrypto.constants.RSA_PKCS1_PSS_PADDING, saltLength: PSS_SALT_LEN }, parsed.authenticator);
193
+ } catch (e) {
194
+ throw new PrivacyPassError("privacy-pass/verify-threw", "privacyPass.verifyToken: signature verification threw: " + ((e && e.message) || e));
195
+ }
196
+ if (!ok) throw new PrivacyPassError("privacy-pass/bad-authenticator", "privacyPass.verifyToken: token authenticator did not verify");
197
+ return { ok: true, tokenType: parsed.tokenType, nonce: parsed.nonce, challengeDigest: parsed.challengeDigest, tokenKeyId: parsed.tokenKeyId };
198
+ }
199
+
200
+ /**
201
+ * @primitive b.privacyPass.buildChallenge
202
+ * @signature b.privacyPass.buildChallenge(opts)
203
+ * @since 0.12.52
204
+ * @status experimental
205
+ * @related b.privacyPass.verifyToken
206
+ *
207
+ * Build a TokenChallenge (RFC 9577 §2.1) and the matching
208
+ * <code>WWW-Authenticate: PrivateToken</code> header value an origin
209
+ * returns to ask a client for a token. The challenge binds the token to
210
+ * this issuer (and optionally this origin and a redemption context);
211
+ * its SHA-256 is the <code>challenge_digest</code> that
212
+ * <code>verifyToken</code> checks.
213
+ *
214
+ * @opts
215
+ * {
216
+ * issuerName: string, // the token issuer's name
217
+ * tokenType?: number, // default 0x0002 (Blind RSA)
218
+ * originInfo?: string, // origin name(s) the token is scoped to (default: any)
219
+ * redemptionContext?: Buffer, // 0 or 32 bytes (default: empty)
220
+ * tokenKey?: Buffer|KeyObject, // issuer SPKI, included as token-key= when given
221
+ * }
222
+ *
223
+ * @example
224
+ * var c = b.privacyPass.buildChallenge({ issuerName: "issuer.example", originInfo: "origin.example" });
225
+ * res.setHeader("WWW-Authenticate", c.wwwAuthenticate);
226
+ */
227
+ function buildChallenge(opts) {
228
+ validateOpts.requireObject(opts, "privacyPass.buildChallenge", PrivacyPassError);
229
+ validateOpts(opts, ["issuerName", "tokenType", "originInfo", "redemptionContext", "tokenKey"], "privacyPass.buildChallenge");
230
+ if (typeof opts.issuerName !== "string" || opts.issuerName === "") throw new PrivacyPassError("privacy-pass/bad-arg", "privacyPass.buildChallenge: opts.issuerName is required");
231
+ var tokenType = opts.tokenType === undefined ? TOKEN_TYPE_BLIND_RSA : opts.tokenType;
232
+ if (typeof tokenType !== "number" || !Number.isInteger(tokenType) || tokenType < 0 || tokenType > 0xffff) throw new PrivacyPassError("privacy-pass/bad-arg", "privacyPass.buildChallenge: tokenType must be a uint16");
233
+
234
+ var issuer = Buffer.from(opts.issuerName, "utf8");
235
+ if (issuer.length > 0xffff) throw new PrivacyPassError("privacy-pass/bad-arg", "privacyPass.buildChallenge: issuerName too long");
236
+ var origin = Buffer.alloc(0);
237
+ if (opts.originInfo !== undefined && opts.originInfo !== null) {
238
+ if (typeof opts.originInfo !== "string") throw new PrivacyPassError("privacy-pass/bad-arg", "privacyPass.buildChallenge: originInfo must be a string");
239
+ origin = Buffer.from(opts.originInfo, "utf8");
240
+ if (origin.length > 0xffff) throw new PrivacyPassError("privacy-pass/bad-arg", "privacyPass.buildChallenge: originInfo too long");
241
+ }
242
+ var rc = opts.redemptionContext !== undefined && opts.redemptionContext !== null ? _bytes(opts.redemptionContext, "redemptionContext") : Buffer.alloc(0);
243
+ if (rc.length !== 0 && rc.length !== 32) throw new PrivacyPassError("privacy-pass/bad-arg", "privacyPass.buildChallenge: redemptionContext must be empty or 32 bytes"); // allow:raw-byte-literal — RFC 9577 redemption_context is 0 or 32 bytes
244
+
245
+ var u16 = function (n) { return Buffer.from([(n >> 8) & 0xff, n & 0xff]); };
246
+ var challenge = Buffer.concat([
247
+ u16(tokenType),
248
+ u16(issuer.length), issuer,
249
+ Buffer.from([rc.length]), rc,
250
+ u16(origin.length), origin,
251
+ ]);
252
+
253
+ var parts = ['PrivateToken challenge="' + _b64urlPadded(challenge) + '"'];
254
+ if (opts.tokenKey !== undefined && opts.tokenKey !== null) {
255
+ var spki = (opts.tokenKey && typeof opts.tokenKey.export === "function") ? opts.tokenKey.export({ format: "der", type: "spki" }) : _bytes(opts.tokenKey, "tokenKey");
256
+ parts.push('token-key="' + _b64urlPadded(spki) + '"');
257
+ }
258
+ return { challenge: challenge, wwwAuthenticate: parts.join(", ") };
259
+ }
260
+
261
+ module.exports = {
262
+ parseToken: parseToken,
263
+ verifyToken: verifyToken,
264
+ buildChallenge: buildChallenge,
265
+ TOKEN_TYPE_BLIND_RSA: TOKEN_TYPE_BLIND_RSA,
266
+ PrivacyPassError: PrivacyPassError,
267
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.12.51",
3
+ "version": "0.12.53",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
package/sbom.cdx.json CHANGED
@@ -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:5b23f9db-6c61-4ca7-b708-bb0ac954fc34",
5
+ "serialNumber": "urn:uuid:20868c28-f68f-42c3-a926-c9e46216c41e",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-25T15:45:58.868Z",
8
+ "timestamp": "2026-05-25T18:23:33.227Z",
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.12.51",
22
+ "bom-ref": "@blamejs/core@0.12.53",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.12.51",
25
+ "version": "0.12.53",
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.12.51",
29
+ "purl": "pkg:npm/%40blamejs/core@0.12.53",
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.12.51",
57
+ "ref": "@blamejs/core@0.12.53",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]