@blamejs/pki 0.4.2 → 0.4.4
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 +36 -1
- package/README.md +3 -1
- package/index.js +5 -0
- package/lib/cms-compress.js +10 -12
- package/lib/cms-sign.js +28 -20
- package/lib/cms-verify.js +21 -12
- package/lib/composite-sig.js +4 -4
- package/lib/constants.js +13 -0
- package/lib/framework-error.js +10 -0
- package/lib/guard-all.js +5 -0
- package/lib/guard-compress.js +164 -0
- package/lib/oid.js +5 -1
- package/lib/schema-c509.js +484 -0
- package/lib/tls-cert-compress.js +398 -0
- package/lib/validator-all.js +2 -0
- package/lib/validator-tls.js +168 -0
- package/lib/webcrypto.js +41 -9
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -4,7 +4,42 @@ 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.
|
|
7
|
+
## v0.4.4 — 2026-08-08
|
|
8
|
+
|
|
9
|
+
pki.schema.c509 encodes and decodes the RFC 3779 resource-delegation extensions -- a C509 certificate carrying IP address blocks or AS identifiers now parses at all, where before it was refused outright, and its addresses ride the compact form the specification defines.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- pki.schema.c509 encodes and decodes the RFC 3779 IPAddrBlocks and ASIdentifiers extensions in their compact value form, together with their RFC 8360 v2 twins, which the specification encodes identically. Previously these extensions had no registry entry, so a conformant C509 certificate carrying one was refused rather than falling back -- a C509 resource certificate could not be read at all. An address family carries its address-family identifier and optional sub-identifier, and its addresses as either the delta-coded integer form or the byte-string form; the prefix length rides the unused-bit count, so a prefix ending in zero bits survives exactly. Both directions reproduce the worked example published in the specification's own appendix, byte for byte.
|
|
14
|
+
- Which address form applies is fixed by the specification, not chosen by the sender: the byte-string form applies to a whole address family as soon as any one of its addresses exceeds eight octets, and the integer form applies otherwise. The decoder enforces that, so a family that used the wrong form -- or mixed the two -- is refused instead of giving one certificate two valid encodings.
|
|
15
|
+
- The compact form is used only for a certificate already in the canonical order RFC 3779 requires, at both levels. Within an address family the entries must be sorted, non-overlapping, and with any two contiguous entries already combined into one; the same three rules apply to AS identifiers. Across families, each address family may appear only once and they must ascend by their identifying octets, with a family carrying no sub-identifier preceding the one sharing its identifier. A certificate breaking any of these keeps its original bytes, because compacting it would give one resource set a second encoding when it already has a canonical one -- and because such a certificate is one an independent validator rejects, so re-encoding it would quietly turn a refused certificate into an accepted one. An address wider than its family allows, or one whose declared unused bits are not zero, is likewise refused. Every rule is enforced in both directions, so the two halves of the codec accept exactly the same certificates.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- A certificate whose version is not v3 is now refused with the reason, rather than reported as one this encoder could not reconstruct. Both C509 certificate types are defined over X.509 v3 and the encoding carries no version field, so a v1 or v2 certificate is outside the format; it previously fell through to the byte-exactness self-check, whose verdict reads as a defect in the encoder rather than a certificate the format does not cover. A v3 certificate whose extensions field is omitted was and remains fully supported -- the specification encodes that as an empty array.
|
|
20
|
+
|
|
21
|
+
## v0.4.3 — 2026-08-08
|
|
22
|
+
|
|
23
|
+
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.
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
- 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.
|
|
28
|
+
- 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.
|
|
29
|
+
- 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.
|
|
30
|
+
- 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.
|
|
31
|
+
- 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.
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
|
|
35
|
+
- 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.
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
- 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.
|
|
40
|
+
- 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.
|
|
41
|
+
|
|
42
|
+
## v0.4.2 — 2026-08-07
|
|
8
43
|
|
|
9
44
|
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.
|
|
10
45
|
|
package/README.md
CHANGED
|
@@ -202,9 +202,10 @@ 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
|
-
| `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),
|
|
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), policy mappings and policy constraints, subject directory attributes, and the RFC 3779 resource-delegation extensions (IP address blocks and AS identifiers, plus their RFC 8360 v2 twins) whose addresses ride either the delta-coded integer form or the byte-string form the specification mandates once an address exceeds eight octets; a value the compact form cannot carry exactly falls back to the byte-string form with its bytes intact, and a certificate outside the invertible set throws a typed `C509Error` |
|
|
208
209
|
| `pki.schema.crl` | Parse DER / PEM X.509 CRLs per RFC 5280 §5 — revoked serials with real-`Date` revocation times, named + partly-decoded extensions, fail-closed — `parse`, `pemDecode`, `pemEncode` |
|
|
209
210
|
| `pki.schema.csr` | Parse DER / PEM PKCS#10 certification requests per RFC 2986 — subject DN, public key, requested attributes, signature, fail-closed — `parse`, `pemDecode`, `pemEncode` |
|
|
210
211
|
| `pki.schema.pkcs8` | Parse DER / PEM PKCS#8 private keys per RFC 5208 / 5958 — algorithm, raw key bytes, attributes, optional public key, fail-closed; encrypted keys recognized (not decrypted) — `parse`, `parseEncrypted`, `pemDecode`, `pemEncode` |
|
|
@@ -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/cms-compress.js
CHANGED
|
@@ -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
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
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
|
-
|
|
104
|
-
|
|
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
|
-
|
|
36
|
-
|
|
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
|
|
50
|
+
// The message digest of the content under the digest algorithm, resolved to a Buffer.
|
|
44
51
|
function _digest(digestName, content) {
|
|
45
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
|
57
|
-
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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 =
|
|
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
|
|
462
|
-
//
|
|
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
|
}
|
package/lib/composite-sig.js
CHANGED
|
@@ -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 (
|
|
58
|
-
// curves
|
|
59
|
-
//
|
|
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", {
|
|
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,
|
package/lib/framework-error.js
CHANGED
|
@@ -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 };
|
package/lib/oid.js
CHANGED
|
@@ -87,7 +87,11 @@ var FAMILIES = {
|
|
|
87
87
|
// id-pe-tlsfeature (RFC 7633) -- the TLS Feature (formerly "must-staple") extension.
|
|
88
88
|
tlsFeature: 24,
|
|
89
89
|
// id-pe-qcStatements (RFC 3739 sec. 3.2.6) -- the qualified-certificate-statements extension.
|
|
90
|
-
qcStatements: 3
|
|
90
|
+
qcStatements: 3,
|
|
91
|
+
// RFC 3779 -- the IP-address and AS-number delegation extensions that carry a resource
|
|
92
|
+
// certificate's authority (RPKI). Each has an RFC 8360 "v2" twin with identical syntax and
|
|
93
|
+
// stricter path-validation semantics, so both are registered here.
|
|
94
|
+
ipAddrBlocks: 7, autonomousSysIds: 8, ipAddrBlocksV2: 28, autonomousSysIdsV2: 29 } },
|
|
91
95
|
|
|
92
96
|
// RFC 3739 sec. 3.2.6.1 id-qcs -- the PKIX QCStatement statementIds carrying SemanticsInformation.
|
|
93
97
|
pkixQcSyntax: { base: [1, 3, 6, 1, 5, 5, 7, 11], of: { qcsPkixQCSyntaxV1: 1, qcsPkixQCSyntaxV2: 2 } },
|