@blamejs/core 0.12.52 → 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,8 @@ 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
+
11
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.
12
14
 
13
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.
package/README.md CHANGED
@@ -97,7 +97,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
97
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`)
98
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`)
99
99
  - **Signed webhooks + API encryption** — SLH-DSA-SHAKE-256f default; ML-DSA-65 opt-in; ECIES API encryption (`b.webhook`, `b.crypto`)
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`)
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
101
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`)
102
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`)
103
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
@@ -395,6 +395,7 @@ var fedcm = require("./lib/fedcm");
395
395
  var dbsc = require("./lib/dbsc");
396
396
  var importmapIntegrity = require("./lib/importmap-integrity");
397
397
  var privacyPass = require("./lib/privacy-pass");
398
+ var contentDigest = require("./lib/content-digest");
398
399
  var standardWebhooks = require("./lib/standard-webhooks");
399
400
  var lro = require("./lib/lro");
400
401
  var jsonApi = require("./lib/jsonapi");
@@ -411,6 +412,7 @@ module.exports = {
411
412
  dbsc: dbsc,
412
413
  importmapIntegrity: importmapIntegrity,
413
414
  privacyPass: privacyPass,
415
+ contentDigest: contentDigest,
414
416
  standardWebhooks: standardWebhooks,
415
417
  lro: lro,
416
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
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.12.52",
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:455224f5-2973-4bdf-8fc9-c8553709b68b",
5
+ "serialNumber": "urn:uuid:20868c28-f68f-42c3-a926-c9e46216c41e",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-25T17:01:11.242Z",
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.52",
22
+ "bom-ref": "@blamejs/core@0.12.53",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.12.52",
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.52",
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.52",
57
+ "ref": "@blamejs/core@0.12.53",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]