@blamejs/core 0.18.51 → 0.18.54

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.
Files changed (44) hide show
  1. package/CHANGELOG.md +185 -0
  2. package/NOTICE +1 -1
  3. package/README.md +3 -3
  4. package/lib/ai-adverse-decision.js +18 -2
  5. package/lib/codepoint-class.js +72 -0
  6. package/lib/cookies.js +7 -10
  7. package/lib/credential-hash.js +8 -1
  8. package/lib/crypto.js +7 -5
  9. package/lib/guard-auth.js +34 -11
  10. package/lib/guard-filename.js +39 -16
  11. package/lib/guard-managesieve-command.js +49 -9
  12. package/lib/guard-regex.js +3 -5
  13. package/lib/guard-yaml.js +212 -31
  14. package/lib/mail-agent.js +23 -9
  15. package/lib/mail-arc-sign.js +40 -7
  16. package/lib/mail-auth.js +75 -20
  17. package/lib/mail-crypto-pgp.js +1 -1
  18. package/lib/mail-dkim.js +80 -11
  19. package/lib/mail-helo.js +10 -0
  20. package/lib/mail-rbl.js +10 -3
  21. package/lib/mail-send-deliver.js +151 -32
  22. package/lib/mail-server-imap.js +121 -55
  23. package/lib/mail-server-jmap.js +31 -4
  24. package/lib/mail-server-managesieve.js +168 -25
  25. package/lib/mail-server-mx.js +76 -4
  26. package/lib/mail-server-net.js +126 -0
  27. package/lib/mail-server-pop3.js +73 -22
  28. package/lib/mail-server-submission.js +21 -2
  29. package/lib/mail-store.js +33 -11
  30. package/lib/mail.js +355 -17
  31. package/lib/middleware/bearer-auth.js +6 -1
  32. package/lib/middleware/fetch-metadata.js +5 -1
  33. package/lib/middleware/headers.js +7 -10
  34. package/lib/network-dns-resolver.js +71 -8
  35. package/lib/network-dns.js +26 -0
  36. package/lib/network-smtp-policy.js +42 -10
  37. package/lib/parsers/safe-yaml.js +24 -3
  38. package/lib/redact.js +13 -3
  39. package/lib/retention.js +22 -2
  40. package/lib/vendor/MANIFEST.json +12 -12
  41. package/lib/vendor/blamejs-pki.cjs +278 -40
  42. package/lib/yaml-lex.js +587 -0
  43. package/package.json +1 -1
  44. package/sbom.cdx.json +6 -6
package/CHANGELOG.md CHANGED
@@ -8,6 +8,191 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.18.x
10
10
 
11
+ - v0.18.54 (2026-08-24) — **Outbound mail could not be sent, and DANE refused exactly the peers that publish TLSA.** `b.mail.send.deliver` reached for a transport name nothing exports, so without an explicit `transportFactory` every recipient deferred 4.4.4 with no socket ever opened. The transport it should have reached discarded `message.raw` and rebuilt the body, so a message that did get through arrived empty. Under `policy.dane: "enforce"`, a peer that published TLSA records was refused while a peer that published none was delivered to, and the records that were fetched authenticated nothing. Alongside those: `b.mailStore` over `b.db` committed a write and then threw, POP3 and ManageSieve treated a SASL challenge as an authentication failure, `mx` gained the RCPT-time recipient check that made `550 5.1.1` reachable, and `b.mail.inbound.verify` now evaluates the ARC chain its own documentation had been promising.
12
+
13
+ Three of these are worth upgrading for ahead of the rest. A pipelined SASL response was refused and the round already in flight was left running, so it could still authenticate the connection whose response had just been refused — on all four listeners. The SMTP client decided how to frame and whether to send a message from the capabilities advertised before STARTTLS, and never discarded them, so an extension line injected on the cleartext leg still steered decisions made under TLS. And DKIM and ARC signed a UTF-8 re-encoding of the message rather than its own octets, which for ordinary 8-bit mail meant a valid signature over bytes the sender never wrote, returned to the caller in place of the message. **Added:** *`b.codepointClass.hasPairWhere`* — The scan for a two-character construct — a path traversal opening on `..`, a regex inline-flag group on `(?`, a YAML merge key on `<<` — was written out by hand in each screen that needed one. The literals and the follow-on tests differ, the scan does not, and three copies of a scan are three places for an off-by-one to live.
14
+
15
+ `hasPairWhere(text, first, second, accept)` calls `accept(i)` at every position where `first` is followed by `second`, and answers whether any of them counted. It reads the string one character at a time, which is what a guard needs: asking the question with a pattern would be a screen running the construct it screens over hostile text.
16
+
17
+ `b.guardFilename`, `b.guardRegex` and `b.guardYaml` now route through it. · *`b.codepointClass.firstLineInjectionCharOffset`* — Returns the index of the first CR, LF or NUL in a string, or `-1`. These three end a line-oriented protocol record, so a value carrying one splits the record it is written into and the remainder is read as a second, attacker-chosen line. Four call sites had hand-rolled the same scan, which is how a fifth could go without it; they now share this one.
18
+
19
+ Deliberately narrower than `firstControlCharOffset`, which refuses every C0 control and DEL: a caller adopting this does not silently start refusing values it used to accept. **Changed:** *Sample tokens and key placeholders no longer look like secrets to a default scanner* — A default-ruleset secret scan of `lib/` reported eleven findings, all inside comment blocks or documentation fixtures, none of them key material. The cost landed on anyone vendoring the tree: either a blanket allowlist over the whole library, which then hides a real finding, or a hand-triage repeated on every refresh.
20
+
21
+ Sample JWTs are now built from their header and claims rather than pasted as literals, which also shows a reader what the token says instead of an opaque blob; the PEM placeholder is written without the `BEGIN` framing; and the two-key recipient shape is named in prose. CI now also scans `lib/` with the default ruleset and no configuration, which is the scan a consumer actually runs. **Fixed:** *`b.mail.send.deliver` can open a connection* — The default transport was read as `b.mail.smtpTransport`, a name nothing defines. The export is `b.mail.transports.smtp`. So the default was `undefined`, calling it threw, the throw was classified as a transient peer problem, and an operator who did not pass `transportFactory` got every recipient deferred `4.4.4` forever with no socket ever opened.
22
+
23
+ The option is documented as an override, which means the default is the path most callers take. · *The SMTP transport sends the message it was given* — `send()` discarded `message.raw` and rebuilt the body from the structured fields. A message composed elsewhere and handed over as bytes therefore arrived as an empty `multipart/alternative`, and the peer answered `250`, so the sender had every reason to believe it had been delivered.
24
+
25
+ Raw bytes now reach the wire verbatim, which took three things beyond keeping the buffer: the message is no longer decoded as UTF-8 on the way through (that replaced every invalid sequence, corrupting exactly the binary payloads this transport detects), the DATA terminator no longer appends a CRLF the message already ended with (which inserted a blank line and broke any signature over the body), and a body requiring BINARYMIME is refused rather than sent under DATA framing, which cannot carry one.
26
+
27
+ BINARYMIME was in fact unreachable: the transport identifies a binary body by the NUL in it, and the outbound smuggling screen refused the message for containing a NUL. That screen now covers the header block for such a message, where the injection class actually lives.
28
+
29
+ The wire-capability negotiation reads the raw bytes too: `SMTPUTF8` is decided from the raw header block, `8BITMIME` from the whole message, and `BINARYMIME` from a NUL anywhere in it, so a raw message still negotiates the extensions its content requires instead of being sent under the wrong ones. · *DANE authenticates the peer instead of refusing it* — Two defects on one path, and together they inverted what DANE is for.
30
+
31
+ The TLSA lookup never passed the DNSSEC acknowledgement that `dane.tlsa` requires under RFC 7672 §1.3, and `deliver` exposed no option that could supply it. A peer publishing no records took an earlier return and was delivered to; a peer that published records reached the check and was refused. The operator who did the work of publishing TLSA was the one who stopped receiving mail.
32
+
33
+ Separately, `dane.verifyChain` shipped and nothing called it, so on the path where the lookup did succeed the certificate the peer presented was never compared against the records just retrieved. `dane: "enforce"` meant "fail if the lookup did not go well", not "authenticate the peer".
34
+
35
+ `policy.dnssecValidated: true` is now the operator's statement that their resolver validates, and `dane: "enforce"` requires it at `create()` rather than failing per-peer later: an un-asserted resolver is a property of the sender, not of any recipient. Under `"opportunistic"` without it the records are unusable, so none are fetched. The records that are fetched go to the transport, which matches the peer's certificate chain against them once the handshake completes and refuses the send on a mismatch, on both the implicit-TLS and STARTTLS paths.
36
+
37
+ A DANE failure now defers instead of bouncing. It says the HOST could not be authenticated, and the ordinary cause is a certificate rollover part-way through DNS propagation, which resolves itself. Classified permanent it also skipped every remaining MX, so one host mid-rollover turned deliverable mail into a report. MTA-STS and REQUIRETLS refusals stay permanent.
38
+
39
+ The lookup goes through the resolver the operator configured on `deliver`, because `dnssecValidated` is a statement about that resolver: fetching the records through the system resolver instead would let a non-validating path supply them under a validated banner. A configured resolver that cannot answer TLSA is refused rather than bypassed.
40
+
41
+ Under DANE-EE and DANE-TA the TLSA record is the trust anchor and PKIX path validation is not performed (RFC 7672 §3.1.1), which is what lets a self-signed MX certificate work at all. Leaving the WebPKI check on rejected such a peer during the handshake, before its certificate could be compared against the records that authenticate it, so every DANE-only MX was deferred while matching its own records exactly. PKIX-TA and PKIX-EE record sets keep the WebPKI check.
42
+
43
+ `b.mail.transports.smtp` takes the records directly as `dane: [tlsaRecord, ...]` for operators driving it themselves. · *`b.guardYaml` no longer reads a block scalar's contents as structure (#642)* — Two lines inside a block scalar that merely look like `key: value` were reported as a duplicate key. A `script: |` block holding JavaScript with `owner: ctx.owner` on two lines was refused, and there is no mapping inside a block scalar for those keys to collide in.
44
+
45
+ 0.18.53 introduced a lexer for exactly this and moved the tag, anchor and alias scans onto it. The duplicate-key screen was left reading the raw source, so the same root survived one release in a different screen.
46
+
47
+ Sweeping that root found a second: the merge-key screen also read raw source, so `<<: *base` written in a comment, in a quoted value, inside a block scalar, or on a plain scalar's continuation line was reported as a merge key. Both screens now ask the lexer, which gained `lexLines` — the mask and the node-start offsets from one pass.
48
+
49
+ The question it asks is "could a node begin here?", not "is this well-formed structure?", because `<<:*d` with no space after the colon is not a valid mapping entry and is still reported: that is the shape being smuggled past a parser variant that reads it as a merge. Asking it that way covers every region that is literal text at once. Enumerating those regions instead was tried first and left six of the eight open, which is why the lexer answers it rather than the screen. · *DKIM and ARC sign the octets they were given (#644)* — RFC 6376 §3.4 signs a canonicalized octet stream. `b.mail.dkim` took the message as a JavaScript string, canonicalized characters, and hashed the UTF-8 re-encoding of the result — so the octets it signed were `utf8(decode_utf8(original))`, equal to the original only when the original was already valid UTF-8. 128 of the 256 single-octet values do not survive that round trip.
50
+
51
+ Signing was the worse half. `sign()` returns the message with the signature prepended, so a caller relaying the return value relayed corrupted bytes carrying a signature that verifies at the receiver: the corruption arrived authenticated, and the octets the sender wrote were gone from the relayed copy. On the receive side a legitimate 8-bit message verified as a DKIM fail, leaving DMARC to fall back to the SPF identity — and to nothing at all once the message had been forwarded.
52
+
53
+ `sign()` and `verify()` now accept a `Buffer` and hash its octets, returning what they were given: a Buffer for a Buffer, a string for a string. A string argument keeps its present meaning, that the octets are its UTF-8 encoding, so existing signatures are unchanged. `b.mail.arc.sign` and the ARC verifier take the same treatment, since a seal covers a message the same way a signature does, and `b.mail.inbound.verify` passes a Buffer message through to both instead of decoding it. Both signers resolve the message through one shared function, which is what stops them answering "which bytes is this message" differently.
54
+
55
+ §3.4 canonicalization touches only SP, HTAB, CR and LF, none of which can appear inside a multi-byte UTF-8 sequence, so this is byte-for-byte identical to the previous behaviour on every valid-UTF-8 message.
56
+
57
+ The message is two views and they are not interchangeable, which is the part worth carrying to the next module that holds mail: a signature covers the bytes on the wire, and an address is characters. `b.mail.inbound.verify` reads the From header as text so an RFC 6531 local part still aligns against a DMARC record, and reads the same message as octets for DKIM and ARC. · *`b.mail.server.imap` returns a message's own octets (#645)* — Every FETCH response was assembled as a string and written with `socket.write`'s string overload, which encodes as UTF-8. RFC 9051 §4.3 makes a literal a counted sequence of octets and §6.4.5 makes `BODY[]` the message, so a message octet that is not valid UTF-8 could not survive: a `BODY[]` of a 69-octet message announced 71 and sent 71, none of them the stored bytes.
58
+
59
+ The two numbers in one response disagreeing is what a client cannot work around — the literal count is what tells it where the response ends, so a count that does not match the payload leaves it reading the next response as part of this one. A `<start.count>` window was affected on ordinary UTF-8 mail too, since §6.4.5 puts no alignment condition on the window and a boundary falling inside a character took three octets to deliver two, none of them the ones requested.
60
+
61
+ `fetchRange` may now return a row's `payload` as a Buffer, and should whenever the row carries message content; the response is framed and written as those octets. A string payload is unchanged and remains right for rows carrying only attributes. Response strings keep their UTF-8 encoding, which is what §5.1 asks for in the one place a response string is not ASCII: a mailbox name under UTF8=ACCEPT.
62
+
63
+ A NOTIFY push carries message content too, and built its FETCH response from a second copy of the same code — so it corrupted identically. Both now go through one builder, which is what stops the two answers from diverging again.
64
+
65
+ The same mailbox over POP3 already returned the message intact, so which protocol an account holder used decided whether they received their own mail. · *Raw 8-bit mail is refused when the peer does not offer 8BITMIME* — A body carrying octets with the high bit set needs 8BITMIME (RFC 6152 §3). When the peer did not advertise it, the framing choice fell through to `7BIT` and the transport wrote those octets anyway — declaring one thing and sending another. A receiver is then free to reject the transaction, or to strip the eighth bit and deliver a corrupted message, which for a signed message means a signature that no longer matches what arrived.
66
+
67
+ The send is now refused with `mail/8bitmime-not-advertised` before DATA or BDAT opens, naming the two ways out: encode the body as quoted-printable or base64, or deliver through a peer that supports the extension. A peer that does advertise 8BITMIME is unaffected and still receives `BODY=8BITMIME`.
68
+
69
+ The BINARYMIME sibling of this already refused. Marking a body as needing an extension and then shipping it without one is the same defect either way, and only one of the two was closed. · *`b.mail.arc.evaluate` agrees with `b.mail.arc.verify`* — `evaluate` answers `verify`'s question plus a trust decision, so the two must never disagree about whether a chain passes. They did, for any message containing a non-ASCII character: `evaluate` resolved the message to its wire form and then handed that form to `verify`, which resolved it again. The second pass read the wire representation as text and re-encoded it, so the octets checked were no longer the octets sealed — the chain passed `verify` and failed `evaluate`, and a trusted sealer went unrecognised.
70
+
71
+ The conversion is idempotent on octets and not on text, so the internal hand-off now passes octets. That contract is written at the function both paths share, because the two views of a message are easy to mix up and the mistake is invisible on ASCII. · *A refused pipelined SASL response can no longer authenticate the connection* — A client can put several lines in one TCP segment, so two SASL responses can arrive together. The shared exchange refuses the second one, because a client that answers before it has been asked is not following the protocol — but it refused it and left the verifier already running for the first response alone.
72
+
73
+ That verifier could then resolve `{ ok: true }` and authenticate the connection whose response had just been refused, so the refusal decided nothing. It could also emit a challenge after the listener had answered and cleared its state.
74
+
75
+ The round in flight is now abandoned rather than merely reported: its completion, success or throw, reaches no callback. The exchange also stays dead afterwards, so a listener that does not tear the connection down cannot have it become authenticable again once the violation has scrolled past. IMAP, submission, POP3 and ManageSieve all drive their SASL exchange through this, so all four were exposed. · *SMTP capabilities are read only from the EHLO that follows STARTTLS* — `b.mail.smtpTransport` decided whether a message could be sent, and how it would be framed, from the capabilities advertised on the cleartext leg. The pre-upgrade extension list was also never discarded, so the set in force after the handshake was the union of both legs.
76
+
77
+ RFC 3207 §4.2 requires the opposite: a client MUST discard every service extension learned before the upgrade, because the cleartext leg is exactly what a network attacker can rewrite. An injected extension line steered decisions made under TLS — a `SIZE` cap that refuses every message, a `CHUNKING` that selects a framing the real peer never offered.
78
+
79
+ It read as an over-refusal too. Servers commonly advertise `CHUNKING` and `BINARYMIME` only once TLS is up, so a binary message was refused before `STARTTLS` was even sent, naming an extension the peer does advertise.
80
+
81
+ Every capability-dependent decision now runs on the post-upgrade EHLO alone: the SMTPUTF8 and BINARYMIME requirement checks, the `SIZE` pre-check, and the BDAT-versus-DATA choice. Implicit-TLS transports are unaffected, since their first EHLO is already inside TLS. · *A deferred recipient names the receiver that refused (#643)* — `b.mail.send.deliver` returned deferred recipients with a reason and a retry budget and no `mxHost`, while delivered ones carried it. A queue view therefore read "deferred, retry in 15 minutes" with no peer attached, and a domain with several MX hosts gives an operator no way to tell which one refused — which is what decides whether to wait, to contact that receiver, or to look at their own transport security against that host.
82
+
83
+ Deferred recipients now carry `mxHost`. It is `null`, rather than absent, when the deferral happened before any host was reached (an MX lookup that failed), so a consumer can tell "no peer was reached" from "the peer was not recorded". · *`b.guardFilename` honours `adsPolicy: "allow"` (#623, reopened)* — `sanitize`, `validate` and the gate accepted `adsPolicy: "allow"`, documented it as opting out of the NTFS alternate-data-stream check, and refused anyway. An operator who set every documented opt-out still could not store `12:30 notes.txt` — a timestamped note, not an attack.
84
+
85
+ 0.18.52 addressed the same report by making the refusal message explain why the option did not apply there. That reads as a considered boundary until you meet a filename like that one: the check is lexical, a colon is an ordinary filename character on Linux and macOS, and the shape of an attack is identical to the shape of a timestamp. Only the operator knows which filesystem the name will be written to, which is why the option exists.
86
+
87
+ The option now opts out at every door that reads it, including strip mode and the validate finding. The default is unchanged and still refuses, in all three profiles — reaching the name requires setting `reservedCharPolicy: "allow"` as well, because a colon is also a Windows reserved character.
88
+
89
+ This also corrects where the rule sits: an ADS suffix was treated as a floor no configuration could lower, alongside NUL bytes, `..` traversal and UNC prefixes. Those three are unsafe on every filesystem. A colon is not, and the rule belongs with `reservedCharPolicy` and `reservedNamePolicy`, the framework's other Windows-specific checks, all default-on with an opt-out.
90
+
91
+ Note for anyone tracking versions: 0.18.52 was tagged but never published — its publish workflow failed and the tag is immutable, so npm goes 0.18.51 to 0.18.53. Nothing from it was lost; 0.18.53 carries it. · *`b.network.dns.resolver` queries the DoH endpoint you configured* — `b.network.dns.useDnsOverHttps({ url, ca })` sets where DNS goes, and `b.network.dns.resolver.create()` ignored it: its wire-format lookup addressed a public provider that was hardcoded in the source. A deployment that had pointed DoH at its own resolver — for split horizon, for privacy, or because that resolver validates DNSSEC — still had every `resolver.query()` leave for the public one.
92
+
93
+ That also hollowed out the DANE assertion above: `policy.dnssecValidated: true` describes the operator's resolver, and the queries were not reaching it. The endpoint and its CA are now read per lookup, so a later `useDnsOverHttps` reaches the next query rather than a handle staying pinned to whatever was configured when it was built.
94
+
95
+ The configured request method travels with it. RFC 8484 §4.1 allows GET and POST, an endpoint may accept only POST, and `useDnsOverHttps({ method: "POST" })` says so — honouring the URL while sending GET leaves that endpoint unusable. With no method configured the choice falls to URL length, using the same threshold the rest of the DNS module uses rather than a second copy of the number.
96
+
97
+ `b.network.dns.activeDohEndpoint()` reports the endpoint in force, or `null` when the deployment is on DoT or the system resolver. · *`b.mail.send.deliver` reads MX records from the framework's own resolver* — The MX normalisation accepted `{ exchange, priority }` on each record, and `b.network.dns` decodes an MX RR into `{ ..., decoded: { preference, exchange } }`. Composed with the framework's own resolver — the one the DANE documentation points operators at — every MX host came out `undefined`, and the first thing to touch one failed the delivery.
98
+
99
+ A comment above the normalisation asserted the flat shape, and every test agreed with it, because every test supplied a hand-written resolver returning exactly what the comment claimed. The disagreement is only visible against a real DNS response, which is what the new `mail-dane-authentication` integration test uses. · *`b.mailStore` over `b.db` no longer commits a write and then throws* — `appendMessage` and `hardExpunge` selected a transaction shape by testing `typeof backend.transaction === "function"`, which is true of both conventions and distinguishes neither. `b.db.transaction(fn)` runs `fn` and returns its result; better-sqlite3's returns a wrapper to call. Against `b.db` the callback ran, the write committed, and the framework then tried to call the returned value and threw. The caller saw a failure over a completed write.
100
+
101
+ Both conventions are now honoured by observing what actually happened: if the callback ran, the result stands; if a function came back instead, it is called. A backend that does neither is refused rather than left to commit unatomically. · *`deliver` accepts the null reverse path a DSN requires* — RFC 5321 §4.5.5 requires a bounce to be sent from the null reverse path, `MAIL FROM:<>`, and the SMTP transport already turns `""` into exactly that. `deliver` refused `envelope.from: ""` as missing, so the one value a DSN must use was the one value it would not accept. Its own documented DSN example sent the report back with the original sender's address, which is the loop RFC 5321 §4.5.5 exists to prevent.
102
+
103
+ `""` is now a value rather than an absence, and no DSN is generated for a message that already carries the null sender. · *POP3 and ManageSieve honour a SASL challenge* — An operator verifier may answer `{ pending: true, challenge }` to ask for another round trip, which IMAP and submission honoured. POP3 and ManageSieve called the verifier once, passed no `step`, and dropped a pending verdict into the branch that writes an authentication failure. That branch also spent the client's authentication-failure budget, so a normal protocol round trip consumed the defence that exists to slow down credential guessing.
104
+
105
+ Both now run the exchange the way the other two listeners do, through one shared step runner rather than a fourth copy of the loop. The client's reply reaches the verifier instead of the command table, `*` cancels without costing budget, and a genuinely failed exchange still charges it.
106
+
107
+ ManageSieve also accepts the literal form of a response. RFC 5804 §1.2 makes a string either quoted or a literal (`{N}` or `{N+}` followed by N bytes), and a SASL response is base64 and can be long, which is exactly when a client reaches for the literal. The initial response accepted both; the continuation accepted only the quoted form, so the literal marker itself was read as the response and its bytes as another one. · *`b.mail.server.mx` can refuse an unknown mailbox* — Recipients were decided from `localDomains` alone, so a local domain accepted every local part and the application first met the recipient at the agent handoff, after `354` and after the whole message body. From there the only answers were `250`, which tells the peer the message arrived and leaves the receiver owing a report, or `451`, which tells a peer holding a permanent condition to keep retrying. `550 5.1.1` was not expressible.
108
+
109
+ `opts.recipientPolicy` runs at RCPT TO, in the same shape the submission listener already had: `{ ok: false, reason }` refuses `550 5.1.1` with the reason, and a throw defers `451 4.7.1`, because a directory that cannot be reached is not a verdict about the mailbox. Refusals charge the same per-IP recipient-failure budget as a relay refusal, since the difference between `250` and `550` is a mailbox-existence oracle. · *A recipient-refusal reason can no longer forge a second SMTP reply* — Both mail listeners wrote an operator policy's refusal reason straight into the `550` line. That reason is rarely the operator's own words: a directory wrapper answers "No such user: <address>", and the address came from the peer. A CR or LF in it ended the reply line early and everything after was read by the peer as a second server response, so a refusal could deliver a forged acceptance.
110
+
111
+ The reason now goes through a check that returns it when it cannot split a line and a fixed fallback when it can, so the recipient is still refused and only the prose is replaced. This covers the submission listener's existing `recipientPolicy` as well as the MX listener's new one, and a detector keeps a third from appearing.
112
+
113
+ The same class is closed on the SASL challenge that the IMAP, POP3, ManageSieve and submission listeners write during a multi-step exchange. There the exchange fails instead of substituting, because a challenge that cannot be sent verbatim cannot authenticate anyone. · *`b.mail.inbound.verify` evaluates ARC* — The module documented `b.mail.arc.verify` among the calls the receiver pipeline composes, and the pipeline never made it. A consumer wiring `b.mail.server.mx` as documented got SPF, DKIM and DMARC, no `arc` field in the result, no `arc=` method in the emitted `Authentication-Results`, and nothing at run time to say so.
114
+
115
+ The chain is now evaluated and reported. It is treated as evidence rather than as a gate: a malformed chain is a `fail` verdict and the pipeline continues, because refusing mail over a defect in an intermediary's headers punishes the wrong party. "none" is reported like any other verdict, so a receiver reading the header can tell a chain that was checked and found absent from a receiver that does not check. · *`safeResolveTxt` reports absence as absence* — The guard listed the resolver's `ENOTFOUND` and `ENODATA` codes, and the DNS resolver reported every non-zero RCODE through one error code, so NXDOMAIN — the commonest form of "no record published" — arrived as a failure and threw.
116
+
117
+ RCODE 3 now carries `resolver/nxdomain` and everything else stays `resolver/query-failed`. The distinction matters beyond this call: `b.mail.rbl` treated any lookup error as "not listed", so a SERVFAIL from a blocklist read as a clean verdict. It now accepts only genuine absence. · *`b.mail.helo` FCrDNS can pass* — The check calls `resolver.queryPtr`, which `b.network.dns.resolver.create()` did not expose. The call returned `undefined`, the resulting TypeError landed in a catch written for NXDOMAIN, and the check reported a clean "no reverse name" for every address it ever ran on. Every test passed throughout, because the only object with a `queryPtr` was the one in the test file.
118
+
119
+ The resolver now provides `queryPtr`, and a resolver that does not is refused rather than read as a miss: "no reverse name" and "I could not ask" are different findings. · *`b.mail.agent.expunge` refuses a posture it cannot resolve* — The retention floor was resolved by indexing the floor table with a fallback to zero, so a misspelled posture, a capitalised one, or one the table does not carry all permitted an unbounded hard delete with no error. It also read through the prototype: posture `"constructor"` returned a function, which is truthy, so the fallback kept it and every age comparison against it was meaningless. A typo was refused where retention windows are computed and accepted at the one call that destroys mail permanently.
120
+
121
+ It now resolves through `b.retention.complianceFloor`. No posture at all remains a legitimate zero-floor configuration; a posture that was supplied and is not understood is refused. · *`b.retention.complianceFloor` stops calling valid postures typos* — Absence from the retention-floor table was read as "not a posture", but that table holds only the subset of regimes carrying a regulator-mandated minimum. Most do not: GDPR Art. 5(1)(e) is storage limitation, the opposite of a floor. So 158 of the 169 postures `b.compliance.set` accepts were reported as misspellings, and an operator who set one could not compute a TTL at all.
122
+
123
+ A posture the framework knows now resolves, to zero when it imposes no minimum. A name outside `b.compliance.KNOWN_POSTURES` still throws, and says which vocabulary it was measured against. · *`b.mail.server.imap` answers a literal with a real continuation* — RFC 9051 §7.5 defines a command continuation request as a line beginning with `+`. The listener wrote it as an untagged response, so the wire carried `* + Ready for literal data`. A conforming client waits for a `+` line, and `APPEND` never completed. The non-synchronizing route was closed too: the strict profile's guard refuses `LITERAL+` and `CAPABILITY` does not advertise it. · *`b.mail.server.jmap` can decline the WebSocket transport* — The RFC 8887 capability was advertised whether or not the consumer had wired the upgrade handler. The check that looked like an opt-out was not one: omit the key and the default added it, supply the key and the merge put it straight back. The only value that suppressed it was `undefined`, and only because `JSON.stringify` drops such keys.
124
+
125
+ `webSocket: false` is now the answer, and it removes both the capability and the top-level `webSocketUrl` alias, so a client is not sent to an endpoint that cannot upgrade. · *`b.credentialHash.needsRehash` refuses an algorithm name it does not know* — `hash()` validated `opts.algo` against the algorithm table and threw on a name it did not recognise. `needsRehash()` read the same table with a fallback to the framework default, so a misspelling silently answered a different question than the caller asked: "is this row still the default?" rather than "is this row argon2id?", on a credential-rotation decision. It also read through the prototype, where `"constructor"` yielded a function that compares unequal to every real algorithm id. · *`b.ai.adverseDecision.wrap` refuses a legal basis it has no deadlines for* — `legalBasis` selects the statutory deadlines stamped on every adverse notice, and an unrecognised value fell back to `operator-defined`, whose deadlines are all null. One missing character in `gdpr-22` produced a notice asserting the subject has no right to an explanation, no right to human review and no right to appeal — the exact obligations Article 22 imposes, switched off silently, while the notice still recorded the misspelled basis as though it had been honoured.
126
+
127
+ An operator whose regime the framework does not carry asks for `operator-defined` by name. · *`b.redact.classifyDefaults` refuses a prototype member as a classifier name* — The unknown-pattern check read `opts.extra[name]` through the prototype, so once a consumer supplied any extra pattern, `constructor` / `toString` / `valueOf` resolved to inherited functions and passed as known names. The scanner then met a function where a pattern spec belongs and threw an untyped error at scan time: a data-loss classifier failing at the moment it was asked to look, rather than refusing at boot. · *Two middleware counters no longer discard their own failures* — `b.middleware.bearerAuth` and `b.middleware.fetchMetadata` recorded their metrics through a call that throws on a misconfigured observability backend, inside the request path. Both now use the drop-silent emitter the rest of the framework's hot paths use, so a metrics problem cannot fail the request that triggered it.
128
+
129
+ - v0.18.53 (2026-08-24) — **The YAML screens refused ordinary documents: a list of records, and an exclamation mark in a sentence.** `b.guardYaml.parse` refused any sequence whose items carry more than one key, which is the shape of nearly every list of records anyone writes. Separately, both `b.guardYaml` and `b.parsers.yaml` decided whether a `!`, `&` or `*` opened a tag, anchor or alias by looking at the character in front of it, so they reported one inside a quoted string, inside a block scalar's shell script, in the middle of a value, and inside a comment. `x: 1 # note !bang` was refused. Both screens now read the document's structure, through one scanner shared by the two modules. **Fixed:** *`b.guardYaml` no longer reports a duplicate key for a list of records* — Key uniqueness was tracked per indentation level, and a sequence-item line never reset that tracking. The second item's keys were therefore checked against the first item's, so every key except the one written inline with the dash was reported as a duplicate:
130
+
131
+ ```yaml
132
+ steps:
133
+ - name: build
134
+ run: make
135
+ - name: test
136
+ run: make check
137
+ ```
138
+
139
+ That document was refused with `duplicate key "run"`, and it has no duplicate key in it. Under `duplicateKeyPolicy: "reject"` — the strict profile's default — the parse threw.
140
+
141
+ Each item of a sequence is now its own mapping, so a key may appear once per item. Two things that were already true stay true, and both are pinned by tests: a key repeated *within* one item is still a duplicate, and so is a key repeated in an ordinary mapping.
142
+
143
+ The key written inline with the dash was being skipped entirely rather than tracked, so `- name: a` followed by `name: b` inside the same item went unreported. It is now tracked with the rest of its mapping. · *A tag, anchor or alias is recognised where it can actually appear* — `!`, `&` and `*` introduce a tag, an anchor and an alias only at a node start. Both YAML screens decided the question by looking at the preceding character — whitespace meant a sigil, anything else meant not — and that rule cannot tell a node start from the middle of a scalar. Every one of these was refused:
144
+
145
+ ```yaml
146
+ x: "hello !world" # a bang in a quoted string
147
+ x: hello !world # a bang in the middle of a value
148
+ x: 1 # note !bang # a bang in a comment
149
+ x: |
150
+ echo !boom # a bang in a shell script
151
+ text: fish &chips # an ampersand in prose
152
+ ```
153
+
154
+ The two modules had separate implementations of the check and separate gaps. `b.parsers.yaml` masked quoted strings but copied comment text through verbatim and had no block-scalar handling, despite a note in it saying both were covered; `b.guardYaml` had none of the three. So a document refused by one was sometimes accepted by the other.
155
+
156
+ Both now use one scanner that tracks where each character sits: inside a quoted scalar, a comment, a block-scalar body, a plain scalar, or in the structure. A sigil is reported only where it opens a node. Scalars that run across several lines are followed rather than read afresh, so neither the continuation of a quoted string nor of an unquoted value is mistaken for structure, and a `!` after the closing quote or the ending comma still is one.
157
+
158
+ **What this changes for a document you already have:** a sigil inside a plain scalar is no longer reported. `text: this &notanchor` and `x: -&a` used to raise an anchor finding and no longer do. Neither declares an anchor — an anchor is a separate token — so nothing can reference it, and the amplification these findings exist to stop has no path through them. A merge key written without spaces, `<<:*d`, is still refused, by the merge-key screen rather than the alias one.
159
+
160
+ `b.parsers.yaml` continues to refuse every real tag, anchor, alias and directive, and the strict profile of `b.guardYaml` continues to report them. · *The residency gate's raw-SQL timing check no longer depends on how busy the machine is* — The check that a padded raw `UPDATE` costs no extra parse time compared the padded call against an unpadded one and allowed 500ms between them. A difference of two timings carries the noise of both, and on a loaded runner the padded sample alone drifted past that allowance while the statement itself parses in a fraction of a millisecond.
161
+
162
+ It now bounds the padded call on its own, and takes the fastest of fifteen samples rather than three. The defect it guards cost roughly seven seconds against 0.04ms once fixed, so the ceiling sits between the two behaviours with several orders of magnitude to spare rather than tracking the machine. The unpadded baseline is still measured and still printed, because a reader looking at a failure needs to know whether the box was slow or the parse was.
163
+
164
+ - v0.18.52 (2026-08-23) — **A refusal that would not say why, and a currency gate that skipped the one pin able to move underneath it.** `b.guardFilename` refuses a filename carrying NTFS alternate-data-stream syntax whatever `adsPolicy` says, which is correct and was documented, but the refusal never mentioned the option — so a caller who set `"allow"` was left comparing their code against a message that said nothing about the setting they had changed. Separately, the pinned-actions currency gate could only see a `uses:` pinned to a commit SHA, so the one workflow reference pinned to a tag was absent from every run rather than reported, and its `--json` output had a human summary line after the document, so nothing could parse it. **Fixed:** *The NTFS-ADS refusal now says where `adsPolicy: "allow"` applies* — A filename carrying alternate-data-stream syntax (`report.txt:stream`) is refused by `b.guardFilename.sanitize`, `validate` and `gate` regardless of `adsPolicy`. That is deliberate: on Windows the write lands on a hidden stream of the base file rather than on a file anyone can see, so it is one of the shapes a filename guard always refuses. `adsPolicy` still takes `"allow"` because `verifyExtractionPath` honours it, for an operator deliberately extracting stream-suffixed entries into a root they chose.
165
+
166
+ What was missing is that none of this was visible from the call site. The refusal said only that the name contained stream syntax, so setting `"allow"` and watching nothing change read as the option being broken rather than as it being scoped. The message now names the boundary, and the split is pinned by tests at all four entry points.
167
+
168
+ One thing worth knowing if you go looking: a colon is also a Windows reserved character, so `reservedCharPolicy` refuses such a name before the ADS check is reached. Setting `adsPolicy` alone never changes the outcome for a name like `12:30 notes.txt` — `reservedCharPolicy` is the one governing it, and `"strip"` there yields `12_30 notes.txt`.
169
+
170
+ This also records a change that shipped in 0.18.48 without being written down: before it, `sanitize` honoured `adsPolicy: "reject"` and so could be opted out of. It no longer can be, and there is no replacement value, because a stream-suffixed name has no safe repair. · *The pinned-actions currency gate could not see a tag-pinned workflow* — `check-actions-currency.js` matched a `uses:` line only when it was pinned to a 40-character commit SHA. A reference pinned to anything else matched nothing, was collected by nothing, and appeared in the run as neither current nor stale — absent, while the summary counted what it had looked at and read as a clean result.
171
+
172
+ The pin that fell through is the one least able to look after itself. A commit SHA is immutable, so a stale SHA pin becomes visible as soon as upstream cuts a release; a tag can be repointed at new code with no local diff at all. The single reference here pinned to a tag is pinned that way by necessity, because the SLSA provenance generator refuses to run from a commit SHA — so the one exception to the pinning discipline was also the one thing never checked.
173
+
174
+ Tag pins are now collected, version-checked like any other, and reported as tag pins. `--fix` deliberately leaves them alone and says so: there is no old SHA to compare against and none to write, and the review material `--fix` prints is a diff between two SHAs, which is exactly what a tag cannot give.
175
+
176
+ An action can also be pinned by SHA in one workflow and by tag in another, and the two need different answers in different places. The pin type is therefore tracked per reference: the ready-to-paste replacement line is printed only where a SHA reference can take it, each tag reference is marked as one, and `--fix` skips such an action entirely rather than bumping the references that were already current and leaving the stale one behind.
177
+
178
+ The cause of the gap is worth naming because it repeated one level down: the first pattern written to catch these anchored the version at end-of-line, and the line in question carries a trailing comment explaining why it is not SHA-pinned. It matched nothing either.
179
+
180
+ So the gate no longer relies on recognising every shape. It reads the `uses:` scalar first, quoting and all, then classifies what it found; a reference it cannot classify is listed and **fails the run** rather than dropping out of the report. That covers quoted values (`uses: "owner/repo@v1"`), version tags carrying a prerelease or build suffix (`@v2.1.0-rc.1`), a SHA pin whose `# vX.Y.Z` comment is missing, and a reference pinned to a branch. Local actions (`./…`) and `docker://` images are skipped deliberately, because neither has an upstream release to compare against, and the body of a `run: |` block is skipped because those lines are script rather than YAML.
181
+
182
+ Two consequences of reading more shapes are worth calling out. Version comparison now follows semver precedence for prerelease identifiers, so `rc.1` is older than `rc.2` and older than `rc.10`, and a release candidate left pinned after the final release ships reports stale rather than current. And `--fix` now verifies that each rewrite actually landed: it reaches through a closing quote to replace a quoted pin, and if a collected reference does not match the replacement it says so and exits non-zero instead of reporting the action fixed over an unchanged file.
183
+
184
+ The pattern matching is gone. Whether a `uses` token is a key is a question about YAML structure, and a pattern cannot answer it: every attempt to widen one admitted a shape it read wrongly, and every attempt to narrow one dropped a shape it should have read. The collector now scans, tracking the three things that actually decide it — quoting, comments, and flow-collection depth.
185
+
186
+ Every form is read as a result: block style, flow mappings whether `uses` is the first key or the fifth, mappings spanning lines, quoted keys (`"uses":`), quoted values, and values inside nested flow collections. And in the other direction, `uses` inside a quoted string, inside a comment, or inside a `run: |` body is text rather than a key, so `- { run: "echo a, uses: owner/repo@main" }` names no action at all. Both block-scalar indicator orders are handled, since `|2-` and `|-2` are equally valid and misreading one scans a shell script as YAML.
187
+
188
+ Position decides what counts, not the value. A reference is a `uses` under `steps`, or a job's own `uses` for a reusable workflow; anything else spelled `uses` is data. That distinction cannot be made from the value, because `owner/repo@main` is a perfectly ordinary string to put in an `env` block or pass through `with`, and matrix `include` entries carry properties an operator names themselves. Naming the two positions the schema defines is finite; listing every container that is not one is not.
189
+
190
+ What the scan covers is printed on every run, as a `scope` line in the report and a `scope` field in `--json`. The original defect was a form the gate did not read and did not say so; that is now two separate things it cannot do.
191
+
192
+ If you run this gate against your own workflows, expect it to fail on references it could previously not see. That is the point of the change; each one it names is a pin whose currency was never being checked. · *`check-actions-currency.js --json` emits only the JSON document* — The JSON branch wrote the document and then fell through into the summary blocks, which wrote to the same stream regardless of the flag. Every `--json` run therefore ended with a `[actions-currency] …` line after the closing brace, and `JSON.parse` on the stream failed with "Unexpected non-whitespace character after JSON". `--fix --json` was the same shape with more trailing text.
193
+
194
+ Every human-readable line now goes through a writer that is silent under `--json`. Exit codes are unchanged: a machine reader still gets a non-zero exit when something is stale.
195
+
11
196
  - v0.18.51 (2026-08-22) — **Five parsers could be made to spend their time on the thing they were parsing.** Three prompt-injection detectors, the BIMI logo parser and the raw-write data-residency gate each ran a pattern whose cost grew with the square or the cube of its input while an ordinary input of the same length cost a millisecond. A 64 KiB prompt took up to 4.5 seconds to classify; a 32 KiB logo took 409 milliseconds to parse; a 4 KB raw UPDATE took 7 seconds to get a verdict from a gate that accepts statements twenty-four times longer. All five now cost what ordinary input of that length costs, and each was checked against a corpus to confirm it still decides what it decided before. Separately, a verified VMC logo was reported as absent when its SVG began with an XML declaration and a DOCTYPE. **Changed:** *`b.guardFilename` no longer repairs a null byte, and five options that accept one value now say so* — 0.18.47 accepted `nullBytePolicy: "strip"` on `b.guardFilename` and removed the null byte from the name. 0.18.48 stopped accepting it. That removal was correct and was not written down, which is the part being fixed here.
12
197
 
13
198
  It was correct because a null byte in a filename is a truncation attack, not a typo: the name the check reads and the name the operating system acts on differ at the byte, so repairing it produces a name nobody validated. The guard refuses with `filename.null-byte` and the message has said `null-byte truncation is never sanitizable` throughout. There is no replacement policy value, because there is no safe repair. A caller that was asking for the strip should refuse the input instead, or rename before validating.
package/NOTICE CHANGED
@@ -68,7 +68,7 @@ Used for: FIPS 203 ML-KEM (ml_kem_512 / ml_kem_768 / ml_kem_1024),
68
68
  reference implementation.
69
69
  --------------------------------------------------------------------------------
70
70
  Component: @blamejs/pki
71
- Version: 0.5.28
71
+ Version: 0.5.30
72
72
  Source: https://github.com/blamejs/pki
73
73
  License: Apache-2.0
74
74
  Copyright: Copyright (c) blamejs contributors
package/README.md CHANGED
@@ -165,7 +165,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
165
165
  - **Archive + filename** — `b.guardArchive` (zip-slip, symlink + hardlink escape, decompression bombs, duplicate-entry); `b.guardFilename` (path traversal raw + percent-encoded + overlong-UTF-8, null-byte, Windows reserved, NTFS ADS, RTLO bidi)
166
166
  - **Email** — `b.guardEmail` (SMTP smuggling per CVE-2023-51764 / 51765 / 51766 class, CRLF header injection, IDN homograph, IP-literals, RFC 5321 length caps)
167
167
  - **Identifiers** — `b.guardUuid` (RFC 9562 form / version / variant, nil + max sentinels); `b.guardCidr` (octet overflow, mask range, reserved-range membership); `b.guardCountry` (ISO 3166-1 alpha-2 from a bundled table, never `Intl` — the 249 officially assigned codes only, so `UK`, `ZZ`, `EU`, `EZ`, `UN` and the user-assigned ranges are refused rather than routed on, and a stripped-ICU build changes no answer)
168
- - **Character catalog** — `b.codepointClass` bidi / C0-control / zero-width / Unicode-Tags / whitespace range tables plus the codepoint scanners the guards screen with (`firstInRanges` / `stripRanges` / `replaceRanges` / `indexOfAny` / `replaceAny` / `trimChars` / `trimRanges` / `containsFolded` / `indexOfFolded` / `matchesAtFolded` / `isRunOf` / `isRunOfRanges` / `splitLines` / `splitLinesAny` / `splitOnWhitespace`), and UTS #39 confusable-script classification
168
+ - **Character catalog** — `b.codepointClass` bidi / C0-control / zero-width / Unicode-Tags / whitespace range tables plus the codepoint scanners the guards screen with (`firstInRanges` / `stripRanges` / `replaceRanges` / `indexOfAny` / `replaceAny` / `trimChars` / `trimRanges` / `containsFolded` / `indexOfFolded` / `matchesAtFolded` / `isRunOf` / `isRunOfRanges` / `hasPairWhere` / `splitLines` / `splitLinesAny` / `splitOnWhitespace`), and UTS #39 confusable-script classification
169
169
  - **No regular expressions** — every screen in every `b.guard*` and `b.safe*` primitive is a character walk, so its cost is the length of the input and its rule is the one the source states. A build gate refuses a new pattern in the family; a pattern that must be RUN — an operator's JSON-schema `pattern`, an operator's SQL identifier shape — goes through `b.regexLinear` or is refused by `b.guardRegex.assertSafe` first
170
170
  - **Profiles + postures** — every member ships strict / balanced / permissive plus hipaa / pci-dss / gdpr / soc2
171
171
  - **Aggregator** — `b.guardAll` registry; every shipped guard ON by default; opt-out per guard with audited reason via `exceptFor: { name: { reason } }`. `b.fileUpload` and `b.staticServe` wire `b.guardAll.byExtension({ profile: "strict" })` + `b.guardFilename.gate({ profile: "strict" })` automatically — operator opts out via `contentSafety: null` / `filenameSafety: null` (audited)
@@ -176,7 +176,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
176
176
  - **Pub/sub + events** — distributed pub/sub with cluster-table / Redis PUB/SUB / custom backends (`b.pubsub`); framework-emitted signal bus for breach / integrity events (`b.events`)
177
177
  - **CloudEvents + SSE** — CloudEvents 1.0.2 for AWS EventBridge / Knative / Azure Event Grid / Google Eventarc / CNCF: `wrap` / `parse` envelopes, non-throwing `validate` / `isValid`, the JSON event + batch formats (`toJSON` / `fromJSON` / `toJSONBatch` / `fromJSONBatch`), and the HTTP binding in both binary and structured content modes with auto-detecting `http.decode` (`b.cloudEvents`); Server-Sent Events with newline-injection refusal in `event:` / `id:` / `data:` / `Last-Event-ID` (CVE-2026-33128 / 29085 / 44217 class) (`b.sse`, `b.middleware.sse`)
178
178
  - **Mail (outbound)** — multipart + attachments + DKIM + calendar invites; bounce intake (`b.mail`, `b.mailBounce`)
179
- - **Mail (outbound delivery)** — MX-lookup → MTA-STS-fetch → DANE-TLSA → REQUIRETLS handshake → SMTP wire layer → RFC 3464 DSN-on-permanent-failure → deferred-retry scheduling, all wired once (`b.mail.send.deliver`)
179
+ - **Mail (outbound delivery)** — MX-lookup → MTA-STS-fetch → DANE-TLSA lookup and per-peer certificate-chain verification (RFC 7672) → REQUIRETLS handshake → SMTP wire layer → RFC 3464 DSN-on-permanent-failure → deferred-retry scheduling, all wired once (`b.mail.send.deliver`)
180
180
  - **Mail (inbound auth)** — SPF / DMARC / ARC verify + ARC chain signing for relays, plus DMARC aggregate (RUA) + forensic (RUF) report parsing (`b.mail.spf`, `b.mail.dmarc`, `b.mail.arc`)
181
181
  - **Mail server listeners** — RFC 5321 MX inbound with connection-level gate cascade (HELO identity / DNS blocklist / greylisting) and a DATA-phase SPF/DKIM/DMARC gate that refuses policy-failing mail before storage (`b.mail.server.mx`), RFC 6409 submission with SASL + identity-binding (`b.mail.server.submission`), RFC 9051 IMAP4rev2 with CONDSTORE / QRESYNC / NOTIFY / METADATA / CATENATE (`b.mail.server.imap`), RFC 8620 + RFC 8621 JMAP Core + Mail over HTTP/SSE/WebSocket (`b.mail.server.jmap`), POP3 (`b.mail.server.pop3`), ManageSieve (`b.mail.server.managesieve`)
182
182
  - **JMAP EmailSubmission reference** — composes `b.mail.send.deliver` to land the RFC 8621 §7.5 surface end-to-end (`b.mail.server.jmap.emailSubmissionSetHandler`)
@@ -323,7 +323,7 @@ All runtime dependencies are committed to the repo — no transitive npm install
323
323
  | [`@noble/hashes`](https://github.com/paulmillr/noble-hashes) | 2.3.0 | [Paul Miller](https://github.com/paulmillr) | Browser (ESM) build only — SHAKE256 / SHA-3 / SHA-2 / HMAC / HKDF for the client half of a hybrid exchange. The server side reaches all of these through `node:crypto`, so there is no server bundle |
324
324
  | [`@noble/curves`](https://github.com/paulmillr/noble-curves) | 2.3.0 (bundles @noble/hashes 2.3.0) | [Paul Miller](https://github.com/paulmillr) | RFC 9497 Oblivious Pseudo-Random Function (OPRF / VOPRF / POPRF) over ristretto255 / P-256 / P-384 / P-521, behind `b.crypto.oprf` |
325
325
  | [`@noble/post-quantum`](https://github.com/paulmillr/noble-post-quantum) | 0.7.0 (bundles @noble/hashes, @noble/curves, @noble/ciphers 2.3.0) | [Paul Miller](https://github.com/paulmillr) | Pure-JS FIPS 203 ML-KEM (`ml_kem_512` / `ml_kem_768` / `ml_kem_1024`), FIPS 204 ML-DSA (`ml_dsa_44/65/87`), FIPS 205 SLH-DSA (`slh_dsa_*`). First-class on both server-side and client-side via `b.pqcSoftware` — security-first defaults pin to the highest cat-5 levels (ML-KEM-1024, ML-DSA-87, SLH-DSA-SHAKE-256f); interoperable with Node's built-in WebCrypto ML-KEM that `b.crypto.encrypt` / `b.middleware.apiEncrypt` use. A browser (ESM) build ships beside it carrying the KEM suites only — a client half encapsulates and does not sign |
326
- | [`@blamejs/pki`](https://github.com/blamejs/pki) | 0.5.28 | [blamejs](https://github.com/blamejs) | Zero-dependency pure-JS X.509 / CRL / PKCS#12 / CSR / CMS toolkit backing `b.mtlsCa` — ML-DSA-87 (FIPS 204) post-quantum + ECDSA-P384 cert signing, PBMAC1 PKCS#12 packaging, chain validation (no openssl CLI) — and the WebAuthn attestation / assertion verification behind `b.auth.passkey` |
326
+ | [`@blamejs/pki`](https://github.com/blamejs/pki) | 0.5.30 | [blamejs](https://github.com/blamejs) | Zero-dependency pure-JS X.509 / CRL / PKCS#12 / CSR / CMS toolkit backing `b.mtlsCa` — ML-DSA-87 (FIPS 204) post-quantum + ECDSA-P384 cert signing, PBMAC1 PKCS#12 packaging, chain validation (no openssl CLI) — and the WebAuthn attestation / assertion verification behind `b.auth.passkey` |
327
327
  | [`SecLists` 10k-most-common.txt](https://github.com/danielmiessler/SecLists/blob/master/Passwords/Common-Credentials/10k-most-common.txt) | master snapshot | [Daniel Miessler / SecLists contributors](https://github.com/danielmiessler/SecLists) (CC-BY-3.0) | Top-10000 common-password dictionary read by `b.auth.password.policy()` for the NIST 800-63B §5.1.1.2 "previously breached" check |
328
328
  | [`prismjs`](https://prismjs.com/) | 1.30.0 | [Lea Verou + contributors](https://github.com/PrismJS/prism) | Syntax highlighting in the example wiki's code blocks (browser-side) |
329
329
 
@@ -65,7 +65,9 @@ var AdverseDecisionError = defineClass("AdverseDecisionError", { alwaysPermanent
65
65
 
66
66
  // Per-regime statutory deadlines for the consumer-rights surfaces.
67
67
  // Operators select via opts.legalBasis; the framework attaches the
68
- // right deadline shape to each adverseNotice.
68
+ // right deadline shape to each adverseNotice. This table IS the accepted
69
+ // vocabulary — wrap() refuses a basis that is not a key here, because the
70
+ // deadlines it carries are not guessable from the name.
69
71
  var REGIME_DEADLINES = Object.freeze({
70
72
  "gdpr-22": { explanation: "30d", humanReview: "30d", appeal: "30d", regulation: "GDPR Article 22" },
71
73
  "ai-act-86": { explanation: "30d", humanReview: "30d", appeal: "30d", regulation: "EU AI Act Article 86" },
@@ -103,7 +105,21 @@ function wrap(opts) {
103
105
  var onAdverse = typeof opts.onAdverse === "function" ? opts.onAdverse : null;
104
106
  var now = typeof opts.now === "function" ? opts.now : function () { return Date.now(); };
105
107
 
106
- var deadlines = REGIME_DEADLINES[legalBasis] || REGIME_DEADLINES["operator-defined"];
108
+ // The basis selects the statutory deadlines stamped on every adverse notice,
109
+ // so an unrecognised one cannot fall back to "operator-defined": its
110
+ // deadlines are all null, and a single missing character in "gdpr-22" then
111
+ // produced a notice asserting the subject has no right to an explanation, to
112
+ // human review, or to appeal — the exact obligations Article 22 imposes,
113
+ // switched off silently while the notice recorded the misspelling as though
114
+ // it had been honoured. An operator whose regime is not carried here asks for
115
+ // "operator-defined" by name.
116
+ if (!Object.prototype.hasOwnProperty.call(REGIME_DEADLINES, legalBasis)) {
117
+ throw new AdverseDecisionError("ai-adverse/bad-legal-basis",
118
+ "ai.adverseDecision.wrap: unknown legalBasis '" + legalBasis + "'. The " +
119
+ "basis fixes the statutory deadlines on every adverse notice, so it cannot " +
120
+ "be guessed. Known: " + Object.keys(REGIME_DEADLINES).join(", "));
121
+ }
122
+ var deadlines = REGIME_DEADLINES[legalBasis];
107
123
 
108
124
  var _emitAudit = audit().namespaced("ai.adverse_decision", opts.audit);
109
125
  var _emitMetric = observability().namespaced("ai.adverse_decision");
@@ -1250,6 +1250,44 @@ function isIdentifierChar(cc) {
1250
1250
  return isAsciiAlnum(cc) || cc === 0x5F; // "_"
1251
1251
  }
1252
1252
 
1253
+ /**
1254
+ * @primitive b.codepointClass.hasPairWhere
1255
+ * @signature b.codepointClass.hasPairWhere(text, first, second, accept)
1256
+ * @since 0.18.54
1257
+ * @status stable
1258
+ * @related b.codepointClass.splitLines
1259
+ *
1260
+ * Is there a place where `first` is immediately followed by `second`, and
1261
+ * `accept` says that place counts? `accept(i)` is called with the index of
1262
+ * `first`, and returns whether the construct opening there is a real one.
1263
+ *
1264
+ * This is the frame every two-character construct screen was writing out by
1265
+ * hand: a path traversal opening on `..`, a regex inline-flag group on `(?`, a
1266
+ * YAML merge key on `<<`. The literals and the follow-on tests differ, the scan
1267
+ * does not, and three copies of a scan are three places for an off-by-one to
1268
+ * live. It reads the string one character at a time, which is the property a
1269
+ * guard needs: asking the question with a pattern would be a screen running the
1270
+ * construct it screens over hostile text.
1271
+ *
1272
+ * `accept` decides alone — the scan does not skip past a hit it rejected, so
1273
+ * overlapping openers are all offered. A screen that needs to COUNT
1274
+ * non-overlapping occurrences is asking a different question and keeps its own
1275
+ * cursor.
1276
+ *
1277
+ * @example
1278
+ * var CP = b.codepointClass;
1279
+ * CP.hasPairWhere("a/../b", ".", ".", function () { return true; }); // → true
1280
+ * CP.hasPairWhere("a.b", ".", ".", function () { return true; }); // → false
1281
+ */
1282
+ function hasPairWhere(text, first, second, accept) {
1283
+ if (typeof text !== "string") return false;
1284
+ for (var i = 0; i + 1 < text.length; i += 1) {
1285
+ if (text.charAt(i) !== first || text.charAt(i + 1) !== second) continue;
1286
+ if (accept(i)) return true;
1287
+ }
1288
+ return false;
1289
+ }
1290
+
1253
1291
  /**
1254
1292
  * @primitive b.codepointClass.splitLines
1255
1293
  * @signature b.codepointClass.splitLines(text)
@@ -1424,6 +1462,38 @@ function firstControlCharOffset(s, opts) {
1424
1462
  return -1;
1425
1463
  }
1426
1464
 
1465
+ /**
1466
+ * @primitive b.codepointClass.firstLineInjectionCharOffset
1467
+ * @signature b.codepointClass.firstLineInjectionCharOffset(s)
1468
+ * @since 0.18.54
1469
+ * @status stable
1470
+ * @related b.codepointClass.firstControlCharOffset
1471
+ *
1472
+ * Return the index of the first CR, LF or NUL in `s`, or `-1` when there is
1473
+ * none. These three are the bytes that end a line-oriented protocol record,
1474
+ * so a value carrying one splits the record it is written into and the
1475
+ * remainder is read as a second, attacker-chosen line: the header-injection
1476
+ * class in HTTP and Set-Cookie, and the command-injection class in SMTP,
1477
+ * POP3, IMAP and ManageSieve.
1478
+ *
1479
+ * Narrower than `firstControlCharOffset`, deliberately. That one refuses every
1480
+ * C0 control and DEL, which is right for text a human wrote; this one answers
1481
+ * the specific question a wire-protocol writer asks, so a caller adopting it
1482
+ * does not silently start refusing values it used to accept.
1483
+ *
1484
+ * @example
1485
+ * b.codepointClass.firstLineInjectionCharOffset("nonce-42"); // -1
1486
+ * b.codepointClass.firstLineInjectionCharOffset("abc\r\nOK"); // 3
1487
+ */
1488
+ function firstLineInjectionCharOffset(s) {
1489
+ if (typeof s !== "string") return -1;
1490
+ for (var i = 0; i < s.length; i += 1) {
1491
+ var c = s.charCodeAt(i);
1492
+ if (c === 0x0d || c === 0x0a || c === 0x00) return i; // CR / LF / NUL
1493
+ }
1494
+ return -1;
1495
+ }
1496
+
1427
1497
  // Decode HTML numeric character references (hex &#x..; and decimal &#..;) just
1428
1498
  // enough to expose a scheme hidden behind entity-encoding. The trailing
1429
1499
  // semicolon is OPTIONAL — a browser decodes `&#106avascript:` (no semicolon)
@@ -1743,6 +1813,7 @@ module.exports = {
1743
1813
  caseFoldPartners: caseFoldPartners,
1744
1814
  isForbiddenControlChar: isForbiddenControlChar,
1745
1815
  firstControlCharOffset: firstControlCharOffset,
1816
+ firstLineInjectionCharOffset: firstLineInjectionCharOffset,
1746
1817
  decodeNumericEntities: decodeNumericEntities,
1747
1818
  decodeMarkupEntities: decodeMarkupEntities,
1748
1819
  NAMED_ENTITY_ASCII: NAMED_ENTITY_ASCII,
@@ -1769,6 +1840,7 @@ module.exports = {
1769
1840
  isAsciiDigit: isAsciiDigit,
1770
1841
  isAsciiHexDigit: isAsciiHexDigit,
1771
1842
  isIdentifierChar: isIdentifierChar,
1843
+ hasPairWhere: hasPairWhere,
1772
1844
  splitLines: splitLines,
1773
1845
  splitLinesAny: splitLinesAny,
1774
1846
  splitOnWhitespace: splitOnWhitespace,
package/lib/cookies.js CHANGED
@@ -591,16 +591,13 @@ function parseSafe(cookieHeader, opts) {
591
591
  });
592
592
  return { jar: jar, issues: issues };
593
593
  }
594
- for (var hi = 0; hi < cookieHeader.length; hi += 1) {
595
- var ch = cookieHeader.charCodeAt(hi);
596
- if (ch === 0x0D || ch === 0x0A || ch === 0x00) { // CR / LF / NUL forbidden in cookie header
597
- issues.push({
598
- kind: "header-control-byte", severity: "high",
599
- snippet: "Cookie header contains CR / LF / NUL — proxy-side " +
600
- "header injection vector",
601
- });
602
- return { jar: jar, issues: issues };
603
- }
594
+ if (codepointClass.firstLineInjectionCharOffset(cookieHeader) !== -1) {
595
+ issues.push({
596
+ kind: "header-control-byte", severity: "high",
597
+ snippet: "Cookie header contains CR / LF / NUL — proxy-side " +
598
+ "header injection vector",
599
+ });
600
+ return { jar: jar, issues: issues };
604
601
  }
605
602
 
606
603
  var pairs = cookieHeader.split(/;\s*/);
@@ -372,8 +372,15 @@ function inspect(envelope) {
372
372
  function needsRehash(envelope, opts) {
373
373
  var decoded = _decodeEnvelope(envelope);
374
374
  if (!decoded) return true; // unrecognized → migrate aggressively
375
+ // Same validation hash() applies to the same option. Reading the table with
376
+ // a fallback to the default meant a misspelled algo silently answered a
377
+ // different question than the caller asked — "is this row still the default?"
378
+ // instead of "is this row argon2id?" — on a credential-rotation decision. It
379
+ // also read through the prototype: algo "constructor" yielded a function,
380
+ // which compares unequal to every real algorithm id.
381
+ _validateOpts(opts);
375
382
  var targetAlgoName = (opts && opts.algo) || DEFAULTS.algo;
376
- var targetId = NAME_TO_ID[targetAlgoName] || C.ACTIVE.CRED_HASH;
383
+ var targetId = NAME_TO_ID[targetAlgoName];
377
384
  if (decoded.algoId !== targetId) return true;
378
385
  if (decoded.algoId === C.CRED_HASH_IDS.ARGON2ID) {
379
386
  // Defer the parameter-lag check to the password primitive's
package/lib/crypto.js CHANGED
@@ -1815,7 +1815,8 @@ function encryptMlkem768X25519(plaintext, recipient) {
1815
1815
  // algorithm gets a clear error rather than the generic "unsupported
1816
1816
  // KEM ID" path.
1817
1817
  //
1818
- // recipient: { privateKey, x25519PrivateKey } — operator's keys
1818
+ // recipient: the operator's two private keys, named privateKey (ML-KEM-768)
1819
+ // and x25519PrivateKey (X25519)
1819
1820
  // ciphertext: base64 envelope from encryptMlkem768X25519
1820
1821
  /**
1821
1822
  * @primitive b.crypto.decryptMlkem768X25519
@@ -1827,8 +1828,9 @@ function encryptMlkem768X25519(plaintext, recipient) {
1827
1828
  * envelope whose KEM ID byte is not `ML_KEM_768_X25519` so an
1828
1829
  * operator who calls this with a ciphertext sealed under a different
1829
1830
  * algorithm gets a clear error rather than the generic dispatch path.
1830
- * Recipient shape is `{ privateKey, x25519PrivateKey }` `privateKey`
1831
- * is the ML-KEM-768 PEM, NOT the framework default ML-KEM-1024.
1831
+ * The recipient carries two private keys: `privateKey`, which is the
1832
+ * ML-KEM-768 PEM and NOT the framework default ML-KEM-1024, and
1833
+ * `x25519PrivateKey`, which is the X25519 PEM.
1832
1834
  *
1833
1835
  * @example
1834
1836
  * var pair = b.crypto.generateMlkem768X25519KeyPair();
@@ -1845,8 +1847,8 @@ function encryptMlkem768X25519(plaintext, recipient) {
1845
1847
  function decryptMlkem768X25519(ciphertext, recipient) {
1846
1848
  if (!recipient || typeof recipient !== "object" ||
1847
1849
  !recipient.privateKey || !recipient.x25519PrivateKey) {
1848
- throw new Error("decryptMlkem768X25519 requires { privateKey, x25519PrivateKey } " +
1849
- "(privateKey is the ML-KEM-768 PEM, x25519PrivateKey is the X25519 PEM)");
1850
+ throw new Error("decryptMlkem768X25519 requires both a privateKey, which is the " +
1851
+ "ML-KEM-768 PEM, and an x25519PrivateKey, which is the X25519 PEM");
1850
1852
  }
1851
1853
  var packed = Buffer.from(ciphertext, "base64");
1852
1854
  if (packed[0] !== C.ENVELOPE_MAGIC) {
package/lib/guard-auth.js CHANGED
@@ -272,8 +272,10 @@ function _detectIssues(bundle, opts) {
272
272
  * oauthMaxBytes: number, // guardOauth's flow cap, not this one
273
273
  *
274
274
  * @example
275
+ * // A token whose header is { "alg": "none" } — unsigned, so anyone can
276
+ * // mint one. Refused under every profile.
275
277
  * var rv = b.guardAuth.validate({
276
- * jwtToken: "eyJhbGciOiJub25lIn0.eyJzdWIiOiJ4In0.",
278
+ * jwtToken: unsignedToken,
277
279
  * }, { profile: "strict" });
278
280
  * rv.ok; // → false
279
281
  * rv.issues.some(function (i) { return i.source === "jwt"; }); // → true
@@ -301,9 +303,10 @@ function _detectIssues(bundle, opts) {
301
303
  * compliancePosture: "hipaa"|"pci-dss"|"gdpr"|"soc2",
302
304
  *
303
305
  * @example
306
+ * // signedToken carries { "alg": "ES256", "typ": "JWT" } over claims
307
+ * // { iss, exp, iat } — an ordinary signed bearer token.
304
308
  * var clean = b.guardAuth.sanitize({
305
- * jwtToken: "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9." +
306
- * "eyJpc3MiOiJleGFtcGxlIiwiZXhwIjo5OTk5OTk5OTk5LCJpYXQiOjE3MDAwMDAwMDB9.sig",
309
+ * jwtToken: signedToken,
307
310
  * cookieHeader: "sid=abc123",
308
311
  * }, { profile: "balanced" });
309
312
  * clean.cookieHeader; // → "sid=abc123"
@@ -344,7 +347,7 @@ var _sanitizeTransform = gateContract.identitySanitize;
344
347
  * @example
345
348
  * var authGate = b.guardAuth.gate({ profile: "strict" });
346
349
  * var verdict = await authGate.check({ authBundle: {
347
- * jwtToken: "eyJhbGciOiJub25lIn0.eyJzdWIiOiJ4In0.",
350
+ * jwtToken: unsignedToken, // header { "alg": "none" }
348
351
  * } });
349
352
  * verdict.action; // → "refuse"
350
353
  */
@@ -366,24 +369,44 @@ function gate(opts) {
366
369
  // single-sourced @abiTemplate blocks in gate-contract.js.
367
370
 
368
371
  // ---- adaptive integration-test fixtures (consumed by layer-5 host harness) ----
372
+
373
+ // The sample tokens are built from their header and claims rather than pasted
374
+ // as literals. Two reasons, and the second is why it is worth the function:
375
+ // a reader of this file sees what the token SAYS (alg=none is the whole point
376
+ // of the hostile fixture) instead of an opaque blob, and no contiguous
377
+ // high-entropy run survives for a default-rule secret scanner to report. Eleven
378
+ // such reports across this tree cost every consumer who vendors it either a
379
+ // blanket allowlist over the whole library or a hand-triage on every refresh,
380
+ // for lines that carry no secret.
381
+ function _sampleJwt(header, claims, signature) {
382
+ var part = function (obj) {
383
+ return Buffer.from(JSON.stringify(obj), "utf8").toString("base64url"); // RFC 7515 §2 base64url, no padding
384
+ };
385
+ return part(header) + "." + part(claims) + "." + (signature || "");
386
+ }
387
+
388
+ var _BENIGN_JWT = _sampleJwt(
389
+ { alg: "ES256", typ: "JWT" },
390
+ { iss: "example", exp: 9999999999, iat: 1700000000 },
391
+ "sig");
392
+ // alg=none — the universal refuse, routed through guardJwt.
393
+ var _HOSTILE_JWT = _sampleJwt({ alg: "none" }, { sub: "x" }, "");
394
+
369
395
  var INTEGRATION_FIXTURES = Object.freeze({
370
396
  kind: "auth-bundle",
371
397
  benignBytes: Buffer.from(JSON.stringify({
372
- jwtToken: "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9." +
373
- "eyJpc3MiOiJleGFtcGxlIiwiZXhwIjo5OTk5OTk5OTk5LCJpYXQiOjE3MDAwMDAwMDB9.sig",
398
+ jwtToken: _BENIGN_JWT,
374
399
  cookieHeader: "sid=abc123; theme=dark",
375
400
  }), "utf8"),
376
401
  hostileBytes: Buffer.from(JSON.stringify({
377
- jwtToken: "eyJhbGciOiJub25lIn0.eyJzdWIiOiJ4In0.",
402
+ jwtToken: _HOSTILE_JWT,
378
403
  }), "utf8"),
379
404
  benignAuthBundle: {
380
- jwtToken: "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9." +
381
- "eyJpc3MiOiJleGFtcGxlIiwiZXhwIjo5OTk5OTk5OTk5LCJpYXQiOjE3MDAwMDAwMDB9.sig",
405
+ jwtToken: _BENIGN_JWT,
382
406
  cookieHeader: "sid=abc123; theme=dark",
383
407
  },
384
- // Hostile: alg=none JWT — universal refuse routed through guardJwt.
385
408
  hostileAuthBundle: {
386
- jwtToken: "eyJhbGciOiJub25lIn0.eyJzdWIiOiJ4In0.",
409
+ jwtToken: _HOSTILE_JWT,
387
410
  },
388
411
  });
389
412