@blamejs/pki 0.1.32 → 0.2.0

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,6 +4,31 @@ 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.2.0 — 2026-07-11
8
+
9
+ Trust-store ingestion, sharded-CRL revocation, and a hardened input-guard layer.
10
+
11
+ ### Added
12
+
13
+ - pki.trust.parseCertdata(text) and pki.trust.parseCcadbCsv(text) parse the Mozilla/NSS certdata.txt object stream and the CCADB All Certificate Records CSV into one Anchor shape carrying the exact { name, publicKey, algorithm, parameters } fields pki.path.validate consumes, plus per-purpose distrustAfter dates, delegator purposes (only CKT_NSS_TRUSTED_DELEGATOR grants a purpose), subjectDer, label, and mozillaCaPolicy. Certificate and trust objects are paired by byte-exact issuer + serial (never adjacency) and cross-checked against the parsed DER, so metadata can never attach to the wrong root. pki.trust.anchor(entry, { purpose }) hands the anchor to validate, failing fast when the entry does not delegate the purpose. Malformed octal, an oversized block or file, an unrecognized trust value, an undecodable distrust date, or a mispaired object throws a typed trust/* error. Offline and pure: the caller supplies the text; nothing fetches.
14
+ - pki.path.validate gains opts.checkPurpose plus trust-anchor constraint enforcement: with an anchor carrying distrustAfter / purposes metadata, a leaf whose notBefore is strictly after the anchor's distrust date for the checked purpose fails with path/distrusted-after (the boundary instant stays trusted, matching Mozilla's enforcement), and a purpose the anchor does not delegate fails with path/purpose-not-trusted. Anchors without the metadata validate exactly as before.
15
+ - cRLDistributionPoints and freshestCRL certificate-extension decoders, and RFC 5280 sec. 6.3.3 distribution-point correspondence in pki.path.crlChecker: a partitioned CRL whose critical issuing-distribution-point shares an identically-encoded name with one of the certificate's distribution points, carries no reason restriction on either side, is current, and verifies now establishes a good status for its shard. A non-corresponding, reason-restricted, non-critical-IDP, delta, or unverifiable shard keeps failing closed to undetermined -- and a listed serial still reports revoked regardless of correspondence.
16
+ - pki.asn1.decode and pki.cbor.decode accept a maxItems option (default C.LIMITS.DER_MAX_ITEMS) capping the total decoded elements, so a small dense input cannot fan out into an unbounded node tree (asn1/too-many-items). OCSP responses are capped at C.LIMITS.OCSP_MAX_CERTS embedded certificates (ocsp/too-many-certs), bounding the pre-authentication signature work an attacker-supplied response can demand.
17
+ - Strict JWK oct key-material decoding: importKey / unwrapKey reject a JWK oct key whose k member is missing, empty, padded, or non-canonical base64url instead of importing a wrong or empty key, and JSON key unwrap rejects duplicate members at every depth so a smuggled second parameter can no longer resolve last-wins. Unwrapped JWK text is bounded (size, nesting depth, strict UTF-8).
18
+
19
+ ### Changed
20
+
21
+ - OID string handling is canonical everywhere: pki.asn1.build.oid and pki.oid.toDER reject a leading-zero arc (previously accepted and silently encoded as a DIFFERENT OID -- "2.05.29.15" emitted the DER of 2.5.29.15), and pki.oid.toArcs / register now enforce the X.660 arc bounds on the string form (a root above 2, or a second arc above 39 under roots 0 and 1, can never be DER-encoded and now throws oid/bad-arc). Error codes consolidated with the canonical form: a one-arc or non-numeric-arc string throws oid/bad-input (previously oid/too-short and oid/bad-arc; oid/too-short is removed).
22
+ - pki.jose and WebCrypto JWK unwrap JSON limits are enforced through one shared bounded reader; an unwrapped-key JSON parse failure reports the typed webcrypto/data error without a nested SyntaxError cause.
23
+
24
+ ### Fixed
25
+
26
+ - Signing via pki.jose over a payload backed by a detached ArrayBuffer now fails closed with a typed error instead of silently signing an empty payload; PEM encoding and EST transfer encoding reject a detached or non-buffer input the same way.
27
+ - EST server-side key generation rejects an EnvelopedData whose encryptedContent is present but zero-length (est/bad-key-part); previously only the fully absent form was rejected, so an empty ciphertext could reach the caller's decrypt step. The same shared check now backs the CRMF encrypted-key proof-of-possession and PKCS#12 paths.
28
+ - An attribute certificate whose objectDigestInfo digest is not octet-aligned is rejected at parse (attrcert/bad-object-digest-info) instead of surfacing a bit-truncated digest.
29
+ - A malformed content-type attribute value inside AuthEnvelopedData authenticated attributes surfaces cms/bad-content-type-attr instead of a raw asn1/* codec error.
30
+ - pki.path.validate rejects fractional maxPathCerts / maxPolicyNodes values (path/bad-input) instead of silently tolerating them, and validates requiredEku / userInitialPolicySet entries as canonical OID strings at the entry point -- a leading-zero or out-of-bounds key could never match decoder output and now fails at boot instead of silently never matching.
31
+
7
32
  ## v0.1.32 — 2026-07-11
8
33
 
9
34
  OCSP-backed revocation checking joins certification-path validation.
package/README.md CHANGED
@@ -220,9 +220,10 @@ is callable today; nothing below is a stub.
220
220
  | `pki.acme` | RFC 8555 / 8737 / 8738 / 9773 ACME message layer over `pki.jose` — resource-object validators (closed status enums, conditional-required fields, unknown fields ignored), the three §7.1.6 state machines, request builders (newAccount + EAB, newOrder + `replaces`, finalize with CSR identifier-set match and account-key-reuse rejection, challenge responses, deactivation, revokeCert in both key modes, the keyChange nested JWS, POST-as-GET), the http-01 / dns-01 / tls-alpn-01 challenge computations, the dns/ip identifier validators, and the ARI certID (serial sign-padding preserved). A message layer, not an HTTP client — transport-injectable, fail-closed — `validate`, `identify`, `assertTransition`, the builders, `keyAuthorization`, `http01`, `dns01`, `tlsAlpn01Extension`, `verifyTlsAlpn01`, `ariCertId` |
221
221
  | `pki.schema.smime` | Decode S/MIME ESS signed-attribute values (RFC 5035 / RFC 8551) — `parseSigningCertificate` / `parseSigningCertificateV2` bind a signature to its signing certificate (cert hash, hash algorithm, issuer `GeneralNames` + serial), `parseSmimeCapabilities` decodes the ordered capability list, and `decodeAttribute` OID-dispatches a CMS attribute (enforcing the single-value rule, recognize-and-defer for unknown types). A companion decoder for CMS signed attributes, not an auto-routed format, fail-closed — `parseSigningCertificate`, `parseSigningCertificateV2`, `parseSmimeCapabilities`, `decodeAttribute` |
222
222
  | `pki.schema.engine` | The declarative ASN.1 structure-schema engine every format parser composes — `walk` / `encode` / `embeddedDer` plus the schema combinators |
223
- | `pki.path` | RFC 5280 §6 certification-path validation — `validate` runs the §6.1 state machine (signature chaining, validity windows, name chaining, basic constraints and path length, key usage, name constraints, the certificate-policy tree) over an ordered path and a trust anchor, returning a structured verdict with per-check reason codes; `crlChecker` supplies CRL-based revocation and `ocspChecker` supplies OCSP-based revocation (RFC 6960 — CertID binding, responder authorization, signature, currency) over the same pluggable hook. Pure and re-entrant, fail-closed — `validate`, `crlChecker`, `ocspChecker` |
223
+ | `pki.path` | RFC 5280 §6 certification-path validation — `validate` runs the §6.1 state machine (signature chaining, validity windows, name chaining, basic constraints and path length, key usage, name constraints, the certificate-policy tree) over an ordered path and a trust anchor, returning a structured verdict with per-check reason codes, and enforces a `pki.trust` anchor's per-purpose distrust-after dates and delegator purposes via `checkPurpose`; `crlChecker` supplies CRL-based revocation — including partitioned/sharded CRLs, whose §6.3.3 Distribution Point ↔ IDP correspondence lets a corresponding full-reason shard establish non-revocation — and `ocspChecker` supplies OCSP-based revocation (RFC 6960 — CertID binding, responder authorization, signature, currency) over the same pluggable hook. Pure and re-entrant, fail-closed — `validate`, `crlChecker`, `ocspChecker` |
224
224
  | `pki.ct` | Parse RFC 6962 Certificate Transparency SCT lists — `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 for external verification. Structure decoded, crypto surfaced raw, bounded decode, fail-closed — `parseSctList`, `reconstructSignedData` |
225
225
  | `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` |
226
+ | `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` |
226
227
  | `pki.C` / `pki.constants` | Version-stable constants — functional scale helpers (`C.TIME.*`, `C.BYTES.*`), codec `LIMITS`, `version` |
227
228
  | `pki.errors` | The `PkiError` taxonomy — `defineClass` plus `ConstantsError` / `Asn1Error` / `OidError` / `PemError` / `CertificateError` / `CrlError` / `CsrError` / `Pkcs8Error` / `CmsError` / `OcspError` / `TspError` / `AttrCertError` / `CrmfError` / `Pkcs12Error` / `CmpError` / `PathError` / `CtError` / `JoseError` / `AcmeError`, each carrying a stable `code` in `domain/reason` form |
228
229
  | `pki` CLI | `pki version`, `pki oid <dotted\|name>`, `pki parse <cert>` |
package/index.js CHANGED
@@ -40,6 +40,7 @@ var merkle = require("./lib/merkle");
40
40
  var est = require("./lib/est");
41
41
  var jose = require("./lib/jose");
42
42
  var acme = require("./lib/acme");
43
+ var trust = require("./lib/trust");
43
44
 
44
45
  module.exports = {
45
46
  version: constants.version,
@@ -87,6 +88,12 @@ module.exports = {
87
88
  // http-01 / dns-01 / tls-alpn-01 challenge math, and the ARI certID. A message
88
89
  // layer, not an HTTP client -- transport is the operator's to inject.
89
90
  acme: acme,
91
+ // `trust` is Mozilla/NSS certdata.txt + CCADB CSV root-store ingestion --
92
+ // pki.trust.parseCertdata / parseCcadbCsv produce constraint-carrying trust
93
+ // anchors (per-purpose trust bits + per-purpose distrust-after dates the bare
94
+ // root list omits); pki.trust.anchor hands one to pki.path.validate. Offline:
95
+ // the operator supplies the text; no fetch.
96
+ trust: trust,
90
97
  // A ready W3C Crypto instance (globalThis.crypto shape) with the classes for
91
98
  // constructing more attached under the same namespace (pki.webcrypto.CryptoKey,
92
99
  // .SubtleCrypto, .Crypto, .WebCryptoError). PQC-first, classical-capable, zero-dep.
package/lib/asn1-der.js CHANGED
@@ -37,6 +37,14 @@ var guard = require("./guard-all");
37
37
  var Asn1Error = frameworkError.Asn1Error;
38
38
  var OidError = frameworkError.OidError;
39
39
 
40
+ // (code, message) -> Asn1Error, the factory shape the composed guards throw
41
+ // through so a malformed codec argument keeps the asn1/* typed verdict.
42
+ function _asn1Error(c, m) { return new Asn1Error(c, m); }
43
+
44
+ // (code, message) -> OidError, the factory the OID string guard throws through
45
+ // so build.oid keeps its oid/* typed verdict (oid/bad-input, oid/bad-arc).
46
+ function _oidErr(c, m) { return new OidError(c, m); }
47
+
40
48
  // ---- Tag constants (universal class) --------------------------------
41
49
 
42
50
  // L1 -- the ASN.1 universal-type descriptor registry: the single source of
@@ -158,20 +166,26 @@ function decode(input, opts) {
158
166
  // recursive descent into a bare RangeError instead of the typed asn1/too-deep.
159
167
  var maxBytes = guard.limits.cap(opts.maxBytes, "maxBytes", constants.LIMITS.DER_MAX_BYTES);
160
168
  var maxDepth = guard.limits.depthCap(opts.maxDepth, "maxDepth", constants.LIMITS.DER_MAX_DEPTH);
169
+ // Total decoded-node ceiling: the eager node tree lets a dense run of tiny TLVs
170
+ // amplify a small input into a huge tree, so a per-node counter fails closed at
171
+ // the cap (the memory-fanout class the CBOR codec closed with CBOR_MAX_ITEMS).
172
+ var maxItems = guard.limits.cap(opts.maxItems, "maxItems", constants.LIMITS.DER_MAX_ITEMS);
161
173
  if (buf.length > maxBytes) {
162
174
  throw new Asn1Error("asn1/too-large", "input " + buf.length + " bytes exceeds cap " + maxBytes);
163
175
  }
164
- var r = _decodeTLV(buf, 0, buf.length, 0, maxDepth, opts.ber === true);
176
+ var ctr = guard.limits.counter(maxItems, _asn1Error, "asn1/too-many-items", "decoded DER node");
177
+ var r = _decodeTLV(buf, 0, buf.length, 0, maxDepth, opts.ber === true, undefined, ctr);
165
178
  if (!opts.allowTrailing && r.end !== buf.length) {
166
179
  throw new Asn1Error("asn1/trailing-bytes", (buf.length - r.end) + " trailing byte(s) after the top-level value");
167
180
  }
168
181
  return r.node;
169
182
  }
170
183
 
171
- function _decodeTLV(buf, start, limit, depth, maxDepth, ber, strDepth) {
184
+ function _decodeTLV(buf, start, limit, depth, maxDepth, ber, strDepth, ctr) {
172
185
  if (depth > maxDepth) {
173
186
  throw new Asn1Error("asn1/too-deep", "nesting exceeds depth cap " + maxDepth);
174
187
  }
188
+ if (ctr) ctr.tick(); // one decoded node -> one tick (memory-fanout cap)
175
189
  var p = start;
176
190
  if (p >= limit) throw new Asn1Error("asn1/truncated", "expected an identifier octet");
177
191
  var first = buf[p]; p += 1;
@@ -271,7 +285,7 @@ function _decodeTLV(buf, start, limit, depth, maxDepth, ber, strDepth) {
271
285
  for (;;) {
272
286
  if (cp + 2 > limit) throw new Asn1Error("asn1/truncated", "indefinite-length value is missing its end-of-contents octets");
273
287
  if (buf[cp] === 0x00 && buf[cp + 1] === 0x00) break;
274
- child = _decodeTLV(buf, cp, limit, depth + 1, maxDepth, ber, strDepth);
288
+ child = _decodeTLV(buf, cp, limit, depth + 1, maxDepth, ber, strDepth, ctr);
275
289
  children.push(child.node);
276
290
  cp = child.end;
277
291
  }
@@ -279,7 +293,7 @@ function _decodeTLV(buf, start, limit, depth, maxDepth, ber, strDepth) {
279
293
  end = cp + 2;
280
294
  } else {
281
295
  while (cp < contentEnd) {
282
- child = _decodeTLV(buf, cp, contentEnd, depth + 1, maxDepth, ber, strDepth);
296
+ child = _decodeTLV(buf, cp, contentEnd, depth + 1, maxDepth, ber, strDepth, ctr);
283
297
  children.push(child.node);
284
298
  cp = child.end;
285
299
  }
@@ -793,7 +807,10 @@ function encodeIdentifier(classBits, constructed, tagNumber) {
793
807
  * pki.asn1.encode(0x00, false, pki.asn1.TAGS.NULL, Buffer.alloc(0));
794
808
  */
795
809
  function encodeTLV(classBits, constructed, tagNumber, content) {
796
- var body = Buffer.isBuffer(content) ? content : Buffer.from(content || []);
810
+ // A Buffer content is re-viewed so a detached backing store fails closed with a
811
+ // typed asn1/not-buffer instead of a raw TypeError out of Buffer.concat; an
812
+ // array / undefined content stays the normal builder path.
813
+ var body = Buffer.isBuffer(content) ? guard.bytes.view(content, Asn1Error, "asn1/not-buffer", "encodeTLV content") : Buffer.from(content || []);
797
814
  var id = encodeIdentifier(classBits, constructed, tagNumber);
798
815
  return Buffer.concat([id, encodeLength(body.length), body]);
799
816
  }
@@ -824,17 +841,12 @@ function intToDer(v) {
824
841
  }
825
842
 
826
843
  function encodeOidContent(dotted) {
827
- if (typeof dotted !== "string" || dotted.length === 0) throw new OidError("oid/bad-input", "OID must be a dotted-decimal string");
828
- var parts = dotted.split(".");
829
- if (parts.length < 2) throw new OidError("oid/too-short", "OID needs at least two arcs");
830
- var arcs = [];
831
- for (var i = 0; i < parts.length; i++) {
832
- if (!/^\d+$/.test(parts[i])) throw new OidError("oid/bad-arc", "arc " + JSON.stringify(parts[i]) + " is not a non-negative integer");
833
- arcs.push(BigInt(parts[i]));
834
- }
844
+ // Canonical dotted-decimal + X.660 arc bounds live in the one string-OID guard,
845
+ // so build.oid cannot emit bytes that decode to a DIFFERENT OID than the string
846
+ // names (a leading-zero arc) or an arc that can never DER-encode.
847
+ guard.identifier.assertCanonicalOid(dotted, _oidErr, "oid/bad-input", "OID", "oid/bad-arc");
848
+ var arcs = dotted.split(".").map(function (p) { return BigInt(p); });
835
849
  var a1 = arcs[0], a2 = arcs[1];
836
- if (a1 > 2n) throw new OidError("oid/bad-arc", "first arc must be 0, 1, or 2");
837
- if (a1 < 2n && a2 >= 40n) throw new OidError("oid/bad-arc", "second arc must be < 40 when the first arc is 0 or 1");
838
850
  var subids = [a1 * 40n + a2].concat(arcs.slice(2));
839
851
  var out = [];
840
852
  for (var s = 0; s < subids.length; s++) {
@@ -905,14 +917,11 @@ var build = {
905
917
  oid: function (dotted) { return _universal(TAGS.OBJECT_IDENTIFIER, false, encodeOidContent(dotted)); },
906
918
  octetString: function (buf) { return _universal(TAGS.OCTET_STRING, false, _asBuffer(buf, "build.octetString")); },
907
919
  bitString: function (buf, unusedBits) {
908
- var u = unusedBits == null ? 0 : unusedBits;
909
920
  // An integer in 0..7 (X.690 sec. 8.6.2.2). A fractional / non-finite count
910
- // would be silently bit-truncated into a DIFFERENT value when written
911
- // into the leading octet -- reject it like the other numeric builder
912
- // inputs (encodeLength, the tag number).
913
- if (typeof u !== "number" || !Number.isInteger(u) || u < 0 || u > 7) {
914
- throw new Asn1Error("asn1/bad-bit-string", "unusedBits must be an integer 0..7");
915
- }
921
+ // would be silently bit-truncated into a DIFFERENT value when written into
922
+ // the leading octet -- the shared integer cap rejects it like the other
923
+ // numeric builder inputs (encodeLength, the tag number).
924
+ var u = guard.limits.cap(unusedBits == null ? 0 : unusedBits, "unusedBits", 0, { E: _asn1Error, code: "asn1/bad-bit-string", min: 0, max: 7 });
916
925
  var body = _asBuffer(buf, "build.bitString");
917
926
  // X.690 8.6.2.3: an empty BIT STRING has no content octets, so its
918
927
  // unused-bit count must be zero -- there are no bits to leave unused.
package/lib/constants.js CHANGED
@@ -132,6 +132,14 @@ var BYTES = {
132
132
  var LIMITS = {
133
133
  DER_MAX_BYTES: BYTES.mib(16),
134
134
  DER_MAX_DEPTH: 64,
135
+ // Total decoded-node ceiling: the DER decoder builds an eager node tree, so a
136
+ // dense run of tiny TLVs (a 2-byte NULL is a node) fans a small input into a
137
+ // huge tree -- ~350 B/node, so a 16 MiB input of NULLs is ~8M nodes ~= 2.8 GiB.
138
+ // 4M sits well above the largest realistic structure (a full 16 MiB CRL carries
139
+ // ~500k revoked entries ~= 2.5M nodes) and below the dense-bomb count; the
140
+ // operator may override via opts.maxItems. (The CBOR sibling caps at 1M, too
141
+ // low for DER's legitimate large CRLs.)
142
+ DER_MAX_ITEMS: 4000000,
135
143
  // Hard stack-safe ceiling on the recursion depth of the DER / CBOR
136
144
  // decoders, independent of the (operator-tunable) maxDepth. Both decoders
137
145
  // are recursive descent; a maxDepth raised above the engine's native call-
@@ -222,6 +230,27 @@ var LIMITS = {
222
230
  // ACME challenge token entropy floor (RFC 8555 sec. 8, errata 6950): >= 128
223
231
  // bits of base64url is >= 22 characters. A shorter token is refused before use.
224
232
  ACME_TOKEN_MIN_CHARS: 22,
233
+ // OCSP embedded-certificate ceiling. A BasicOCSPResponse (or an OCSP request
234
+ // Signature) carries the responder / requestor certificate plus at most a short
235
+ // chain; the delegated-responder authorization loop verifies each candidate
236
+ // BEFORE the response signature is checked, so an attacker-crafted stapled /
237
+ // relayed response with a huge certs list would drive unbounded pre-auth
238
+ // signature verifies. 32 is generous headroom over a real chain.
239
+ OCSP_MAX_CERTS: 32,
240
+ // Trust-store ingestion ceilings (Mozilla/NSS certdata.txt + CCADB CSV).
241
+ // Every cap is refused BEFORE the offending allocation with a typed trust/*
242
+ // error -- never a silent truncate. A live certdata.txt is a few MiB and a
243
+ // CCADB all-records export a few MiB more, so 16 MiB whole-input headroom
244
+ // matches the codec siblings. A root certificate (or a CCADB PEM cell) is a
245
+ // few KiB; a MULTILINE_OCTAL blob or CSV field past 64 KiB is amplification,
246
+ // not a root. The real store is ~150 roots x 2 object blocks (and one CCADB
247
+ // row per root), so 10000 objects / rows is generous headroom while bounding
248
+ // a block/row bomb.
249
+ TRUST_MAX_BYTES: BYTES.mib(16),
250
+ TRUST_MAX_OCTAL_BYTES: BYTES.kib(64),
251
+ TRUST_MAX_OBJECTS: 10000,
252
+ TRUST_MAX_CSV_ROWS: 10000,
253
+ TRUST_MAX_CSV_FIELD_BYTES: BYTES.kib(64),
225
254
  };
226
255
 
227
256
  // Single-sourced from the package manifest so the reported version can
package/lib/ct.js CHANGED
@@ -46,6 +46,10 @@ var guard = require("./guard-all.js");
46
46
  var CtError = frameworkError.CtError;
47
47
  var C = constants;
48
48
 
49
+ // (code, message) -> CtError, the factory the composed guards throw through so a
50
+ // malformed value keeps the ct/* typed verdict.
51
+ function _ctErr(c, m) { return new CtError(c, m); }
52
+
49
53
  // RFC 5246 sec. 7.4.1.4.1 code points -- 1-byte, NOT OIDs. Surfaced named; an
50
54
  // unknown code surfaces as its numeric byte with a null name (never rejected --
51
55
  // off-profile-pair rejection is a verifier-tier log-conformance concern).
@@ -211,6 +215,9 @@ function parseSctList(extValue) {
211
215
  throw new CtError("ct/empty-list", "an SCT list must contain at least one SCT (RFC 6962 sec. 3.3)");
212
216
  }
213
217
  var scts = [], unknownScts = [];
218
+ // The shared item counter bounds the TOTAL element count (known + preserved-
219
+ // unknown) before it can drive unbounded per-element work (RFC 6962 sec. 3.3).
220
+ var sctCount = guard.limits.counter(C.LIMITS.SCT_MAX_COUNT, _ctErr, "ct/too-many-scts", "SCT");
214
221
  while (!outer.atEnd()) {
215
222
  if (outer.remaining() < 2) {
216
223
  throw new CtError("ct/list-trailing-bytes", "a dangling partial element after the last complete SCT (RFC 6962 sec. 3.3)");
@@ -222,14 +229,10 @@ function parseSctList(extValue) {
222
229
  if (outer.remaining() < sctLen) {
223
230
  throw new CtError("ct/list-trailing-bytes", "a SerializedSCT length " + sctLen + " overruns the list (RFC 6962 sec. 3.3)");
224
231
  }
232
+ sctCount.tick();
225
233
  var one = _parseSct(outer.subReader(sctLen, "ct/list-trailing-bytes"), sctLen);
226
234
  if (one.unknown) unknownScts.push({ version: one.version, rawSct: one.rawSct });
227
235
  else scts.push(one);
228
- // The cap bounds the TOTAL element count (known + preserved-unknown) before it
229
- // can drive unbounded per-element work.
230
- if (scts.length + unknownScts.length > C.LIMITS.SCT_MAX_COUNT) {
231
- throw new CtError("ct/too-many-scts", "SCT count exceeds the cap " + C.LIMITS.SCT_MAX_COUNT);
232
- }
233
236
  }
234
237
  return { scts: scts, unknownScts: unknownScts };
235
238
  }
@@ -283,16 +286,14 @@ function reconstructSignedData(entry, sct) {
283
286
  if (!sct || typeof sct.timestamp !== "bigint" || sct.version !== 0) {
284
287
  throw new CtError("ct/bad-input", "reconstructSignedData expects a decoded v1 SCT from parseSctList().scts[]");
285
288
  }
286
- // RFC 6962 3.2: the timestamp is a uint64. A hand-built value outside
287
- // 0..2^64-1 cannot fill the fixed 8-byte field -- refused typed here rather
288
- // than escaping as a raw RangeError from the Buffer write.
289
- if (sct.timestamp < 0n || sct.timestamp > 0xffffffffffffffffn) {
290
- throw new CtError("ct/bad-input", "sct.timestamp must be a uint64 (0..2^64-1), got " + sct.timestamp.toString() + " (RFC 6962 3.2)");
291
- }
289
+ // RFC 6962 3.2: the timestamp is a uint64. The BigInt-preserving range guard
290
+ // refuses a value that cannot fill the fixed 8-byte field, typed, rather than
291
+ // letting it escape as a raw RangeError from the Buffer write.
292
+ var tsVal = guard.range.uint64(sct.timestamp, _ctErr, "ct/bad-input", "sct.timestamp (RFC 6962 3.2)");
292
293
  var parts = [];
293
294
  parts.push(Buffer.from([sct.version & 0xff])); // Version -- v1(0)
294
295
  parts.push(Buffer.from([0])); // SignatureType -- certificate_timestamp(0)
295
- var ts = Buffer.alloc(8); ts.writeBigUInt64BE(sct.timestamp); parts.push(ts); // uint64 timestamp
296
+ var ts = Buffer.alloc(8); ts.writeBigUInt64BE(tsVal); parts.push(ts); // uint64 timestamp
296
297
  parts.push(Buffer.from([(entryType >> 8) & 0xff, entryType & 0xff])); // LogEntryType (2 bytes BE)
297
298
  if (entryType === 0) {
298
299
  var cert = _toBuffer(entry.leafCert, "leafCert");
package/lib/est.js CHANGED
@@ -140,13 +140,10 @@ function transferDecode(body) {
140
140
  charset: "latin1", tooLarge: "est/too-large", badInput: "est/bad-input", label: "the EST payload",
141
141
  });
142
142
  var stripped = s.replace(/[\r\n \t]/g, "");
143
- if (!/^[A-Za-z0-9+/]*={0,2}$/.test(stripped)) throw E("est/bad-base64", "the EST payload is not RFC 4648 base64 (RFC 8951 sec. 3.1)");
144
- var der = Buffer.from(stripped, "base64");
145
- // Buffer.from silently truncates a non-canonical body (a length not a multiple
146
- // of 4, e.g. "A", or non-zero trailing bits). Re-encode and require an exact
147
- // round-trip so a malformed payload fails closed instead of decoding to a
148
- // different, shorter DER (the PEM decoder applies the same canonical check).
149
- if (der.toString("base64") !== stripped) throw E("est/bad-base64", "the EST payload is not canonical RFC 4648 base64 (RFC 8951 sec. 3.1)");
143
+ // Strict canonical RFC 4648 base64 (RFC 8951 sec. 3.1) via the shared encoding
144
+ // guard: the alphabet + whole-4-group + canonical round-trip fail a malformed
145
+ // body closed instead of silently truncating to a shorter, different DER.
146
+ var der = guard.encoding.base64(stripped, null, E, "est/bad-base64", "the EST payload");
150
147
  if (der.length > constants.LIMITS.DER_MAX_BYTES) throw E("est/too-large", "the decoded EST DER exceeds the size cap");
151
148
  return der;
152
149
  }
@@ -167,6 +164,8 @@ function transferDecode(body) {
167
164
  */
168
165
  function transferEncode(der) {
169
166
  if (!Buffer.isBuffer(der)) throw E("est/bad-input", "transferEncode requires a DER Buffer");
167
+ // Re-view: a detached-backed Buffer reads as zero-length and would encode "".
168
+ der = guard.bytes.view(der, EstError, "est/bad-input", "transferEncode DER input");
170
169
  return der.toString("base64");
171
170
  }
172
171
 
@@ -414,9 +413,10 @@ function parseServerKeygenResponse(body, contentType, opts) {
414
413
  // not just the outer ContentInfo, so a key wrapped around id-data or anything
415
414
  // else fails closed before the caller decrypts it.
416
415
  if (!parsedKey.encryptedContentInfo || parsedKey.encryptedContentInfo.contentType !== ID_SIGNED_DATA) throw E("est/bad-key-part", "a server-generated encrypted key's EnvelopedData must encapsulate a CMS SignedData (RFC 7030 sec. 4.4.2)");
417
- // A detached EnvelopedData (encryptedContent omitted) carries no ciphertext to
418
- // decrypt -- there is no key to recover, so it is not a valid key response.
419
- if (parsedKey.encryptedContentInfo.encryptedContent === null) throw E("est/bad-key-part", "a server-generated encrypted key's EnvelopedData must carry its encrypted content, not be detached (RFC 7030 sec. 4.4.2)");
416
+ // A detached OR empty EnvelopedData carries no ciphertext to decrypt -- there is
417
+ // no key to recover, so it is not a valid key response (the shared CMS assert
418
+ // rejects both null and zero-length, matching the CRMF / PKCS#12 siblings).
419
+ cms.assertAttachedCiphertext(parsedKey.encryptedContentInfo, E, "est/bad-key-part", "a server-generated encrypted key's EnvelopedData");
420
420
  if (Buffer.isBuffer(opts.expectedRecipientKeyId) || opts.expectedRecipientIssuerSerial) {
421
421
  if (!_recipientMatches(parsedKey.recipientInfos, opts)) throw E("est/recipient-mismatch", "the server-generated key is not encrypted to the advertised recipient (RFC 7030 sec. 4.4.2)");
422
422
  }
@@ -272,6 +272,16 @@ var JoseError = defineClass("JoseError", { withCause: true });
272
272
  // Carries the delegated jose/* or a DER leaf fault as `.cause`.
273
273
  var AcmeError = defineClass("AcmeError", { withCause: true });
274
274
 
275
+ // TrustError -- a Mozilla/NSS certdata.txt or CCADB CSV root-store input that
276
+ // fails ingestion: a malformed or oversized object block, a bad \ooo octal
277
+ // escape, an unrecognized CK_TRUST value, a certificate/trust-object pairing
278
+ // disagreement (metadata must never attach to the wrong root), an undecodable
279
+ // distrust-after time, a CSV framing / required-column violation, a CKA_VALUE
280
+ // or PEM cell that is not a well-formed certificate, or a purpose the entry is
281
+ // not a trusted delegator for. Carries the underlying leaf fault (an x509/* or
282
+ // asn1/* error) as `.cause`.
283
+ var TrustError = defineClass("TrustError", { withCause: true });
284
+
275
285
  module.exports = {
276
286
  PkiError: PkiError,
277
287
  defineClass: defineClass,
@@ -300,4 +310,5 @@ module.exports = {
300
310
  EstError: EstError,
301
311
  JoseError: JoseError,
302
312
  AcmeError: AcmeError,
313
+ TrustError: TrustError,
303
314
  };
package/lib/guard-all.js CHANGED
@@ -21,6 +21,15 @@
21
21
  // guard.range.int / .uint31 / .positiveInt31
22
22
  // -- bound a decoded integer before narrowing
23
23
  // to Number (silent-narrowing defence)
24
+ // guard.name.dnEqual / .rdnEqual / .assertNoControlBytes / .assertPrintableIa5
25
+ // -- canonical DN identity + name-string integrity
26
+ // (CVE-2009-2408 truncation / identity defence)
27
+ // guard.encoding.base64url / .base64 / .hex
28
+ // -- strict textual-encoding decode, canonical
29
+ // + capped (encoding-malleability defence)
30
+ // guard.identifier.assertCanonicalOid
31
+ // -- canonical dotted-decimal OID string form
32
+ // (canonicalization-divergence defence)
24
33
  //
25
34
  // Each shape is enforced by a codebase-patterns detector: the characteristic
26
35
  // token of a guard (the Buffer.from(x.buffer, byteOffset) re-view, the
@@ -34,6 +43,9 @@ var limits = require("./guard-limits");
34
43
  var crypto = require("./guard-crypto");
35
44
  var range = require("./guard-range");
36
45
  var name = require("./guard-name");
46
+ var encoding = require("./guard-encoding");
47
+ var json = require("./guard-json");
48
+ var identifier = require("./guard-identifier");
37
49
 
38
50
  module.exports = {
39
51
  bytes: bytes,
@@ -42,4 +54,7 @@ module.exports = {
42
54
  crypto: crypto,
43
55
  range: range,
44
56
  name: name,
57
+ encoding: encoding,
58
+ json: json,
59
+ identifier: identifier,
45
60
  };
@@ -20,6 +20,12 @@
20
20
  // lengths, so the gate must precede it) then a single timingSafeEqual over the
21
21
  // equal-length buffers -- both operands are always read, no data-dependent
22
22
  // branch. A length mismatch is an honest false, never a throw.
23
+ //
24
+ // Also defends the signature-malleability class (CWE-347 / CWE-20): a signature /
25
+ // MAC / digest BIT STRING with a non-zero unused-bits count is not whole octets,
26
+ // so trailing sub-octet bits let two encodings carry "one" signature. Every RFC
27
+ // 9481/4211/6960/5280 signature output is a whole number of octets, so a value
28
+ // with unusedBits !== 0 fails closed BEFORE its bytes reach a verifier.
23
29
 
24
30
  var nodeCrypto = require("node:crypto");
25
31
 
@@ -31,4 +37,28 @@ function constantTimeEqual(a, b) {
31
37
  return a.length === b.length && nodeCrypto.timingSafeEqual(a, b);
32
38
  }
33
39
 
34
- module.exports = { constantTimeEqual: constantTimeEqual };
40
+ // isOctetAligned(bitString) -> boolean. True when a decoded BIT STRING (a
41
+ // { bytes, unusedBits } node) is whole octets. For the never-throw verify paths
42
+ // (a boolean/continue verdict) so a malleable signature is skipped, not honored.
43
+ // @enforced-by behavioral -- shares the octet-alignment rule below; a bare
44
+ // .unusedBits check has legitimate non-signature siblings, so the RED vectors
45
+ // (a non-octet-aligned signature is skipped / rejected) are the guard.
46
+ function isOctetAligned(bitString) {
47
+ return !!bitString && bitString.unusedBits === 0;
48
+ }
49
+
50
+ // assertOctetAligned(bitString, E, code, label) -> bitString | throws E(code,...).
51
+ // The throwing form for a parse boundary: a signature / MAC / digest BIT STRING
52
+ // MUST be octet-aligned, else the malleable value is rejected fail-closed.
53
+ // @enforced-by behavioral -- .unusedBits !== 0 is a per-field RFC rule, and the
54
+ // same token legitimately tests a DIFFERENT invariant (NamedBitList minimality,
55
+ // schema-engine assertMinimalNamedBits), so a blanket detector would false-fire;
56
+ // the RED vectors (a non-octet-aligned signature/digest rejects) are the guard.
57
+ function assertOctetAligned(bitString, E, code, label) {
58
+ if (!isOctetAligned(bitString)) {
59
+ throw E(code, (label || "signature") + " BIT STRING must be octet-aligned (0 unused bits)");
60
+ }
61
+ return bitString;
62
+ }
63
+
64
+ module.exports = { constantTimeEqual: constantTimeEqual, isOctetAligned: isOctetAligned, assertOctetAligned: assertOctetAligned };
@@ -0,0 +1,90 @@
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 textual-encoding integrity composes this guard (pki.jose
7
+ // base64url, pki.schema.* PEM bodies, pki.webcrypto JWK key material, pki.acme
8
+ // / pki.est transfer encodings).
9
+ //
10
+ // guard-encoding -- strict decode of an untrusted base64 / base64url / hex text
11
+ // to bytes. Node's Buffer.from(x, "base64"|"base64url"|"hex") is LENIENT: it
12
+ // silently drops the first invalid character (and everything after a stray one),
13
+ // accepts non-canonical trailing bits, and tolerates missing/extra padding, so
14
+ // two distinct texts alias one byte string -- or a malformed text decodes to a
15
+ // SHORTER, different value than intended. And it allocates the whole decode
16
+ // before any size is checked. This is CWE-172 (encoding-transformation error) /
17
+ // CWE-20 (canonicalization malleability) + CWE-770 (allocate-before-cap): RFC
18
+ // 4648 sec. 3.5 / sec. 5 and RFC 8555 sec. 6.1 require the UNIQUE canonical
19
+ // encoding. Each decoder here gates the alphabet, rejects an impossible length,
20
+ // enforces the byte cap BEFORE the copy, decodes, and verifies canonicality by a
21
+ // re-encode round-trip -- so a value that is not the one canonical encoding of
22
+ // its bytes fails closed with the caller's typed error.
23
+ //
24
+ // E is the caller's (code, message) typed-error factory; `code` the frozen
25
+ // domain/reason; `label` the field phrase; `maxBytes` an optional decoded-size
26
+ // cap (null/undefined = uncapped, for a PEM cert body already bounded upstream).
27
+
28
+ var B64URL_ALPHABET = /^[A-Za-z0-9_-]*$/;
29
+ var B64_ALPHABET = /^[A-Za-z0-9+/]*={0,2}$/;
30
+ var HEX_ALPHABET = /^[0-9A-Fa-f]*$/;
31
+
32
+ // Reject before Buffer.from allocates: a base64 text of N chars decodes to at
33
+ // most floor(N*3/4) bytes, a hex text to N/2.
34
+ function _capBefore(nChars, perByteChars, maxBytes, E, code, label) {
35
+ if (maxBytes == null) return; // documented uncapped mode (bounded upstream)
36
+ // The cap is an authoring input: a NaN / fractional / negative maxBytes makes
37
+ // the comparison below always false -- the cap silently disabled. Config-time
38
+ // TypeError instead (null/undefined stays the documented uncapped mode).
39
+ if (!Number.isInteger(maxBytes) || maxBytes < 0) {
40
+ throw new TypeError("guard.encoding: maxBytes must be a non-negative integer or null");
41
+ }
42
+ if (Math.floor(nChars / perByteChars) > maxBytes) {
43
+ throw E(code, label + " exceeds the maximum decoded size of " + maxBytes + " bytes");
44
+ }
45
+ }
46
+
47
+ // base64url(text, maxBytes, E, code, label) -> Buffer. Unpadded base64url (RFC
48
+ // 4648 sec. 5 / RFC 7515): no "=" padding, no "+"/"/", canonical final char.
49
+ // @enforced-by base64-decode-not-via-guard
50
+ function base64url(text, maxBytes, E, code, label) {
51
+ if (typeof text !== "string") throw E(code, label + " must be a string");
52
+ if (!B64URL_ALPHABET.test(text)) throw E(code, label + " is not base64url (padding or a non-alphabet character)");
53
+ if (text.length % 4 === 1) throw E(code, label + " has an impossible base64url length");
54
+ _capBefore(text.length * 3, 4, maxBytes, E, code, label);
55
+ var buf = Buffer.from(text, "base64url");
56
+ if (buf.toString("base64url") !== text) throw E(code, label + " is not canonical base64url");
57
+ return buf;
58
+ }
59
+
60
+ // base64(text, maxBytes, E, code, label) -> Buffer. Padded base64 (RFC 4648
61
+ // sec. 4): whole 4-character groups, canonical padding + trailing bits.
62
+ // @enforced-by base64-decode-not-via-guard
63
+ function base64(text, maxBytes, E, code, label) {
64
+ if (typeof text !== "string") throw E(code, label + " must be a string");
65
+ if (!B64_ALPHABET.test(text)) throw E(code, label + " is not base64 (a non-alphabet character)");
66
+ if (text.length % 4 !== 0) throw E(code, label + " must be whole 4-character base64 groups (RFC 4648 sec. 3.5)");
67
+ _capBefore(text.length * 3, 4, maxBytes, E, code, label);
68
+ var buf = Buffer.from(text, "base64");
69
+ if (buf.toString("base64") !== text) throw E(code, label + " is not canonical base64 (RFC 4648 sec. 3.5)");
70
+ return buf;
71
+ }
72
+
73
+ // hex(text, maxBytes, E, code, label) -> Buffer. Even-length hex; canonical via
74
+ // a lower-case re-encode round-trip (RFC 4514 sec. 2.4 hex-string values).
75
+ // @enforced-by behavioral -- the hex Buffer.from token has legitimate non-decode
76
+ // siblings in the tree (a hard-coded AES-KW IV constant, an internal
77
+ // parser-emitted canonical serial, an internal BigInt->hex), so a blanket
78
+ // Buffer.from(x,"hex") detector would false-fire; the RED conformance vectors
79
+ // (a non-canonical / odd-length / non-hex #hex attribute value rejects) guard it.
80
+ function hex(text, maxBytes, E, code, label) {
81
+ if (typeof text !== "string") throw E(code, label + " must be a string");
82
+ if (!HEX_ALPHABET.test(text)) throw E(code, label + " is not hexadecimal");
83
+ if (text.length % 2 !== 0) throw E(code, label + " must have an even number of hex digits");
84
+ _capBefore(text.length, 2, maxBytes, E, code, label);
85
+ var buf = Buffer.from(text, "hex");
86
+ if (buf.toString("hex") !== text.toLowerCase()) throw E(code, label + " is not canonical hexadecimal");
87
+ return buf;
88
+ }
89
+
90
+ module.exports = { base64url: base64url, base64: base64, hex: hex };
@@ -0,0 +1,56 @@
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 identifier-string integrity composes this guard (pki.oid
7
+ // name/arc resolution, pki.asn1 OID encoding, pki.path.validate requiredEku /
8
+ // userInitialPolicySet key checking).
9
+ //
10
+ // guard-identifier -- fail-closed canonicalization of the structured-identifier
11
+ // STRINGS the toolkit compares and encodes. An identifier used as a lookup /
12
+ // comparison key must be in one canonical form, or the string form and the
13
+ // decoded form of the same identifier silently disagree.
14
+ //
15
+ // Defends the canonicalization-divergence class (CWE-20): a dotted-decimal OID
16
+ // with a leading-zero arc ("2.05.29.15") round-trips to a DIFFERENT OID
17
+ // ("2.5.29.15"), and an arc outside the X.660 bounds can never DER-encode at all.
18
+ // A hand-rolled string check that accepts either lets `build.oid` emit bytes that
19
+ // decode to a different OID than the string names, or lets a non-canonical policy /
20
+ // EKU key compare unequal to the canonical decoder output it is matched against (a
21
+ // silent false-reject). Every string-form identifier check routes through here so
22
+ // the string and DER forms cannot diverge.
23
+
24
+ // assertCanonicalOid(str, E, code, label, boundsCode) -> str | throws
25
+ // A canonical dotted-decimal object identifier string: two or more arcs, each a
26
+ // non-negative decimal integer with no leading zero (the SYNTAX), and -- unless
27
+ // bounds are waived -- the root arc 0..2 with the second arc 0..39 under roots 0
28
+ // and 1 (the X.660 ENCODABILITY bounds: the first two arcs pack into a single
29
+ // sub-identifier as 40*root+second). E is the (code, message) typed-error factory:
30
+ // - a syntax fault always throws E(code, ...).
31
+ // - boundsCode === null waives the arc-bound check -- for a LOOKUP key (oid.name
32
+ // / oid.has), where a well-formed but non-encodable OID is simply not
33
+ // registered (a miss), not an error.
34
+ // - otherwise an out-of-range arc throws E(boundsCode, ...) (boundsCode defaults
35
+ // to code), so a caller distinguishing the two reasons (oid.js: oid/bad-input
36
+ // syntax vs oid/bad-arc bounds) keeps both codes.
37
+ // @enforced-by behavioral -- string-form OID canonicalization has no rename-proof
38
+ // code shape distinct from the arc-based bounds check oid.js legitimately keeps
39
+ // for the arc path; the divergence RED vectors (build.oid / oid.toArcs / requiredEku
40
+ // reject a non-canonical OID) driving the composing consumers are the guard.
41
+ function assertCanonicalOid(str, E, code, label, boundsCode) {
42
+ var who = label || "OID";
43
+ if (typeof str !== "string" || !/^(0|[1-9]\d*)(\.(0|[1-9]\d*))+$/.test(str)) {
44
+ throw E(code, who + " must be a canonical dotted-decimal OID string of two or more arcs with no leading-zero component");
45
+ }
46
+ if (boundsCode === null) return str;
47
+ var bcode = boundsCode === undefined ? code : boundsCode;
48
+ var parts = str.split(".");
49
+ var root = BigInt(parts[0]);
50
+ var second = BigInt(parts[1]);
51
+ if (root > 2n) throw E(bcode, who + " root arc must be 0, 1, or 2 (X.660)");
52
+ if (root < 2n && second > 39n) throw E(bcode, who + " second arc must be 0..39 under roots 0 and 1 (X.660)");
53
+ return str;
54
+ }
55
+
56
+ module.exports = { assertCanonicalOid: assertCanonicalOid };