@blamejs/pki 0.3.24 → 0.3.26

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.
@@ -40,9 +40,13 @@ var crl = require("./schema-crl");
40
40
  var ocsp = require("./schema-ocsp");
41
41
  var ocspVerify = require("./ocsp-verify");
42
42
  var crlVerify = require("./crl-verify");
43
+ var cmpVerify = require("./cmp-verify");
43
44
  var guard = require("./guard-all");
44
45
  var constants = require("./constants");
45
46
  var validator = require("./validator-all");
47
+ var cms = require("./schema-cms");
48
+ var httpTransport = require("./http-transport");
49
+ var net = require("net");
46
50
  var compositeSig = require("./composite-sig");
47
51
  var edwardsPoint = require("./edwards-point");
48
52
 
@@ -78,6 +82,8 @@ var OID = {
78
82
  cRLDistributionPoints: oid.byName("cRLDistributionPoints"),
79
83
  subjectKeyIdentifier: oid.byName("subjectKeyIdentifier"),
80
84
  authorityKeyIdentifier: oid.byName("authorityKeyIdentifier"),
85
+ authorityInfoAccess: oid.byName("authorityInfoAccess"),
86
+ caIssuers: oid.byName("caIssuers"),
81
87
  };
82
88
 
83
89
  // The set of extension OIDs the validator PROCESSES -- an unrecognized critical
@@ -1875,6 +1881,13 @@ var ocspCore = ocspVerify.makeOcspVerify({
1875
1881
  dnEqual: dnEqual,
1876
1882
  });
1877
1883
 
1884
+ // Inject this validator's signature engine + full path build/validate into the internal cmp-verify seam,
1885
+ // so pki.cmp.verify routes incoming CMP signature protection through the SAME engine (never a second, weaker
1886
+ // CMP verifier; never build's self-check, which skips the EdDSA low-order-point gate) and chains an
1887
+ // out-of-path signer certificate through the FULL RFC 5280 sec. 6.1 path validation -- without exposing any
1888
+ // of it on the public pki.path surface (index.js exports the whole path-validate module).
1889
+ cmpVerify.setEngine({ verifyWithSpki: _verifyWithSpki, build: build, validate: validate });
1890
+
1878
1891
  /**
1879
1892
  * @primitive pki.path.ocspChecker
1880
1893
  * @signature pki.path.ocspChecker(responses) -> RevocationChecker
@@ -2117,6 +2130,14 @@ function identityKey(cert) {
2117
2130
  cert.subjectPublicKeyInfo.bytes.toString("hex");
2118
2131
  }
2119
2132
 
2133
+ // A byte-EXACT certificate identity (the signed tbs region + the signature), for deduping certificates fetched
2134
+ // over AIA before they enter the shared pool. Unlike identityKey (a subject/SAN/key tuple, deliberately broad for
2135
+ // loop pruning), this collapses ONLY true byte-duplicates -- so a mirror URL or a repeating CMS returning the same
2136
+ // issuer is added once, but a functionally-different cert sharing a subject+key (e.g. a key rollover) is kept.
2137
+ function certDerKey(cert) {
2138
+ return cert.tbsBytes.toString("base64") + "|" + (cert.signatureValue && cert.signatureValue.bytes ? cert.signatureValue.bytes.toString("base64") : "");
2139
+ }
2140
+
2120
2141
  function childAkiKeyId(cert) {
2121
2142
  var d = softDecode(cert, OID.authorityKeyIdentifier);
2122
2143
  return (d && d.value && d.value.keyIdentifier) ? d.value.keyIdentifier : null;
@@ -2147,9 +2168,140 @@ function scoreCandidate(cand, childAki, anchors, time) {
2147
2168
  return score;
2148
2169
  }
2149
2170
 
2171
+ // Sort scored issuer candidates by descending priority and push each non-looping one onto the DFS stack as a
2172
+ // child frame (the chain grows leaf-ward by PREPENDING the issuer). Shared by the static-pool expansion and the
2173
+ // AIA-fallback expansion so both enforce the SAME total-work counter (sec. 3.5 breadth cap), loop pruning
2174
+ // (the identity visited-set), and priority order. `scored` is [{cand, score}]; returns the number of candidates
2175
+ // ticked so the caller can advance its `considered` tally.
2176
+ function _pushCandidates(frame, scored, stack, counter) {
2177
+ scored.sort(function (a, b) { return a.score - b.score; }); // ascending -> push lowest first so the highest is popped first
2178
+ var n = 0;
2179
+ for (var ci = 0; ci < scored.length; ci++) {
2180
+ counter.tick(); // breadth / total-work cap -> throws path/build-limit
2181
+ n += 1;
2182
+ var cand = scored[ci].cand, candKey = identityKey(cand);
2183
+ if (frame.keys.has(candKey)) continue; // the (subject, SAN, key) tuple is already on this branch -> a loop, prune
2184
+ var childKeys = new Set(frame.keys);
2185
+ childKeys.add(candKey);
2186
+ stack.push({ chain: [cand].concat(frame.chain), hop: frame.hop + 1, keys: childKeys });
2187
+ }
2188
+ return n;
2189
+ }
2190
+
2191
+ // ---- AIA caIssuers network fetching (RFC 5280 sec. 4.2.2.1, opt-in over pki.transport) --------------------
2192
+ // Discover a MISSING intermediate by GETting the caIssuers accessLocation of the certificate being chained
2193
+ // past, feeding the fetched cert(s) into the SAME candidate search. SSRF / amplification bounded: https-only,
2194
+ // NO private / loopback / link-local destination -- an IP LITERAL is refused by the pre-check, and a DNS NAME
2195
+ // that RESOLVES to such an address is refused (and the address pinned) by the transport's blockPrivateAddresses
2196
+ // filter set on every AIA request, so an untrusted cert cannot drive an authenticated GET to an internal service
2197
+ // / cloud metadata by literal OR by hostname; a
2198
+ // total fetch budget (a SILENT cap -- stop fetching, never a throw that aborts a buildable path), a per-cert URL
2199
+ // cap over DISTINCT normalized URLs, a build-wide URL dedupe (fragment-free), a response size + certificate-count
2200
+ // cap, no redirect following. Every fetch fault is a SKIP (the DFS continues over the pool). A fetched cert is UNTRUSTED pool
2201
+ // material -- when validate is on (the default) it flows through validate() like any candidate and is NEVER a
2202
+ // trust anchor; in pure-builder mode (opts.validate:false) it is returned unvalidated, exactly like a static candidate.
2203
+
2204
+ // SSRF guard (literal pre-check): is a URL host a private / loopback / link-local / reserved IP LITERAL? An AIA
2205
+ // URL comes from an UNTRUSTED certificate, so a literal address into RFC 1918 / loopback / the 169.254 cloud-
2206
+ // metadata range must not be fetched (with enterprise TLS trust it would be an authenticated GET to an internal
2207
+ // service). This is a fast pre-filter that avoids even opening a socket for an obvious literal; a DNS NAME is
2208
+ // judged at RESOLUTION time by the transport's blockPrivateAddresses filter (set on the AIA request below), which
2209
+ // also pins the checked address. The IP classification is shared with the transport (one range set, no drift).
2210
+ function _isBlockedAiaHost(host) {
2211
+ if (host.charAt(0) === "[" && host.charAt(host.length - 1) === "]") host = host.slice(1, -1); // an IPv6 literal: URL.hostname keeps the [brackets]
2212
+ if (net.isIP(host) === 0) return false; // a DNS name -> not judged here; the transport's resolution-time filter blocks a private resolution
2213
+ return httpTransport.isBlockedIp(host); // an IP literal -> the shared private/loopback/link-local classifier
2214
+ }
2215
+
2216
+ // Parse an AIA response body as a single DER certificate (RFC 2585) OR a certs-only CMS bundle (RFC 5272).
2217
+ // The media type is only an ordering HINT (RFC 5280 sec. 4.2.2.1: "should not depend solely on the ... media
2218
+ // type") -- both structures are attempted, the wire decides. Returns raw certificate DER Buffers; throws
2219
+ // (caught upstream as a skip) if the body is neither.
2220
+ function _aiaParseBody(body, contentType, maxCerts) {
2221
+ var certsFirst = String(contentType || "").toLowerCase().indexOf("pkcs7") >= 0; // HINT: order the attempts only
2222
+ var order = certsFirst ? ["certs", "cert"] : ["cert", "certs"];
2223
+ for (var i = 0; i < order.length; i++) {
2224
+ try {
2225
+ if (order[i] === "cert") { x509.parse(body); return [Buffer.from(body)]; }
2226
+ return cms.parseCertsOnly(body, E, "path", maxCerts).certificates; // maxCerts bounds the parse of an untrusted bundle
2227
+ } catch (_e) { /* structure-sniff: try the other form */ }
2228
+ }
2229
+ throw E("path/aia-bad-body", "an AIA response body is neither a DER certificate nor a certs-only CMS");
2230
+ }
2231
+
2232
+ // Fetch ONE caIssuers URL over the injected/default transport; returns the parsed candidate certs, or throws
2233
+ // (the caller collapses any throw to a skip). Only a 200 with a non-empty, in-cap body is a cert source (M12).
2234
+ async function _aiaFetchOne(uri, aia) {
2235
+ // blockPrivateAddresses: the real transport refuses -- and pins -- a hostname that RESOLVES to a private /
2236
+ // loopback / link-local address (the literal pre-check only catches an IP literal). An injected test transport
2237
+ // ignores the flag; the DFS treats a blocked-address transport error as a silent skip like any fetch fault.
2238
+ var res = await aia.transport({ method: "GET", url: uri, tls: aia.tls, timeout: aia.timeout, maxResponseBytes: aia.maxResponseBytes, blockPrivateAddresses: true });
2239
+ res = res || {};
2240
+ 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)");
2241
+ var body = Buffer.isBuffer(res.body) ? res.body : Buffer.from(res.body == null ? "" : String(res.body), "latin1");
2242
+ if (body.length === 0) throw E("path/aia-empty", "an AIA fetch returned an empty body");
2243
+ // Belt for an INJECTED transport that ignores maxResponseBytes (the real transport streaming-aborts at the cap).
2244
+ if (body.length > aia.maxResponseBytes) throw E("path/aia-too-large", "an AIA response exceeds the " + aia.maxResponseBytes + "-byte cap");
2245
+ var headers = {};
2246
+ Object.keys(res.headers || {}).forEach(function (k) { headers[k.toLowerCase()] = res.headers[k]; });
2247
+ return _aiaParseBody(body, headers["content-type"], aia.maxCertsPerResponse);
2248
+ }
2249
+
2250
+ // Discover issuer candidates for `current` from its AIA caIssuers URLs. Returns parsed candidate certs (coerced
2251
+ // like any pool cert). Bounded + fail-closed; ticks aia.fetchCounter (the total-budget throw) and mutates
2252
+ // aia.fetchedUrls (build-wide dedupe). A cert with no AIA, a malformed AIA, or no fetchable https caIssuers
2253
+ // URI simply yields no candidates (RFC 5280 sec. 4.2.2.1 AIA is advisory / non-critical).
2254
+ async function _fetchAiaIssuers(current, aia) {
2255
+ var d = softDecode(current, OID.authorityInfoAccess); // no / malformed AIA -> no fetch
2256
+ if (!d || !d.value) return [];
2257
+ // Collect DISTINCT, normalized, fetchable https URLs, deduping BEFORE the per-cert cap so a flood of duplicate
2258
+ // (or fragment-variant) entries can never crowd out a usable later URL.
2259
+ var uris = [];
2260
+ var seenThisCert = new Set();
2261
+ for (var i = 0; i < d.value.length; i++) {
2262
+ var ad = d.value[i];
2263
+ if (ad.accessMethod !== OID.caIssuers) continue; // ONLY id-ad-caIssuers, never id-ad-ocsp
2264
+ if (!ad.accessLocation || ad.accessLocation.tag !== 6) continue; // ONLY a uniformResourceIdentifier [6]
2265
+ // Parse the URI ONCE: the NORMALIZED href is both the dedupe key AND the exact URL handed to the transport.
2266
+ var u;
2267
+ try { u = new URL(ad.accessLocation.value); }
2268
+ catch (_e) { continue; } // an unparseable URI -> skip (catch on its own line so the swallow gate can trace it)
2269
+ if (u.protocol !== "https:") continue; // https-only: no socket for http/ldap/ftp/file/mailto
2270
+ if (_isBlockedAiaHost(u.hostname)) continue; // SSRF: never fetch a private / loopback / link-local IP literal
2271
+ // A DNS-NAME host's private RESOLUTION can only be blocked by a transport that filters the resolved address;
2272
+ // with an UNGUARDED transport (an injected fn that does not vouch blocksPrivateAddresses) fail closed -- fetch
2273
+ // IP literals only (already validated above), never a hostname whose resolved address we cannot verify.
2274
+ if (net.isIP(u.hostname.replace(/^\[(.*)\]$/, "$1")) === 0 && !aia.transportGuardsAddresses) continue;
2275
+ u.hash = ""; // the fragment is never sent on the wire -> not part of the request/dedupe identity
2276
+ if (aia.fetchedUrls.has(u.href) || seenThisCert.has(u.href)) continue; // dedupe (build-wide OR same-cert) on the normalized URL
2277
+ if (seenThisCert.size >= aia.maxPerCert) break; // per-cert DISTINCT-url cap, checked BEFORE appending so maxAiaPerCert:0 collects nothing (no fetch at all)
2278
+ seenThisCert.add(u.href);
2279
+ uris.push(u.href);
2280
+ }
2281
+ var out = [];
2282
+ for (var k = 0; k < uris.length; k++) {
2283
+ if (aia.fetchedUrls.has(uris[k])) continue; // a same-cert normalization-equal duplicate
2284
+ if (aia.fetches >= aia.maxFetches) break; // total budget reached -> STOP fetching (a SILENT cap, never a throw that denies a buildable path)
2285
+ aia.fetchedUrls.add(uris[k]); // mark BEFORE the fetch -> fetched at most once, even on failure
2286
+ aia.fetches += 1;
2287
+ var certs;
2288
+ try { certs = await _aiaFetchOne(uris[k], aia); }
2289
+ catch (_e2) { continue; } // any fetch / parse fault is a skip; the DFS continues over the pool + other URLs
2290
+ // Each response is already capped to maxCertsPerResponse by parseCertsOnly (per RESPONSE), so an earlier
2291
+ // URL's bundle never consumes a later URL's allowance -- coerce every returned cert.
2292
+ for (var c = 0; c < certs.length; c++) {
2293
+ var parsed;
2294
+ try { parsed = coerceCert(certs[c]); }
2295
+ 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; }
2296
+ out.push(parsed);
2297
+ }
2298
+ }
2299
+ return out;
2300
+ }
2301
+
2150
2302
  /**
2151
2303
  * @primitive pki.path.build
2152
- * @signature pki.path.build(leaf, opts) -> Promise<{ valid, path, trustAnchor, result, candidatesConsidered }>
2304
+ * @signature pki.path.build(leaf, opts) -> Promise<{ valid, path, trustAnchor, result, candidatesConsidered, aiaFetches }>
2153
2305
  * @since 0.3.7
2154
2306
  * @status experimental
2155
2307
  * @spec RFC 4158, RFC 5280
@@ -2176,8 +2328,16 @@ function scoreCandidate(cand, childAki, anchors, time) {
2176
2328
  * `validate` consumes (anchor-proximal first, leaf last, the anchor excluded). Fail-closed: bad
2177
2329
  * options throw `path/bad-input`; no chain to any anchor throws `path/no-path`; chains that
2178
2330
  * 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`.
2331
+ * the search bound throws `path/build-limit`. By default `build` is OFFLINE (zero network) -- supply
2332
+ * intermediates in `opts.candidates`. Set `opts.fetchAia: true` to opt in to fetching a MISSING intermediate
2333
+ * from a certificate's Authority Information Access `caIssuers` URL (RFC 5280 sec. 4.2.2.1) over
2334
+ * `pki.transport`: the fetch triggers only on a pool miss, every fetched certificate is UNTRUSTED pool material
2335
+ * that still flows through `validate` when validation is on (never a trust anchor), and the whole surface is
2336
+ * SSRF/amplification bounded -- https-only, a total fetch budget (a SILENT cap, never a throw that denies a
2337
+ * buildable path), a per-cert URL cap, a build-wide URL dedupe, a response size + certificate-count cap, and no
2338
+ * redirect following; every fetch fault is a silent skip. `aiaFetches` reports how many network GETs the build
2339
+ * performed (`0` when `fetchAia` is off). NOTE: with `opts.validate:false` (pure-builder mode) a fetched cert is
2340
+ * returned unvalidated, identical to a static candidate -- the "flows through validate" guarantee needs validation on.
2181
2341
  *
2182
2342
  * @opts candidates The untrusted candidate CA pool (array of DER/PEM/parsed certs; alias `intermediates`).
2183
2343
  * @opts trustAnchors The trust store (non-empty array of `{ name, publicKey, algorithm }` tuples or self-signed root certificates).
@@ -2185,6 +2345,13 @@ function scoreCandidate(cand, childAki, anchors, time) {
2185
2345
  * @opts maxDepth Chain-length depth cap (default `C.LIMITS.PATH_BUILD_MAX_DEPTH`).
2186
2346
  * @opts maxCandidatesConsidered Total-work cap on candidate expansions (default `C.LIMITS.PATH_BUILD_MAX_CANDIDATES`).
2187
2347
  * @opts validate `false` returns the ordered path without validating (pure-builder mode; default `true`).
2348
+ * @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`.
2349
+ * @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.
2350
+ * @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.
2351
+ * @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.
2352
+ * @opts maxAiaPerCert Cap on caIssuers URLs tried per certificate (default `C.LIMITS.PATH_AIA_MAX_PER_CERT`).
2353
+ * @opts aiaTimeout Per-fetch timeout in ms, forwarded to the transport.
2354
+ * @opts maxResponseBytes Per-fetch response size cap, forwarded to the transport (tightenable downward only).
2188
2355
  * @opts (validate options) Every `pki.path.validate` option (`requiredEku`, `revocationChecker`, `checkPurpose`, the initial policy inputs, ...) is forwarded unchanged.
2189
2356
  * @example
2190
2357
  * var result = await pki.path.build(pemString, {
@@ -2212,6 +2379,11 @@ async function build(leaf, opts) {
2212
2379
  var pool;
2213
2380
  try { pool = poolInput.map(coerceCert); }
2214
2381
  catch (e) { throw E("path/bad-input", "build: a candidate certificate did not parse", e); }
2382
+ // Byte-exact identities of every cert already in the shared pool, so an AIA-fetched duplicate (a mirror URL or a
2383
+ // repeating CMS returning the same issuer) is appended AT MOST ONCE -- it otherwise inflates the pool, charging
2384
+ // the candidate budget and the ceiling for each copy before the issuer is ever evaluated.
2385
+ var poolDerKeys = new Set();
2386
+ for (var pdk = 0; pdk < pool.length; pdk++) poolDerKeys.add(certDerKey(pool[pdk]));
2215
2387
 
2216
2388
  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
2389
  var anchors = opts.trustAnchors.map(toAnchor);
@@ -2235,10 +2407,55 @@ async function build(leaf, opts) {
2235
2407
  var maxConsidered = guard.limits.cap(opts.maxCandidatesConsidered, "build: opts.maxCandidatesConsidered", poolCeiling, { E: E, code: "path/bad-input", min: 1 });
2236
2408
  var doValidate = opts.validate !== false;
2237
2409
 
2410
+ // AIA caIssuers fetching is OFF unless opts.fetchAia === true -- the default build is byte-identical to
2411
+ // today's offline search (no transport constructed, no socket, no new path executed). When on, opts.transport
2412
+ // is the injectable seam (tests drive it offline); with no injected transport the default https transport
2413
+ // fails closed unless opts.tls carries an anchor / useSystemStore. opts.tls is the TLS trust for the AIA
2414
+ // HTTPS host -- DISTINCT from opts.trustAnchors (the PKI trust store the built path validates against).
2415
+ var aiaCtx = null;
2416
+ if (opts.fetchAia === true) {
2417
+ // Validate the AIA-specific options at CONFIG time (a caller typo is a path/bad-input throw, tier 1), not
2418
+ // lazily inside the fetch where a bad transport / timeout would be caught as an ordinary fetch fault and
2419
+ // silently degrade to path/no-path. A non-function transport can never be called; an injected transport gets
2420
+ // a validated timeout (it may not cap the value itself).
2421
+ if (opts.transport !== undefined && typeof opts.transport !== "function") {
2422
+ throw E("path/bad-input", "build: opts.transport must be a transport function (request) -> Promise<{ status, headers, body }>");
2423
+ }
2424
+ // Validate aiaTimeout against the SAME bounds the built-in transport enforces (integer, 1..MAX_TIMEOUT) at
2425
+ // CONFIG time, so a non-integer / over-ceiling value is a path/bad-input throw here rather than a transport
2426
+ // rejection swallowed later as a fetch fault. guard.limits.cap is the exact primitive the transport applies.
2427
+ if (opts.aiaTimeout !== undefined) {
2428
+ guard.limits.cap(opts.aiaTimeout, "build: opts.aiaTimeout", 1, { E: E, code: "path/bad-input", min: 1, max: httpTransport.MAX_TIMEOUT });
2429
+ }
2430
+ var aiaTransport = opts.transport || httpTransport.https({ E: E, errPrefix: "path" });
2431
+ aiaCtx = {
2432
+ transport: aiaTransport,
2433
+ // SSRF for a DNS-name AIA host needs resolution-time address filtering + pinning, which only a transport
2434
+ // that ADVERTISES the capability performs (the built-in, or an injected one that sets blocksPrivateAddresses
2435
+ // to vouch it filters). An injected fn(request) that ignores the flag is treated as UNGUARDED: a DNS-name AIA
2436
+ // URL is then fail-closed (skipped) and only an IP literal (validated by the literal pre-check) is fetched.
2437
+ transportGuardsAddresses: aiaTransport.blocksPrivateAddresses === true,
2438
+ tls: opts.tls || {},
2439
+ timeout: opts.aiaTimeout, // validated above; the transport applies its own default + cap
2440
+ // Default the AIA response cap BELOW the general HTTP ceiling (a caIssuers response is small); tighten
2441
+ // downward only, never above HTTP_MAX_RESPONSE_BYTES.
2442
+ 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 }),
2443
+ maxPerCert: guard.limits.cap(opts.maxAiaPerCert, "build: opts.maxAiaPerCert", constants.LIMITS.PATH_AIA_MAX_PER_CERT, { E: E, code: "path/bad-input", min: 0 }),
2444
+ maxCertsPerResponse: constants.LIMITS.PATH_AIA_MAX_CERTS_PER_RESPONSE,
2445
+ // The total fetch budget is a SILENT cap (stop initiating fetches when reached), NOT a throw: a bound on
2446
+ // an ADVISORY fetch must never abort a build that the static pool could still complete. maxFetches may be
2447
+ // 0 (fetchAia:true but no network -- identical to offline).
2448
+ maxFetches: guard.limits.cap(opts.maxAiaFetches, "build: opts.maxAiaFetches", constants.LIMITS.PATH_AIA_MAX_FETCHES, { E: E, code: "path/bad-input", min: 0 }),
2449
+ fetchedUrls: new Set(),
2450
+ fetches: 0,
2451
+ };
2452
+ }
2453
+
2238
2454
  // The build-specific options are consumed here; every remaining validate
2239
2455
  // option is forwarded unchanged to the interleaved validate call. Object.keys
2240
2456
  // 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 };
2457
+ var BUILD_ONLY_OPT = { candidates: 1, intermediates: 1, trustAnchors: 1, maxDepth: 1, maxCandidatesConsidered: 1, validate: 1,
2458
+ fetchAia: 1, transport: 1, tls: 1, maxAiaFetches: 1, maxAiaPerCert: 1, aiaTimeout: 1, maxResponseBytes: 1 };
2242
2459
  var forwarded = {};
2243
2460
  Object.keys(opts).forEach(function (k) { if (!BUILD_ONLY_OPT[k]) forwarded[k] = opts[k]; });
2244
2461
  function validateOpts(anchor) {
@@ -2266,7 +2483,61 @@ async function build(leaf, opts) {
2266
2483
  // Caps are enforced before every expansion; a candidate whose (subject, SAN,
2267
2484
  // public key) tuple is already on the chain is a loop and is pruned.
2268
2485
  var stack = [{ chain: [leafCert], hop: 0, keys: new Set([identityKey(leafCert)]) }];
2269
- while (stack.length && !success) {
2486
+ // AIA fetch frames are DEFERRED into this queue and drained ONLY when `stack` is empty -- i.e. once the ENTIRE
2487
+ // local (static-pool) search has failed. This enforces RFC 4158 sec. 7.2 "local before remote" GLOBALLY across
2488
+ // the whole search, not per-branch: a build the static pool can complete never issues a network request, and a
2489
+ // higher-priority local dead-end can never fetch ahead of a still-unexplored lower-priority static sibling.
2490
+ // Drained DEEPEST-first, and among equal depth EARLIEST-deferred (= highest DFS priority) first: the actual
2491
+ // dead end (a missing deeper hop) is fetched before an ancestor whose issuer the pool ALREADY supplies (so a
2492
+ // scarce budget is never spent re-retrieving a locally-resolved hop), and a higher-scoring sibling's AIA is
2493
+ // tried before a lower-scoring one (a plain LIFO pop would reverse sibling priority and let a stale low-priority
2494
+ // branch's dead URL waste a tight budget before the preferred branch).
2495
+ var deferredAia = [];
2496
+ var deferSeq = 0; // monotonic deferral order (DFS priority): the drain tie-breaks same-depth frames by it
2497
+ while (!success) {
2498
+ if (!stack.length) {
2499
+ // The local search is drained. Begin / continue the fetch phase: pick a deferred frame with PENDING WORK --
2500
+ // one that has NOT yet fetched, OR whose poolMark trails the shared pool (a SIBLING branch fetched a cert
2501
+ // SINCE this frame last ran, which may complete it). A drained frame is NOT discarded: a later sibling's
2502
+ // fetch can add the very issuer an earlier, already-run branch needed, so every frame stays eligible for
2503
+ // pool growth until it has fetched AND seen the whole pool. Among eligible frames pick the deepest, then the
2504
+ // earliest-deferred (highest DFS priority). No eligible frame -> the whole search is exhausted.
2505
+ var _bi = -1;
2506
+ for (var _di = 0; _di < deferredAia.length; _di++) {
2507
+ var _f = deferredAia[_di];
2508
+ if (_f.fetched && _f.poolMark >= pool.length) continue; // nothing left to fetch or to re-expand against
2509
+ if (_bi === -1) { _bi = _di; continue; }
2510
+ var _bf = deferredAia[_bi];
2511
+ if (_f.hop > _bf.hop || (_f.hop === _bf.hop && _f.seq < _bf.seq)) _bi = _di;
2512
+ }
2513
+ if (_bi === -1) break; // no deferred frame can make further progress
2514
+ var fb = deferredAia[_bi]; // NOT removed: it stays eligible for certs a later sibling fetch adds
2515
+ var fbCur = fb.chain[0];
2516
+ if (!fb.fetched && aiaCtx && aiaCtx.fetches < aiaCtx.maxFetches) {
2517
+ var fetched = await _fetchAiaIssuers(fbCur, aiaCtx); // append newly-fetched issuer(s) to the SHARED pool
2518
+ for (var fj = 0; fj < fetched.length; fj++) {
2519
+ var fdk = certDerKey(fetched[fj]);
2520
+ if (poolDerKeys.has(fdk)) continue; // a byte-duplicate (mirror URL / repeating CMS) -> add once, never re-charge the budget/ceiling
2521
+ poolDerKeys.add(fdk);
2522
+ if (pool.length < poolCeiling) pool.push(fetched[fj]);
2523
+ }
2524
+ }
2525
+ fb.fetched = true; // the fetch attempt is done (or budget-skipped); the frame stays eligible for FUTURE pool growth
2526
+ // Re-expand fb against every pool cert added SINCE it last ran -- its OWN just-fetched certs AND any a SIBLING
2527
+ // branch fetched into the shared pool. The pool-index mark skips certs fb already scored (no redundant work);
2528
+ // advancing it means fb re-scores only certs added even later. This runs even when the budget is exhausted, so
2529
+ // a budget-capped frame still benefits from a sibling fetch.
2530
+ var fbAki = childAkiKeyId(fbCur);
2531
+ var fbScored = [];
2532
+ for (var pj = fb.poolMark; pj < pool.length; pj++) {
2533
+ if (nameMatchSoft(pool[pj].subject.rdns, fbCur.issuer.rdns)) {
2534
+ fbScored.push({ cand: pool[pj], score: scoreCandidate(pool[pj], fbAki, anchors, opts.time) });
2535
+ }
2536
+ }
2537
+ fb.poolMark = pool.length;
2538
+ considered += _pushCandidates(fb, fbScored, stack, counter);
2539
+ continue;
2540
+ }
2270
2541
  var frame = stack.pop();
2271
2542
  var current = frame.chain[0];
2272
2543
 
@@ -2290,28 +2561,32 @@ async function build(leaf, opts) {
2290
2561
  scored.push({ cand: pool[pi], score: scoreCandidate(pool[pi], childAki, anchors, opts.time) });
2291
2562
  }
2292
2563
  }
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 });
2564
+ // Opt-in AIA caIssuers fetch as a FALLBACK: DEFER it (drained above only after the ENTIRE local search is
2565
+ // exhausted, RFC 4158 sec. 7.2 "local before remote") rather than pushing it onto the stack, so it can never
2566
+ // run ahead of an unexplored local sibling -- a build the static pool can complete never fetches. It is gated
2567
+ // on `success` being unset (guaranteed by the continue above), NOT on the issuer name failing to match an
2568
+ // anchor: an issuer DN that matches an anchor whose KEY did not validate this chain (a CA key rollover -- the
2569
+ // real same-DN, different-key intermediate is missing and reachable only via AIA) still needs the fetch.
2570
+ if (aiaCtx && frame.hop < maxDepth && aiaCtx.fetches < aiaCtx.maxFetches) {
2571
+ // poolMark: the shared-pool length now, so the drain re-expands this frame only against certs added LATER
2572
+ // (its own fetch + any sibling branch's fetch), never re-scoring the static pool it expands against below.
2573
+ // seq: the deferral order, so the drain can tie-break same-depth frames by DFS priority (earliest first).
2574
+ // fetched: false until this frame's own AIA fetch runs; it then stays eligible for later sibling pool growth.
2575
+ deferredAia.push({ chain: frame.chain, hop: frame.hop, keys: frame.keys, poolMark: pool.length, seq: deferSeq, fetched: false });
2576
+ deferSeq += 1;
2304
2577
  }
2578
+ considered += _pushCandidates(frame, scored, stack, counter);
2305
2579
  }
2306
2580
 
2581
+ var aiaFetches = aiaCtx ? aiaCtx.fetches : 0; // the count of AIA caIssuers network GETs this build performed (0 when opts.fetchAia is off)
2307
2582
  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 };
2583
+ if (doValidate) return { valid: true, path: success.path, trustAnchor: success.trustAnchor, result: success.result, candidatesConsidered: considered, aiaFetches: aiaFetches };
2584
+ return { path: success.path, trustAnchor: success.trustAnchor, candidatesConsidered: considered, aiaFetches: aiaFetches };
2310
2585
  }
2311
2586
  if (anyChainAssembled) {
2312
2587
  // Chains reached an anchor but none validated -> the soft verdict carrying
2313
2588
  // 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 };
2589
+ return { valid: false, path: bestFail.path, trustAnchor: bestFail.trustAnchor, result: bestFail.result, candidatesConsidered: considered, aiaFetches: aiaFetches };
2315
2590
  }
2316
2591
  // No chain to any configured anchor could even be assembled -- a permanent
2317
2592
  // 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,
@@ -73,11 +73,6 @@ var OID_X509_CRL = oid.byName("x509CRL");
73
73
  var OID_FRIENDLY_NAME = oid.byName("friendlyName");
74
74
  var OID_LOCAL_KEY_ID = oid.byName("localKeyId");
75
75
  var OID_PBMAC1 = oid.byName("pbmac1");
76
- // The PBKDF2-params prf DEFAULT is algid-hmacWithSHA1 -- hmacWithSHA1 with NULL
77
- // parameters (RFC 8018 sec. 5.2) -- needed for the encoded-DEFAULT rejection below.
78
- var OID_HMAC_SHA1 = oid.byName("hmacWithSHA1");
79
- var DER_NULL = asn1.build.nullValue();
80
-
81
76
  // DigestInfo ::= SEQUENCE { digestAlgorithm AlgorithmIdentifier, digest OCTET STRING }.
82
77
  var DIGEST_INFO = schema.seq([
83
78
  schema.field("digestAlgorithm", pkix.algorithmIdentifier(NS)),
@@ -89,67 +84,11 @@ var DIGEST_INFO = schema.seq([
89
84
  },
90
85
  });
91
86
 
92
- // PBKDF2-params (RFC 8018 sec. 5.2), constrained to the RFC 9579 PBMAC1 profile:
93
- // the salt MUST use the specified (OCTET STRING) choice and keyLength MUST be
94
- // present (sec. 4.b / sec. 5 -- a MacData consumer cannot infer the MAC key size).
95
- var PBKDF2_PARAMS = schema.seq([
96
- schema.field("salt", schema.octetString()),
97
- schema.field("iterationCount", schema.integerLeaf()),
98
- schema.optional("keyLength", schema.integerLeaf(), { whenUniversal: [TAGS.INTEGER] }),
99
- schema.optional("prf", pkix.algorithmIdentifier(NS), { whenUniversal: [TAGS.SEQUENCE] }),
100
- ], {
101
- assert: "sequence", code: "pkcs12/bad-mac-data", what: "PBKDF2-params",
102
- build: function (m, ctx) {
103
- if (!m.fields.keyLength.present) {
104
- throw ctx.E("pkcs12/bad-mac-data", "PBMAC1 PBKDF2-params must carry keyLength (RFC 9579 sec. 5)");
105
- }
106
- // guard.range.positiveInt31 bounds + narrows each counter atomically -- a
107
- // value past the bound would round silently and hand a verifier wrong inputs.
108
- var iterationCount = guard.range.positiveInt31(m.fields.iterationCount.value, ctx.E, "pkcs12/bad-mac-data", "PBKDF2 iterationCount");
109
- var keyLength = guard.range.positiveInt31(m.fields.keyLength.value, ctx.E, "pkcs12/bad-mac-data", "PBKDF2 keyLength");
110
- var prf = m.fields.prf.present ? m.fields.prf.value.result : null;
111
- // X.690 sec. 11.5 -- a DEFAULT-valued component must be omitted from a DER
112
- // encoding, and the prf DEFAULT is algid-hmacWithSHA1: hmacWithSHA1 with
113
- // NULL parameters (RFC 8018 sec. 5.2). An explicit prf byte-equal to that
114
- // default is non-canonical and rejects -- the structured-value analogue of
115
- // the MacData iterations rule below. hmacWithSHA1 with ABSENT parameters
116
- // is a different value from the NULL-parameters default and decodes.
117
- if (prf && prf.oid === OID_HMAC_SHA1 && prf.parameters !== null && prf.parameters.equals(DER_NULL)) {
118
- throw ctx.E("pkcs12/bad-mac-data", "a PBKDF2 prf equal to its DEFAULT algid-hmacWithSHA1 must be omitted (X.690 sec. 11.5, RFC 8018 sec. 5.2)");
119
- }
120
- return {
121
- salt: m.fields.salt.value,
122
- iterationCount: iterationCount,
123
- keyLength: keyLength,
124
- prfOid: prf ? prf.oid : OID_HMAC_SHA1,
125
- prfName: prf ? prf.name : "hmacWithSHA1",
126
- };
127
- },
128
- });
129
-
130
- // PBMAC1-params ::= SEQUENCE { keyDerivationFunc AlgorithmIdentifier{PBKDF2},
131
- // messageAuthScheme AlgorithmIdentifier } (RFC 8018 sec. A.5 / RFC 9579 sec. 4).
132
- var PBMAC1_PARAMS = schema.seq([
133
- schema.field("keyDerivationFunc", schema.seq([
134
- schema.field("algorithm", schema.oidLeaf()),
135
- schema.field("parameters", PBKDF2_PARAMS),
136
- ], { assert: "sequence", arity: { exact: 2 }, code: "pkcs12/bad-mac-data", what: "PBMAC1 keyDerivationFunc" })),
137
- schema.field("messageAuthScheme", pkix.algorithmIdentifier(NS)),
138
- ], {
139
- assert: "sequence", arity: { exact: 2 }, code: "pkcs12/bad-mac-data", what: "PBMAC1-params",
140
- build: function (m, ctx) {
141
- var kdf = m.fields.keyDerivationFunc.value;
142
- if (kdf.fields.algorithm.value !== oid.byName("pbkdf2")) {
143
- throw ctx.E("pkcs12/bad-mac-data", "PBMAC1 keyDerivationFunc must be PBKDF2 (RFC 9579 sec. 4)");
144
- }
145
- var scheme = m.fields.messageAuthScheme.value.result;
146
- return {
147
- kdf: kdf.fields.parameters.value.result,
148
- schemeOid: scheme.oid,
149
- schemeName: scheme.name,
150
- };
151
- },
152
- });
87
+ // PBKDF2-params + PBMAC1-params (RFC 8018 sec. 5.2 / App. A.5, RFC 9579 sec. 4) constrained to the PBMAC1
88
+ // profile -- keyLength MUST be present, the prf DEFAULT is enforced non-canonical. Shared with CMP PBMAC1
89
+ // protection verification, so the reader lives once in schema-pkix (ns-parameterized) rather than a second
90
+ // copy per format; composed here with the pkcs12 namespace so it emits pkcs12/bad-mac-data.
91
+ var PBMAC1_PARAMS = pkix.pbmac1Params(NS);
153
92
 
154
93
  // MacData ::= SEQUENCE { mac DigestInfo, macSalt OCTET STRING,
155
94
  // iterations INTEGER DEFAULT 1 }.