@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 +1 -0
- package/README.md +2 -0
- package/index.js +4 -0
- package/lib/cms-codec.js +685 -0
- package/lib/daemon.js +29 -4
- package/lib/mail-crypto-pgp.js +10 -9
- package/lib/mail-crypto-smime.js +15 -31
- package/lib/metrics.js +68 -8
- package/lib/stream-throttle.js +235 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
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,
|
package/lib/cms-codec.js
ADDED
|
@@ -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
|
-
|
|
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:
|
|
245
|
-
stdio:
|
|
246
|
-
cwd:
|
|
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" };
|
package/lib/mail-crypto-pgp.js
CHANGED
|
@@ -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)
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
* `b.
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
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
|
package/lib/mail-crypto-smime.js
CHANGED
|
@@ -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
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
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
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
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
|
-
*
|
|
793
|
-
*
|
|
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
|
-
* //
|
|
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
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:
|
|
5
|
+
"serialNumber": "urn:uuid:7f2411e6-1488-4a9f-954b-bef06640070c",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-
|
|
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.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.10.13",
|
|
23
23
|
"type": "library",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.10.
|
|
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.
|
|
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.
|
|
57
|
+
"ref": "@blamejs/core@0.10.13",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|