@blamejs/core 0.7.23 → 0.7.38

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,36 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.7.x
10
10
 
11
+ - **0.7.38** (2026-05-05) — RFC 6698 + RFC 7672 DANE certificate-chain verification, plus smoke-runner LPT scheduling and a wiki failure-detail bugfix. **`b.network.smtp.dane.verifyChain(certChain, tlsaRecords, opts?)`** (NEW) — walks the peer cert chain (leaf-first DER buffers — typically `sock.getPeerCertificate(true).raw`) and confirms at least one TLSA record matches per the record's usage / selector / mtype. SMTP outbound (RFC 7672) only honors `DANE-TA` (2) and `DANE-EE` (3); `PKIX-TA` (0) and `PKIX-EE` (1) require a full PKIX path validator + CA-bundle and are refused unless the operator opts in via `allowPkixModes`. Selector `Cert` (0) compares the full DER; selector `SPKI` (1) compares the SubjectPublicKeyInfo bytes (extracted via the framework ASN.1 walker). Matching types: `Full`, sha-two-family at the short-digest length, sha-two-family at the long-digest length. Returns `{ ok, matches: [{ tlsaIndex, certIndex, usage, mtype }], errors }`. **ASN.1 walker** — `lib/asn1-der.js` `readNode` now surfaces a `.raw` field on each node (header + value bytes from the source buffer) so SPKI extraction can return the exact wire bytes for byte-identical TLSA matching. **Smoke-runner LPT scheduling** — `test/smoke.js` parallel mode now uses Longest-Processing-Time-first scheduling with a continuous worker queue. Per-test durations persist under `.test-output/smoke-timings.json` keyed by `process.platform` (so host win32/darwin and Linux container don't pollute each other's medians) with a 5-run history per test. Replaces the prior batched `Promise.all(slice(i, i + PARALLEL))` scheduling that left workers idle when one batch contained the long-tail test. **Wiki failure-detail bugfix** — `examples/wiki/test/e2e.js` was printing the per-example failure list AFTER the assert that throws on failure → operators only saw "1 of 178 failed" without WHICH example. Reordered so failure detail prints first. Smoke 8627 → 8635 / wiki e2e 178 / Linux container smoke 8635 / Linux container wiki e2e 178 / eslint clean / api-snapshot baseline refreshed.
12
+
13
+ - **0.7.37** (2026-05-05) — RFC 8460 TLS-RPT submission transport: `b.network.smtp.tlsRpt.fetchPolicy` + `submit`. The previous `recordShape` only generated the JSON; operators had to wire submission themselves. **`b.network.smtp.tlsRpt.fetchPolicy(domain, opts)`** (NEW) — reads the RFC 8460 §3 `_smtp._tls.<domain>` TXT record and returns `{ version, rua: [string,...] }` where `rua` is the comma-separated list of report URIs (`https://` and `mailto:`) the recipient publishes. Returns `null` when no record is published. `opts.dnsLookup` is operator-supplied so the call composes with `b.network.dns` (DoH / DoT) without a hard dep; falls back to `node:dns/promises.resolveTxt`. **`b.network.smtp.tlsRpt.submit(report, opts)`** (NEW) — submits a TLS-RPT report to the published rua endpoints. `https://` URIs receive an HTTPS `POST` with `Content-Type: application/tlsrpt+gzip` and a gzip-compressed JSON body per RFC 8460 §6.2. `mailto:` URIs return a prepared `{ to, subject, contentType, encoding, body }` object so operators hand it to `b.mail` with their configured relay (the framework doesn't bake an SMTP relay in — operators wire transport per their environment). Per-endpoint result records carry `{ uri, kind, ok, status, error }` so a failure on one rua doesn't cascade. Routes through `b.httpClient` so SSRF + DNS-pin + retry policy come for free. Smoke 8619 → 8627 / wiki e2e 178 / Linux container smoke 8627 / Linux container wiki e2e 178 / eslint clean / api-snapshot baseline refreshed.
14
+
15
+ - **0.7.36** (2026-05-05) — RFC 7633 must-staple enforcement: `b.network.tls.ocsp.inspectMustStaple` + `requireMustStaple` predicate. The TLS Feature extension (OID `1.3.6.1.5.5.7.1.24`) is the cert-side contract that says "every connection MUST carry an OCSP staple"; clients that ignore the extension defeat its purpose. **`b.network.tls.ocsp.inspectMustStaple(rawDer)`** (NEW) — walks the X.509 cert DER and reads the TLS Feature extension. Returns `{ mustStaple, features }` where `mustStaple === true` when status_request (5) is in the feature list. Tolerant of malformed cert input (returns `{ mustStaple: false, features: [] }` rather than throwing). **`b.network.tls.ocsp.requireMustStaple(opts)`** (NEW) — operator predicate `(peerCert, ctx) → Error|null`. Refuses connections where the cert advertises must-staple but `ctx.ocspBytes` is empty/missing per RFC 7633 §4.2.3. `opts.enforceUnconditional: true` extends the policy to refuse staple-less responses on certs that don't carry must-staple — operator-stricter-than-RFC posture. Error codes: `tls/ocsp-no-cert` / `tls/ocsp-must-staple-violated` / `tls/ocsp-staple-required`. Smoke 8611 → 8619 / wiki e2e 178 / Linux container smoke 8619 / Linux container wiki e2e 178 / eslint clean / api-snapshot baseline refreshed.
16
+
17
+ - **0.7.35** (2026-05-05) — DKIM verify hardening: process-local key cache + RSA modulus-size enforcement + `l=` warning. **Process-local DKIM key cache** — `_fetchDkimKey` now caches each successful TXT lookup keyed by `<selector>._domainkey.<domain>` for 5 minutes (TTL-bounded; rotated keys propagate within minutes). LRU-ish eviction at 1024 entries. Mailing-list fan-out and bulk-replay scenarios that previously hammered DNS for the same selector now hit the cache. **RSA modulus-size enforcement** — RSA keys < 1024 bits hard-fail per RFC 8301 §3.1; keys < 2048 bits emit a `rsa-key-weak` warning so operators can quarantine while transitioning. Reads `keyObj.asymmetricKeyDetails.modulusLength` from `node:crypto`. **`l=` body-length warning on verify** — the framework refuses `l=` at SIGN-time per v0.7.18 (M³AAWG / Gmail / Microsoft 365 guidance). On VERIFY of inbound mail, an `l=` tag now surfaces as `l-tag-present: append-after-signature exposure (RFC 6376 §8.2)` in the result's `warnings` array. The verifier still honors the cap so legitimate senders that use `l=` don't break, but operators see the exposure. Verify-result shape gains a `warnings` field on `pass` / `fail` results (alongside the existing `errors` array). Smoke 8606 → 8611 / wiki e2e 178 / Linux container smoke 8611 / Linux container wiki e2e 178 / eslint clean / api-snapshot stable.
18
+
19
+ - **0.7.34** (2026-05-05) — `b.network.tls.ct.verifyScts` with real RFC 6962 / 9162 Signed Certificate Timestamp signature verification, plus `b.network.tls.ct.parseScts` ASN.1 walker. The honest counterpart to v0.7.31's `requireScts` rename: previous behavior was OID-presence detection only; the new implementation walks the X.509 cert, extracts the SCT-list extension, parses each SCT into `{ version, logIdHex, timestamp, extensions, sigAlg, sigHashAlg, signature }`, reconstructs the RFC 6962 §3.2 signed entry (cert without SCT extension), and verifies each SCT's ECDSA signature against operator-supplied log public keys. **`b.network.tls.ct.parseScts(rawDer)`** (NEW) — full ASN.1 walk; returns `[{ version, logIdHex, timestamp, signature, ... }]` or `[]` when no SCT extension present (tolerant of malformed cert input — returns empty rather than throwing). **`b.network.tls.ct.verifyScts(rawDer, opts)`** (NEW) — full verification. `opts.logKeys` maps log_id (hex SHA-256 of the log's pubkey) → PEM public key; operators populate from the Chrome CT log list (the framework doesn't bake log keys in — they rotate). Returns `{ ok, verifiedCount, totalScts, reason, scts: [{ logIdHex, verified, ... }] }`. **`b.network.tls.ct.requireScts({ minScts, logKeys })`** (UPGRADED) — was OID-presence-only; now performs full signature verification. Reason-prefixed error codes: `tls/ct-no-cert` / `tls/ct-no-sct-extension` / `tls/ct-insufficient-verified` / `tls/ct-not-verified`. **Breaking — `network.tls.ct.APPROVED_LOGS` removed**. The hardcoded log-list instance was always a stand-in; CT logs rotate keys and add/remove entries on the order of months. Operators now pass `logKeys` per call. Pre-v1, no compat shim. Smoke 8602 → 8606 / wiki e2e 178 / Linux container smoke 8606 / Linux container wiki e2e 178 / eslint clean / api-snapshot baseline refreshed.
20
+
21
+ - **0.7.33** (2026-05-05) — `b.network.tls.ocsp.requireGood` with real signature verification, plus a focused ASN.1 DER walker for the framework's narrow cryptographic uses. **`lib/asn1-der.js`** (NEW) — minimal DER walker exposing `readNode` / `readSequence` / `readOid` / `readOctetString` / `readUnsignedInt` / `readBitString` / `unwrapExplicit`. Refuses BER indefinite-length encoding (DER-only). Throws `Asn1Error` with stable error codes (`asn1/short` / `asn1/bad-length` / `asn1/oid-malformed` / `asn1/wrong-tag` / etc.). **`b.network.tls.ocsp.parseResponse(der)`** — RFC 6960 OCSPResponse parser. Returns `{ status, basic: { tbsResponseDataDer, signatureAlgorithmOid, signature, responses[] } }`. Each response carries `{ certIdSerialHex, certStatus, thisUpdate, nextUpdate }`. **`b.network.tls.ocsp.evaluate(der, { issuerPem, serialHex? })`** — full verification: parse + signature-verify against the operator-supplied issuer cert (rsa-sha256/384/512 + ecdsa-sha256/384/512) + certStatus check. Returns `{ ok, status, certStatus, thisUpdate, nextUpdate, signatureValid, errors }`. **`b.network.tls.ocsp.requireGood(opts)`** (NEW) — connect + parse + signature-verify + certStatus check in one call. Throws `tls/ocsp-not-good` when the OCSP response is malformed, unsigned, signed-by-wrong-key, or carries `certStatus=revoked/unknown`. The honest counterpart to v0.7.31's `requireStapled` rename: `requireStapled` checks presence only (existing wrapper), `requireGood` does the full RFC 6960 validation. Smoke 8580 → 8602 / wiki e2e 178 / Linux container smoke 8602 / Linux container wiki e2e 178 / eslint clean / api-snapshot baseline refreshed.
22
+
23
+ - **0.7.32** (2026-05-05) — wiki backfill for v0.7.18-31 primitives + parallel e2e example execution. **New wiki sections** added across mail.js / middleware.js / safe-parsers.js / compliance-patterns.js / crypto-vault.js / network-config.js / auth.js for: `b.mail.dkim.verify`, `b.mail.spf.verify`, `b.mail.dmarc.evaluate`, `b.mail.arc.verify`, `b.network.smtp.mtaSts.fetch/matchMx`, `b.network.smtp.dane.tlsa/recordShape`, `b.network.smtp.tlsRpt.recordShape`, `b.middleware.bearerAuth`, `b.middleware.fetchMetadata`, `b.safeRedirect.resolve`, `b.pick`, `b.compliance.set/current/assert`, `b.dora.create`, `b.retention.complianceFloor`, `b.crypto.encryptMlkem768X25519`, `b.network.tls.ocsp.connect/requireStapled`, `b.network.tls.ct.inspect/requireScts`, `b.auth.jwt.verifyExternal`, `b.auth.password.params`. Each section ships the four required pieces (signature heading + opts model + description prose + runnable example). `UNDOCUMENTED_BACKLOG` shrunk by ~15 entries. **Wiki primitive-signature regex relaxed** — was `b.X.Y(...)` two-level only; now also matches `b.X(args)` top-level functions like `b.pick`, `b.lazyRequire`, `b.createApp`. **Parallel e2e example execution** — `examples/wiki/test/validate-primitive-sections.js` `runExamples()` now respects `SMOKE_PARALLEL=N` (capped at 64) and forks the per-example child processes in parallel batches. Timing: 78s sequential → 15s parallel-64 (5.2× speedup). Same env var as smoke; `SMOKE_PARALLEL=1` falls back to sequential for diagnosis. Smoke 8580 / wiki e2e 178 / Linux container smoke 8580 / Linux container wiki e2e 178 / eslint clean / api-snapshot baseline refreshed.
24
+
25
+ - **0.7.31** (2026-05-05) — DKIM verify + ARC per-hop signature verification + wiki missing-section gate. **`b.mail.dkim.verify(rfc822, opts)`** (NEW) — RFC 6376 verifier counterpart to the existing signer. Walks every `DKIM-Signature` header in the message, parses the tag list, fetches the signing public key from DNS TXT at `<selector>._domainkey.<domain>` (operator passes `dnsLookup` callback or falls back to `node:dns/promises.resolveTxt`), canonicalizes the body + headers per the `c=` tag, and runs `node:crypto.verify`. Returns one result per signature: `{ d, s, alg, result, errors }` where result is `pass` / `fail` / `permerror` / `temperror` / `none`. Supports `rsa-sha256` and `ed25519-sha256`. **`b.mail.arc.verify(rfc822, opts)`** (UPGRADED) — was structural-only; now performs full per-hop ARC-Message-Signature + ARC-Seal signature verification per RFC 8617 §5.1.1 and §5.1.2. AMS reuses the DKIM verifier (identical cryptographic shape); AS canonicalizes the chain of prior AAR/AMS/AS headers + own AAR/AMS plus AS-with-empty-`b=` per §5.1.2. Returns `{ chainStatus, hopCount, cv, hops: [{ instance, amsResult, asResult, ... }] }`. Chain validity per §5.2 — most recent AS's `cv=` must reflect upstream validity. **DKIM signer fold-output fix** — `_foldSignatureHeader` was producing malformed wire (`v=1\r\n\ta=rsa-sha256;\r\n\t...` — missing `;` after the first segment). RFC 6376 §3.2 requires `;` to stay on the prior line at fold boundaries. The corrected output is `v=1;\r\n\ta=rsa-sha256;\r\n\t...`. Pre-v1, no compat shims — operators rotate keys / rebuild any saved signatures. **`b.network.tls.ocsp.requireGood` → `requireStapled`** (RENAMED) — the wrapper claimed "good" but only checked stapling-presence + non-empty bytes; full OCSP-response signature verification is a follow-up patch alongside an ASN.1 DER helper. The honest name keeps the surface from claiming verification it doesn't perform. **Wiki missing-section gate** — `examples/wiki/test/validate-primitive-sections.js` now enumerates every operator-facing primitive on `b.*` and refuses release if a primitive lacks a documented wiki section. Pre-v0.7.31 backlog explicitly listed in `UNDOCUMENTED_BACKLOG` with per-primitive reasons (most are documented under existing pages with prose-form rather than signature-form headings — backfilling the headings is a separate sweep). New primitives shipped from v0.7.31 forward MUST either land with a wiki section OR add an explicit BACKLOG entry. Smoke 8575 → 8580 / wiki e2e 178 / Linux container smoke 8580 / Linux container wiki e2e 178 / eslint clean / api-snapshot baseline refreshed.
26
+
27
+ - **0.7.30** (2026-05-05) — `b.mail.spf` + `b.mail.dmarc` + `b.mail.arc` inbound mail authentication-results verification family. Counterpart to the existing outbound DKIM signer. Operators receiving mail (incoming webhooks, customer-support inboxes, mailing-list ingestion, .eml uploads) evaluate sender authenticity and decide on accept / quarantine / reject. **`b.mail.spf.verify({ ip, mailFrom, helo, dnsLookup })`** — RFC 7208 SPF check with ip4 / ip6 / include / all mechanisms; recursive include resolution with the 10-DNS-lookup ceiling per §4.6.4; returns `{ result, domain, explanation, lookupCount }` where result is one of `pass` / `fail` / `softfail` / `neutral` / `none` / `temperror` / `permerror`. **`b.mail.dmarc.evaluate({ from, spf, dkim, dnsLookup })`** — RFC 7489 DMARC alignment + policy resolution; fetches `_dmarc.<domain>` TXT record, evaluates `aspf` / `adkim` (relaxed/strict) alignment between From-header domain and SPF/DKIM authenticated domains, returns `{ result, policy, alignment, recommendedAction }` where recommendedAction is `deliver` / `quarantine` / `reject` per the published policy. **`b.mail.arc.verify(rfc822)`** — RFC 8617 ARC chain inspection; parses `ARC-Seal` / `ARC-Message-Signature` / `ARC-Authentication-Results` headers, returns `{ chainStatus, hopCount, hops }` with status `pass` / `fail` / `none` (structural integrity only — per-hop signature verification deferred to a follow-up patch). **Wiki e2e parallelizable** — added `BLAMEJS_E2E_DATA_DIR` env override so the host wiki e2e and the Linux container wiki e2e can run in parallel without colliding on `examples/wiki/data-e2e`. **Out of scope (deferred)**: SPF a / mx / exists / ptr / redirect mechanisms (operator-supplied dnsLookup callback handles the rare cases via permerror); full DKIM verify path (composes the canonicalization helpers from lib/mail-dkim.js but needs the symmetric verifier — substantial follow-up); ARC per-hop signature verification (depends on the DKIM verifier). The framework gives operators the OUTCOME-policy layer + the structural verifier; the deferred items address the residual signature-verification surface. Smoke 8559 → 8575 / wiki e2e 178 / Linux container smoke 8575 / Linux container wiki e2e 178 / eslint clean / api-snapshot baseline refreshed.
28
+
29
+ - **0.7.29** (2026-05-05) — `b.network.smtp` MTA-STS + DANE + TLS-RPT outbound SMTP gates. Gmail and Microsoft 365 penalize senders without these policies; the framework now ships the operator surface for verifying recipient-domain policies before opening the SMTP socket. **`b.network.smtp.mtaSts.fetch(domain)`** — fetches `https://mta-sts.<domain>/.well-known/mta-sts.txt`, parses the policy text, returns `{ version, mode, mx[], max_age }` or `null` (domain doesn't publish). TTL-bounded `b.cache` per-process. **`b.network.smtp.mtaSts.matchMx(mxHost, mxList)`** — RFC 8461 §3.2 wildcard-aware MX matcher (`*.example.com` matches `mx.example.com` but not `a.b.example.com`). **`b.network.smtp.dane.tlsa(domain, port?)`** — DNS TYPE 52 lookup via `node:dns.resolveTlsa`; returns `[{ usage, selector, mtype, dataHex }, ...]` for the `_<port>._tcp.<domain>` qname. **`b.network.smtp.dane.recordShape(rec)`** — adds RFC 6698 human-readable labels (`PKIX-TA` / `PKIX-EE` / `DANE-TA` / `DANE-EE`; `Cert` / `SPKI`; `Full` / `SHA-256` / `SHA-512`). **`b.network.smtp.tlsRpt.recordShape(opts)`** — RFC 8460 TLS-RPT JSON report-shape generator with `organization-name` / `date-range` / `policies[].summary.total-{successful,failure}-session-count` / `failure-details`. Operators wire their report transport (SMTP / HTTPS) on top. **Out of scope (deferred)**: full DANE certificate-chain verification per RFC 6698 (needs ASN.1 cert parsing); TLS-RPT submission transport (operator-side); DNSSEC ad-bit validation (operators pin to a DNSSEC-validating resolver externally). Smoke 8546 → 8559 / wiki e2e 178 / Linux container smoke 8559 / Linux container wiki e2e 178 / eslint clean / api-snapshot baseline refreshed.
30
+
31
+ - **0.7.28** (2026-05-05) — `ML_KEM_768_X25519` second envelope (TLS-interop hybrid). The IETF / Cloudflare / Chrome standardized hybrid for TLS 1.3 (codepoint 0x11EC, draft-kwiatkowski-tls-ecdhe-mlkem). Smaller payload than ML-KEM-1024 + P-384 (~1.1 KB vs ~1.6 KB), wider interop with non-blamejs peers using the same hybrid (Cloudflare Workers / Chrome / blamejs-on-the-other-side). **Surface**: `b.crypto.generateMlkem768X25519KeyPair()` returns `{ mlkemPublicKey, mlkemPrivateKey, x25519PublicKey, x25519PrivateKey }`. `b.crypto.encryptMlkem768X25519(plaintext, recipient)` produces a base64 envelope (KEM ID `0x04`). Existing `b.crypto.decrypt(envelope, privateKeys)` dispatches on the envelope-magic so operators decrypt with `{ privateKey: mlkemPrivateKey, x25519PrivateKey }` — no new entry point needed for the inverse direction. **`b.crypto.SUPPORTED_KEM_ALGORITHMS`** lists every KEM hybrid the framework accepts on decrypt — `ml-kem-1024` / `ml-kem-1024-p384` (default) / `ml-kem-768-x25519` — for compliance audit visibility. `ACTIVE.KEM` stays on `ML_KEM_1024_P384`; the new hybrid is opt-in for cross-system interop. Smoke 8536 → 8546 / wiki e2e 178 / Linux container smoke 8546 / Linux container wiki e2e 178 / eslint clean / api-snapshot baseline refreshed.
32
+
33
+ - **0.7.27** (2026-05-05) — `b.compliance` top-level posture coordinator. Single source of truth for "what regulatory posture is this deployment running under?". Primitives with a `compliancePosture` opt fall back to the global setting when the operator hasn't passed one explicitly. **Surface**: `b.compliance.set("hipaa")` / `b.compliance.current()` / `b.compliance.assert("hipaa")` / `b.compliance.clear()`. **`KNOWN_POSTURES`** — `hipaa` / `pci-dss` / `gdpr` / `soc2` / `dora` / `sox`. **Boot-time only** — `set()` to a different posture after one is already active throws `compliance/already-set` (runtime switches forbidden — half-set state across initialized primitives is worse than no state); same-value re-set is idempotent. **Audit emissions** — `compliance.posture.set` on every successful set, `compliance.posture.cleared` on `clear()`. **Wired** into `b.gateContract.resolveProfileAndPosture` so every guard-* family member picks up the global posture as fallback when no per-call `compliancePosture` is given. Operators with multi-tenant deploys keep using per-call `compliancePosture` opts to override the global. Smoke 8519 → 8536 / wiki e2e 178 / Linux container smoke 8536 / Linux container wiki e2e 178 / eslint clean / api-snapshot baseline refreshed.
34
+
35
+ - **0.7.26** (2026-05-05) — `b.network.tls.ocsp` + `b.network.tls.ct` operator surface for OCSP enforcement and CT SCT inspection. **`b.network.tls.ocsp.connect(opts)` / `.requireGood(opts)`** — wraps `tls.connect({ requestOCSP: true })` to give operators a one-call shape for "connect with OCSP request" and "refuse if peer didn't staple a good OCSP response". Returns `{ authorized, ocspBytes, peerCert }` on success; rejects with `tls/ocsp-not-stapled` or `tls/ocsp-empty` from `requireGood` when the peer didn't deliver. **`b.network.tls.ct`** — Certificate Transparency (RFC 6962 / 9162) operator surface. `ct.inspect(rawDer)` returns `{ hasSctExtension, rawLength }` after byte-pattern locating the SCT extension OID `1.3.6.1.4.1.11129.2.4.2`. `ct.requireScts({ minScts? })` returns a predicate operators wire into their TLS-connect outcome flow — refuses peer certs lacking the SCT extension with `tls/ct-no-sct-extension`. `ct.APPROVED_LOGS` lists the 2026-current Google Argon / Cloudflare Nimbus / DigiCert Yeti / Sectigo Sabre / LetsEncrypt Oak shards. **Out of scope this patch (deferred)**: full ASN.1 OCSPRequest building and OCSPResponse parsing; full SCT-signature verification against log pubkeys (presence-only enforcement until the ASN.1 dependency lands). The framework gives operators the OUTCOME-policy layer; node:tls already does the protocol. Smoke 8507 → 8519 / wiki e2e 178 / Linux container smoke 8519 / Linux container wiki e2e 178 / eslint clean / api-snapshot baseline refreshed.
36
+
37
+ - **0.7.25** (2026-05-05) — `b.dora` DORA Article 17 incident-reporting workflow. Digital Operational Resilience Act (Regulation (EU) 2022/2554) Article 17 requires every "financial entity" subject to DORA to classify, document, and report ICT-related incidents per the harmonized RTS template (Commission Delegated Regulation 2024/1772). **Surface**: `b.dora.create({ audit })` returns `{ classify, report, draftFinalReport }`. `classify(input)` evaluates the impact dimensions (`severityIndicator` / `affectedClients` / `economicImpact.eur` / `geographicScope` / `durationMs` / `dataAffected` / `reputationalImpact`) against the RTS Article 1 thresholds and returns `{ classification: "major"|"significant"|"minor", mustReport, mustReportInitialByMs, reasons }`. `report(input)` validates + builds the three-stage RTS-shaped record (initial / intermediate / final) with Article 19 deadline auto-computed (initial → +72h intermediate due; intermediate → +30 days final due). `draftFinalReport(record)` returns a final-stage draft with the RTS Article 19(6) fields ready for operator fill-in (`rootCause`, `remediationActions`, `lessonsLearned`, `preventiveMeasures`). **RTS-aligned thresholds** (`b.dora.MAJOR_INCIDENT_THRESHOLDS`, `b.dora.SIGNIFICANT_INCIDENT_THRESHOLDS`): 100k clients / 100k EUR / 2+ member states / 8h critical-process disruption (major); 10k clients / 10k EUR / 2h disruption (significant). Operator-side submission to ESAs / national supervisors is OUT of scope (channel + credentials are operator-specific); the primitive produces the RTS-shaped record their submission code drops into the regulator's API. Audit emissions: `dora.incident.classified` / `dora.incident.reported` / `dora.incident.draftFinal`. Smoke 8488 → 8507 / wiki e2e 178 / Linux container smoke 8507 / Linux container wiki e2e 178 / eslint clean / api-snapshot baseline refreshed.
38
+
39
+ - **0.7.24** (2026-05-05) — `b.retention.complianceFloor` accessor for regulatory minimum-retention windows. **Surface**: `b.retention.complianceFloor(posture, candidateTtlMs?)` returns the effective TTL that meets-or-exceeds the regulatory floor; throws `retention/unknown-posture` on unknown names. **`b.retention.COMPLIANCE_RETENTION_FLOOR_MS`** exposes the per-posture minimums for compliance audit visibility: `pci-dss` 365 days (PCI-DSS Req 10.7.1), `hipaa` 6 years (45 CFR §164.316(b)(2)), `sox` 7 years (Sarbanes-Oxley §802), `soc2` 1 year, `dora` 5 years (DORA Article 17). When `candidateTtlMs` exceeds the floor it wins; when it's below, the floor takes over. Smoke 8488 / wiki e2e 178 / Linux container smoke 8488 / Linux container wiki e2e 178 / eslint clean / api-snapshot baseline refreshed.
40
+
11
41
  - **0.7.23** (2026-05-05) — DNS-over-HTTPS default-on. **Default-on DoH for outbound DNS** — `lib/network-dns.js` now uses Cloudflare DoH (`https://cloudflare-dns.com/dns-query`) for hostname resolution by default when neither `useDnsOverHttps()` nor `useDnsOverTls()` has been called explicitly. Privacy-respecting (encrypted DNS over TLS to a non-ISP resolver), aligned with Core Rule §3 ("security defaults are not opt-in"). **Local-form hosts route through node:dns** — RFC 6761 / 6762 special-form names (`localhost`, `*.localhost`, `*.local`, `*.test`, `*.invalid`, `*.internal`, `*.intranet`, `*.lan`, `*.home`, `*.corp`) AND IP literals skip DoH and use node:dns directly so `/etc/hosts` lookups + dev workflows continue to resolve correctly. **Operator opt-out via `b.network.dns.useSystemResolver()`** — split-horizon / internal-DNS deployments call this once at boot and every lookup routes through the OS resolver thereafter. **Env override `BLAMEJS_DNS_TRANSPORT`** — operators set `system` (force OS resolver), `dot` (force Cloudflare DNS-over-TLS at 1.1.1.1:853), or leave unset (default DoH). Smoke 8478 / wiki e2e 178 / Linux container smoke 8478 / Linux container wiki e2e 178 / eslint clean / api-snapshot baseline refreshed.
12
42
 
13
43
  - **0.7.22** (2026-05-05) — DKIM dual-signer + soc2-cc7 → soc2 posture rename. **`b.mail.dkim.dualSigner`** — RFC 8463 §3 transition signer that produces messages with BOTH a legacy RSA-SHA-256 DKIM-Signature AND an Ed25519-SHA-256 DKIM-Signature header. Receivers without Ed25519 support validate the RSA signature; receivers that prefer Ed25519 validate the post-quantum-friendlier signature. Operators rolling off RSA-SHA-256 wire `b.mail.dkim.dualSigner({ domain, rsa: { selector, privateKey }, eddsa: { selector, privateKey } })` and pass the result anywhere a regular DKIM signer is accepted; `sign()` produces a wire with two `DKIM-Signature` headers. Both signers are constructed eagerly at create-time (configuration errors surface at boot, not at first send). **soc2-cc7 → soc2 posture rename** — every guard's compliance posture name changes from `"soc2-cc7"` to `"soc2"`. The CC7-specific scoping was misleading (SOC 2 controls span CC1–CC9; the existing posture wasn't CC7-specific). Operators with `compliancePosture: "soc2-cc7"` MUST update to `compliancePosture: "soc2"` — the old name now throws `unknown compliance posture`. Smoke 8478 / wiki e2e 178 / Linux container smoke 8478 / Linux container wiki e2e 178 / eslint clean / api-snapshot baseline refreshed.
package/index.js CHANGED
@@ -97,6 +97,8 @@ var websocket = require("./lib/websocket");
97
97
  var safeUrl = require("./lib/safe-url");
98
98
  var safeRedirect = require("./lib/safe-redirect");
99
99
  var pick = require("./lib/pick");
100
+ var dora = require("./lib/dora");
101
+ var compliance = require("./lib/compliance");
100
102
  var gateContract = require("./lib/gate-contract");
101
103
  var guardCsv = require("./lib/guard-csv");
102
104
  var guardHtml = require("./lib/guard-html");
@@ -236,6 +238,8 @@ module.exports = {
236
238
  safeUrl: safeUrl,
237
239
  safeRedirect: safeRedirect,
238
240
  pick: pick,
241
+ dora: dora,
242
+ compliance: compliance,
239
243
  gateContract: gateContract,
240
244
  guardCsv: guardCsv,
241
245
  guardHtml: guardHtml,
@@ -0,0 +1,274 @@
1
+ "use strict";
2
+ /**
3
+ * asn1-der — minimal ASN.1 DER walker for the framework's narrow
4
+ * cryptographic uses (OCSP response parsing, CT SCT extension parsing).
5
+ *
6
+ * The standard library does NOT ship an ASN.1 parser; we add a focused
7
+ * one rather than vendoring asn1.js (40+ KiB of JS for a small subset
8
+ * of features). The parser is presence-only: it reads tag + length +
9
+ * value and walks SEQUENCE / SET / context-specific structures. It
10
+ * does NOT decode arbitrary types — callers cherry-pick OIDs / INTEGER
11
+ * / OCTET STRING / BIT STRING / GeneralizedTime / UTCTime via the
12
+ * helpers below.
13
+ *
14
+ * Shape:
15
+ *
16
+ * var node = readNode(buf);
17
+ * // → { tag, tagClass, constructed, length, value, valueStart, totalLength }
18
+ *
19
+ * var children = readSequence(buf); // returns array of nodes
20
+ * var oidStr = readOid(buf, node);
21
+ * var int = readUnsignedInt(buf, node);
22
+ * var bytes = readOctetString(buf, node);
23
+ *
24
+ * Errors throw `Asn1Error` with a `code` (`asn1/short` / `asn1/bad-length`
25
+ * / `asn1/oid-malformed` / etc.) so callers can route on a stable shape.
26
+ */
27
+
28
+ var { defineClass } = require("./framework-error");
29
+
30
+ var Asn1Error = defineClass("Asn1Error", { alwaysPermanent: true });
31
+
32
+ // ASN.1 tag classes per ITU-T X.690 §8.1.2.
33
+ var TAG_CLASS = Object.freeze({
34
+ UNIVERSAL: 0, // allow:raw-byte-literal — ASN.1 tag class
35
+ APPLICATION: 1, // allow:raw-byte-literal — ASN.1 tag class
36
+ CONTEXT_SPECIFIC: 2, // allow:raw-byte-literal — ASN.1 tag class
37
+ PRIVATE: 3, // allow:raw-byte-literal — ASN.1 tag class
38
+ });
39
+
40
+ // Universal tag numbers used by the framework.
41
+ var TAG = Object.freeze({
42
+ BOOLEAN: 0x01,
43
+ INTEGER: 0x02,
44
+ BIT_STRING: 0x03,
45
+ OCTET_STRING: 0x04,
46
+ NULL: 0x05,
47
+ OID: 0x06,
48
+ ENUMERATED: 0x0a,
49
+ UTF8_STRING: 0x0c,
50
+ PRINTABLE_STRING: 0x13,
51
+ IA5_STRING: 0x16,
52
+ UTC_TIME: 0x17,
53
+ GENERALIZED_TIME: 0x18,
54
+ SEQUENCE: 0x10,
55
+ SET: 0x11,
56
+ });
57
+
58
+ // Read a TLV (tag + length + value) starting at offset. Returns:
59
+ // { tag, tagClass, constructed, length, value, valueStart, totalLength }
60
+ // where:
61
+ // tag — numeric tag (universal-class numbers are 0x01..0x1e;
62
+ // context-specific [N] tags surface as N with tagClass=2)
63
+ // constructed — true for SEQUENCE / SET / explicit tags; false for primitive
64
+ // value — Buffer slice covering the value bytes
65
+ // totalLength — number of bytes consumed (header + value)
66
+ function readNode(buf, offset) {
67
+ offset = offset || 0;
68
+ if (offset >= buf.length) {
69
+ throw new Asn1Error("asn1/short", "buffer ended at offset " + offset);
70
+ }
71
+
72
+ var b0 = buf[offset];
73
+ var tagClass = (b0 >> 6) & 0x03; // allow:raw-byte-literal — tag-class extraction
74
+ var constructed = (b0 & 0x20) !== 0; // allow:raw-byte-literal — constructed bit
75
+ var tag = b0 & 0x1f; // allow:raw-byte-literal — short-form tag
76
+
77
+ var headerLen = 1;
78
+ if (tag === 0x1f) {
79
+ // High-tag-number form (multi-byte tag). Walk continuation octets
80
+ // (each top bit set means another follows).
81
+ tag = 0;
82
+ while (true) {
83
+ if (offset + headerLen >= buf.length) {
84
+ throw new Asn1Error("asn1/short", "tag continuation truncated");
85
+ }
86
+ var byte = buf[offset + headerLen];
87
+ headerLen += 1;
88
+ tag = (tag << 7) | (byte & 0x7f); // allow:raw-byte-literal — base-128 tag bits
89
+ if ((byte & 0x80) === 0) break; // allow:raw-byte-literal — continuation bit
90
+ }
91
+ }
92
+
93
+ if (offset + headerLen >= buf.length) {
94
+ throw new Asn1Error("asn1/short", "length-byte missing");
95
+ }
96
+ var lenByte = buf[offset + headerLen];
97
+ headerLen += 1;
98
+ var length;
99
+ if ((lenByte & 0x80) === 0) {
100
+ // Short form — length is the byte itself.
101
+ length = lenByte;
102
+ } else {
103
+ // Long form — bottom 7 bits = number of length octets.
104
+ var lenOctets = lenByte & 0x7f;
105
+ if (lenOctets === 0) {
106
+ // Indefinite length — only valid in BER, not DER. Refuse.
107
+ throw new Asn1Error("asn1/indefinite-length",
108
+ "indefinite-length form is not allowed in DER");
109
+ }
110
+ if (lenOctets > 4) { // allow:raw-byte-literal — DER length cap (>4 GiB)
111
+ throw new Asn1Error("asn1/bad-length",
112
+ "length octets " + lenOctets + " exceeds 4 — refusing >4 GiB structure");
113
+ }
114
+ if (offset + headerLen + lenOctets > buf.length) {
115
+ throw new Asn1Error("asn1/short", "length octets truncated");
116
+ }
117
+ length = 0;
118
+ for (var i = 0; i < lenOctets; i += 1) {
119
+ length = (length * 256) + buf[offset + headerLen + i]; // allow:raw-byte-literal — base-256 length bytes
120
+ }
121
+ headerLen += lenOctets;
122
+ }
123
+
124
+ var valueStart = offset + headerLen;
125
+ if (valueStart + length > buf.length) {
126
+ throw new Asn1Error("asn1/short",
127
+ "value extends past buffer: needs " + length + " bytes at " + valueStart);
128
+ }
129
+ return {
130
+ tag: tag,
131
+ tagClass: tagClass,
132
+ constructed: constructed,
133
+ length: length,
134
+ value: buf.slice(valueStart, valueStart + length),
135
+ raw: buf.slice(offset, offset + headerLen + length),
136
+ valueStart: valueStart,
137
+ totalLength: headerLen + length,
138
+ };
139
+ }
140
+
141
+ function readSequence(buf) {
142
+ // Walk the children of a SEQUENCE / SET. The buffer passed in IS
143
+ // the value of an outer node (already past the header).
144
+ var out = [];
145
+ var offset = 0;
146
+ while (offset < buf.length) {
147
+ var node = readNode(buf, offset);
148
+ out.push(node);
149
+ offset += node.totalLength;
150
+ }
151
+ return out;
152
+ }
153
+
154
+ // Decode an OBJECT IDENTIFIER (ITU-T X.690 §8.19) into dotted-decimal.
155
+ function readOid(node) {
156
+ if (node.tag !== TAG.OID || node.tagClass !== TAG_CLASS.UNIVERSAL) {
157
+ throw new Asn1Error("asn1/wrong-tag",
158
+ "expected OID (tag 0x06), got " + node.tag);
159
+ }
160
+ var bytes = node.value;
161
+ if (bytes.length === 0) {
162
+ throw new Asn1Error("asn1/oid-empty", "OID value is empty");
163
+ }
164
+ // First two arcs are encoded as `40*X + Y`.
165
+ var first = Math.floor(bytes[0] / 40); // allow:raw-byte-literal — OID encoding constant
166
+ var second = bytes[0] % 40; // allow:raw-byte-literal — OID encoding constant
167
+ // Per X.690, when first byte >= 80 the first arc is 2 and second is byte-80.
168
+ if (first > 2) { first = 2; second = bytes[0] - 80; } // allow:raw-byte-literal — OID encoding constant
169
+ var arcs = [String(first), String(second)];
170
+
171
+ var i = 1;
172
+ while (i < bytes.length) {
173
+ var arc = 0;
174
+ var j = i;
175
+ while (j < bytes.length) {
176
+ var b = bytes[j];
177
+ arc = (arc * 128) + (b & 0x7f); // allow:raw-byte-literal — base-128 OID arc
178
+ j += 1;
179
+ if ((b & 0x80) === 0) break; // allow:raw-byte-literal — continuation bit
180
+ }
181
+ if (j === i) {
182
+ throw new Asn1Error("asn1/oid-malformed", "OID arc never terminated");
183
+ }
184
+ arcs.push(String(arc));
185
+ i = j;
186
+ }
187
+ return arcs.join(".");
188
+ }
189
+
190
+ function readOctetString(node) {
191
+ if (node.tag !== TAG.OCTET_STRING || node.tagClass !== TAG_CLASS.UNIVERSAL) {
192
+ throw new Asn1Error("asn1/wrong-tag",
193
+ "expected OCTET STRING (tag 0x04), got " + node.tag);
194
+ }
195
+ return node.value;
196
+ }
197
+
198
+ function readUnsignedInt(node) {
199
+ if (node.tag !== TAG.INTEGER && node.tag !== TAG.ENUMERATED) {
200
+ throw new Asn1Error("asn1/wrong-tag",
201
+ "expected INTEGER/ENUMERATED, got " + node.tag);
202
+ }
203
+ // DER INTEGER may have a leading 0x00 byte to disambiguate from
204
+ // negative values when the high bit is set; strip it for unsigned
205
+ // interpretation.
206
+ var bytes = node.value;
207
+ if (bytes.length === 0) {
208
+ throw new Asn1Error("asn1/int-empty", "INTEGER value is empty");
209
+ }
210
+ if (bytes.length > 8) { // allow:raw-byte-literal — JS safe-int byte cap
211
+ // Caller wanted an unsigned int — for big serials they want the raw
212
+ // bytes instead. Surface as hex string so caller decides.
213
+ return { hex: bytes.toString("hex") };
214
+ }
215
+ var n = 0;
216
+ var start = (bytes[0] === 0 && bytes.length > 1) ? 1 : 0; // allow:raw-byte-literal — DER zero-pad
217
+ for (var k = start; k < bytes.length; k += 1) {
218
+ n = (n * 256) + bytes[k]; // allow:raw-byte-literal — base-256 byte
219
+ }
220
+ return n;
221
+ }
222
+
223
+ function readBitString(node) {
224
+ if (node.tag !== TAG.BIT_STRING || node.tagClass !== TAG_CLASS.UNIVERSAL) {
225
+ throw new Asn1Error("asn1/wrong-tag",
226
+ "expected BIT STRING (tag 0x03), got " + node.tag);
227
+ }
228
+ // BIT STRING value: first byte is "unused bits in last byte" count;
229
+ // remaining bytes are the bit content. For signature blobs we want
230
+ // just the bytes — the unused count is always 0 for full-byte sigs.
231
+ if (node.value.length === 0) {
232
+ throw new Asn1Error("asn1/bit-string-empty", "BIT STRING is empty");
233
+ }
234
+ var unused = node.value[0];
235
+ if (unused !== 0) {
236
+ throw new Asn1Error("asn1/bit-string-unused-bits",
237
+ "BIT STRING with unused bits is unsupported (got " + unused + ")");
238
+ }
239
+ return node.value.slice(1);
240
+ }
241
+
242
+ // Read a context-specific [N] EXPLICIT-tagged child. Returns the
243
+ // inner node (the tag inside the explicit wrapper).
244
+ function unwrapExplicit(node, expectedTag) {
245
+ if (node.tagClass !== TAG_CLASS.CONTEXT_SPECIFIC || node.tag !== expectedTag) {
246
+ throw new Asn1Error("asn1/wrong-tag",
247
+ "expected context-specific [" + expectedTag + "], got class=" +
248
+ node.tagClass + " tag=" + node.tag);
249
+ }
250
+ return readNode(node.value, 0);
251
+ }
252
+
253
+ // Find a child node of a SEQUENCE / SET by predicate. Returns null if
254
+ // no child matches.
255
+ function findChild(children, predicate) {
256
+ for (var i = 0; i < children.length; i += 1) {
257
+ if (predicate(children[i])) return children[i];
258
+ }
259
+ return null;
260
+ }
261
+
262
+ module.exports = {
263
+ TAG_CLASS: TAG_CLASS,
264
+ TAG: TAG,
265
+ readNode: readNode,
266
+ readSequence: readSequence,
267
+ readOid: readOid,
268
+ readOctetString: readOctetString,
269
+ readUnsignedInt: readUnsignedInt,
270
+ readBitString: readBitString,
271
+ unwrapExplicit: unwrapExplicit,
272
+ findChild: findChild,
273
+ Asn1Error: Asn1Error,
274
+ };
package/lib/audit.js CHANGED
@@ -203,10 +203,12 @@ var FRAMEWORK_NAMESPACES = [
203
203
  "backup", // b.backup
204
204
  "breakglass", // b.breakGlass — column-policy / row-enforcement step-up auth (audit namespace lowercased per the validator's `namespace.verb` rule, same convention as b.apiKey → apikey.*)
205
205
  "cache", // b.cache
206
+ "compliance", // b.compliance (compliance.posture.set / cleared)
206
207
  "config", // b.configDrift (config.baseline.captured / config.drift.detected / config.baseline.tamper / config.baseline.unreadable)
207
208
  "db", // b.db / b.middleware.dbRoleFor / b.externalDb.runAs
208
209
  // (role-switching, RLS-shaped events)
209
210
  "dkim", // b.mail.dkim (DKIM-Signature generation events)
211
+ "dora", // b.dora (DORA Article 17: dora.incident.classified / reported / draftFinal)
210
212
  "dual", // b.dualControl (dual.grant.requested / approved / denied / consumed / expired / self_approval_denied)
211
213
  "mail", // b.mail (b.mail-bounce uses "system.mail.*")
212
214
  "network", // b.middleware.networkAllowlist (network.gate.denied)
@@ -0,0 +1,114 @@
1
+ "use strict";
2
+ /**
3
+ * b.compliance — top-level compliance-posture coordinator.
4
+ *
5
+ * Sets a global posture (`hipaa` / `pci-dss` / `gdpr` / `soc2` /
6
+ * `dora`) that primitives with a `compliancePosture` opt fall back to
7
+ * when the operator hasn't passed one explicitly. Single source of
8
+ * truth for "what regulatory posture is this deployment running
9
+ * under?".
10
+ *
11
+ * b.compliance.set("hipaa");
12
+ * b.compliance.current(); // → "hipaa"
13
+ * b.compliance.assert("hipaa"); // throws if not the named posture
14
+ *
15
+ * // Every primitive with a compliancePosture opt now picks "hipaa"
16
+ * // by default:
17
+ * var gate = b.guardCsv.gate({}); // hipaa overlay applied
18
+ * var ttl = b.retention.complianceFloor("hipaa", customTtl);
19
+ *
20
+ * Boot-time only — `set()` MUST run before the primitives it
21
+ * coordinates are first used. Runtime switches are forbidden because
22
+ * they would create a half-set state across primitives that have
23
+ * already initialized.
24
+ *
25
+ * Audit emission: `compliance.posture.set` on every successful
26
+ * `set()`, `compliance.posture.cleared` on `clear()`. Operators
27
+ * tracking deploys can grep audit for these to reconstruct posture
28
+ * history per deployment.
29
+ */
30
+
31
+ var lazyRequire = require("./lazy-require");
32
+ var { ComplianceError } = require("./framework-error");
33
+
34
+ var audit = lazyRequire(function () { return require("./audit"); });
35
+
36
+ // Recognised posture names. Aligns with the compliance-posture
37
+ // vocabulary every guard / retention floor / etc. accepts. Operators
38
+ // passing an unknown name get a typo-surfacing throw at set-time, not
39
+ // silent fall-through to no-op.
40
+ var KNOWN_POSTURES = Object.freeze([
41
+ "hipaa", "pci-dss", "gdpr", "soc2", "dora", "sox",
42
+ ]);
43
+
44
+ var STATE = { posture: null, setAt: null };
45
+
46
+ function _emitAudit(action, metadata) {
47
+ try {
48
+ audit().safeEmit({
49
+ action: action,
50
+ outcome: "success",
51
+ metadata: metadata,
52
+ });
53
+ } catch (_e) { /* audit best-effort */ }
54
+ }
55
+
56
+ function set(posture) {
57
+ if (typeof posture !== "string" || posture.length === 0) {
58
+ throw new ComplianceError("compliance/bad-posture",
59
+ "compliance.set: posture must be a non-empty string, got " +
60
+ JSON.stringify(posture));
61
+ }
62
+ if (KNOWN_POSTURES.indexOf(posture) === -1) {
63
+ throw new ComplianceError("compliance/unknown-posture",
64
+ "compliance.set: unknown posture '" + posture + "'; expected one of " +
65
+ KNOWN_POSTURES.join(", "));
66
+ }
67
+ if (STATE.posture && STATE.posture !== posture) {
68
+ throw new ComplianceError("compliance/already-set",
69
+ "compliance.set: posture is already '" + STATE.posture + "' (set at " +
70
+ new Date(STATE.setAt).toISOString() + "). Runtime switches are " +
71
+ "forbidden — they create half-set state across already-initialized " +
72
+ "primitives. Set once at boot.");
73
+ }
74
+ STATE.posture = posture;
75
+ STATE.setAt = Date.now();
76
+ _emitAudit("compliance.posture.set", { posture: posture });
77
+ }
78
+
79
+ function current() {
80
+ return STATE.posture;
81
+ }
82
+
83
+ function assert(posture) {
84
+ if (STATE.posture !== posture) {
85
+ throw new ComplianceError("compliance/assertion-failed",
86
+ "compliance.assert('" + posture + "'): current posture is " +
87
+ JSON.stringify(STATE.posture));
88
+ }
89
+ }
90
+
91
+ function clear() {
92
+ // Reserved for tests + operator-controlled tear-down. Emits an audit
93
+ // row so the chain shows the posture was intentionally cleared.
94
+ if (STATE.posture) {
95
+ _emitAudit("compliance.posture.cleared", { previous: STATE.posture });
96
+ }
97
+ STATE.posture = null;
98
+ STATE.setAt = null;
99
+ }
100
+
101
+ function _resetForTest() {
102
+ STATE.posture = null;
103
+ STATE.setAt = null;
104
+ }
105
+
106
+ module.exports = {
107
+ set: set,
108
+ current: current,
109
+ assert: assert,
110
+ clear: clear,
111
+ KNOWN_POSTURES: KNOWN_POSTURES,
112
+ ComplianceError: ComplianceError,
113
+ _resetForTest: _resetForTest,
114
+ };
package/lib/constants.js CHANGED
@@ -75,6 +75,14 @@ var ENVELOPE_MAGIC = 0xE1;
75
75
  var KEM_IDS = Object.freeze({
76
76
  ML_KEM_1024: 0x02,
77
77
  ML_KEM_1024_P384: 0x03,
78
+ // 0x04 — ML-KEM-768 + X25519 hybrid. The IETF / Cloudflare / Chrome
79
+ // standardized hybrid for TLS 1.3 (codepoint 0x11EC, draft-kwiatkowski-
80
+ // tls-ecdhe-mlkem). Smaller payload than ML-KEM-1024+P384 (~1.1 KB
81
+ // vs ~1.6 KB), wider interop with non-blamejs peers using the same
82
+ // hybrid. ACTIVE.KEM stays on ML_KEM_1024_P384 — operators opt in to
83
+ // the smaller hybrid via b.crypto.encrypt(..., { algorithm: "ml-kem-
84
+ // 768-x25519" }) when targeting a peer that needs it.
85
+ ML_KEM_768_X25519: 0x04,
78
86
  });
79
87
 
80
88
  var CIPHER_IDS = Object.freeze({
package/lib/crypto.js CHANGED
@@ -203,6 +203,21 @@ function decryptEnvelope(packed, privateKeys) {
203
203
  symmetricKey = kdf(Buffer.concat([mlkemSs, ecSs]), C.BYTES.bytes(32));
204
204
  } else if (kemId === C.KEM_IDS.ML_KEM_1024) {
205
205
  symmetricKey = kdf(mlkemSs, C.BYTES.bytes(32));
206
+ } else if (kemId === C.KEM_IDS.ML_KEM_768_X25519) {
207
+ // ML-KEM-768 + X25519 hybrid envelope. The mlkemPriv must be an
208
+ // ML-KEM-768 key (not 1024); operators are responsible for passing
209
+ // the correct keypair via privateKeys when the envelope was sealed
210
+ // with this algorithm. Same length-prefixed shape as the P-384
211
+ // hybrid: 2-byte ec-eph-len + DER X25519 pubkey + nonce + ct.
212
+ var x25519EphLen = packed.readUInt16BE(pos); pos += 2;
213
+ var x25519EphDer = packed.subarray(pos, pos + x25519EphLen); pos += x25519EphLen;
214
+ var x25519PrivPem = typeof privateKeys === "string" ? null : privateKeys.x25519PrivateKey;
215
+ if (!x25519PrivPem) throw new Error("ML-KEM-768 + X25519 hybrid envelope requires x25519PrivateKey");
216
+ var x25519Ss = nodeCrypto.diffieHellman({
217
+ privateKey: nodeCrypto.createPrivateKey(x25519PrivPem),
218
+ publicKey: nodeCrypto.createPublicKey({ key: x25519EphDer, type: "spki", format: "der" }),
219
+ });
220
+ symmetricKey = kdf(Buffer.concat([mlkemSs, x25519Ss]), C.BYTES.bytes(32));
206
221
  } else {
207
222
  throw new Error("Invalid envelope: unsupported KEM ID " + kemId);
208
223
  }
@@ -241,6 +256,80 @@ function decryptPacked(packed, key, aad) {
241
256
  );
242
257
  }
243
258
 
259
+ // ---- ML-KEM-768 + X25519 hybrid (TLS-interop envelope) ----
260
+ //
261
+ // The IETF / Cloudflare / Chrome standardized hybrid for TLS 1.3
262
+ // (codepoint 0x11EC). Smaller payload than ML-KEM-1024 + P-384
263
+ // (~1.1 KB vs ~1.6 KB), wider interop with peers using the same
264
+ // hybrid (Cloudflare Workers, Chrome, blamejs-on-the-other-side).
265
+ //
266
+ // Operators wire this when the recipient publishes ML-KEM-768 +
267
+ // X25519 keys. Generation:
268
+ //
269
+ // var pair = b.crypto.generateMlkem768X25519KeyPair();
270
+ // // → { mlkemPublicKey, mlkemPrivateKey,
271
+ // // x25519PublicKey, x25519PrivateKey }
272
+ //
273
+ // var envelope = b.crypto.encryptMlkem768X25519(plaintext, {
274
+ // mlkemPublicKey: recipient.mlkemPublicKey,
275
+ // x25519PublicKey: recipient.x25519PublicKey,
276
+ // });
277
+ //
278
+ // Decryption goes through the existing b.crypto.decrypt(envelope,
279
+ // privateKeys) — the envelope-magic dispatch handles KEM_IDS.
280
+ // ML_KEM_768_X25519. privateKeys MUST shape as { privateKey,
281
+ // x25519PrivateKey } — privateKey is the ML-KEM-768 PEM, NOT the
282
+ // default ML-KEM-1024.
283
+
284
+ function generateMlkem768X25519KeyPair() {
285
+ var mlkem = generateKeyPair("ml-kem-768");
286
+ var x25519 = generateKeyPair("x25519");
287
+ return {
288
+ mlkemPublicKey: mlkem.publicKey,
289
+ mlkemPrivateKey: mlkem.privateKey,
290
+ x25519PublicKey: x25519.publicKey,
291
+ x25519PrivateKey: x25519.privateKey,
292
+ };
293
+ }
294
+
295
+ function encryptMlkem768X25519(plaintext, recipient) {
296
+ if (!recipient || !recipient.mlkemPublicKey || !recipient.x25519PublicKey) {
297
+ throw new Error("encryptMlkem768X25519 requires { mlkemPublicKey, x25519PublicKey }");
298
+ }
299
+ var mlkemPub = nodeCrypto.createPublicKey(recipient.mlkemPublicKey);
300
+ var kem = nodeCrypto.encapsulate(mlkemPub);
301
+ var ephX25519 = generateKeyPair("x25519", {
302
+ publicKeyEncoding: { type: "spki", format: "der" },
303
+ privateKeyEncoding: { type: "pkcs8", format: "pem" },
304
+ });
305
+ var x25519Ss = nodeCrypto.diffieHellman({
306
+ privateKey: nodeCrypto.createPrivateKey(ephX25519.privateKey),
307
+ publicKey: nodeCrypto.createPublicKey(recipient.x25519PublicKey),
308
+ });
309
+ var key = kdf(Buffer.concat([kem.sharedKey, x25519Ss]), C.BYTES.bytes(32));
310
+ var nonce = generateBytes(C.BYTES.bytes(24));
311
+ var ct = xchacha20poly1305(key, nonce).encrypt(Buffer.from(plaintext, "utf8"));
312
+
313
+ var kemCtLen = Buffer.alloc(2); kemCtLen.writeUInt16BE(kem.ciphertext.length);
314
+ var x25519EphDer = ephX25519.publicKey;
315
+ var x25519EphLen = Buffer.alloc(2); x25519EphLen.writeUInt16BE(x25519EphDer.length);
316
+
317
+ return Buffer.concat([
318
+ Buffer.from([C.ENVELOPE_MAGIC, C.KEM_IDS.ML_KEM_768_X25519,
319
+ C.ACTIVE.CIPHER, C.ACTIVE.KDF]),
320
+ kemCtLen, kem.ciphertext, x25519EphLen, x25519EphDer, nonce, Buffer.from(ct),
321
+ ]).toString("base64");
322
+ }
323
+
324
+ // Operator-audit accessor — exposes every supported KEM hybrid for
325
+ // compliance audit visibility ("which envelopes does this deploy
326
+ // accept on decrypt?").
327
+ var SUPPORTED_KEM_ALGORITHMS = Object.freeze([
328
+ { id: "ml-kem-1024", envelopeId: C.KEM_IDS.ML_KEM_1024, description: "ML-KEM-1024 KEM-only (legacy single-component)" },
329
+ { id: "ml-kem-1024-p384", envelopeId: C.KEM_IDS.ML_KEM_1024_P384, description: "ML-KEM-1024 + ECDH P-384 hybrid (framework default)" },
330
+ { id: "ml-kem-768-x25519", envelopeId: C.KEM_IDS.ML_KEM_768_X25519, description: "ML-KEM-768 + X25519 hybrid (IETF / Cloudflare / Chrome TLS 1.3 codepoint 0x11EC)" },
331
+ ]);
332
+
244
333
  module.exports = {
245
334
  // Hashing
246
335
  sha3Hash: sha3Hash,
@@ -254,12 +343,15 @@ module.exports = {
254
343
  // Keys
255
344
  generateEncryptionKeyPair: generateEncryptionKeyPair,
256
345
  generateSigningKeyPair: generateSigningKeyPair,
346
+ generateMlkem768X25519KeyPair: generateMlkem768X25519KeyPair,
257
347
  // Signatures
258
348
  sign: sign,
259
349
  verify: verify,
260
350
  // Envelope encrypt/decrypt
261
351
  encrypt: encrypt,
262
352
  decrypt: decrypt,
353
+ encryptMlkem768X25519: encryptMlkem768X25519,
354
+ SUPPORTED_KEM_ALGORITHMS: SUPPORTED_KEM_ALGORITHMS,
263
355
  // Symmetric buffer encrypt/decrypt
264
356
  encryptPacked: encryptPacked,
265
357
  decryptPacked: decryptPacked,