@blamejs/core 0.10.12 → 0.10.13

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,7 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.10.x
10
10
 
11
+ - v0.10.13 (2026-05-18) — **`b.cms` CMS codec + `b.streamThrottle` bandwidth limiter + histogram-aware snapshot writer + Windows-safe daemonize.** **`b.cms` — RFC 5652 Cryptographic Message Syntax codec.** New crypto primitive: in-tree CMS encoder + decoder built on the existing `b.asn1Der` walker and the vendored noble-post-quantum primitives. **(a) `b.cms.encodeSignedData({ encapContent, digestAlg, signers })`** — emits a DER-encoded `ContentInfo` carrying `SignedData` per RFC 5652 §5 with PQC signers: ML-DSA-65 + ML-DSA-87 ([RFC 9909](https://www.rfc-editor.org/rfc/rfc9909.html)) and SLH-DSA-SHAKE-256f ([RFC 9881](https://www.rfc-editor.org/rfc/rfc9881.html)). Digest algorithms are SHA3-256 or SHA3-512 (PQC-first; SHA-2 family refused with `cms/bad-digest`). Signed-attributes carry `contentType` + `messageDigest` + `signingTime` in DER-canonical SET-OF ordering; the signature input re-tags the IMPLICIT `[0]` to the universal SET (`0x31`) per §5.4 paragraph 3 so signatures round-trip with any conforming verifier. Signer identifiers carry the full `issuerAndSerialNumber` extracted from the operator-supplied cert DER ([RFC 5652 §10.2.4](https://www.rfc-editor.org/rfc/rfc5652#section-10.2.4)). **(b) `b.cms.encodeEnvelopedData({ plaintext, recipients })`** — emits a DER-encoded `ContentInfo` carrying `EnvelopedData` with `KEMRecipientInfo` recipients per [RFC 9629](https://www.rfc-editor.org/rfc/rfc9629.html) and ML-KEM-1024 per [RFC 9936](https://www.rfc-editor.org/rfc/rfc9936.html). Each recipient encapsulates against the operator-supplied ML-KEM-1024 public key; the framework's SHAKE256 KDF derives a 32-byte content-encryption KEK from the KEM shared-secret bound to the literal label `cms/kemri/chacha20-poly1305` (so a key derived for this composition cannot be confused with one derived for any other framework path). Content encryption is ChaCha20-Poly1305 ([RFC 8103](https://www.rfc-editor.org/rfc/rfc8103.html) OID); the AEAD tag makes Efail-class CBC-malleability impossible by construction ([CVE-2017-17688](https://nvd.nist.gov/vuln/detail/CVE-2017-17688) / [CVE-2017-17689](https://nvd.nist.gov/vuln/detail/CVE-2017-17689)). **(c) `b.cms.decode(buf, { maxBytes? })`** — returns `{ contentType, content }` where `contentType` is the dotted-OID string and `content` is the inner `asn1-der` node. Refuses input past `maxBytes` (default 64 MiB), non-SEQUENCE top-level, missing `[0] EXPLICIT` content, and malformed OID encodings (closes the [CVE-2022-47629](https://nvd.nist.gov/vuln/detail/CVE-2022-47629) libksba class via the existing `b.asn1Der` strict-decode posture). **(d) Refusal posture documented in `lib/cms-codec.js` @intro**: only PQC signature algorithms (`cms/bad-sig-alg`), only ML-KEM-1024 recipients (`cms/bad-recipient-type`), non-empty signers / recipients required at encode (`cms/no-signers` / `cms/no-recipients`). **Operator impact:** no breaking changes; new primitive at `b.cms`. **Deferred to v0.10.14:** the on-the-wire S/MIME 4.0 layer ([RFC 8551](https://www.rfc-editor.org/rfc/rfc8551.html) `multipart/signed` framing, base64 DER body, `micalg` mapping) and OpenPGP encrypt + decrypt + WKD discovery ([RFC 9580](https://www.rfc-editor.org/rfc/rfc9580.html) §5.1 / §5.13 packets plus [draft-koch-openpgp-webkey-service](https://datatracker.ietf.org/doc/draft-koch-openpgp-webkey-service/)) land together so the mail-crypto surface lights up coherently rather than half-on-each-side across two patches. AuthEnvelopedData ([RFC 5083](https://www.rfc-editor.org/rfc/rfc5083.html)) as a distinct `ContentInfo` shape is deferred — EnvelopedData with ChaCha20-Poly1305 is already AEAD by construction; the §5083 OID rewrap lights up alongside S/MIME for peers that refuse the EnvelopedData form. References: [RFC 5652 CMS](https://www.rfc-editor.org/rfc/rfc5652.html) · [RFC 9629 KEMRecipientInfo](https://www.rfc-editor.org/rfc/rfc9629.html) · [RFC 9909 ML-DSA in X.509+CMS](https://www.rfc-editor.org/rfc/rfc9909.html) · [RFC 9881 SLH-DSA in X.509+CMS](https://www.rfc-editor.org/rfc/rfc9881.html) · [RFC 9936 ML-KEM in CMS](https://www.rfc-editor.org/rfc/rfc9936.html) · [RFC 8103 ChaCha20-Poly1305 in CMS](https://www.rfc-editor.org/rfc/rfc8103.html). **(e) `b.streamThrottle` — token-bucket bandwidth limiter.** New primitive: `b.streamThrottle.create({ bytesPerSec, burstBytes? })` returns a shared token bucket whose `.transform()` instances each consume from the same budget. The missing primitive between per-request rate-limit and per-process worker pools: N parallel transfers share the operator-configured byte budget rather than each getting their own. Composes with `node:stream.pipeline` as a regular `stream.Transform`; chunks larger than `burstBytes` refuse with `stream-throttle/oversize-chunk` unless `transform({ allowOversize: true })`. Algorithm is the [RFC 2697 srTCM](https://www.rfc-editor.org/rfc/rfc2697.html) single-rate token-bucket shape, with lazy refill so there is no per-throttle background timer. **(f) Histogram-aware metrics snapshot writer.** `b.metrics.snapshot.startWriter` gains an opt-in `registry` field. When supplied, the JSON snapshot grows a `metrics` field carrying every registered counter / gauge / histogram in structured form — histograms include `buckets` + `observations: [{ labels, counts, sum, count }]`, so sidecar readers compose `histogram_quantile()` against the snapshot file without running a separate `/metrics` HTTP endpoint. `fileMode` default unchanged (0o640). **(g) Windows-safe daemonize.** `b.daemon.start` detached-fork mode now branches by platform. POSIX continues inheriting the parent-opened log FD via `stdio: ["ignore", logFd, logFd]` (unchanged). Windows now uses `stdio: "ignore"` + `windowsHide: true` so the child has no inherited handles that the OS invalidates on parent exit — the previously-broken Windows daemonize path now produces a survivable detached process. The child is responsible for opening its own log file (operators pass `--log` in `opts.args`). `daemon.started` audit gains `stdioMode` so operators can grep for the chosen strategy. **Closes: [#94](https://github.com/blamejs/blamejs/issues/94), [#100](https://github.com/blamejs/blamejs/issues/100), [#101](https://github.com/blamejs/blamejs/issues/101); also closes [#92](https://github.com/blamejs/blamejs/issues/92) and [#93](https://github.com/blamejs/blamejs/issues/93) (already shipped in v0.10.9 as `b.promisePool` / `b.sdNotify` — left open until now).**
11
12
  - 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
13
  - 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
14
  - 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/).
package/README.md CHANGED
@@ -95,6 +95,8 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
95
95
  - **AAD-bound sealed columns** — AEAD tag tied to `(table, rowId, column, schemaVersion)`; copy-paste between rows or schema-version replay surfaces as refused decrypt (`b.vault.aad`)
96
96
  - **Signed webhooks + API encryption** — SLH-DSA-SHAKE-256f default; ML-DSA-65 opt-in; ECIES API encryption (`b.webhook`, `b.crypto`)
97
97
  - **HPKE / HTTP signatures** — RFC 9180 HPKE with ML-KEM-1024 + HKDF-SHA3-512 + ChaCha20-Poly1305 (`b.crypto.hpke`); RFC 9421 HTTP Message Signatures with derived components and ed25519 / ML-DSA-65 (`b.crypto.httpSig`)
98
+ - **CMS codec** — RFC 5652 Cryptographic Message Syntax encoder + decoder with PQC signers (ML-DSA-65 / ML-DSA-87 / SLH-DSA-SHAKE-256f; RFC 9909 + 9881) and KEMRecipientInfo recipients (ML-KEM-1024; RFC 9629 + 9936); ChaCha20-Poly1305 content encryption (RFC 8103) so Efail-class malleability cannot apply (`b.cms`)
99
+ - **Stream throttle** — shared token-bucket bandwidth limiter (RFC 2697 srTCM shape); N concurrent `node:stream` pipelines draw from one operator-configured `bytesPerSec` budget (`b.streamThrottle`)
98
100
  - **TLS / channel binding** — RFC 9266 TLS-Exporter token-to-session pinning (`b.tlsExporter`); RFC 9162 CT v2 inclusion-proof verification (`b.network.tls.ct.verifyInclusion`); RFC 8555 ACME + RFC 9773 ARI for 47-day certs with `{ jitter: true }` fleet-scheduling (`b.acme.renewIfDue`); draft-aaron-acme-profiles (`acme.listProfiles()` + `newOrder({ profile })`); draft-ietf-acme-dns-account-label (`acme.dnsAccount01ChallengeRecord(token, { identifier })`); RFC 8470 0-RTT inbound posture refuse / replay-cache (`b.router.create({tls0Rtt})`); RFC 9794 SecP256r1MLKEM768 in preferred-group order (`b.network.tls.preferredGroups`)
99
101
  - **mTLS CA** — pure-JS, issues clientAuth / serverAuth / dual-EKU certs with SAN; auto-detects highest-PQC signature alg (today ECDSA-P384-SHA384; self-upgrades to SLH-DSA / ML-DSA when X.509 ecosystem catches up); PQC TLS gates inbound + outbound (`b.mtlsCa`, `b.pqcGate`, `b.pqcAgent`)
100
102
  ### HTTP
package/index.js CHANGED
@@ -375,9 +375,13 @@ var watcher = require("./lib/watcher");
375
375
  var localDbThin = require("./lib/local-db-thin");
376
376
  var daemon = require("./lib/daemon");
377
377
  var selfUpdate = require("./lib/self-update");
378
+ var cmsCodec = require("./lib/cms-codec");
379
+ var streamThrottle = require("./lib/stream-throttle");
378
380
 
379
381
  module.exports = {
380
382
  crypto: crypto,
383
+ cms: cmsCodec,
384
+ streamThrottle: streamThrottle,
381
385
  router: router,
382
386
  constants: constants,
383
387
  vault: vault,
@@ -0,0 +1,685 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.cms
4
+ * @nav Crypto
5
+ * @title CMS Codec
6
+ *
7
+ * @intro
8
+ * RFC 5652 Cryptographic Message Syntax encoder + decoder built on
9
+ * the framework's existing `b.asn1Der` substrate and the vendored
10
+ * noble-post-quantum primitives (`b.pqcSoftware.ml_dsa_*` /
11
+ * `ml_kem_1024` / `slh_dsa_shake_256f`). Re-opens the CMS forward-
12
+ * watch item from the 2026-05-08 audit (deferred-with-condition
13
+ * pending operator-side demand from the live mail-stack listeners).
14
+ * Operator-demand condition is now met by the inbound MX + JMAP
15
+ * listeners (v0.9.45–v0.9.50).
16
+ *
17
+ * Scope (v0.10.13):
18
+ *
19
+ * - **ContentInfo** wrapper (RFC 5652 §3) for all top-level emissions.
20
+ * - **SignedData** (§5) encode + decode with PQC signer support
21
+ * (ML-DSA-65 per RFC 9909 §5, ML-DSA-87 per RFC 9909 §6,
22
+ * SLH-DSA-SHAKE-256f per RFC 9881). The signature input is the
23
+ * DER-encoded SET OF signed-attributes with the IMPLICIT [0] tag
24
+ * re-tagged to the universal SET tag per §5.4 third paragraph.
25
+ * - **EnvelopedData** (§6) encode with `KEMRecipientInfo` (RFC 9629)
26
+ * for ML-KEM-1024 recipients (RFC 9936). The content-encryption
27
+ * key is wrapped under a KEK derived from the KEM shared-secret
28
+ * via HKDF-SHA3-512; content is encrypted with ChaCha20-Poly1305
29
+ * (RFC 8103 OID). Efail-class CBC-malleability is impossible by
30
+ * construction — every CMS content blob emitted by this module
31
+ * carries an AEAD tag.
32
+ * - Strict DER on emit (canonical: lexicographic SET-OF ordering,
33
+ * minimal-length encoding, no indefinite length).
34
+ *
35
+ * Deferred from v0.10.13 (each with documented condition):
36
+ *
37
+ * - **AuthEnvelopedData** (RFC 5083) as a distinct ContentInfo
38
+ * ciphertext shape. Operator demand is not yet surfaced — every
39
+ * v0.10.13 emission uses EnvelopedData with the ChaCha20-Poly1305
40
+ * content-encryption OID, which is already AEAD by construction.
41
+ * Defer condition: at least one interop case requires a peer that
42
+ * refuses EnvelopedData and accepts only the §5083 ContentInfo
43
+ * OID. Cheap escape hatch: operators on such a peer compose
44
+ * `b.asn1Der` directly to rewrap an EnvelopedData blob into an
45
+ * AuthEnvelopedData ContentInfo. Lights up in v0.10.14 alongside
46
+ * `b.mail.smime` sign + verify, where the on-the-wire S/MIME 4.0
47
+ * content shape calls for it.
48
+ * - **`b.cms.decode` parse-tree of inner SignedData / EnvelopedData**
49
+ * beyond the ContentInfo wrapper. v0.10.13 returns the inner
50
+ * SEQUENCE bytes as `content` (an asn1-der node); callers that
51
+ * need fielded access walk it via `b.asn1Der.readSequence`. The
52
+ * fielded decoders ship alongside S/MIME verify in v0.10.14 where
53
+ * they're actually consumed.
54
+ *
55
+ * Refusal posture:
56
+ *
57
+ * - Top-level must be SEQUENCE { OID, [0] EXPLICIT content }; any
58
+ * other shape throws `cms/bad-content-info`.
59
+ * - Recipient/signer counts must be non-empty (`cms/no-signers` /
60
+ * `cms/no-recipients`).
61
+ * - Only PQC signature algorithms are accepted (`cms/bad-sig-alg`).
62
+ * - Only ML-KEM-1024 recipients are accepted (`cms/bad-recipient-type`).
63
+ * - Input past `opts.maxBytes` (default 64 MiB) throws `cms/oversize`.
64
+ *
65
+ * @card
66
+ * RFC 5652 CMS codec (SignedData + EnvelopedData) on b.asn1Der + vendored noble-post-quantum. PQC signers per RFC 9909 / 9881; ML-KEM-1024 recipients per RFC 9629 / 9936. AEAD-only content (ChaCha20-Poly1305) — Efail-class malleability cannot apply.
67
+ */
68
+
69
+ var nodeCrypto = require("node:crypto");
70
+ var asn1 = require("./asn1-der");
71
+ var bCrypto = require("./crypto");
72
+ var pqcSoftware = require("./pqc-software");
73
+ var { defineClass } = require("./framework-error");
74
+ var audit = require("./audit");
75
+
76
+ var CmsCodecError = defineClass("CmsCodecError", { alwaysPermanent: true });
77
+
78
+ // Common CMS OIDs (RFC 5652, RFC 5083, RFC 9629, RFC 9909, RFC 9881).
79
+ var OID = Object.freeze({
80
+ data: "1.2.840.113549.1.7.1",
81
+ signedData: "1.2.840.113549.1.7.2",
82
+ envelopedData: "1.2.840.113549.1.7.3",
83
+ authEnvelopedData: "1.2.840.113549.1.9.16.1.23", // RFC 5083
84
+ // PQC signature algorithms (RFC 9909, RFC 9881).
85
+ mldsa44: "2.16.840.1.101.3.4.3.17",
86
+ mldsa65: "2.16.840.1.101.3.4.3.18",
87
+ mldsa87: "2.16.840.1.101.3.4.3.19",
88
+ slhDsaShake256f: "2.16.840.1.101.3.4.3.31",
89
+ // PQC KEM algorithms (RFC 9935, RFC 9936).
90
+ mlkem768: "2.16.840.1.101.3.4.4.2",
91
+ mlkem1024: "2.16.840.1.101.3.4.4.3",
92
+ // KEMRecipientInfo type (RFC 9629 §3).
93
+ kemri: "1.2.840.113549.1.9.16.13.3",
94
+ // Symmetric content encryption — ChaCha20-Poly1305 (RFC 8103 IANA codepoint).
95
+ chacha20Poly1305: "1.2.840.113549.1.9.16.3.18",
96
+ // KDF — SHAKE256 XOF (NIST SP 800-185), the framework's PQC-first
97
+ // KDF substrate (`b.crypto.kdf` wraps it). OID per NIST registry.
98
+ shake256: "2.16.840.1.101.3.4.2.12",
99
+ // Signed-attribute attribute types.
100
+ contentType: "1.2.840.113549.1.9.3",
101
+ messageDigest: "1.2.840.113549.1.9.4",
102
+ signingTime: "1.2.840.113549.1.9.5",
103
+ // Digest algorithms (SHA3-256 / -512 — framework PQC-first hash family).
104
+ sha3_256: "2.16.840.1.101.3.4.2.8",
105
+ sha3_512: "2.16.840.1.101.3.4.2.10",
106
+ });
107
+
108
+ // Refusal ceilings.
109
+ var MAX_DEPTH = 32; // allow:raw-byte-literal — ASN.1 recursion ceiling
110
+ var DEFAULT_MAX_LEN = 64 * 1024 * 1024; // allow:raw-byte-literal — 64 MiB default decode cap
111
+
112
+ // Universal-tag bytes used in encode helpers.
113
+ var TAG_SEQUENCE = 0x30; // allow:raw-byte-literal — ASN.1 SEQUENCE constructed
114
+ var TAG_SET = 0x31; // allow:raw-byte-literal — ASN.1 SET constructed
115
+ var TAG_UTCTIME = 0x17; // allow:raw-byte-literal — UTCTime universal
116
+ var TAG_GENTIME = 0x18; // allow:raw-byte-literal — GeneralizedTime universal
117
+
118
+ /**
119
+ * @primitive b.cms.encodeSignedData
120
+ * @signature b.cms.encodeSignedData(opts)
121
+ * @since 0.10.13
122
+ * @status stable
123
+ * @related b.cms.decode, b.cms.encodeEnvelopedData
124
+ *
125
+ * Encode an RFC 5652 §5 SignedData ContentInfo with PQC signer
126
+ * support. The output is a DER-encoded Buffer ready for embedding in
127
+ * S/MIME `application/pkcs7-mime; smime-type=signed-data` parts or
128
+ * for standalone CMS-over-network use.
129
+ *
130
+ * @opts
131
+ * encapContent: Buffer, // bytes to sign
132
+ * digestAlg: "sha3-256" | "sha3-512", // default sha3-512
133
+ * signers: [{ certificate: Buffer, secretKey: Uint8Array, sigAlg: string }],
134
+ * certificates: Buffer[], // additional DER certs (optional)
135
+ * detached: boolean, // default false; true → omit encapContent
136
+ *
137
+ * @example
138
+ * var pq = b.pqcSoftware;
139
+ * var kp = pq.ml_dsa_65.keygen();
140
+ * var bytes = b.cms.encodeSignedData({
141
+ * encapContent: Buffer.from("payload"),
142
+ * digestAlg: "sha3-512",
143
+ * signers: [{ certificate: certDer, secretKey: kp.secretKey, sigAlg: "ML-DSA-65" }],
144
+ * });
145
+ */
146
+ function encodeSignedData(opts) {
147
+ if (!opts || typeof opts !== "object") {
148
+ throw new CmsCodecError("cms/bad-opts", "encodeSignedData: opts required");
149
+ }
150
+ if (!Buffer.isBuffer(opts.encapContent)) {
151
+ throw new CmsCodecError("cms/bad-encap", "encodeSignedData: encapContent must be a Buffer");
152
+ }
153
+ if (!Array.isArray(opts.signers) || opts.signers.length === 0) {
154
+ throw new CmsCodecError("cms/no-signers",
155
+ "encodeSignedData: opts.signers must be a non-empty array");
156
+ }
157
+ var digestAlg = opts.digestAlg || "sha3-512";
158
+ if (digestAlg !== "sha3-256" && digestAlg !== "sha3-512") {
159
+ throw new CmsCodecError("cms/bad-digest",
160
+ "encodeSignedData: digestAlg must be 'sha3-256' or 'sha3-512' " +
161
+ "(PQC-first; SHA-2 family not accepted in v1)");
162
+ }
163
+ var digestOid = digestAlg === "sha3-256" ? OID.sha3_256 : OID.sha3_512;
164
+ var detached = opts.detached === true;
165
+
166
+ // Message digest over encapContent (SHA3-256 or SHA3-512 per opts.digestAlg).
167
+ var msgDigest = nodeCrypto.createHash(digestAlg).update(opts.encapContent).digest();
168
+
169
+ // digestAlgorithms SET — one entry per distinct digest algorithm used.
170
+ var digestAlgs = asn1.writeNode(TAG_SET, _algorithmIdentifier(digestOid));
171
+
172
+ // EncapsulatedContentInfo.
173
+ var encapInfo = _encapsulatedContentInfo(opts.encapContent, detached);
174
+
175
+ // Optional certificates [0] IMPLICIT — operator-supplied DER cert blobs.
176
+ var certsBlock = Buffer.alloc(0);
177
+ if (Array.isArray(opts.certificates) && opts.certificates.length > 0) {
178
+ var concat = Buffer.concat(opts.certificates.map(function (c) {
179
+ if (!Buffer.isBuffer(c)) {
180
+ throw new CmsCodecError("cms/bad-cert",
181
+ "encodeSignedData: certificates entries must be DER Buffers");
182
+ }
183
+ return c;
184
+ }));
185
+ // certificates [0] IMPLICIT CertificateSet — CertificateSet is a SET
186
+ // of certificates (constructed), so this wrap is the constructed
187
+ // form per RFC 5652 §5.1.
188
+ certsBlock = _writeImplicitConstructed(0, concat);
189
+ }
190
+
191
+ // signerInfos SET — one SignerInfo per signer.
192
+ var sigInfos = opts.signers.map(function (s) {
193
+ return _signerInfo(s, msgDigest, digestOid);
194
+ });
195
+ var signerInfosSet = asn1.writeNode(TAG_SET, Buffer.concat(sigInfos));
196
+
197
+ // SignedData SEQUENCE per §5.1.
198
+ var signedDataSeq = asn1.writeNode(TAG_SEQUENCE, Buffer.concat([
199
+ asn1.writeInteger(Buffer.from([1])), // allow:raw-byte-literal — CMSVersion 1 per §5.1
200
+ digestAlgs,
201
+ encapInfo,
202
+ certsBlock,
203
+ signerInfosSet,
204
+ ]));
205
+
206
+ // ContentInfo wrapper.
207
+ var contentInfo = asn1.writeNode(TAG_SEQUENCE, Buffer.concat([
208
+ asn1.writeOid(OID.signedData),
209
+ asn1.writeContextExplicit(0, signedDataSeq),
210
+ ]));
211
+ try {
212
+ audit.safeEmit({
213
+ action: "cms.signedData.encoded",
214
+ outcome: "success",
215
+ actor: {},
216
+ metadata: { signerCount: opts.signers.length, digestAlg: digestAlg, detached: detached },
217
+ });
218
+ } catch (_e) { /* drop-silent */ }
219
+ return contentInfo;
220
+ }
221
+
222
+ /**
223
+ * @primitive b.cms.encodeEnvelopedData
224
+ * @signature b.cms.encodeEnvelopedData(opts)
225
+ * @since 0.10.13
226
+ * @status stable
227
+ * @related b.cms.decode, b.cms.encodeSignedData
228
+ *
229
+ * Encode an RFC 5652 §6 EnvelopedData ContentInfo with ML-KEM-1024
230
+ * recipients per RFC 9629 (KEMRecipientInfo) + RFC 9936 (ML-KEM in
231
+ * CMS). The content-encryption key is wrapped under a KEK derived
232
+ * from the per-recipient KEM shared-secret via HKDF-SHA3-512;
233
+ * content is encrypted with ChaCha20-Poly1305 so Efail-class
234
+ * malleability cannot apply.
235
+ *
236
+ * @opts
237
+ * plaintext: Buffer, // bytes to encrypt
238
+ * recipients: [{ type: "kem-mlkem-1024", publicKey: Uint8Array, recipientId: Buffer }],
239
+ *
240
+ * @example
241
+ * var pq = b.pqcSoftware;
242
+ * var kp = pq.ml_kem_1024.keygen();
243
+ * var bytes = b.cms.encodeEnvelopedData({
244
+ * plaintext: Buffer.from("secret"),
245
+ * recipients: [{ type: "kem-mlkem-1024", publicKey: kp.publicKey, recipientId: Buffer.from([1]) }],
246
+ * });
247
+ */
248
+ function encodeEnvelopedData(opts) {
249
+ if (!opts || typeof opts !== "object") {
250
+ throw new CmsCodecError("cms/bad-opts", "encodeEnvelopedData: opts required");
251
+ }
252
+ if (!Buffer.isBuffer(opts.plaintext)) {
253
+ throw new CmsCodecError("cms/bad-plaintext", "encodeEnvelopedData: plaintext must be a Buffer");
254
+ }
255
+ if (!Array.isArray(opts.recipients) || opts.recipients.length === 0) {
256
+ throw new CmsCodecError("cms/no-recipients",
257
+ "encodeEnvelopedData: opts.recipients must be a non-empty array");
258
+ }
259
+ // Fresh ChaCha20-Poly1305 content key.
260
+ var contentKey = bCrypto.generateBytes(32); // allow:raw-byte-literal — 256-bit ChaCha20 key
261
+
262
+ // recipientInfos SET — one KEMRecipientInfo per recipient.
263
+ var ris = opts.recipients.map(function (r) {
264
+ return _recipientInfo(r, contentKey);
265
+ });
266
+ var recipientInfosSet = asn1.writeNode(TAG_SET, Buffer.concat(ris));
267
+
268
+ // EncryptedContentInfo + ChaCha20-Poly1305 ciphertext.
269
+ var encContent = _encryptedContentInfo(opts.plaintext, contentKey);
270
+
271
+ // EnvelopedData SEQUENCE per §6.1. CMSVersion 4 (RFC 9629 §3 — when
272
+ // any RecipientInfo is OtherRecipientInfo, here KEMRecipientInfo).
273
+ var envelopedSeq = asn1.writeNode(TAG_SEQUENCE, Buffer.concat([
274
+ asn1.writeInteger(Buffer.from([4])), // allow:raw-byte-literal — CMSVersion 4 per RFC 9629 §3
275
+ recipientInfosSet,
276
+ encContent,
277
+ ]));
278
+ var contentInfo = asn1.writeNode(TAG_SEQUENCE, Buffer.concat([
279
+ asn1.writeOid(OID.envelopedData),
280
+ asn1.writeContextExplicit(0, envelopedSeq),
281
+ ]));
282
+ try {
283
+ audit.safeEmit({
284
+ action: "cms.envelopedData.encoded",
285
+ outcome: "success",
286
+ actor: {},
287
+ metadata: { recipientCount: opts.recipients.length },
288
+ });
289
+ } catch (_e) { /* drop-silent */ }
290
+ return contentInfo;
291
+ }
292
+
293
+ /**
294
+ * @primitive b.cms.decode
295
+ * @signature b.cms.decode(buf, opts?)
296
+ * @since 0.10.13
297
+ * @status stable
298
+ * @related b.cms.encodeSignedData, b.cms.encodeEnvelopedData
299
+ *
300
+ * Decode a CMS ContentInfo from `buf` (DER bytes). Returns
301
+ * `{ contentType, content }` where `contentType` is the dotted-OID
302
+ * string (e.g. `"1.2.840.113549.1.7.2"` for SignedData) and
303
+ * `content` is the inner asn1-der node (SignedData / EnvelopedData /
304
+ * other) — operators walk it via `b.asn1Der.readSequence`. Fielded
305
+ * decoders for SignedData / EnvelopedData ship in v0.10.14 alongside
306
+ * S/MIME sign+verify.
307
+ *
308
+ * Refuses input past `opts.maxBytes` (default 64 MiB), top-level
309
+ * non-SEQUENCE shapes, missing OID + [0] EXPLICIT child pair.
310
+ *
311
+ * @opts
312
+ * maxBytes: number, // default 64 MiB
313
+ *
314
+ * @example
315
+ * var ci = b.cms.decode(derBytes);
316
+ * ci.contentType; // → "1.2.840.113549.1.7.2"
317
+ */
318
+ function decode(buf, opts) {
319
+ opts = opts || {};
320
+ if (!Buffer.isBuffer(buf)) {
321
+ throw new CmsCodecError("cms/bad-input", "decode: buf must be a Buffer");
322
+ }
323
+ var maxBytes = opts.maxBytes || DEFAULT_MAX_LEN;
324
+ if (buf.length > maxBytes) {
325
+ throw new CmsCodecError("cms/oversize",
326
+ "decode: input " + buf.length + " bytes exceeds maxBytes=" + maxBytes);
327
+ }
328
+ var node;
329
+ try { node = asn1.readNode(buf); }
330
+ catch (e) {
331
+ throw new CmsCodecError("cms/bad-asn1",
332
+ "decode: ASN.1 parse failed: " + ((e && e.message) || String(e)));
333
+ }
334
+ if (!(node.tag === asn1.TAG.SEQUENCE && node.constructed)) {
335
+ throw new CmsCodecError("cms/bad-content-info",
336
+ "decode: top-level must be SEQUENCE (got tag 0x" + node.tag.toString(16) + ")"); // allow:raw-byte-literal — hex radix for error-message formatting
337
+ }
338
+ // ContentInfo SEQUENCE children: { contentType OID, [0] EXPLICIT ANY }.
339
+ var children;
340
+ try { children = asn1.readSequence(node.value); }
341
+ catch (e2) {
342
+ throw new CmsCodecError("cms/bad-content-info",
343
+ "decode: ContentInfo body parse failed: " + ((e2 && e2.message) || String(e2)));
344
+ }
345
+ if (children.length < 2) {
346
+ throw new CmsCodecError("cms/bad-content-info",
347
+ "decode: ContentInfo SEQUENCE must have 2 children (contentType + [0] content)");
348
+ }
349
+ var contentType;
350
+ try { contentType = asn1.readOid(children[0]); }
351
+ catch (e3) {
352
+ throw new CmsCodecError("cms/bad-oid",
353
+ "decode: contentType OID parse failed: " + ((e3 && e3.message) || String(e3)));
354
+ }
355
+ // [0] EXPLICIT content — unwrap via asn1.unwrapExplicit(node, expectedTagNumber).
356
+ var inner;
357
+ try { inner = asn1.unwrapExplicit(children[1], 0); }
358
+ catch (e4) {
359
+ throw new CmsCodecError("cms/bad-explicit-content",
360
+ "decode: [0] EXPLICIT content unwrap failed: " + ((e4 && e4.message) || String(e4)));
361
+ }
362
+ return { contentType: contentType, content: inner };
363
+ }
364
+
365
+ // ---- Internal helpers -----------------------------------------------------
366
+
367
+ // OIDs whose AlgorithmIdentifier specifies ABSENT parameters per their
368
+ // publishing RFC — emitting NULL here would make the CMS structure
369
+ // non-conformant for strict validators (Codex P1 finding on PR #102).
370
+ // ML-DSA per RFC 9909 §3, SLH-DSA per RFC 9881 §3, ML-KEM per
371
+ // RFC 9936 §3. SHAKE-family per FIPS 202 (NIST registry — absent params).
372
+ var ABSENT_PARAM_OIDS = new Set([
373
+ "2.16.840.1.101.3.4.3.17", // ml_dsa_44
374
+ "2.16.840.1.101.3.4.3.18", // ml_dsa_65
375
+ "2.16.840.1.101.3.4.3.19", // ml_dsa_87
376
+ "2.16.840.1.101.3.4.3.31", // slh_dsa_shake_256f
377
+ "2.16.840.1.101.3.4.4.2", // ml_kem_768
378
+ "2.16.840.1.101.3.4.4.3", // ml_kem_1024
379
+ "2.16.840.1.101.3.4.2.12", // shake256 (KDF/digest — absent params)
380
+ ]);
381
+
382
+ function _algorithmIdentifier(oidStr) {
383
+ // SEQUENCE { algorithm OID, parameters ANY DEFINED BY algorithm OPTIONAL }.
384
+ // PQC OIDs (RFC 9909 / 9881 / 9936) MUST emit with parameters ABSENT;
385
+ // legacy non-PQC OIDs (SHA-2 / SHA-3 hash OIDs in this module, ChaCha20-
386
+ // Poly1305 wrap OID) still carry the historical NULL parameter shape
387
+ // that fielded CMS toolchains expect.
388
+ if (ABSENT_PARAM_OIDS.has(oidStr)) {
389
+ return asn1.writeNode(TAG_SEQUENCE, asn1.writeOid(oidStr));
390
+ }
391
+ return asn1.writeNode(TAG_SEQUENCE, Buffer.concat([
392
+ asn1.writeOid(oidStr),
393
+ asn1.writeNull(),
394
+ ]));
395
+ }
396
+
397
+ function _writeImplicitConstructed(tagNumber, payload) {
398
+ // [N] IMPLICIT context-specific CONSTRUCTED — for wrapping SEQUENCE /
399
+ // SET payloads (e.g. certificates [0], crls [1], OtherRecipientInfo
400
+ // value).
401
+ var tagByte = 0xa0 | (tagNumber & 0x1f); // allow:raw-byte-literal — context-specific constructed mask
402
+ return asn1.writeNode(tagByte, payload);
403
+ }
404
+
405
+ function _writeImplicitPrimitive(tagNumber, value) {
406
+ // [N] IMPLICIT context-specific PRIMITIVE — for wrapping primitive
407
+ // ASN.1 types (OCTET STRING / INTEGER / OID) that have been IMPLICIT-
408
+ // tagged. The constructed bit MUST NOT be set or strict CMS parsers
409
+ // reject the structure (Codex P1 finding on PR #102 — RecipientIdentifier
410
+ // CHOICE's SubjectKeyIdentifier alternative is `[0] IMPLICIT OCTET STRING`,
411
+ // a primitive type).
412
+ var tagByte = 0x80 | (tagNumber & 0x1f); // allow:raw-byte-literal — context-specific primitive mask
413
+ return asn1.writeNode(tagByte, value);
414
+ }
415
+
416
+ function _encapsulatedContentInfo(content, detached) {
417
+ // EncapsulatedContentInfo: SEQUENCE { eContentType OID, eContent [0] EXPLICIT OCTET STRING? }
418
+ var inner = [asn1.writeOid(OID.data)];
419
+ if (!detached) {
420
+ inner.push(asn1.writeContextExplicit(0, asn1.writeOctetString(content)));
421
+ }
422
+ return asn1.writeNode(TAG_SEQUENCE, Buffer.concat(inner));
423
+ }
424
+
425
+ function _signerInfo(signer, msgDigest, digestOid) {
426
+ if (!signer || typeof signer !== "object") {
427
+ throw new CmsCodecError("cms/bad-signer", "signer entry must be an object");
428
+ }
429
+ if (!Buffer.isBuffer(signer.certificate)) {
430
+ throw new CmsCodecError("cms/bad-signer-cert",
431
+ "signer.certificate must be a DER Buffer");
432
+ }
433
+ if (signer.sigAlg !== "ML-DSA-65" && signer.sigAlg !== "ML-DSA-87" &&
434
+ signer.sigAlg !== "SLH-DSA-SHAKE-256f") {
435
+ throw new CmsCodecError("cms/bad-sig-alg",
436
+ "signer.sigAlg must be ML-DSA-65 / ML-DSA-87 / SLH-DSA-SHAKE-256f " +
437
+ "(PQC-first; RSA / ECDSA not accepted)");
438
+ }
439
+ if (!(signer.secretKey instanceof Uint8Array)) {
440
+ throw new CmsCodecError("cms/bad-signer-key",
441
+ "signer.secretKey must be a Uint8Array from the matching PQC keygen");
442
+ }
443
+ var sigAlgOid;
444
+ var pqcAlg;
445
+ if (signer.sigAlg === "ML-DSA-65") { sigAlgOid = OID.mldsa65; pqcAlg = pqcSoftware.ml_dsa_65; }
446
+ else if (signer.sigAlg === "ML-DSA-87") { sigAlgOid = OID.mldsa87; pqcAlg = pqcSoftware.ml_dsa_87; }
447
+ else { sigAlgOid = OID.slhDsaShake256f; pqcAlg = pqcSoftware.slh_dsa_shake_256f; }
448
+
449
+ // signedAttrs SET OF Attribute — IMPLICIT [0] tagged in the SignerInfo.
450
+ // For the signature input we re-tag as universal SET (0x31) per
451
+ // RFC 5652 §5.4 paragraph 3.
452
+ var signedAttrs = _signedAttrs({
453
+ contentType: OID.data,
454
+ messageDigest: msgDigest,
455
+ signingTime: signer.signingTime instanceof Date ? signer.signingTime : new Date(),
456
+ });
457
+ // signedAttrs is already `31 LL VV...` — re-tag to `A0 LL VV...` for the
458
+ // SignerInfo, and use the original `31 LL VV...` form as the signature
459
+ // input.
460
+ var signatureInput = signedAttrs;
461
+ var signedAttrsImplicit = Buffer.concat([Buffer.from([0xa0]), // allow:raw-byte-literal — IMPLICIT [0] tag per RFC 5652 §5.3
462
+ signedAttrs.slice(1)]);
463
+
464
+ var signature;
465
+ try {
466
+ // noble signature: sign(msg, secretKey) → Uint8Array.
467
+ var sigBytes = pqcAlg.sign(new Uint8Array(signatureInput), signer.secretKey);
468
+ signature = Buffer.from(sigBytes);
469
+ } catch (e) {
470
+ throw new CmsCodecError("cms/sign-failed",
471
+ "SignerInfo signature failed: " + ((e && e.message) || String(e)));
472
+ }
473
+
474
+ // SignerInfo SEQUENCE per §5.3 (issuerAndSerialNumber variant — CMSVersion 1).
475
+ return asn1.writeNode(TAG_SEQUENCE, Buffer.concat([
476
+ asn1.writeInteger(Buffer.from([1])), // allow:raw-byte-literal — CMSVersion 1 for issuerAndSerialNumber
477
+ _issuerAndSerialNumber(signer.certificate),
478
+ _algorithmIdentifier(digestOid),
479
+ signedAttrsImplicit,
480
+ _algorithmIdentifier(sigAlgOid),
481
+ asn1.writeOctetString(signature),
482
+ ]));
483
+ }
484
+
485
+ function _signedAttrs(attrs) {
486
+ // SET OF Attribute — DER canonical: sort entries by encoded bytes (X.690 §11.6).
487
+ var entries = [];
488
+ entries.push(_attribute(OID.contentType, asn1.writeOid(attrs.contentType)));
489
+ entries.push(_attribute(OID.messageDigest, asn1.writeOctetString(attrs.messageDigest)));
490
+ entries.push(_attribute(OID.signingTime, _encodeTime(attrs.signingTime)));
491
+ entries.sort(Buffer.compare);
492
+ return asn1.writeNode(TAG_SET, Buffer.concat(entries));
493
+ }
494
+
495
+ function _attribute(typeOid, valueBuf) {
496
+ // Attribute ::= SEQUENCE { attrType OID, attrValues SET OF ANY }
497
+ return asn1.writeNode(TAG_SEQUENCE, Buffer.concat([
498
+ asn1.writeOid(typeOid),
499
+ asn1.writeNode(TAG_SET, valueBuf),
500
+ ]));
501
+ }
502
+
503
+ function _encodeTime(date) {
504
+ var pad = function (n) { return n < 10 ? "0" + n : String(n); };
505
+ var y = date.getUTCFullYear();
506
+ var mm = pad(date.getUTCMonth() + 1);
507
+ var dd = pad(date.getUTCDate());
508
+ var hh = pad(date.getUTCHours());
509
+ var mi = pad(date.getUTCMinutes());
510
+ var ss = pad(date.getUTCSeconds());
511
+ if (y >= 1950 && y <= 2049) {
512
+ var yy = pad(y % 100);
513
+ return asn1.writeNode(TAG_UTCTIME, Buffer.from(yy + mm + dd + hh + mi + ss + "Z", "ascii"));
514
+ }
515
+ return asn1.writeNode(TAG_GENTIME, Buffer.from(String(y) + mm + dd + hh + mi + ss + "Z", "ascii"));
516
+ }
517
+
518
+ function _issuerAndSerialNumber(certDer) {
519
+ // RFC 5280 §4.1 Certificate SEQUENCE { tbsCertificate, signatureAlgorithm, signatureValue }.
520
+ // tbsCertificate SEQUENCE { [0] version?, serialNumber INTEGER, signature AlgId, issuer Name, ... }
521
+ // We extract `issuer Name` (SEQUENCE) + `serialNumber` (INTEGER) and wrap as
522
+ // SEQUENCE { issuer, serialNumber } per RFC 5652 §10.2.4.
523
+ var cert;
524
+ try { cert = asn1.readNode(certDer); }
525
+ catch (e) {
526
+ throw new CmsCodecError("cms/bad-cert", "certificate DER parse failed: " + ((e && e.message) || String(e)));
527
+ }
528
+ if (cert.tag !== asn1.TAG.SEQUENCE) {
529
+ throw new CmsCodecError("cms/bad-cert", "certificate top-level is not a SEQUENCE");
530
+ }
531
+ var certChildren;
532
+ try { certChildren = asn1.readSequence(cert.value); }
533
+ catch (e2) {
534
+ throw new CmsCodecError("cms/bad-cert", "certificate body parse failed: " + ((e2 && e2.message) || String(e2)));
535
+ }
536
+ if (certChildren.length < 1 || certChildren[0].tag !== asn1.TAG.SEQUENCE) {
537
+ throw new CmsCodecError("cms/bad-cert", "certificate has no tbsCertificate SEQUENCE");
538
+ }
539
+ var tbsChildren;
540
+ try { tbsChildren = asn1.readSequence(certChildren[0].value); }
541
+ catch (e3) {
542
+ throw new CmsCodecError("cms/bad-cert", "tbsCertificate body parse failed: " + ((e3 && e3.message) || String(e3)));
543
+ }
544
+ // Optional [0] EXPLICIT version then serialNumber INTEGER.
545
+ var idx = 0;
546
+ if (tbsChildren[idx] && tbsChildren[idx].tagClass === asn1.TAG_CLASS.CONTEXT_SPECIFIC &&
547
+ tbsChildren[idx].tag === 0) {
548
+ idx += 1;
549
+ }
550
+ var serialNode = tbsChildren[idx];
551
+ if (!serialNode || serialNode.tag !== asn1.TAG.INTEGER) {
552
+ throw new CmsCodecError("cms/bad-cert", "tbsCertificate has no serialNumber INTEGER");
553
+ }
554
+ idx += 1;
555
+ // Skip signature AlgId (SEQUENCE).
556
+ if (!tbsChildren[idx] || tbsChildren[idx].tag !== asn1.TAG.SEQUENCE) {
557
+ throw new CmsCodecError("cms/bad-cert", "tbsCertificate has no signature AlgorithmIdentifier");
558
+ }
559
+ idx += 1;
560
+ // Issuer Name (SEQUENCE).
561
+ var issuerNode = tbsChildren[idx];
562
+ if (!issuerNode || issuerNode.tag !== asn1.TAG.SEQUENCE) {
563
+ throw new CmsCodecError("cms/bad-cert", "tbsCertificate has no issuer Name SEQUENCE");
564
+ }
565
+ // Reconstruct the full DER bytes of issuer (header + value) and
566
+ // serialNumber (header + value) — readNode gave us value-only Buffers.
567
+ var issuerDer = _reEncodeNode(issuerNode);
568
+ var serialDer = _reEncodeNode(serialNode);
569
+ return asn1.writeNode(TAG_SEQUENCE, Buffer.concat([issuerDer, serialDer]));
570
+ }
571
+
572
+ function _reEncodeNode(node) {
573
+ // Reconstruct the TLV bytes of `node` — asn1-der's readNode returns the
574
+ // value slice but the issuerAndSerialNumber surface needs the full
575
+ // TLV. writeNode rebuilds canonical DER from the original tag byte +
576
+ // value bytes; the tag byte is reconstructed from tagClass + constructed +
577
+ // tag number.
578
+ var classBits = (node.tagClass & 0x03) << 6; // allow:raw-byte-literal — tag-class shift
579
+ var consBit = node.constructed ? 0x20 : 0x00; // allow:raw-byte-literal — constructed bit
580
+ var tagBits = node.tag & 0x1f; // allow:raw-byte-literal — short-form tag
581
+ var tagByte = classBits | consBit | tagBits;
582
+ return asn1.writeNode(tagByte, node.value);
583
+ }
584
+
585
+ function _recipientInfo(recipient, contentKey) {
586
+ // RFC 9629 KEMRecipientInfo wrapped in [1] IMPLICIT OtherRecipientInfo
587
+ // SEQUENCE per §3:
588
+ // ori [4] IMPLICIT OtherRecipientInfo
589
+ // OtherRecipientInfo ::= SEQUENCE { oriType OID, oriValue ANY DEFINED BY oriType }
590
+ // oriType = id-ori-kem (RFC 9629 §3)
591
+ // oriValue = KEMRecipientInfo SEQUENCE { version, rid, kem, kemct, kdf, kekLength, ukm?, wrap, encryptedKey }
592
+ if (!recipient || typeof recipient !== "object") {
593
+ throw new CmsCodecError("cms/bad-recipient", "recipient must be an object");
594
+ }
595
+ if (recipient.type !== "kem-mlkem-1024") {
596
+ throw new CmsCodecError("cms/bad-recipient-type",
597
+ "recipient.type must be 'kem-mlkem-1024' " +
598
+ "(other KEMs / KEKRecipientInfo / KeyAgreeRecipientInfo deferred)");
599
+ }
600
+ if (!(recipient.publicKey instanceof Uint8Array)) {
601
+ throw new CmsCodecError("cms/bad-recipient-key",
602
+ "recipient.publicKey must be a Uint8Array from b.pqcSoftware.ml_kem_1024.keygen()");
603
+ }
604
+ if (!Buffer.isBuffer(recipient.recipientId)) {
605
+ throw new CmsCodecError("cms/bad-recipient-id",
606
+ "recipient.recipientId must be a Buffer (SubjectKeyIdentifier or issuer-and-serial-number DER)");
607
+ }
608
+ // KEM encapsulate against the recipient's ML-KEM-1024 public key.
609
+ var encap;
610
+ try { encap = pqcSoftware.ml_kem_1024.encapsulate(recipient.publicKey); }
611
+ catch (e) {
612
+ throw new CmsCodecError("cms/kem-encap-failed",
613
+ "ML-KEM-1024 encapsulation failed: " + ((e && e.message) || String(e)));
614
+ }
615
+ // Derive 32-byte KEK from the KEM shared secret via SHAKE256 (the
616
+ // framework's PQC-first KDF). The info-label binds the derivation to
617
+ // the CMS KEMRecipientInfo + ChaCha20-Poly1305 wrap context so a key
618
+ // derived here cannot be confused with a key derived for any other
619
+ // composition path.
620
+ var infoLabel = Buffer.from("cms/kemri/chacha20-poly1305", "ascii");
621
+ var kdfInput = Buffer.concat([Buffer.from(encap.sharedSecret), infoLabel]);
622
+ var kek = bCrypto.kdf(kdfInput, 32); // allow:raw-byte-literal — 256-bit KEK
623
+ // Wrap the content key under the KEK using ChaCha20-Poly1305.
624
+ var wrapped;
625
+ try { wrapped = bCrypto.encryptPacked(contentKey, kek); }
626
+ catch (e2) {
627
+ throw new CmsCodecError("cms/wrap-failed",
628
+ "content-key wrap failed: " + ((e2 && e2.message) || String(e2)));
629
+ }
630
+ // KEMRecipientInfo SEQUENCE.
631
+ // Simplified ordering, version 0 per RFC 9629 §3.
632
+ var kemRi = asn1.writeNode(TAG_SEQUENCE, Buffer.concat([
633
+ asn1.writeInteger(Buffer.from([0])), // allow:raw-byte-literal — KEMRecipientInfo version 0
634
+ // rid CHOICE per RFC 9629 §3: this module ships the [0] IMPLICIT
635
+ // SubjectKeyIdentifier alternative — SKI is `[0] IMPLICIT OCTET
636
+ // STRING` (PRIMITIVE per RFC 5652 §10.2.4). The constructed form
637
+ // (0xa0) is the IssuerAndSerialNumber CHOICE alternative; this
638
+ // module picks SKI for KEM recipients since the operator-supplied
639
+ // recipientId is opaque key-identifier bytes.
640
+ _writeImplicitPrimitive(0, recipient.recipientId),
641
+ _algorithmIdentifier(OID.mlkem1024), // kem
642
+ asn1.writeOctetString(Buffer.from(encap.cipherText)), // kemct
643
+ _algorithmIdentifier(OID.shake256), // kdf
644
+ asn1.writeInteger(Buffer.from([32])), // allow:raw-byte-literal — kekLength = 32 bytes
645
+ _algorithmIdentifier(OID.chacha20Poly1305), // wrap (also used as content-encryption AlgId; same OID)
646
+ asn1.writeOctetString(wrapped), // encryptedKey
647
+ ]));
648
+ // OtherRecipientInfo SEQUENCE { oriType OID, oriValue ANY DEFINED BY oriType }
649
+ // wrapped in [4] IMPLICIT context tag per RFC 5652 §6.2 RecipientInfo
650
+ // CHOICE alternative.
651
+ var oriValue = Buffer.concat([
652
+ asn1.writeOid(OID.kemri),
653
+ kemRi,
654
+ ]);
655
+ return asn1.writeNode(0xa4, oriValue); // allow:raw-byte-literal — [4] IMPLICIT context-specific constructed (ori CHOICE)
656
+ }
657
+
658
+ function _encryptedContentInfo(plaintext, contentKey) {
659
+ // EncryptedContentInfo SEQUENCE { contentType OID, contentEncryptionAlgorithm AlgId,
660
+ // encryptedContent [0] IMPLICIT OCTET STRING OPTIONAL }
661
+ // The ChaCha20-Poly1305 ciphertext is the framework's encryptPacked output
662
+ // (nonce ‖ ciphertext ‖ tag). Operators decoding with a non-blamejs CMS
663
+ // peer need to know the framework wire format — documented in @intro.
664
+ var ct;
665
+ try { ct = bCrypto.encryptPacked(plaintext, contentKey); }
666
+ catch (e) {
667
+ throw new CmsCodecError("cms/encrypt-failed",
668
+ "content encryption failed: " + ((e && e.message) || String(e)));
669
+ }
670
+ return asn1.writeNode(TAG_SEQUENCE, Buffer.concat([
671
+ asn1.writeOid(OID.data),
672
+ _algorithmIdentifier(OID.chacha20Poly1305),
673
+ _writeImplicitPrimitive(0, ct),
674
+ ]));
675
+ }
676
+
677
+ module.exports = {
678
+ encodeSignedData: encodeSignedData,
679
+ encodeEnvelopedData: encodeEnvelopedData,
680
+ decode: decode,
681
+ OID: OID,
682
+ MAX_DEPTH: MAX_DEPTH,
683
+ DEFAULT_MAX_LEN: DEFAULT_MAX_LEN,
684
+ CmsCodecError: CmsCodecError,
685
+ };
package/lib/daemon.js CHANGED
@@ -237,13 +237,37 @@ function start(opts) {
237
237
  throw new DaemonError("daemon/already-running",
238
238
  "daemon.start: pidFile '" + pidFile + "' held by live PID " + existingLive);
239
239
  }
240
- var logFd = logFile ? _openLogFd(logFile) : "ignore";
240
+ // Detached-stdio strategy diverges by platform:
241
+ //
242
+ // POSIX: inherit the parent's open log FD via stdio so the child
243
+ // writes to the operator's log file without re-opening it. POSIX
244
+ // keeps the FD alive across the parent's exit; the child sees it
245
+ // as fd 1 / 2 and writes normally.
246
+ //
247
+ // Windows: passing a parent-opened FD through stdio causes the
248
+ // child to die the moment the parent's handle is closed (the OS
249
+ // ref-counts file handles per-process and the inherited handle
250
+ // becomes invalid on parent exit). The Windows-safe pattern is
251
+ // `stdio: "ignore"` + `windowsHide: true` so the child has no
252
+ // inherited handles to lose, and the operator's child code opens
253
+ // the log file itself once its logger initialises. The child is
254
+ // responsible for `--log` parsing on Windows — pass it via
255
+ // `opts.args` and let the application code handle the open.
256
+ var isWindows = process.platform === "win32";
257
+ var logFd = (!isWindows && logFile) ? _openLogFd(logFile) : null;
258
+ var spawnStdio;
259
+ if (isWindows || logFd === null) {
260
+ spawnStdio = "ignore";
261
+ } else {
262
+ spawnStdio = ["ignore", logFd, logFd];
263
+ }
241
264
  var child;
242
265
  try {
243
266
  child = processSpawn.spawn(opts.command, opts.args || [], {
244
- detached: true,
245
- stdio: ["ignore", logFd, logFd],
246
- cwd: typeof opts.cwd === "string" ? opts.cwd : undefined,
267
+ detached: true,
268
+ stdio: spawnStdio,
269
+ cwd: typeof opts.cwd === "string" ? opts.cwd : undefined,
270
+ windowsHide: isWindows ? true : undefined,
247
271
  });
248
272
  } catch (e) {
249
273
  try { if (typeof logFd === "number") nodeFs.closeSync(logFd); }
@@ -267,6 +291,7 @@ function start(opts) {
267
291
  logFile: logFile,
268
292
  commandKind: "detached-fork",
269
293
  pid: child.pid,
294
+ stdioMode: isWindows ? "ignore-windows" : (logFd === null ? "ignore" : "inherit-logfd"),
270
295
  });
271
296
  log("daemon started (detached) pid=" + child.pid + " pidFile=" + pidFile);
272
297
  return { pid: child.pid, pidFile: pidFile, logFile: logFile, mode: "detached" };
@@ -60,15 +60,16 @@
60
60
  * Deferred from v1 (each with the documented condition for opting in):
61
61
  * - In-process encrypt + decrypt (Message Encrypted Session Key +
62
62
  * Symmetrically Encrypted Integrity Protected Data packets,
63
- * RFC 9580 §5.1 / §5.13). Defer condition: no operator demand
64
- * surfaced for in-process encrypt-to-recipient yet. Operators
65
- * wanting at-rest encrypted mail blobs compose `b.vault` +
66
- * `b.cryptoField`; operators wanting wire-level encrypt-to-
67
- * recipient with WKD key discovery wait for v0.9.59 once
68
- * `b.publicSuffix` + WKD lookup are confirmed. Cheap escape
69
- * hatch: operators wire a third-party OpenPGP library in their
70
- * own consumer code and call sign() / verify() on the resulting
71
- * cleartext blob.
63
+ * RFC 9580 §5.1 / §5.13) and WKD key discovery (draft-koch-
64
+ * openpgp-webkey-service). Defer condition: ships in v0.10.14
65
+ * alongside `b.mail.crypto.smime` sign + verify the CMS
66
+ * substrate `b.cms` landed in v0.10.13 unblocked the S/MIME
67
+ * side, and OpenPGP encrypt rides the same release so the
68
+ * mail-crypto surface lights up coherently rather than half-
69
+ * on-each-side across two patches. Cheap escape hatch (pre-
70
+ * v0.10.14): operators wire a third-party OpenPGP library in
71
+ * their own consumer code and call this module's sign() /
72
+ * verify() on the resulting cleartext blob.
72
73
  * - v6 signature packets (RFC 9580 §5.2.3, packet version 6 with
73
74
  * SHA2-512 fingerprints and salted hashes). Defer condition: v6
74
75
  * is not yet emitted by GnuPG 2.4 LTS or by Sequoia stable, so
@@ -64,38 +64,22 @@
64
64
  * packet decoder shipped in `b.mail.crypto.pgp` — but with
65
65
  * dramatically more shape variation across implementations.
66
66
  *
67
- * Defer condition: no operator demand has surfaced for in-process
68
- * S/MIME verification; operators receiving S/MIME-signed mail
69
- * today either (a) trust the gateway's authentication-results
70
- * header (composed via `b.mail.authResults`) and treat S/MIME as
71
- * a downstream concern, or (b) run S/MIME verification in their
72
- * own consumer code with a vetted CMS library. Reopen this
73
- * surface when ALL of the following hold:
74
- * 1. At least one operator surfaces concrete demand for
75
- * in-process S/MIME verify (use case + sample message
76
- * shape).
77
- * 2. A vendorable ASN.1 BER/DER decoder lands in `lib/vendor/`
78
- * under the framework's vendoring discipline (MANIFEST.json
79
- * + sha256 pin + no transitive deps), OR an operator
80
- * provides a tested decoder we can fold in directly.
81
- * 3. RFC 8551 §2.5 + RFC 5652 §11 conformance test vectors are
82
- * available to drive the implementation. (NIST PKITS-style
83
- * test vectors exist for X.509 chain validation; equivalent
84
- * coverage for CMS SignedData is sparser.)
67
+ * Reopen condition: the in-tree CMS substrate (`b.cms`) shipped
68
+ * in v0.10.13 the RFC 5652 SignedData encode + decode + PQC
69
+ * signer dispatch is now available. The S/MIME wire layer
70
+ * (multipart/signed framing, micalg mapping, base64 DER body,
71
+ * Content-Type parameters) lights up on top of `b.cms` in
72
+ * v0.10.14 alongside `b.mail.crypto.pgp` encrypt + decrypt + WKD
73
+ * discovery, so operators get the full mail-crypto surface in a
74
+ * single release rather than half of each side.
85
75
  *
86
- * Cheap escape hatch: operators wire `node-forge` / `pkijs` /
87
- * openssl(1) (via child_process) in their own consumer code,
88
- * extract the signed payload + signature components, and compose
89
- * with `b.mail.authResults` to record the verification outcome
90
- * for downstream policy. The S/MIME wire-format constants
91
- * (Content-Type protocol parameter, micalg mapping, base64 DER
92
- * framing) are stable and operator-side code interoperates with
93
- * any inbound S/MIME-signed message regardless of this module's
94
- * state.
95
- *
96
- * v2 reopen tag: the next minor (v0.9.60+) once the conditions
97
- * above are met. The deferred surface lights up sign+verify
98
- * together so operators never see a half-implementation.
76
+ * Cheap escape hatch (pre-v0.10.14): operators wanting in-process
77
+ * S/MIME today compose `b.cms.encodeSignedData` directly with a
78
+ * hand-written multipart/signed wrapper. The MIME framing is two
79
+ * parts (the signed content + `application/pkcs7-signature` body
80
+ * carrying the base64-encoded CMS DER from `b.cms`); the helper
81
+ * in v0.10.14 collapses that into `b.mail.crypto.smime.sign({ ... })`
82
+ * so the next-release path is additive, not a rewrite.
99
83
  *
100
84
  * RFC citations:
101
85
  * - RFC 8551 (S/MIME 4.0 Message Specification, April 2019;
package/lib/metrics.js CHANGED
@@ -783,23 +783,66 @@ function _resetForTest() {
783
783
  * path: string, // absolute path to write the snapshot
784
784
  * intervalMs: number, // milliseconds between flushes (>=100)
785
785
  * fields: Function, // returns an object — written as JSON
786
+ * registry: object, // optional `b.metrics.create()` handle — adds a
787
+ * // structured `metrics` field carrying every
788
+ * // registered counter / gauge / histogram (incl.
789
+ * // bucket counts) so sidecar readers compose
790
+ * // histogram_quantile() against the snapshot
786
791
  * fileMode: number, // POSIX mode (default 0o640 — owner rw, group r)
787
792
  *
788
793
  * @example
794
+ * var registry = b.metrics.create();
795
+ * var latency = registry.histogram("op_latency_seconds", { buckets: [0.01, 0.1, 1] });
789
796
  * var stop = b.metrics.snapshot.startWriter({
790
797
  * path: "/run/blamejs/metrics.json",
791
798
  * intervalMs: 5000,
792
- * fields: function () {
793
- * return {
794
- * uptimeMs: process.uptime() * 1000,
795
- * queueDepth: myQueue.size,
796
- * lastSyncAt: lastSyncAt,
797
- * };
798
- * },
799
+ * registry: registry,
800
+ * fields: function () { return { uptimeMs: process.uptime() * 1000 }; },
799
801
  * });
800
- * // ... on SIGTERM:
802
+ * // Snapshot file: { writtenAt, fields, metrics: { op_latency_seconds: { type, buckets, observations: [{ labels, counts, sum, count }] } } }
801
803
  * stop();
802
804
  */
805
+ function _serializeRegistry(registry) {
806
+ // Walk every registered metric in the registry.metrics Map and emit
807
+ // a JSON-friendly structured shape. Histograms get full buckets +
808
+ // bucket counts so downstream consumers compose
809
+ // `histogram_quantile()` against the snapshot without a separate
810
+ // exposition endpoint (issue #100).
811
+ var out = {};
812
+ var names = registry.metrics instanceof Map
813
+ ? Array.from(registry.metrics.keys()).sort()
814
+ : Object.keys(registry.metrics).sort();
815
+ for (var i = 0; i < names.length; i += 1) {
816
+ var name = names[i];
817
+ var m = registry.metrics instanceof Map ? registry.metrics.get(name) : registry.metrics[name];
818
+ if (!m) continue;
819
+ var entry = { type: m.type, help: m.help || "", labelNames: m.labelNames || [] };
820
+ if (m.type === "histogram") {
821
+ entry.buckets = m.buckets.slice();
822
+ entry.observations = [];
823
+ var hKeys = m.values instanceof Map ? Array.from(m.values.keys()).sort() : Object.keys(m.values).sort();
824
+ for (var hi = 0; hi < hKeys.length; hi += 1) {
825
+ var hv = m.values instanceof Map ? m.values.get(hKeys[hi]) : m.values[hKeys[hi]];
826
+ entry.observations.push({
827
+ labels: hv.labels,
828
+ counts: hv.counts.slice(),
829
+ sum: hv.sum,
830
+ count: hv.count,
831
+ });
832
+ }
833
+ } else {
834
+ entry.observations = [];
835
+ var vKeys = m.values instanceof Map ? Array.from(m.values.keys()).sort() : Object.keys(m.values).sort();
836
+ for (var vi = 0; vi < vKeys.length; vi += 1) {
837
+ var vv = m.values instanceof Map ? m.values.get(vKeys[vi]) : m.values[vKeys[vi]];
838
+ entry.observations.push({ labels: vv.labels, value: vv.value });
839
+ }
840
+ }
841
+ out[name] = entry;
842
+ }
843
+ return out;
844
+ }
845
+
803
846
  function snapshotStartWriter(opts) {
804
847
  opts = opts || {};
805
848
  validateOpts.requireNonEmptyString(opts.path,
@@ -813,8 +856,21 @@ function snapshotStartWriter(opts) {
813
856
  throw new MetricsError("metrics-snapshot/bad-fields",
814
857
  "metrics.snapshot.startWriter: opts.fields must be a function returning the snapshot object");
815
858
  }
859
+ // Issue #100 — optional `registry` handle pulls every registered
860
+ // metric into a structured `metrics` field in the JSON snapshot:
861
+ // counters / gauges as `{ value }` per label set, histograms as
862
+ // `{ buckets, observations }` with bucket counts + sum + count.
863
+ // Sidecar readers compose `histogram_quantile()` against the
864
+ // snapshot file without running a separate /metrics endpoint.
865
+ if (opts.registry !== undefined && opts.registry !== null &&
866
+ (typeof opts.registry !== "object" || typeof opts.registry.metrics !== "object")) {
867
+ throw new MetricsError("metrics-snapshot/bad-registry",
868
+ "metrics.snapshot.startWriter: opts.registry must be a metrics registry " +
869
+ "(from b.metrics.create()) or omitted");
870
+ }
816
871
  var p = opts.path;
817
872
  var fieldsFn = opts.fields;
873
+ var registry = opts.registry || null;
818
874
  var intervalMs = opts.intervalMs;
819
875
  // CRYPTO-6 — file mode for the atomic write. Default 0o640
820
876
  // (owner rw, group r, world none). Operators with a sidecar
@@ -844,6 +900,10 @@ function snapshotStartWriter(opts) {
844
900
  writtenAt: new Date().toISOString(),
845
901
  fields: snap,
846
902
  };
903
+ if (registry) {
904
+ try { payload.metrics = _serializeRegistry(registry); }
905
+ catch (e2) { log("snapshot.metrics serialize failed: " + ((e2 && e2.message) || String(e2))); }
906
+ }
847
907
  try {
848
908
  // CRYPTO-6 — default 0o640 (owner rw, group r, world none) so
849
909
  // operator-supplied snapshot fields aren't world-readable on a
@@ -0,0 +1,235 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.streamThrottle
4
+ * @nav Networking
5
+ * @title Stream Throttle
6
+ * @order 130
7
+ * @slug stream-throttle
8
+ *
9
+ * @card
10
+ * Shared token-bucket bandwidth limiter for `node:stream` pipelines.
11
+ * Caps aggregate bytes-per-second across N concurrent streams that
12
+ * draw from the same bucket — the missing primitive between per-
13
+ * request rate-limit and per-process worker pool.
14
+ *
15
+ * @intro
16
+ * `b.streamThrottle.create({ bytesPerSec, burstBytes })` returns a
17
+ * token bucket that hands out `transform()` instances; every
18
+ * transform consumes from the same shared bucket. Operators wiring
19
+ * bulk-transfer daemons (object-storage fan-out, log shippers,
20
+ * replication readers) compose a single throttle and apply it
21
+ * to every concurrent transfer — N parallel transforms share the
22
+ * `bytesPerSec` budget rather than each getting their own.
23
+ *
24
+ * Algorithm:
25
+ *
26
+ * - Bucket holds up to `burstBytes` tokens (default = `bytesPerSec`,
27
+ * i.e. one second of headroom). Tokens refill at `bytesPerSec`
28
+ * bytes per second, capped at `burstBytes`. Refill is computed
29
+ * lazily on every chunk write so there is no per-throttle timer.
30
+ * - On each chunk, the transform asks the bucket for the chunk's
31
+ * byte count. If enough tokens are available, the chunk passes
32
+ * immediately and the tokens are decremented. If not, the
33
+ * transform sleeps for `ceil((bytes - tokens) / bytesPerSec * 1000)`
34
+ * ms and then retries — the chunk is forwarded as-is once the
35
+ * debt is paid.
36
+ *
37
+ * Composes with:
38
+ *
39
+ * - `node:stream.pipeline(src, throttle.transform(), dst)` — the
40
+ * transform is a regular `stream.Transform`, so backpressure
41
+ * flows in both directions without operator wiring.
42
+ * - `b.appShutdown` — the throttle has no background timer; once
43
+ * every transform finishes its `_transform`, the bucket is
44
+ * garbage-collected with the surrounding daemon.
45
+ *
46
+ * Refusal posture:
47
+ *
48
+ * - `bytesPerSec <= 0` / non-finite throws `stream-throttle/bad-rate`.
49
+ * - `burstBytes < bytesPerSec` throws `stream-throttle/bad-burst`
50
+ * (smaller burst than refill rate would stall on a single full-rate
51
+ * chunk forever).
52
+ * - Chunks larger than `burstBytes` would never fit in the bucket;
53
+ * `transform({ allowOversize: true })` opts into splitting them
54
+ * across multiple wait windows. Default refuses with a typed error
55
+ * so operators catch this at config time.
56
+ *
57
+ * RFC + reference:
58
+ *
59
+ * - [RFC 2697 srTCM](https://www.rfc-editor.org/rfc/rfc2697.html) — single-rate
60
+ * three-color marker, the canonical token-bucket shape this primitive
61
+ * implements (single PIR + CBS, no committed burst tier).
62
+ * - [Wikipedia: Token bucket](https://en.wikipedia.org/wiki/Token_bucket).
63
+ */
64
+
65
+ var nodeStream = require("node:stream");
66
+ var { defineClass } = require("./framework-error");
67
+
68
+ var StreamThrottleError = defineClass("StreamThrottleError", { alwaysPermanent: true });
69
+
70
+ // Milliseconds-per-second conversion factor — used for rate arithmetic
71
+ // (bytes/sec ↔ wait-ms). This is a unit-conversion constant, not a
72
+ // memory cap or protocol-byte literal; the framework's C.TIME / C.BYTES
73
+ // helpers don't apply.
74
+ var MS_PER_SECOND = 1000; // allow:raw-byte-literal — ms/sec unit conversion // allow:raw-time-literal — ms/sec unit conversion
75
+ var NS_PER_MS = 1e6; // allow:raw-byte-literal — ns/ms unit conversion
76
+ var MS_PER_SECOND_HRTIME = 1000; // allow:raw-byte-literal — hrtime seconds→ms // allow:raw-time-literal — hrtime seconds→ms
77
+
78
+ /**
79
+ * @primitive b.streamThrottle.create
80
+ * @signature b.streamThrottle.create(opts)
81
+ * @since 0.10.13
82
+ * @status stable
83
+ * @related b.streamThrottle
84
+ *
85
+ * Create a shared token bucket. Returns `{ transform(opts?), state() }`.
86
+ * `transform(tOpts?)` returns a `stream.Transform` that consumes from
87
+ * the shared bucket; multiple transforms returned from the same
88
+ * bucket share the rate budget. `state()` returns
89
+ * `{ bytesPerSec, burstBytes, tokens, lastRefillMs }` for observation.
90
+ *
91
+ * Refill resilience: `_refill` clamps elapsed-since-last-refill to
92
+ * the "empty-to-full" duration (`burstBytes / bytesPerSec` seconds)
93
+ * so an NTP clock step or VM resume can't credit hours of pent-up
94
+ * tokens into the bucket in a single call.
95
+ *
96
+ * @opts
97
+ * bytesPerSec: number, // refill rate (bytes per second; required, > 0)
98
+ * burstBytes: number, // bucket capacity (default = bytesPerSec)
99
+ *
100
+ * `transform(tOpts)` opts:
101
+ * allowOversize: boolean, // permit chunks larger than burstBytes (default false)
102
+ * maxWaitMs: number, // per-chunk wait ceiling — when set, any
103
+ * // computed wait > maxWaitMs refuses the chunk
104
+ * // with `stream-throttle/wait-exceeds-max`
105
+ * // instead of silently pinning the pipeline.
106
+ *
107
+ * @example
108
+ * var throttle = b.streamThrottle.create({ bytesPerSec: 5 * 1024 * 1024 });
109
+ * await new Promise(function (resolve, reject) {
110
+ * require("node:stream").pipeline(src, throttle.transform(), dst,
111
+ * function (e) { return e ? reject(e) : resolve(); });
112
+ * });
113
+ */
114
+ function create(opts) {
115
+ opts = opts || {};
116
+ if (typeof opts.bytesPerSec !== "number" || !isFinite(opts.bytesPerSec) || opts.bytesPerSec <= 0) {
117
+ throw new StreamThrottleError("stream-throttle/bad-rate",
118
+ "streamThrottle.create: opts.bytesPerSec must be a finite number > 0, got " + opts.bytesPerSec);
119
+ }
120
+ var bytesPerSec = opts.bytesPerSec;
121
+ var burstBytes = opts.burstBytes !== undefined ? opts.burstBytes : bytesPerSec;
122
+ if (typeof burstBytes !== "number" || !isFinite(burstBytes) || burstBytes <= 0) {
123
+ throw new StreamThrottleError("stream-throttle/bad-burst",
124
+ "streamThrottle.create: opts.burstBytes must be a finite number > 0, got " + burstBytes);
125
+ }
126
+ if (burstBytes < bytesPerSec) {
127
+ throw new StreamThrottleError("stream-throttle/bad-burst",
128
+ "streamThrottle.create: opts.burstBytes (" + burstBytes + ") must be >= bytesPerSec (" +
129
+ bytesPerSec + ") — a smaller burst than refill rate stalls forever on a single full-rate chunk");
130
+ }
131
+ var tokens = burstBytes;
132
+ var lastRefill = _hrtimeMs();
133
+
134
+ // Cap how far elapsed-since-last-refill can stretch in one call.
135
+ // Without the cap, a system clock jump (NTP step / VM resume / a
136
+ // process suspended in a debugger) credits the bucket with enough
137
+ // tokens to drain hours of pent-up backlog in a single chunk —
138
+ // defeating the rate ceiling for the recovery window. The cap
139
+ // is `burstBytes / bytesPerSec` seconds — exactly the time it
140
+ // takes to refill an empty bucket to full at the configured rate
141
+ // — so legitimate idle periods recover correctly while clock
142
+ // skew never overshoots.
143
+ var maxElapsedMs = Math.ceil((burstBytes / bytesPerSec) * MS_PER_SECOND);
144
+
145
+ function _refill() {
146
+ var now = _hrtimeMs();
147
+ var elapsed = now - lastRefill;
148
+ if (elapsed > maxElapsedMs) elapsed = maxElapsedMs;
149
+ if (elapsed > 0) {
150
+ tokens = Math.min(burstBytes, tokens + (elapsed / MS_PER_SECOND) * bytesPerSec);
151
+ lastRefill = now;
152
+ }
153
+ }
154
+
155
+ function _consume(bytes, allowOversize) {
156
+ if (bytes > burstBytes && !allowOversize) {
157
+ throw new StreamThrottleError("stream-throttle/oversize-chunk",
158
+ "chunk of " + bytes + " bytes exceeds burstBytes=" + burstBytes +
159
+ "; pass transform({ allowOversize: true }) to split across wait windows");
160
+ }
161
+ _refill();
162
+ if (tokens >= bytes) {
163
+ tokens -= bytes;
164
+ return 0;
165
+ }
166
+ // Bucket has a deficit. Deduct the full chunk's bytes — the bucket
167
+ // goes negative — and tell the caller to wait for the deficit to
168
+ // refill. Subsequent _refill() calls re-accumulate from there, so
169
+ // the next consume sees an accurate budget. A parallel transform
170
+ // hitting the same bucket while it is negative also waits.
171
+ var deficitBytes = bytes - tokens;
172
+ var waitMs = Math.ceil((deficitBytes / bytesPerSec) * MS_PER_SECOND);
173
+ tokens -= bytes;
174
+ return waitMs;
175
+ }
176
+
177
+ function transform(tOpts) {
178
+ tOpts = tOpts || {};
179
+ var allowOversize = tOpts.allowOversize === true;
180
+ // Per-chunk wait ceiling. A misconfigured operator passing
181
+ // chunkBytes / bytesPerSec ratios that schedule a 10-minute
182
+ // single-chunk wait would otherwise pin the pipeline silently;
183
+ // when `maxWaitMs` is set, any computed wait > maxWaitMs refuses
184
+ // the chunk with `stream-throttle/wait-exceeds-max`. Defaults to
185
+ // omitted (no ceiling) for back-compat with operators wanting
186
+ // the historical "wait however long" behavior.
187
+ var maxWaitMs = tOpts.maxWaitMs;
188
+ if (maxWaitMs !== undefined &&
189
+ (typeof maxWaitMs !== "number" || !isFinite(maxWaitMs) || maxWaitMs <= 0)) {
190
+ throw new StreamThrottleError("stream-throttle/bad-max-wait",
191
+ "transform: maxWaitMs must be a finite number > 0, got " + maxWaitMs);
192
+ }
193
+ return new nodeStream.Transform({
194
+ transform: function (chunk, _enc, cb) {
195
+ var buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
196
+ var bytes = buf.length;
197
+ var waitMs;
198
+ try { waitMs = _consume(bytes, allowOversize); }
199
+ catch (e) { cb(e); return; }
200
+ if (maxWaitMs !== undefined && waitMs > maxWaitMs) {
201
+ cb(new StreamThrottleError("stream-throttle/wait-exceeds-max",
202
+ "computed wait " + waitMs + "ms exceeds maxWaitMs=" + maxWaitMs +
203
+ " (chunk=" + bytes + " bytes, rate=" + bytesPerSec + " bytes/s) — " +
204
+ "reduce chunk size, increase rate, or raise maxWaitMs"));
205
+ return;
206
+ }
207
+ if (waitMs === 0) { cb(null, buf); return; }
208
+ setTimeout(function () { cb(null, buf); }, waitMs);
209
+ },
210
+ });
211
+ }
212
+
213
+ function state() {
214
+ _refill();
215
+ return {
216
+ bytesPerSec: bytesPerSec,
217
+ burstBytes: burstBytes,
218
+ tokens: tokens,
219
+ lastRefillMs: lastRefill,
220
+ };
221
+ }
222
+
223
+ return { transform: transform, state: state };
224
+ }
225
+
226
+ function _hrtimeMs() {
227
+ // hrtime returns [s, ns] integer pair; convert to ms float.
228
+ var t = process.hrtime();
229
+ return t[0] * MS_PER_SECOND_HRTIME + t[1] / NS_PER_MS;
230
+ }
231
+
232
+ module.exports = {
233
+ create: create,
234
+ StreamThrottleError: StreamThrottleError,
235
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.10.12",
3
+ "version": "0.10.13",
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:c0157c5c-5ef3-45a3-aef2-004727f9fd8c",
5
+ "serialNumber": "urn:uuid:7f2411e6-1488-4a9f-954b-bef06640070c",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-18T06:22:30.244Z",
8
+ "timestamp": "2026-05-18T20:01:39.367Z",
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.10.12",
22
+ "bom-ref": "@blamejs/core@0.10.13",
23
23
  "type": "library",
24
24
  "name": "blamejs",
25
- "version": "0.10.12",
25
+ "version": "0.10.13",
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.10.12",
29
+ "purl": "pkg:npm/%40blamejs/core@0.10.13",
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.10.12",
57
+ "ref": "@blamejs/core@0.10.13",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]