@blamejs/pki 0.4.1 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,7 +4,45 @@ All notable changes to `@blamejs/pki` are documented here. The format
4
4
  follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this
5
5
  project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
- ## v0.4.1 — 2026-08-08
7
+ ## v0.4.3 — 2026-08-08
8
+
9
+ pki.tls encodes and decodes RFC 8879 compressed certificate messages -- the largest payload a TLS handshake carries, and the one post-quantum chains grow by kilobytes -- with the two-sided decompression bound the specification requires. Alongside it, SHAKE128 and SHAKE256 join the digest surface, which brings the Ed448 composite signature arm into service.
10
+
11
+ ### Added
12
+
13
+ - pki.tls.decompressCertificate and pki.tls.compressCertificate encode and decode an RFC 8879 CompressedCertificate. Decompression applies the bound RFC 8879 sec. 5 requires on both sides: the decompressor is capped at the message's own declared uncompressed length, so a decompression bomb is refused as its output would exceed that declaration rather than after the memory is committed, and the recovered length must then equal the declaration exactly. A caller's own cap applies independently and can only tighten the limit, never raise it. An algorithm outside the registry, one this runtime cannot decompress, or one absent from the caller's advertised set is refused before any decompressor runs; an empty compressed body is a framing violation; and trailing bytes are refused, so one chain has exactly one encoding. compressCertificate decodes its own output before returning it, so a message this toolkit produces cannot be one this toolkit refuses, and refuses to emit one whose own framing would exceed the handshake limit.
14
+ - All three registered compression algorithms are implemented, and each is offered only where the running Node can decompress it safely. A decompressor is required to fault on a frame it could not finish; where one instead returns a short result and reports the whole input as consumed, a peer could cut a frame's tail and have the receiver process a prefix as if it were the entire message. Any algorithm whose decompressor behaves that way is dropped at startup and is then neither advertised nor accepted, rather than being offered with a truncation it cannot detect. On the current long-term-support Node this drops zstd, leaving zlib and brotli; it returns by itself on a runtime that reports the fault.
15
+ - pki.tls.parseCertificateMessage decodes the RFC 8446 sec. 4.4.2 Certificate message itself, surfacing each entry's certificate DER exactly as it arrived -- ready for pki.schema.x509.parse and never re-serialized -- alongside its raw extensions and the certificate request context. The certificate type is negotiated by a separate extension and is not present in the message, so it is declared through an option rather than inferred from the bytes. The number of entries is bounded: a message's byte ceiling does not limit how many it declares, since the smallest legal entry is six bytes, so a message well inside the framing limit could otherwise declare hundreds of thousands and exhaust memory. The cap matches the longest chain the path validator will accept, and is exactly one under a negotiated RawPublicKey type, which RFC 8446 sec. 4.4.2 requires.
16
+ - pki.webcrypto.subtle.digest computes SHAKE128 and SHAKE256, at the 32- and 64-byte lengths RFC 8702 sec. 4 fixes for message-digest use. The length follows from the name rather than being chosen by the caller, so a digest cannot be squeezed to a non-conforming width. The extendable-output functions are a digest route only: the signature, MAC and key-derivation operations continue to refuse them with the same typed error as before.
17
+ - The composite signature arm id-MLDSA87-Ed448-SHAKE256 now verifies and signs. It was registered and parameter-guarded but failed closed as unsupported because its SHAKE256 pre-hash was unavailable; it is now checked byte-for-byte against the composite specification's own known-answer certificate, and both components must pass for the signature to be accepted. Sixteen of the eighteen arms now verify; the two remaining are the brainpool-curve arms.
18
+
19
+ ### Changed
20
+
21
+ - pki.cms.sign and pki.cms.verify compute their message digests through the crypto engine rather than each holding a private digest table. Behaviour is unchanged; the digest algorithms a signer and a verifier accept are now defined in one place.
22
+
23
+ ### Fixed
24
+
25
+ - pki.cms.decompress refuses a stream carrying bytes after the end of the compressed data. A decompressor stops at the end of the first complete frame and ignores whatever follows, so arbitrary bytes -- or a second entire frame -- could be appended and the same content still recovered. That gave one content unboundedly many encodings, so a digest over the compressed object no longer identified what it decompressed to. The whole octet string must now be exactly one frame, which is what the DER layer already required of its own encodings.
26
+ - An unusable hash is now refused when a key is created rather than when it is first used. pki.webcrypto.subtle.importKey and generateKey recorded the requested hash without resolving it, so a name this engine cannot use produced a CryptoKey that failed only at its first sign, verify or wrap -- after the caller had already paid for the key generation. The name is now resolved at the entry point, through the same table the operations use, so what a key can be created with and what it can be used with cannot diverge.
27
+
28
+ ## v0.4.2 — 2026-08-07
29
+
30
+ A NumericString attribute value no longer shares distinguished-name identity with a printable or UTF-8 value of the same characters -- the comparison that decides name chaining, revocation-issuer matching and name constraints. Alongside it, several C509 name-encoding conformance fixes and a move to Node 24.19.0.
31
+
32
+ ### Changed
33
+
34
+ - The supported Node floor moves to 24.19.0, the current long-term-support release. Nothing is transpiled, so the supported version is the version the source runs on; the release is verified against that runtime.
35
+ - pki.asn1.read.numericString reads a NumericString value, validated strictly to the digits and space the type permits. The shared string reader no longer accepts the type, so a caller that wants it asks for it by name.
36
+
37
+ ### Fixed
38
+
39
+ - A NumericString attribute value no longer compares equal to a PrintableString or UTF8String attribute value carrying the same characters. RFC 5280 sec. 7.1 name comparison folds the directory-string types into one identity class, and NumericString is not one of them; because the previous release read it through the shared string reader, it entered that class and was treated as the same name by the comparison that decides certificate chaining, revocation-issuer matching and name-constraint evaluation. It now reads through its own reader and, as before, renders in the RFC 4514 hexadecimal form rather than as a plain string.
40
+ - A natively signed C509 certificate is no longer accepted with, or built carrying, a negative attribute-type integer. The sign of that integer exists only to reproduce the string type of an original X.509 encoding, which a natively signed certificate does not have, so all of its integers are non-negative (draft-ietf-cose-cbor-encoded-cert-20 sec. 3.1.4); the toolkit previously read such a certificate and could also emit one that a conformant implementation must reject.
41
+ - A country name or serial number attribute now keeps the string type its attribute integer's sign declares, and its restriction to the printable-string character subset is enforced on the characters instead. Both signs previously rebuilt the same certificate bytes, so two distinct compact encodings of one value produced one identical certificate under a single signature.
42
+ - The rendered distinguished-name string now escapes its values (RFC 4514 sec. 2.4), so an attribute value containing a comma can no longer read as though the name held several attributes, and a control byte can no longer reach a log line unescaped.
43
+ - An empty issuer name is now refused (RFC 5280 sec. 4.1.2.4 requires a non-empty issuer). It previously parsed and rebuilt a certificate that this toolkit's own certificate parser declines to load. An empty subject is still accepted; the profile pairs that with a subject alternative name, which this codec does not yet require.
44
+
45
+ ## v0.4.1 — 2026-08-07
8
46
 
9
47
  pki.schema.c509 encodes and decodes the compact subjectDirectoryAttributes value form -- a C509 certificate's subject directory attributes ride their draft-20 registry integers (or unwrapped OIDs) with their directory-string values, interoperating with a conformant C509 implementation rather than only this decoder.
10
48
 
package/README.md CHANGED
@@ -63,7 +63,7 @@ Web Crypto API with its limits on streaming, opaque keys, and algorithm reach.
63
63
  npm i @blamejs/pki
64
64
  ```
65
65
 
66
- Requires Node.js 24.18+ (runs on the shipped runtime — no build step, no
66
+ Requires Node.js 24.19+ (runs on the shipped runtime — no build step, no
67
67
  transpilation).
68
68
 
69
69
  ```js
@@ -202,6 +202,7 @@ is callable today; nothing below is a stub.
202
202
  | `pki.cbor` | Strict, bounded RFC 8949 deterministic CBOR codec — `decode` (zero-copy node tree) + `read.*` typed leaf readers incl. the keyed map lookup `read.mapGet` (text or COSE-label integer key, the map's major type asserted in the accessor), fail-closed on every non-canonical shape (indefinite length, non-minimal argument, unsorted / duplicate map keys, non-shortest float, trailing bytes) |
203
203
  | `pki.oid` | Two-way OID ↔ name registry — `name`, `byName`, `register`, `toArcs`/`fromArcs`, `toDER`/`fromDER`; seeded with RFC 5280 + NIST PQC arcs |
204
204
  | `pki.webcrypto` | A W3C WebCrypto (`SubtleCrypto`) engine over `node:crypto` — `sign`/`verify`/`encrypt`/`decrypt`/`deriveBits`/`digest`/`generateKey`/`importKey`/`exportKey` across RSA, ECDSA, ECDH, Ed25519/Ed448, AES, HMAC, HKDF, PBKDF2, SHA — **and** post-quantum ML-DSA-44/65/87 and SLH-DSA signatures, plus ML-KEM-512/768/1024 key generation and certificate/PKCS#8 import — the RFC 9935 seed / expandedKey / both private-key CHOICE is validated fail-closed, so an OpenSSL-legacy bare-seed or an internally inconsistent key is rejected with a typed error (KEM encapsulation lands with CMS KEM-decrypt). Zero-dependency, OpenSSL-interoperable |
205
+ | `pki.tls` | RFC 8879 TLS certificate compression — `decompressCertificate` / `compressCertificate` decode and build a `CompressedCertificate` (zlib / brotli / zstd, each offered only where the runtime decompresses it safely) with the two-sided bound RFC 8879 §5 requires (capped at the message's own declared length, then compared to it exactly), and `parseCertificateMessage` decodes the RFC 8446 §4.4.2 Certificate message to per-entry certificate DER |
205
206
  | `pki.schema` | The schema family — `parse` detects which PKI format DER / PEM encodes and routes to the right parser, `all` enumerates the registered formats, and the engine + per-format members are grouped here |
206
207
  | `pki.schema.x509` | Parse DER / PEM certificates into structured, validated fields, with named + partly-decoded extensions — including the RFC 3739 / ETSI EN 319 412-5 qualified-certificate `qcStatements` (EU-qualified declaration, reliance limit, QSCD flag, certificate type, retention, PDS URLs, country of qualification; unknown statements preserved opaque) and the Microsoft Active Directory Certificate Services enrollment extensions (certificate template, CA version, previous-CA-certificate hash, application policies), fail-closed — `parse`, `pemDecode`, `pemEncode` |
207
208
  | `pki.schema.c509` | Parse **and encode** C509 CBOR-encoded certificates (draft-ietf-cose-cbor-encoded-cert) — the compact CBOR profile of X.509, decoded fail-closed under deterministic CBOR; an explicit `parse` call (CBOR, not DER, so not auto-routed). `encode(input)` is the byte-exact inverse: a DER X.509 v3 certificate forward-transforms to a compact type-3 C509 whose reconstruction reproduces the original DER byte for byte (so the original signature still verifies), or a `parse` result re-emits its native array — canonical deterministic CBOR with the registry integer shorthands, the C509 compressions, and the compact draft-20 per-extension value forms — the scalar extensions (keyUsage, basicConstraints, extended key usage, subject key identifier, and more), the general-name-bearing extensions (subjectAltName, issuer alt name, name constraints, CRL distribution points, authority/subject information access, and the full authority key identifier) over one shared GeneralNames value codec, certificate policies (registry-integer or OID policy identifiers with their CPS-URI and UserNotice qualifiers), and policy mappings and policy constraints, and subject directory attributes; a certificate outside the invertible set throws a typed `C509Error` |
@@ -236,6 +237,7 @@ is callable today; nothing below is a stub.
236
237
  | `pki.tsp` | RFC 3161 Time-Stamp Protocol — `sign(messageImprint, tsa, opts)` produces a TimeStampToken: a CMS SignedData (over `pki.cms.sign`) whose content is a `TSTInfo` carrying the timestamped message imprint, the TSA policy, a serial number, and `genTime` (with optional accuracy / nonce / ordering), plus the RFC 3161 §2.4.2 signing-certificate attribute binding the token to the TSA certificate (SHA-2 imprints, any `pki.cms.sign` TSA key). `request` / `parseRequest` build and parse the TimeStampReq a client sends (imprint, requested policy, nonce, certReq), `response` / `parseResponse` the TimeStampResp a TSA returns — a granted status wrapping a token, or a rejection with PKIStatus and failure info, the §2.4.2 status↔token coupling enforced in both directions. `verify(token, data, opts)` verifies a token fail-closed: the CMS signature over the exact signed bytes, the message imprint recomputed from the data, the TSTInfo content type, the ESSCertID(V2) binding to the TSA certificate, the §2.3 critical timeStamping-only extendedKeyUsage, the request nonce when used, and — with a trust anchor supplied — full certification-path validation of the TSA certificate at the token's `genTime`, returning `{ valid, genTime, serialNumber, tstInfo, … }` — `sign`, `request`, `parseRequest`, `response`, `parseResponse`, `verify` |
237
238
  | `pki.ocsp` | RFC 6960 Online Certificate Status Protocol — the responder and relying-party surface. `buildRequest(query, opts)` builds an OCSPRequest for one or more `{ cert, issuer }` pairs (CertID hashed under SHA-1 by default per the RFC 5019 lightweight profile, or SHA-2; optional RFC 9654 nonce, optional requestor signature). `sign(responseData, responder, opts)` produces a signed BasicOCSPResponse over the exact `ResponseData` DER — the issuing CA directly or a delegated responder, any `pki.cms.sign` key including the post-quantum ML-DSA / SLH-DSA sets, with `good` / `revoked` (reason + time) / `unknown` per-certificate status, and `buildErrorResponse(status)` the unsigned §2.3 error (`tryLater` / `unauthorized` / …). `verify(response, opts)` verifies a response fail-closed against the same hardened gates `pki.path.ocspChecker` runs: the CertID binding, responder authorization (the issuing CA or a CA-issued delegate bearing id-kp-OCSPSigning **and** id-pkix-ocsp-nocheck, passing the full out-of-path certificate gates), the signature over `tbsResponseDataBytes`, currency (`thisUpdate`/`nextUpdate`), and the request-nonce echo — returning `{ status: "good" / "revoked" / "unknown", … }`, never a silent accept. Transport-free — `buildRequest`, `sign`, `buildErrorResponse`, `verify` |
238
239
  | `pki.ct` | RFC 6962 Certificate Transparency SCTs — `parseSctList` decodes the `SignedCertificateTimestampList` a certificate or OCSP response carries in the SCT extension (a TLS-presentation-language payload inside the §3.3 double DER wrap) into per-SCT log id, exact `timestamp` (BigInt), named signature algorithm, and raw signature; `reconstructSignedData` rebuilds the exact `digitally-signed` preimage; `verifySct` verifies an SCT signature against a log's public key by reconstructing the signed data, routing an ECDSA signature through the strict DER-conformance gate, and verifying through the crypto engine — resolving true or false, and throwing a typed error on a structural fault. The producing side: `encodeSctList` builds the extension value byte-for-byte (the exact inverse of `parseSctList`) and `signSct` performs a log's signing step (rebuilding the same signed-data preimage the verifier hashes and signing it with the log's ECDSA-P-256 / RSA key). The trust surface: `parseLogList` ingests the CT log-list JSON into constraint-carrying trusted logs — recomputing each log's id as SHA-256 of its key and refusing a disagreeing id (a swapped key, §3.2), decoding the state + temporal-interval constraints — and `verifySctWithLogList` resolves the log key from an SCT's log id, enforces the state (usable/qualified/readonly trusted; retired only before retirement; pending/rejected refused) and the temporal-interval window, then delegates the signature check to `verifySct`. `verifyLogListSignature(json, signature, publicKey)` verifies the detached `log_list.sig` over the raw log-list bytes against a caller-pinned signer key (RSASSA-PKCS1-v1.5/SHA-256, EC P-256 arm; forgeable-key defenses fail closed) — cross-checked against `openssl dgst`, completing the offline log-list trust chain. `fetchLogList(opts)` turns that chain into a live client: it GETs the `log_list.json` and its detached `log_list.sig` over the shared `pki.transport`, verifies the detached signature over the raw fetched bytes against a caller-pinned distributor key **before** parsing (verify-before-parse — an unverified document is never parsed, read, cached, or surfaced), then ingests the same bytes through `parseLogList` and returns the trusted-log set plus the surfaced `version`/`timestamp`. No baked-in vendor URL or key (both caller-pinned); explicit TLS trust with `rejectUnauthorized` always on; each response is size-capped before the trust chain; the transport is dependency-injectable so it is fully testable offline. Structure decoded, crypto fail-closed — `parseSctList`, `reconstructSignedData`, `verifySct`, `encodeSctList`, `signSct`, `parseLogList`, `verifySctWithLogList`, `verifyLogListSignature`, `fetchLogList` |
240
+ | `pki.tls` | RFC 8879 TLS certificate compression + the RFC 8446 Certificate message it carries -- the handshake's largest payload, and the one post-quantum chains grow by kilobytes. `decompressCertificate(bytes, opts)` decodes a `CompressedCertificate` and returns the algorithm, the declared uncompressed length, the recovered Certificate message raw, and that message already decoded to per-entry certificate DER. The decompression bound is the two-sided one sec. 5 requires: the decompressor is capped at the message's OWN declared length, so a bomb is refused mid-stream rather than allocated, and the recovered length must then equal that declaration exactly -- which catches the under-length direction a cap cannot see. An algorithm the receiver never advertised is refused before any decompressor runs, an empty compressed body is a framing violation, and trailing bytes -- after the message or after the compressed frame -- are refused, so one chain has exactly one encoding. `compressCertificate(message, opts)` is the producing inverse and verifies its own output round-trips before returning it. `parseCertificateMessage(bytes, opts)` decodes the RFC 8446 sec. 4.4.2 Certificate message on its own, surfacing each entry's certificate DER ready for `pki.schema.x509.parse` and its raw extensions; `certificate_type` is negotiated by a separate extension and so is declared, never guessed. All three registered algorithms (zlib, brotli, zstd) are implemented, and each is offered only where the running Node can decompress it safely -- one whose decompressor answers a truncated frame with a short result instead of a fault is dropped at startup rather than advertised with a truncation it cannot detect. Structure only -- no handshake is spoken and no certificate verified -- `decompressCertificate`, `compressCertificate`, `parseCertificateMessage` |
239
241
  | `pki.merkle` | RFC 6962 / RFC 9162 Merkle-tree proof verification — `leafHash` / `nodeHash` / `emptyRootHash` build the domain-separated (0x00 leaf / 0x01 node) SHA-256 tree hashes; `verifyInclusion` folds an audit proof back to a root and `verifyConsistency` reconstructs both the old and new root (the append-only guarantee), each constant-time-compared to a trusted checkpoint root. Fail-closed on bad geometry, sync hashing, transport-free — `leafHash`, `nodeHash`, `emptyRootHash`, `verifyInclusion`, `verifyConsistency` |
240
242
  | `pki.trust` | Mozilla / CCADB trust-store ingestion — `parseCertdata` reads the NSS `certdata.txt` object stream and `parseCcadbCsv` the CCADB CSV export into one identical constraint-carrying anchor shape: the per-purpose trust bits (only `CKT_NSS_TRUSTED_DELEGATOR` grants) and the per-purpose distrust-after dates the bare root list omits. Certificate and trust objects pair by byte-exact issuer + serial (never adjacency) and are cross-checked against the parsed DER, so metadata can never attach to the wrong root; `anchor()` hands an entry to `pki.path.validate({ trustAnchor, checkPurpose })`. Offline, fail-closed, bounded — `parseCertdata`, `parseCcadbCsv`, `anchor` |
241
243
  | `pki.shbs` | Stateful hash-based signature **verification** — HSS/LMS (RFC 8554), carried in X.509 by RFC 9802 and CMS by RFC 9708, profiled by NIST SP 800-208 (CNSA 2.0 firmware signing). `verify` checks an HSS signature (every level must pass) and `verifyLms` a single-tree LMS, over the raw public-key / signature blobs the parsers already surface. Pure public-input SHA-256 / SHAKE256 hashing, a data-driven typecode registry, bounds-before-slice reads; a malformed blob throws a typed `ShbsError`, a well-formed-but-wrong signature returns `false`. **Verify only by design** — stateful signing needs atomic one-time-key state that belongs in an HSM — `verify`, `verifyLms` |
package/index.js CHANGED
@@ -36,6 +36,7 @@ var webcrypto = require("./lib/webcrypto");
36
36
  var schema = require("./lib/schema-all");
37
37
  var path = require("./lib/path-validate");
38
38
  var ct = require("./lib/ct");
39
+ var tls = require("./lib/tls-cert-compress");
39
40
  var cms = require("./lib/cms-verify");
40
41
  var smime = require("./lib/smime");
41
42
  var tsp = require("./lib/tsp-sign");
@@ -86,6 +87,10 @@ module.exports = {
86
87
  // SCT-list extension a certificate / OCSP response carries; the signature is
87
88
  // surfaced raw for external verification (pki.ct.reconstructSignedData).
88
89
  ct: ct,
90
+ // 'tls' is TLS handshake structures that carry certificates -- RFC 8879 compressed
91
+ // certificate messages (zlib / brotli / zstd) and the RFC 8446 Certificate message
92
+ // inside them, decoded to per-entry certificate DER. Structure only; no handshake.
93
+ tls: tls,
89
94
  cms: cms,
90
95
  smime: smime,
91
96
  tsp: tsp,
package/lib/asn1-der.js CHANGED
@@ -623,11 +623,20 @@ function _decodePrintable(buf) {
623
623
  return s;
624
624
  }
625
625
 
626
- // NumericString (X.680 sec. 41, Table 9) permits ONLY the digits and SPACE. It carries the X.520 syntax of
627
- // the x121Address and internationalISDNNumber directory attributes, so a value of this type must be readable
628
- // -- and strictly, since anything outside that alphabet is not a valid encoding of the type.
629
- function _decodeNumeric(buf) {
630
- var s = buf.toString("latin1");
626
+ // NumericString (X.680 sec. 41, Table 9) permits ONLY the digits and SPACE. It carries the X.520 syntax of the
627
+ // x121Address and internationalISDNNumber directory attributes.
628
+ //
629
+ // It is read through its OWN reader, deliberately NOT through readString. Every type readString returns a
630
+ // plain string for enters the RFC 5280 sec. 7.1 name-comparison identity class, and
631
+ // guard.name.dnEqual -- the single choke point for name chaining, revocation-issuer matching and name
632
+ // constraints -- compares the strings it returns. Admitting NumericString there would make a NumericString
633
+ // attribute value compare EQUAL to a PrintableString / UTF8String one with the same characters, silently
634
+ // widening distinguished-name identity across the whole toolkit. A caller that genuinely wants this type asks
635
+ // for it by name.
636
+ function readNumericString(node) {
637
+ _expectUniversal(node, TAGS.NUMERIC_STRING, "readNumericString");
638
+ _expectPrimitive(node, "readNumericString");
639
+ var s = node.content.toString("latin1");
631
640
  if (!NUMERIC_RE.test(s)) throw new Asn1Error("asn1/bad-numeric-string", "NumericString has characters outside the digits-and-space set");
632
641
  return s;
633
642
  }
@@ -647,7 +656,6 @@ function readString(node) {
647
656
  _expectPrimitive(node, "readString");
648
657
  switch (node.tagNumber) {
649
658
  case TAGS.UTF8_STRING: return _decodeUtf8Strict(node.content);
650
- case TAGS.NUMERIC_STRING: return _decodeNumeric(node.content);
651
659
  case TAGS.PRINTABLE_STRING: return _decodePrintable(node.content);
652
660
  case TAGS.IA5_STRING: return _decodeIa5(node.content);
653
661
  case TAGS.TELETEX_STRING: return _decodeText(node.content, "latin1");
@@ -1079,6 +1087,7 @@ module.exports = {
1079
1087
  booleanImplicit: readBooleanImplicit,
1080
1088
  oid: readOid,
1081
1089
  string: readString,
1090
+ numericString: readNumericString,
1082
1091
  time: readTime,
1083
1092
  },
1084
1093
  };
@@ -92,20 +92,18 @@ function _toDer(input) {
92
92
  throw _err("cms/bad-input", "input must be a DER Buffer, Uint8Array, or PEM string");
93
93
  }
94
94
 
95
- // Inflate an RFC 1950 ZLIB stream, bounding the output at `cap` bytes BEFORE it is materialized:
96
- // Node's maxOutputLength throws ERR_BUFFER_TOO_LARGE the moment the output would exceed the bound
97
- // (it does not first allocate the whole output), so a decompression bomb cannot exhaust memory
98
- // (CWE-409). The cap breach maps to cms/decompress-too-large; every other zlib fault (bad RFC-1950
99
- // header, truncated stream, corrupt DEFLATE, trailing garbage) collapses to the uniform
100
- // cms/decompress-failed -- a per-errno surface would be needless attack telemetry.
95
+ // Inflate the RFC 1950 ZLIB stream through the shared bounded-decompression guard, which applies
96
+ // the output cap at the decompressor (so a bomb is refused mid-stream rather than after it is
97
+ // allocated) and requires the whole octet string to be exactly one compressed frame (so trailing
98
+ // bytes cannot give one content a second encoding). This module keeps its own verdict codes: a cap
99
+ // breach is cms/decompress-too-large, every other fault the uniform cms/decompress-failed.
100
+ //
101
+ // `_err` is a FACTORY, and the guard calls it as one -- handing it CmsError instead would throw
102
+ // "class cannot be invoked without new" on the rejection path, turning a refusal into a crash.
101
103
  function _inflateBounded(stream, cap) {
102
104
  var view = guard.bytes.view(stream, CmsError, "cms/decompress-failed", "the compressed content");
103
- try {
104
- return zlib.inflateSync(view, { maxOutputLength: cap });
105
- } catch (e) {
106
- if (e && e.code === "ERR_BUFFER_TOO_LARGE") throw _err("cms/decompress-too-large", "the decompressed output exceeds the " + cap + "-byte cap (a decompression-bomb defense)", e);
107
- throw _err("cms/decompress-failed", "the compressed content could not be decompressed", e);
108
- }
105
+ return guard.compress.bounded("zlib", view, cap, _err,
106
+ { tooLarge: "cms/decompress-too-large", failed: "cms/decompress-failed" }, "the compressed content");
109
107
  }
110
108
 
111
109
  module.exports = { compress: compress, decompress: decompress };
package/lib/cms-sign.js CHANGED
@@ -13,17 +13,19 @@
13
13
  // for ECDSA/EdDSA, the RSASSA-PSS params SEQUENCE), with the sign->verify round-trip (and OpenSSL
14
14
  // cms -verify) as the guard.
15
15
 
16
- var nodeCrypto = require("crypto");
17
16
  var asn1 = require("./asn1-der");
18
17
  var oid = require("./oid");
19
18
  var x509 = require("./schema-x509");
20
19
  var pkix = require("./schema-pkix");
21
20
  var frameworkError = require("./framework-error");
22
21
 
22
+ var webcrypto = require("./webcrypto");
23
23
  var signScheme = require("./sign-scheme");
24
24
  var guard = require("./guard-all");
25
25
  var pkiBuild = require("./pki-build");
26
26
  var cms = require("./schema-cms");
27
+
28
+ var subtle = webcrypto.webcrypto.subtle;
27
29
  var CmsError = frameworkError.CmsError;
28
30
  var b = asn1.build;
29
31
  function _err(code, message, cause) { return new CmsError(code, message, cause); }
@@ -32,20 +34,22 @@ function _err(code, message, cause) { return new CmsError(code, message, cause);
32
34
  function _signE(kind, message, cause) { return new CmsError("cms/" + kind, message, cause); }
33
35
  function O(name) { return oid.byName(name); }
34
36
 
35
- var NODE_DIGEST = { sha256: "sha256", sha384: "sha384", sha512: "sha512", shake128: "shake128", shake256: "shake256" };
36
- var SHAKE_OUT = { shake128: 32, shake256: 64 }; // SHAKE output lengths: SHAKE128 256-bit (RFC 9814 sec. 4), SHAKE256 512-bit (RFC 8419 sec. 2.3 / RFC 9814)
37
+ // A digest-algorithm name -> the engine's hash name. The FIPS 202 extendable-output
38
+ // functions are message digests here at the lengths RFC 8702 sec. 4 fixes for that use,
39
+ // which the engine applies from the name alone.
40
+ var DIGEST_HASH = {
41
+ sha256: "SHA-256", sha384: "SHA-384", sha512: "SHA-512",
42
+ shake128: "SHAKE128", shake256: "SHAKE256",
43
+ };
37
44
 
38
45
  var OID_DATA = O("data");
39
46
  var OID_SIGNED_DATA = O("signedData");
40
47
  var OID_SKI = O("subjectKeyIdentifier");
41
48
 
42
49
 
43
- // The message digest of the content under the digest algorithm (SHA-2 or SHAKE256).
50
+ // The message digest of the content under the digest algorithm, resolved to a Buffer.
44
51
  function _digest(digestName, content) {
45
- var h = SHAKE_OUT[digestName]
46
- ? nodeCrypto.createHash(NODE_DIGEST[digestName], { outputLength: SHAKE_OUT[digestName] })
47
- : nodeCrypto.createHash(NODE_DIGEST[digestName]);
48
- return h.update(content).digest();
52
+ return subtle.digest(DIGEST_HASH[digestName], content).then(function (d) { return Buffer.from(d); });
49
53
  }
50
54
 
51
55
 
@@ -140,13 +144,15 @@ function _buildSignerInfo(signer, content, eContentType, opts) {
140
144
  if (opts.signedAttributes === false) return content; // sign the content directly (no signed attributes)
141
145
  // Signed attributes (RFC 5652 sec. 5.3): content-type == eContentType, message-digest ==
142
146
  // digest(content), and (by default) signing-time, plus any caller-supplied attribute.
143
- var pairs = [
144
- { type: O("contentType"), values: [b.oid(eContentType)] },
145
- { type: O("messageDigest"), values: [b.octetString(_digest(scheme.digest, content))] },
146
- ];
147
- if (opts.signingTime !== false) pairs.push({ type: O("signingTime"), values: [_timeValue(opts.signingTime)] });
148
- pairs = pairs.concat(_resolveAttrPairs(opts.additionalSignedAttributes, "a signed attribute value"));
149
- return _buildSignedAttrs(pairs);
147
+ return _digest(scheme.digest, content).then(function (md) {
148
+ var pairs = [
149
+ { type: O("contentType"), values: [b.oid(eContentType)] },
150
+ { type: O("messageDigest"), values: [b.octetString(md)] },
151
+ ];
152
+ if (opts.signingTime !== false) pairs.push({ type: O("signingTime"), values: [_timeValue(opts.signingTime)] });
153
+ pairs = pairs.concat(_resolveAttrPairs(opts.additionalSignedAttributes, "a signed attribute value"));
154
+ return _buildSignedAttrs(pairs);
155
+ });
150
156
  }).then(function (toSign) {
151
157
  var signedBytes = toSign.setOf ? toSign.setOf : toSign; // SET-OF form for signing (sec. 5.4)
152
158
  return signScheme.signOverTbs(scheme, so.key, signedBytes, _signE).then(function (sig) {
@@ -272,11 +278,13 @@ function _buildCountersignature(targetSigOctets, countersigner, opts) {
272
278
  var sidv = _buildSid(cert, opts.sid === "ski");
273
279
  return Promise.resolve().then(function () {
274
280
  if (opts.signedAttributes === false) return null; // sign the target signature octets directly
275
- var pairs = [{ type: O("messageDigest"), values: [b.octetString(_digest(scheme.digest, targetSigOctets))] }];
276
- if (opts.signingTime !== false) pairs.push({ type: O("signingTime"), values: [_timeValue(opts.signingTime)] });
277
- var extra = _resolveAttrPairs(opts.additionalSignedAttributes, "a countersignature signed attribute value");
278
- extra.forEach(function (p) { if (p.type === O("contentType")) throw _err("cms/bad-input", "a countersignature must not carry a content-type attribute (RFC 5652 sec. 11.4)"); });
279
- return _buildSignedAttrs(pairs.concat(extra));
281
+ return _digest(scheme.digest, targetSigOctets).then(function (md) {
282
+ var pairs = [{ type: O("messageDigest"), values: [b.octetString(md)] }];
283
+ if (opts.signingTime !== false) pairs.push({ type: O("signingTime"), values: [_timeValue(opts.signingTime)] });
284
+ var extra = _resolveAttrPairs(opts.additionalSignedAttributes, "a countersignature signed attribute value");
285
+ extra.forEach(function (p) { if (p.type === O("contentType")) throw _err("cms/bad-input", "a countersignature must not carry a content-type attribute (RFC 5652 sec. 11.4)"); });
286
+ return _buildSignedAttrs(pairs.concat(extra));
287
+ });
280
288
  }).then(function (attrs) {
281
289
  return signScheme.signOverTbs(scheme, so.key, attrs ? attrs.setOf : targetSigOctets, _signE).then(function (sig) {
282
290
  var fields = [b.integer(BigInt(sidv.version)), sidv.sid, scheme.digestAlgId];
package/lib/cms-verify.js CHANGED
@@ -26,7 +26,6 @@
26
26
  * @card Verify a CMS SignedData signature (S/MIME, timestamps, code signing).
27
27
  */
28
28
 
29
- var nodeCrypto = require("crypto");
30
29
  var asn1 = require("./asn1-der");
31
30
  var oid = require("./oid");
32
31
  var x509 = require("./schema-x509");
@@ -53,16 +52,26 @@ var OID_MESSAGE_DIGEST = oid.byName("messageDigest");
53
52
  var OID_CONTENT_TYPE = oid.byName("contentType");
54
53
  var OID_COUNTERSIGNATURE = oid.byName("countersignature");
55
54
 
56
- // A digest-algorithm name -> the WebCrypto hash.
57
- var DIGEST_HASH = { sha1: "SHA-1", sha256: "SHA-256", sha384: "SHA-384", sha512: "SHA-512" };
58
- // SHAKE256 (RFC 8419 sec. 2.3, the Ed448 message digest) has no WebCrypto hash, so it is computed
59
- // with node:crypto; its 512-bit (64-byte) output length is fixed by the profile.
60
- var SHAKE_OUT = { shake128: 32, shake256: 64 }; // SHAKE128 256-bit (RFC 9814 sec. 4), SHAKE256 512-bit
61
- // Is `name` a message-digest algorithm this verifier supports (SHA-2 family or SHAKE256)?
62
- function _supportedDigest(name) { return !!(DIGEST_HASH[name] || SHAKE_OUT[name]); }
55
+ // A digest-algorithm name -> the engine's hash name, for computing a MESSAGE DIGEST (the
56
+ // message-digest attribute and the content digest). The FIPS 202 extendable-output functions
57
+ // belong here at the lengths RFC 8702 sec. 4 fixes for that use (SHAKE128 32 bytes, SHAKE256 64).
58
+ var DIGEST_HASH = {
59
+ sha1: "SHA-1", sha256: "SHA-256", sha384: "SHA-384", sha512: "SHA-512",
60
+ shake128: "SHAKE128", shake256: "SHAKE256",
61
+ };
62
+ // The SIGNATURE hash a bare-key-OID scheme (rsaEncryption, ecPublicKey) inherits from the
63
+ // SignerInfo digestAlgorithm. Deliberately a SEPARATE table from DIGEST_HASH, and deliberately
64
+ // without the extendable-output functions: RFC 8702 sec. 3.2 gives RSASSA-PKCS1-v1_5-with-SHAKE
65
+ // and ECDSA-with-SHAKE their OWN signature OIDs and never pairs a bare key OID with a SHAKE
66
+ // digestAlgorithm, so that combination is non-conformant and must keep its precise refusal.
67
+ // Folding the two roles into one table would resolve a signature hash for it instead, letting
68
+ // it past the fail-closed gate below and into the engine, where it surfaces as a relabeled
69
+ // foreign fault rather than this module's own unsupported-algorithm verdict.
70
+ var SIG_HASH = { sha1: "SHA-1", sha256: "SHA-256", sha384: "SHA-384", sha512: "SHA-512" };
71
+ // Is `name` a message-digest algorithm this verifier supports?
72
+ function _supportedDigest(name) { return !!DIGEST_HASH[name]; }
63
73
  // The digest of `content` under the named algorithm, resolved to a Buffer.
64
74
  function _computeDigest(name, content) {
65
- if (SHAKE_OUT[name]) return Promise.resolve(nodeCrypto.createHash(name, { outputLength: SHAKE_OUT[name] }).update(content).digest());
66
75
  return subtle.digest(DIGEST_HASH[name], content).then(function (d) { return Buffer.from(d); });
67
76
  }
68
77
  // A signatureAlgorithm name -> its verify scheme. A combined OID (sha256WithRSAEncryption,
@@ -322,7 +331,7 @@ function _verifyOne(si, content, eContentType, parsedCerts, csTarget) {
322
331
  if (composite) return _verifyComposite(si, composite, content, eContentType, parsedCerts, csTarget);
323
332
  var scheme = SIG_SCHEME[si.signatureAlgorithm.name];
324
333
  if (!scheme) return Promise.resolve({ ok: false, code: "cms/unsupported-algorithm", sid: si.sid, message: "unsupported signature algorithm " + JSON.stringify(si.signatureAlgorithm.name) });
325
- var digestHash = DIGEST_HASH[si.digestAlgorithm.name];
334
+ var digestHash = SIG_HASH[si.digestAlgorithm.name];
326
335
  // RSASSA-PSS takes its signature hash and salt length from the RSASSA-PSS-params, not the
327
336
  // digestAlgorithm; a non-conformant parameter set is a fail-closed unsupported verdict.
328
337
  var pss = scheme.kind === "rsapss" ? _resolvePss(si.signatureAlgorithm.parameters) : null;
@@ -458,8 +467,8 @@ function _verifyComposite(si, comp, content, eContentType, parsedCerts, csTarget
458
467
  if (si.signatureAlgorithm.parameters !== null && si.signatureAlgorithm.parameters !== undefined) {
459
468
  return Promise.resolve({ ok: false, code: "cms/unsupported-algorithm", sid: si.sid, message: "the composite signatureAlgorithm parameters must be absent (draft-ietf-lamps-cms-composite-sigs sec. 3.4)" });
460
469
  }
461
- // The 3 arms Node's WebCrypto surface cannot verify (brainpool curves; the SHAKE256/64 pre-hash)
462
- // fail closed -- never a silent single-component accept (the AND->OR downgrade this feature prevents).
470
+ // The 2 arms Node's WebCrypto surface cannot verify (the brainpool curves) fail closed --
471
+ // never a silent single-component accept (the AND->OR downgrade this feature prevents).
463
472
  if (comp.trad.unsupported) {
464
473
  return Promise.resolve({ ok: false, code: "cms/unsupported-algorithm", sid: si.sid, message: "composite " + comp.name + ": " + comp.trad.unsupported });
465
474
  }
@@ -54,9 +54,9 @@ var PH_CMS = { "SHA-256": "sha256", "SHA-512": "sha512", "SHAKE256": "shake256"
54
54
  var COMPOSITE_ALGS = {};
55
55
  // _comp(name, mldsa, ph, label, trad). `trad` is exactly one component shape:
56
56
  // { ec, hash } | { eddsa } | { rsaPss, hash, salt } | { rsaPkcs1, hash } |
57
- // { unsupported } for the arms Node's WebCrypto surface cannot verify (brainpool
58
- // curves; the SHAKE256/64 pre-hash) -- registered + params-guarded, deferred at
59
- // verify to the caller's unsupported-algorithm code rather than silently accepted.
57
+ // { unsupported } for the arms Node's WebCrypto surface cannot verify (the two
58
+ // brainpool curves) -- registered + params-guarded, deferred at verify to the
59
+ // caller's unsupported-algorithm code rather than silently accepted.
60
60
  function _comp(name, mldsa, ph, label, trad) {
61
61
  var sz = MLDSA_COMPONENT[mldsa];
62
62
  COMPOSITE_ALGS[oid.byName(name)] = {
@@ -78,7 +78,7 @@ _comp("id-MLDSA65-ECDSA-brainpoolP256r1-SHA512", "ML-DSA-65", "SHA-512", "COMPSI
78
78
  _comp("id-MLDSA65-Ed25519-SHA512", "ML-DSA-65", "SHA-512", "COMPSIG-MLDSA65-Ed25519-SHA512", { eddsa: "Ed25519" });
79
79
  _comp("id-MLDSA87-ECDSA-P384-SHA512", "ML-DSA-87", "SHA-512", "COMPSIG-MLDSA87-ECDSA-P384-SHA512", { ec: "P-384", hash: "SHA-384" });
80
80
  _comp("id-MLDSA87-ECDSA-brainpoolP384r1-SHA512", "ML-DSA-87", "SHA-512", "COMPSIG-MLDSA87-ECDSA-BP384-SHA512", { unsupported: "brainpoolP384r1 is not in the WebCrypto ECDSA curve set" });
81
- _comp("id-MLDSA87-Ed448-SHAKE256", "ML-DSA-87", "SHAKE256", "COMPSIG-MLDSA87-Ed448-SHAKE256", { unsupported: "the SHAKE256/64 pre-hash is not in the WebCrypto digest set" });
81
+ _comp("id-MLDSA87-Ed448-SHAKE256", "ML-DSA-87", "SHAKE256", "COMPSIG-MLDSA87-Ed448-SHAKE256", { eddsa: "Ed448" });
82
82
  _comp("id-MLDSA87-RSA3072-PSS-SHA512", "ML-DSA-87", "SHA-512", "COMPSIG-MLDSA87-RSA3072-PSS-SHA512", { rsaPss: true, hash: "SHA-256", salt: 32, rsaBits: 3072 });
83
83
  _comp("id-MLDSA87-RSA4096-PSS-SHA512", "ML-DSA-87", "SHA-512", "COMPSIG-MLDSA87-RSA4096-PSS-SHA512", { rsaPss: true, hash: "SHA-384", salt: 48, rsaBits: 4096 });
84
84
  _comp("id-MLDSA87-ECDSA-P521-SHA512", "ML-DSA-87", "SHA-512", "COMPSIG-MLDSA87-ECDSA-P521-SHA512", { ec: "P-521", hash: "SHA-512" });
package/lib/constants.js CHANGED
@@ -284,6 +284,19 @@ var LIMITS = {
284
284
  // output is materialized (a decompression-bomb / resource-exhaustion defense, CWE-409); a caller may
285
285
  // tighten it DOWNWARD via opts.maxOutputBytes, never loosen it.
286
286
  COMPRESS_MAX_BYTES: BYTES.mib(16),
287
+ // The largest TLS handshake message body. RFC 8446 sec. 4 frames every Handshake with a uint24
288
+ // length, so a Certificate message cannot exceed 2^24-1 bytes -- the ceiling RFC 8879 sec. 5
289
+ // requires a CompressedCertificate to respect ("they MUST apply the same limit as if no
290
+ // compression were used"). That section spells the number 16777216, which is one MORE than any
291
+ // uint24 can express; the field width governs, so this is deliberately not mib(16).
292
+ TLS_CERT_MSG_MAX_BYTES: BYTES.mib(16) - 1,
293
+ // The most CertificateEntry elements a TLS Certificate message may carry. The byte ceiling
294
+ // alone does not bound this: the smallest legal entry is 6 bytes (a uint24 length, one payload
295
+ // octet, an empty uint16 extensions vector), so a message inside the framing limit can declare
296
+ // millions of them and each costs far more heap than wire -- an allocation DoS (CWE-770) that
297
+ // terminates but exhausts memory. This matches PATH_MAX_CERTS: a chain longer than the path
298
+ // validator will ever accept has nothing to offer, so refusing it at the decoder is free.
299
+ TLS_CERT_MAX_ENTRIES: 100,
287
300
  // ACME challenge token entropy floor (RFC 8555 sec. 8, errata 6950): >= 128
288
301
  // bits of base64url is >= 22 characters. A shorter token is refused before use.
289
302
  ACME_TOKEN_MIN_CHARS: 22,
@@ -231,6 +231,15 @@ var SmimeError = defineClass("SmimeError", { withCause: true });
231
231
  // inner `asn1/*` decode error) as `.cause`.
232
232
  var CtError = defineClass("CtError", { withCause: true });
233
233
 
234
+ // TlsError -- a byte sequence that is not a well-formed TLS handshake structure:
235
+ // an RFC 8879 CompressedCertificate whose framing is violated (a lying vector
236
+ // length, a field read past its bound, an empty compressed message), whose
237
+ // compression algorithm this toolkit does not implement or the caller did not
238
+ // offer, whose declared uncompressed length disagrees with what the stream
239
+ // actually produces, or whose compressed body is a decompression bomb. Carries
240
+ // the underlying leaf fault (the decompressor's own error) as `.cause`.
241
+ var TlsError = defineClass("TlsError", { withCause: true });
242
+
234
243
  // C509Error -- a CBOR byte sequence that is not a well-formed C509 certificate
235
244
  // (draft-ietf-cose-cbor-encoded-cert): a root that is not the 11-element array, a
236
245
  // c509CertificateType outside {2,3}, a field encoded against its ~biguint/~time/~oid
@@ -359,6 +368,7 @@ module.exports = {
359
368
  CmpError: CmpError,
360
369
  PathError: PathError,
361
370
  CtError: CtError,
371
+ TlsError: TlsError,
362
372
  C509Error: C509Error,
363
373
  ShbsError: ShbsError,
364
374
  HpkeError: HpkeError,
package/lib/guard-all.js CHANGED
@@ -34,6 +34,9 @@
34
34
  // guard.identifier.assertCanonicalOid
35
35
  // -- canonical dotted-decimal OID string form
36
36
  // (canonicalization-divergence defence)
37
+ // guard.compress.bounded -- decompress an untrusted stream under a hard
38
+ // output cap, whole-input (decompression-bomb +
39
+ // trailing-frame malleability defence)
37
40
  // guard.header.assertField -- emitted MIME/RFC 5322 header field name +
38
41
  // value integrity (CR/LF/NUL header-injection
39
42
  // defence, CWE-93)
@@ -55,6 +58,7 @@ var encoding = require("./guard-encoding");
55
58
  var json = require("./guard-json");
56
59
  var identifier = require("./guard-identifier");
57
60
  var header = require("./guard-header");
61
+ var compress = require("./guard-compress");
58
62
 
59
63
  module.exports = {
60
64
  bytes: bytes,
@@ -68,4 +72,5 @@ module.exports = {
68
72
  json: json,
69
73
  identifier: identifier,
70
74
  header: header,
75
+ compress: compress,
71
76
  };
@@ -0,0 +1,164 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (c) blamejs contributors
3
+ "use strict";
4
+ //
5
+ // @internal -- no operator-facing namespace. The documented surface is the
6
+ // consumers whose decompression composes this guard (pki.cms.decompress,
7
+ // pki.tls.decompressCertificate).
8
+ //
9
+ // guard-compress -- bounded, non-malleable decompression of an untrusted stream.
10
+ // Node's zlib / brotli / zstd decompressors are by default both UNBOUNDED and
11
+ // PERMISSIVE, and each is a distinct vulnerability class:
12
+ //
13
+ // * Unbounded output (CWE-409, decompression bomb) -- a few hundred bytes
14
+ // expand to gigabytes. `maxOutputLength` refuses the moment the output WOULD
15
+ // exceed the bound; it does not allocate the whole output and then measure.
16
+ // So the cap has to be applied AT the call and can never be a check on the
17
+ // returned value.
18
+ // * Trailing input silently ignored (CWE-20, canonicalization malleability) --
19
+ // all three stop at the end of the first complete frame and discard whatever
20
+ // follows, so a caller may append arbitrary bytes, or a second entire frame,
21
+ // and still recover byte-identical output. One content would then have
22
+ // unboundedly many encodings, and a digest over the compressed object would
23
+ // stop identifying what it decompresses to.
24
+ //
25
+ // Both defences are applied here, once, for every algorithm, so a new consumer
26
+ // cannot pick up one and miss the other. Verified uniform across zlib, brotli
27
+ // and zstd: each reports consumed input as `engine.bytesWritten` under
28
+ // `info: true`, each raises ERR_BUFFER_TOO_LARGE on a cap breach, and each
29
+ // accepts trailing bytes when left unchecked.
30
+ //
31
+ // E is the caller's (code, message, cause) typed-error FACTORY -- never an error
32
+ // class. Passing a class here crashes on the error path ("class cannot be invoked
33
+ // without new"), which converts a rejection into a fail-open. `codes` names the
34
+ // two verdicts a caller distinguishes: `tooLarge` when the output would exceed
35
+ // the cap, `failed` for every other fault. Every non-cap fault collapses to one
36
+ // code deliberately -- a per-errno surface is attack telemetry.
37
+
38
+ var zlib = require("zlib");
39
+
40
+ // The decompressor per algorithm: a registry, so a new algorithm is a row rather
41
+ // than a branch and an unrecognized name fails closed.
42
+ var DECOMPRESS = {
43
+ zlib: zlib.inflateSync, // RFC 1950
44
+ brotli: zlib.brotliDecompressSync, // RFC 7932
45
+ zstd: zlib.zstdDecompressSync, // RFC 8478
46
+ };
47
+
48
+ // The compressors, used ONLY by the truncation probe below. Producing a compressed stream is
49
+ // not the guarded shape -- decompressing an untrusted one is.
50
+ var _PROBE_COMPRESS = {
51
+ zlib: zlib.deflateSync,
52
+ brotli: zlib.brotliCompressSync,
53
+ zstd: zlib.zstdCompressSync,
54
+ };
55
+
56
+ // A decompressor must REPORT a frame it could not finish. Not all of them do: on some supported
57
+ // runtimes the zstd binding returns a SHORT (often empty) result for a truncated frame instead
58
+ // of faulting, and reports the entire input as consumed -- so neither the returned bytes nor the
59
+ // consumed-length check below can see that the frame was cut. That is a silent truncation: a peer
60
+ // strips a frame's tail and the receiver processes a prefix as though it were the whole message.
61
+ //
62
+ // An algorithm whose runtime cannot report this cannot be decompressed safely here, so it is
63
+ // dropped from the advertised set at load rather than accepted with a defect. Consumers build
64
+ // their wire registries by intersecting with algorithms(), so a protocol simply does not offer
65
+ // or accept it, and it returns on its own once the runtime reports truncation.
66
+ // Nothing here is enabled by an exception alone: an algorithm qualifies only by POSITIVE proof
67
+ // that a whole frame round-trips AND that a cut frame is refused. Every other outcome -- a
68
+ // missing function, a compressor fault, a wrong round-trip, an unrelated error -- leaves it out.
69
+ //
70
+ // Coverage residual: the early-return branches are reachable only on a runtime whose own
71
+ // compression library is broken (a missing export, a compressor that throws on 46 ASCII bytes, a
72
+ // round-trip that does not reproduce its input). The tables are captured at module load, so those
73
+ // states cannot be induced from a test without replacing node:zlib beforehand -- and forcing them
74
+ // would assert nothing about the property this function exists to establish. They stay because
75
+ // their absence is what would make a broken runtime silently qualify an algorithm.
76
+ function _reportsTruncation(name) {
77
+ var compress = _PROBE_COMPRESS[name];
78
+ var decompress = DECOMPRESS[name];
79
+ if (typeof compress !== "function" || typeof decompress !== "function") return false;
80
+ var sample = Buffer.from("0123456789abcdefghijklmnopqrstuvwxyz0123456789");
81
+ var frame, whole;
82
+ try { frame = compress(sample); }
83
+ catch (_e) { /* allow:swallow-unverified a compressor that cannot compress 46 ASCII bytes is a broken runtime; the algorithm simply does not qualify, which is the fail-closed direction */ return false; }
84
+ // A cut of 2 removes the frame's end marker; the sample is long enough that what remains is
85
+ // still a plausible frame head rather than nothing at all.
86
+ if (!Buffer.isBuffer(frame) || frame.length < 4) return false;
87
+ try { whole = decompress(frame, { maxOutputLength: 4096 }); }
88
+ catch (_e2) { /* allow:swallow-unverified a decompressor that rejects its own compressor's whole frame is a broken runtime; the algorithm does not qualify, again fail-closed */ return false; }
89
+ if (!Buffer.isBuffer(whole) || !whole.equals(sample)) return false;
90
+ // The cut frame must be REFUSED. A returned value of any length -- including the empty buffer
91
+ // some bindings hand back -- means a truncation would pass through unseen.
92
+ var cutAccepted = false;
93
+ try {
94
+ decompress(frame.subarray(0, frame.length - 2), { maxOutputLength: 4096 });
95
+ cutAccepted = true;
96
+ } catch (_e3) { /* refused, as a decompressor handed an unfinished frame must */ }
97
+ return !cutAccepted;
98
+ }
99
+
100
+ var SAFE = {};
101
+ Object.keys(DECOMPRESS).forEach(function (n) { if (_reportsTruncation(n)) SAFE[n] = true; });
102
+
103
+ // bounded(algorithm, stream, cap, E, codes, label) -> Buffer. Decompress `stream`
104
+ // under `algorithm`, refusing any output that would exceed `cap` bytes and any
105
+ // input carrying bytes past the end of the compressed frame.
106
+ // The shape is the DECOMPRESSOR CALL ITSELF, not the presence of a cap: an
107
+ // uncapped call is precisely the dangerous form, so keying on `maxOutputLength`
108
+ // would fire on the safe re-inline and stay silent on the unsafe one. Every
109
+ // decompression in lib/ therefore belongs to this module or routes through it.
110
+ // @enforced-by guard-shape-reinlined
111
+ // @guard-shape \bzlib\.(inflate|brotliDecompress|zstdDecompress)Sync\b
112
+ // @guard-via \bguard\.compress\.bounded\s*\(
113
+ function bounded(algorithm, stream, cap, E, codes, label) {
114
+ var decompress = DECOMPRESS[algorithm];
115
+ // An unknown algorithm -- or one this runtime cannot decompress safely -- is an authoring
116
+ // fault, not untrusted input: a caller resolves a wire value to a name against algorithms()
117
+ // and rejects one it does not find, before reaching here. Arriving with a name outside that
118
+ // set means the consumer skipped the step, so it faults loudly rather than silently.
119
+ if (!Object.prototype.hasOwnProperty.call(SAFE, algorithm) || !decompress) {
120
+ throw new TypeError("guard.compress.bounded: unknown or unsafe algorithm " + JSON.stringify(algorithm));
121
+ }
122
+ // The byte-source normalization is guard.bytes.view's job and belongs at the
123
+ // consumer's boundary; reaching here with anything else means it was skipped.
124
+ if (!Buffer.isBuffer(stream)) {
125
+ throw new TypeError("guard.compress.bounded: stream must be a Buffer (route the input through guard.bytes.view first)");
126
+ }
127
+ // The ceiling is an authoring input: an undefined / NaN / fractional / negative
128
+ // cap would silently disable the bomb defence. It must also be at least 1 --
129
+ // node rejects maxOutputLength 0 with a different fault than a cap breach, so a
130
+ // zero cap would surface as a malformed-stream verdict rather than a config error.
131
+ if (!Number.isInteger(cap) || cap < 1) {
132
+ throw new TypeError("guard.compress.bounded: cap must be a positive integer");
133
+ }
134
+ var what = label || "the compressed input";
135
+ var res;
136
+ try {
137
+ res = decompress(stream, { maxOutputLength: cap, info: true });
138
+ } catch (e) {
139
+ if (e && e.code === "ERR_BUFFER_TOO_LARGE") {
140
+ throw E(codes.tooLarge, what + " decompresses to more than the " + cap + "-byte cap (a decompression-bomb defense)", e);
141
+ }
142
+ throw E(codes.failed, what + " could not be decompressed", e);
143
+ }
144
+ // `bytesWritten` is the count of INPUT bytes the engine consumed. A frame that
145
+ // ends before the input does means the remainder was ignored -- the malleability
146
+ // above -- so the whole input must be exactly one frame.
147
+ var consumed = res.engine.bytesWritten;
148
+ if (consumed !== stream.length) {
149
+ throw E(codes.failed, what + " carries " + (stream.length - consumed) +
150
+ " trailing byte(s) after the end of the compressed stream");
151
+ }
152
+ return res.buffer;
153
+ }
154
+
155
+ // The algorithm names this guard can decompress SAFELY on this runtime -- the registry
156
+ // minus any whose decompressor cannot report an unfinished frame. A consumer intersects
157
+ // its protocol's algorithm registry with this, so it never advertises or accepts something
158
+ // it could not refuse a truncation of.
159
+ // @enforced-by behavioral -- a read-only list of the safe registry's keys has no
160
+ // rename-proof code shape to re-inline; the guard's reject path is the contract,
161
+ // and a consumer offering an algorithm this list omits fails closed at `bounded`.
162
+ function algorithms() { return Object.keys(SAFE); }
163
+
164
+ module.exports = { bounded: bounded, algorithms: algorithms };