@blamejs/pki 0.3.24 → 0.3.25

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,19 @@ 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.3.25 — 2026-07-26
8
+
9
+ pki.path.build can now fetch a missing intermediate over the network -- opt in with `fetchAia` and it discovers the issuer from a certificate's AIA caIssuers URL, so a chain with a gap in the supplied pool still builds.
10
+
11
+ ### Added
12
+
13
+ - pki.path.build accepts opts.fetchAia: true to discover a missing intermediate from a certificate's AIA caIssuers URL (RFC 5280 sec. 4.2.2.1) over pki.transport, explored as a lazy fallback only after the local candidate pool is exhausted (RFC 4158 sec. 7.2 local-before-remote), so a build the pool can complete never touches the network. The result gains aiaFetches (the count of network GETs). opts.transport injects the transport for offline use; opts.tls carries the TLS trust for the AIA host (distinct from opts.trustAnchors); opts.maxAiaFetches / opts.maxAiaPerCert / opts.aiaTimeout / opts.maxResponseBytes bound the fetch. Off by default -- the default build is byte-identical offline. RFC 5280 sec. 4.2.2.1, RFC 4158 sec. 6.3 / sec. 8.1.
14
+ - pki.inspect renders the authorityInfoAccess extension (RFC 5280 sec. 4.2.2.1) -- the CA Issuers and OCSP access descriptions with their URLs -- instead of a hex dump.
15
+
16
+ ### Changed
17
+
18
+ - The AIA fetch is fail-closed and SSRF-bounded: an http/ldap/ftp/file/mailto or non-URI caIssuers accessLocation, or an id-ad-ocsp access method, is never fetched (no socket); a destination that is -- or that resolves to -- a private, loopback, or link-local address is refused (the checked address pinned for the connection), so an untrusted certificate cannot drive an authenticated GET to an internal service by IP literal or by hostname; a total fetch budget silently caps fetching (never a throw that denies a buildable path); a per-certificate URL cap (maxAiaPerCert:0 disables per-certificate fetching outright), a build-wide URL dedupe on the normalized URL, a response-size cap, and a per-response certificate-count cap bound the work; no redirect is followed. Every fetch fault -- a transport error, a non-200, an oversize or non-certificate body -- is a silent skip, so an unreachable or hostile AIA endpoint never fails a build that the pool could still complete.
19
+
7
20
  ## v0.3.24 — 2026-07-26
8
21
 
9
22
  pki.smime.verify / decrypt can now recognize a legacy (RFC 8551) header-protected message -- opt in with `legacyHeaderProtection` and the real headers of an older `message/rfc822`-wrapped message are surfaced, safely separated from the authenticated header set.
package/README.md CHANGED
@@ -222,7 +222,7 @@ is callable today; nothing below is a stub.
222
222
  | `pki.acme` | RFC 8555 / 8737 / 8738 / 9773 ACME — `client(directoryUrl, opts)` is a stateful client that drives a live CA directory over `pki.transport` (inject your own, or the fail-closed default): `newAccount` / `newOrder` / `getOrder` / `getAuthorization` / `getChallenge` / `respondToChallenge` / `finalize` / `pollOrder` / `pollAuthorization` / `downloadCertificate` walk the issuance flow, and `revokeCert` (account-key or certificate-key signed), `keyChange` (account key rotation), `deactivateAccount` / `deactivateAuthorization`, and `renewalInfo` (ARI) round out the lifecycle — https-only for every URL, an explicit trust anchor required, a fresh single-use nonce per request with a bounded badNonce retry, POST-as-GET reads, bounded polling that sleeps on a Retry-After via an injectable sleeper (capped by a poll count and a total-wait budget), and every response body size-capped. Over the message layer it composes: 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), fail-closed — `client`, `validate`, `identify`, `assertTransition`, the builders, `keyAuthorization`, `http01`, `dns01`, `tlsAlpn01Extension`, `verifyTlsAlpn01`, `ariCertId` |
223
223
  | `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` |
224
224
  | `pki.schema.engine` | The declarative ASN.1 structure-schema engine every format parser composes — `walk` / `encode` / `embeddedDer` plus the schema combinators |
225
- | `pki.path` | RFC 5280 §6 certification-path validation — `validate` runs the §6.1 state machine (signature chaining across RSA, ECDSA, EdDSA, ML-DSA, SLH-DSA and hybrid composite ML-DSA signatures — a composite is accepted only when **both** its post-quantum and traditional components verify; 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. `build(leaf, opts)` is the discovering complement (RFC 4158): from a leaf, an untrusted pool of candidate CA certificates, and a trust store, it finds the ordered leaf→anchor path `validate` accepts — name chaining plus the RFC 4158 §3.5 sort hints (AKI/SKI match, anchor-adjacent issuer, CA + keyCertSign, validity), a depth-first search with backtracking so the first path `validate` accepts wins, and a bounded search (chain-length cap, candidate-expansion cap, identity-tuple visited-set) so a cross-certificate cycle or Bridge-CA fan-out terminates deterministically; every accept flows through `validate` and its verdict is cross-checked against `openssl verify`. Pure and re-entrant, fail-closed — `validate`, `build`, `crlChecker`, `ocspChecker` |
225
+ | `pki.path` | RFC 5280 §6 certification-path validation — `validate` runs the §6.1 state machine (signature chaining across RSA, ECDSA, EdDSA, ML-DSA, SLH-DSA and hybrid composite ML-DSA signatures — a composite is accepted only when **both** its post-quantum and traditional components verify; 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. `build(leaf, opts)` is the discovering complement (RFC 4158): from a leaf, an untrusted pool of candidate CA certificates, and a trust store, it finds the ordered leaf→anchor path `validate` accepts — name chaining plus the RFC 4158 §3.5 sort hints (AKI/SKI match, anchor-adjacent issuer, CA + keyCertSign, validity), a depth-first search with backtracking so the first path `validate` accepts wins, and a bounded search (chain-length cap, candidate-expansion cap, identity-tuple visited-set) so a cross-certificate cycle or Bridge-CA fan-out terminates deterministically; every accept flows through `validate` and its verdict is cross-checked against `openssl verify`. **Opt-in AIA `caIssuers` fetching** (`opts.fetchAia: true`) discovers a *missing* intermediate from a certificate's Authority Information Access URL (RFC 5280 §4.2.2.1) over `pki.transport`, triggered only on a pool miss — SSRF/amplification-bounded (https-only, a total fetch budget that silently caps fetching rather than throwing, a per-cert URL cap, a build-wide URL dedupe, a response-size + certificate-count cap, no redirect following; every fault a silent skip), with the TLS trust (`opts.tls`) kept distinct from the PKI `trustAnchors` and every fetched certificate remaining untrusted pool material that still flows through `validate` (never a trust anchor). Off by default — the default build is byte-identical offline. Pure and re-entrant, fail-closed — `validate`, `build`, `crlChecker`, `ocspChecker` |
226
226
  | `pki.x509` | X.509 certificate issuance (RFC 5280 §4) — `sign(spec, issuer, opts)` builds and signs a certificate: a `spec` of subject (a common-name string, an array of RDNs, or raw Name DER), the public key being certified, the validity window, an optional serial, and an optional `extensions` object; an `issuer` that is a key alone (self-signed — issuer equals subject, signed with that key) or a name + public key + key, or an issuing certificate + key (CA-signed). The signature algorithm is resolved from the signing key through the shared registry, so RSA (PKCS#1 v1.5 / PSS via `opts.pss`), ECDSA P-256/384/521, Ed25519, Ed448, ML-DSA-44/65/87, the twelve SLH-DSA sets, and the composite arms all issue without a per-algorithm branch. It encodes basic constraints, key usage, extended key usage, subject and authority key identifiers (the SKI auto-derived by SHA-1 of the subject key), subject alternative names, and certificate policies from the spec — any other extension supplied as pre-encoded DER — derives the version from the field set, and enforces the serial bounds, the UTCTime/GeneralizedTime cutover, the DER default omissions, and the CA cross-field rules; a violation throws a typed `CertificateError`. Returns DER, or a PEM `CERTIFICATE` with `opts.pem`; every arm is independently verified by OpenSSL. Parsing stays at `pki.schema.x509.parse` — `sign` |
227
227
  | `pki.csr` | PKCS#10 certification-request issuance (RFC 2986 / RFC 2985) — `sign(spec, key, opts)` builds and signs a `CertificationRequest`: a `spec` of subject (a common-name string, an array of RDNs, or raw Name DER; may be empty), the public key being certified, an optional `extensionRequest` (requested v3 extensions — subject alternative names, key usage, extended key usage, basic constraints, certificate policies, subject key identifier, or an array of pre-encoded Extension DER — that a CA copies into the issued certificate), and an optional `challengePassword`. `key` (or `{ key }`) is the subject's own private key: the request is self-signed to prove possession of the private half of `subjectPublicKey`, and that proof is verified before the request is returned (what `openssl req -verify` checks). The signature algorithm is resolved from the subject key, so RSA (PKCS#1 v1.5 / PSS via `opts.pss`), ECDSA, EdDSA, ML-DSA, SLH-DSA, and the composite arms all sign without a per-algorithm branch. Returns DER, or a PEM `CERTIFICATE REQUEST` with `opts.pem`; malformed input throws a typed `CsrError`. Parsing stays at `pki.schema.csr.parse` — `sign` |
228
228
  | `pki.attrcert` | RFC 5755 attribute-certificate issuance — `sign(spec, issuer, opts)` builds and signs an `AttributeCertificate` as an Attribute Authority: a `spec` of `holder` (exactly one of an entity name, a `baseCertificateID` public-key-certificate reference, a `fromCertificate` binding derived from a certificate, or an object digest), the validity window (GeneralizedTime), an optional serial (positive, ≤ 20 octets; randomly generated when omitted), the `attributes` (the privilege syntaxes — role, clearance, group, chargingIdentity, accessIdentity, authenticationInfo — or pre-encoded Attribute DER), and optional `extensions` (auditIdentity, targetInformation, noRevAvail, aaControls, acProxying, authorityKeyIdentifier, or pre-encoded Extension DER, each with its RFC 5755 criticality). An attribute certificate is never self-signed — the `issuer` is the signing AA, supplied as `{ cert, key }` or `{ name, publicKey, key }`. The signature algorithm is resolved from the AA key, so RSA (PKCS#1 v1.5 / PSS via `opts.pss`), ECDSA, EdDSA, ML-DSA, SLH-DSA, and the composite arms all sign without a per-algorithm branch, and the signature is verified under the AA public key before the certificate is returned. Returns DER, or a PEM `ATTRIBUTE CERTIFICATE` with `opts.pem`; malformed input throws a typed `AttrCertError`. Parsing stays at `pki.schema.attrcert.parse` — `sign` |
package/index.js CHANGED
@@ -154,7 +154,9 @@ module.exports = {
154
154
  // drive -- pki.transport.https(defaults) returns a transport(request) -> {status,
155
155
  // headers, body}. The toolkit's sole socket choke point: explicit trust anchors,
156
156
  // rejectUnauthorized always on, a TLS floor, a streaming response cap, and a timeout.
157
- transport: transport,
157
+ // Curated to the public `https` factory; the module's `isBlockedIp` classifier is an
158
+ // internal helper pki.path.build reuses (require the module), not a public surface.
159
+ transport: { https: transport.https },
158
160
  // `jose` is the RFC 7515 Flattened JWS + RFC 7638 JWK-thumbprint layer: a strict
159
161
  // base64url codec, a bounded duplicate-key-rejecting JSON reader, profiled
160
162
  // sign/verify (ACME-outer / EAB-inner / keyChange-inner), and an alg registry
package/lib/constants.js CHANGED
@@ -223,6 +223,21 @@ var LIMITS = {
223
223
  // overridable (opts.maxDepth / opts.maxCandidatesConsidered).
224
224
  PATH_BUILD_MAX_DEPTH: 20,
225
225
  PATH_BUILD_MAX_CANDIDATES: 1000,
226
+ // AIA caIssuers network-fetch bounds (pki.path.build with opts.fetchAia). Fetching an issuer over the
227
+ // network from an untrusted certificate's authorityInfoAccess opens an SSRF / amplification surface (a
228
+ // hostile mesh where each fetched cert advertises a fresh caIssuers URL). PATH_AIA_MAX_FETCHES is the
229
+ // TOTAL network GET budget across a whole build() call (a breach throws path/aia-fetch-limit);
230
+ // PATH_AIA_MAX_PER_CERT caps how many caIssuers URIs are tried for a single certificate (an AIA MAY carry
231
+ // many). Both are small by default and operator-overridable (opts.maxAiaFetches / opts.maxAiaPerCert).
232
+ PATH_AIA_MAX_FETCHES: 10,
233
+ PATH_AIA_MAX_PER_CERT: 3,
234
+ // A caIssuers response is one certificate or a short chain -- never a 24 MiB bundle. Bounding the AIA
235
+ // fetch below the general HTTP ceiling (and capping the certificate COUNT a single response contributes)
236
+ // stops a hostile-but-TLS-trusted AIA endpoint from forcing tens of thousands of certificate parses per
237
+ // fetch (parse work bounded by COUNT, not only by bytes). Both operator-overridable (opts.maxResponseBytes
238
+ // tightens downward only; the count cap is a fixed defense).
239
+ PATH_AIA_MAX_RESPONSE_BYTES: BYTES.mib(1),
240
+ PATH_AIA_MAX_CERTS_PER_RESPONSE: 16,
226
241
  // PKCS#12 container ceilings. A PFX carries lists at three altitudes
227
242
  // (ContentInfos per AuthenticatedSafe, SafeBags per SafeContents,
228
243
  // attributes per bag) and can chain fresh DER blobs inside OCTET STRINGs,
package/lib/est.js CHANGED
@@ -56,7 +56,6 @@ var oid = require("./oid");
56
56
  var constants = require("./constants");
57
57
  var cms = require("./schema-cms");
58
58
  var x509 = require("./schema-x509");
59
- var crl = require("./schema-crl");
60
59
  var pkcs8 = require("./schema-pkcs8");
61
60
  var csr = require("./schema-csr");
62
61
  var frameworkError = require("./framework-error");
@@ -66,8 +65,6 @@ var retryAfter = require("./http-retry-after");
66
65
 
67
66
  var EstError = frameworkError.EstError;
68
67
  function E(code, message, cause) { return new EstError(code, message, cause); }
69
-
70
- var ID_DATA = oid.byName("data");
71
68
  var ID_SIGNED_DATA = oid.byName("signedData");
72
69
  var OID_CHALLENGE_PASSWORD = oid.byName("challengePassword");
73
70
  var OID_DECRYPT_KEY_ID = oid.byName("decryptKeyID");
@@ -212,36 +209,9 @@ function splitMultipartMixed(body, contentType) {
212
209
  * r.certificates; // -> [Buffer, ...] raw, unordered
213
210
  */
214
211
  function parseCertsOnly(der) {
215
- var r;
216
- try { r = cms.parse(der); }
217
- catch (e) { if (e instanceof EstError) throw e; throw E("est/bad-response", "the EST response did not decode as CMS: " + ((e && e.message) || String(e)), e); }
218
- if (r.contentTypeName !== "signedData") throw E("est/not-certs-only", "an EST certs-only response must be a CMS SignedData (RFC 5272 sec. 4.1)");
219
- if (r.encapContentInfo.eContentType !== ID_DATA || r.encapContentInfo.eContent !== null) {
220
- throw E("est/not-certs-only", "a certs-only Simple PKI Response must carry id-data with no eContent (RFC 5272 sec. 4.1)");
221
- }
222
- if (r.signerInfos.length !== 0) throw E("est/not-certs-only", "a certs-only Simple PKI Response must have empty signerInfos (RFC 5272 sec. 4.1)");
223
- if (!r.certificates || r.certificates.length === 0) throw E("est/no-certificates", "an EST certs-only response must contain at least one certificate (RFC 7030 sec. 4.1.3)");
224
- for (var i = 0; i < r.certificates.length; i++) {
225
- if (r.certificates[i].tagClass !== "universal") throw E("est/bad-certificate-choice", "EST exchanges plain X.509 certificates; a tagged CertificateChoices alternative is not permitted (RFC 7030)");
226
- // A universal-SEQUENCE CertificateChoice must be a well-formed X.509
227
- // Certificate, not merely any SEQUENCE. Parse it structurally (still
228
- // returning the raw bytes below) so a malformed response fails closed.
229
- try { x509.parse(r.certificates[i].bytes); }
230
- catch (e) { if (e instanceof EstError) throw e; throw E("est/bad-certificate", "a certs-only response carried a non-certificate in its certificates field (RFC 5272 sec. 4.1)", e); }
231
- }
232
- var crls = r.crls || [];
233
- for (var j = 0; j < crls.length; j++) {
234
- // A RevocationInfoChoice is a plain X.509 CertificateList or a [1] otherRevInfo;
235
- // EST surfaces CRLs, so reject the tagged alternative and structurally validate
236
- // each universal entry as a CertificateList (mirrors the certificate path).
237
- if (crls[j].tagClass !== "universal") throw E("est/bad-crl", "an EST response CRL must be a plain X.509 CertificateList, not a tagged otherRevInfo alternative (RFC 5652 sec. 10.2.1)");
238
- try { crl.parse(crls[j].bytes); }
239
- catch (e) { if (e instanceof EstError) throw e; throw E("est/bad-crl", "a response carried a non-CRL in its crls field", e); }
240
- }
241
- return {
242
- certificates: r.certificates.map(function (c) { return c.bytes; }),
243
- crls: crls.map(function (c) { return c.bytes; }),
244
- };
212
+ // The certs-only Simple PKI Response shape is a CMS concern shared with AIA path building; the reader lives
213
+ // in schema-cms. The "est" prefix keeps the exact est/* codes (est/not-certs-only, est/no-certificates, ...).
214
+ return cms.parseCertsOnly(der, E, "est");
245
215
  }
246
216
 
247
217
  // Pick the issued certificate from a certs-only response by matching its public
@@ -39,6 +39,7 @@
39
39
  var nodeHttps = require("node:https");
40
40
  var nodeNet = require("node:net");
41
41
  var nodeTls = require("node:tls");
42
+ var nodeDns = require("node:dns");
42
43
  var constants = require("./constants");
43
44
  var guard = require("./guard-all");
44
45
  var frameworkError = require("./framework-error");
@@ -97,11 +98,81 @@ function _systemCa() {
97
98
  return out;
98
99
  }
99
100
 
101
+ // Classify a bare IP string (no brackets) as a private / loopback / link-local / reserved destination an
102
+ // untrusted URL must not reach: RFC 1918 + loopback + this-network + multicast/reserved + 169.254 (cloud
103
+ // metadata) + CGNAT for IPv4, and loopback / unspecified / IPv4-mapped / ULA (fc00::/7) + link-local (fe80::/10)
104
+ // for IPv6. This is the SAME range set the AIA literal pre-check applies (pki.path.build reuses it), enforced
105
+ // here at DNS-RESOLUTION time so a hostname pointing AT an internal address is caught too. A malformed IP fails
106
+ // CLOSED (net.isIP === 0 -> not a v4/v6 arm -> the caller treats a non-IP as un-judgeable, never as public).
107
+ function _isBlockedIp(ip) {
108
+ var fam = nodeNet.isIP(ip);
109
+ if (fam === 4) {
110
+ // net.isIP === 4 guarantees exactly four octets 0..255. Block the COMPLETE IANA special-purpose /
111
+ // non-global set (RFC 6890) so an untrusted destination can reach ONLY globally-routable public space.
112
+ var o = ip.split("."), a = +o[0], b = +o[1], c = +o[2];
113
+ return a === 0 || a === 10 || a === 127 || a >= 224 || // this-network / RFC1918 10/8 / loopback / multicast 224/4 + reserved 240/4 + broadcast
114
+ (a === 100 && b >= 64 && b <= 127) || // 100.64/10 CGNAT
115
+ (a === 169 && b === 254) || // 169.254/16 link-local (cloud metadata)
116
+ (a === 172 && b >= 16 && b <= 31) || // 172.16/12
117
+ (a === 192 && b === 168) || // 192.168/16
118
+ (a === 192 && b === 0 && (c === 0 || c === 2)) || // 192.0.0/24 IETF protocol + 192.0.2/24 TEST-NET-1
119
+ (a === 192 && b === 88 && c === 99) || // 192.88.99/24 6to4 relay anycast (deprecated)
120
+ (a === 198 && (b === 18 || b === 19)) || // 198.18/15 benchmarking
121
+ (a === 198 && b === 51 && c === 100) || // 198.51.100/24 TEST-NET-2
122
+ (a === 203 && b === 0 && c === 113); // 203.0.113/24 TEST-NET-3
123
+ }
124
+ if (fam === 6) {
125
+ var l = ip.toLowerCase();
126
+ if (l.indexOf("::ffff:") === 0) return true; // IPv4-mapped -- may embed a private v4; block all (fail-closed)
127
+ var parts = l.split(":");
128
+ var h = parseInt(parts[0], 16); // first hextet ("" for a leading "::" -> NaN -> not in 2000::/3 -> blocked)
129
+ if (!(h >= 0x2000 && h <= 0x3fff)) return true; // outside global unicast 2000::/3: loopback/ULA/link-local/site-local/multicast(ff00::/8)/unspecified/unallocated
130
+ // Within 2000::/3, carve out the non-globally-routable special-purpose sub-ranges (IANA IPv6 Special-Purpose
131
+ // Address Registry) an attacker could route to an internal service: a first-hextet allow of the whole block
132
+ // would admit them. The second hextet is 0 when "::" compresses it (parts[1] empty).
133
+ var h2 = parts[1] ? parseInt(parts[1], 16) : 0;
134
+ if (h === 0x2002) return true; // 2002::/16 6to4 (embeds an IPv4 that may be private)
135
+ if (h === 0x2001 && h2 < 0x0200) return true; // 2001::/23 IETF protocol assignments (Teredo / benchmarking / ORCHID / AMT / ...)
136
+ if (h === 0x2001 && h2 === 0x0db8) return true; // 2001:db8::/32 documentation (RFC 3849)
137
+ if (h === 0x3fff && h2 < 0x1000) return true; // 3fff::/20 documentation (RFC 9637)
138
+ return false;
139
+ }
140
+ return false;
141
+ }
142
+
143
+ // A DNS-rebinding-safe SSRF filter, installed as node's `lookup` ONLY when a request opts into
144
+ // blockPrivateAddresses (the AIA fetch of an untrusted-cert URL). node connects to EXACTLY the address this
145
+ // returns -- no second resolution -- so checking the resolved address here PINS it, closing the resolve/connect
146
+ // TOCTOU. Any private / loopback / link-local result fails the lookup, which surfaces as transport/blocked-address.
147
+ function _blockedAddrErr(hostname, address) {
148
+ var e = new Error("refusing to connect to " + hostname + " -> " + address + " (private / loopback / link-local address blocked)");
149
+ e.pkiBlockedAddress = true;
150
+ return e;
151
+ }
152
+ // Built over an injectable resolver (defaults to nodeDns.lookup) so every branch -- a resolve error, the
153
+ // options.all array shape, a blocked result, a permitted result -- is unit-testable without a live DNS.
154
+ function _makeGuardedLookup(lookupFn) {
155
+ return function guardedLookup(hostname, options, callback) {
156
+ lookupFn(hostname, options || {}, function (err, address, family) {
157
+ if (err) return callback(err);
158
+ if (Array.isArray(address)) { // options.all -> [{ address, family }, ...]; reject if ANY resolved address is blocked
159
+ for (var i = 0; i < address.length; i++) if (_isBlockedIp(address[i].address)) return callback(_blockedAddrErr(hostname, address[i].address));
160
+ return callback(null, address);
161
+ }
162
+ if (_isBlockedIp(address)) return callback(_blockedAddrErr(hostname, address));
163
+ return callback(null, address, family);
164
+ });
165
+ };
166
+ }
167
+ var _guardedLookup = _makeGuardedLookup(nodeDns.lookup);
168
+
100
169
  // Classify a node request/TLS error into the transport's fail-closed verdict: a
101
- // protocol-version mismatch is the TLS floor; a certificate / identity / handshake
102
- // failure is a server-authentication failure; anything else is a generic transport
103
- // error. Every arm threads the raw fault as `.cause`, so the diagnostic survives.
170
+ // blocked-address lookup rejection; a protocol-version mismatch is the TLS floor; a
171
+ // certificate / identity / handshake failure is a server-authentication failure;
172
+ // anything else is a generic transport error. Every arm threads the raw fault as
173
+ // `.cause`, so the diagnostic survives.
104
174
  function _classifyError(e, C) {
175
+ if (e && e.pkiBlockedAddress) return C("blocked-address");
105
176
  var s = String((e && e.code) || "") + " " + String((e && e.message) || "");
106
177
  if (/PROTOCOL_VERSION|UNSUPPORTED_PROTOCOL|VERSION_TOO_LOW|WRONG_VERSION|NO_PROTOCOLS_AVAILABLE|INAPPROPRIATE_FALLBACK/i.test(s)) return C("tls-floor");
107
178
  if (/CERT|SELF.?SIGNED|VERIFY|ALTNAME|HOSTNAME|DEPTH_ZERO|LOCAL_ISSUER|HANDSHAKE|\bSSL\b|\bTLS\b/i.test(s)) return C("server-auth-failed");
@@ -114,7 +185,7 @@ function _classifyError(e, C) {
114
185
  * @since 0.3.16
115
186
  * @status experimental
116
187
  * @spec RFC 7030, RFC 8996
117
- * @defends tls-downgrade (CWE-757), server-impersonation (CWE-297), response-flooding (CWE-770)
188
+ * @defends tls-downgrade (CWE-757), server-impersonation (CWE-297), response-flooding (CWE-770), ssrf (CWE-918)
118
189
  * @related pki.est.cacerts, pki.est.simpleenroll
119
190
  *
120
191
  * Build a fail-closed `node:https` transport: `transport(request) -> Promise<{ status,
@@ -139,6 +210,7 @@ function _classifyError(e, C) {
139
210
  * - `tls.minVersion` -- 'TLSv1.2' (default) or 'TLSv1.3'; never below the floor.
140
211
  * - `tls.servername` / `tls.checkServerIdentity` -- SNI + RFC 6125 identity; may tighten, never disable.
141
212
  * - `timeout` -- ms (default C.TIME.seconds(30)); `maxResponseBytes` -- default LIMITS.HTTP_MAX_RESPONSE_BYTES, tightenable downward only.
213
+ * - `blockPrivateAddresses` -- boolean; when true, an IP-literal host OR a hostname resolving to a private / loopback / link-local address is refused (`transport/blocked-address`), and a resolved address is pinned for the connection. For fetching an untrusted-certificate URL (AIA caIssuers); default false.
142
214
  * @example
143
215
  * var t = pki.transport.https({ tls: { anchors: [caPem] } });
144
216
  * var res = await t({ method: "GET", url: "https://ca.example/.well-known/est/cacerts" });
@@ -240,11 +312,23 @@ function httpsTransport(defaults) {
240
312
  return callerCsi(host, cert2);
241
313
  };
242
314
  }
315
+ // SSRF at resolution time: when a request opts in (the AIA fetch of an untrusted-cert URL), install a
316
+ // lookup that refuses -- and pins -- a private / loopback / link-local resolved address, so a hostname
317
+ // pointing at an internal service is blocked even though the literal-address check saw only a DNS name.
318
+ // A STRICT boolean true (a truthy string/object is treated as absent) keeps a malformed config fail-open-free.
319
+ var blockPrivate = (request.blockPrivateAddresses !== undefined ? request.blockPrivateAddresses : defaults.blockPrivateAddresses) === true;
320
+ if (blockPrivate) {
321
+ // Node does NOT invoke a custom `lookup` for an IP-LITERAL host (there is nothing to resolve), so the
322
+ // resolver alone would let a literal private / loopback / link-local destination through. Reject a blocked
323
+ // literal here, before installing the resolver -- so the option blocks a literal AND a resolved hostname.
324
+ if (_isBlockedIp(host)) throw E(C("blocked-address"), "refusing to connect to the private / loopback / link-local address literal " + host);
325
+ options.lookup = _guardedLookup;
326
+ }
243
327
 
244
328
  return { options: options, timeout: timeout, maxBytes: maxBytes, body: body };
245
329
  }
246
330
 
247
- return function transport(request) {
331
+ var _transportFn = function transport(request) {
248
332
  request = request || {};
249
333
  var prep;
250
334
  try { prep = _prepare(request); }
@@ -318,6 +402,11 @@ function httpsTransport(defaults) {
318
402
  } catch (e) { fail(C("transport-error"), "the request could not be initiated: " + ((e && e.message) || String(e)), e); }
319
403
  });
320
404
  };
405
+ // Advertise that this transport HONORS the blockPrivateAddresses request flag (it filters and pins a resolved
406
+ // address). A consumer of an UNTRUSTED URL (pki.path.build's AIA fetch) checks this marker before relying on the
407
+ // flag for SSRF protection -- an injected transport that does not set it is treated as unguarded (fail-closed).
408
+ _transportFn.blocksPrivateAddresses = true;
409
+ return _transportFn;
321
410
  }
322
411
 
323
- module.exports = { https: httpsTransport };
412
+ module.exports = { https: httpsTransport, isBlockedIp: _isBlockedIp, _makeGuardedLookup: _makeGuardedLookup, MAX_TIMEOUT: MAX_TIMEOUT };
package/lib/inspect.js CHANGED
@@ -381,6 +381,26 @@ var EXT_RENDERERS = {
381
381
  },
382
382
  cRLDistributionPoints: _renderCrlDp,
383
383
  freshestCRL: _renderCrlDp,
384
+ authorityInfoAccess: function (decoded, inner) {
385
+ // AccessDescription list: <accessMethod> - <accessLocation>. The method resolves to its name (caIssuers /
386
+ // ocsp); the accessLocation is a GeneralName (a URI in the common case). An unregistered method / an
387
+ // uncommon accessLocation tag falls back to the raw OID / bracketed tag rather than dropping the entry.
388
+ var LABEL = { caIssuers: "CA Issuers", ocsp: "OCSP" };
389
+ return (decoded || []).map(function (ad) {
390
+ var m = null;
391
+ try { m = oid.name(ad.accessMethod); } catch (_e) { /* allow:swallow-unverified display best-effort: an unregistered accessMethod OID falls back to the raw dotted OID below (inspection is best-effort, never a verdict) */ }
392
+ var loc = ad.accessLocation || {}, lv;
393
+ // The string choices (URI/DNS/email) are IA5String values already control-byte-rejected at decode by the
394
+ // CVE-2009-2408 guard, so they are safe to emit directly. The iPAddress choice is a RAW 4/16-byte Buffer --
395
+ // render it through _ipString (never raw), so a byte such as 0x0a cannot inject a line and spoof a field.
396
+ if (loc.tag === 6) lv = "URI:" + loc.value;
397
+ else if (loc.tag === 2) lv = "DNS:" + loc.value;
398
+ else if (loc.tag === 1) lv = "email:" + loc.value;
399
+ else if (loc.tag === 7) lv = "IP:" + _ipString(loc.value);
400
+ else lv = typeof loc.value === "string" ? loc.value : "[" + loc.tag + "]";
401
+ return inner + (LABEL[m] || m || ad.accessMethod) + " - " + lv;
402
+ }).join("\n");
403
+ },
384
404
  nameConstraints: function (decoded, inner) {
385
405
  var ncLines = [];
386
406
  ["permittedSubtrees:Permitted", "excludedSubtrees:Excluded"].forEach(function (pair) {
@@ -43,6 +43,9 @@ var crlVerify = require("./crl-verify");
43
43
  var guard = require("./guard-all");
44
44
  var constants = require("./constants");
45
45
  var validator = require("./validator-all");
46
+ var cms = require("./schema-cms");
47
+ var httpTransport = require("./http-transport");
48
+ var net = require("net");
46
49
  var compositeSig = require("./composite-sig");
47
50
  var edwardsPoint = require("./edwards-point");
48
51
 
@@ -78,6 +81,8 @@ var OID = {
78
81
  cRLDistributionPoints: oid.byName("cRLDistributionPoints"),
79
82
  subjectKeyIdentifier: oid.byName("subjectKeyIdentifier"),
80
83
  authorityKeyIdentifier: oid.byName("authorityKeyIdentifier"),
84
+ authorityInfoAccess: oid.byName("authorityInfoAccess"),
85
+ caIssuers: oid.byName("caIssuers"),
81
86
  };
82
87
 
83
88
  // The set of extension OIDs the validator PROCESSES -- an unrecognized critical
@@ -2117,6 +2122,14 @@ function identityKey(cert) {
2117
2122
  cert.subjectPublicKeyInfo.bytes.toString("hex");
2118
2123
  }
2119
2124
 
2125
+ // A byte-EXACT certificate identity (the signed tbs region + the signature), for deduping certificates fetched
2126
+ // over AIA before they enter the shared pool. Unlike identityKey (a subject/SAN/key tuple, deliberately broad for
2127
+ // loop pruning), this collapses ONLY true byte-duplicates -- so a mirror URL or a repeating CMS returning the same
2128
+ // issuer is added once, but a functionally-different cert sharing a subject+key (e.g. a key rollover) is kept.
2129
+ function certDerKey(cert) {
2130
+ return cert.tbsBytes.toString("base64") + "|" + (cert.signatureValue && cert.signatureValue.bytes ? cert.signatureValue.bytes.toString("base64") : "");
2131
+ }
2132
+
2120
2133
  function childAkiKeyId(cert) {
2121
2134
  var d = softDecode(cert, OID.authorityKeyIdentifier);
2122
2135
  return (d && d.value && d.value.keyIdentifier) ? d.value.keyIdentifier : null;
@@ -2147,9 +2160,140 @@ function scoreCandidate(cand, childAki, anchors, time) {
2147
2160
  return score;
2148
2161
  }
2149
2162
 
2163
+ // Sort scored issuer candidates by descending priority and push each non-looping one onto the DFS stack as a
2164
+ // child frame (the chain grows leaf-ward by PREPENDING the issuer). Shared by the static-pool expansion and the
2165
+ // AIA-fallback expansion so both enforce the SAME total-work counter (sec. 3.5 breadth cap), loop pruning
2166
+ // (the identity visited-set), and priority order. `scored` is [{cand, score}]; returns the number of candidates
2167
+ // ticked so the caller can advance its `considered` tally.
2168
+ function _pushCandidates(frame, scored, stack, counter) {
2169
+ scored.sort(function (a, b) { return a.score - b.score; }); // ascending -> push lowest first so the highest is popped first
2170
+ var n = 0;
2171
+ for (var ci = 0; ci < scored.length; ci++) {
2172
+ counter.tick(); // breadth / total-work cap -> throws path/build-limit
2173
+ n += 1;
2174
+ var cand = scored[ci].cand, candKey = identityKey(cand);
2175
+ if (frame.keys.has(candKey)) continue; // the (subject, SAN, key) tuple is already on this branch -> a loop, prune
2176
+ var childKeys = new Set(frame.keys);
2177
+ childKeys.add(candKey);
2178
+ stack.push({ chain: [cand].concat(frame.chain), hop: frame.hop + 1, keys: childKeys });
2179
+ }
2180
+ return n;
2181
+ }
2182
+
2183
+ // ---- AIA caIssuers network fetching (RFC 5280 sec. 4.2.2.1, opt-in over pki.transport) --------------------
2184
+ // Discover a MISSING intermediate by GETting the caIssuers accessLocation of the certificate being chained
2185
+ // past, feeding the fetched cert(s) into the SAME candidate search. SSRF / amplification bounded: https-only,
2186
+ // NO private / loopback / link-local destination -- an IP LITERAL is refused by the pre-check, and a DNS NAME
2187
+ // that RESOLVES to such an address is refused (and the address pinned) by the transport's blockPrivateAddresses
2188
+ // filter set on every AIA request, so an untrusted cert cannot drive an authenticated GET to an internal service
2189
+ // / cloud metadata by literal OR by hostname; a
2190
+ // total fetch budget (a SILENT cap -- stop fetching, never a throw that aborts a buildable path), a per-cert URL
2191
+ // cap over DISTINCT normalized URLs, a build-wide URL dedupe (fragment-free), a response size + certificate-count
2192
+ // cap, no redirect following. Every fetch fault is a SKIP (the DFS continues over the pool). A fetched cert is UNTRUSTED pool
2193
+ // material -- when validate is on (the default) it flows through validate() like any candidate and is NEVER a
2194
+ // trust anchor; in pure-builder mode (opts.validate:false) it is returned unvalidated, exactly like a static candidate.
2195
+
2196
+ // SSRF guard (literal pre-check): is a URL host a private / loopback / link-local / reserved IP LITERAL? An AIA
2197
+ // URL comes from an UNTRUSTED certificate, so a literal address into RFC 1918 / loopback / the 169.254 cloud-
2198
+ // metadata range must not be fetched (with enterprise TLS trust it would be an authenticated GET to an internal
2199
+ // service). This is a fast pre-filter that avoids even opening a socket for an obvious literal; a DNS NAME is
2200
+ // judged at RESOLUTION time by the transport's blockPrivateAddresses filter (set on the AIA request below), which
2201
+ // also pins the checked address. The IP classification is shared with the transport (one range set, no drift).
2202
+ function _isBlockedAiaHost(host) {
2203
+ if (host.charAt(0) === "[" && host.charAt(host.length - 1) === "]") host = host.slice(1, -1); // an IPv6 literal: URL.hostname keeps the [brackets]
2204
+ if (net.isIP(host) === 0) return false; // a DNS name -> not judged here; the transport's resolution-time filter blocks a private resolution
2205
+ return httpTransport.isBlockedIp(host); // an IP literal -> the shared private/loopback/link-local classifier
2206
+ }
2207
+
2208
+ // Parse an AIA response body as a single DER certificate (RFC 2585) OR a certs-only CMS bundle (RFC 5272).
2209
+ // The media type is only an ordering HINT (RFC 5280 sec. 4.2.2.1: "should not depend solely on the ... media
2210
+ // type") -- both structures are attempted, the wire decides. Returns raw certificate DER Buffers; throws
2211
+ // (caught upstream as a skip) if the body is neither.
2212
+ function _aiaParseBody(body, contentType, maxCerts) {
2213
+ var certsFirst = String(contentType || "").toLowerCase().indexOf("pkcs7") >= 0; // HINT: order the attempts only
2214
+ var order = certsFirst ? ["certs", "cert"] : ["cert", "certs"];
2215
+ for (var i = 0; i < order.length; i++) {
2216
+ try {
2217
+ if (order[i] === "cert") { x509.parse(body); return [Buffer.from(body)]; }
2218
+ return cms.parseCertsOnly(body, E, "path", maxCerts).certificates; // maxCerts bounds the parse of an untrusted bundle
2219
+ } catch (_e) { /* structure-sniff: try the other form */ }
2220
+ }
2221
+ throw E("path/aia-bad-body", "an AIA response body is neither a DER certificate nor a certs-only CMS");
2222
+ }
2223
+
2224
+ // Fetch ONE caIssuers URL over the injected/default transport; returns the parsed candidate certs, or throws
2225
+ // (the caller collapses any throw to a skip). Only a 200 with a non-empty, in-cap body is a cert source (M12).
2226
+ async function _aiaFetchOne(uri, aia) {
2227
+ // blockPrivateAddresses: the real transport refuses -- and pins -- a hostname that RESOLVES to a private /
2228
+ // loopback / link-local address (the literal pre-check only catches an IP literal). An injected test transport
2229
+ // ignores the flag; the DFS treats a blocked-address transport error as a silent skip like any fetch fault.
2230
+ var res = await aia.transport({ method: "GET", url: uri, tls: aia.tls, timeout: aia.timeout, maxResponseBytes: aia.maxResponseBytes, blockPrivateAddresses: true });
2231
+ res = res || {};
2232
+ if (res.status !== 200) throw E("path/aia-status", "an AIA fetch returned HTTP " + res.status + " (only 200 is a cert source; no redirect following)");
2233
+ var body = Buffer.isBuffer(res.body) ? res.body : Buffer.from(res.body == null ? "" : String(res.body), "latin1");
2234
+ if (body.length === 0) throw E("path/aia-empty", "an AIA fetch returned an empty body");
2235
+ // Belt for an INJECTED transport that ignores maxResponseBytes (the real transport streaming-aborts at the cap).
2236
+ if (body.length > aia.maxResponseBytes) throw E("path/aia-too-large", "an AIA response exceeds the " + aia.maxResponseBytes + "-byte cap");
2237
+ var headers = {};
2238
+ Object.keys(res.headers || {}).forEach(function (k) { headers[k.toLowerCase()] = res.headers[k]; });
2239
+ return _aiaParseBody(body, headers["content-type"], aia.maxCertsPerResponse);
2240
+ }
2241
+
2242
+ // Discover issuer candidates for `current` from its AIA caIssuers URLs. Returns parsed candidate certs (coerced
2243
+ // like any pool cert). Bounded + fail-closed; ticks aia.fetchCounter (the total-budget throw) and mutates
2244
+ // aia.fetchedUrls (build-wide dedupe). A cert with no AIA, a malformed AIA, or no fetchable https caIssuers
2245
+ // URI simply yields no candidates (RFC 5280 sec. 4.2.2.1 AIA is advisory / non-critical).
2246
+ async function _fetchAiaIssuers(current, aia) {
2247
+ var d = softDecode(current, OID.authorityInfoAccess); // no / malformed AIA -> no fetch
2248
+ if (!d || !d.value) return [];
2249
+ // Collect DISTINCT, normalized, fetchable https URLs, deduping BEFORE the per-cert cap so a flood of duplicate
2250
+ // (or fragment-variant) entries can never crowd out a usable later URL.
2251
+ var uris = [];
2252
+ var seenThisCert = new Set();
2253
+ for (var i = 0; i < d.value.length; i++) {
2254
+ var ad = d.value[i];
2255
+ if (ad.accessMethod !== OID.caIssuers) continue; // ONLY id-ad-caIssuers, never id-ad-ocsp
2256
+ if (!ad.accessLocation || ad.accessLocation.tag !== 6) continue; // ONLY a uniformResourceIdentifier [6]
2257
+ // Parse the URI ONCE: the NORMALIZED href is both the dedupe key AND the exact URL handed to the transport.
2258
+ var u;
2259
+ try { u = new URL(ad.accessLocation.value); }
2260
+ catch (_e) { continue; } // an unparseable URI -> skip (catch on its own line so the swallow gate can trace it)
2261
+ if (u.protocol !== "https:") continue; // https-only: no socket for http/ldap/ftp/file/mailto
2262
+ if (_isBlockedAiaHost(u.hostname)) continue; // SSRF: never fetch a private / loopback / link-local IP literal
2263
+ // A DNS-NAME host's private RESOLUTION can only be blocked by a transport that filters the resolved address;
2264
+ // with an UNGUARDED transport (an injected fn that does not vouch blocksPrivateAddresses) fail closed -- fetch
2265
+ // IP literals only (already validated above), never a hostname whose resolved address we cannot verify.
2266
+ if (net.isIP(u.hostname.replace(/^\[(.*)\]$/, "$1")) === 0 && !aia.transportGuardsAddresses) continue;
2267
+ u.hash = ""; // the fragment is never sent on the wire -> not part of the request/dedupe identity
2268
+ if (aia.fetchedUrls.has(u.href) || seenThisCert.has(u.href)) continue; // dedupe (build-wide OR same-cert) on the normalized URL
2269
+ if (seenThisCert.size >= aia.maxPerCert) break; // per-cert DISTINCT-url cap, checked BEFORE appending so maxAiaPerCert:0 collects nothing (no fetch at all)
2270
+ seenThisCert.add(u.href);
2271
+ uris.push(u.href);
2272
+ }
2273
+ var out = [];
2274
+ for (var k = 0; k < uris.length; k++) {
2275
+ if (aia.fetchedUrls.has(uris[k])) continue; // a same-cert normalization-equal duplicate
2276
+ if (aia.fetches >= aia.maxFetches) break; // total budget reached -> STOP fetching (a SILENT cap, never a throw that denies a buildable path)
2277
+ aia.fetchedUrls.add(uris[k]); // mark BEFORE the fetch -> fetched at most once, even on failure
2278
+ aia.fetches += 1;
2279
+ var certs;
2280
+ try { certs = await _aiaFetchOne(uris[k], aia); }
2281
+ catch (_e2) { continue; } // any fetch / parse fault is a skip; the DFS continues over the pool + other URLs
2282
+ // Each response is already capped to maxCertsPerResponse by parseCertsOnly (per RESPONSE), so an earlier
2283
+ // URL's bundle never consumes a later URL's allowance -- coerce every returned cert.
2284
+ for (var c = 0; c < certs.length; c++) {
2285
+ var parsed;
2286
+ try { parsed = coerceCert(certs[c]); }
2287
+ catch (_e3) { /* allow:swallow-unverified verified-unreachable: every cert here already passed the IDENTICAL x509.parse in _aiaParseBody (single-DER validates `body`; certs-only validates each via parseCertsOnly), so coerceCert re-parsing the same bytes cannot throw -- the guard stays as defence-in-depth */ continue; }
2288
+ out.push(parsed);
2289
+ }
2290
+ }
2291
+ return out;
2292
+ }
2293
+
2150
2294
  /**
2151
2295
  * @primitive pki.path.build
2152
- * @signature pki.path.build(leaf, opts) -> Promise<{ valid, path, trustAnchor, result, candidatesConsidered }>
2296
+ * @signature pki.path.build(leaf, opts) -> Promise<{ valid, path, trustAnchor, result, candidatesConsidered, aiaFetches }>
2153
2297
  * @since 0.3.7
2154
2298
  * @status experimental
2155
2299
  * @spec RFC 4158, RFC 5280
@@ -2176,8 +2320,16 @@ function scoreCandidate(cand, childAki, anchors, time) {
2176
2320
  * `validate` consumes (anchor-proximal first, leaf last, the anchor excluded). Fail-closed: bad
2177
2321
  * options throw `path/bad-input`; no chain to any anchor throws `path/no-path`; chains that
2178
2322
  * assemble but none validate return `{ valid:false }` with the best failing `validate` result;
2179
- * the search bound throws `path/build-limit`. AIA caIssuers fetching is out of scope -- `build` is
2180
- * offline (zero network); supply fetched issuers in `opts.candidates`.
2323
+ * the search bound throws `path/build-limit`. By default `build` is OFFLINE (zero network) -- supply
2324
+ * intermediates in `opts.candidates`. Set `opts.fetchAia: true` to opt in to fetching a MISSING intermediate
2325
+ * from a certificate's Authority Information Access `caIssuers` URL (RFC 5280 sec. 4.2.2.1) over
2326
+ * `pki.transport`: the fetch triggers only on a pool miss, every fetched certificate is UNTRUSTED pool material
2327
+ * that still flows through `validate` when validation is on (never a trust anchor), and the whole surface is
2328
+ * SSRF/amplification bounded -- https-only, a total fetch budget (a SILENT cap, never a throw that denies a
2329
+ * buildable path), a per-cert URL cap, a build-wide URL dedupe, a response size + certificate-count cap, and no
2330
+ * redirect following; every fetch fault is a silent skip. `aiaFetches` reports how many network GETs the build
2331
+ * performed (`0` when `fetchAia` is off). NOTE: with `opts.validate:false` (pure-builder mode) a fetched cert is
2332
+ * returned unvalidated, identical to a static candidate -- the "flows through validate" guarantee needs validation on.
2181
2333
  *
2182
2334
  * @opts candidates The untrusted candidate CA pool (array of DER/PEM/parsed certs; alias `intermediates`).
2183
2335
  * @opts trustAnchors The trust store (non-empty array of `{ name, publicKey, algorithm }` tuples or self-signed root certificates).
@@ -2185,6 +2337,13 @@ function scoreCandidate(cand, childAki, anchors, time) {
2185
2337
  * @opts maxDepth Chain-length depth cap (default `C.LIMITS.PATH_BUILD_MAX_DEPTH`).
2186
2338
  * @opts maxCandidatesConsidered Total-work cap on candidate expansions (default `C.LIMITS.PATH_BUILD_MAX_CANDIDATES`).
2187
2339
  * @opts validate `false` returns the ordered path without validating (pure-builder mode; default `true`).
2340
+ * @opts fetchAia `true` opts in to AIA caIssuers network fetching of a missing intermediate (default `false` -- fully offline). Off unless set; when set, a fetch runs only on a pool miss for a non-anchor-adjacent cert, and (with validation on) every fetched cert still flows through `validate`.
2341
+ * @opts transport The injectable transport seam (`fn(request) -> Promise<{ status, headers, body }>`); tests drive the fetch offline. With none, the default `pki.transport.https` is used, which fails closed unless `opts.tls` carries trust. SSRF: for a caIssuers URL with a DNS hostname, a custom transport is used ONLY if it declares `fn.blocksPrivateAddresses = true` -- vouching it refuses (and pins) a resolved private / loopback / link-local / special-use address, as the default transport does. Without that marker a DNS-name AIA URL is fail-closed (skipped) and only an IP-literal URL (validated up front) is fetched; set the marker on your transport when it filters resolved addresses.
2342
+ * @opts tls The TLS trust for the AIA HTTPS host (`{ anchors, useSystemStore, ... }`) -- DISTINCT from `opts.trustAnchors` (the PKI trust store the path validates against). The default transport refuses an unpinned server.
2343
+ * @opts maxAiaFetches Total AIA network GET budget across the whole build (default `C.LIMITS.PATH_AIA_MAX_FETCHES`); on reaching it the builder stops fetching (a silent cap), never a throw -- a fetch bound never denies a path the pool could build.
2344
+ * @opts maxAiaPerCert Cap on caIssuers URLs tried per certificate (default `C.LIMITS.PATH_AIA_MAX_PER_CERT`).
2345
+ * @opts aiaTimeout Per-fetch timeout in ms, forwarded to the transport.
2346
+ * @opts maxResponseBytes Per-fetch response size cap, forwarded to the transport (tightenable downward only).
2188
2347
  * @opts (validate options) Every `pki.path.validate` option (`requiredEku`, `revocationChecker`, `checkPurpose`, the initial policy inputs, ...) is forwarded unchanged.
2189
2348
  * @example
2190
2349
  * var result = await pki.path.build(pemString, {
@@ -2212,6 +2371,11 @@ async function build(leaf, opts) {
2212
2371
  var pool;
2213
2372
  try { pool = poolInput.map(coerceCert); }
2214
2373
  catch (e) { throw E("path/bad-input", "build: a candidate certificate did not parse", e); }
2374
+ // Byte-exact identities of every cert already in the shared pool, so an AIA-fetched duplicate (a mirror URL or a
2375
+ // repeating CMS returning the same issuer) is appended AT MOST ONCE -- it otherwise inflates the pool, charging
2376
+ // the candidate budget and the ceiling for each copy before the issuer is ever evaluated.
2377
+ var poolDerKeys = new Set();
2378
+ for (var pdk = 0; pdk < pool.length; pdk++) poolDerKeys.add(certDerKey(pool[pdk]));
2215
2379
 
2216
2380
  if (!Array.isArray(opts.trustAnchors) || opts.trustAnchors.length === 0) throw E("path/bad-input", "build: opts.trustAnchors must be a non-empty array of anchor tuples or root certificates");
2217
2381
  var anchors = opts.trustAnchors.map(toAnchor);
@@ -2235,10 +2399,55 @@ async function build(leaf, opts) {
2235
2399
  var maxConsidered = guard.limits.cap(opts.maxCandidatesConsidered, "build: opts.maxCandidatesConsidered", poolCeiling, { E: E, code: "path/bad-input", min: 1 });
2236
2400
  var doValidate = opts.validate !== false;
2237
2401
 
2402
+ // AIA caIssuers fetching is OFF unless opts.fetchAia === true -- the default build is byte-identical to
2403
+ // today's offline search (no transport constructed, no socket, no new path executed). When on, opts.transport
2404
+ // is the injectable seam (tests drive it offline); with no injected transport the default https transport
2405
+ // fails closed unless opts.tls carries an anchor / useSystemStore. opts.tls is the TLS trust for the AIA
2406
+ // HTTPS host -- DISTINCT from opts.trustAnchors (the PKI trust store the built path validates against).
2407
+ var aiaCtx = null;
2408
+ if (opts.fetchAia === true) {
2409
+ // Validate the AIA-specific options at CONFIG time (a caller typo is a path/bad-input throw, tier 1), not
2410
+ // lazily inside the fetch where a bad transport / timeout would be caught as an ordinary fetch fault and
2411
+ // silently degrade to path/no-path. A non-function transport can never be called; an injected transport gets
2412
+ // a validated timeout (it may not cap the value itself).
2413
+ if (opts.transport !== undefined && typeof opts.transport !== "function") {
2414
+ throw E("path/bad-input", "build: opts.transport must be a transport function (request) -> Promise<{ status, headers, body }>");
2415
+ }
2416
+ // Validate aiaTimeout against the SAME bounds the built-in transport enforces (integer, 1..MAX_TIMEOUT) at
2417
+ // CONFIG time, so a non-integer / over-ceiling value is a path/bad-input throw here rather than a transport
2418
+ // rejection swallowed later as a fetch fault. guard.limits.cap is the exact primitive the transport applies.
2419
+ if (opts.aiaTimeout !== undefined) {
2420
+ guard.limits.cap(opts.aiaTimeout, "build: opts.aiaTimeout", 1, { E: E, code: "path/bad-input", min: 1, max: httpTransport.MAX_TIMEOUT });
2421
+ }
2422
+ var aiaTransport = opts.transport || httpTransport.https({ E: E, errPrefix: "path" });
2423
+ aiaCtx = {
2424
+ transport: aiaTransport,
2425
+ // SSRF for a DNS-name AIA host needs resolution-time address filtering + pinning, which only a transport
2426
+ // that ADVERTISES the capability performs (the built-in, or an injected one that sets blocksPrivateAddresses
2427
+ // to vouch it filters). An injected fn(request) that ignores the flag is treated as UNGUARDED: a DNS-name AIA
2428
+ // URL is then fail-closed (skipped) and only an IP literal (validated by the literal pre-check) is fetched.
2429
+ transportGuardsAddresses: aiaTransport.blocksPrivateAddresses === true,
2430
+ tls: opts.tls || {},
2431
+ timeout: opts.aiaTimeout, // validated above; the transport applies its own default + cap
2432
+ // Default the AIA response cap BELOW the general HTTP ceiling (a caIssuers response is small); tighten
2433
+ // downward only, never above HTTP_MAX_RESPONSE_BYTES.
2434
+ maxResponseBytes: guard.limits.cap(opts.maxResponseBytes, "build: opts.maxResponseBytes", constants.LIMITS.PATH_AIA_MAX_RESPONSE_BYTES, { E: E, code: "path/bad-input", min: 1, max: constants.LIMITS.PATH_AIA_MAX_RESPONSE_BYTES }),
2435
+ maxPerCert: guard.limits.cap(opts.maxAiaPerCert, "build: opts.maxAiaPerCert", constants.LIMITS.PATH_AIA_MAX_PER_CERT, { E: E, code: "path/bad-input", min: 0 }),
2436
+ maxCertsPerResponse: constants.LIMITS.PATH_AIA_MAX_CERTS_PER_RESPONSE,
2437
+ // The total fetch budget is a SILENT cap (stop initiating fetches when reached), NOT a throw: a bound on
2438
+ // an ADVISORY fetch must never abort a build that the static pool could still complete. maxFetches may be
2439
+ // 0 (fetchAia:true but no network -- identical to offline).
2440
+ maxFetches: guard.limits.cap(opts.maxAiaFetches, "build: opts.maxAiaFetches", constants.LIMITS.PATH_AIA_MAX_FETCHES, { E: E, code: "path/bad-input", min: 0 }),
2441
+ fetchedUrls: new Set(),
2442
+ fetches: 0,
2443
+ };
2444
+ }
2445
+
2238
2446
  // The build-specific options are consumed here; every remaining validate
2239
2447
  // option is forwarded unchanged to the interleaved validate call. Object.keys
2240
2448
  // enumerates only own enumerable properties, so no prototype-pollution belt.
2241
- var BUILD_ONLY_OPT = { candidates: 1, intermediates: 1, trustAnchors: 1, maxDepth: 1, maxCandidatesConsidered: 1, validate: 1 };
2449
+ var BUILD_ONLY_OPT = { candidates: 1, intermediates: 1, trustAnchors: 1, maxDepth: 1, maxCandidatesConsidered: 1, validate: 1,
2450
+ fetchAia: 1, transport: 1, tls: 1, maxAiaFetches: 1, maxAiaPerCert: 1, aiaTimeout: 1, maxResponseBytes: 1 };
2242
2451
  var forwarded = {};
2243
2452
  Object.keys(opts).forEach(function (k) { if (!BUILD_ONLY_OPT[k]) forwarded[k] = opts[k]; });
2244
2453
  function validateOpts(anchor) {
@@ -2266,7 +2475,61 @@ async function build(leaf, opts) {
2266
2475
  // Caps are enforced before every expansion; a candidate whose (subject, SAN,
2267
2476
  // public key) tuple is already on the chain is a loop and is pruned.
2268
2477
  var stack = [{ chain: [leafCert], hop: 0, keys: new Set([identityKey(leafCert)]) }];
2269
- while (stack.length && !success) {
2478
+ // AIA fetch frames are DEFERRED into this queue and drained ONLY when `stack` is empty -- i.e. once the ENTIRE
2479
+ // local (static-pool) search has failed. This enforces RFC 4158 sec. 7.2 "local before remote" GLOBALLY across
2480
+ // the whole search, not per-branch: a build the static pool can complete never issues a network request, and a
2481
+ // higher-priority local dead-end can never fetch ahead of a still-unexplored lower-priority static sibling.
2482
+ // Drained DEEPEST-first, and among equal depth EARLIEST-deferred (= highest DFS priority) first: the actual
2483
+ // dead end (a missing deeper hop) is fetched before an ancestor whose issuer the pool ALREADY supplies (so a
2484
+ // scarce budget is never spent re-retrieving a locally-resolved hop), and a higher-scoring sibling's AIA is
2485
+ // tried before a lower-scoring one (a plain LIFO pop would reverse sibling priority and let a stale low-priority
2486
+ // branch's dead URL waste a tight budget before the preferred branch).
2487
+ var deferredAia = [];
2488
+ var deferSeq = 0; // monotonic deferral order (DFS priority): the drain tie-breaks same-depth frames by it
2489
+ while (!success) {
2490
+ if (!stack.length) {
2491
+ // The local search is drained. Begin / continue the fetch phase: pick a deferred frame with PENDING WORK --
2492
+ // one that has NOT yet fetched, OR whose poolMark trails the shared pool (a SIBLING branch fetched a cert
2493
+ // SINCE this frame last ran, which may complete it). A drained frame is NOT discarded: a later sibling's
2494
+ // fetch can add the very issuer an earlier, already-run branch needed, so every frame stays eligible for
2495
+ // pool growth until it has fetched AND seen the whole pool. Among eligible frames pick the deepest, then the
2496
+ // earliest-deferred (highest DFS priority). No eligible frame -> the whole search is exhausted.
2497
+ var _bi = -1;
2498
+ for (var _di = 0; _di < deferredAia.length; _di++) {
2499
+ var _f = deferredAia[_di];
2500
+ if (_f.fetched && _f.poolMark >= pool.length) continue; // nothing left to fetch or to re-expand against
2501
+ if (_bi === -1) { _bi = _di; continue; }
2502
+ var _bf = deferredAia[_bi];
2503
+ if (_f.hop > _bf.hop || (_f.hop === _bf.hop && _f.seq < _bf.seq)) _bi = _di;
2504
+ }
2505
+ if (_bi === -1) break; // no deferred frame can make further progress
2506
+ var fb = deferredAia[_bi]; // NOT removed: it stays eligible for certs a later sibling fetch adds
2507
+ var fbCur = fb.chain[0];
2508
+ if (!fb.fetched && aiaCtx && aiaCtx.fetches < aiaCtx.maxFetches) {
2509
+ var fetched = await _fetchAiaIssuers(fbCur, aiaCtx); // append newly-fetched issuer(s) to the SHARED pool
2510
+ for (var fj = 0; fj < fetched.length; fj++) {
2511
+ var fdk = certDerKey(fetched[fj]);
2512
+ if (poolDerKeys.has(fdk)) continue; // a byte-duplicate (mirror URL / repeating CMS) -> add once, never re-charge the budget/ceiling
2513
+ poolDerKeys.add(fdk);
2514
+ if (pool.length < poolCeiling) pool.push(fetched[fj]);
2515
+ }
2516
+ }
2517
+ fb.fetched = true; // the fetch attempt is done (or budget-skipped); the frame stays eligible for FUTURE pool growth
2518
+ // Re-expand fb against every pool cert added SINCE it last ran -- its OWN just-fetched certs AND any a SIBLING
2519
+ // branch fetched into the shared pool. The pool-index mark skips certs fb already scored (no redundant work);
2520
+ // advancing it means fb re-scores only certs added even later. This runs even when the budget is exhausted, so
2521
+ // a budget-capped frame still benefits from a sibling fetch.
2522
+ var fbAki = childAkiKeyId(fbCur);
2523
+ var fbScored = [];
2524
+ for (var pj = fb.poolMark; pj < pool.length; pj++) {
2525
+ if (nameMatchSoft(pool[pj].subject.rdns, fbCur.issuer.rdns)) {
2526
+ fbScored.push({ cand: pool[pj], score: scoreCandidate(pool[pj], fbAki, anchors, opts.time) });
2527
+ }
2528
+ }
2529
+ fb.poolMark = pool.length;
2530
+ considered += _pushCandidates(fb, fbScored, stack, counter);
2531
+ continue;
2532
+ }
2270
2533
  var frame = stack.pop();
2271
2534
  var current = frame.chain[0];
2272
2535
 
@@ -2290,28 +2553,32 @@ async function build(leaf, opts) {
2290
2553
  scored.push({ cand: pool[pi], score: scoreCandidate(pool[pi], childAki, anchors, opts.time) });
2291
2554
  }
2292
2555
  }
2293
- // Ascending sort -> push lowest priority first so the highest is on top.
2294
- scored.sort(function (a, bb) { return a.score - bb.score; });
2295
- for (var ci = 0; ci < scored.length; ci++) {
2296
- counter.tick(); // breadth / total-work cap -> throws path/build-limit
2297
- considered += 1;
2298
- var cand = scored[ci].cand;
2299
- var candKey = identityKey(cand);
2300
- if (frame.keys.has(candKey)) continue; // the (subject,SAN,key) tuple is already on this branch -> a loop, prune
2301
- var childKeys = new Set(frame.keys);
2302
- childKeys.add(candKey);
2303
- stack.push({ chain: [cand].concat(frame.chain), hop: frame.hop + 1, keys: childKeys });
2556
+ // Opt-in AIA caIssuers fetch as a FALLBACK: DEFER it (drained above only after the ENTIRE local search is
2557
+ // exhausted, RFC 4158 sec. 7.2 "local before remote") rather than pushing it onto the stack, so it can never
2558
+ // run ahead of an unexplored local sibling -- a build the static pool can complete never fetches. It is gated
2559
+ // on `success` being unset (guaranteed by the continue above), NOT on the issuer name failing to match an
2560
+ // anchor: an issuer DN that matches an anchor whose KEY did not validate this chain (a CA key rollover -- the
2561
+ // real same-DN, different-key intermediate is missing and reachable only via AIA) still needs the fetch.
2562
+ if (aiaCtx && frame.hop < maxDepth && aiaCtx.fetches < aiaCtx.maxFetches) {
2563
+ // poolMark: the shared-pool length now, so the drain re-expands this frame only against certs added LATER
2564
+ // (its own fetch + any sibling branch's fetch), never re-scoring the static pool it expands against below.
2565
+ // seq: the deferral order, so the drain can tie-break same-depth frames by DFS priority (earliest first).
2566
+ // fetched: false until this frame's own AIA fetch runs; it then stays eligible for later sibling pool growth.
2567
+ deferredAia.push({ chain: frame.chain, hop: frame.hop, keys: frame.keys, poolMark: pool.length, seq: deferSeq, fetched: false });
2568
+ deferSeq += 1;
2304
2569
  }
2570
+ considered += _pushCandidates(frame, scored, stack, counter);
2305
2571
  }
2306
2572
 
2573
+ var aiaFetches = aiaCtx ? aiaCtx.fetches : 0; // the count of AIA caIssuers network GETs this build performed (0 when opts.fetchAia is off)
2307
2574
  if (success) {
2308
- if (doValidate) return { valid: true, path: success.path, trustAnchor: success.trustAnchor, result: success.result, candidatesConsidered: considered };
2309
- return { path: success.path, trustAnchor: success.trustAnchor, candidatesConsidered: considered };
2575
+ if (doValidate) return { valid: true, path: success.path, trustAnchor: success.trustAnchor, result: success.result, candidatesConsidered: considered, aiaFetches: aiaFetches };
2576
+ return { path: success.path, trustAnchor: success.trustAnchor, candidatesConsidered: considered, aiaFetches: aiaFetches };
2310
2577
  }
2311
2578
  if (anyChainAssembled) {
2312
2579
  // Chains reached an anchor but none validated -> the soft verdict carrying
2313
2580
  // the best failing validate result (parity with validate; never a throw).
2314
- return { valid: false, path: bestFail.path, trustAnchor: bestFail.trustAnchor, result: bestFail.result, candidatesConsidered: considered };
2581
+ return { valid: false, path: bestFail.path, trustAnchor: bestFail.trustAnchor, result: bestFail.result, candidatesConsidered: considered, aiaFetches: aiaFetches };
2315
2582
  }
2316
2583
  // No chain to any configured anchor could even be assembled -- a permanent
2317
2584
  // structural verdict (name/key chaining dead-ended before the trust store).
package/lib/schema-cms.js CHANGED
@@ -49,6 +49,8 @@ var schema = require("./schema-engine");
49
49
  var pkix = require("./schema-pkix");
50
50
  var oid = require("./oid");
51
51
  var frameworkError = require("./framework-error");
52
+ var schemaX509 = require("./schema-x509");
53
+ var schemaCrl = require("./schema-crl");
52
54
 
53
55
  var CmsError = frameworkError.CmsError;
54
56
  var PemError = frameworkError.PemError;
@@ -1225,11 +1227,54 @@ function assertAttachedCiphertext(eci, E, code, label) {
1225
1227
  return eci;
1226
1228
  }
1227
1229
 
1230
+ // The "certs-only" Simple PKI Response (RFC 5272 sec. 4.1): a degenerate SignedData carrying certificates (and
1231
+ // optionally CRLs) but NO signed content -- id-data with no eContent and EMPTY signerInfos. It is how RFC 7030
1232
+ // EST returns a CA chain (/cacerts) and how an RFC 5280 sec. 4.2.2.1 AIA caIssuers URL may serve issuers
1233
+ // (application/pkcs7-mime). Returns { certificates: [raw DER], crls: [raw DER] } -- the RAW bytes only; the
1234
+ // caller validates each cert/CRL for its own purpose (EST re-parses; path building runs each through validate).
1235
+ // Shared so the EST client and the path builder decode an identical shape; the caller passes its typed-error
1236
+ // factory `E(code, msg, cause)` and domain `prefix` so a fault surfaces the caller's own `prefix/*` code.
1237
+ // `maxCerts` (optional) caps how many certificates / CRLs are validated + returned, BEFORE the per-element
1238
+ // X.509 / CRL parse -- so an untrusted source (an AIA caIssuers URL) cannot force tens of thousands of parses
1239
+ // with one in-cap-bytes bundle; the EST client passes no cap (a /cacerts chain is small and fully returned).
1240
+ function parseCertsOnly(der, E, prefix, maxCerts) {
1241
+ var r;
1242
+ try { r = parse(der); }
1243
+ catch (e) { throw E(prefix + "/bad-response", "a certs-only response did not decode as CMS: " + ((e && e.message) || String(e)), e); }
1244
+ if (r.contentTypeName !== "signedData") throw E(prefix + "/not-certs-only", "a certs-only response must be a CMS SignedData (RFC 5272 sec. 4.1)");
1245
+ if (r.encapContentInfo.eContentType !== OID_DATA || r.encapContentInfo.eContent !== null) {
1246
+ throw E(prefix + "/not-certs-only", "a certs-only Simple PKI Response must carry id-data with no eContent (RFC 5272 sec. 4.1)");
1247
+ }
1248
+ if (r.signerInfos.length !== 0) throw E(prefix + "/not-certs-only", "a certs-only Simple PKI Response must have empty signerInfos (RFC 5272 sec. 4.1)");
1249
+ if (!r.certificates || r.certificates.length === 0) throw E(prefix + "/no-certificates", "a certs-only response must contain at least one certificate (RFC 5272 sec. 4.1)");
1250
+ // Cap BEFORE the per-certificate X.509 parse: bound parse work by count, not only by the decoded byte size.
1251
+ var certs = (maxCerts != null && r.certificates.length > maxCerts) ? r.certificates.slice(0, maxCerts) : r.certificates;
1252
+ for (var i = 0; i < certs.length; i++) {
1253
+ // A universal-SEQUENCE CertificateChoice must be a well-formed X.509 Certificate, not merely any SEQUENCE;
1254
+ // a tagged CertificateChoices alternative (attribute cert / other) is not a plain certificate.
1255
+ if (certs[i].tagClass !== "universal") throw E(prefix + "/bad-certificate-choice", "a certs-only response exchanges plain X.509 certificates; a tagged CertificateChoices alternative is not permitted (RFC 5272)");
1256
+ try { schemaX509.parse(certs[i].bytes); }
1257
+ catch (e) { throw E(prefix + "/bad-certificate", "a certs-only response carried a non-certificate in its certificates field (RFC 5272 sec. 4.1)", e); }
1258
+ }
1259
+ var allCrls = r.crls || [];
1260
+ var crls = (maxCerts != null && allCrls.length > maxCerts) ? allCrls.slice(0, maxCerts) : allCrls;
1261
+ for (var j = 0; j < crls.length; j++) {
1262
+ if (crls[j].tagClass !== "universal") throw E(prefix + "/bad-crl", "a certs-only response CRL must be a plain X.509 CertificateList, not a tagged otherRevInfo alternative (RFC 5652 sec. 10.2.1)");
1263
+ try { schemaCrl.parse(crls[j].bytes); }
1264
+ catch (e) { throw E(prefix + "/bad-crl", "a certs-only response carried a non-CRL in its crls field", e); }
1265
+ }
1266
+ return {
1267
+ certificates: certs.map(function (c) { return c.bytes; }),
1268
+ crls: crls.map(function (c) { return c.bytes; }),
1269
+ };
1270
+ }
1271
+
1228
1272
  module.exports = {
1229
1273
  parse: parse,
1230
1274
  pemDecode: pemDecode,
1231
1275
  pemEncode: pemEncode,
1232
1276
  matches: matches,
1277
+ parseCertsOnly: parseCertsOnly,
1233
1278
  walkEnvelopedData: walkEnvelopedData,
1234
1279
  walkSignedData: walkSignedData,
1235
1280
  walkEncryptedData: walkEncryptedData,
@@ -809,6 +809,30 @@ function certExtensionDecoders(ns) {
809
809
  return schema.walk(generalNames(ns, { decodeValue: true, code: C }), n, ns).result;
810
810
  }
811
811
 
812
+ // authorityInfoAccess ::= AuthorityInfoAccessSyntax ::= SEQUENCE SIZE(1..MAX) OF AccessDescription
813
+ // (RFC 5280 sec. 4.2.2.1); AccessDescription ::= SEQUENCE { accessMethod OBJECT IDENTIFIER, accessLocation
814
+ // GeneralName }. Surfaces [{ accessMethod: <dotted OID>, accessLocation: { tag, value } }] in wire order --
815
+ // accessLocation is the shared generalName leaf (its context tag number + decoded value, so a control-byte
816
+ // URI is rejected by the CVE-2009-2408 guard). BOTH accessMethods surface (id-ad-caIssuers AND id-ad-ocsp):
817
+ // a consumer filters by accessMethod (caIssuers for issuer fetching, ocsp for responder discovery). An empty
818
+ // SEQUENCE violates SIZE(1..MAX) and is malformed. A composed decoder + registry row, not a hand-roll.
819
+ function authorityInfoAccess(buf) {
820
+ var C = ns.prefix + "/bad-extension-value";
821
+ var descs = seqChildren(buf, C, "AuthorityInfoAccessSyntax");
822
+ if (descs.length < 1) throw ns.E(C, "AuthorityInfoAccessSyntax must contain at least one AccessDescription (RFC 5280 sec. 4.2.2.1, SIZE(1..MAX))");
823
+ var GN = generalName(ns, { decodeValue: true, code: C });
824
+ return descs.map(function (d) {
825
+ if (d.tagClass !== "universal" || d.tagNumber !== _T.SEQUENCE || !d.children || d.children.length !== 2) {
826
+ throw ns.E(C, "AccessDescription must be a SEQUENCE { accessMethod, accessLocation } (RFC 5280 sec. 4.2.2.1)");
827
+ }
828
+ var method;
829
+ try { method = asn1.read.oid(d.children[0]); }
830
+ catch (e) { throw ns.E(C, "AccessDescription accessMethod must be an OBJECT IDENTIFIER", e); }
831
+ var loc = schema.walk(GN, d.children[1], ns); // a decode leaf returns its value directly (no .result wrapper)
832
+ return { accessMethod: method, accessLocation: { tag: loc.tagNumber, value: loc.value } };
833
+ });
834
+ }
835
+
812
836
  // extKeyUsage ::= SEQUENCE SIZE(1..MAX) OF KeyPurposeId (OID)
813
837
  function extKeyUsage(buf) {
814
838
  var C = ns.prefix + "/bad-extension-value";
@@ -1105,6 +1129,7 @@ function certExtensionDecoders(ns) {
1105
1129
  byOid[O("precertificatePoison")] = precertPoison;
1106
1130
  byOid[O("cRLDistributionPoints")] = crlDistributionPoints;
1107
1131
  byOid[O("freshestCRL")] = crlDistributionPoints;
1132
+ byOid[O("authorityInfoAccess")] = authorityInfoAccess;
1108
1133
  byOid[O("msCertificateTemplate")] = msCertificateTemplate;
1109
1134
  byOid[O("msEnrollCertType")] = msEnrollCertType;
1110
1135
  byOid[O("msCaVersion")] = msCaVersion;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/pki",
3
- "version": "0.3.24",
3
+ "version": "0.3.25",
4
4
  "description": "Pure-JavaScript PKI toolkit that owns its stack — X.509, ASN.1/DER, CMS, PQC-first.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
package/sbom.cdx.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.5",
5
- "serialNumber": "urn:uuid:342eb0a5-bbfa-46f5-9b51-912a21d97f53",
5
+ "serialNumber": "urn:uuid:5e29cae0-edec-47f9-be9b-305be03dd73c",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-07-26T21:06:05.598Z",
8
+ "timestamp": "2026-07-27T02:30:05.287Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/pki@0.3.24",
22
+ "bom-ref": "@blamejs/pki@0.3.25",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.3.24",
25
+ "version": "0.3.25",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "Pure-JavaScript PKI toolkit that owns its stack — X.509, ASN.1/DER, CMS, PQC-first.",
29
- "purl": "pkg:npm/%40blamejs/pki@0.3.24",
29
+ "purl": "pkg:npm/%40blamejs/pki@0.3.25",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/pki@0.3.24",
57
+ "ref": "@blamejs/pki@0.3.25",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]