@blamejs/core 0.18.34 → 0.18.36

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,71 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.18.x
10
10
 
11
+ - v0.18.36 (2026-08-18) — **Six constant-time comparisons told you which candidate matched, by how long they took.** A comparison that runs in constant time answers one question without leaking anything. A loop of them that stops at the first match answers a second question nobody asked: how far down the list the match was. Six places in the framework compared a presented value against a list of candidates and returned as soon as one matched, so the response time reported the match's position — which deny-list entry a certificate hit, which verifier's nonce a token carried, where in a key rotation the sender currently sits.
12
+
13
+ Every one of them returned the correct answer. The defect is only in how long it took, which is why no test caught it and why the guard that replaces them is structural.
14
+
15
+ **Upgrade if you use `b.crypto.isCertRevoked`, `b.webhookHmac.verify`, `b.standardWebhooks.verify`, `b.httpMessageSignature.verify`, `b.eat` nonce verification, or the DPoP nonce middleware.** No API changes, no configuration changes — the comparisons simply stop reporting position. **Added:** *b.crypto.timingSafeEqualAny(presented, candidates)* — Returns `true` when `presented` equals any entry of `candidates`, comparing against every entry whatever the outcome. The loop has no `break` and no `return` inside it, and folds each result into an accumulator rather than writing `matched || compare(...)` — that short-circuits the call away once something matches, which is the early exit wearing different syntax.
16
+
17
+ A candidate that is neither a Buffer nor a string counts as a non-match rather than throwing, because a deny list assembled from operator configuration should not fail the request on one malformed row. The presented value and the array itself are entry-tier: passing the wrong shape is a caller bug that would otherwise answer `false` for every input. **Fixed:** *b.auth.totp gains a drift-window and replay suite* — Fixing the drift loop exposed that nothing tested it. TOTP had assertions elsewhere, but `driftSteps` and `lastUsedStep` appeared in none of them — so the window's width, which step `verify()` reports, and the replay guard were all unverified.
18
+
19
+ The new suite pins them against RFC 6238's Appendix B vectors, driven through the verify-only SHA-1 path an operator migrating from a legacy authenticator actually uses. It asserts that every step in the window verifies and reports its OWN step rather than the current one — reporting the current step would quietly make `lastUsedStep` replay-tracking useless — that a step outside the window is refused and the same code is accepted once the window widens, that a replayed step is refused while the next step still works, and that a malformed token reads as no-match while a misconfigured verifier still throws. · *Four NIST SP 800-53 control mappings corrected* — `docs/cis-sqlite-equivalent.md` maps the framework's posture to Revision 5 and cited three enhancements that Revision 5 withdrew: `AU-8(1)` for time-source synchronisation, now `SC-45(1)`; `IA-2(11)` for step-up authentication, now `IA-2(6)`; and `CP-2(8)` for recovery objectives, now folded into `CP-2`.
20
+
21
+ The fourth was a live enhancement claimed for a mechanism that does not meet it. `AU-9(1)` requires audit trails written to hardware-enforced write-once media; the framework's WORM tables are a database trigger refusing UPDATE and DELETE. That is software enforcement, and an assessor would reject the mapping. The row now cites `AU-9` and says what a deployment claiming the enhancement has to add underneath. **Security:** *Six multi-candidate comparisons no longer leak the matching candidate's position* — Each of these compared a presented value against a list and exited on the first hit:
22
+
23
+ - `b.crypto.isCertRevoked` — which entry of the deny list the certificate's fingerprint matched. Its own docstring promised the opposite: "every comparison runs through `crypto.timingSafeEqual` so the answer doesn't leak which entry matched", above a loop that returned inside itself.
24
+ - `b.eat` nonce verification — which verifier's nonce an attestation token carried, under a comment reading "Constant-time compare against each candidate".
25
+ - The DPoP nonce middleware — whether a client presented the current or the previous nonce, which locates it in the rotation window. Its comment claimed the compare existed "so server-issued nonce probing can't narrow the rolling-pair bytes via response-timing".
26
+ - `b.webhookHmac.verify` and `b.standardWebhooks.verify` — the position of the matching signature in a multi-signature header, which tracks the sender's key rotation.
27
+ - `b.httpMessageSignature.verify` — the position of the matching member in a `Content-Digest` header.
28
+
29
+ All six now compare against every candidate. A length mismatch still short-circuits a single comparison and still leaks nothing: the lengths involved are digest and fingerprint sizes fixed by an algorithm, not secrets. · *Two more, found by checking loop scope instead of matching text* — The six above were found by reading code. Replacing the text pattern that guarded them with one that actually resolves loop bodies — brace matching over comment-stripped source — turned up two the pattern could not have seen, because neither exits with the `return true` it was looking for:
30
+
31
+ - `b.auth.password` breach checking returned on the first HaveIBeenPwned range line that matched, so the response time reported WHERE in the range the hit was. The range is public; the position narrows the SHA-1 suffix, which is precisely the half of the hash that k-anonymity exists to withhold. Every line is now compared before the verdict.
32
+ - `b.totp.verify` returned on the first drift step that matched, reporting the clock offset between authenticator and server through timing. Every step in the window is now compared, and the earliest match still wins.
33
+
34
+ Neither is guarded by a `matched === null` condition on the comparison itself — that short-circuits the call away once something matches, which is the same early exit in different syntax.
35
+
36
+ `b.network.dns.dane.matchCertificate` also exits on its first match and is deliberately left alone: it compares a TLSA record published in DNS against a digest of the certificate presented in the handshake. Both operands are public, so the iteration count reveals nothing an observer could not compute, and reporting which record matched is the function's contract. **Detectors:** *A detector can pin the shapes it must NOT refuse* — A `codebase-patterns` entry may now carry `fixtures: { fires, quiet }`, and a check runs them: every listed shape must fire, every listed shape must stay quiet. Nothing enforced that before, so a pattern's precision lived in whatever its author happened to try by hand — which is one direction, against the code the pattern was written for.
37
+
38
+ The entry added in this release got both directions wrong before the fixtures existed. It refused a single negated comparison (`if (!timingSafeEqual(a, b)) return false; return true;` — one comparison, no position to leak), and separately refused a `.find()` locating a record by type merely because a comparison appeared nearby. Neither shape was visible from the code the pattern was aimed at. · *The live-integration runner no longer counts a file that asserted nothing as a pass* — The runner decided a file had passed from its exit status, then printed whatever `OK — <n> checks passed` line the file happened to emit. Four of the fifty integration files never emitted one, so their column was blank and nobody could tell the difference between a file that ran its assertions and a file whose `run()` resolved before reaching them. Between them those four carry 111 checks.
39
+
40
+ Exit status alone cannot answer the question: a file that stops asserting exits 0 exactly like a file that passed. The runner now requires the count line and requires it to be greater than zero — `OK — 0 checks passed` matches the old pattern while verifying nothing — and the four silent files report their counts.
41
+
42
+ A static check covers the same ground during the fast gates, since the runner needs the live backends to say anything. It reads the source with comment lines blanked: a commented-out success print contains every token the check looks for, and that is the likeliest way a file goes silent, so reading raw source would pass on exactly the state being guarded against. The same blanking now backs the fuzz-harness entry-point check, where a commented-out `module.exports.fuzz` would otherwise read as a wired harness. · *A constant-time compare in a loop may not exit early* — Two checks, split by what each can actually decide.
43
+
44
+ A pattern covers the loop-free shape: two comparisons in sequence, each guarding its own early exit — the DPoP current/previous nonce pair. That is a sequence of tokens, so a pattern decides it exactly.
45
+
46
+ Loop membership is decided by matching braces over comment-stripped source instead, because whether a comparison sits inside a particular loop body is a question about scope. Successive attempts to answer it with a pattern flagged an ordinary one-pair verifier that merely followed an unrelated loop — a text pattern stops at a brace in the first column, and loop bodies close indented — while missing `for await`, `do…while`, and brace-less single-statement bodies. Resolving the body outright covers all of them and found the two additional leaks above.
47
+
48
+ A `continue` is not an early exit for this purpose: it skips a candidate that was never eligible and the loop still runs to the end, which is what `timingSafeEqualAny` does. Counting it flagged the primitive written to fix the defect.
49
+
50
+ An ordinary one-pair verifier stays quiet in both checks — one comparison has no position to report.
51
+
52
+ This one is the primary guard rather than the usual second line, and the reason is worth stating: every affected site produced the correct output, so no assertion on a return value could observe the defect, and a wall-clock measurement cannot assert it reliably under a parallel test runner. The property is structural — the loop must have no exit to take — so the check is structural too, and the fix routes through a primitive that cannot be talked into returning early rather than asking each loop to be reviewed.
53
+
54
+ Its scope is deliberately narrow, and the boundary is recorded in the entry itself. A comparison inside a `.some` or `.find` callback is the same defect, and the check does not claim it: deciding whether a call sits inside a particular callback is a parsing question, and every attempt to answer it with a pattern either refused working code or missed a real shape. What it does cover — a positive comparison guarding an early exit — is the shape all six of these took. **References:** [NIST SP 800-53 Rev. 5 SC-45(1) — Synchronization with Authoritative Time Source](https://csf.tools/reference/nist-sp-800-53/r5/sc/sc-45/sc-45-1/) · [NIST SP 800-53 Rev. 5 AU-9(1) — Hardware Write-once Media](https://csf.tools/reference/nist-sp-800-53/r5/au/au-9/au-9-1/) · [RFC 9449 — OAuth 2.0 Demonstrating Proof of Possession (DPoP), nonce handling](https://www.rfc-editor.org/rfc/rfc9449.html) · [RFC 9421 — HTTP Message Signatures, Content-Digest verification](https://www.rfc-editor.org/rfc/rfc9421.html)
55
+
56
+ - v0.18.35 (2026-08-18) — **Thirteen character policies the guards declared and did not apply, and two content guards that could not perform the repair they promised.** A guard's profile names a policy per character class — `reject`, `strip`, `audit`, `allow`. Thirteen of those settings across `b.guardEmail`, `b.guardXml`, `b.guardMarkdown`, `b.guardJson` and `b.guardYaml` resolved to something other than what they named, because the code decided by the finding's SEVERITY rather than by the policy. Severity is fixed per class — a bidi control is `critical` however it is configured — so a severity-gated decision refuses what an operator asked to record and, in a guard whose findings never reach `critical`, lets through what they asked to reject. Both directions were live at once.
57
+
58
+ Every declared character policy now resolves to the action it names, and a family-wide test asserts it for all five classes across every content guard.
59
+
60
+ **Read the behaviour changes below before upgrading if you rely on `b.guardEmail`, `b.guardXml.sanitize`, `b.guardMarkdown.sanitize` or `b.guardJson.parse`.** Two of them refuse more than before and three repair where they used to throw.
61
+
62
+ `b.guardJson` and `b.guardYaml` were `KIND: "content"` — whose contract is serve / audit-only / sanitize / refuse — and declared `strip` in six policy cells each while exporting no `sanitize` at all. Twelve settings that no wiring could honour. Both now have one. **Added:** *b.guardJson.sanitize and b.guardYaml.sanitize* — Both guards are content guards whose contract includes a repair action, and both declared `strip` policies with nothing to perform them. `b.guardJson.sanitize` applies the character-strip policies and then re-serializes, which is the repair its gate already ran — now shared, so the two cannot drift. `b.guardYaml.sanitize` repairs at the character level only: removing an invisible or control character is a text edit needing no re-emit, which is why it is safe here when re-serializing a YAML document is not. The tag, alias and multi-document shapes have no faithful round-trip and refuse through their own policies. · *b.gateContract.throwOnRefusedDisposition(issues, cfg)* — The refusal step for a guard whose findings carry a policy. Throws on the first finding whose disposition is `refuse`, and hands whatever the guard leaves unclassified to `throwOnRefusalSeverity`, so a structural finding with no policy — a parse failure, an alias explosion, a blown cap — keeps the conservative severity answer rather than a free pass. Replaces the hand-rolled loop this release would otherwise have grown in three places. **Fixed:** *A declared character policy is the policy that runs* — `b.gateContract.charThreatDisposition` binds each of the five shared character classes to its own policy, and the guards that never routed through it fell back to a severity rule where `critical` and `high` both refuse. `b.guardYaml` and `b.guardEmail` now route through it, so `strip` repairs and `audit` records instead of both refusing.
63
+
64
+ The same confusion sat one level down in the sanitize path. `b.guardXml` and `b.guardMarkdown` narrowed their refusal filter to `critical`, which catches a bidi control whatever its policy says — so `bidiPolicy: "strip"` threw from `sanitize()` while the gate, which bypasses that function precisely to avoid this, stripped the character and served the document. Two public entry points gave opposite answers for one setting. Refusal now comes from the policy in both.
65
+
66
+ `b.guardJson.parse` refused any `critical` pre-parse finding, so `bidiPolicy: "audit"` refused the document rather than recording it; it also stripped control, zero-width and Tags characters under `strip` but never bidi, so that policy left the character in place and then refused it for being critical. Both are fixed. · *b.guardEmail refuses a bidi override and a null byte at `balanced`* — **Behaviour change.** These two cells declared `strip` and refused anyway, because both findings are critical and the refusal ran before the strip table. Of the two ways to make the declaration and the behaviour agree, this is the one that does not weaken a guard, and it is the right reading for a message: a bidi override reorders how an address or a subject line renders, which is the mechanism behind a spoofed sender, and a null byte truncates a header at whichever parser reads it first, so two halves of a message can disagree about where a field ends.
67
+
68
+ Nothing that was accepted before is refused now — the profile text changed to match what the guard already did. Operators who set `bidiPolicy` or `nullBytePolicy` explicitly are unaffected. · *b.guardXml.sanitize, b.guardMarkdown.sanitize and b.guardJson.parse repair where they used to throw* — **Behaviour change.** Under `balanced` and `permissive` these declare `strip` or `audit` for the character classes, and all three now honour it: `sanitize()` returns the repaired document instead of raising, and `parse()` accepts a document carrying an audited character.
69
+
70
+ A caller that treated the exception as "this input is dangerous" now receives a repaired value instead. That is what `strip` asks for, and it is what the corresponding gates already did — the sanitize functions were the outliers. Every class set to `reject` still refuses, verified per guard per profile. · *guardJson.parse enforces its byte ceiling before it strips anything* — `maxBytes` was checked at the end of `parse`, after the character-strip policies had already run — so a small document padded with several hundred zero-width, Tags or bidi characters shrank under the limit on the way through and was accepted. A 911-byte input passed a 16-byte cap. A resource cap that an attacker removes by sending MORE input is pointing the wrong way, so the ceiling now binds the input as received, before any strip mutates it.
71
+
72
+ The hole was open for zero-width and Tags already; a `bidiPolicy: "strip"` pass added in this release made it reachable for a third class, which is how it was noticed. · *guardJson.sanitize repairs a mid-stream BOM under bomPolicy* — U+FEFF reached the strip table only as a zero-width character, and `parse` removed only a LEADING one — so with `bomPolicy: "strip"` and `zeroWidthPolicy: "allow"`, validation reported `bom-mid-stream` on a document `sanitize` then returned still carrying it. The repair follows the BOM's own policy now: `strip` removes it wherever it sits, `reject` refuses, `allow` keeps it. · *Vendored dependencies are current* — `@blamejs/pki` moves to 0.5.10, whose change is a spelling normalization across its documentation and source comments plus a gate that keeps them consistent. No functional change to the module, no lifecycle scripts, and its runtime dependency set stays empty. · *b.guardArchive drops four character policies it never read* — Every profile declared `bidiPolicy`, `controlPolicy`, `nullBytePolicy` and `zeroWidthPolicy`, and nothing in the guard consulted any of them — an operator passing `zeroWidthPolicy: "reject"` was configuring nothing and had no way to discover that. Entry-name character policy comes from `filenameProfile`, which routes the name through `b.guardFilename`; that is the setting to use, and it is unchanged. The four are removed rather than wired, because two spellings of one setting is how they come to disagree. **Detectors:** *A guard may only declare a policy it can perform, and must perform the one it declares* — Two family-wide invariants. The first is structural: for every registered guard, a policy that promises a repair requires a `sanitize` to perform it, and an `entries` guard — which cannot repair a hostile archive member — may not declare one at all. That is the check twelve unperformable settings shipped past.
73
+
74
+ The second drives the gate: for every content guard, profile and character class, the action taken must be the action the policy names. It compares against a baseline of the same document without the character and judges only where the injected character is the lone new finding, so a document broken by the injection is not mistaken for a policy failure. All five classes are covered, with no exemptions. · *A failing check prints the diagnostic it built* — The shared test assertion accepted a third argument carrying the offending items, and discarded it at all 1829 call sites that passed one, so a failing gate printed its label and nothing else. It is now included, flattened to one line and bounded, for the same reason the error formatter is: the text can carry a fixture's bytes verbatim. The first run after the change named a third affected guard that the label alone had hidden. **References:** [CVE-2021-42574 — Trojan Source, bidirectional override characters](https://nvd.nist.gov/vuln/detail/CVE-2021-42574) · [Unicode Technical Report #36 — Unicode Security Considerations](https://www.unicode.org/reports/tr36/) · [Unicode Standard Annex #9 — Unicode Bidirectional Algorithm](https://www.unicode.org/reports/tr9/)
75
+
11
76
  - v0.18.34 (2026-08-17) — **Two policies the framework declared and did not apply: a wildcard registry's organizational domain, and a guard's refusal of invisible characters.** Eight guards resolved `zeroWidthPolicy: "reject"` under `profile: "strict"` and accepted a zero-width character anyway, because the shared scan was opt-in through an argument six callers never passed. `b.guardYaml`, `b.guardXml`, `b.guardMarkdown`, `b.guardShell`, `b.guardRegex`, `b.guardJsonpath`, `b.guardTemplate` and `b.guardJson` are affected, and `b.guardDomain` at every profile. The control table was also missing `U+007F`, contradicting the module's own predicate, and the zero-width table was missing `U+2061`-`U+2064`. **A strict profile now refuses input it previously served** — check that before upgrading if you validate operator-supplied text.
12
77
 
13
78
  `b.publicSuffix` ranked a matching exact rule above a matching wildcard rule. The Public Suffix List ranks by label count instead, so `x.kawasaki.jp` takes `*.kawasaki.jp` and not `jp` — and under the old order every name in such a registry collapsed onto the registry's own organizational domain. 275 of the list's 283 wildcard rules resolved that way. Anything deriving a boundary from that answer inherited it: DMARC relaxed alignment, cookie scope, same-site decisions. If you serve or accept mail from names under a wildcard registry, this changes what counts as aligned.
package/NOTICE CHANGED
@@ -68,7 +68,7 @@ Used for: FIPS 203 ML-KEM (ml_kem_512 / ml_kem_768 / ml_kem_1024),
68
68
  reference implementation.
69
69
  --------------------------------------------------------------------------------
70
70
  Component: @blamejs/pki
71
- Version: 0.5.9
71
+ Version: 0.5.10
72
72
  Source: https://github.com/blamejs/pki
73
73
  License: Apache-2.0
74
74
  Copyright: Copyright (c) blamejs contributors
package/README.md CHANGED
@@ -322,7 +322,7 @@ All runtime dependencies are committed to the repo — no transitive npm install
322
322
  | [`@noble/hashes`](https://github.com/paulmillr/noble-hashes) | 2.3.0 | [Paul Miller](https://github.com/paulmillr) | Browser (ESM) build only — SHAKE256 / SHA-3 / SHA-2 / HMAC / HKDF for the client half of a hybrid exchange. The server side reaches all of these through `node:crypto`, so there is no server bundle |
323
323
  | [`@noble/curves`](https://github.com/paulmillr/noble-curves) | 2.3.0 (bundles @noble/hashes 2.3.0) | [Paul Miller](https://github.com/paulmillr) | RFC 9497 Oblivious Pseudo-Random Function (OPRF / VOPRF / POPRF) over ristretto255 / P-256 / P-384 / P-521, behind `b.crypto.oprf` |
324
324
  | [`@noble/post-quantum`](https://github.com/paulmillr/noble-post-quantum) | 0.7.0 (bundles @noble/hashes, @noble/curves, @noble/ciphers 2.3.0) | [Paul Miller](https://github.com/paulmillr) | Pure-JS FIPS 203 ML-KEM (`ml_kem_512` / `ml_kem_768` / `ml_kem_1024`), FIPS 204 ML-DSA (`ml_dsa_44/65/87`), FIPS 205 SLH-DSA (`slh_dsa_*`). First-class on both server-side and client-side via `b.pqcSoftware` — security-first defaults pin to the highest cat-5 levels (ML-KEM-1024, ML-DSA-87, SLH-DSA-SHAKE-256f); interoperable with Node's built-in WebCrypto ML-KEM that `b.crypto.encrypt` / `b.middleware.apiEncrypt` use. A browser (ESM) build ships beside it carrying the KEM suites only — a client half encapsulates and does not sign |
325
- | [`@blamejs/pki`](https://github.com/blamejs/pki) | 0.5.9 | [blamejs](https://github.com/blamejs) | Zero-dependency pure-JS X.509 / CRL / PKCS#12 / CSR / CMS toolkit backing `b.mtlsCa` — ML-DSA-87 (FIPS 204) post-quantum + ECDSA-P384 cert signing, PBMAC1 PKCS#12 packaging, chain validation (no openssl CLI) — and the WebAuthn attestation / assertion verification behind `b.auth.passkey` |
325
+ | [`@blamejs/pki`](https://github.com/blamejs/pki) | 0.5.10 | [blamejs](https://github.com/blamejs) | Zero-dependency pure-JS X.509 / CRL / PKCS#12 / CSR / CMS toolkit backing `b.mtlsCa` — ML-DSA-87 (FIPS 204) post-quantum + ECDSA-P384 cert signing, PBMAC1 PKCS#12 packaging, chain validation (no openssl CLI) — and the WebAuthn attestation / assertion verification behind `b.auth.passkey` |
326
326
  | [`SecLists` 10k-most-common.txt](https://github.com/danielmiessler/SecLists/blob/master/Passwords/Common-Credentials/10k-most-common.txt) | master snapshot | [Daniel Miessler / SecLists contributors](https://github.com/danielmiessler/SecLists) (CC-BY-3.0) | Top-10000 common-password dictionary read by `b.auth.password.policy()` for the NIST 800-63B §5.1.1.2 "previously breached" check |
327
327
  | [`prismjs`](https://prismjs.com/) | 1.30.0 | [Lea Verou + contributors](https://github.com/PrismJS/prism) | Syntax highlighting in the example wiki's code blocks (browser-side) |
328
328
 
@@ -346,7 +346,7 @@ Because when something breaks, `blame` should know exactly where it lives. We ow
346
346
 
347
347
  Every release passes a layered gate at `test/layer-0-primitives/codebase-patterns.test.js` that operates on lib/ source:
348
348
 
349
- - **Bug-class detectors** — raw byte / time literals, `JSON.parse` on operator input without size cap, numeric opts that silently accept `Infinity` / `NaN`, ReDoS-risky regex without length cap, hash / token compares without `timingSafeEqual`, raw `new URL` skipping the SSRF gate, `Math.random()` in security-sensitive paths, and a couple dozen others — each a bug class the framework already swept once and won't re-introduce.
349
+ - **Bug-class detectors** — raw byte / time literals, `JSON.parse` on operator input without size cap, numeric opts that silently accept `Infinity` / `NaN`, ReDoS-risky regex without length cap, hash / token compares without `timingSafeEqual`, a constant-time compare in a loop that exits on the first match (which leaks the matching candidate's position however constant each comparison is), raw `new URL` skipping the SSRF gate, `Math.random()` in security-sensitive paths, and a couple dozen others — each a bug class the framework already swept once and won't re-introduce.
350
350
  - **Inline-shape catalog (n=1)** — every primitive that's been extracted (`validateOpts.requireNonEmptyString`, `safeAsync.makeScheduledFlush`, `dbSchema.runInTransaction`, etc.) registers the inline shape it replaced; new code that re-implements the shape fails the gate even if it's the only file matching.
351
351
  - **Cluster allowlist (n>=3)** — duplicate-block detection across files. Genuine new clusters get extracted; clusters that resist extraction (parser error class signature mismatches, framework-convention shapes, cross-domain coincidences) get an entry with a documented structural reason. No silent allowlisting.
352
352
 
@@ -460,6 +460,7 @@ function policy(opts) {
460
460
  var lines = bodyText.split(/\r?\n/);
461
461
  var goodLines = 0;
462
462
  var badLines = 0;
463
+ var breachedCount = null;
463
464
  for (var li = 0; li < lines.length; li++) {
464
465
  var line = lines[li].trim();
465
466
  if (line.length === 0) continue;
@@ -469,13 +470,21 @@ function policy(opts) {
469
470
  var count = parseInt(line.slice(colon + 1), 10);
470
471
  if (!isFinite(count)) { badLines += 1; continue; }
471
472
  goodLines += 1;
472
- if (timingSafeEqual(Buffer.from(hashSuffix, "utf8"), Buffer.from(suffix, "utf8")) &&
473
- count >= p.breachThreshold) {
474
- return _fail("breached",
475
- "plaintext appears in HaveIBeenPwned with count " + count +
476
- " (threshold " + p.breachThreshold + ")");
473
+ // Compare against every line, then decide. Returning on the first match
474
+ // made the response time report WHERE in the range response the hit was,
475
+ // and the range is public — so that position narrows the suffix, which is
476
+ // the half of the password's SHA-1 that k-anonymity exists to keep back.
477
+ // The condition order matters too: `count >= threshold &&` first would
478
+ // skip the comparison on low-count lines and leak through that instead.
479
+ if (timingSafeEqual(Buffer.from(hashSuffix, "utf8"), Buffer.from(suffix, "utf8"))) {
480
+ if (count >= p.breachThreshold && breachedCount === null) breachedCount = count;
477
481
  }
478
482
  }
483
+ if (breachedCount !== null) {
484
+ return _fail("breached",
485
+ "plaintext appears in HaveIBeenPwned with count " + breachedCount +
486
+ " (threshold " + p.breachThreshold + ")");
487
+ }
479
488
  // If a hostile / poisoned mirror returned a response shaped like
480
489
  // HIBP but with mostly-unparseable counts, the original loop
481
490
  // skipped them silently and reported breachCheckCount=0 — i.e.
package/lib/crypto.js CHANGED
@@ -570,6 +570,66 @@ function timingSafeEqual(a, b) {
570
570
  return nodeCrypto.timingSafeEqual(bufA, bufB);
571
571
  }
572
572
 
573
+ /**
574
+ * @primitive b.crypto.timingSafeEqualAny
575
+ * @signature b.crypto.timingSafeEqualAny(presented, candidates)
576
+ * @since 0.18.36
577
+ * @status stable
578
+ * @related b.crypto.timingSafeEqual, b.crypto.isCertRevoked, b.crypto.spkiPinVerifier
579
+ *
580
+ * Returns `true` when `presented` equals any entry of `candidates`, comparing
581
+ * against EVERY entry whatever the outcome. A per-candidate compare in a loop
582
+ * that stops at the first match answers a second question nobody asked — how
583
+ * far down the list the match was — and the caller's own timing then reports
584
+ * it: matching the first pinned key returns sooner than matching the last, and
585
+ * matching nothing runs the whole list. That is a position oracle over a secret
586
+ * ordering, so this loop has no `break`, no `return` inside it, and folds each
587
+ * result into an accumulator instead.
588
+ *
589
+ * A length mismatch short-circuits a single comparison, and that leaks nothing:
590
+ * the lengths compared here are digest and fingerprint sizes fixed by an
591
+ * algorithm, not secrets. `presented` and each candidate may be a Buffer or a
592
+ * string; a candidate that is neither counts as a non-match rather than
593
+ * throwing, because a deny list assembled from operator config should not fail
594
+ * the request on one malformed row.
595
+ *
596
+ * Entry-tier on `presented` and on the array itself: a caller that passes the
597
+ * wrong shape has a bug, and the compare would otherwise silently answer
598
+ * `false` for every input.
599
+ *
600
+ * @example
601
+ * var ok = b.crypto.timingSafeEqualAny(presentedKey, [primary, previous]);
602
+ * // → true when it matches either, in the same time either way
603
+ */
604
+ function timingSafeEqualAny(presented, candidates) {
605
+ if (!Buffer.isBuffer(presented) && typeof presented !== "string") {
606
+ throw new TypeError(
607
+ "crypto.timingSafeEqualAny: argument 'presented' must be a Buffer or " +
608
+ "string, got " + (presented === null ? "null" : typeof presented)
609
+ );
610
+ }
611
+ if (!Array.isArray(candidates)) {
612
+ throw new TypeError(
613
+ "crypto.timingSafeEqualAny: argument 'candidates' must be an array, got " +
614
+ (candidates === null ? "null" : typeof candidates)
615
+ );
616
+ }
617
+ var presentedBuf = Buffer.isBuffer(presented)
618
+ ? presented : Buffer.from(presented, "utf8");
619
+ var matched = false;
620
+ for (var i = 0; i < candidates.length; i += 1) {
621
+ var candidate = candidates[i];
622
+ if (!Buffer.isBuffer(candidate) && typeof candidate !== "string") continue;
623
+ var candidateBuf = Buffer.isBuffer(candidate)
624
+ ? candidate : Buffer.from(candidate, "utf8");
625
+ if (candidateBuf.length !== presentedBuf.length) continue;
626
+ // `matched ||` would short-circuit the call away once something matched,
627
+ // which is the early exit wearing a different syntax.
628
+ if (nodeCrypto.timingSafeEqual(presentedBuf, candidateBuf)) matched = true;
629
+ }
630
+ return matched;
631
+ }
632
+
573
633
  // ===========================================================
574
634
  // Public API — built on core primitives
575
635
  // ===========================================================
@@ -2088,18 +2148,23 @@ function isCertRevoked(pemOrDer, denyList) {
2088
2148
  var fp = hashCertFingerprint(pemOrDer);
2089
2149
  var fpHex = Buffer.from(fp.hex, "hex");
2090
2150
  var fpColon = Buffer.from(fp.colon);
2151
+ // The docstring above promises the answer does not leak which entry matched.
2152
+ // It did: the loop returned inside itself, so a hit on the first deny-list
2153
+ // row answered sooner than a hit on the last, and a miss ran the whole list.
2154
+ // Split by form and route each half through timingSafeEqualAny, which has no
2155
+ // exit to take. The two buckets are compared independently because the
2156
+ // colon form and the hex form are different byte lengths.
2157
+ var colonEntries = [];
2158
+ var hexEntries = [];
2091
2159
  for (var i = 0; i < denyList.length; i++) {
2092
2160
  var entry = denyList[i];
2093
2161
  if (typeof entry !== "string" || entry.length === 0) continue;
2094
- var normalized = entry.indexOf(":") !== -1 ? entry.toUpperCase() : entry.toLowerCase();
2095
- var normalizedBuf = entry.indexOf(":") !== -1 ? Buffer.from(normalized) : Buffer.from(normalized, "hex");
2096
- var compareBuf = entry.indexOf(":") !== -1 ? fpColon : fpHex;
2097
- if (normalizedBuf.length === compareBuf.length &&
2098
- nodeCrypto.timingSafeEqual(normalizedBuf, compareBuf)) {
2099
- return true;
2100
- }
2162
+ if (entry.indexOf(":") !== -1) colonEntries.push(Buffer.from(entry.toUpperCase()));
2163
+ else hexEntries.push(Buffer.from(entry.toLowerCase(), "hex"));
2101
2164
  }
2102
- return false;
2165
+ var hitColon = timingSafeEqualAny(fpColon, colonEntries);
2166
+ var hitHex = timingSafeEqualAny(fpHex, hexEntries);
2167
+ return hitColon || hitHex;
2103
2168
  }
2104
2169
 
2105
2170
  // ---- RFC 7469 SubjectPublicKeyInfo pinning ----
@@ -2461,6 +2526,7 @@ module.exports = {
2461
2526
  kdf: kdf,
2462
2527
  // Comparison
2463
2528
  timingSafeEqual: timingSafeEqual,
2529
+ timingSafeEqualAny: timingSafeEqualAny,
2464
2530
  // Cert fingerprint helpers
2465
2531
  hashCertFingerprint: hashCertFingerprint,
2466
2532
  isCertRevoked: isCertRevoked,
package/lib/eat.js CHANGED
@@ -88,13 +88,17 @@ function _nonceMatches(claimValue, expected) {
88
88
  var exp = _toBuf(expected);
89
89
  if (!exp) return false;
90
90
  // eat_nonce may be a single byte string or an array of them (one per
91
- // verifier). Constant-time compare against each candidate.
91
+ // verifier). Compare against every candidate: the loop this replaced said
92
+ // "constant-time compare against each candidate" and then returned on the
93
+ // first hit, so the time reported WHICH verifier's nonce matched — an
94
+ // ordering the relying party did not agree to publish.
92
95
  var candidates = Array.isArray(claimValue) ? claimValue : [claimValue];
96
+ var bufs = [];
93
97
  for (var i = 0; i < candidates.length; i++) {
94
98
  var c = _toBuf(candidates[i]);
95
- if (c && c.length === exp.length && bCrypto.timingSafeEqual(c, exp)) return true;
99
+ if (c) bufs.push(c);
96
100
  }
97
- return false;
101
+ return bCrypto.timingSafeEqualAny(exp, bufs);
98
102
  }
99
103
 
100
104
  /**
@@ -1285,6 +1285,71 @@ function resolveProfileName(opts, postures, defaultProfile) {
1285
1285
  return opts.profile || postureProfile || defaultProfile;
1286
1286
  }
1287
1287
 
1288
+ /**
1289
+ * @primitive b.gateContract.throwOnRefusedDisposition
1290
+ * @signature b.gateContract.throwOnRefusedDisposition(issues, cfg)
1291
+ * @since 0.18.35
1292
+ * @status stable
1293
+ * @related b.gateContract.throwOnRefusalSeverity, b.gateContract.charThreatDisposition, b.gateContract.defineGuard
1294
+ *
1295
+ * The refusal step for a guard whose findings carry a POLICY. Throws on the
1296
+ * first issue the guard's `dispositionFor` resolves to `refuse`, and hands
1297
+ * whatever it leaves unclassified to `throwOnRefusalSeverity`, so a kind with
1298
+ * no policy keeps the conservative severity answer instead of a free pass.
1299
+ *
1300
+ * Severity is the wrong axis for these guards and quietly so. `bidi-override`
1301
+ * and `null-byte` are stamped `critical` whatever their policy says, and
1302
+ * `b.guardJson` has eight kinds that never reach `critical` at all — so a
1303
+ * severity-gated refusal both refuses what the operator asked to record and
1304
+ * lets through what they asked to reject, in the same guard. Policy first,
1305
+ * severity only for what policy did not decide.
1306
+ *
1307
+ * @opts
1308
+ * dispositionFor: function, // (issue, opts) -> "refuse"|"sanitize"|"audit"|null
1309
+ * opts: object, // resolved guard opts handed to dispositionFor
1310
+ * errorClass: function, // the guard's FrameworkError subclass; required
1311
+ * codePrefix: string, // error-code namespace; the `.refused` fallback
1312
+ * op: string, // operation name in the message (default "sanitize")
1313
+ * severities: string[], // severities that refuse an UNCLASSIFIED finding
1314
+ * skipKinds: string[], // kinds the caller already handled itself
1315
+ *
1316
+ * @example
1317
+ * b.gateContract.throwOnRefusedDisposition(detect(input, opts), {
1318
+ * dispositionFor: _gateDispositionFor, opts: opts,
1319
+ * errorClass: GuardYamlError, codePrefix: "yaml",
1320
+ * });
1321
+ * // throws on a class whose policy is "reject"; a class set to "strip" or
1322
+ * // "audit" passes through to the caller's transform
1323
+ */
1324
+ function throwOnRefusedDisposition(issues, cfg) {
1325
+ var unclassified = [];
1326
+ for (var i = 0; i < issues.length; i += 1) {
1327
+ var issue = issues[i];
1328
+ if (cfg.skipKinds && cfg.skipKinds.indexOf(issue.kind) !== -1) continue;
1329
+ var disp = typeof cfg.dispositionFor === "function"
1330
+ ? cfg.dispositionFor(issue, cfg.opts || {}) : null;
1331
+ if (disp === "refuse") {
1332
+ throwOnRefusalSeverity([issue], {
1333
+ errorClass: cfg.errorClass,
1334
+ codePrefix: cfg.codePrefix,
1335
+ op: cfg.op,
1336
+ // A policy refusal is the operator's choice, not an impact judgement,
1337
+ // so it must fire whatever severity the finding happens to carry.
1338
+ severities: [issue.severity],
1339
+ });
1340
+ }
1341
+ if (disp !== "sanitize" && disp !== "audit") unclassified.push(issue);
1342
+ }
1343
+ if (unclassified.length) {
1344
+ throwOnRefusalSeverity(unclassified, {
1345
+ errorClass: cfg.errorClass,
1346
+ codePrefix: cfg.codePrefix,
1347
+ op: cfg.op,
1348
+ severities: cfg.severities,
1349
+ });
1350
+ }
1351
+ }
1352
+
1288
1353
  /**
1289
1354
  * @primitive b.gateContract.throwOnRefusalSeverity
1290
1355
  * @signature b.gateContract.throwOnRefusalSeverity(issues, cfg)
@@ -2812,9 +2877,28 @@ function defineGuard(spec) {
2812
2877
  issues[bi].snippet || "sanitize: input is not processable");
2813
2878
  }
2814
2879
  }
2815
- var throwOpts = { errorClass: ErrorClass, codePrefix: prefix };
2816
- if (sanitizeSeverities) throwOpts.severities = sanitizeSeverities;
2817
- throwOnRefusalSeverity(issues, throwOpts);
2880
+ // A guard that classifies its findings by POLICY answers here with the
2881
+ // policy, not with severity. Severity is the wrong axis for these:
2882
+ // guard-json has eight finding kinds that are never `critical`
2883
+ // (bom-leading, comment-block, hex-literal, nan-infinity,
2884
+ // single-quoted-key, trailing-comma, numeric-precision-loss) and
2885
+ // guard-yaml scores its octal ambiguity `high` even under `reject`.
2886
+ // A severity filter narrowed to ["critical"] therefore lets a finding
2887
+ // the operator asked to REFUSE fall past the check and into a
2888
+ // transform that does not repair it — the caller gets the threat back
2889
+ // with no error, which is the exact shape this refusal exists to stop.
2890
+ // Policy decides what refuses; severity covers only what policy left
2891
+ // unclassified. A guard that classifies nothing keeps the pure
2892
+ // severity rule it always had.
2893
+ var refuseOpts = { errorClass: ErrorClass, codePrefix: prefix };
2894
+ if (sanitizeSeverities) refuseOpts.severities = sanitizeSeverities;
2895
+ if (typeof spec.dispositionFor === "function") {
2896
+ refuseOpts.dispositionFor = spec.dispositionFor;
2897
+ refuseOpts.opts = resolved;
2898
+ throwOnRefusedDisposition(issues, refuseOpts);
2899
+ } else {
2900
+ throwOnRefusalSeverity(issues, refuseOpts);
2901
+ }
2818
2902
  }
2819
2903
  var out = spec.sanitizeTransform(subject, resolved);
2820
2904
  if (ampCapField) {
@@ -3256,6 +3340,7 @@ module.exports = {
3256
3340
  makeProfileResolver: makeProfileResolver,
3257
3341
  resolveProfileName: resolveProfileName,
3258
3342
  throwOnRefusalSeverity: throwOnRefusalSeverity,
3343
+ throwOnRefusedDisposition: throwOnRefusedDisposition,
3259
3344
  badInputResultIfNotStringOrBuffer: badInputResultIfNotStringOrBuffer,
3260
3345
  aggregateIssues: aggregateIssues,
3261
3346
  composeHooks: composeHooks,
@@ -113,9 +113,15 @@ var MAGIC_SIGNATURES = Object.freeze([
113
113
 
114
114
  // ---- Profile presets ----
115
115
 
116
+ // Character-class policy for an entry NAME comes from `filenameProfile`, which
117
+ // routes the name through b.guardFilename — this guard never reads a
118
+ // bidi/control/null/zero-width policy of its own. It used to declare them in
119
+ // every profile anyway, so an operator passing `zeroWidthPolicy` here was
120
+ // configuring nothing and had no way to find that out. The declarations are
121
+ // gone rather than wired, because `filenameProfile` is already the one place
122
+ // that decides it and two spellings of the same setting is how they disagree.
116
123
  var PROFILES = Object.freeze({
117
124
  "strict": {
118
- ...gateContract.CHAR_THREATS_REJECT_ALL,
119
125
  traversalPolicy: "reject",
120
126
  absolutePathPolicy: "reject",
121
127
  symlinkPolicy: "reject",
@@ -134,10 +140,6 @@ var PROFILES = Object.freeze({
134
140
  maxNestedDepth: 0, // recursion depth, not byte size
135
141
  },
136
142
  "balanced": {
137
- bidiPolicy: "reject",
138
- controlPolicy: "reject",
139
- nullBytePolicy: "reject",
140
- zeroWidthPolicy: "strip",
141
143
  traversalPolicy: "reject",
142
144
  absolutePathPolicy: "reject",
143
145
  symlinkPolicy: "audit", // allowed within extraction root
@@ -156,10 +158,6 @@ var PROFILES = Object.freeze({
156
158
  maxNestedDepth: 2, // recursion depth, not byte size
157
159
  },
158
160
  "permissive": {
159
- bidiPolicy: "audit",
160
- controlPolicy: "strip",
161
- nullBytePolicy: "reject",
162
- zeroWidthPolicy: "strip",
163
161
  traversalPolicy: "reject",
164
162
  absolutePathPolicy: "reject",
165
163
  symlinkPolicy: "audit",
@@ -368,9 +368,22 @@ var PROFILES = Object.freeze({
368
368
  mixedScriptPolicy: "reject",
369
369
  displayNameSpoofPolicy: "audit",
370
370
  bomPolicy: "strip",
371
- bidiPolicy: "strip",
371
+ // Refuse, not strip. `sanitize` already refuses a bidi override here — the
372
+ // finding is critical and the refusal filter catches it before the strip
373
+ // table runs — so `strip` named a repair that never happened. Of the two
374
+ // ways to make the two agree, this is the one that does not weaken a
375
+ // guard: an override reorders how an address or a subject line RENDERS
376
+ // (CVE-2021-42574), which is the whole mechanism behind a spoofed sender,
377
+ // and a message is the surface where that gets acted on. The other content
378
+ // guards repair it at this profile; email refuses it.
379
+ bidiPolicy: "reject",
372
380
  controlPolicy: "strip",
373
- nullBytePolicy: "strip",
381
+ // Refuse for the same reason, and by the same evidence: `sanitize` throws
382
+ // `email.null-byte` here rather than removing it, because the finding is
383
+ // critical. A NUL truncates a header at whichever parser reads it first,
384
+ // so the two halves of a message can disagree about where a field ends —
385
+ // that is a smuggling primitive, not a blemish to be edited out.
386
+ nullBytePolicy: "reject",
374
387
  zeroWidthPolicy: "strip",
375
388
  allowedScripts: ["latin", "cyrillic", "greek"],
376
389
  maxLocalPartBytes: LIMIT_LOCAL_PART,
@@ -1001,11 +1014,20 @@ function sanitize(input, opts) {
1001
1014
  if (typeof input !== "string") {
1002
1015
  throw _err("email.bad-input", "sanitize requires string input");
1003
1016
  }
1004
- // Critical shapes have no safe sanitization in email — throw on
1005
- // smuggling / CRLF injection / multi-@ / mixed-script.
1017
+ // Shapes with no safe repair in email — smuggling / CRLF injection /
1018
+ // multi-@ / mixed-script — refuse here. Which those are is decided by the
1019
+ // operator's policy for the class, not by the finding's impact: bidi and
1020
+ // null-byte are stamped `critical` whatever their policy says, so reading
1021
+ // severity made `bidiPolicy: "audit"` throw instead of recording. A kind the
1022
+ // disposition map does not classify keeps the conservative severity answer.
1006
1023
  var issues = _detectMessageIssues(input, opts);
1007
- gateContract.throwOnRefusalSeverity(issues,
1008
- { errorClass: GuardEmailError, codePrefix: "email", severities: ["critical"] });
1024
+ gateContract.throwOnRefusedDisposition(issues, {
1025
+ dispositionFor: _gateDispositionFor,
1026
+ opts: opts,
1027
+ errorClass: GuardEmailError,
1028
+ codePrefix: "email",
1029
+ severities: ["critical"],
1030
+ });
1009
1031
  // A character class set to "reject" is refused here rather than by the
1010
1032
  // severity filter above, which only refuses critical findings — a C0 control
1011
1033
  // scores high, so it would pass the filter, and the strip table below only
@@ -1048,17 +1070,25 @@ function sanitize(input, opts) {
1048
1070
  * rv.action; // → "serve"
1049
1071
  * });
1050
1072
  */
1073
+ // _gateDispositionFor — the shared character classes answer from their own
1074
+ // policy; everything else returns null and takes the conservative severity
1075
+ // answer it always did. Deciding the whole finding vocabulary here would be a
1076
+ // second, silent policy change riding along with this one — the divergence
1077
+ // being fixed is that a profile asking to STRIP a control character refused the
1078
+ // message instead, and that is what this binds.
1079
+ function _gateDispositionFor(issue, opts) {
1080
+ return gateContract.charThreatDisposition(issue, opts);
1081
+ }
1082
+
1051
1083
  function gate(opts) {
1052
1084
  opts = _resolveOpts(opts);
1053
- return gateContract.buildGuardGate(
1054
- opts.name || "guardEmail:" + (opts.profile || "default"),
1055
- opts,
1056
- async function (ctx) {
1057
- var text = gateContract.extractBytesAsText(ctx);
1058
- if (!text) return { ok: true, action: "serve" };
1059
- var rv = validateMessage(text, opts);
1060
- return gateContract.severityDisposition(rv.issues);
1061
- });
1085
+ return gateContract.buildContentGate({
1086
+ name: opts.name || "guardEmail:" + (opts.profile || "default"),
1087
+ opts: opts,
1088
+ validate: validateMessage,
1089
+ dispositionFor: _gateDispositionFor,
1090
+ produceSanitized: function (text, o) { return sanitize(text, o); },
1091
+ });
1062
1092
  }
1063
1093
 
1064
1094
  // buildProfile / compliancePosture / loadRulePack are assembled by
package/lib/guard-json.js CHANGED
@@ -863,6 +863,14 @@ function parse(input, opts) {
863
863
  throw _err("json.bad-input", "parse requires string input");
864
864
  }
865
865
  // Strip BOM if policy says strip.
866
+ // The byte ceiling binds the input the CALLER sent, before any strip shrinks
867
+ // it. safeJson.parse enforces maxBytes at the end of this function, by which
868
+ // point the strips below have already removed whatever the attacker padded
869
+ // with — so a small value plus 300 zero-width, Tags or bidi characters passed
870
+ // a 16-byte cap. The strip that made it reachable for bidi was added here;
871
+ // the same hole was already open for the other two classes, which is why the
872
+ // bound goes at the top rather than in front of one strip.
873
+ codepointClass.assertWithinMaxBytes(input, opts, _err, "json");
866
874
  if (opts.bomPolicy === "strip" && input.indexOf(BOM_CHAR) === 0) {
867
875
  input = input.slice(1);
868
876
  }
@@ -871,6 +879,13 @@ function parse(input, opts) {
871
879
  if (opts.controlPolicy === "strip") {
872
880
  input = codepointClass.stripRanges(input, codepointClass.CTRL_RANGES);
873
881
  }
882
+ // Bidi belongs in the same list. It was the one strip-able class this walk
883
+ // did not remove, so `bidiPolicy: "strip"` left the character in the source
884
+ // and the refusal below then threw on it for being critical — a strip policy
885
+ // that refused, in the one place a caller cannot see it happening.
886
+ if (opts.bidiPolicy === "strip") {
887
+ input = codepointClass.stripRanges(input, codepointClass.BIDI_RANGES);
888
+ }
874
889
  // Zero-width AND Unicode Tags. Tags follows the zero-width policy when the
875
890
  // guard names none of its own — a strip of one and not the other reports the
876
891
  // character as a threat in `validate` and then hands it back inside the
@@ -891,16 +906,19 @@ function parse(input, opts) {
891
906
  "(__proto__ / constructor / prototype)");
892
907
  }
893
908
  // Refuse on other critical pre-parse threats per policy.
894
- var preIssues = _scanRawSource(input, opts);
895
- for (var pi = 0; pi < preIssues.length; pi += 1) {
896
- var issue = preIssues[pi];
897
- if (issue.kind === "prototype-pollution-key") continue; // handled above
898
- if (issue.severity === "critical" ||
899
- (issue.severity === "high" &&
900
- opts[_policyKeyForRuleId(issue.ruleId)] === "reject")) {
901
- throw _err(issue.ruleId, "guardJson.parse: " + issue.snippet);
902
- }
903
- }
909
+ // Refuse by the operator's POLICY for the class, not by the finding's impact.
910
+ // Severity is fixed at `critical` for bidi and null-byte whatever the policy
911
+ // says, so reading it here made `bidiPolicy: "audit"` refuse the document — a
912
+ // setting that asks to record something and refused it instead. A kind the
913
+ // disposition map does not classify keeps the conservative severity answer.
914
+ gateContract.throwOnRefusedDisposition(_scanRawSource(input, opts), {
915
+ dispositionFor: _gateDispositionFor,
916
+ opts: opts,
917
+ errorClass: GuardJsonError,
918
+ codePrefix: "json",
919
+ op: "parse",
920
+ skipKinds: ["prototype-pollution-key"], // refused above, with its own message
921
+ });
904
922
  // safeJson.parse strips POISONED_KEYS via the reviver pass; this is
905
923
  // the canonical strip path. allowProto=true preserves them for the
906
924
  // permissive/audit path.
@@ -1013,6 +1031,22 @@ function _gateDispositionFor(issue, opts) {
1013
1031
  }
1014
1032
  }
1015
1033
 
1034
+ // _sanitizeTransform — the repair itself, shared by the gate's sanitize action
1035
+ // and the public `sanitize` the factory generates. One body so the two cannot
1036
+ // drift into repairing different things.
1037
+ function _sanitizeTransform(text, opts) {
1038
+ var subject = text;
1039
+ // BOM is repaired under its OWN policy. The strip table reaches U+FEFF only
1040
+ // as a zero-width character, and `parse` removes only a LEADING one — so with
1041
+ // `bomPolicy: "strip"` and `zeroWidthPolicy: "allow"` nothing removed a
1042
+ // mid-stream BOM, and validate reported `bom-mid-stream` on a document this
1043
+ // function then handed back carrying it.
1044
+ if (gateContract.policyDisposition(opts.bomPolicy) === "sanitize") {
1045
+ subject = codepointClass.stripRanges(subject, [0xFEFF]);
1046
+ }
1047
+ return JSON.stringify(parse(codepointClass.applyCharStripPolicies(subject, opts), opts));
1048
+ }
1049
+
1016
1050
  function gate(opts) {
1017
1051
  opts = _resolveOpts(opts);
1018
1052
  return gateContract.buildContentGate({
@@ -1027,9 +1061,7 @@ function gate(opts) {
1027
1061
  // __proto__ / comments / NaN / trailing commas per the active policy. Under a
1028
1062
  // reject policy the finding is already refuse-disposition, so this is not
1029
1063
  // reached for that class.
1030
- produceSanitized: function (text, o) {
1031
- return JSON.stringify(parse(codepointClass.applyCharStripPolicies(text, o), o));
1032
- },
1064
+ produceSanitized: _sanitizeTransform,
1033
1065
  });
1034
1066
  }
1035
1067
 
@@ -1069,6 +1101,17 @@ module.exports = gateContract.defineGuard({
1069
1101
  intOpts: ["maxBytes", "maxDepth", "maxKeysPerObject", "maxArrayLength",
1070
1102
  "maxStringLength", "maxTotalNodes"],
1071
1103
  gate: gate,
1104
+ // The same two-pass repair the gate already runs, exposed as the public
1105
+ // `sanitize` every content guard owes: char-strip policies remove the
1106
+ // classes set to a mitigation, then a parse + re-serialize drops
1107
+ // __proto__ / comments / NaN / trailing commas per the active policy.
1108
+ // Refusal is decided by `dispositionFor` rather than by severity, because
1109
+ // eight of this guard's finding kinds never reach `critical` — a severity
1110
+ // filter would hand back a document carrying a finding the operator set to
1111
+ // `reject`. Without a sanitize the profiles declared six strip policies the
1112
+ // guard could not perform, and the gate refused instead of repairing.
1113
+ sanitizeTransform: _sanitizeTransform,
1114
+ dispositionFor: _gateDispositionFor,
1072
1115
  extra: {
1073
1116
  _gateDispositionForTest: _gateDispositionFor,
1074
1117
  parse: parse,
@@ -976,7 +976,12 @@ module.exports = gateContract.defineGuard({
976
976
  integrationFixtures: INTEGRATION_FIXTURES,
977
977
  detect: _detectIssues,
978
978
  sanitizeTransform: _sanitizeTransform,
979
- sanitizeSeverities: ["critical"],
979
+ dispositionFor: _gateDispositionFor,
980
+ // No sanitizeSeverities — refusal comes from `dispositionFor` and each
981
+ // finding's own policy. This guard's gate already documents why it passes the
982
+ // raw strip transform rather than this function: the severity filter refused
983
+ // a critical bidi control even under `bidiPolicy: "strip"`, turning a
984
+ // policy-selected repair into a refusal. With policy deciding, the two agree.
980
985
  intOpts: ["maxBytes", "maxLines", "maxLinks", "maxImages", "maxAutolinks",
981
986
  "maxRefDefs", "maxListDepth", "maxBlockquoteDepth"],
982
987
  gate: gate,
package/lib/guard-xml.js CHANGED
@@ -745,7 +745,16 @@ var _guard = module.exports = gateContract.defineGuard({
745
745
  integrationFixtures: INTEGRATION_FIXTURES,
746
746
  detect: _detectIssues,
747
747
  sanitizeTransform: _sanitizeTransform,
748
- sanitizeSeverities: ["critical"],
748
+ dispositionFor: _gateDispositionFor,
749
+ // No sanitizeSeverities: refusal is decided by `dispositionFor`, which reads
750
+ // each finding's own policy. The severity filter this replaces refused every
751
+ // critical finding, and a bidi control is critical whatever its policy says —
752
+ // so `bidiPolicy: "strip"` threw here while the gate, which bypasses this
753
+ // function precisely to avoid that, stripped it. Two public entry points gave
754
+ // opposite answers for one setting. The structural shapes still refuse, now
755
+ // because DOCTYPE / ENTITY / external-entity resolve to `refuse` by policy,
756
+ // and the capped shapes (element / depth / numeric-char-ref) keep the
757
+ // conservative severity answer through the unclassified fallback.
749
758
  intOpts: ["maxBytes", "maxDepth", "maxElements", "maxAttrsPerElement",
750
759
  "maxAttrValueBytes", "maxNumericCharRefs"],
751
760
  gate: gate,
package/lib/guard-yaml.js CHANGED
@@ -656,14 +656,17 @@ function parse(input, opts) {
656
656
  });
657
657
  }
658
658
 
659
- // The gate is the standard serve -> audit-only -> refuse chain (content
660
- // kind, reading ctx.bytes); it is assembled by gateContract.defineGuard's
661
- // default gate below. YAML sanitize is intentionally not offered — there's
662
- // no safe re-emit for tag-injection / alias-explosion shapes; the only
663
- // correct response is refusal, which the default chain (no sanitize action)
664
- // matches exactly. Its "guardYaml:<profile>" gate name and
665
- // serve/audit-only/refuse decisions are identical to the hand-written gate
666
- // this replaced.
659
+ // The gate is the standard serve -> audit-only -> sanitize -> refuse chain
660
+ // (content kind, reading ctx.bytes), assembled by gateContract.defineGuard
661
+ // below with this guard's own disposition map.
662
+ //
663
+ // `sanitize` repairs at the CHARACTER level only. That distinction is what the
664
+ // "YAML cannot be sanitized" reading missed: there is indeed no safe re-emit
665
+ // for a tag-injection or alias-explosion shape, and those still refuse through
666
+ // their own policies — but removing an invisible or control character is a text
667
+ // edit that never re-serializes the document, so a profile asking to strip one
668
+ // can be honoured. Withholding the repair entirely meant six declared strip
669
+ // policies did nothing except refuse.
667
670
 
668
671
  // buildProfile / compliancePosture / loadRulePack are assembled by
669
672
  // gateContract.defineGuard below; their wiki sections render from the
@@ -689,9 +692,49 @@ var INTEGRATION_FIXTURES = Object.freeze({
689
692
  // buildProfile / compliancePosture / loadRulePack wiring, plus the
690
693
  // per-guard inspection surface (validate) and YAML extras
691
694
  // (parse / DANGEROUS_TAG_PREFIXES / SAFE_CORE_TAGS) passed through
692
- // verbatim. The gate is the factory default serve/audit-only/refuse chain
693
- // (content kind, no sanitize action there's no safe re-emit for
694
- // tag-injection / alias-explosion shapes).
695
+ // verbatim. The gate is the factory chain for a content guard, dispatching on
696
+ // this guard's own disposition map, with a character-level sanitize.
697
+
698
+ // _gateDispositionFor — bind each finding to the operator's policy for it.
699
+ // Without this the gate fell back to severity, where `critical` and `high` both
700
+ // refuse, so a profile asking to strip a character class refused the document
701
+ // instead and an `audit` setting refused it too.
702
+ function _gateDispositionFor(issue, opts) {
703
+ var shared = gateContract.charThreatDisposition(issue, opts);
704
+ if (shared) return shared;
705
+ switch (issue.kind) {
706
+ case "core-tag":
707
+ case "custom-tag":
708
+ case "dangerous-tag": return gateContract.policyDisposition(opts.tagPolicy);
709
+ case "alias-disabled": return gateContract.policyDisposition(opts.aliasPolicy);
710
+ case "duplicate-key": return gateContract.policyDisposition(opts.duplicateKeyPolicy);
711
+ case "leading-zero-octal": return gateContract.policyDisposition(opts.leadingZeroPolicy);
712
+ case "merge-key": return gateContract.policyDisposition(opts.mergeKeyPolicy);
713
+ case "multi-document": return gateContract.policyDisposition(opts.multiDocPolicy);
714
+ case "norway-implicit-bool": return gateContract.policyDisposition(opts.norwayPolicy);
715
+ // An alias explosion, a blown anchor cap, an oversized document and a
716
+ // document that does not parse carry no policy and admit no repair. They
717
+ // are left unclassified on purpose: the caller then applies the
718
+ // conservative severity answer, which refuses them.
719
+ default: return null;
720
+ }
721
+ }
722
+
723
+ // _sanitizeTransform — repair at the CHARACTER level only. Stripping an
724
+ // invisible or control character is a text edit that needs no re-emit, which is
725
+ // why it is safe here when re-serializing a YAML document is not: the tag,
726
+ // alias and multi-document shapes have no faithful round-trip, and they refuse
727
+ // upstream through their own policies rather than being rewritten.
728
+ function _sanitizeTransform(input, opts) {
729
+ // scrubCharThreats, not a bare applyCharStripPolicies: the strip table
730
+ // removes only the classes set to `strip`, so calling it alone would hand a
731
+ // `reject` class back unrepaired and unreported. The factory's generated
732
+ // sanitize already bounds and asserts before this runs, but a transform whose
733
+ // safety depends on its caller is one refactor away from being wrong — this
734
+ // owns the whole ordering itself and is correct however it is reached.
735
+ return codepointClass.scrubCharThreats(input, opts, _err, "yaml");
736
+ }
737
+
695
738
  module.exports = gateContract.defineGuard({
696
739
  name: "yaml",
697
740
  kind: "content",
@@ -705,6 +748,8 @@ module.exports = gateContract.defineGuard({
705
748
  detect: _detectIssues,
706
749
  intOpts: ["maxBytes", "maxDepth", "maxAnchors", "maxAliasDepth",
707
750
  "maxDocuments", "maxNodes", "maxScalarLength"],
751
+ dispositionFor: _gateDispositionFor,
752
+ sanitizeTransform: _sanitizeTransform,
708
753
  extra: {
709
754
  parse: parse,
710
755
  DANGEROUS_TAG_PREFIXES: DANGEROUS_TAG_PREFIXES,
@@ -703,19 +703,23 @@ function verify(msg, opts) {
703
703
  // buried inside another member's value or parameters. Peer-supplied
704
704
  // sha-512 / sha-256 identifiers stay the operator's responsibility.
705
705
  var expectedDigest = contentDigest(m.body); // "sha3-512=:<b64>:"
706
- var matchedDigest = false;
707
706
  var digestMembers = structuredFields.splitTopLevel(presented, ",");
707
+ var offeredDigests = [];
708
708
  for (var di = 0; di < digestMembers.length; di++) {
709
709
  var member = digestMembers[di].trim();
710
710
  var deq = member.indexOf("=");
711
711
  if (deq < 1) continue;
712
712
  var dkv = structuredFields.parseKeyValuePiece(member);
713
713
  if (dkv.key !== "sha3-512") continue;
714
- var memberCanonical = "sha3-512=" + dkv.value.trim();
715
- // crypto.timingSafeEqual is the length-tolerant constant-time wrapper
716
- // (returns false for unequal lengths without leaking via a length branch).
717
- if (bCrypto.timingSafeEqual(memberCanonical, expectedDigest)) { matchedDigest = true; break; }
714
+ offeredDigests.push("sha3-512=" + dkv.value.trim());
718
715
  }
716
+ // Every offered member is compared. The `break` this replaced stopped at
717
+ // the first match, so a Content-Digest header whose first sha3-512 member
718
+ // matched answered sooner than one whose last did — the position of the
719
+ // matching member, reported by timing. timingSafeEqualAny is the
720
+ // length-tolerant constant-time compare (an unequal length is a non-match
721
+ // without a branch that leaks anything: digest lengths are fixed).
722
+ var matchedDigest = bCrypto.timingSafeEqualAny(expectedDigest, offeredDigests);
719
723
  if (!matchedDigest) {
720
724
  return { valid: false, reason: "content-digest-mismatch" };
721
725
  }
@@ -114,12 +114,16 @@ function _nonceManager(rotateSec) {
114
114
  if (shutdown) return false;
115
115
  _maybeRotate();
116
116
  if (typeof n !== "string" || n.length === 0) return false;
117
- // Constant-time compare so server-issued nonce probing can't
118
- // narrow the rolling-pair bytes via response-timing matches
119
- // the timingSafeEqual discipline on the DPoP-proof nonce.
120
- if (current && bCrypto.timingSafeEqual(n, current.nonce)) return true;
121
- if (previous && bCrypto.timingSafeEqual(n, previous.nonce)) return true;
122
- return false;
117
+ // Constant-time compare so server-issued nonce probing can't narrow the
118
+ // rolling-pair bytes via response-timing. Both halves of the pair are
119
+ // compared every time: returning on `current` answered a client holding
120
+ // the current nonce sooner than one holding the previous one, which
121
+ // reports where in the rotation window the caller sits — the property
122
+ // the comment above already claimed this had.
123
+ var accepted = [];
124
+ if (current) accepted.push(current.nonce);
125
+ if (previous) accepted.push(previous.nonce);
126
+ return bCrypto.timingSafeEqualAny(n, accepted);
123
127
  },
124
128
  // Hot-reload coexistence. Operators redeploying without
125
129
  // a clean process restart need a way to drain in-flight clients
@@ -158,17 +158,19 @@ function verify(opts) {
158
158
  var toSign = id + "." + ts + "." + bodyBuf.toString("utf8");
159
159
  var expected = nodeCrypto.createHmac("sha256", opts.secret).update(toSign).digest("base64");
160
160
  // Multi-version: signature header is `v1,<sig> v2,<sig>` etc.
161
+ // Every v1 signature in the header is compared, with no break: stopping at
162
+ // the first match let the response time report the POSITION of the matching
163
+ // signature within a multi-version header, which is a property of the
164
+ // sender's key rotation and not something a verifier should hand back.
161
165
  var parts = sigHeader.split(" ");
162
- var any = false;
166
+ var offered = [];
163
167
  for (var p = 0; p < parts.length; p += 1) {
164
168
  var pair = parts[p].split(",");
165
169
  if (pair.length !== 2) continue;
166
170
  if (pair[0] !== "v1") continue;
167
- if (bCrypto.timingSafeEqual(Buffer.from(expected, "utf8"), Buffer.from(pair[1], "utf8"))) {
168
- any = true;
169
- break;
170
- }
171
+ offered.push(Buffer.from(pair[1], "utf8"));
171
172
  }
173
+ var any = bCrypto.timingSafeEqualAny(Buffer.from(expected, "utf8"), offered);
172
174
  if (!any) {
173
175
  throw new StandardWebhooksError("standard-webhooks/bad-signature",
174
176
  "verify: no v1 signature matched");
package/lib/totp.js CHANGED
@@ -282,6 +282,12 @@ function verify(secret, code, opts) {
282
282
  var userCode = String(code).replace(/[\s.\-_]/g, "").padStart(resolved.digits, "0");
283
283
  var userBuf = Buffer.from(userCode);
284
284
 
285
+ // Every step in the drift window is compared, even after one matches. Returning
286
+ // from inside the loop would make the response time report WHICH step matched,
287
+ // and that is the clock offset between the authenticator and the server — a
288
+ // value derived from the shared secret's timeline that a caller submitting
289
+ // codes can otherwise only guess at.
290
+ var matchedStep = null;
285
291
  for (var d = -resolved.driftSteps; d <= resolved.driftSteps; d++) {
286
292
  var step = currentStep + d;
287
293
  if (lastUsedStep !== null && step <= lastUsedStep) continue; // reject replays at-or-below the last accepted step
@@ -293,11 +299,14 @@ function verify(secret, code, opts) {
293
299
  try { expected = _hotp(secret, step, resolved); }
294
300
  catch (_e) { return false; }
295
301
  var expectedBuf = Buffer.from(expected);
302
+ // The comparison is not guarded by `matchedStep === null` — that would
303
+ // short-circuit the call away once something matched, which is the early
304
+ // exit wearing a different syntax. The earliest match still wins.
296
305
  if (timingSafeEqual(expectedBuf, userBuf)) {
297
- return step;
306
+ if (matchedStep === null) matchedStep = step;
298
307
  }
299
308
  }
300
- return false;
309
+ return matchedStep === null ? false : matchedStep;
301
310
  }
302
311
 
303
312
  function uri(secret, account, opts) {
@@ -21,7 +21,7 @@
21
21
  "server": "sha256:f3325f480cb8eb814fcb0baaa19336cbbf2b993f48624c6aa9600ffd69d0be5e",
22
22
  "browser": "sha256:0ffd91540bcb586a29b56e52ee1c29df69097b50776beb4036a07558f7a4e12e"
23
23
  },
24
- "refreshedAt": "2026-08-18T07:35:55.830Z"
24
+ "refreshedAt": "2026-08-18T14:13:33.454Z"
25
25
  },
26
26
  "@noble/hashes": {
27
27
  "version": "2.3.0",
@@ -48,7 +48,7 @@
48
48
  "hashes": {
49
49
  "browser": "sha256:dfe4b7ae3c9880e388c8da4b68f44742b229b53afacd1e674179527e33da62b0"
50
50
  },
51
- "refreshedAt": "2026-08-18T07:35:55.830Z"
51
+ "refreshedAt": "2026-08-18T14:13:33.454Z"
52
52
  },
53
53
  "@noble/curves": {
54
54
  "version": "2.3.0",
@@ -70,7 +70,7 @@
70
70
  "hashes": {
71
71
  "server": "sha256:b5fe88d1ea780d0581dee6145d666f89d46fc9531b5db35db2e5b16627840890"
72
72
  },
73
- "refreshedAt": "2026-08-18T07:35:55.830Z",
73
+ "refreshedAt": "2026-08-18T14:13:33.454Z",
74
74
  "components": {
75
75
  "@noble/hashes": {
76
76
  "url": "https://github.com/paulmillr/noble-hashes",
@@ -114,7 +114,7 @@
114
114
  "server": "sha256:fab7ebe5737793862c473444f4ee5912f79dd1edec86683acbb4eecbca0f5892",
115
115
  "browser": "sha256:cae1d5bbdc7184b202b6ca68df6e1db7b0d0f668c77809ded189ca7f271accc9"
116
116
  },
117
- "refreshedAt": "2026-08-18T07:35:55.830Z",
117
+ "refreshedAt": "2026-08-18T14:13:33.454Z",
118
118
  "components": {
119
119
  "@noble/hashes": {
120
120
  "url": "https://github.com/paulmillr/noble-hashes",
@@ -148,7 +148,7 @@
148
148
  },
149
149
  "runtime_artifact": "lib/vendor/common-passwords-top-10000.data.js",
150
150
  "integrity_layers": "sha256 + sha3-512 + SLH-DSA-SHAKE-256f signature + in-payload canary (where applicable)",
151
- "refreshedAt": "2026-08-18T07:35:55.830Z"
151
+ "refreshedAt": "2026-08-18T14:13:33.454Z"
152
152
  },
153
153
  "bimi-trust-anchors": {
154
154
  "version": "operator-managed",
@@ -173,7 +173,7 @@
173
173
  },
174
174
  "runtime_artifact": "lib/vendor/bimi-trust-anchors.data.js",
175
175
  "integrity_layers": "sha256 + sha3-512 + SLH-DSA-SHAKE-256f signature + in-payload canary (where applicable)",
176
- "refreshedAt": "2026-08-18T07:35:55.830Z"
176
+ "refreshedAt": "2026-08-18T14:13:33.454Z"
177
177
  },
178
178
  "publicsuffix-list": {
179
179
  "version": "master",
@@ -193,10 +193,10 @@
193
193
  },
194
194
  "runtime_artifact": "lib/vendor/public-suffix-list.data.js",
195
195
  "integrity_layers": "sha256 + sha3-512 + SLH-DSA-SHAKE-256f signature + in-payload canary (where applicable)",
196
- "refreshedAt": "2026-08-18T07:35:55.830Z"
196
+ "refreshedAt": "2026-08-18T14:13:33.454Z"
197
197
  },
198
198
  "@blamejs/pki": {
199
- "version": "0.5.9",
199
+ "version": "0.5.10",
200
200
  "license": "Apache-2.0",
201
201
  "author": "blamejs",
202
202
  "source": "https://github.com/blamejs/pki",
@@ -217,11 +217,11 @@
217
217
  },
218
218
  "bundler": "esbuild --format=cjs --platform=node --external:crypto --external:node:crypto",
219
219
  "bundledAt": "2026-08-18T00:00:00Z",
220
- "cpe": "cpe:2.3:a:blamejs:pki:0.5.9:*:*:*:*:node.js:*:*",
220
+ "cpe": "cpe:2.3:a:blamejs:pki:0.5.10:*:*:*:*:node.js:*:*",
221
221
  "hashes": {
222
- "server": "sha256:15cea20b7d6ac11427a0c3ca40a055fa057c9dc3fb304f3beb043ce3f82780aa"
222
+ "server": "sha256:c7c7b82d0c5cd604e726c52332de66ff06ffb7877d4a44720d8bbd746c5d6023"
223
223
  },
224
- "refreshedAt": "2026-08-18T07:35:55.830Z"
224
+ "refreshedAt": "2026-08-18T14:13:33.454Z"
225
225
  }
226
226
  }
227
227
  }
@@ -1,4 +1,4 @@
1
- // @blamejs/pki v0.5.9 — vendored (Apache-2.0). Zero-dep pure CJS.
1
+ // @blamejs/pki v0.5.10 — vendored (Apache-2.0). Zero-dep pure CJS.
2
2
  // https://github.com/blamejs/pki Exports: x509, crl, pkcs12, key, webcrypto, schema, csr, cms, ...
3
3
  // Backs lib/mtls-engine-default.js (PQC-capable CA + PKCS#12 engine).
4
4
  var __getOwnPropNames = Object.getOwnPropertyNames;
@@ -141,7 +141,7 @@ var require_package = __commonJS({
141
141
  "node_modules/@blamejs/pki/package.json"(exports2, module2) {
142
142
  module2.exports = {
143
143
  name: "@blamejs/pki",
144
- version: "0.5.9",
144
+ version: "0.5.10",
145
145
  description: "Pure-JavaScript PKI toolkit that owns its stack \u2014 X.509, ASN.1/DER, CMS, PQC-first.",
146
146
  license: "Apache-2.0",
147
147
  author: "blamejs contributors",
@@ -209,9 +209,11 @@ var require_package = __commonJS({
209
209
  test: "node test/smoke.js",
210
210
  lint: "eslint --max-warnings 0 .",
211
211
  fuzz: "npm ci --prefix fuzz && npx --prefix fuzz jazzer fuzz/asn1-der.fuzz.js -- -max_total_time=60",
212
- gates: "node test/layer-0-primitives/codebase-patterns.test.js && node scripts/validate-source-comment-blocks.js && node scripts/check-api-snapshot.js",
212
+ gates: "node test/layer-0-primitives/codebase-patterns.test.js && node scripts/validate-source-comment-blocks.js && node scripts/check-api-snapshot.js && node scripts/check-spelling-consistency.js",
213
+ "check:spelling": "node scripts/check-spelling-consistency.js",
213
214
  coverage: "c8 --include=lib/** --include=index.js --reporter=text-summary --reporter=lcov node test/smoke.js",
214
215
  "check:prose": "node scripts/check-operator-prose.js",
216
+ "check:prose:all": `node -e "process.env.PKI_PROSE_CHECKS='all';var r=require('child_process').spawnSync(process.execPath,['scripts/check-operator-prose.js'],{stdio:'inherit'});if(r.error)throw r.error;process.exit(r.signal?1:r.status)"`,
215
217
  "check:swallows": "node scripts/check-swallow-coverage.js",
216
218
  "coverage:gated": "npm run coverage && npm run check:swallows",
217
219
  "coverage:unified": "node scripts/coverage-unified.js",
@@ -329,7 +331,7 @@ var require_constants = __commonJS({
329
331
  // is copied/tokenized so a hostile server cannot drive unbounded work parsing it
330
332
  // (CWE-770). 8 KiB clears any realistic multi-scheme Digest challenge.
331
333
  HTTP_AUTH_HEADER_MAX_BYTES: BYTES.kib(8),
332
- // Deterministic-CBOR codec ceilings (RFC 8949), the DER neighbours' siblings:
334
+ // Deterministic-CBOR codec ceilings (RFC 8949), the DER neighbors' siblings:
333
335
  // a whole-document cap refused before the walk, a nesting cap, and a per-value
334
336
  // bignum ceiling the document cap can't provide. Unlike DER_MAX_INTEGER_BYTES,
335
337
  // the bignum cap carries NO +1 sign octet -- a CBOR tag-2/3 bignum body is pure
@@ -24533,7 +24535,7 @@ var require_path_validate = __commonJS({
24533
24535
  // The set of extension OIDs whose CRITICAL semantics this validator processes (RFC 5280 sec. 6.1).
24534
24536
  // Exposed so a linter can distinguish "processed" from "merely decoded" and stay consistent with
24535
24537
  // the path-validation verdict on a critical extension -- a decoder in certExtensionDecoders is NOT
24536
- // by itself proof the criticality is honoured. Both sets are frozen so a caller cannot mutate them.
24538
+ // by itself proof the criticality is honored. Both sets are frozen so a caller cannot mutate them.
24537
24539
  PROCESSED_EXTENSIONS,
24538
24540
  // Extensions that ARE processed for an intermediate CA but are unprocessed on the target/leaf, so a
24539
24541
  // critical instance on the target fails closed (RFC 5280 sec. 6.1.5(f)) -- policyMappings is
@@ -25581,7 +25583,7 @@ var require_cmc_build = __commonJS({
25581
25583
  // The exchange binding (RFC 5272 sec. 6.6 / 6.4). First-class here because
25582
25584
  // pki.cmc.verify names these same three when checking the response: a builder
25583
25585
  // that made the caller hand-encode them while the verifier took them by name is
25584
- // the asymmetry that lets a request ship with no replay defence.
25586
+ // the asymmetry that lets a request ship with no replay defense.
25585
25587
  transactionId: 1,
25586
25588
  senderNonce: 1,
25587
25589
  dataReturn: 1
@@ -26304,7 +26306,7 @@ var require_cmc_verify = __commonJS({
26304
26306
  if (!bound.boundToRequest && sent.allowUnbound !== true) {
26305
26307
  throw E(
26306
26308
  "cmc/unbound-response",
26307
- "nothing ties this response to a request. Pass what the request retained (`transactionId`, `senderNonce`, whose echo is the replay defence of RFC 5272 sec. 6.6, or `dataReturn`) so the echo can be checked, or `allowUnbound: true` to interpret a response that could be a replay of any earlier exchange with this CA"
26309
+ "nothing ties this response to a request. Pass what the request retained (`transactionId`, `senderNonce`, whose echo is the replay defense of RFC 5272 sec. 6.6, or `dataReturn`) so the echo can be checked, or `allowUnbound: true` to interpret a response that could be a replay of any earlier exchange with this CA"
26308
26310
  );
26309
26311
  }
26310
26312
  return bound;
@@ -36973,7 +36975,7 @@ var require_webauthn_mds = __commonJS({
36973
36975
  tooLarge: "webauthn/too-large",
36974
36976
  badJson: "webauthn/bad-metadata-blob",
36975
36977
  // Every code the guard can raise is named. An omitted one falls back to the framework default
36976
- // and the module's own defences -- duplicate-member smuggling, the depth cap -- surface under
36978
+ // and the module's own defenses -- duplicate-member smuggling, the depth cap -- surface under
36977
36979
  // a generic code no webauthn/* consumer can switch on.
36978
36980
  tooDeep: "webauthn/bad-metadata-blob",
36979
36981
  duplicateMember: "webauthn/bad-metadata-blob",
@@ -197,17 +197,19 @@ function verify(opts) {
197
197
  var signed = Buffer.concat([Buffer.from(tsRaw + ".", "utf8"), bodyBuf]);
198
198
  var expected = bCrypto.hmac(secretBuf, signed, nodeAlg);
199
199
  var expectedBuf = Buffer.from(expected, "utf8");
200
- var matched = false;
200
+ // Every offered signature is compared. The `break` this replaced ended the
201
+ // loop at the first match, so a header whose first signature matched
202
+ // answered sooner than one whose last did — the position of the sender's
203
+ // current key inside its rotation, reported by timing.
204
+ //
205
+ // timingSafeEqualAny applies the same length pre-check per candidate; a
206
+ // wrong-length candidate cannot be the digest (the hex length is fixed by
207
+ // the algorithm, and is not secret), so it leaks nothing.
208
+ var offeredBufs = [];
201
209
  for (var s = 0; s < sigs.length; s += 1) {
202
- // timingSafeEqual requires equal-length inputs; a wrong-length candidate
203
- // cannot be the digest (the hex length is fixed by the algorithm, and is
204
- // not secret), so the length pre-check leaks nothing.
205
- if (sigs[s].length === expected.length &&
206
- bCrypto.timingSafeEqual(expectedBuf, Buffer.from(sigs[s], "utf8"))) {
207
- matched = true;
208
- break;
209
- }
210
+ offeredBufs.push(Buffer.from(sigs[s], "utf8"));
210
211
  }
212
+ var matched = bCrypto.timingSafeEqualAny(expectedBuf, offeredBufs);
211
213
  if (!matched) {
212
214
  throw new WebhookHmacError("webhook-hmac/bad-signature",
213
215
  "verify: no '" + sigField + "' signature matched");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.18.34",
3
+ "version": "0.18.36",
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:2cb1fb20-caa4-410b-a394-4b3b992bb4b6",
5
+ "serialNumber": "urn:uuid:f384e0aa-ad27-4c4f-a0b0-eceb0cac068e",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-08-18T10:34:21.920Z",
8
+ "timestamp": "2026-08-18T23:20:05.652Z",
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.18.34",
22
+ "bom-ref": "@blamejs/core@0.18.36",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.18.34",
25
+ "version": "0.18.36",
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.18.34",
29
+ "purl": "pkg:npm/%40blamejs/core@0.18.36",
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.18.34",
57
+ "ref": "@blamejs/core@0.18.36",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]