@blamejs/core 0.10.9 → 0.10.12

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,9 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.10.x
10
10
 
11
+ - v0.10.12 (2026-05-18) — **`b.agent.tenant` adoption across the mail-server listeners.** The v0.10.11 shared `b.mail.serverRegistry` primitive gains optional `opts.tenantScope` (a `b.agent.tenant.create()` instance) + `opts.agentTenantId` (the tenant this listener serves). When supplied, every method dispatch first gates on `tenantScope.check(state.actor, agentTenantId)` BEFORE guard validation or audit emission; cross-tenant access surfaces as the v0.9.25-typed `agent-tenant/cross-tenant-access-refused` which the listener's catch-path converts to the protocol's `BAD` / `NO` refusal reply. **(a) `b.mail.server.imap.create({ tenantScope, agentTenantId })`** — IMAP dispatch is gated for every command after AUTH. **(b) `b.mail.server.jmap.create({ tenantScope, agentTenantId })`** — JMAP per-method dispatch routes through the tenant scope alongside its existing per-`accountId` isolation. **(c) `b.mail.server.managesieve.create({ tenantScope, agentTenantId })`** — ManageSieve same pattern. **(d) `b.mail.server.submission.create({ tenantScope, agentTenantId })`** — submission listener gates at the AUTH-success boundary (before `state.actor` is committed) so cross-tenant authentication surfaces as `535 5.7.0 Authentication rejected (cross-tenant)` and the SMTP envelope never begins under the wrong tenant. **(e) `b.mail.server.pop3.create({ tenantScope, agentTenantId })`** — same AUTH-success gate; cross-tenant refusal returns `-ERR Authentication rejected (cross-tenant)`. New audit events: `mail.server.submission.cross_tenant_refused` and `mail.server.pop3.cross_tenant_refused`. **Operator impact:** no breaking changes — `tenantScope` / `agentTenantId` are optional; operators not running multi-tenant see identical behavior. Operators with multi-tenant deployments wire `b.agent.tenant.create({...})` once and pass the same scope to every per-tenant listener instance — cross-tenant isolation becomes structural rather than per-handler opt-in. **Deferred to v0.10.12.1:** per-tenant `b.mailStore` seal-key derivation via `tenantScope.derivedKey(tenantId, "seal")` and per-tenant audit namespaces via `tenantScope.auditFor(tenantId)`. Today every mail listener seals through the framework primary vault key — adequate for single-tenant and multi-tenant-trusted deployments; the v0.10.12.1 follow-up adds per-tenant key separation for compromise-isolation use cases. References: [RFC 9051 IMAP4rev2 §3 state machine](https://www.rfc-editor.org/rfc/rfc9051#section-3) · [RFC 8620 JMAP Core §1.6.2 accountId](https://www.rfc-editor.org/rfc/rfc8620#section-1.6.2) · [RFC 6409 Submission §6.1 actor-to-MAIL-FROM identity binding](https://www.rfc-editor.org/rfc/rfc6409#section-6.1) · [RFC 1939 POP3 §6 transaction state](https://www.rfc-editor.org/rfc/rfc1939#section-6) · v0.9.25 `b.agent.tenant` contract.
12
+ - v0.10.11 (2026-05-18) — **Mail-server per-method registration sweep.** New shared primitive `b.mail.serverRegistry` (`lib/mail-server-registry.js`) replaces the hand-rolled `switch (verb)` dispatchers in the IMAP, JMAP, and ManageSieve listener factories. Operators can now override individual command / method handlers (e.g. IMAP `FETCH`, JMAP `Email/query`, ManageSieve `PUTSCRIPT`) via `opts.overrides: { NAME: { fn, maxHandlerBytes, maxHandlerMs } }` without re-implementing wire-protocol state machines or bypassing the guard substrate. **(a) Per-handler resource budgets — required at registration.** Operators MUST supply `maxHandlerBytes` (≤ 256 MiB) and `maxHandlerMs` (≤ 5 min) on every override; the registration throws `mail-server-registry/bad-max-handler-bytes` / `bad-max-handler-ms` on missing or out-of-range budgets. Defends [CVE-2024-34055](https://nvd.nist.gov/vuln/detail/CVE-2024-34055) (Cyrus authenticated OOM) and [CVE-2026-26312](https://nvd.nist.gov/vuln/detail/CVE-2026-26312) (Stalwart malformed nested `message/rfc822` cyclical OOM) by forcing operators to declare the resource ceiling explicitly. **(b) Catalogue gate.** Per-protocol method names outside the IANA / RFC catalogue refuse registration unless `allowExperimental: true` is supplied — opting in audits the registration so operators can grep for off-spec handlers. **(c) Guard chain preserved.** The listener factories run `b.guardImapCommand` / `b.guardJmap` / `b.guardManagesieveCommand` BEFORE the registry lookup; operator overrides cannot bypass the wire-protocol validation, smuggling defenses, or rate-limit budgets. **(d) Handler timeout.** Promise-returning handlers wrap through `b.safeAsync.withTimeout(maxHandlerMs)`; a runaway override raises `mail-server-registry/handler-timeout` rather than pinning the connection. **(e) Defaults seeded.** IMAP picks up 30 verbs (CAPABILITY, NOOP, LOGOUT, ID, STARTTLS, AUTHENTICATE, LOGIN, ENABLE, SELECT, EXAMINE, LIST, STATUS, NAMESPACE, APPEND, CHECK, CLOSE, UNSELECT, EXPUNGE, FETCH, STORE, UID, IDLE, DONE — plus the previously-undispatched SEARCH / CREATE / DELETE / RENAME / SUBSCRIBE / UNSUBSCRIBE / COPY / MOVE which default to `NO not-configured` until operator overrides); ManageSieve picks up 12 verbs (CAPABILITY, NOOP, STARTTLS, LOGOUT, AUTHENTICATE, HAVESPACE, PUTSCRIPT, LISTSCRIPTS, SETACTIVE, GETSCRIPT, DELETESCRIPT, RENAMESCRIPT); JMAP wraps the existing `opts.methods` map with a one-time deprecation audit (`mail.server.jmap.methods_opt_deprecated`) and routes through the same registry — operators migrate to `opts.overrides` with explicit budgets. **(f) New audit event:** `mail.serverRegistry.method_dispatch` carries `{ protocol, name, source: "builtin" | "operator-override" }` on every dispatch; `mail.serverRegistry.experimental_registration` audits opt-in off-catalogue registrations. **Operator impact:** existing JMAP `opts.methods` callers see the deprecation audit but continue to function (legacy auto-budget = 10 MiB / 30 s); existing IMAP / ManageSieve operators have no migration burden — the listener factories continue to accept the same opts shape. Operators wiring NEW overrides MUST supply explicit budgets. References: [RFC 9051 IMAP4rev2](https://www.rfc-editor.org/rfc/rfc9051) · [RFC 8620 JMAP Core](https://www.rfc-editor.org/rfc/rfc8620) · [RFC 8621 JMAP for Mail](https://www.rfc-editor.org/rfc/rfc8621) · [RFC 5804 ManageSieve](https://www.rfc-editor.org/rfc/rfc5804) · [RFC 2971 IMAP4 ID](https://www.rfc-editor.org/rfc/rfc2971) · [RFC 2177 IMAP IDLE](https://www.rfc-editor.org/rfc/rfc2177) · [CVE-2024-34055](https://nvd.nist.gov/vuln/detail/CVE-2024-34055) · [CVE-2026-26312](https://nvd.nist.gov/vuln/detail/CVE-2026-26312).
13
+ - v0.10.10 (2026-05-17) — **PQC envelope completion (experimental).** Two new opt-in PQC-protocol primitives behind explicit experimental namespaces. **(a) `b.jose.jwe.experimental.encrypt` / `.decrypt`** — RFC 7516 compact-serialization JWE with ML-KEM-1024 key encapsulation and XChaCha20-Poly1305 AEAD content encryption. Lives under `b.jose.jwe.experimental` because the JOSE PQC IANA codepoint registration ([draft-ietf-jose-pqc-kem-05](https://datatracker.ietf.org/doc/draft-ietf-jose-pqc-kem/)) hasn't finalized — the namespace name is the contract: codepoints may change between minors without affecting the framework's stable surface. Header carries `{ alg: "ML-KEM-1024", enc: "XC20P", "x-blamejs-experimental": true }`; decrypt refuses any envelope missing the experimental marker (defends a stable-system consumer that accidentally ingests an experimental envelope and treats it as IANA-compliant). Header bytes route through `b.safeJson.parse` for proto-pollution / depth / size defenses; header is byte-capped at 4 KiB. **(b) `b.crypto.hpke.pq.connolly.seal` / `.open` + `b.crypto.hpke.pq.wg.seal` / `.open`** — both active PQ-HPKE drafts behind explicit opt-in: [draft-connolly-cfrg-hpke-mlkem-04](https://datatracker.ietf.org/doc/draft-connolly-cfrg-hpke-mlkem/) (individual; codepoints today) and [draft-ietf-hpke-pq-03](https://datatracker.ietf.org/doc/draft-ietf-hpke-pq/) (WG-adopted). Each wrapper binds a draft-distinguishing label into the RFC 9180 §5.1 `info` parameter so cross-draft substitution (sealing under connolly and opening as wg, or vice versa) refuses by construction — the derived AEAD key diverges and Poly1305 verify fails. Both compose the existing `b.crypto.hpke.seal` / `.open` core (ML-KEM-1024 KEM + HKDF-SHA3-512 + ChaCha20-Poly1305 per project PQC-first policy); the wrappers add the draft-isolation label without touching the wire-format primitives. **New audit namespace:** `jose` (`jose.jwe.experimental.encrypt` / `.decrypt`). **Operator impact:** no breaking changes. The stable `b.crypto.hpke.seal` and the existing `b.crypto.encrypt` envelope shape are unaffected. Operators integrating against systems speaking one of the active PQ-HPKE drafts use the explicit `.pq.connolly` / `.pq.wg` paths; operators wanting IANA-final codepoints wait for graduation to the stable surface (one-minor deprecation window will ship when IANA registration lands). The framework refuses to silently pick a winner between the two drafts. **Deferred to follow-up:** COSE-PQ signatures (draft-ietf-cose-pqc-* — pending IANA codepoint registration), JWE JSON serialization variant (compact-only at this experimental tier), FIPS 203 KAT test vectors against the vendored bundle (test-corpus addition; functional parity is established by the existing v0.8.41 hybrid-KEM verify path). References: [draft-ietf-jose-pqc-kem-05](https://datatracker.ietf.org/doc/draft-ietf-jose-pqc-kem/) · [draft-connolly-cfrg-hpke-mlkem-04](https://datatracker.ietf.org/doc/draft-connolly-cfrg-hpke-mlkem/) · [draft-ietf-hpke-pq-03](https://datatracker.ietf.org/doc/draft-ietf-hpke-pq/) · [RFC 9180 HPKE](https://www.rfc-editor.org/rfc/rfc9180.html) · [RFC 7516 JWE](https://www.rfc-editor.org/rfc/rfc7516.html) · [FIPS 203 ML-KEM](https://csrc.nist.gov/pubs/fips/203/final) · [draft-irtf-cfrg-xchacha XChaCha20-Poly1305](https://datatracker.ietf.org/doc/draft-irtf-cfrg-xchacha/).
11
14
  - v0.10.9 (2026-05-17) — **Ergonomic helpers bundle (A / B / C / D / E / H).** Six small DX primitives bundled into one release. **(a) `b.safePath.resolve` / `.resolveOrNull` / `.validate`** — path-traversal-safe multi-segment resolve. Refuses absolute / UNC / drive-letter `rel`, NUL bytes, C0 control chars, bidi-override codepoints (CVE-2021-42574 Trojan Source class), URL-encoded + fullwidth + division-slash path separators, Windows reserved device names CON / PRN / AUX / NUL / COM[0-9] / LPT[0-9] on EVERY platform (closes [CVE-2025-27210](https://nvd.nist.gov/vuln/detail/CVE-2025-27210) cross-mount class), trailing-`.`/trailing-space segments under windows-mode, NTFS Alternate Data Stream markers ([CVE-2024-12217](https://nvd.nist.gov/vuln/detail/CVE-2024-12217) class), and `..` segments that escape `base` after lexical resolve. Optional `opts.realpath: true` adds symlink-escape detection via `fs.realpathSync.native`. Every documented failure mode → coded refusal (`safe-path/absolute-rel` / `null-byte` / `bidi` / `win-reserved` / `escapes-base` / etc.); no best-effort path. **(b) `b.bootGates.run([{ name, fn, timeoutMs?, exitCode?, onFail? }], opts?)`** — sequential boot-invariant runner. Each gate runs in order; on first failure: emits `bootgates.failed` audit, runs the gate's `onFail` callback (swallows + audits onFail throws), writes a single-line failure summary via `opts.log`, and calls the operator-supplied `opts.exit(code)`. The default `exit` throws `BootGatesError("bootgates/no-exit-wired")` rather than calling `process.exit` directly (lib/ never terminates the process — the CLI surface owns that wiring). Each gate runs under a 60s default `timeoutMs` budget configurable per-gate; overall budget via `opts.overallTimeoutMs`. **(c) `b.metrics.snapshot.shadowRegistry({ namespace, counters, gauges, info, cardinalityCap?, onCardinalityExceeded? })`** — namespaced shadow registry that mirrors a subset of a primary registry's metrics for export to systems needing isolated views (sidecar / per-tenant scrape endpoint / compliance-tagged subset). Cardinality cap (default 10000 per metric name) closes the [client_golang CVE-2022-21698](https://nvd.nist.gov/vuln/detail/CVE-2022-21698) unbounded-cardinality DoS class; policy is `drop` (default), `audit-only`, or `refuse`. Emits `metrics.shadow.cardinality_dropped` audit (rate-limited to 1/sec per shadow registry). **(d) Per-instance `agent.reloadCerts({ cert, key, ca })` on `b.pqcAgent.create()` returns** — long-running daemons that rotate TLS material via explicit `b.pqcAgent.create()` agents previously needed a process restart; the new instance method tests the new material via `tls.createSecureContext`, swaps `agent.options` atomically, closes idle keep-alive sockets via `agent.destroy()` (in-flight sockets complete naturally), and emits `pqcagent.reloadCerts` audit. Cert/key mismatch surfaces as `pqcagent/reload-mismatch` with the OpenSSL chain; CA bundle parse failures surface as `pqcagent/reload-bad-ca`. **(e) `b.metrics.snapshot.render(snap, { format: "text", groups })`** — operator-readable text format gains an `opts.groups` map that sections the output (`== HTTP ==` / `== Queue ==` / `== TLS ==`); fields not named in any group fall to `== Other ==`. Group ordering preserved per insertion order. Prometheus / OpenMetrics formats unchanged. **(h) ISO-8601 date strings render-eligible in the text format** — timestamps shaped as `2026-05-17T20:00:00.000Z` (length-bounded at 64 chars) now render verbatim in the text format instead of degrading to `[skipped: non-numeric]`; the Prometheus format gets a parallel `<name>_epoch_ms` gauge so downstream alerting can compute durations per OpenMetrics 1.0 §3.4 (Timestamps MUST be float64 Unix-epoch). Non-ISO strings continue to skip in Prometheus (label-value injection defense). **New compliance scaffold:** new namespace `b.bootGates`. New audit namespaces: `bootgates`, `metrics`. **Operator impact:** no breaking changes. `b.bootGates.run` callers MUST supply `opts.exit: process.exit.bind(process)` from their daemon main() if they want the failure path to terminate the process — the default-throw shape exists so lib/-internal callers can't accidentally `process.exit` from inside a primitive. `b.safePath.resolve` is a brand-new primitive; existing code is unaffected. References: [Node.js path.resolve docs](https://nodejs.org/api/path.html#pathresolvepaths) · [CVE-2025-27210 Windows device-name bypass](https://nvd.nist.gov/vuln/detail/CVE-2025-27210) · [CVE-2024-12217 NTFS ADS](https://nvd.nist.gov/vuln/detail/CVE-2024-12217) · [CVE-2021-42574 Trojan Source](https://nvd.nist.gov/vuln/detail/CVE-2021-42574) · [CVE-2022-21698 Prometheus cardinality DoS](https://nvd.nist.gov/vuln/detail/CVE-2022-21698) · [OpenMetrics 1.0 spec](https://github.com/prometheus/OpenMetrics/blob/v1.0.0/specification/OpenMetrics.md) · [CVE-2026-21637 SNI sync-throw](https://nvd.nist.gov/vuln/detail/CVE-2026-21637).
12
15
  - v0.10.8 (2026-05-17) — **EU AI Act Art. 50 + AB-853 + CAC implicit label + AIBOM + operator-surfaced DX primitives.** Calendar-bound release ahead of the 2026-08-02 EU AI Act Art. 50 transparency / California SB-942-as-amended-by-AB-853 effective date and the live (2025-09-01) China CAC GB 45438-2025 labeling regime. Three new AI-transparency surfaces + three operator-surfaced DX primitives (issues #91 / #92 / #93). **(a) `b.ai.aiContentDetect.report`** — inbound-asset provenance detector. Operators extract C2PA-COSE envelopes / CAC implicit-label JSON / IPTC PhotoMetadata via their format-specific muxer and feed the artifacts to `report({...})`; the framework verifies signatures, anchors against an operator-pinned trust list, and returns a normalized provenance report for the AB-853 §22757.21 disclosure UI. Trust-list-empty surfaces as an alert rather than silent acceptance. Profile / posture cascade: `ca-ab-853`, `ca-sb-942`, `eu-ai-act-art-50`, `cac-genai-label` pin to `strict` (refuse on signer not on trust list); `nist-ai-600-1`, `iso-42001`, `iso-23894`, `nist-ai-rmf` pin to `balanced`. **(b) `b.contentCredentials.cacImplicitLabel` + `b.contentCredentials.cacImplicitLabelRead`** — China CAC (Cyberspace Administration) "Measures for Labeling AI-Generated Synthetic Content" + mandatory standard GB 45438-2025 implicit metadata emitter and reverse parser. Validates the 18-character Chinese unified social credit code (统一社会信用代码 per GB 32100-2015), `aigcMarker` field, and `contentKind` enum at the config-time tier. Operators co-emit alongside the C2PA-COSE manifest by declaring `cac-genai-label` posture on the existing `b.contentCredentials.build`. **(c) `b.ai.modelManifest.build` / `.sign` / `.verify`** — CycloneDX 1.6 ML-BOM (AI bill of materials) emitter. EU AI Act Art. 11 + Annex IV require technical documentation for high-risk AI systems; CycloneDX 1.6 ML-BOM is the de-facto serialization (and forward-positioned for EU CRA 2027-12-11 — Regulation (EU) 2024/2847 requires SBOM-style documentation for AI components in products with digital elements). Emits `bomFormat: "CycloneDX"` + `specVersion: "1.6"` + `serialNumber` UUIDv4 URN + `metadata.timestamp` + `metadata.tools[]` + `metadata.component` (primary model with `type: "machine-learning-model"`) + `components[]` datasets + `properties[]` hyperparameters (per CycloneDX spec issue #702 EU CRA alignment) + `formulation[]` workflows + `services[]` external model APIs. ML-DSA-87 signature over canonical-JSON-1785 representation; verify path NEVER trusts an embedded "signedBytes" field — defends the CVE-2025-29774 / CVE-2025-29775 xml-crypto-style signature-substitution class. Self-validates required CycloneDX 1.6 fields at emit time. **(d) `b.atomicFile.conflictPath`** — filesystem-portable conflict-suffix path builder (issue #91). `notes.md` → `notes.conflict-2026-05-17T19-30-00Z.md`; Windows-safe (no `:` / `.`), extension-preserving, dotfile-aware, optional `tag` + `suffix` disambiguator for same-second collisions. Composes the existing `b.atomicFile.pathTimestamp`. **(e) `b.promisePool.create`** — bounded-concurrency promise pool (issue #92). The gap between `b.workerPool` (worker-thread CPU-bound work) and `b.queue` (durable cross-process messaging). `run(taskFn)` / `fire(taskFn)` / `drain({ close? })` shape with back-pressure on enqueue, queueLimit refusal, composes with `b.appShutdown` for drain-on-shutdown. No hidden retry — operators compose `b.retry.withRetry` inside the task body when they want it. **(f) `b.sdNotify.send` / `.ready` / `.stopping` / `.reloading` / `.watchdog`** — sd_notify protocol surface for systemd Type=notify daemons (issue #93). Reads `$NOTIFY_SOCKET` via `b.parsers.safeEnv.readVar`, dispatches `READY=1` / `STOPPING=1` / `RELOADING=1` / `WATCHDOG=1` via `systemd-notify(1)` with `execFile` (no shell). No-op (with audit) when `$NOTIFY_SOCKET` is unset (foreground / container / non-systemd init). Compose with `b.appShutdown.create` for the STOPPING signal; compose with a periodic watchdog interval for systemd's auto-restart-on-hang guarantee. **(g) `b.crypto.randomInt` substrate** — exported alongside the v0.10.7 substrate to give the new AIBOM UUID generator a single greppable random-int path. **(h) New compliance postures in `b.contentCredentials` / `b.ai.aiContentDetect` / `b.ai.modelManifest`:** `ca-ab-853`, `ca-sb-942`, `eu-ai-act-art-50`, `eu-ai-act-art-11`, `cac-genai-label`, `nist-ai-600-1`, `nist-ai-rmf`, `iso-42001`, `iso-23894`. **(i) New audit namespaces:** `aibom` (aibom.signed / aibom.verified), `aicontentdetect` (aicontentdetect.report), `sdnotify` (sdnotify.send / sdnotify.send.skipped). **Deferred to v0.10.9:** in-tree IPTC PhotoMetadata reader for `digitalSourceType` field — operators pre-parse with their tool of choice and pass via `opts.ipmd`. **Operator impact:** no breaking changes. Operators already declaring `eu-ai-act-art-50` posture should pin a trust list via the new primitive before turning on default-on detection in production; AB-853 §22757.21 platform-detection obligations are 2027-01-01 effective so there's runway. References: [EU AI Act Regulation (EU) 2024/1689](https://eur-lex.europa.eu/eli/reg/2024/1689) · [California SB-942 + AB-853](https://leginfo.legislature.ca.gov/faces/billNavClient.xhtml?bill_id=202320240SB942) · [CAC GB 45438-2025](http://www.cac.gov.cn/2025-03/14/c_1742700786675936.htm) · [C2PA 2.1 / 2.2 spec](https://c2pa.org/specifications/specifications/2.2/) · [CycloneDX 1.6 ML-BOM](https://cyclonedx.org/docs/1.6/json/) · [OWASP CycloneDX AI/ML-BOM Authoritative Guide](https://owasp.org/www-project-cyclonedx/) · [NIST AI 600-1 Generative AI Profile](https://nvlpubs.nist.gov/nistpubs/ai/NIST.AI.600-1.pdf) · [ISO/IEC 42001:2023](https://www.iso.org/standard/81230.html) · [systemd-notify(1)](https://www.freedesktop.org/software/systemd/man/latest/systemd-notify.html) · [CVE-2025-29774](https://nvd.nist.gov/vuln/detail/CVE-2025-29774) · [CVE-2025-29775](https://nvd.nist.gov/vuln/detail/CVE-2025-29775) · [CVE-2025-32711 EchoLeak](https://nvd.nist.gov/vuln/detail/CVE-2025-32711).
13
16
  - v0.10.7 (2026-05-17) — **Mail-stack P3 / P4 hardening sweep.** Twenty-plus refusals + observability additions across the four mail listeners, the DKIM verifier, ARC signer, MIME parser, and the DNS / DSN / List-* guards. No new public primitives; one substrate addition (`b.crypto.randomInt`); two new operator-visible opts on the submission listener. **(a) `b.crypto.randomInt(min, max)`** — substrate wrapper that routes every framework integer draw through one greppable primitive. Migrates the inline `nodeCrypto.randomInt` sites in `b.network.dns` / `b.network.dns.resolver` (DNS query-ID), `b.mail.auth` (DMARC `pct` sampling), and `b.externalDb` (transaction-retry jitter) so the audit trail is uniform and future detectors see one shape. **(b) `b.mail.server.submission.create({ requireDkim, dkimRequireMode })`** — outbound DKIM-required gate per Yahoo / Google 2024 bulk-sender alignment. `requireDkim` defaults `true` under `strict` profile (`false` under `balanced` / `permissive`). `dkimRequireMode` is `"self"` (signer's `d=` must match authenticated identity's domain), `"any"` (any signer present), or `"off"` (no gate). Default `"any"`. Submission listener that doesn't carry a `DKIM-Signature:` header at DATA-end refuses with `5.7.20`. **(c) `b.mail.server.{mx,submission}.create({ allowSmtpUtf8 })`** — single per-listener SMTPUTF8 (RFC 6531) switch threaded end-to-end into `guardSmtpCommand.validate`. Default `false`. Operators that accept EAI envelopes flip to `true` and the toggle reaches every wire-line guard call. **(d) DKIM verifier signature-count cap.** `b.mail.dkim.verify` now refuses (`policy` verdict) rather than silently truncating when a message carries more `DKIM-Signature` headers than `maxSignatures` (default 8). The opt is range-checked at config time against a ceiling of 16; out-of-range throws `dkim/bad-max-signatures`. Closes a verifier-fan-out DoS shape per RFC 6376 §6.1. Emits `dkim.verify.signature_count_cap` audit on the refusal so postmasters see DoS attempts in the authentication-results stream. **(e) MX listener size-overrun + observability.** `MAIL FROM SIZE=` is now reconciled against the actual DATA byte count after dot-stuffing reversal — senders that understate `SIZE=` to probe `maxMessageBytes` get `552 5.3.4` rather than silently accepted, with `mail.server.mx.size_overrun` audit. Refused-recipient list (bounded at 32 per transaction) now surfaces in the `data_accepted` / `delivered` audit metadata. Write-backpressure on every reply attaches a once-per-socket `mail.server.mx.write_backpressure` audit so operators see stalled connections without flooding on every reply. **(f) IMAP refinements.** `APPEND mailbox [flags] [date-time] {literal}` now honors the optional RFC 9051 §6.3.12 date-time argument (parsed into `internalDate` ms-epoch, refused with `BAD` rather than silently falling back to `Date.now()`). `FETCH` / `STORE` outside of Selected state now respond `BAD` (RFC 9051 §6.4.5 / §6.4.6 — protocol-context violation, not policy refusal). `LOGIN` quoted-string args honor `\"` / `\\` escape pairs per the RFC 9051 §5.1 grammar (the prior shape terminated at the first `"`, letting a hostile client smuggle `LOGIN "alice\"@example.com" "pw"` past the username binding). **(g) ARC signer hop-count ceiling.** `b.mail.arc.sign` extracts prior hops with the RFC 8617 §5 50-hop cap; an inbound chain claiming >50 hops or an out-of-range `i=` tag is refused rather than enumerated. **(h) MIME parser charset + observability.** `b.safeMime.parse` now decodes `utf-16` (RFC 2781 §3.3 BOM detection + BE default), `utf-16be`, and `utf-16le` end-to-end — the prior shape advertised `utf-16` / `utf-16be` in the allowlist but only decoded `utf-16le`. `binary` Content-Transfer-Encoding is removed from the default allowlist (RFC 3030 §3 — `binary` requires explicit BINARYMIME negotiation; operators that wire BINARYMIME opt back in via `transferEncodingAllowlist: [..., "binary"]`). Control-character refusal errors now report the BYTE offset (via `Buffer.byteLength` on the JS string prefix) rather than the UTF-16 code-unit index, so audit lines align with wire-level inspection. **(i) DKIM / DMARC / ARC / iPrev / DSN tightening.** `b.guardDsn` splits the RFC 3464 §2.1.1 block separator on literal `\r\n\r\n` only (the prior `\n\s*\n` accepted `\v` / `\f` whitespace as a block boundary, letting a hostile sender bend the per-message vs per-recipient boundary). `b.guardMessageId` now validates id-left + id-right against RFC 5322 §3.2.3 dot-atom-text shape under `strict` profile; `b.guardListId` extends the localhost FQDN exception to `.local` (RFC 6762) and `.lan` (draft-chapin-rfc2606bis). **(j) `b.guardListUnsubscribe` SSRF defense.** HTTPS one-click URIs now refuse IP-literal hosts (v4 + v6), reserved-local hostnames (`localhost` / `localhost.localdomain` / `ip6-localhost` / `ip6-loopback`), and reserved-local TLD suffixes (`.local` / `.lan` / `.internal`). New optional `allowedHosts` opt provides a domain allowlist — when supplied, every HTTPS host (or any ancestor) must be on the list. **(k) `b.mailStore` JMAP objectid bump to 128 bits.** RFC 8474 §1.5.1 — the prior 24-char hex prefix cut entropy to 96 bits; full 32-char hex restores 128 bits. **Operator impact:** Submission listeners on `strict` profile WITHOUT operator-side DKIM signing (`b.mail.dkim.sign` pre-relay) now refuse outbound DATA — operators in this state either wire DKIM signing, opt to `dkimRequireMode: "off"`, or step down to `balanced`. `b.mail.dkim.verify` callers passing `maxSignatures > 16` now throw at config time — clamp via the opt or rely on the framework default. `b.safeMime.parse` callers that legitimately receive `binary` Content-Transfer-Encoding (BINARYMIME-aware downstream pipelines) opt back in via `transferEncodingAllowlist`. `b.guardListUnsubscribe.validate` callers that legitimately rely on IP-literal one-click URIs (test harnesses, internal-network operators) opt in via `allowedHosts: ["10.0.0.0/8"]` style ancestor matches. **Deferred to v0.10.8 / v0.10.12:** per-tenant pepper on `b.mailStore` derived hashes (`from_hash` / `message_id_hash`) ships in v0.10.12 alongside the `b.agent.tenant` adoption refactor; the schema migration is too invasive to fold into this patch. `b.mailStore` forensic-recovery columns (original Content-Transfer-Encoding + charset preserved alongside decoded body) defer to v0.10.8 — the schema change carries its own backwards-compatibility surface. References: [RFC 9051 IMAP4rev2](https://www.rfc-editor.org/rfc/rfc9051), [RFC 6376 DKIM §6.1](https://www.rfc-editor.org/rfc/rfc6376#section-6.1), [RFC 6531 SMTPUTF8](https://www.rfc-editor.org/rfc/rfc6531), [RFC 3030 BINARYMIME](https://www.rfc-editor.org/rfc/rfc3030), [RFC 2781 UTF-16 BOM](https://www.rfc-editor.org/rfc/rfc2781), [RFC 1870 SMTP SIZE](https://www.rfc-editor.org/rfc/rfc1870), [RFC 8474 JMAP objectid](https://www.rfc-editor.org/rfc/rfc8474), [RFC 8617 ARC §5](https://www.rfc-editor.org/rfc/rfc8617#section-5), [RFC 3464 DSN §2.1.1](https://www.rfc-editor.org/rfc/rfc3464#section-2.1.1), [RFC 5322 Message Format §3.2.3](https://www.rfc-editor.org/rfc/rfc5322#section-3.2.3), [RFC 6761 Reserved Domain Names](https://www.rfc-editor.org/rfc/rfc6761), [Yahoo / Gmail bulk-sender 2024](https://blog.google/products/gmail/gmail-security-authentication-spam-protection/).
package/index.js CHANGED
@@ -57,6 +57,11 @@ var crypto = require("./lib/crypto");
57
57
  // remembering separate top-level namespaces. Implementations live in
58
58
  // the dedicated lib files; these are thin aliases.
59
59
  crypto.hpke = require("./lib/crypto-hpke");
60
+ // Both PQ-HPKE drafts behind one opt-in sub-namespace — see
61
+ // lib/crypto-hpke-pq.js. Operators that need a draft-codepoint
62
+ // shape reach for b.crypto.hpke.pq.connolly / .wg explicitly; the
63
+ // stable b.crypto.hpke.seal stays IANA-codepoint-neutral.
64
+ crypto.hpke.pq = require("./lib/crypto-hpke-pq");
60
65
  crypto.httpSig = require("./lib/http-message-signature");
61
66
  var tlsExporter = require("./lib/tls-exporter");
62
67
  var router = require("./lib/router");
@@ -267,6 +272,7 @@ var time = require("./lib/time");
267
272
  var uuid = require("./lib/uuid");
268
273
  var mail = require("./lib/mail");
269
274
  mail.rbl = require("./lib/mail-rbl");
275
+ mail.serverRegistry = require("./lib/mail-server-registry");
270
276
  mail.greylist = require("./lib/mail-greylist");
271
277
  mail.helo = require("./lib/mail-helo");
272
278
  mail.deploy = require("./lib/mail-deploy");
@@ -407,6 +413,9 @@ module.exports = {
407
413
  sdNotify: require("./lib/sd-notify"),
408
414
  safePath: require("./lib/safe-path"),
409
415
  bootGates: require("./lib/boot-gates"),
416
+ // b.jose.jwe.experimental — see lib/jose-jwe-experimental.js for
417
+ // the codepoint-stability contract.
418
+ jose: { jwe: { experimental: require("./lib/jose-jwe-experimental") } },
410
419
  queue: queue,
411
420
  logStream: logStream,
412
421
  redact: redact,
package/lib/audit.js CHANGED
@@ -301,6 +301,7 @@ var FRAMEWORK_NAMESPACES = [
301
301
  "sdnotify", // b.sdNotify (sdnotify.send / sdnotify.send.skipped — systemd Type=notify)
302
302
  "bootgates", // b.bootGates (bootgates.passed / bootgates.failed / bootgates.onfail_threw — boot-invariant runner)
303
303
  "metrics", // b.metrics.snapshot.shadowRegistry (metrics.shadow.cardinality_dropped — namespaced metrics export)
304
+ "jose", // b.jose.jwe.experimental (jose.jwe.experimental.encrypt / .decrypt — ML-KEM-JWE pre-IANA)
304
305
  ];
305
306
  var registeredNamespaces = new Set(FRAMEWORK_NAMESPACES);
306
307
 
package/lib/auth/dpop.js CHANGED
@@ -77,7 +77,11 @@ function _b64urlDecode(s) {
77
77
  if (typeof s !== "string") {
78
78
  throw new AuthError("auth-dpop/bad-base64", "expected base64url string");
79
79
  }
80
- return bCrypto.fromBase64Url(s);
80
+ try { return bCrypto.fromBase64Url(s); }
81
+ catch (_e) {
82
+ throw new AuthError("auth-dpop/bad-base64",
83
+ "DPoP segment is not valid base64url");
84
+ }
81
85
  }
82
86
 
83
87
  // Canonical JWK per RFC 7638 — keys present in lexicographic order,
@@ -308,12 +308,28 @@ function _ttlFromNextUpdate(nextUpdateDate) {
308
308
  }
309
309
 
310
310
  // MDS3 nextUpdate per spec section 3.1.7 is "YYYY-MM-DD" (UTC midnight).
311
+ // Round-trip the parsed components through `new Date(utcMs).getUTC*()`
312
+ // and verify each field matches the input — Date.UTC silently
313
+ // normalises impossible calendar dates (`2026-02-31` -> `2026-03-03`),
314
+ // which would let a malformed MDS3 BLOB nextUpdate masquerade as a
315
+ // valid future timestamp and influence the cache-TTL clamp downstream.
311
316
  function _parseNextUpdate(s) {
312
317
  if (typeof s !== "string") return null;
313
- var m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s); // allow:raw-byte-literal — ISO-8601 date components
318
+ var m = s.match(/^(\d{4})-(\d{2})-(\d{2})$/); // allow:raw-byte-literal — ISO-8601 date components
314
319
  if (!m) return null;
315
- var d = new Date(Date.UTC(parseInt(m[1], 10), parseInt(m[2], 10) - 1, parseInt(m[3], 10)));
316
- return isFinite(d.getTime()) ? d : null;
320
+ var year = parseInt(m[1], 10);
321
+ var month = parseInt(m[2], 10) - 1;
322
+ var day = parseInt(m[3], 10);
323
+ if (day < 1 || day > 31 || month < 0 || month > 11) return null;
324
+ var utcMs = Date.UTC(year, month, day);
325
+ if (!isFinite(utcMs)) return null;
326
+ var d = new Date(utcMs);
327
+ if (d.getUTCFullYear() !== year ||
328
+ d.getUTCMonth() !== month ||
329
+ d.getUTCDate() !== day) {
330
+ return null;
331
+ }
332
+ return d;
317
333
  }
318
334
 
319
335
  // Internal verify-blob helper used by both fetch (live HTTP) and the
package/lib/auth/jwt.js CHANGED
@@ -91,7 +91,8 @@ function _b64urlEncode(buf) { return bCrypto.toBase64Url(buf); }
91
91
 
92
92
  function _b64urlDecode(s) {
93
93
  if (typeof s !== "string") throw new AuthError("auth-jwt/malformed", "expected base64url string");
94
- return bCrypto.fromBase64Url(s);
94
+ try { return bCrypto.fromBase64Url(s); }
95
+ catch (_e) { throw new AuthError("auth-jwt/malformed", "JWT segment is not valid base64url"); }
95
96
  }
96
97
 
97
98
  function _toKeyObject(pemOrKey, kind) {
package/lib/auth/oauth.js CHANGED
@@ -245,7 +245,8 @@ function _b64urlEncode(buf) { return bCrypto.toBase64Url(buf); }
245
245
 
246
246
  function _b64urlDecode(s) {
247
247
  if (typeof s !== "string") throw new OAuthError("auth-oauth/bad-base64", "expected base64url string");
248
- return bCrypto.fromBase64Url(s);
248
+ try { return bCrypto.fromBase64Url(s); }
249
+ catch (_e) { throw new OAuthError("auth-oauth/bad-base64", "segment is not valid base64url"); }
249
250
  }
250
251
 
251
252
  function _generateRandomToken(bytes) {
@@ -274,11 +275,22 @@ function _validateUrl(url, allowHttp, label) {
274
275
  var isLocalhostHttp = false;
275
276
  try {
276
277
  var parsed = new URL(url); // allow:raw-new-url — RFC 9700 §4.1.1 localhost-exception lookup; safeUrl re-validates below for non-localhost paths
278
+ // Strip trailing root-zone dot before the localhost compare.
279
+ // RFC 1034 §3.1 — `localhost.` resolves identically to `localhost`;
280
+ // without the strip, an attacker who registers `evil.com` as a
281
+ // public OAuth issuer and supplies `http://localhost./...` (with
282
+ // a trailing dot) slips past the equality check on a name that
283
+ // some DNS configurations resolve to a different target than the
284
+ // operator expects.
285
+ var rawHost = parsed.hostname || "";
286
+ while (rawHost.length > 0 && rawHost.charAt(rawHost.length - 1) === ".") {
287
+ rawHost = rawHost.slice(0, -1);
288
+ }
277
289
  if (parsed.protocol === "http:" &&
278
- (parsed.hostname === "localhost" ||
279
- parsed.hostname === "127.0.0.1" ||
280
- parsed.hostname === "[::1]" ||
281
- parsed.hostname === "::1")) {
290
+ (rawHost === "localhost" ||
291
+ rawHost === "127.0.0.1" ||
292
+ rawHost === "[::1]" ||
293
+ rawHost === "::1")) {
282
294
  isLocalhostHttp = true;
283
295
  }
284
296
  } catch (_e) { /* malformed; let safeUrl surface the canonical error below */ }
@@ -69,7 +69,13 @@ var MAX_LIST_BYTES = C.BYTES.mib(1);
69
69
 
70
70
  function _b64url(buf) { return bCrypto.toBase64Url(buf); }
71
71
 
72
- function _fromB64url(s) { return bCrypto.fromBase64Url(s); }
72
+ function _fromB64url(s) {
73
+ try { return bCrypto.fromBase64Url(s); }
74
+ catch (_e) {
75
+ throw new StatusListError("status-list/bad-base64",
76
+ "status-list segment is not valid base64url");
77
+ }
78
+ }
73
79
 
74
80
  function _validateBits(bits) {
75
81
  if (!SUPPORTED_BIT_SIZES[bits]) {
@@ -0,0 +1,187 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.crypto.hpke.pq
4
+ * @nav Crypto
5
+ * @title HPKE-PQ (experimental)
6
+ * @slug crypto-hpke-pq
7
+ *
8
+ * @intro
9
+ * Post-quantum HPKE variants under explicit opt-in. The IETF HPKE-WG
10
+ * has two active drafts proposing ML-KEM as a KEM for RFC 9180:
11
+ *
12
+ * - **`b.crypto.hpke.pq.connolly`** — draft-connolly-cfrg-hpke-mlkem-04
13
+ * (individual draft; carries codepoint allocations today).
14
+ * - **`b.crypto.hpke.pq.wg`** — draft-ietf-hpke-pq-03 (WG-adopted; the
15
+ * more authoritative track but codepoints may still move before
16
+ * IANA registration).
17
+ *
18
+ * The framework ships BOTH behind opt-in namespaces rather than
19
+ * picking a single draft prematurely. Each wrapper binds a draft-
20
+ * distinguishing label into the RFC 9180 §5.1 `info` parameter so
21
+ * an envelope sealed under one draft CANNOT be opened by the other
22
+ * — the cross-draft substitution attack the IANA codepoint
23
+ * normally prevents is enforced by the info-label binding.
24
+ *
25
+ * Both wrappers compose the existing `b.crypto.hpke.seal` / `.open`
26
+ * path (ML-KEM-1024 KEM + HKDF-SHA3-512 KDF + ChaCha20-Poly1305 AEAD
27
+ * per framework PQC-first policy). Operators wanting to migrate to
28
+ * the final IANA-registered codepoints (when they appear) call the
29
+ * stable `b.crypto.hpke.seal` directly — the experimental wrappers
30
+ * exist for operators integrating against systems that speak one of
31
+ * the active drafts today.
32
+ *
33
+ * @card
34
+ * Both HPKE-PQ drafts (connolly + ietf-hpke-pq) behind opt-in namespaces. Cross-draft substitution refused via info-label binding.
35
+ */
36
+
37
+ var hpke = require("./crypto-hpke");
38
+
39
+ // Draft-distinguishing labels. These prepend the operator's `info`
40
+ // parameter so the RFC 9180 §5.1 suite_id binding catches any cross-
41
+ // draft substitution attempt. The label is part of the AEAD AAD by
42
+ // construction — an envelope sealed under `connolly-04` cannot be
43
+ // opened by `wg-03` because the derived AEAD key diverges.
44
+ var CONNOLLY_LABEL = "draft-connolly-cfrg-hpke-mlkem-04";
45
+ var WG_LABEL = "draft-ietf-hpke-pq-03";
46
+
47
+ function _prependLabel(label, info) {
48
+ if (info === undefined || info === null) return label;
49
+ var infoBytes = Buffer.isBuffer(info) ? info : Buffer.from(String(info), "utf8");
50
+ // Treat empty info the same as omitted — RFC 9180's `info`
51
+ // parameter is bytes-or-absent; an empty string and `undefined`
52
+ // produce the same key schedule under the underlying HPKE. The
53
+ // wrapper MUST preserve that equivalence so a seal({...}) (no
54
+ // info) round-trips with an open({ info: "" }) (and vice versa)
55
+ // without false AEAD-tag failures.
56
+ if (infoBytes.length === 0) return label;
57
+ return Buffer.concat([Buffer.from(label + "/", "utf8"), infoBytes]);
58
+ }
59
+
60
+ function _wrappedSeal(label, opts) {
61
+ opts = opts || {};
62
+ var bound = Object.assign({}, opts, { info: _prependLabel(label, opts.info) });
63
+ return hpke.seal(bound);
64
+ }
65
+
66
+ function _wrappedOpen(label, opts) {
67
+ opts = opts || {};
68
+ var bound = Object.assign({}, opts, { info: _prependLabel(label, opts.info) });
69
+ return hpke.open(bound);
70
+ }
71
+
72
+ /**
73
+ * @primitive b.crypto.hpke.pq.connolly.seal
74
+ * @signature b.crypto.hpke.pq.connolly.seal(opts)
75
+ * @since 0.10.10
76
+ * @status experimental
77
+ * @related b.crypto.hpke.pq.wg.seal, b.crypto.hpke.pq.connolly.open
78
+ *
79
+ * Seal a payload under draft-connolly-cfrg-hpke-mlkem-04 codepoints.
80
+ * Returns `{ enc, ciphertext }`; the framework's existing
81
+ * `b.crypto.hpke.seal` semantics apply (ML-KEM-1024 + HKDF-SHA3-512 +
82
+ * ChaCha20-Poly1305 per project policy). Opens ONLY via
83
+ * `b.crypto.hpke.pq.connolly.open` — cross-draft substitution into
84
+ * `b.crypto.hpke.pq.wg.open` refuses by construction.
85
+ *
86
+ * @opts
87
+ * recipientPubKey: string, // ML-KEM-1024 PEM
88
+ * plaintext: Buffer|string,
89
+ * info: Buffer|string, // application context
90
+ * aad: Buffer|string, // additional authenticated data
91
+ *
92
+ * @example
93
+ * var pair = b.crypto.hpke.generateKeyPair();
94
+ * var sealed = b.crypto.hpke.pq.connolly.seal({
95
+ * recipientPubKey: pair.publicKey,
96
+ * plaintext: "hello",
97
+ * info: "app/topic",
98
+ * });
99
+ */
100
+ function connollySeal(opts) { return _wrappedSeal(CONNOLLY_LABEL, opts); }
101
+
102
+ /**
103
+ * @primitive b.crypto.hpke.pq.connolly.open
104
+ * @signature b.crypto.hpke.pq.connolly.open(opts)
105
+ * @since 0.10.10
106
+ * @status experimental
107
+ * @related b.crypto.hpke.pq.connolly.seal
108
+ *
109
+ * Open a draft-connolly-cfrg-hpke-mlkem-04 envelope produced by
110
+ * `connolly.seal`. Refuses envelopes sealed under `wg.seal` (the
111
+ * info-label binding catches cross-draft substitution).
112
+ *
113
+ * @opts
114
+ * privateKey: string, // ML-KEM-1024 PEM
115
+ * enc: Buffer,
116
+ * ciphertext: Buffer,
117
+ * info: Buffer|string,
118
+ * aad: Buffer|string,
119
+ *
120
+ * @example
121
+ * var pt = b.crypto.hpke.pq.connolly.open({
122
+ * privateKey: pair.privateKey, enc: sealed.enc,
123
+ * ciphertext: sealed.ciphertext, info: "app/topic",
124
+ * });
125
+ */
126
+ function connollyOpen(opts) { return _wrappedOpen(CONNOLLY_LABEL, opts); }
127
+
128
+ /**
129
+ * @primitive b.crypto.hpke.pq.wg.seal
130
+ * @signature b.crypto.hpke.pq.wg.seal(opts)
131
+ * @since 0.10.10
132
+ * @status experimental
133
+ * @related b.crypto.hpke.pq.connolly.seal, b.crypto.hpke.pq.wg.open
134
+ *
135
+ * Seal under draft-ietf-hpke-pq-03 codepoints (the WG-adopted PQ-HPKE
136
+ * draft). Otherwise identical contract to `b.crypto.hpke.pq.connolly.seal`.
137
+ *
138
+ * @opts
139
+ * recipientPubKey: string, // ML-KEM-1024 PEM
140
+ * plaintext: Buffer|string,
141
+ * info: Buffer|string,
142
+ * aad: Buffer|string,
143
+ *
144
+ * @example
145
+ * var sealed = b.crypto.hpke.pq.wg.seal({
146
+ * recipientPubKey: pair.publicKey,
147
+ * plaintext: "hello",
148
+ * });
149
+ */
150
+ function wgSeal(opts) { return _wrappedSeal(WG_LABEL, opts); }
151
+
152
+ /**
153
+ * @primitive b.crypto.hpke.pq.wg.open
154
+ * @signature b.crypto.hpke.pq.wg.open(opts)
155
+ * @since 0.10.10
156
+ * @status experimental
157
+ * @related b.crypto.hpke.pq.wg.seal
158
+ *
159
+ * Open a draft-ietf-hpke-pq-03 envelope produced by `wg.seal`.
160
+ *
161
+ * @opts
162
+ * privateKey: string,
163
+ * enc: Buffer,
164
+ * ciphertext: Buffer,
165
+ * info: Buffer|string,
166
+ * aad: Buffer|string,
167
+ *
168
+ * @example
169
+ * var pt = b.crypto.hpke.pq.wg.open({
170
+ * privateKey: pair.privateKey, enc: sealed.enc,
171
+ * ciphertext: sealed.ciphertext,
172
+ * });
173
+ */
174
+ function wgOpen(opts) { return _wrappedOpen(WG_LABEL, opts); }
175
+
176
+ module.exports = {
177
+ connolly: {
178
+ seal: connollySeal,
179
+ open: connollyOpen,
180
+ label: CONNOLLY_LABEL,
181
+ },
182
+ wg: {
183
+ seal: wgSeal,
184
+ open: wgOpen,
185
+ label: WG_LABEL,
186
+ },
187
+ };
package/lib/crypto.js CHANGED
@@ -1218,7 +1218,13 @@ function decrypt(ciphertext, privateKeys, opts) {
1218
1218
  if (packed[0] !== C.ENVELOPE_MAGIC) {
1219
1219
  throw new Error("Invalid envelope: unsupported format");
1220
1220
  }
1221
- return decryptEnvelope(packed, privateKeys);
1221
+ // `opts.raw: true` returns the decrypted Buffer rather than the
1222
+ // utf8-decoded string. Callers carrying binary plaintext (JWE
1223
+ // experimental wrapper, future signed-blob carriers) opt in to keep
1224
+ // arbitrary bytes lossless; default stays utf8-string for backwards
1225
+ // compatibility with the existing API contract.
1226
+ return decryptEnvelope(packed, privateKeys,
1227
+ opts && opts.raw === true ? { raw: true } : undefined);
1222
1228
  }
1223
1229
 
1224
1230
  function decryptEnvelope(packed, privateKeys, internalOpts) {
@@ -1283,9 +1289,11 @@ function decryptEnvelope(packed, privateKeys, internalOpts) {
1283
1289
  // dispatched on. A tampered header (algorithm-substitution attack)
1284
1290
  // surfaces here as a Poly1305 tag verification failure.
1285
1291
  var headerAad = packed.subarray(0, 4); // allow:raw-byte-literal — envelope-header byte slice
1286
- return Buffer.from(
1292
+ var plainBuf = Buffer.from(
1287
1293
  xchacha20poly1305(symmetricKey, nonce, headerAad).decrypt(packed.subarray(pos))
1288
- ).toString("utf8");
1294
+ );
1295
+ if (internalOpts && internalOpts.raw === true) return plainBuf;
1296
+ return plainBuf.toString("utf8");
1289
1297
  }
1290
1298
 
1291
1299
  // ---- Symmetric buffer encrypt/decrypt (for storage) ----
@@ -230,7 +230,12 @@ function validate(headerValue, opts) {
230
230
  // `lan` (IETF draft-chapin-rfc2606bis). All three are non-routable
231
231
  // single-network labels and the FQDN floor doesn't apply.
232
232
  var lastLabel = parts[parts.length - 1].toLowerCase();
233
- var isLocalScopeTld = lastLabel === "localhost" || lastLabel === "local" || lastLabel === "lan";
233
+ // List-Id (RFC 2919) is a dot-atom-text token NOT a wire-format
234
+ // hostname; the value goes through dot-atom-text validation upstream
235
+ // so a `localhost.` label-suffix is already refused at the segment-
236
+ // shape level (an empty trailing segment fails the dot-atom-text
237
+ // grammar). No trailing-dot bypass surface here.
238
+ var isLocalScopeTld = lastLabel === "localhost" || lastLabel === "local" || lastLabel === "lan"; // allow:hostname-compare-trailing-dot — see comment above; List-Id parts already split on `.` so trailing-dot label is empty and refused upstream
234
239
  if (caps.requireFqdn) {
235
240
  if (parts.length < 3 && !isLocalScopeTld) { // allow:raw-byte-literal — FQDN requires ≥ 3 labels for non-local-scope namespace
236
241
  return _refuse("list-id has < 3 labels for non-local-scope namespace (FQDN required under '" +