@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 +45 -0
- package/README.md +1 -1
- package/lib/auth/password.js +14 -5
- package/lib/crypto.js +74 -8
- package/lib/eat.js +7 -3
- package/lib/http-message-signature.js +9 -5
- package/lib/middleware/dpop.js +10 -6
- package/lib/standard-webhooks.js +7 -5
- package/lib/totp.js +11 -2
- package/lib/webhook-hmac.js +11 -9
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
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
|
|
package/lib/auth/password.js
CHANGED
|
@@ -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
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
|
|
2095
|
-
|
|
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
|
-
|
|
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).
|
|
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
|
|
99
|
+
if (c) bufs.push(c);
|
|
96
100
|
}
|
|
97
|
-
return
|
|
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
|
-
|
|
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
|
}
|
package/lib/middleware/dpop.js
CHANGED
|
@@ -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
|
-
//
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
package/lib/standard-webhooks.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
package/lib/webhook-hmac.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
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:
|
|
5
|
+
"serialNumber": "urn:uuid:f384e0aa-ad27-4c4f-a0b0-eceb0cac068e",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-08-
|
|
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.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.18.36",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.18.
|
|
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.
|
|
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.
|
|
57
|
+
"ref": "@blamejs/core@0.18.36",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|