@blamejs/core 0.18.35 → 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,51 @@ 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
+
11
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.
12
57
 
13
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.
package/README.md CHANGED
@@ -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
  /**
@@ -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) {
@@ -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.35",
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:2105a3fd-0629-4778-a83f-39c23801d347",
5
+ "serialNumber": "urn:uuid:f384e0aa-ad27-4c4f-a0b0-eceb0cac068e",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-08-18T15:12:45.318Z",
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.35",
22
+ "bom-ref": "@blamejs/core@0.18.36",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.18.35",
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.35",
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.35",
57
+ "ref": "@blamejs/core@0.18.36",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]