@blamejs/core 0.18.35 → 0.18.37

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,73 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.18.x
10
10
 
11
+ - v0.18.37 (2026-08-19) — **The no-regex rule for content-safety primitives never covered lib/parsers, where 55 patterns were screening adversarial input.** SECURITY.md states that no `b.guard*` or `b.safe*` primitive contains a regular expression, so a screen costs the length of its input and the byte cap that bounds the input bounds the screen. The build gate enforcing that selected files with `lib/(safe-|guard-)[^/]+\.js` — top level only. Every nested primitive was therefore outside it, and all five of them are the ones that parse untrusted bytes: `b.parsers.yaml`, `b.parsers.toml`, `b.parsers.ini`, `b.parsers.env` and `b.parsers.xml`. Between them they carried 55 pattern literals.
12
+
13
+ All 55 are gone, and the gate now covers nested paths. Nothing about the parsers' behaviour changes: each screen was differential-tested against the pattern it replaced before the call site was swapped.
14
+
15
+ Upgrade if you parse YAML, TOML, INI, `.env` or XML from anything you do not control. **Changed:** *The no-regex gate asks a parser instead of walking the source itself* — Deciding whether a `/` opens a regular expression or divides means implementing the ECMAScript lexical grammar, and the previous scanner kept meeting parts of it that were not implemented yet — template substitutions nest, `of` is contextual, a labelled `break` ends at the terminator after its label, `<!--` is a comment, a shebang is not JavaScript.
16
+
17
+ The check now runs as an ESLint rule, which sees the file after it has already been parsed. All of those questions are answered before the rule is asked. It needs no new dependency: ESLint already runs over the repository as the first static gate, and a flat config takes an inline rule.
18
+
19
+ Suppression, if a case ever warrants it, is the standard `// eslint-disable-next-line blamejs/no-regex-in-content-safety` with the reason on the line above, replacing a bespoke marker that was never used.
20
+
21
+ The hand-written scanner is gone with it — about 1,240 lines, including the fixture table that had grown to justify it. Leaving both in place would have meant a justified exception passing one gate and failing the other, since the old one honoured only its own marker.
22
+
23
+ The rule reports pattern literals. `new RegExp(...)` is not yet included: three call sites use it and they are not one thing — two compile a pattern in order to validate it, which is the opposite of matching against input, while `b.guardSql` builds its detectors that way and does match input. Reporting all three today would mean either an allowlist entry for the one that should be caught, or a rushed conversion of a set of injection detectors. It is tracked instead, and the rule stays exactly as strict as the scanner it replaces. **Fixed:** *A YAML tag beginning with a digit slipped past the tag ban* — `b.parsers.yaml` refuses anchors, aliases, tags and directives. The tag check accepted `!` followed by a letter or `<`, while the comment beside it described the rule as "alphanumeric or `<`" — and the code was the weaker of the two. `a: !123` is a local tag, and it parsed as the plain string `"!123"` rather than being refused.
24
+
25
+ It is now refused, along with `!!123`. A quoted `"!123"` is still an ordinary string, because a quoted scalar is not a tag. **Security:** *Every pattern literal is gone from the five parsers that consume adversarial bytes* — Fifty-five of them: `safe-yaml` 24, `safe-toml` 16, `safe-env` 8, `safe-ini` 7, `safe-xml` 1.
26
+
27
+ The replacements walk characters: YAML's core-schema scalar resolution is now exact-set membership and a left-to-right digit walk; TOML's date, time and offset fields are index comparisons against fixed widths; INI's integer, float and hex classification and its `[name "subsection"]` header are single passes; `.env`'s inline-comment split, `export ` prefix and `$VAR` refusal are scans.
28
+
29
+ Every replacement was compared against the pattern it replaced over exhaustive short strings and adversarial cases before the call site changed — between roughly 60,000 and 1,400,000 inputs each, all with zero divergence. Where a reported line number derives from a match position, the position was compared too, not just the verdict.
30
+
31
+ One honest note on scale: these patterns were not all hanging. `.env`'s inline-comment matcher looked like catastrophic backtracking and is not — its separator and token classes are disjoint, so the partition of a line is unique, and it measures flat to 40,000 characters. The rule removes it anyway, and that is the point: the cost of a screen in this family holds by construction, rather than by someone re-deriving the ambiguity of each pattern correctly every time one is edited. **References:** [ESTree — RegExpLiteral carries the pattern and flags as parsed data](https://github.com/estree/estree/blob/master/es5.md#regexpliteral) · [OWASP — Regular expression Denial of Service (ReDoS)](https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS)
32
+
33
+ - 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.
34
+
35
+ 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.
36
+
37
+ **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.
38
+
39
+ 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.
40
+
41
+ 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`.
42
+
43
+ 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:
44
+
45
+ - `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.
46
+ - `b.eat` nonce verification — which verifier's nonce an attestation token carried, under a comment reading "Constant-time compare against each candidate".
47
+ - 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".
48
+ - `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.
49
+ - `b.httpMessageSignature.verify` — the position of the matching member in a `Content-Digest` header.
50
+
51
+ 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:
52
+
53
+ - `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.
54
+ - `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.
55
+
56
+ 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.
57
+
58
+ `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.
59
+
60
+ 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.
61
+
62
+ 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.
63
+
64
+ 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.
65
+
66
+ 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.
67
+
68
+ 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.
69
+
70
+ 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.
71
+
72
+ An ordinary one-pair verifier stays quiet in both checks — one comparison has no position to report.
73
+
74
+ 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.
75
+
76
+ 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)
77
+
11
78
  - 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
79
 
13
80
  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
@@ -64,6 +64,7 @@ var lazyRequire = require("../lazy-require");
64
64
  var numericBounds = require("../numeric-bounds");
65
65
  var safeBuffer = require("../safe-buffer");
66
66
  var safeJson = require("../safe-json");
67
+ var codepointClass = require("../codepoint-class");
67
68
  var { FrameworkError } = require("../framework-error");
68
69
  var { boot } = require("../log");
69
70
 
@@ -90,10 +91,117 @@ class SafeEnvError extends FrameworkError {
90
91
  }
91
92
  }
92
93
 
94
+ // Split an unquoted value at an inline `# comment`, returning the text BEFORE
95
+ // the comment, or null when there is no comment to strip. Replaces
96
+ // `/^([^\s#]*(?:[ \t]+[^#\s]+)*)\s+#.*$/`.
97
+ //
98
+ // That pattern was NOT catastrophic, and it is worth saying so rather than
99
+ // implying the family rule only ever removes live bugs. `[ \t]+` and `[^#\s]+`
100
+ // are disjoint, so the partition of a line into separators and tokens is unique
101
+ // and there is nothing for the engine to backtrack through — measured flat to
102
+ // 40k characters across every shape that defeats the match. It goes because the
103
+ // rule is uniform: a screen in this family costs the length of its input, and
104
+ // that holds by construction instead of by someone re-deriving the ambiguity of
105
+ // each pattern correctly, every time one is edited. The neighbouring
106
+ // trailing-whitespace strip is already a walk, for a reason that WAS measured —
107
+ // `.replace(/[ \t]+$/)` is quadratic in V8 and the parser caps total bytes, not
108
+ // bytes per line.
109
+ //
110
+ // Reading the pattern back gives a rule with no search in it. Group 1 admits no
111
+ // `#` at all, so the `#` the pattern finds is always the FIRST one; it must be
112
+ // preceded by whitespace; and everything before that whitespace run may only be
113
+ // separated by spaces and tabs (a `\v`, `\f` or U+00A0 is whitespace to `\s` but
114
+ // not to `[ \t]`, so the pattern would not match and neither does this).
115
+ function _splitInlineComment(rest) {
116
+ var hash = rest.indexOf("#");
117
+ if (hash <= 0) return null; // absent, or nothing before it
118
+ if (!codepointClass.inRanges(rest.charCodeAt(hash - 1), codepointClass.WHITESPACE_RANGES)) {
119
+ return null; // `color#red` keeps its `#`
120
+ }
121
+ // Walk back over the whitespace run that `\s+` consumed.
122
+ var end = hash - 1;
123
+ while (end >= 0 && codepointClass.inRanges(rest.charCodeAt(end), codepointClass.WHITESPACE_RANGES)) {
124
+ end -= 1;
125
+ }
126
+ var head = rest.slice(0, end + 1);
127
+ // The separators inside group 1 are `[ \t]` only.
128
+ for (var i = 0; i < head.length; i += 1) {
129
+ var cc = head.charCodeAt(i);
130
+ if (!codepointClass.inRanges(cc, codepointClass.WHITESPACE_RANGES)) continue;
131
+ if (cc !== 0x20 && cc !== 0x09) return null;
132
+ }
133
+ // `#.*$` cannot cross a line terminator, and `$` without `m` is end-of-string.
134
+ // Lines arrive split on LF and CR already, but U+2028 and U+2029 survive that
135
+ // and are terminators to the grammar, so a comment containing one meant the
136
+ // pattern did not match and the whole value was kept.
137
+ for (var t = hash; t < rest.length; t += 1) {
138
+ var tc = rest.charCodeAt(t);
139
+ if (tc === 0x0A || tc === 0x0D || tc === 0x2028 || tc === 0x2029) return null;
140
+ }
141
+ return head;
142
+ }
143
+
144
+ // `export FOO=bar` — the POSIX shell convention. Replaces a `/^export\s+/` test
145
+ // paired with an identical `.replace`, which walked the prefix twice.
146
+ // The default key shape, `^[A-Z_][A-Z0-9_]*$`, as a walk. The label is what the
147
+ // refusal message quotes, so an operator still sees the shape they violated
148
+ // rather than the word "function".
149
+ var _ASCII_UPPER = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
150
+ var _KEY_HEAD_CHARS = _ASCII_UPPER + "_";
151
+ var _KEY_TAIL_CHARS = _ASCII_UPPER + codepointClass.ASCII_DIGITS + "_";
152
+ var DEFAULT_KEY_SHAPE = "^[A-Z_][A-Z0-9_]*$";
153
+
154
+ function _matchesDefaultKeyShape(key) {
155
+ if (typeof key !== "string" || key.length === 0) return false;
156
+ if (_KEY_HEAD_CHARS.indexOf(key.charAt(0)) === -1) return false;
157
+ if (key.length === 1) return true; // isRunOf("") is false
158
+ return codepointClass.isRunOf(key.slice(1), _KEY_TAIL_CHARS);
159
+ }
160
+
161
+ var _EXPORT = "export";
162
+
163
+ function _stripExportPrefix(line) {
164
+ if (line.slice(0, _EXPORT.length) !== _EXPORT) return line;
165
+ var i = _EXPORT.length;
166
+ var afterWord = i;
167
+ while (i < line.length &&
168
+ codepointClass.inRanges(line.charCodeAt(i), codepointClass.WHITESPACE_RANGES)) i += 1;
169
+ if (i === afterWord) return line; // `exported=1` is a key, not a prefix
170
+ return line.slice(i);
171
+ }
172
+
173
+ // `$VAR` and `${VAR}` references, which this parser refuses rather than expands.
174
+ // Replaces `/\$(\{[A-Za-z_]|[A-Za-z_])/` — a `$` followed either by a brace and
175
+ // an identifier head, or by an identifier head directly. A trailing `$`, `$1` or
176
+ // `$ ` is a literal dollar and stays one.
177
+ function _isIdentifierHead(ch) {
178
+ // The length guard is load-bearing: `charAt` past the end returns "", and
179
+ // `indexOf("")` is 0 on every string, so without it a trailing `$` reads as a
180
+ // reference and a literal dollar at end-of-value is refused.
181
+ if (ch.length !== 1) return false;
182
+ return ch === "_" || codepointClass.ASCII_ALPHA.indexOf(ch) !== -1;
183
+ }
184
+
185
+ function _hasVariableReference(text) {
186
+ for (var i = 0; i < text.length; i += 1) {
187
+ if (text.charAt(i) !== "$") continue;
188
+ var next = text.charAt(i + 1);
189
+ if (next === "{") {
190
+ if (_isIdentifierHead(text.charAt(i + 2))) return true;
191
+ continue;
192
+ }
193
+ if (_isIdentifierHead(next)) return true;
194
+ }
195
+ return false;
196
+ }
197
+
93
198
  var DEFAULTS = {
94
199
  maxBytes: C.BYTES.kib(64),
95
200
  maxKeys: 1_000,
96
- keyShape: /^[A-Z_][A-Z0-9_]*$/,
201
+ // The DEFAULT shape is a walk, not a pattern. An operator may still pass a
202
+ // RegExp — there the pattern is the INPUT, which is the one case this family
203
+ // allows — and that override is honoured unchanged below.
204
+ keyShape: null,
97
205
  applyToProcess: false,
98
206
  allowOverwrite: false,
99
207
  rejectUnknown: false,
@@ -120,7 +228,11 @@ function parse(input, opts) {
120
228
  ? Math.min(opts.maxBytes, C.BYTES.mib(1)) : DEFAULTS.maxBytes;
121
229
  var maxKeys = opts.maxKeys !== undefined
122
230
  ? Math.min(opts.maxKeys, 100_000) : DEFAULTS.maxKeys;
123
- var keyShape = opts.keyShape instanceof RegExp ? opts.keyShape : DEFAULTS.keyShape;
231
+ var operatorKeyShape = opts.keyShape instanceof RegExp ? opts.keyShape : null;
232
+ var keyShapeLabel = operatorKeyShape ? String(operatorKeyShape) : DEFAULT_KEY_SHAPE;
233
+ function keyShapeAccepts(key) {
234
+ return operatorKeyShape ? operatorKeyShape.test(key) : _matchesDefaultKeyShape(key);
235
+ }
124
236
 
125
237
  input = safeBuffer.normalizeText(input, {
126
238
  maxBytes: maxBytes,
@@ -129,7 +241,9 @@ function parse(input, opts) {
129
241
  sizeCode: "env/too-large",
130
242
  });
131
243
 
132
- var rawLines = input.split(/\r\n|\r|\n/);
244
+ // splitLinesAny breaks on LF, CR and CRLF-as-one — the same three the
245
+ // `/\r\n|\r|\n/` alternation covered, in the same order of preference.
246
+ var rawLines = codepointClass.splitLinesAny(input);
133
247
  var values = Object.create(null);
134
248
  var seen = new Set();
135
249
 
@@ -137,14 +251,12 @@ function parse(input, opts) {
137
251
  var line = rawLines[i];
138
252
  var lineNumber = i + 1;
139
253
  // Trim leading whitespace (operators sometimes indent for readability)
140
- var trimmed = line.replace(/^[ \t]+/, "");
254
+ var trimmed = codepointClass.trimChars(line, " \t", { trailing: false });
141
255
  if (trimmed.length === 0) continue;
142
256
  if (trimmed.charAt(0) === "#") continue;
143
257
 
144
258
  // Optional `export ` prefix (POSIX shell convention)
145
- if (/^export\s+/.test(trimmed)) {
146
- trimmed = trimmed.replace(/^export\s+/, "");
147
- }
259
+ trimmed = _stripExportPrefix(trimmed);
148
260
 
149
261
  var eqIdx = trimmed.indexOf("=");
150
262
  if (eqIdx < 0) {
@@ -159,9 +271,9 @@ function parse(input, opts) {
159
271
  if (pick.isPoisonedKey(key)) {
160
272
  throw new SafeEnvError("forbidden key '" + key + "'", "env/poisoned-key", lineNumber);
161
273
  }
162
- if (!keyShape.test(key)) {
274
+ if (!keyShapeAccepts(key)) {
163
275
  throw new SafeEnvError(
164
- "key '" + key + "' does not match keyShape " + keyShape,
276
+ "key '" + key + "' does not match keyShape " + keyShapeLabel,
165
277
  "env/bad-key-shape", lineNumber
166
278
  );
167
279
  }
@@ -188,18 +300,14 @@ function parse(input, opts) {
188
300
  // Unquoted value: strip trailing whitespace + inline `# comment`.
189
301
  // The comment marker MUST be preceded by whitespace to count
190
302
  // (so a value like `KEY=color#red` keeps the literal `#`).
191
- var commentMatch = rest.match(/^([^\s#]*(?:[ \t]+[^#\s]+)*)\s+#.*$/);
303
+ var beforeComment = _splitInlineComment(rest);
192
304
  // stripTrailingHspace is a linear char-scan; .replace(/[ \t]+$/) is O(n^2)
193
305
  // in V8 and the env parser only caps TOTAL bytes, not per-line, so a
194
306
  // single huge-whitespace value line would otherwise hang the parser.
195
- if (commentMatch) {
196
- value = safeBuffer.stripTrailingHspace(commentMatch[1]);
197
- } else {
198
- value = safeBuffer.stripTrailingHspace(rest);
199
- }
307
+ value = safeBuffer.stripTrailingHspace(beforeComment === null ? rest : beforeComment);
200
308
  // Reject `$VAR` style references — explicit error so operators
201
309
  // see the policy rather than silently getting unexpanded text.
202
- if (/\$(\{[A-Za-z_]|[A-Za-z_])/.test(value)) {
310
+ if (_hasVariableReference(value)) {
203
311
  throw new SafeEnvError(
204
312
  "$VAR / ${VAR} expansion not supported (escape with \\$ if literal, or quote and expand yourself)",
205
313
  "env/expansion-banned", lineNumber
@@ -254,7 +362,8 @@ function _decodeDoubleQuoted(rest, lineNumber) {
254
362
  // Optional inline comment after closing quote — ignore.
255
363
  return out;
256
364
  }
257
- if (ch === "$" && /^[{A-Za-z_]/.test(rest.charAt(i + 1) || "")) {
365
+ var afterDollar = rest.charAt(i + 1);
366
+ if (ch === "$" && (afterDollar === "{" || _isIdentifierHead(afterDollar))) {
258
367
  throw new SafeEnvError(
259
368
  "$VAR / ${VAR} expansion not supported in double-quoted value (use \\$ for literal $)",
260
369
  "env/expansion-banned", lineNumber