@blamejs/core 0.7.39 → 0.7.41

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,17 +8,21 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.7.x
10
10
 
11
- - **0.7.39** (2026-05-05) — RFC 8954 OCSP nonce extension default-on, plus persistent-log reliability fix in the smoke runner. **`b.network.tls.ocsp.buildRequest(opts)`** (NEW) constructs a DER-encoded OCSPRequest for a single (`leafCertDer`, `issuerCertDer`) pair. The RFC 8954 nonce extension is **on by default** (security-defaults-on rule); operators talking to responders that ignore nonces opt out via `nonce: false`. Default nonce length is 16 bytes (RFC 8954 §2.1 floor 1, ceiling 32 — `nonceLen` overrides). CertID hashes are SHA-1 per RFC 6960 §4.1.1 (the universally-supported algorithm; SHA-256 in OCSP requests is §4.3 optional and many responders reject). Returns `{ requestDer, nonce }`. Operators send `requestDer` to the OCSP responder URL via `b.httpClient` (Content-Type: `application/ocsp-request`) and pass the returned `nonce` Buffer to `evaluate(responseDer, { issuerPem, expectedNonce })` — defends against replay attacks where an attacker captures a "good" OCSP response and replays it after the cert is revoked. **DER writer surface added to `lib/asn1-der.js`** `writeNode` / `writeSequence` / `writeOctetString` / `writeInteger` / `writeNull` / `writeOid` / `writeContextExplicit` (minimal, ~80 lines, X.690 short + long-form length encoding). **`evaluate(der, opts)` accepts `expectedNonce`** when supplied, the response's nonce extension MUST match (else `tls/ocsp-nonce-mismatch`). Result shape gains a `nonce` field: `"matched"` / `"present-not-checked"` / `"n/a"`. **Smoke-runner persistent log reliability fix** the `.test-output/smoke.log` tee was using `fs.createWriteStream` (async); when the runner threw on a test failure the buffer didn't flush before `process.exit`, so the failure detail never reached the log. Replaced with synchronous `fs.writeSync` against an open fd — every byte hits disk before exit. Surface change for diagnosing future failures: the log now ALWAYS captures the failure stack instead of truncating mid-run. Smoke 8635 8642 / wiki e2e 178 / Linux container smoke 8642 / Linux container wiki e2e 178 / eslint clean / api-snapshot baseline refreshed.
11
+ - **0.7.41** (2026-05-05) — wiki primitive-validator BACKLOG sweep: 50 of 101 entries pruned. Audited every entry in `examples/wiki/test/validate-primitive-sections.js`'s `UNDOCUMENTED_BACKLOG` map against the actual wiki page bodies; primitives that already have signature-form headings (`b.X.Y(...)`) discovered by the validator no longer need a BACKLOG entry suppressing them. Removed entries: `consent`, `websocketChannels`, `ssrfGuard`, `htmlBalance`, `csv`, `uuid`, `time`, `mailBounce`, `archive`, `breakGlass`, `forms`, `render`, `errorPage`, `cluster`, `safeBuffer`, `safeSql`, `safeUrl`, `retry`, `fileType`, `scheduler`, `jobs`, `backup`, `restore`, `i18n`, `cache`, `crypto`, `createApp`, `fileUpload`, `mtlsCa`, `pqcGate`, `pqcAgent`, `permissions`, `apiKey`, `webhook`, `notify`, `credentialHash`, `queue`, plus the 11-guard family (`guardEmail` through `guardAll`). The remaining 51 entries fall into three real classes: namespace-level entries with method-only headings (vault primitives, audit chain, etc.), pages structured around backend-builder patterns instead of flat methods (`objectStore`, `backupBundle`, etc.), and internal helpers that aren't part of the operator surface (`cli`, `boot`, `dev`, `protocolDispatcher`). The shrunk BACKLOG narrows the gap operators see between "covered by the parent's wiki page" and "actually drift waiting to be fixed."
12
12
 
13
- - **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.
13
+ - **0.7.40** (2026-05-05) — RFC 8617 ARC chain-validity hardening: duplicate-instance refusal, gap detection, hop ceiling, per-hop `cv=` rule enforcement. Closes a class of chain-injection holes the prior `arcVerify` would silently accept. **Duplicate instance** two `ARC-Seal` (or AMS / AAR) headers at the same `i=N` now fail with `reason: "duplicate-instance"` instead of silently letting the second header overwrite the first signer's record (the latter is a known forwarder-injection attack). **Non-contiguous chain** gap detection (`i=1, i=3` with no `i=2`) now correctly fails with `reason: "incomplete-or-non-contiguous"`. The prior `.some()` check skipped sparse-array empty slots and let the gap through. **Hop ceiling** — RFC 8617 §5.1.2 caps the chain at 50 sets; verifier now refuses with `reason: "too-many-hops"`. **Per-hop `cv=` rules** — i=1 MUST be `cv=none` (no upstream chain to validate); i≥2 MUST be `cv=pass` or `cv=fail` (cv=none invalid past hop 1); a downstream `cv=pass` after an upstream `cv=fail` is invalid (a hop can't claim chain-pass when an earlier hop saw it fail). Each violation surfaces with a structured `reason` field. **Result shape gains** `perHopCv: [string|null,...]` (one entry per hop) and `reason: string` when `chainStatus === "fail"` so operators can distinguish "signature failed" from "chain rules violated" without parsing free-text errors.
14
14
 
15
- - **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.
15
+ - **0.7.39** (2026-05-05) — RFC 8954 OCSP nonce extension default-on, plus persistent-log reliability fix in the smoke runner. **`b.network.tls.ocsp.buildRequest(opts)`** (NEW) constructs a DER-encoded OCSPRequest for a single (`leafCertDer`, `issuerCertDer`) pair. The RFC 8954 nonce extension is **on by default** (security-defaults-on rule); operators talking to responders that ignore nonces opt out via `nonce: false`. Default nonce length is 16 bytes (RFC 8954 §2.1 floor 1, ceiling 32 `nonceLen` overrides). CertID hashes are SHA-1 per RFC 6960 §4.1.1 (the universally-supported algorithm; SHA-256 in OCSP requests is §4.3 optional and many responders reject). Returns `{ requestDer, nonce }`. Operators send `requestDer` to the OCSP responder URL via `b.httpClient` (Content-Type: `application/ocsp-request`) and pass the returned `nonce` Buffer to `evaluate(responseDer, { issuerPem, expectedNonce })`defends against replay attacks where an attacker captures a "good" OCSP response and replays it after the cert is revoked. **DER writer surface added to `lib/asn1-der.js`** `writeNode` / `writeSequence` / `writeOctetString` / `writeInteger` / `writeNull` / `writeOid` / `writeContextExplicit` (minimal, ~80 lines, X.690 short + long-form length encoding). **`evaluate(der, opts)` accepts `expectedNonce`** when supplied, the response's nonce extension MUST match (else `tls/ocsp-nonce-mismatch`). Result shape gains a `nonce` field: `"matched"` / `"present-not-checked"` / `"n/a"`. **Smoke-runner persistent log reliability fix** the `.test-output/smoke.log` tee was using `fs.createWriteStream` (async); when the runner threw on a test failure the buffer didn't flush before `process.exit`, so the failure detail never reached the log. Replaced with synchronous `fs.writeSync` against an open fd every byte hits disk before exit. Surface change for diagnosing future failures: the log now ALWAYS captures the failure stack instead of truncating mid-run.
16
16
 
17
- - **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.
17
+ - **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.
18
18
 
19
- - **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.
19
+ - **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.
20
20
 
21
- - **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.
21
+ - **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-stapleoperator-stricter-than-RFC posture. Error codes: `tls/ocsp-no-cert` / `tls/ocsp-must-staple-violated` / `tls/ocsp-staple-required`.
22
+
23
+ - **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).
24
+
25
+ - **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.
22
26
 
23
27
  - **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.
24
28
 
package/lib/mail-auth.js CHANGED
@@ -376,6 +376,10 @@ function _parseHeaderLines(headerSection) {
376
376
  return unfolded;
377
377
  }
378
378
 
379
+ // RFC 8617 §5.1.2 caps the chain at 50 sets to bound verifier work and
380
+ // limit how far an attacker can push junk headers.
381
+ var ARC_MAX_HOPS = 50; // allow:raw-byte-literal — RFC 8617 §5.1.2 chain ceiling
382
+
379
383
  async function arcVerify(rfc822, opts) {
380
384
  if (typeof rfc822 !== "string" || rfc822.length === 0) {
381
385
  throw new MailAuthError("mail-auth/arc-bad-input",
@@ -384,8 +388,16 @@ async function arcVerify(rfc822, opts) {
384
388
  opts = opts || {};
385
389
  var headers = _parseHeaderLines(_splitHeaders(rfc822));
386
390
  var hops = [];
387
-
388
- // 1. Index ARC headers by instance number.
391
+ var seenSlot = {}; // {`<instance>:<name>`: true} — duplicate detection
392
+
393
+ // 1. Index ARC headers by instance number. Refuse duplicates: a single
394
+ // hop has exactly one ARC-Seal / ARC-Message-Signature /
395
+ // ARC-Authentication-Results. A second copy at the same instance is
396
+ // a malformed chain (per RFC 8617 §5.1 implicit, and a known
397
+ // injection vector — a forwarder that re-signs with a duplicate
398
+ // instance would silently overwrite the original signer).
399
+ var duplicate = false;
400
+ var maxInstanceSeen = 0;
389
401
  for (var i = 0; i < headers.length; i += 1) {
390
402
  var line = headers[i];
391
403
  var colonAt = line.indexOf(":");
@@ -396,7 +408,11 @@ async function arcVerify(rfc822, opts) {
396
408
  name !== "arc-authentication-results") continue;
397
409
  var iMatch = value.match(/(?:^|[;,\s])i=(\d+)/); // allow:regex-no-length-cap — header bounded by RFC 5322 998
398
410
  var inst = iMatch ? parseInt(iMatch[1], 10) : null;
399
- if (inst === null) continue;
411
+ if (inst === null || !isFinite(inst) || inst < 1) continue;
412
+ if (inst > maxInstanceSeen) maxInstanceSeen = inst;
413
+ var slotKey = inst + ":" + name;
414
+ if (seenSlot[slotKey]) { duplicate = true; continue; }
415
+ seenSlot[slotKey] = true;
400
416
  if (!hops[inst - 1]) hops[inst - 1] = { instance: inst };
401
417
  hops[inst - 1][name] = value;
402
418
  }
@@ -405,14 +421,48 @@ async function arcVerify(rfc822, opts) {
405
421
  return { chainStatus: "none", hopCount: 0, hops: [] };
406
422
  }
407
423
 
408
- // 2. Structural check — every hop must carry all three headers.
409
- var structuralFail = hops.some(function (h) {
410
- return !h || !h["arc-seal"] || !h["arc-message-signature"] ||
411
- !h["arc-authentication-results"];
412
- });
424
+ if (duplicate) {
425
+ return {
426
+ chainStatus: "fail",
427
+ reason: "duplicate-instance",
428
+ hopCount: hops.filter(Boolean).length,
429
+ hops: hops.filter(Boolean).map(function (h) {
430
+ return { instance: h.instance,
431
+ hasSeal: !!h["arc-seal"],
432
+ hasMessageSignature: !!h["arc-message-signature"],
433
+ hasAuthenticationResults: !!h["arc-authentication-results"],
434
+ amsResult: "skipped", asResult: "skipped" };
435
+ }),
436
+ };
437
+ }
438
+
439
+ if (maxInstanceSeen > ARC_MAX_HOPS) {
440
+ return {
441
+ chainStatus: "fail",
442
+ reason: "too-many-hops",
443
+ hopCount: maxInstanceSeen,
444
+ hops: [],
445
+ };
446
+ }
447
+
448
+ // 2. Structural check — every hop must carry all three headers AND
449
+ // the chain must start at i=1 with no gaps. RFC 8617 §5.1 requires
450
+ // instances to form a contiguous 1..N sequence. Indexed loop (not
451
+ // .some) because sparse arrays skip empty slots in callbacks —
452
+ // a non-contiguous chain ([hop1, , hop3]) would silently pass.
453
+ var structuralFail = false;
454
+ for (var sci = 0; sci < hops.length; sci += 1) {
455
+ var sch = hops[sci];
456
+ if (!sch || !sch["arc-seal"] || !sch["arc-message-signature"] ||
457
+ !sch["arc-authentication-results"]) {
458
+ structuralFail = true;
459
+ break;
460
+ }
461
+ }
413
462
  if (structuralFail) {
414
463
  return {
415
464
  chainStatus: "fail",
465
+ reason: "incomplete-or-non-contiguous",
416
466
  hopCount: hops.filter(Boolean).length,
417
467
  hops: hops.filter(Boolean).map(function (h) {
418
468
  return { instance: h.instance,
@@ -454,23 +504,73 @@ async function arcVerify(rfc822, opts) {
454
504
  if (amsResult.result !== "pass" || asResult.result !== "pass") anyFail = true;
455
505
  }
456
506
 
457
- // 4. Chain Validation per §5.2: the most recent AS's cv= must
458
- // reflect the validity of every prior hop.
459
- var lastAs = hops[hops.length - 1]["arc-seal"];
460
- var cvMatch = lastAs.match(/(?:^|[;,\s])cv=(none|pass|fail)/);
461
- var cv = cvMatch ? cvMatch[1] : null;
507
+ // 4. Chain Validation per RFC 8617 §5.2.
508
+ //
509
+ // Per-hop cv= self-attestation rules:
510
+ // i=1 — cv=none REQUIRED (no upstream chain to validate)
511
+ // i>=2 — cv=pass or cv=fail; cv=none is invalid at i>=2
512
+ //
513
+ // Once any hop's AS reports cv=fail, the chain is permanently
514
+ // broken — downstream cv=pass claims after an upstream cv=fail
515
+ // are malformed (a hop can't claim the chain validates when it
516
+ // knows an earlier hop saw it fail).
517
+ var perHopCv = [];
518
+ var hopRuleViolation = null;
519
+ var sawFail = false;
520
+ for (var hi = 0; hi < hops.length; hi += 1) {
521
+ var as = hops[hi]["arc-seal"];
522
+ var hopCvMatch = as.match(/(?:^|[;,\s])cv=(none|pass|fail)/);
523
+ var hopCv = hopCvMatch ? hopCvMatch[1] : null;
524
+ perHopCv.push(hopCv);
525
+ if (hopCv === null) {
526
+ hopRuleViolation = "missing-cv-at-i=" + (hi + 1);
527
+ break;
528
+ }
529
+ if (hi === 0 && hopCv !== "none") {
530
+ hopRuleViolation = "i=1-cv-must-be-none-got-" + hopCv;
531
+ break;
532
+ }
533
+ if (hi >= 1 && hopCv === "none") {
534
+ hopRuleViolation = "i=" + (hi + 1) + "-cv=none-invalid-after-hop-1";
535
+ break;
536
+ }
537
+ if (hopCv === "fail") sawFail = true;
538
+ if (hopCv === "pass" && sawFail) {
539
+ hopRuleViolation = "i=" + (hi + 1) + "-cv=pass-after-upstream-fail";
540
+ break;
541
+ }
542
+ }
462
543
 
463
- var chainStatus = anyFail ? "fail" :
464
- (hops.length === 1 && cv === "none") ? "pass" :
465
- (hops.length > 1 && cv === "pass") ? "pass" :
466
- "fail";
544
+ var lastCv = perHopCv[perHopCv.length - 1];
545
+ var chainStatus;
546
+ var reasonOut = null;
547
+ if (hopRuleViolation) {
548
+ chainStatus = "fail";
549
+ reasonOut = hopRuleViolation;
550
+ } else if (anyFail) {
551
+ chainStatus = "fail";
552
+ reasonOut = "signature-verification-failed";
553
+ } else if (lastCv === "fail") {
554
+ chainStatus = "fail";
555
+ reasonOut = "last-as-cv=fail";
556
+ } else if (hops.length === 1 && lastCv === "none") {
557
+ chainStatus = "pass";
558
+ } else if (hops.length > 1 && lastCv === "pass") {
559
+ chainStatus = "pass";
560
+ } else {
561
+ chainStatus = "fail";
562
+ reasonOut = "unexpected-cv-state";
563
+ }
467
564
 
468
- return {
565
+ var out = {
469
566
  chainStatus: chainStatus,
470
567
  hopCount: hops.length,
471
- cv: cv,
568
+ cv: lastCv,
569
+ perHopCv: perHopCv,
472
570
  hops: perHop,
473
571
  };
572
+ if (reasonOut) out.reason = reasonOut;
573
+ return out;
474
574
  }
475
575
 
476
576
  // Verify a single AMS or AS within the chain by reconstructing the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.7.39",
3
+ "version": "0.7.41",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.5",
5
- "serialNumber": "urn:uuid:3c8db39e-745e-4810-ae2a-7b527c808492",
5
+ "serialNumber": "urn:uuid:133bf37c-6b3d-4650-85be-95672ffff590",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-05T19:17:04.824Z",
8
+ "timestamp": "2026-05-05T19:17:17.538Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/core@0.7.39",
22
+ "bom-ref": "@blamejs/core@0.7.41",
23
23
  "type": "library",
24
24
  "name": "blamejs",
25
- "version": "0.7.39",
25
+ "version": "0.7.41",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "The Node framework that owns its stack.",
29
- "purl": "pkg:npm/%40blamejs/core@0.7.39",
29
+ "purl": "pkg:npm/%40blamejs/core@0.7.41",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/core@0.7.39",
57
+ "ref": "@blamejs/core@0.7.41",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]