@blamejs/core 0.11.1 → 0.11.3

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,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.11.x
10
10
 
11
+ - v0.11.3 (2026-05-19) — **SPF `a` and `mx` mechanism dispatch + smaller deferral-condition cleanups.** `b.mail.spf.verify` now evaluates the `a` and `mx` mechanisms per [RFC 7208 §5.3 + §5.4](https://www.rfc-editor.org/rfc/rfc7208), including the dual-cidr-length syntax (`a:foo.example/24//64`, `mx//64`). Senders publishing `v=spf1 mx -all` or `v=spf1 a -all` previously permerrored against this framework even though those are the second-most-common SPF mechanisms in fielded policies; verification now resolves the operator-supplied A / AAAA / MX records (via the existing `dnsLookup` callback contract — which is now honored for every record type, not only TXT) and matches the connecting IP under the parsed cidr. MX expansion is capped at the RFC §4.6.4 limit of 10 hosts (over-limit = permerror); each MX-host A/AAAA expansion counts toward the 10-lookup global ceiling and the 2-lookup void-lookup sub-limit. Empty digit segments in the dual-cidr-length grammar (`a/`, `a//`, `mx/`, `mx//`, `a/24//`) permerror with an explanatory message — RFC §5.3/§5.4 grammar requires `1*DIGIT` after each slash, and accepting empty would over-authorize senders publishing `v=spf1 a/ -all` (would match every IP in the /32 of every A record). The `exists` (RFC §5.7) and `ptr` (RFC §5.5) mechanisms remain deferred — `exists` needs macro-string expansion (RFC §7) to be usable in fielded policies, `ptr` is "strongly discouraged" by the RFC and rarely seen — and each now permerrors with an explanatory message naming the RFC section and a practical operator-side mitigation. `b.mail.crypto.smime` `@card` and the v1-only-emits-metadata comment in `lib/mail-crypto-smime.js` are corrected to reflect that sign + verify shipped in v0.10.16 on the `b.cms` substrate (EFAIL-class encrypt/decrypt remains the only deferred slice). `b.acme.create.revokeCert({ useCertKey: true })` and the `BAD UID <subverb>` IMAP listener response now carry explicit re-open conditions + named operator escape hatches alongside the deferral. **New codebase-patterns detector `slice1-optional-parseint-silent-default`** flags the class — any `.slice(1)` followed by an `if (X.length > 0)` guard around `parseInt(X, 10)` MUST sit in a file that also carries an explicit empty-segment refusal phrasing, so future cidr-length / prefix-length / port-range parsers inherit the discipline automatically. **References:** [RFC 7208 §5.3 a mechanism](https://www.rfc-editor.org/rfc/rfc7208#section-5.3) · [RFC 7208 §5.4 mx mechanism](https://www.rfc-editor.org/rfc/rfc7208#section-5.4) · [RFC 7208 §4.6.4 DNS-lookup limits](https://www.rfc-editor.org/rfc/rfc7208#section-4.6.4) · [RFC 8551 S/MIME 4.0](https://www.rfc-editor.org/rfc/rfc8551.html) · [RFC 9051 IMAP4rev2 §6.4.9 UID](https://www.rfc-editor.org/rfc/rfc9051#section-6.4.9).
12
+
13
+ - v0.11.2 (2026-05-19) — **Node 26 floor-bump preparation.** Today's `engines.node` floor is `>=24.14.1` and the framework runs cleanly on Node 26 (which satisfies the floor). This release ships the **prep** scaffolding so the future floor-bump slice (when Node 26 promotes to Active LTS and `>=26.x` becomes the floor) is mechanical. **`b.backup.diskStorage(opts)`** is the new canonical name for the local-filesystem backup storage backend; `b.backup.localStorage(opts)` continues to work and emits a one-time deprecation warning via `b.deprecate.alias`, with removal scheduled for the next major. The rename avoids the Node 26 platform-level `localStorage` global naming collision; the deprecation path follows the framework's stable upgrade policy (one minor with deprecation warnings before removal). **New codebase-patterns detector `map-get-or-insert-pre-node-26`** flags the `if (!m.has(k)) m.set(k, factory()); m.get(k)` shape that Node 26's `Map.prototype.getOrInsertComputed(key, factory)` replaces in a single call. The detector lands as an allowlist marker — every existing call site in `lib/` is allowlisted with the spec file as the migration target; new code post-this-patch trips the gate. When the floor bumps the allowlist is walked + the detector flips to enforce. **`test/integration/pqc-pkcs8-forward-compat.test.js`** captures the ML-KEM-1024 / ML-DSA-65 / ML-DSA-87 / SLH-DSA-SHAKE-256f / Ed25519 PKCS8 export-byte shape on the current Node, asserts the sign+verify / encap+decap roundtrip via a re-imported KeyObject, and embeds a Node-26-shape fixture that re-imports every run — so the forward-compat contract is testable today and the reverse-direction (Node-26-exported → Node-24-imported) test follows the floor-bump. **SECURITY.md gains a "Node 26 compatibility" section** documenting the `localStorage` global naming collision (bare references in operator handler code now resolve to a Node global rather than throwing `ReferenceError`) and the ML-KEM / ML-DSA seed-only PKCS8 export shape (Node-24-sealed material re-imports cleanly on Node 26; new material from Node 26 is seed-only — parallel Node 24 readers of the same sealed disk need a one-time migration when the writer moves). README "Requirements" line gains the matching Node 26 note. **References:** [Node.js v26 release notes](https://nodejs.org/en/blog/release/v26.0.0) · [TC39 Map.getOrInsertComputed](https://github.com/tc39/proposal-upsert) · [RFC 8032 §5.1 Ed25519 context parameter](https://www.rfc-editor.org/rfc/rfc8032.html#section-5.1).
14
+
11
15
  - v0.11.1 (2026-05-19) — **Integration suite hardening + live coverage for the v0.11.0 surface.** **`b.httpClient.request`** now skips the local SSRF DNS lookup when a proxy is configured AND the operator passes `allowInternal: true`. The proxy resolves the destination hostname in its own network context, so requiring local resolution refused legitimate intranet / docker-service-name targets routed through the proxy. The SSRF gate still runs when `allowInternal` is false / array-form (the proxy's freedom to reach internal IPs is not a blanket license; the explicit opt-in is still required). **`b.mtlsCa`** integration tests now compose with the `caKeySealedMode: "disabled"` opt for fixture purposes; production deployments continue to wire `opts.vault` for sealed-at-rest CA-key storage. **`b.mail.crypto.smime.verify`** return shape gains a `chainVerified: boolean` field reflecting whether `opts.trustAnchorCertsPem` was supplied and the leaf-to-root chain walk completed. **Integration coverage added for v0.11.0 primitives:** new `test/integration/mail-crypto-smime.test.js` round-trips S/MIME sign + verify with a real X.509 chain issued by `b.mtlsCa` (CA → leaf cert → ML-DSA-65 signer), exercises tamper / wrong-key / untrusted-anchor refusal paths, and validates the `chainVerified` return field. `test/integration/federation-auth.test.js` extends to cover SAML SLO (`buildLogoutRequest` against Keycloak's `/protocol/saml` SLO endpoint with the wire-format-parse assertion) and RFC 7592 Dynamic Client Registration Management (`registerClient` / `readClient` / `updateClient` / `deleteClient` against Keycloak's DCR endpoint).
12
16
 
13
17
  - v0.11.0 (2026-05-19) — **Mail-crypto sign/verify + encrypt/decrypt, SAML Single Logout (Redirect / POST / SOAP) + EncryptedAssertion + Holder-of-Key, browser identity (FedCM / DBSC / VAPID), CSP3 builder, hypermedia + observability formats, OAuth DCR management + OIDC Native SSO, sectoral compliance posture growth.** **`b.cms.parseSignedData(buf)`** walks an inbound RFC 5652 SignedData ContentInfo and returns `{ digestAlgs, encapContent, certificates, signerInfos }` so consumers verify signatures without re-implementing the SignedData walker. **`b.mail.crypto.smime.sign(opts)` + `.verify(opts)`** are now live on the `b.cms` substrate: `sign` emits an RFC 8551 `multipart/signed; protocol="application/pkcs7-signature"; micalg=sha3-{256,512}` envelope; `verify` recomputes the message digest, compares it against the signed-attrs `messageDigest` attribute, refuses tamper with `mail-crypto/smime/message-digest-mismatch`, and PQC-verifies the signature against the operator-supplied signer public key. Supports ML-DSA-65 / ML-DSA-87 / SLH-DSA-SHAKE-256f signers; SHA-2 family refused at `cms/bad-digest`. **`b.mail.crypto.pgp.experimental.encrypt(opts)` + `.decrypt(opts)` + `.wkd.computeUrl(email)`** ship under the `experimental` namespace because the relevant IANA codepoint registrations are still in draft: ML-KEM-1024 KEM + ChaCha20-Poly1305 AEAD multi-recipient envelope; WKD URL computer per [draft-koch-openpgp-webkey-service](https://datatracker.ietf.org/doc/draft-koch-openpgp-webkey-service/) (SHAKE256-hash localpart + zbase32 encoding; operators supply their own HTTPS fetcher). **`b.auth.saml.sp.buildLogoutRequest({...})` + `parseLogoutRequest(b64, opts)` + `buildLogoutResponse({...})`** implement SAML 2.0 Single Logout on the HTTP-Redirect binding per SAML Bindings §3.4.4.1 with PQC-signed canonical query (ML-DSA-65 / ML-DSA-87 / Ed25519); SP metadata now emits `<md:SingleLogoutService>` bindings when `singleLogoutServiceUrl` is set; tamper / wrong-key / missing-signature each refuse as typed errors. **`b.webPush.generateVapidKeypair()` + `.buildVapidAuthHeader(opts)`** sign the RFC 8292 VAPID JWT inline (ECDSA-P256 per the spec; the framework's PQC-default JWT signer refuses ES256 by design, so `b.webPush` owns the signing rather than relaxing the broader policy). **`b.fedcm`** ships the W3C FedCM 2024 IdP-side response builders: `wellKnown({ provider_urls })` / `config({ accounts_endpoint, ... })` / `accountsResponse({ accounts })` / `idAssertionResponse({ token })`. **`b.dbsc`** implements IETF Device-Bound Session Credentials: `challenge({ secretKey })` mints an HMAC-SHA3-512-signed token requiring no server-side storage; `verifyBindingAssertion(jwt, opts)` refuses HS256 / none as algorithm-confusion, validates ES256/RS256 against the embedded JWK, and returns the RFC 7638 JWK thumbprint so operators pin the binding key to a session. **`b.importmapIntegrity.build({ modules })`** emits a WICG Import Maps + SRI integrity map (SHA-384 default) so browsers refuse module bytes that don't match. **OpenMetrics 1.0 exposition** — `b.metrics` registry `exposition({ format: "openmetrics" })` emits the counter `_total` suffix, `# UNIT` lines, exemplar trace IDs on histograms, and `# EOF` terminator per the openmetrics.io 1.0 wire format. **`b.standardWebhooks.sign + verify`** implement the standardwebhooks.com consortium spec (Stripe / Svix / Okta wire format): HMAC-SHA256, multi-version signature header, 5-minute default skew tolerance. **`b.lro`** implements AIP-151 Long-Running Operations (`create({ store }) → { submit, status, list, cancel }`) with operator-supplied storage and AbortSignal-aware cancellation. **`b.jsonApi.dataResponse(data, opts) + .errorResponse(errors)`** wraps domain payloads in the JSON:API v1.1 top-level shape; refuses missing Resource Object `type`. **`b.hal.resource(payload, { links, embedded, templates })`** builds HAL responses (draft-kelly-json-hal) with an RFC 8288 link-object normaliser. **Lib-side codebase-patterns detector `number-coerce-or-zero-on-json-source`** refuses `Number(<var>['<kebab-cased-key>']) || 0` shapes — that coercion silently accepts `Infinity`, NaN, negative values, and arbitrary strings on operator-untrusted JSON-source input. **(a) `b.cms.parseSignedData(buf)`** — RFC 5652 §5.1 SignedData walker that surfaces `digestAlgs` / `encapContent` / `certificates` / `signerInfos` as structured arrays so downstream verifiers can check signatures without re-implementing the walker. **(b) `b.mail.crypto.smime.sign(opts)` + `b.mail.crypto.smime.verify(opts)` — LIVE on the b.cms substrate.** `sign` composes `b.cms.encodeSignedData` and wraps in an RFC 8551 `multipart/signed; protocol="application/pkcs7-signature"; micalg=sha3-{256,512}` envelope. `verify` parses the CMS SignedData payload, walks signed-attributes to extract the `messageDigest` attribute, recomputes the message digest, refuses tamper with `mail-crypto/smime/message-digest-mismatch`, and PQC-verifies the signature against the operator-supplied signer public key. Supports ML-DSA-65 / ML-DSA-87 / SLH-DSA-SHAKE-256f signers; SHA-2 family refused at `cms/bad-digest`. **(c) `b.mail.crypto.pgp.experimental.encrypt(opts)` + `decrypt(opts)` + `wkd.computeUrl(email)`** — PQC PGP encrypt/decrypt under `experimental` namespace (RFC 9580bis PKESK ML-KEM codepoints haven't IANA-registered yet; framework-private envelope similar to v0.10.10 `b.jose.jwe.experimental`). ML-KEM-1024 KEM + ChaCha20-Poly1305 AEAD + per-recipient KEK derived via SHAKE256 bound to the literal label `pgp/experimental/chacha20-poly1305`. Multi-recipient envelopes; tamper / wrong-key refusal as typed errors. WKD URL computer per [draft-koch-openpgp-webkey-service](https://datatracker.ietf.org/doc/draft-koch-openpgp-webkey-service/) — SHAKE256-hash localpart + zbase32 encoding; returns `{ direct, advanced }` URLs (operators supply their own HTTPS fetcher). **(d) `b.auth.saml.sp.buildLogoutRequest({...})` + `parseLogoutRequest(b64, opts)` + `buildLogoutResponse({...})`** — SAML 2.0 Single Logout on the HTTP-Redirect binding per SAML Bindings §3.4.4.1 with PQC-signed canonical query string (ML-DSA-65 / ML-DSA-87 / Ed25519). `parseLogoutRequest` inflates the operator-supplied SAMLRequest, optionally verifies the redirect-binding signature against an IdP public key, and surfaces NameID / SessionIndex / Issuer. Tamper / wrong-key / missing-signature each refuse as typed errors. Closes the largest item from the v0.10.15 SAML SLO + identity-residual queued plan. **(e) Codex P2 detector `number-coerce-or-zero-on-json-source`** (lib-side) — flags `Number(<var>['<kebab-cased-key>']) || 0` shapes that silently accept Infinity / NaN / negative on operator-untrusted JSON-source numeric input. The v0.10.15 TLS-RPT fix was a one-off; this detector forces the discipline. **Additional residual closure:** `b.auth.saml.sp.buildLogoutRequestPost` / `parseLogoutRequestPost` / `buildLogoutRequestSoap` / `parseLogoutResponseSoap` add SAML SLO HTTP-POST + SOAP synchronous back-channel bindings with embedded XMLDSig-Enveloped signatures. **SignatureMethod surface** spans the W3C XMLDSig Core 1.1 + RFC 9231 vocabulary so the framework interops with deployed IdPs out of the box: `rsa-sha256` / `rsa-sha384` / `rsa-sha512` (W3C XMLDSig Core 1.1), `ecdsa-sha256` / `ecdsa-sha384` / `ecdsa-sha512` (W3C XMLDSig Core 1.1), `ed25519` (RFC 9231). Classical keys are PEM strings or `node:crypto` KeyObject instances; PQC keys remain `Uint8Array` from `b.pqcSoftware.ml_dsa_*.keygen()`. Post-quantum `ml-dsa-65` / `ml-dsa-87` are also accepted on the same surface under clearly framework-private URIs (`urn:blamejs:experimental:saml-sig-alg:ml-dsa-65` / `:ml-dsa-87`) — no IETF/W3C XMLDSig registration exists for ML-DSA yet (LAMPS WG drafts in flight). Verification refuses `'unsupported-c14n'` (only exclusive c14n; inclusive-c14n IdPs must upgrade), alg-confusion (SignatureMethod URI must match the operator-declared `idpVerifyAlg`), signature-wrapping (Reference URI must match root ID via timing-safe digest compare), and SHA-1 digest methods (CVE-2017-7525-class). **EncryptedAssertion (SAML 2.0 §2.5)** decrypts AES-128-GCM / AES-256-GCM (W3C XMLEnc 1.1 §5.2.4) content + RSA-OAEP-MGF1P / xmlenc11 RSA-OAEP key transport (SHA-256/384/512 only — SHA-1 OAEP refused as CVE-2023-49141-class). AES-CBC content encryption is **refused** under both `xmlenc#aes128-cbc` and `xmlenc#aes256-cbc` — CVE-2011-1473 padding-oracle research makes CBC mode under XMLEnc unsuitable without a per-content MAC; operators integrating with IdPs that default to CBC (older ADFS / Azure AD / Okta / Keycloak / OneLogin) switch the IdP's content-encryption setting to AES-128-GCM or AES-256-GCM. Framework-experimental URIs `urn:blamejs:experimental:xmlenc:ml-kem-1024` (key transport) and `urn:blamejs:experimental:xmlenc:xchacha20-poly1305` (content) are accepted alongside the W3C URIs. `b.mail.crypto.smime.verify({ trustAnchorCertsPem })` now walks the SignerInfo cert chain leaf-to-root via `node:crypto.X509Certificate` with notBefore/notAfter checks and refuses `mail-crypto/smime/untrusted-chain` / `cert-expired` / `cert-not-yet-valid`; revocation is operator-wired via `b.network.tls.ocsp` when freshness is required. `b.auth.saml.sp.verifyResponse({ holderOfKey: { presentedCertPem } })` honors `urn:oasis:names:tc:SAML:2.0:cm:holder-of-key` SubjectConfirmation: SHA3-512 fingerprint of the embedded KeyInfo/X509Data certificate is compared against the operator-supplied presented mTLS / possession-proof cert via timingSafeEqual; HoK and Bearer confirmations coexist. `b.auth.oauth.readClient(uri, token)` / `updateClient(uri, token, metadata)` / `deleteClient(uri, token)` implement RFC 7592 Dynamic Client Registration Management Protocol (GET/PUT/DELETE bound to the registration_access_token returned by `registerClient`); updateClient enforces the same redirect_uris-array refusal as registerClient. `b.auth.oauth.nativeSsoExchange({ deviceSecret, idToken, audience })` convenience-wraps `exchangeToken` for OpenID Connect Native SSO 1.0 §6 with `urn:openid:params:token-type:device-secret` added to the RFC 8693 §3 token-type allowlist. **`b.csp.build(directives, opts?)` + `b.csp.nonce(byteLen?)` + `b.csp.hash(scriptBody, alg?)`** ship the CSP Level 3 builder surface: refuses `'unsafe-*'` / catch-all `*` / `https:` / `data:` in non-image directives without an explicit acknowledgement opt; auto-appends `require-trusted-types-for 'script'` plus the operator-supplied `trusted-types` policy list when any script-* directive is set; emits ≥128-bit nonces by default; computes sha256/sha384/sha512 hash sources for inline scripts. `b.middleware.securityHeaders` `coep` opt now documents the W3C CR 2024-12 `credentialless` value alongside `require-corp`. **`b.compliance` posture catalog gains 17 sectoral / cybersecurity / AI-governance regimes:** `42-cfr-part-2`, `hti-1`, `uscdi-v4`, `irs-1075`, `nist-800-172-r3`, `tlp-2.0`, `soci-au`, `nis2`, `cra`, `ffiec-cat-2`, `cri-profile-v2.0`, `m-22-09`, `m-22-18`, `nist-800-53-r5-privacy`, `nist-ai-600-1-genai`, `nist-csf-2.0`, `sb-53`, `nyc-ll144-2024`. Each posture cascade pins the regime's normative floor (backupEncryptionRequired / auditChainSignedRequired / tlsMinVersion / requireVacuumAfterErase). **References:** [RFC 5652 CMS](https://www.rfc-editor.org/rfc/rfc5652.html) · [RFC 8551 S/MIME 4.0](https://www.rfc-editor.org/rfc/rfc8551.html) · [RFC 9580 OpenPGP](https://www.rfc-editor.org/rfc/rfc9580.html) · [draft-koch-openpgp-webkey-service](https://datatracker.ietf.org/doc/draft-koch-openpgp-webkey-service/) · [SAML Bindings §3.4 HTTP-Redirect + §3.5 HTTP-POST + §3.2 SOAP](https://docs.oasis-open.org/security/saml/v2.0/saml-bindings-2.0-os.pdf) · [SAML 2.0 §2.5 EncryptedAssertion](https://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf) · [W3C XML-Encryption 1.1](https://www.w3.org/TR/xmlenc-core1/) · [RFC 7592 Dynamic Client Management](https://www.rfc-editor.org/rfc/rfc7592.html) · [OpenID Connect Native SSO 1.0](https://openid.net/specs/openid-connect-native-sso-1_0.html) · [W3C CSP Level 3](https://www.w3.org/TR/CSP3/) · [W3C Trusted Types](https://www.w3.org/TR/trusted-types/).
package/MIGRATING.md CHANGED
@@ -4,9 +4,16 @@ Operator-facing migration recipes per breaking change. The bulk of this file is
4
4
 
5
5
  **Out-of-band breaking changes** (schema breaks, config-shape changes, on-disk format breaks) cannot be expressed as `deprecate()` calls because there's no in-process runtime to warn from. They're hardcoded in the OUT_OF_BAND_BREAKS table inside `scripts/gen-migrating.js` so the operator sees the full upgrade path here without needing to grep CHANGELOG.
6
6
 
7
- ## No active deprecations
7
+ ## Removed in v0.x
8
8
 
9
- The framework has no `deprecate()`-marked surface awaiting removal.
9
+ ### `localStorage`
10
+
11
+ - **Since:** 0.11.2
12
+ - **Removed in:** 0.12.0
13
+ - **Defined at:** [`lib/backup/index.js`](lib/backup/index.js)
14
+ - **Renamed to:** `diskStorage`
15
+
16
+ b.backup.localStorage was renamed to b.backup.diskStorage — the Node 26 `localStorage` global doesn't clash today, but the rename keeps the operator-facing surface unambiguous. Update the call site; removal lands in the next major.
10
17
 
11
18
  ---
12
19
 
package/README.md CHANGED
@@ -52,7 +52,7 @@ var b = require("@blamejs/core");
52
52
  })();
53
53
  ```
54
54
 
55
- **Requirements:** Node.js 24.14+ (current active LTS, fixes CVE-2026-21713 non-constant-time HMAC compare).
55
+ **Requirements:** Node.js 24.14+ (current active LTS, fixes CVE-2026-21713 non-constant-time HMAC compare). Node 26 satisfies the floor and the framework test suite runs cleanly on it today; the floor itself will bump to `>=26.x` when Node 26 promotes to Active LTS. Two Node 26 platform changes operators integrating with blamejs should know about: the new `localStorage` global (the framework's storage backend was renamed from `b.backup.localStorage` to `b.backup.diskStorage` in v0.11.2 to avoid the ambiguity; the legacy name still works with a deprecation warning), and the seed-only ML-KEM / ML-DSA PKCS8 export shape (sealed material from Node 24 re-imports cleanly on Node 26; new material from Node 26 in the seed-only shape). See [SECURITY.md](SECURITY.md#node-26-compatibility) for the details.
56
56
 
57
57
  ## What ships in the box
58
58
 
package/lib/acme.js CHANGED
@@ -822,13 +822,28 @@ function create(opts) {
822
822
  if (typeof ropts.reason === "number") payload.reason = ropts.reason;
823
823
  var signedOpts = { useJwk: false }; // account-key signed by default
824
824
  if (ropts.useCertKey === true) {
825
- // RFC 8555 §7.6 alternate: certificate's own key as signer. Operator
826
- // supplies the cert's private key via ropts.certPrivateKey; we
827
- // build a one-off signed-post bypassing _signedPost's state.accountUrl
828
- // assumption. For minimal v1 we support account-key signing only and
829
- // document the cert-key path as not-yet-implemented.
825
+ // RFC 8555 §7.6 alternate signer: the certificate's own private
826
+ // key signs the revocation JWS (bypassing the account-key
827
+ // requirement). Useful when the account that originally issued
828
+ // the cert is lost / compromised but the cert's private key is
829
+ // still under operator control.
830
+ //
831
+ // Re-open condition: operator surfaces a CA whose only accepted
832
+ // revocation path is cert-key signing (rare — Let's Encrypt /
833
+ // ZeroSSL / Google CA all accept account-key signing as the
834
+ // primary path), OR the operator's account-recovery posture
835
+ // demands cert-key as a break-glass route. Track via a new opt
836
+ // shape: `ropts.certPrivateKey: <PEM string>` would route
837
+ // through a dedicated signed-post that detaches from
838
+ // state.accountUrl.
839
+ //
840
+ // Operator escape hatch today: account-key signing covers every
841
+ // mainstream public CA. Operators in the rare cert-key-only
842
+ // scenario reach the CA directly via the CA's own revocation
843
+ // portal (web UI / out-of-band API) until this lights up.
830
844
  throw _err("acme/revoke-cert-key-not-implemented",
831
- "revokeCert: cert-key signing path not yet implemented; use account-key signing", true);
845
+ "revokeCert: cert-key signing path (RFC 8555 §7.6 alternate) is deferred; " +
846
+ "use account-key signing (the default, omit useCertKey)", true);
832
847
  }
833
848
  var rsp = await _signedPost(state.directory.revokeCert, payload, signedOpts);
834
849
  if (rsp.statusCode !== 200) {
@@ -13,8 +13,11 @@
13
13
  * The namespace wires `b.backupBundle.create` (encrypt + emit a bundle
14
14
  * directory) to a pluggable storage backend, plus retention policy +
15
15
  * audit emission. Ships with a local-filesystem backend
16
- * (`b.backup.localStorage`); S3 or any custom backend drops in through
17
- * the same interface.
16
+ * (`b.backup.diskStorage`); S3 or any custom backend drops in through
17
+ * the same interface. The legacy alias `b.backup.localStorage` still
18
+ * works and routes through `b.deprecate.alias` (warns once per
19
+ * process; removal scheduled for the next major). The rename avoids
20
+ * the Node 26 `localStorage` global naming collision.
18
21
  *
19
22
  * Storage backend contract:
20
23
  *
@@ -58,6 +61,7 @@ var backupManifest = require("./manifest");
58
61
  var lazyRequire = require("../lazy-require");
59
62
  var validateOpts = require("../validate-opts");
60
63
  var numericBounds = require("../numeric-bounds");
64
+ var deprecate = require("../deprecate");
61
65
  var audit = lazyRequire(function () { return require("../audit"); });
62
66
  var compliance = lazyRequire(function () { return require("../compliance"); });
63
67
  // lazyRequire ../db so backup stays a leaf module operators can use
@@ -110,9 +114,9 @@ function _dirSize(p) {
110
114
  // ---- Local filesystem storage backend (the default) ----
111
115
 
112
116
  /**
113
- * @primitive b.backup.localStorage
114
- * @signature b.backup.localStorage(opts)
115
- * @since 0.4.0
117
+ * @primitive b.backup.diskStorage
118
+ * @signature b.backup.diskStorage(opts)
119
+ * @since 0.11.2
116
120
  * @status stable
117
121
  * @related b.backup.create
118
122
  *
@@ -126,6 +130,12 @@ function _dirSize(p) {
126
130
  * custom backend matching the same shape; the engine never touches the
127
131
  * filesystem directly.
128
132
  *
133
+ * Renamed from `b.backup.localStorage` to avoid the Node 26 global
134
+ * `localStorage` naming collision (Node 26 adds `localStorage` as a
135
+ * platform-wide global). The legacy `b.backup.localStorage` alias
136
+ * continues to work and emits a one-time deprecation warning per
137
+ * `b.deprecate.alias`; removal is scheduled for the next major.
138
+ *
129
139
  * @opts
130
140
  * root: string, // required; directory under which bundle dirs land
131
141
  *
@@ -135,14 +145,14 @@ function _dirSize(p) {
135
145
  * var os = require("node:os");
136
146
  * var root = fs.mkdtempSync(path.join(os.tmpdir(), "backup-root-"));
137
147
  *
138
- * var storage = b.backup.localStorage({ root: root });
148
+ * var storage = b.backup.diskStorage({ root: root });
139
149
  * storage.name; // → "local"
140
150
  * typeof storage.writeBundle; // → "function"
141
151
  * typeof storage.listBundles; // → "function"
142
152
  */
143
- function localStorage(opts) {
153
+ function diskStorage(opts) {
144
154
  opts = opts || {};
145
- validateOpts.requireNonEmptyString(opts.root, "localStorage: opts.root", BackupError, "backup/no-storage-root");
155
+ validateOpts.requireNonEmptyString(opts.root, "diskStorage: opts.root", BackupError, "backup/no-storage-root");
146
156
  var root = opts.root;
147
157
 
148
158
  function _bundlePath(bundleId) {
@@ -215,7 +225,7 @@ function localStorage(opts) {
215
225
  function _validateStorage(storage) {
216
226
  if (!storage || typeof storage !== "object") {
217
227
  throw new BackupError("backup/bad-storage",
218
- "storage backend is required (use b.backup.localStorage or pass a custom one)");
228
+ "storage backend is required (use b.backup.diskStorage or pass a custom one)");
219
229
  }
220
230
  var required = ["writeBundle", "readBundle", "listBundles", "deleteBundle", "hasBundle"];
221
231
  for (var i = 0; i < required.length; i++) {
@@ -247,7 +257,7 @@ async function _resolveVaultKeyJson(vaultKeyJsonOpt) {
247
257
  * @since 0.4.0
248
258
  * @status stable
249
259
  * @compliance hipaa, pci-dss, gdpr, soc2, dora
250
- * @related b.backup.localStorage, b.backup.recommendedFiles, b.backup.verifyManifestSignature, b.backupBundle.create
260
+ * @related b.backup.diskStorage, b.backup.recommendedFiles, b.backup.verifyManifestSignature, b.backupBundle.create
251
261
  *
252
262
  * Build a backup engine bound to a data directory, a storage backend,
253
263
  * the operator's passphrase, and an include list. Returns an object
@@ -266,7 +276,7 @@ async function _resolveVaultKeyJson(vaultKeyJsonOpt) {
266
276
  *
267
277
  * @opts
268
278
  * dataDir: string, // required; must exist on disk
269
- * storage: StorageBackend, // required; localStorage() or custom
279
+ * storage: StorageBackend, // required; diskStorage() or custom
270
280
  * passphrase: Buffer | string, // required; KEK for per-file Argon2id wrap
271
281
  * files: Array<{ relativePath, kind, required }>,
272
282
  * vaultKeyJson: string | () => string | Promise<string>,
@@ -292,7 +302,7 @@ async function _resolveVaultKeyJson(vaultKeyJsonOpt) {
292
302
  *
293
303
  * var engine = b.backup.create({
294
304
  * dataDir: dataDir,
295
- * storage: b.backup.localStorage({ root: root }),
305
+ * storage: b.backup.diskStorage({ root: root }),
296
306
  * passphrase: Buffer.from("operator backup passphrase"),
297
307
  * files: [
298
308
  * { relativePath: "db.enc", kind: "raw", required: true },
@@ -991,7 +1001,7 @@ function runInWorker(opts) {
991
1001
 
992
1002
  module.exports = {
993
1003
  create: create,
994
- localStorage: localStorage,
1004
+ diskStorage: diskStorage,
995
1005
  recommendedFiles: recommendedFiles,
996
1006
  runInWorker: runInWorker,
997
1007
  verifyManifestSignature: verifyManifestSignature,
@@ -999,3 +1009,17 @@ module.exports = {
999
1009
  BackupError: BackupError,
1000
1010
  BUNDLE_ID_RE: BUNDLE_ID_RE,
1001
1011
  };
1012
+
1013
+ // Legacy alias — `b.backup.localStorage(...)`. The Node 26 `localStorage`
1014
+ // global doesn't clash with this property-access shape inside the
1015
+ // framework, but the rename keeps operator-facing surface unambiguous
1016
+ // and avoids any future drift on the bare identifier. Removed in the
1017
+ // next major (engines.node bump to >=26 — Node 24 LTS sunset window).
1018
+ deprecate.alias(module.exports, "localStorage", "diskStorage", {
1019
+ since: "0.11.2",
1020
+ removeIn: "0.12.0",
1021
+ message: "b.backup.localStorage was renamed to b.backup.diskStorage — " +
1022
+ "the Node 26 `localStorage` global doesn't clash today, but the " +
1023
+ "rename keeps the operator-facing surface unambiguous. " +
1024
+ "Update the call site; removal lands in the next major.",
1025
+ });
package/lib/cli.js CHANGED
@@ -742,7 +742,7 @@ async function _runAudit(args, ctx) {
742
742
  // inspect a specific one, do a live in-place restore (with rollback
743
743
  // preservation), and roll back to a previous restore point. Wraps the
744
744
  // restore primitive's run / inspect / list / rollback / list-rollbacks
745
- // surface; uses b.backup.localStorage as the storage adapter (the same
745
+ // surface; uses b.backup.diskStorage as the storage adapter (the same
746
746
  // adapter that wrote the bundles).
747
747
  //
748
748
  // Two ways to identify a bundle for inspect / apply:
@@ -846,7 +846,7 @@ async function _runRestore(args, ctx) {
846
846
  var sel = _resolveRestoreBundleSelector(args, ctx, report, false);
847
847
  if (!sel) return 2;
848
848
  try {
849
- var storage = backup.localStorage({ root: sel.storageRoot });
849
+ var storage = backup.diskStorage({ root: sel.storageRoot });
850
850
  var bundles = await storage.listBundles();
851
851
  if (bundles.length === 0) {
852
852
  report.write("no bundles in " + sel.storageRoot);
@@ -869,7 +869,7 @@ async function _runRestore(args, ctx) {
869
869
  var selI = _resolveRestoreBundleSelector(args, ctx, report, true);
870
870
  if (!selI) return 2;
871
871
  try {
872
- var storageI = backup.localStorage({ root: selI.storageRoot });
872
+ var storageI = backup.diskStorage({ root: selI.storageRoot });
873
873
  // restore.create needs a passphrase + dataDir even for inspect because
874
874
  // its closure captures them; pass placeholders since inspect doesn't
875
875
  // touch them.
@@ -918,7 +918,7 @@ async function _runRestore(args, ctx) {
918
918
  return report.error("--max-pulled-files must be a positive number", 2);
919
919
  }
920
920
  try {
921
- var storageA = backup.localStorage({ root: selA.storageRoot });
921
+ var storageA = backup.diskStorage({ root: selA.storageRoot });
922
922
  var rA = restore.create({
923
923
  dataDir: dd,
924
924
  storage: storageA,
package/lib/mail-auth.js CHANGED
@@ -13,11 +13,21 @@
13
13
  * b.mail.dmarc.evaluate({ from, spf, dkim, dnsLookup }) → result
14
14
  * b.mail.arc.verify(rfc822, opts) → chain status
15
15
  *
16
- * SPF (RFC 7208) — IPv4 / IPv6 / a / mx / include / all mechanisms.
17
- * Mechanism limit: 10 DNS lookups per RFC 7208 §4.6.4.
18
- * Macro expansion + redirect + ptr + exists are deferred (rare in
19
- * practice; the framework returns "permerror" / "neutral" for
20
- * policies that require them, so operators see the diagnosis).
16
+ * SPF (RFC 7208) — ip4 / ip6 / a / mx / include / all / redirect=
17
+ * mechanisms.
18
+ * Mechanism limit: 10 DNS lookups per RFC 7208 §4.6.4 (with the
19
+ * void-lookup sub-limit at 2). The `a` and `mx` arms honor RFC
20
+ * §5.3 / §5.4 dual-cidr-length syntax (`a:foo.com/24//64`).
21
+ *
22
+ * Deferred mechanisms (each carries an explicit Re-open condition
23
+ * in the dispatch arm in this file):
24
+ * - exists: requires macro-string expansion (§7) to be useful;
25
+ * re-opens when macros land OR an operator surfaces a
26
+ * real macro-less `exists:` policy.
27
+ * - ptr: "strongly discouraged" by §5.5; re-opens when an
28
+ * operator surfaces a legitimate ptr-only sender.
29
+ * - macro-string expansion (§7) itself — separate slice tracked
30
+ * under blamejs-roadmap.md.
21
31
  *
22
32
  * DMARC (RFC 7489) — TXT record at _dmarc.<domain>; alignment check
23
33
  * between From-header domain and DKIM-d / SPF-from-domain;
@@ -27,9 +37,9 @@
27
37
  * List).
28
38
  *
29
39
  * ARC (RFC 8617) — chain-of-custody verification. The framework parses
30
- * the existing chain headers + reports validity; full per-hop
31
- * signature verification is deferred (composes the same DKIM
32
- * verifier that's deferred from this patch).
40
+ * the existing chain headers, recomputes the per-hop signatures, and
41
+ * reports validity by composing `lib/mail-dkim.js` (which carries
42
+ * the actual signature-verification surface).
33
43
  */
34
44
 
35
45
  var zlib = require("node:zlib");
@@ -76,16 +86,21 @@ var SPF_RECORD_MAX_BYTES = 450;
76
86
  // alone tripped.
77
87
  var SPF_REDIRECT_DEPTH_LIMIT = 10; // allow:raw-byte-literal — same shape as RFC 7208 §4.6.4 lookup ceiling
78
88
 
79
- // Shared safe-DNS TXT/A/AAAA/PTR lookup. Operator-supplied
80
- // `dnsLookup` (legacy `[[strings]]` shape for TXT; flat `[addr, ...]`
81
- // for A/AAAA; flat `[name]` for PTR) takes precedence; otherwise
82
- // routes through `b.network.dns.resolver` (DoH by default per
83
- // v0.7.23). CVE-2008-1447 (Kaminsky) + CVE-2022-3204
84
- // (NRDelegationAttack) class the encrypted DoH transport plus
85
- // b.safeDns parse caps defend transport and parse-side. Earlier
86
- // shape fell back to `node:dns.promises.resolveTxt` directly, which
87
- // sent plaintext UDP/53 to whatever the system resolver was — every
88
- // downstream finding inherited that exposure.
89
+ // Shared safe-DNS TXT/A/AAAA/MX/PTR lookup. Operator-supplied
90
+ // `dnsLookup(qname, type)` is honored for every type when present:
91
+ // TXT → [[ "v=spf1 ...", ... ], ...] (array of TXT-string-arrays)
92
+ // A → [ "192.0.2.1", ... ] (flat IPv4 string array)
93
+ // AAAA [ "2001:db8::1", ... ] (flat IPv6 string array)
94
+ // MX → [ { exchange, preference }, ...] (or [ "mx1.example.", ... ]
95
+ // when operator omits preference)
96
+ // PTR → [ "host.example.", ... ] (flat PTR-name array)
97
+ // When no operator callback is supplied, requests route through
98
+ // `b.network.dns.resolver` (DoH by default per v0.7.23). CVE-2008-1447
99
+ // (Kaminsky) + CVE-2022-3204 (NRDelegationAttack) class — the encrypted
100
+ // DoH transport plus b.safeDns parse caps defend transport and parse-
101
+ // side. Earlier shape fell back to `node:dns.promises.resolveTxt`
102
+ // directly, which sent plaintext UDP/53 to whatever the system
103
+ // resolver was — every downstream finding inherited that exposure.
89
104
  var _defaultResolver = null;
90
105
  function _getDefaultResolver() {
91
106
  if (_defaultResolver) return _defaultResolver;
@@ -111,7 +126,21 @@ async function _safeResolveTxt(qname, operatorLookup) {
111
126
  return out;
112
127
  }
113
128
 
114
- async function _safeResolveA(qname, family /* 4|6 */) {
129
+ async function _safeResolveA(qname, family /* 4|6 */, operatorLookup) {
130
+ // Pre-v0.11.3 the operatorLookup parameter wasn't threaded here, so
131
+ // the documented `dnsLookup` shape for A/AAAA was unhonored — SPF a/
132
+ // mx mechanism tests had no operator-mockable path. The function
133
+ // signature now matches the docstring contract above. Operator
134
+ // returns a flat string array of IP literals.
135
+ if (operatorLookup) {
136
+ var resp = await operatorLookup(qname, family === 6 ? "AAAA" : "A");
137
+ if (!Array.isArray(resp) || resp.length === 0) {
138
+ var aerr = new Error("no " + (family === 6 ? "AAAA" : "A") + " records for " + qname);
139
+ aerr.code = "ENODATA";
140
+ throw aerr;
141
+ }
142
+ return resp.map(function (x) { return String(x); });
143
+ }
115
144
  var r = await _getDefaultResolver().query(qname, family === 6 ? "AAAA" : "A");
116
145
  var out = [];
117
146
  for (var i = 0; i < r.rrs.length; i += 1) {
@@ -127,6 +156,50 @@ async function _safeResolveA(qname, family /* 4|6 */) {
127
156
  return out;
128
157
  }
129
158
 
159
+ // RFC 1035 §3.3.9 MX record: { preference, exchange }. Returns array of
160
+ // exchange hostnames sorted by preference (lowest first). Operator-
161
+ // supplied dnsLookup callback may return either:
162
+ // - [ { exchange, preference }, ... ] — full shape (preferred)
163
+ // - [ "mx1.example.", ... ] — exchanges only (preference
164
+ // treated as 0 → first-served)
165
+ async function _safeResolveMx(qname, operatorLookup) {
166
+ if (operatorLookup) {
167
+ var resp = await operatorLookup(qname, "MX");
168
+ if (!Array.isArray(resp) || resp.length === 0) {
169
+ var merr = new Error("no MX records for " + qname);
170
+ merr.code = "ENODATA";
171
+ throw merr;
172
+ }
173
+ var normalized = resp.map(function (entry) {
174
+ if (typeof entry === "string") return { exchange: entry.replace(/\.$/, ""), preference: 0 };
175
+ var ex = entry && entry.exchange;
176
+ var pref = (entry && typeof entry.preference === "number") ? entry.preference : 0;
177
+ return { exchange: String(ex || "").replace(/\.$/, ""), preference: pref };
178
+ }).filter(function (e) { return e.exchange.length > 0; });
179
+ normalized.sort(function (a, b) { return a.preference - b.preference; });
180
+ return normalized.map(function (e) { return e.exchange; });
181
+ }
182
+ var r = await _getDefaultResolver().query(qname, "MX");
183
+ var entries = [];
184
+ for (var i = 0; i < r.rrs.length; i += 1) {
185
+ var rr = r.rrs[i];
186
+ if (rr && rr.type === 15) { // allow:raw-byte-literal — IANA DNS qtype MX
187
+ var d = rr.decoded || {};
188
+ if (d.exchange) {
189
+ entries.push({ exchange: String(d.exchange).replace(/\.$/, ""),
190
+ preference: typeof d.preference === "number" ? d.preference : 0 });
191
+ }
192
+ }
193
+ }
194
+ if (entries.length === 0) {
195
+ var err = new Error("no MX records for " + qname);
196
+ err.code = "ENODATA";
197
+ throw err;
198
+ }
199
+ entries.sort(function (a, b) { return a.preference - b.preference; });
200
+ return entries.map(function (e) { return e.exchange; });
201
+ }
202
+
130
203
  async function _safeReverse(ip) {
131
204
  // PTR query against the reverse-arpa name. IPv4: a.b.c.d.in-addr.arpa
132
205
  // (reversed octets); IPv6: nibble-reversed under ip6.arpa.
@@ -273,7 +346,13 @@ function _parseSpfRecord(text) {
273
346
  ? colonAt : slashAt;
274
347
  var mech = sep === -1 ? p : p.slice(0, sep);
275
348
  var arg = sep === -1 ? null : p.slice(sep + 1);
276
- mechanisms.push({ qualifier: qualifier, mechanism: mech.toLowerCase(), arg: arg });
349
+ // `raw` preserves the full mechanism+arg token after qualifier-
350
+ // strip. The a/mx dispatch arm reparses this directly because
351
+ // RFC 7208 §5.3/§5.4 allow `dual-cidr-length` after the optional
352
+ // domain-spec (e.g. `a:example.com/24//64`); the simple `arg`
353
+ // field above splits on the first separator and loses the
354
+ // information about whether that separator was `:` or `/`.
355
+ mechanisms.push({ qualifier: qualifier, mechanism: mech.toLowerCase(), arg: arg, raw: p });
277
356
  }
278
357
  // Surface modifiers via a non-enumerable property so callers that
279
358
  // don't expect them don't see them in JSON-serialized records but
@@ -322,9 +401,188 @@ async function _fetchSpfRecord(domain, dnsLookup) {
322
401
  return { kind: "found", record: matches[0] };
323
402
  }
324
403
 
325
- // SPF verifyrecursive include resolution + ip4/ip6/all/+a/+mx
326
- // (a / mx omit deferred operators rarely depend on them at this
327
- // scope; permerror surfaces the diagnosis).
404
+ // RFC 7208 §5.3 / §5.4 `a [ ":" domain-spec ] [ dual-cidr-length ]`
405
+ // and `mx [ ":" domain-spec ] [ dual-cidr-length ]`. dual-cidr-length
406
+ // is `[ "/" ip4-cidr ] [ "//" ip6-cidr ]`. Returns the parsed target
407
+ // domain plus per-family prefix lengths (32 / 128 when omitted).
408
+ //
409
+ // `raw` is the post-qualifier token (e.g. "a", "a:foo.com", "a/24",
410
+ // "a//64", "a:foo.com/24//64"). Throws MailAuthError on bad cidr.
411
+ function _parseADualCidr(raw, mech, defaultDomain) {
412
+ var rest = raw.slice(mech.length);
413
+ var domain = defaultDomain;
414
+ var v4Mask = 32; // allow:raw-byte-literal — IPv4 max prefix
415
+ var v6Mask = 128; // allow:raw-byte-literal — IPv6 max prefix
416
+
417
+ if (rest.charAt(0) === ":") {
418
+ rest = rest.slice(1);
419
+ var slashAt = rest.indexOf("/");
420
+ if (slashAt === -1) { domain = rest; rest = ""; }
421
+ else { domain = rest.slice(0, slashAt); rest = rest.slice(slashAt); }
422
+ }
423
+
424
+ if (rest.length > 0) {
425
+ // rest is now "" | "/v4" | "//v6" | "/v4//v6".
426
+ var dblSlash = rest.indexOf("//");
427
+ var v4Part = "";
428
+ var v6Part = "";
429
+ if (dblSlash !== -1) {
430
+ v4Part = rest.slice(0, dblSlash); // "" or "/24"
431
+ v6Part = rest.slice(dblSlash + 2); // "64"
432
+ } else {
433
+ v4Part = rest; // "/24"
434
+ }
435
+ if (v4Part.length > 0) {
436
+ if (v4Part.charAt(0) !== "/") {
437
+ throw new MailAuthError("mail-auth/spf-bad-cidr",
438
+ "SPF " + mech + " dual-cidr malformed: " + JSON.stringify(raw));
439
+ }
440
+ var v4Str = v4Part.slice(1);
441
+ // RFC 7208 §5.3 / §5.4 — `ip4-cidr-length = "/" 1*DIGIT`. An
442
+ // empty digit segment (`a/`, `mx/`) is malformed grammar; the
443
+ // receiver MUST permerror. Pre-fix this silently kept the
444
+ // default /32 and would authorize the connecting IP under any
445
+ // A record of the target, which can over-authorize senders
446
+ // publishing `v=spf1 a/ -all` (would match every IP in the
447
+ // /32 of every A record).
448
+ if (v4Str.length === 0) {
449
+ throw new MailAuthError("mail-auth/spf-bad-cidr",
450
+ "SPF " + mech + " v4 cidr-length is empty (RFC 7208 §5.3/§5.4 grammar requires 1*DIGIT): " +
451
+ JSON.stringify(raw));
452
+ }
453
+ var v4n = parseInt(v4Str, 10);
454
+ if (!isFinite(v4n) || v4n < 0 || v4n > 32 || String(v4n) !== v4Str) { // allow:raw-byte-literal — IPv4 max prefix
455
+ throw new MailAuthError("mail-auth/spf-bad-cidr",
456
+ "SPF " + mech + " v4 cidr-length invalid: " + JSON.stringify(raw));
457
+ }
458
+ v4Mask = v4n;
459
+ }
460
+ // RFC 7208 §5.3 / §5.4 — `ip6-cidr-length = "/" 1*DIGIT` (after
461
+ // the "//" separator). When the `//` separator IS present (i.e.
462
+ // the raw token contained `//`) the digit segment MUST be 1*DIGIT.
463
+ // Empty (`a//`, `a/24//`, `mx//`) is malformed grammar; permerror.
464
+ if (dblSlash !== -1) {
465
+ if (v6Part.length === 0) {
466
+ throw new MailAuthError("mail-auth/spf-bad-cidr",
467
+ "SPF " + mech + " v6 cidr-length is empty (RFC 7208 §5.3/§5.4 grammar requires 1*DIGIT): " +
468
+ JSON.stringify(raw));
469
+ }
470
+ var v6n = parseInt(v6Part, 10);
471
+ if (!isFinite(v6n) || v6n < 0 || v6n > 128 || String(v6n) !== v6Part) { // allow:raw-byte-literal — IPv6 max prefix
472
+ throw new MailAuthError("mail-auth/spf-bad-cidr",
473
+ "SPF " + mech + " v6 cidr-length invalid: " + JSON.stringify(raw));
474
+ }
475
+ v6Mask = v6n;
476
+ }
477
+ }
478
+
479
+ if (!domain || domain.length === 0) {
480
+ throw new MailAuthError("mail-auth/spf-bad-cidr",
481
+ "SPF " + mech + " has no target domain (current-domain unavailable)");
482
+ }
483
+ return { domain: domain.toLowerCase(), v4Mask: v4Mask, v6Mask: v6Mask };
484
+ }
485
+
486
+ // RFC 7208 §5.3 / §5.4 — `a` and `mx` mechanism evaluation. Both
487
+ // resolve the target domain (or the current SPF-evaluating domain when
488
+ // arg omitted) to a set of IP addresses; the connecting IP matches if
489
+ // it falls inside any of those addresses under the parsed cidr prefix.
490
+ //
491
+ // Lookup accounting per §4.6.4:
492
+ // - `a`: the outer evaluator has already counted this as one DNS-
493
+ // touching mechanism. The single A/AAAA query is THAT one
494
+ // lookup; no additional increment here.
495
+ // - `mx`: the outer evaluator has counted the MX query itself.
496
+ // EACH MX hostname's A/AAAA expansion adds an additional
497
+ // lookup; total expansion is capped at 10 MX hostnames per
498
+ // §4.6.4 (the explicit "MX limit"). Crossing the global
499
+ // 10-lookup ceiling at any expansion step permerrors.
500
+ //
501
+ // Returns one of:
502
+ // { match: true } — connecting IP matched
503
+ // { match: false } — no IP matched / record absent
504
+ // { error: "temperror", reason: "..." } — transient DNS failure
505
+ // { error: "permerror", reason: "..." } — over-limit / bad CIDR / bad MX count
506
+ async function _spfMatchAMx(mech, raw, ip, isIpv6, defaultDomain, dnsLookup, lookups) {
507
+ var parsed;
508
+ try { parsed = _parseADualCidr(raw, mech, defaultDomain); }
509
+ catch (e) { return { error: "permerror", reason: e.message }; }
510
+
511
+ var mask = isIpv6 ? parsed.v6Mask : parsed.v4Mask;
512
+ var family = isIpv6 ? 6 : 4; // allow:raw-byte-literal — IP family marker
513
+
514
+ var targetIps = [];
515
+ if (mech === "a") {
516
+ try { targetIps = await _safeResolveA(parsed.domain, family, dnsLookup); }
517
+ catch (e) {
518
+ var code = e && e.code;
519
+ if (code === "ENOTFOUND" || code === "ENODATA") return { match: false };
520
+ return { error: "temperror",
521
+ reason: "SPF a:" + parsed.domain + " lookup failed: " +
522
+ ((e && e.message) || String(e)) };
523
+ }
524
+ } else { // mech === "mx"
525
+ var mxHosts;
526
+ try { mxHosts = await _safeResolveMx(parsed.domain, dnsLookup); }
527
+ catch (e) {
528
+ var mcode = e && e.code;
529
+ if (mcode === "ENOTFOUND" || mcode === "ENODATA") return { match: false };
530
+ return { error: "temperror",
531
+ reason: "SPF mx:" + parsed.domain + " MX lookup failed: " +
532
+ ((e && e.message) || String(e)) };
533
+ }
534
+ // RFC 7208 §4.6.4 — the MX expansion is capped at 10 hostnames.
535
+ // Crossing this is a permerror; receivers MUST NOT silently
536
+ // truncate, since a misconfigured sender publishing 20 MX hosts
537
+ // would otherwise have only the first 10 contribute to authz.
538
+ if (mxHosts.length > 10) { // allow:raw-byte-literal — RFC 7208 §4.6.4 MX limit
539
+ return { error: "permerror",
540
+ reason: "SPF mx:" + parsed.domain + " resolved " + mxHosts.length +
541
+ " MX hosts (RFC 7208 §4.6.4 caps at 10)" };
542
+ }
543
+ for (var mi = 0; mi < mxHosts.length; mi += 1) {
544
+ lookups.count += 1;
545
+ if (lookups.count > lookups.limit) {
546
+ return { error: "permerror",
547
+ reason: "DNS lookup limit exceeded (RFC 7208 §4.6.4) during mx:" +
548
+ parsed.domain + " expansion" };
549
+ }
550
+ try {
551
+ var hostIps = await _safeResolveA(mxHosts[mi], family, dnsLookup);
552
+ for (var hi = 0; hi < hostIps.length; hi += 1) targetIps.push(hostIps[hi]);
553
+ } catch (e) {
554
+ var hcode = e && e.code;
555
+ if (hcode === "ENOTFOUND" || hcode === "ENODATA") {
556
+ // Void lookup — counts toward §4.6.4 ceiling for the MX
557
+ // expansion (the MX hostname has no A/AAAA in the relevant
558
+ // family). Some hosts are v4-only and won't have AAAA; we
559
+ // skip the host but charge the void slot.
560
+ lookups.void = (lookups.void || 0) + 1;
561
+ if (lookups.void > SPF_VOID_LOOKUP_LIMIT) {
562
+ return { error: "permerror",
563
+ reason: "SPF void-lookup limit exceeded (RFC 7208 §4.6.4) during mx expansion" };
564
+ }
565
+ continue;
566
+ }
567
+ return { error: "temperror",
568
+ reason: "SPF mx host " + mxHosts[mi] + " A/AAAA lookup failed: " +
569
+ ((e && e.message) || String(e)) };
570
+ }
571
+ }
572
+ }
573
+
574
+ for (var ti = 0; ti < targetIps.length; ti += 1) {
575
+ var cidr = targetIps[ti] + "/" + mask;
576
+ if (isIpv6) { if (_ipv6InCidr(ip, cidr)) return { match: true }; }
577
+ else { if (_ipv4InCidr(ip, cidr)) return { match: true }; }
578
+ }
579
+ return { match: false };
580
+ }
581
+
582
+ // SPF verify — recursive include resolution + ip4 / ip6 / a / mx /
583
+ // include / all / redirect=. The `exists` and `ptr` mechanisms +
584
+ // macro-string expansion remain deferred (see the mechanism dispatch
585
+ // arm for the Re-open condition + operator escape hatch).
328
586
  async function spfVerify(opts) {
329
587
  opts = opts || {};
330
588
  validateOpts(opts, ["ip", "mailFrom", "helo", "dnsLookup"], "mail.spf.verify");
@@ -427,15 +685,69 @@ async function _spfEvaluateDomain(domain, ip, dnsLookup, lookups, ctx) {
427
685
  return { verdict: "permerror",
428
686
  explanation: "include:" + m.arg + " has no SPF record (RFC 7208 §5.2)" };
429
687
  }
430
- } else if (m.mechanism === "a" || m.mechanism === "mx" ||
431
- m.mechanism === "exists" || m.mechanism === "ptr") {
432
- // Out of scope this patch operators with these get permerror
433
- // so they know to investigate.
688
+ } else if (m.mechanism === "a" || m.mechanism === "mx") {
689
+ // RFC 7208 §5.3 / §5.4. The mechanism itself counts as one DNS
690
+ // lookup per §4.6.4 (already incremented by the outer loop's
691
+ // `lookups.count += 1` for non-initial domains; ip4/ip6/all are
692
+ // overcounted as a result, but only by mechanisms whose lookup
693
+ // budget the spec doesn't care about — they're not DNS-touching).
694
+ // The `a` / `mx` arms additionally expand per RFC §4.6.4 (each
695
+ // MX hostname adds another lookup); the helper handles that
696
+ // accounting.
697
+ lookups.count += 1;
698
+ if (lookups.count > lookups.limit) {
699
+ return { verdict: "permerror",
700
+ explanation: "DNS lookup limit exceeded (RFC 7208 §4.6.4) at " +
701
+ m.mechanism };
702
+ }
703
+ var amRes = await _spfMatchAMx(m.mechanism, m.raw, ip, isIpv6,
704
+ domain, dnsLookup, lookups);
705
+ if (amRes.error === "permerror") {
706
+ return { verdict: "permerror", explanation: amRes.reason };
707
+ }
708
+ if (amRes.error === "temperror") {
709
+ return { verdict: "temperror", explanation: amRes.reason };
710
+ }
711
+ if (amRes.match) match = true;
712
+ } else if (m.mechanism === "exists" || m.mechanism === "ptr") {
713
+ // RFC 7208 §5.7 (exists) + §5.5 (ptr) — deferred from v0.11.3.
714
+ //
715
+ // exists: requires macro-string expansion (RFC 7208 §7) to be
716
+ // useful in practice; almost every published `exists:` policy
717
+ // uses macros like `exists:%{l}.%{d}._spf.example.com` to do
718
+ // per-recipient or per-IP lookups. A non-macro `exists:` is
719
+ // technically valid but vanishingly rare in published policies.
720
+ //
721
+ // ptr: RFC 7208 §5.5 explicitly says "use of this mechanism
722
+ // is strongly discouraged" — the receiver does reverse-DNS +
723
+ // forward-confirm per query, doubling DNS load and tying the
724
+ // sender's authz to whoever controls their PTR zone. Despite
725
+ // this discouragement, a small minority of legacy senders
726
+ // still publish `+ptr -all` policies as their only SPF stance.
727
+ //
728
+ // Re-open conditions:
729
+ // - exists: macro-string expansion lands in the framework (a
730
+ // standalone slice; tracked under blamejs-roadmap.md), OR an
731
+ // operator surfaces a real `exists:` policy without macros
732
+ // and asks for the simple A-existence form.
733
+ // - ptr: an operator surfaces a legitimate sender whose
734
+ // ONLY SPF stance is `ptr` and needs the framework to
735
+ // evaluate it (rather than the operator's MTA already doing
736
+ // iprev via `b.mail.auth.iprev`).
737
+ //
738
+ // Operator escape hatch today:
739
+ // - exists: senders almost universally have a non-`exists:`
740
+ // mechanism alongside; the framework returns "permerror"
741
+ // here, surfacing the gap, but legitimate mail flow that
742
+ // ALSO carries a passing ip4/ip6/include path is unaffected.
743
+ // - ptr: operators evaluating a ptr-only sender wire
744
+ // `b.mail.auth.iprev(ip)` and treat fcrdns=true the same as
745
+ // SPF pass for that domain.
434
746
  return {
435
747
  verdict: "permerror",
436
- explanation: "SPF mechanism '" + m.mechanism + "' is not yet implemented; " +
437
- "operator can wire b.mail.spf.verify({ dnsLookup }) with their " +
438
- "own resolver",
748
+ explanation: "SPF mechanism '" + m.mechanism + "' is not yet implemented (RFC 7208 §" +
749
+ (m.mechanism === "exists" ? "5.7 + §7 macros" : "5.5") +
750
+ "); senders typically publish ip4 / ip6 / a / mx / include alongside",
439
751
  };
440
752
  }
441
753
  if (match) {
@@ -7,8 +7,10 @@
7
7
  * @slug mail-crypto-smime
8
8
  *
9
9
  * @card
10
- * S/MIME 4.0 signature verification per RFC 8551 + RFC 5652 CMS
11
- * SignedData. v1 surface is cert preflight; sign/verify deferred.
10
+ * S/MIME 4.0 sign + verify (PQC-first ML-DSA / SLH-DSA signers) on
11
+ * the b.cms substrate. RFC 8551 multipart/signed with RFC 5652
12
+ * SignedData; EFAIL-class encrypt/decrypt deferred until the AAD-
13
+ * binding posture lands.
12
14
  *
13
15
  * @intro
14
16
  * S/MIME 4.0 (RFC 8551, replacing RFC 5751) `multipart/signed;
@@ -110,8 +112,10 @@ var ALLOWED_HASHES = ["sha256", "sha384", "sha512"];
110
112
  var REFUSED_HASHES = ["md5", "sha1"]; // allow:raw-byte-literal — CVE-2017-9006-class
111
113
 
112
114
  // PROFILES + COMPLIANCE_POSTURES — the framework's standard cross-
113
- // primitive contract. v1 only emits the metadata; the deferred sign/
114
- // verify methods read them when they light up.
115
+ // primitive contract. sign() and verify() (live since v0.10.16) read
116
+ // these to determine which hash + RSA-bit floors apply per operator
117
+ // posture; encrypt() / decrypt() (deferred per the @intro EFAIL note)
118
+ // will compose the same set when they land.
115
119
  var PROFILES = ["strict", "balanced", "permissive"];
116
120
  var COMPLIANCE_POSTURES = {
117
121
  hipaa: "strict",
@@ -1251,7 +1251,29 @@ function create(opts) {
1251
1251
  var subArgs = sub[2];
1252
1252
  if (subVerb === "FETCH") return _handleFetch(state, socket, tag, subArgs, true);
1253
1253
  if (subVerb === "STORE") return _handleStore(state, socket, tag, subArgs, true);
1254
- _writeTagged(socket, tag, "BAD UID " + subVerb + " not implemented in v1");
1254
+ // RFC 9051 §6.4.9 also defines UID SEARCH / UID COPY / UID MOVE /
1255
+ // UID EXPUNGE; deferred from the initial listener slice.
1256
+ //
1257
+ // SEARCH: composes with the existing _handleSearch path; needs
1258
+ // the searchRange path threaded through `useUid: true`.
1259
+ // COPY: composes with the existing _handleCopy path; needs
1260
+ // the mailStore.copyRange opt accepted.
1261
+ // MOVE: RFC 6851; same shape as COPY plus an atomic-delete
1262
+ // step on the source mailbox.
1263
+ // EXPUNGE: RFC 4315 UIDPLUS; expunges by uid-set instead of by
1264
+ // \Deleted-flag scan.
1265
+ //
1266
+ // Re-open condition: operator surfaces a real IMAP client that
1267
+ // refuses to fall back to seq-number variants (most modern
1268
+ // clients — mutt / Thunderbird / Apple Mail / Outlook — already
1269
+ // use the seq-number forms when UID variants are unavailable).
1270
+ //
1271
+ // Operator escape hatch today: clients that issue these UID
1272
+ // sub-commands receive `BAD` and retry against the seq-number
1273
+ // variant (SEARCH / COPY / MOVE / EXPUNGE) which the listener
1274
+ // does serve.
1275
+ _writeTagged(socket, tag, "BAD UID " + subVerb +
1276
+ " is not yet implemented; client may retry with the seq-number form");
1255
1277
  }
1256
1278
 
1257
1279
  function _handleIdle(state, socket, tag) {
package/lib/restore.js CHANGED
@@ -11,7 +11,7 @@
11
11
  *
12
12
  * var restore = b.restore.create({
13
13
  * dataDir: "./data",
14
- * storage: b.backup.localStorage({ root: "./backups" }),
14
+ * storage: b.backup.diskStorage({ root: "./backups" }),
15
15
  * passphrase: Buffer.from("operator backup passphrase"),
16
16
  * rollbackRoot: "./data.rollbacks", // optional; default <dataDir>.rollbacks
17
17
  * audit: true,
@@ -76,7 +76,7 @@ class RestoreError extends FrameworkError {
76
76
  function _validateStorage(storage) {
77
77
  if (!storage || typeof storage !== "object") {
78
78
  throw new RestoreError("restore/bad-storage",
79
- "storage backend is required (use b.backup.localStorage or pass a custom one)");
79
+ "storage backend is required (use b.backup.diskStorage or pass a custom one)");
80
80
  }
81
81
  var required = ["readBundle", "listBundles", "hasBundle"];
82
82
  for (var i = 0; i < required.length; i++) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.11.1",
3
+ "version": "0.11.3",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
package/sbom.cdx.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.6",
5
- "serialNumber": "urn:uuid:7fbc6c04-e867-4e6a-b8a0-d1686fdb9dc7",
5
+ "serialNumber": "urn:uuid:b5450662-adf2-4f43-baef-731a1c66e80f",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-19T15:04:27.770Z",
8
+ "timestamp": "2026-05-19T17:04:46.477Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/core@0.11.1",
22
+ "bom-ref": "@blamejs/core@0.11.3",
23
23
  "type": "library",
24
24
  "name": "blamejs",
25
- "version": "0.11.1",
25
+ "version": "0.11.3",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "The Node framework that owns its stack.",
29
- "purl": "pkg:npm/%40blamejs/core@0.11.1",
29
+ "purl": "pkg:npm/%40blamejs/core@0.11.3",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/core@0.11.1",
57
+ "ref": "@blamejs/core@0.11.3",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]