@blamejs/pki 0.5.27 → 0.5.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,7 +4,41 @@ 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.5.27 — 2026-08-23
7
+ ## v0.5.29 — 2026-08-24
8
+
9
+ pki.path.validate refuses a mis-shaped trust anchor instead of returning a verdict against it, and accepts a parsed certificate directly.
10
+
11
+ ### Added
12
+
13
+ - `pki.path.anchorFromCert(cert)` turns a parsed certificate into the `{ name, publicKey, algorithm }` trust-anchor tuple `pki.path.validate` and `pki.path.build` consume, so a root can be pinned from its certificate without hand-building the tuple. `pki.path.validate` and `pki.path.build` also accept a parsed certificate passed directly as the anchor.
14
+
15
+ ### Changed
16
+
17
+ - A normalized trust anchor -- what `pki.path.anchorFromCert` returns and what `pki.path.build` reports as `result.trustAnchor` -- carries a defined set of fields: `name`, `publicKey`, `algorithm`, and `parameters`, plus `purposes`, `distrustAfter`, `subjectDer`, `label`, and `mozillaCaPolicy` when the source supplies them (the fields `pki.trust.parseCertdata` emits). These survive normalization, so a pinned trust-store entry round-trips; a field attached outside that set is not carried onto the anchor.
18
+ - A trust anchor's `purposes` and `distrustAfter` constraint maps must be plain own data properties. A map -- or one of its entries -- supplied as a getter, or reached through the object's prototype rather than as an own property, is refused with `path/bad-input` rather than read, so a getter cannot answer a purpose or distrust check inconsistently and a restriction is never silently dropped. The `{ purposes, distrustAfter }` shape `pki.trust.parseCertdata` emits and a hand-built one are the normal, unaffected form.
19
+ - A trust-anchor tuple's nested `algorithm.oid` and `name.rdns` must likewise be plain own data properties. An accessor-backed or prototype-inherited definition is refused with `path/bad-input` rather than read, so a getter on one nested field cannot rewrite the other -- for example, rewriting a wrong declared algorithm OID to the key's real one -- before it is validated. A plain `{ oid }` algorithm and `{ rdns, bytes }` name, and a parsed certificate's own, are the normal, unaffected form.
20
+ - The ACME client's per-request methods refuse an unrecognized option instead of reading it as absent and applying a default: `finalize`, `pollOrder` / `pollAuthorization`, `downloadCertificate`, `revokeCert`, `keyChange`, and `renewalWindow`. A mistyped option name is now an `acme/bad-input` error rather than a silently ignored setting, matching the client constructor and the rest of the toolkit's option-taking verbs.
21
+
22
+ ### Security
23
+
24
+ - `pki.path.validate` refuses a malformed `opts.trustAnchor` with `path/bad-input` at entry, rather than seeding the path from it and returning a soft verdict. An anchor tuple missing its `algorithm` could previously make the path validate and return `valid: true` -- a self-describing key algorithm filled the gap the absent field left -- so a caller who passed a mis-shaped anchor received a verdict that did not answer the question they asked. The anchor is now normalized and shape-checked at the door, the same way `pki.path.build` already treated its `trustAnchors`.
25
+ - `pki.path.validate` and `pki.path.build` also refuse a trust anchor whose declared `algorithm` does not match the algorithm of its `publicKey`, and take the key's algorithm parameters from that SubjectPublicKeyInfo rather than a declared `parameters` field. An anchor that named a different or unrelated algorithm than the key it carried was previously accepted and validated against the real key; a declared algorithm inconsistent with the key is now a `path/bad-input` refusal at entry. And because the key's own parameters are authoritative, a declared curve that disagreed with the key can no longer be promoted and inherited by an intermediate certificate that omits its own parameters -- which could otherwise validate a chain RFC 5280 parameter inheritance from the real key should reject.
26
+ - A trust anchor passed as a parsed certificate is recognized as a certificate before any tuple field is read, so a value reached through the object's prototype -- such as a polluted `Object.prototype` supplying `name`, `publicKey`, and `algorithm` -- cannot reclassify the certificate as a hand-built tuple and bind a substituted key. The certificate's own key is always the one used.
27
+ - A trust anchor supplied as a `Proxy`, or one whose `purposes` / `distrustAfter` constraint map is a `Proxy`, is refused with `path/bad-input`. A `Proxy`'s traps can answer a field read differently on successive lookups or report a field absent while forwarding the rest, so no field-by-field normalization can trust it to describe itself -- a `Proxy` could report `purposes` absent while carrying the other fields, or a `Proxy` distrust map could report no keys, dropping a `{ serverAuth: false }` restriction or an expired-cutoff date the caller attached and validating a path the anchor forbids. A normal anchor -- a plain tuple, a parsed certificate, or an object inheriting from one, with plain-object constraint maps -- is not a `Proxy` and is unaffected.
28
+
29
+ ## v0.5.28 — 2026-08-22
30
+
31
+ Importing the toolkit is now silent, and a key's WebCrypto algorithm can no longer change under a signature once the key has been created.
32
+
33
+ ### Changed
34
+
35
+ - `require("@blamejs/pki")` no longer prints Node experimental-feature warnings on load. The engine's typed-array scan reads each global's property descriptor and uses only a data value, so it never touches a host's lazy accessor (Node's `localStorage` and `WASI` emit an `ExperimentalWarning` the moment they are read). Recognition of the concrete typed-array kinds -- the reason for the scan -- is unchanged, since those are ordinary data properties.
36
+
37
+ ### Security
38
+
39
+ - A `CryptoKey`'s `algorithm` is immutable once the key is created: the property is non-writable so it cannot be replaced, and its value is frozen so its fields cannot be changed. This engine reads `key.algorithm.hash` at sign time, and a mutable algorithm let the hash checked against a JWS `alg` (an `RS256` header signs under SHA-256) be rewritten between that check and the signature -- by swapping the whole object or a field of it, including from a microtask during the signing await -- producing a JWS whose signature does not match the algorithm its header advertises. The frozen value is a copy, so a caller's own `importKey` parameters object is left untouched. A key adopted from another WebCrypto implementation is re-imported from its own algorithm; the keys this engine mints, which is what the enrollment builders sign with, carry the immutable one.
40
+
41
+ ## v0.5.27 — 2026-08-22
8
42
 
9
43
  A mistyped option passed to pki.trust.anchor or the pki.acme.client constructor is now refused, naming it, instead of being read as absent and silently defaulted.
10
44
 
package/lib/acme.js CHANGED
@@ -1955,6 +1955,28 @@ var _CLIENT_OPTS = {
1955
1955
  accountKey: 1, accountJwk: 1, alg: 1, transport: 1, tls: 1, timeout: 1, maxResponseBytes: 1,
1956
1956
  maxRedirects: 1, maxNonceRetries: 1, maxPolls: 1, maxTotalWait: 1, sleep: 1, clock: 1,
1957
1957
  };
1958
+ // The per-request options each client method reads. A near-miss spelling in a method's own bag is
1959
+ // refused the same way the constructor refuses one, rather than read as undefined and silently
1960
+ // defaulted -- a mistyped `requireBinding` or `reason` would otherwise change the request with no error.
1961
+ // finalize accepts `identifiers` but NEVER lets it override the order's own set (RFC 8555 sec. 7.4), so
1962
+ // it is recognized here though the binding ignores it.
1963
+ var _METHOD_OPTS = {
1964
+ finalize: { csr: 1, identifiers: 1 },
1965
+ poll: { maxPolls: 1, maxTotalWait: 1, onRetryAfter: 1 },
1966
+ download: { expectedSpki: 1, identifiers: 1, requireBinding: 1, selectChain: 1, maxAlternates: 1 },
1967
+ revoke: { certAlg: 1, certJwk: 1, certKey: 1, certificate: 1, reason: 1 },
1968
+ keyChange: { newAlg: 1, newJwk: 1, newKey: 1 },
1969
+ renewalWindow: { random: 1, clock: 1, replaced: 1, previous: 1 },
1970
+ };
1971
+ // Settle a per-method options bag the way the constructor settles its own: guard.identifier.optionsObject
1972
+ // snapshots the bag and refuses one a getter rewrites mid-read (a `requireBinding` getter that grows a
1973
+ // misspelled key after the check would otherwise slip past assertKnownKeys), THEN reject any unknown key.
1974
+ // Returns the settled bag so the method reads the frozen copy, never the live object a getter could rewrite.
1975
+ function _gateOpts(bag, whitelist, name) {
1976
+ bag = guard.identifier.optionsObject(bag, E, "acme/bad-input", "pki.acme " + name + " options");
1977
+ guard.identifier.assertKnownKeys(bag, whitelist, E, "acme/bad-input", "unknown " + name + " option ");
1978
+ return bag;
1979
+ }
1958
1980
  function client(directoryUrl, opts) {
1959
1981
  opts = guard.identifier.optionsObject(opts, E, "acme/bad-input", "pki.acme.client options");
1960
1982
  guard.identifier.assertKnownKeys(opts, _CLIENT_OPTS, E, "acme/bad-input", "unknown pki.acme.client option ");
@@ -2216,7 +2238,7 @@ function client(directoryUrl, opts) {
2216
2238
 
2217
2239
  function _finalize(order, o) { return _promised(function () { return _finalizeBody(order, o); }); }
2218
2240
  function _finalizeBody(order, o) {
2219
- o = o || {};
2241
+ o = _gateOpts(o, _METHOD_OPTS.finalize, "finalize");
2220
2242
  if (!_isObject(order) || !_isString(order.finalize)) throw E("acme/bad-input", "finalize requires the order object with its finalize URL");
2221
2243
  if (!Array.isArray(order.identifiers)) throw E("acme/bad-input", "finalize requires the order's identifiers to enforce the RFC 8555 sec. 7.4 CSR-set match");
2222
2244
  // The sec. 7.4 CSR-vs-order check is ALWAYS bound to the order's OWN authoritative identifiers (the
@@ -2229,12 +2251,12 @@ function client(directoryUrl, opts) {
2229
2251
  // slept via the injectable sleep), bounded by maxPolls + maxTotalWait; each observed pair runs
2230
2252
  // assertTransition. A terminal `invalid` surfaces the object (the caller inspects .status).
2231
2253
  function _poll(kind, url, budget) {
2232
- budget = budget || {};
2233
2254
  // A per-call poll override is validated through the SAME bounds as the constructor budget, so an
2234
2255
  // Infinity / NaN / over-ceiling value cannot turn a non-terminal resource into an unbounded loop.
2235
2256
  // Surface a bad override (or url) as a REJECTION, not a sync throw, so this async verb is uniform.
2236
2257
  var capPolls, capWait;
2237
2258
  try {
2259
+ budget = _gateOpts(budget, _METHOD_OPTS.poll, "poll");
2238
2260
  capPolls = budget.maxPolls != null ? guard.limits.cap(budget.maxPolls, "maxPolls", maxPolls, { E: E, code: "acme/bad-input", min: 1, max: 1000 }) : maxPolls;
2239
2261
  capWait = budget.maxTotalWait != null ? guard.limits.cap(budget.maxTotalWait, "maxTotalWait", maxTotalWait, { E: E, code: "acme/bad-input", min: 0, max: retryAfter.MAX_RETRY_AFTER_SECONDS }) : maxTotalWait;
2240
2262
  url = _clientUrl(url);
@@ -2346,9 +2368,9 @@ function client(directoryUrl, opts) {
2346
2368
  }
2347
2369
 
2348
2370
  function _downloadCertificate(url, opts) {
2349
- opts = opts || {};
2350
2371
  // Prelude in a resolved-promise .then so a bad opt / URL is a REJECTION, uniform for a .catch caller.
2351
2372
  return Promise.resolve().then(function () {
2373
+ opts = _gateOpts(opts, _METHOD_OPTS.download, "downloadCertificate");
2352
2374
  var select = opts.selectChain;
2353
2375
  if (select !== undefined && typeof select !== "function") throw E("acme/bad-input", "downloadCertificate opts.selectChain must be a function");
2354
2376
  var maxAlt = guard.limits.cap(opts.maxAlternates, "maxAlternates", DEFAULT_MAX_ALTERNATES, { E: E, code: "acme/bad-input", min: 0, max: 64 });
@@ -2405,26 +2427,29 @@ function client(directoryUrl, opts) {
2405
2427
  }
2406
2428
 
2407
2429
  function _revokeCert(o) {
2408
- o = o || {};
2409
- // Surface bad input as a REJECTION (not a sync throw) so this async verb is uniform for a .catch caller.
2410
- if (!Buffer.isBuffer(o.certificate)) return Promise.reject(E("acme/bad-input", "revokeCert requires a DER certificate Buffer (opts.certificate)"));
2411
- // Certificate-key mode (RFC 8555 sec. 7.6) needs the COMPLETE triple certKey + certJwk + certAlg: an
2412
- // incomplete set must NOT fall through to the account-key path (wrong credential), and certAlg must be
2413
- // explicit -- inheriting the account alg would sign a different-family certificate key under the wrong
2414
- // JWS algorithm (an ES256 account revoking with an RSA certificate key), which JOSE then rejects.
2415
- var certKeyMode = o.certKey != null || _isObject(o.certJwk) || o.certAlg != null;
2416
- if (certKeyMode && !(o.certKey != null && _isObject(o.certJwk) && _isString(o.certAlg))) return Promise.reject(E("acme/bad-input", "revokeCert certificate-key mode requires certKey, certJwk, AND certAlg together, or none (RFC 8555 sec. 7.6)"));
2417
- var extra = { certificate: o.certificate };
2418
- if (o.reason !== undefined) extra.reason = o.reason;
2419
- return _resource("revokeCert").then(function (url) {
2420
- if (certKeyMode) {
2421
- // sign with the certificate key (jwk mode) rather than the account (RFC 8555 sec. 7.6), through
2422
- // the same _postJws so it inherits the badNonce bounded retry the kid path has.
2423
- return _postJws(url, function (nonce) {
2424
- return revokeCert(Object.assign({}, extra, { key: o.certKey, alg: o.certAlg, nonce: nonce, url: url, jwk: o.certJwk }));
2425
- }).then(function () { return true; });
2426
- }
2427
- return _post(url, revokeCert, extra, "kid").then(function () { return true; });
2430
+ // Bad input surfaces as a REJECTION -- _promised turns a throw into one -- so this async verb stays
2431
+ // uniform for a .catch caller rather than throwing synchronously.
2432
+ return _promised(function () {
2433
+ o = _gateOpts(o, _METHOD_OPTS.revoke, "revokeCert");
2434
+ if (!Buffer.isBuffer(o.certificate)) throw E("acme/bad-input", "revokeCert requires a DER certificate Buffer (opts.certificate)");
2435
+ // Certificate-key mode (RFC 8555 sec. 7.6) needs the COMPLETE triple certKey + certJwk + certAlg: an
2436
+ // incomplete set must NOT fall through to the account-key path (wrong credential), and certAlg must be
2437
+ // explicit -- inheriting the account alg would sign a different-family certificate key under the wrong
2438
+ // JWS algorithm (an ES256 account revoking with an RSA certificate key), which JOSE then rejects.
2439
+ var certKeyMode = o.certKey != null || _isObject(o.certJwk) || o.certAlg != null;
2440
+ if (certKeyMode && !(o.certKey != null && _isObject(o.certJwk) && _isString(o.certAlg))) throw E("acme/bad-input", "revokeCert certificate-key mode requires certKey, certJwk, AND certAlg together, or none (RFC 8555 sec. 7.6)");
2441
+ var extra = { certificate: o.certificate };
2442
+ if (o.reason !== undefined) extra.reason = o.reason;
2443
+ return _resource("revokeCert").then(function (url) {
2444
+ if (certKeyMode) {
2445
+ // sign with the certificate key (jwk mode) rather than the account (RFC 8555 sec. 7.6), through
2446
+ // the same _postJws so it inherits the badNonce bounded retry the kid path has.
2447
+ return _postJws(url, function (nonce) {
2448
+ return revokeCert(Object.assign({}, extra, { key: o.certKey, alg: o.certAlg, nonce: nonce, url: url, jwk: o.certJwk }));
2449
+ }).then(function () { return true; });
2450
+ }
2451
+ return _post(url, revokeCert, extra, "kid").then(function () { return true; });
2452
+ });
2428
2453
  });
2429
2454
  }
2430
2455
 
@@ -2433,7 +2458,7 @@ function client(directoryUrl, opts) {
2433
2458
 
2434
2459
  function _keyChange(o) { return _promised(function () { return _keyChangeBody(o); }); }
2435
2460
  function _keyChangeBody(o) {
2436
- o = o || {};
2461
+ o = _gateOpts(o, _METHOD_OPTS.keyChange, "keyChange");
2437
2462
  if (!o.newKey || !_isObject(o.newJwk) || !_isString(o.newAlg)) throw E("acme/bad-input", "keyChange requires newKey, newJwk, and newAlg");
2438
2463
  var account = _requireKid();
2439
2464
  return _resource("keyChange").then(function (url) {
@@ -2502,8 +2527,8 @@ function client(directoryUrl, opts) {
2502
2527
  // returns the decision as DATA. It never sleeps or schedules: an auto-renewing daemon is an explicit
2503
2528
  // future opt-in, not a hidden timer inside the thin client.
2504
2529
  function _renewalWindow(certDer, o) {
2505
- o = o || {};
2506
2530
  return Promise.resolve().then(function () {
2531
+ o = _gateOpts(o, _METHOD_OPTS.renewalWindow, "renewalWindow");
2507
2532
  if (!Buffer.isBuffer(certDer)) throw E("acme/bad-input", "renewalWindow requires a DER certificate Buffer");
2508
2533
  if (o.random !== undefined && typeof o.random !== "function") throw E("acme/bad-input", "renewalWindow opts.random must be a function returning a number in [0, 1]");
2509
2534
  if (o.clock !== undefined && typeof o.clock !== "function") throw E("acme/bad-input", "renewalWindow opts.clock must be a function returning epoch milliseconds");
@@ -433,13 +433,17 @@ var _INTRINSIC_HOLDERS = (function () {
433
433
  // built over `%TypedArray%.prototype`, and a bag inheriting `BYTES_PER_ELEMENT` from such an
434
434
  // object then read as an intrinsic member rather than as the unknown option it was.
435
435
  //
436
- // Reading a global can run a getter, and a deprecated one can throw; a fault here would break
437
- // this module's load over a name it does not need.
436
+ // Read the DESCRIPTOR, never the value through the runtime. `globalThis[n]` invokes a getter, and a
437
+ // host may define one that warns on read -- Node exposes `localStorage` and `WASI` as lazy accessors
438
+ // that emit an ExperimentalWarning the moment they are touched, so a plain read here printed that
439
+ // warning on every import -- or one that throws. A typed-array constructor is a plain data property, so
440
+ // taking `d.value` finds every concrete kind without ever invoking an accessor, which also means such
441
+ // an accessor can neither warn nor fault this load over a name it does not need.
438
442
  _forEach(_getOwnPropertyNames(globalThis), function (n) {
439
- var C;
440
- try { C = globalThis[n]; }
441
- catch (_e) { /* allow:swallow-unverified this runs once at load over whatever globals the host defines, and no global in a supported runtime throws on read, so no test drives it; it is here so an embedder's throwing accessor cannot stop this module loading over a name it does not need */ return; }
442
- if (typeof C !== "function" || typeof C.BYTES_PER_ELEMENT !== "number") return;
443
+ var d = _getOwnPropertyDescriptor(globalThis, n);
444
+ if (!d || typeof d.value !== "function") return;
445
+ var C = d.value;
446
+ if (typeof C.BYTES_PER_ELEMENT !== "number") return;
443
447
  if (!C.prototype || _getPrototypeOf(C.prototype) !== _TYPED_ARRAY_PROTO) return;
444
448
  _mapSet(m, C.prototype, _CONCRETE_TYPED_MEMBERS);
445
449
  });
@@ -84,6 +84,21 @@ function mapsAnyPolicy(mappings) {
84
84
 
85
85
  var NS = pkix.makeNS("path", PathError, oid);
86
86
  var EXT_DECODERS = pkix.certExtensionDecoders(NS).byOid;
87
+ // Validates a trust-anchor tuple's raw publicKey as a COMPLETE SubjectPublicKeyInfo (AlgorithmIdentifier +
88
+ // key BIT STRING), not just a decodable first OID -- a structurally-incomplete SPKI must fail closed at the
89
+ // anchor door, not slip through to a soft valid:false when key import later rejects it.
90
+ var ANCHOR_SPKI_SCHEMA = pkix.spki(NS);
91
+ // A trust anchor carries a CLOSED, producer-derived field set -- the shape pki.trust.parseCertdata documents
92
+ // and path validation + trust dedup consume: name, publicKey, algorithm, parameters, purposes, distrustAfter,
93
+ // subjectDer, label, mozillaCaPolicy. Normalizing a caller tuple reads each of those fields exactly once (by
94
+ // property access, which follows the prototype chain, so an inherited restriction is kept) and builds a fresh
95
+ // object from those reads -- publicKey pinned to a private copy, algorithm/parameters taken from the validated
96
+ // SubjectPublicKeyInfo (never the caller's declared value), the validation-read metadata (name, purposes,
97
+ // distrustAfter) materialized as DATA because validation reads each more than once and a live accessor could
98
+ // answer inconsistently. entry's own shape is NEVER enumerated (no `in` / ownKeys / getOwnPropertyDescriptor)
99
+ // and the prototype chain is never walked, so a Proxy anchor cannot use an enumeration trap to mutate state
100
+ // mid-normalization and a hostile getPrototypeOf cannot be driven into a loop (Buffer.isBuffer invokes
101
+ // getPrototypeOf once). A field the caller attached OUTSIDE the closed set is not carried. See toAnchor.
87
102
 
88
103
  var subtle = webcrypto.webcrypto.subtle;
89
104
 
@@ -332,6 +347,10 @@ function resolveDescriptor(sigAlg) {
332
347
  function assertKeyMatchesSigAlg(spkiBytes, sigOid, d) {
333
348
  if (!d || !d.sameKeyOid) return;
334
349
  var keyOid;
350
+ // The working-key SPKI is decodable on the shipped path (the anchor's is validated at toAnchor entry and
351
+ // every chain SPKI is strict-parsed), so this catch is defense-in-depth -- unreachable through
352
+ // pki.path.validate, kept to keep a typed verdict for any future caller that supplies raw key bytes. The
353
+ // RFC 9814 sec. 4 algorithm-confusion check below stays reachable and covered.
335
354
  try { keyOid = asn1.read.oid(asn1.decode(spkiBytes).children[0].children[0]); }
336
355
  catch (e) { throw E("path/algorithm-mismatch", "cannot read the issuer public-key algorithm identifier", e); }
337
356
  if (keyOid !== sigOid) {
@@ -791,7 +810,7 @@ function checkedSubtreeSeeds(list, optName) {
791
810
  });
792
811
  }
793
812
 
794
- function initialize(certs, params, seeds) {
813
+ function initialize(certs, params, seeds, anchor) {
795
814
  var n = certs.length;
796
815
  return {
797
816
  validPolicyTree: rootNode(),
@@ -804,10 +823,10 @@ function initialize(certs, params, seeds) {
804
823
  explicitPolicy: params.initialExplicitPolicy ? 0 : n + 1,
805
824
  inhibitAnyPolicy: params.initialAnyPolicyInhibit ? 0 : n + 1,
806
825
  policyMapping: params.initialPolicyMappingInhibit ? 0 : n + 1,
807
- workingPublicKeyAlgorithm: params.trustAnchor.algorithm,
808
- workingPublicKey: params.trustAnchor.publicKey,
809
- workingPublicKeyParameters: params.trustAnchor.parameters || null,
810
- workingIssuerName: params.trustAnchor.name,
826
+ workingPublicKeyAlgorithm: anchor.algorithm,
827
+ workingPublicKey: anchor.publicKey,
828
+ workingPublicKeyParameters: anchor.parameters || null,
829
+ workingIssuerName: anchor.name,
811
830
  maxPathLength: n,
812
831
  userInitialPolicySet: params.userInitialPolicySet || [OID.anyPolicy],
813
832
  results: [],
@@ -1230,6 +1249,10 @@ async function validate(path, opts) {
1230
1249
  var n = certs.length;
1231
1250
  if (n < 1) throw E("path/empty-path", "validate: the certification path is empty");
1232
1251
  if (!opts.trustAnchor) throw E("path/bad-input", "validate: a trustAnchor is required");
1252
+ // Normalize the anchor at the door: a parsed root CERTIFICATE becomes a { name, publicKey, algorithm }
1253
+ // tuple, and a mis-shaped tuple fails closed with path/bad-input rather than seeding an undefined
1254
+ // working key that a self-describing SPKI could still validate against (a soft-verdict fail-open).
1255
+ var anchor = toAnchor(opts.trustAnchor, "validate");
1233
1256
  // The validity-window check is always on (6.1.3(a)(2)); a missing/invalid
1234
1257
  // check date must fail closed, never silently disable it.
1235
1258
  guard.time.assertValid(opts.time, E, "path/bad-input", "validate: opts.time (the always-on validity-window check date)");
@@ -1262,7 +1285,7 @@ async function validate(path, opts) {
1262
1285
  var requiredEku = purposeOpts.requiredEku;
1263
1286
  var checkPurpose = purposeOpts.checkPurpose;
1264
1287
 
1265
- var state = initialize(certs, opts, seeds);
1288
+ var state = initialize(certs, opts, seeds, anchor);
1266
1289
  state._n = n;
1267
1290
  state.requiredEku = requiredEku;
1268
1291
  var verifier = opts.verifier || null;
@@ -1287,7 +1310,7 @@ async function validate(path, opts) {
1287
1310
  // `distrustAfter.serverAuth` without being told the validation is about serverAuth -- so without
1288
1311
  // one the caller's stated intent would be discarded silently, and a root distrusted years ago
1289
1312
  // would validate a current leaf. An anchor carrying no such metadata is unaffected.
1290
- if (!checkPurpose && opts.trustAnchor && _hasPurposeScopedMetadata(opts.trustAnchor)) {
1313
+ if (!checkPurpose && _hasPurposeScopedMetadata(anchor)) {
1291
1314
  throw E("path/bad-input", "validate: the trust anchor carries purpose-scoped metadata (distrustAfter / purposes), which is keyed by key purpose -- supply opts.checkPurpose to say which purpose this validation is for, or the constraint cannot be applied");
1292
1315
  }
1293
1316
 
@@ -1473,7 +1496,7 @@ async function validate(path, opts) {
1473
1496
  // anchor or an absent checkPurpose is byte-identical to today). The anchor's
1474
1497
  // per-purpose distrust-after date and delegator purposes apply to the
1475
1498
  // end-entity leaf it ultimately certifies.
1476
- var ta = opts.trustAnchor;
1499
+ var ta = anchor;
1477
1500
  // A PRESENT-but-malformed distrustAfter (an Invalid Date: instanceof Date yet a
1478
1501
  // NaN time) would make `notBefore > it` NaN-false and SILENTLY drop the distrust
1479
1502
  // restriction -- the NaN-Date fail-open. Validate a present date fail-closed
@@ -2617,13 +2640,25 @@ function _hasPurposeScopedMetadata(ta) {
2617
2640
  if (!ta || typeof ta !== "object") return false;
2618
2641
  return guard.list.anyMatches(["distrustAfter", "purposes"], function (k) {
2619
2642
  var m = ta[k];
2620
- return !!m && typeof m === "object" && Object.keys(m).length > 0;
2643
+ // Own names, not Object.keys: a non-enumerable own entry is still a restriction and must count, so it cannot
2644
+ // evade this "the anchor carries purpose-scoped metadata -> require checkPurpose" gate. An inherited map
2645
+ // (Object.create(base) or a polluted Object.prototype) is already refused by toAnchor before an anchor
2646
+ // reaches here, so this read does not resolve an inherited value.
2647
+ return !!m && typeof m === "object" && intrinsic.getOwnPropertyNames(m).length > 0;
2621
2648
  });
2622
2649
  }
2623
2650
 
2624
2651
  function assertAnchorConstraints(ta, checkPurpose) {
2625
- var d = (checkPurpose && ta && ta.distrustAfter && intrinsic.hasOwn(ta.distrustAfter, checkPurpose))
2626
- ? ta.distrustAfter[checkPurpose] : null;
2652
+ if (!checkPurpose || !ta) return null;
2653
+ // Read distrustAfter ONCE into a local: reading the anchor field for the presence gate and again for the
2654
+ // value would let an accessor-backed field answer the two reads differently and slip the distrust control.
2655
+ // The normalized anchor already materializes this to plain data; reading once holds the guarantee for any
2656
+ // caller and keeps this from being a live-getter multi-read even if a future path skips normalization. An
2657
+ // inherited distrustAfter (Object.create(base), or a polluted Object.prototype) is already refused by toAnchor
2658
+ // -- _captureConstraintMap / the certificate branch reject a `"distrustAfter" in entry` that is not own -- so
2659
+ // this read never resolves an inherited value on an anchor that reached here.
2660
+ var da = ta.distrustAfter;
2661
+ var d = (da && intrinsic.hasOwn(da, checkPurpose)) ? da[checkPurpose] : null;
2627
2662
  if (d == null) return null;
2628
2663
  return guard.time.assertValid(d, E, "path/bad-input", "trustAnchor.distrustAfter." + checkPurpose);
2629
2664
  }
@@ -2661,24 +2696,411 @@ function resolvePurposeOpts(opts) {
2661
2696
  return { requiredEku: requiredEku, checkPurpose: checkPurpose };
2662
2697
  }
2663
2698
 
2664
- function toAnchor(entry) {
2665
- if (entry && typeof entry === "object" && !Buffer.isBuffer(entry) && entry.name && entry.publicKey && entry.algorithm) {
2699
+ // Snapshot a trust-anchor purposes / distrustAfter map (purpose name -> boolean or Date) to a fresh plain
2700
+ // object, reading each own entry from its DATA DESCRIPTOR -- never by property access. Reading by value would
2701
+ // invoke an accessor entry (a `get serverAuth()`), which is caller code that could mutate the sibling map
2702
+ // before it is captured, so an accessor entry is REFUSED instead. A later getter cannot mutate the fresh copy.
2703
+ // A distrustAfter entry is a Date: copy it BY VALUE (guard.time.instantOf via the intrinsic) so a later
2704
+ // setTime() cannot move the cutoff. Entry enumerability is preserved so the purpose gates see the same shape.
2705
+ function _snapshotConstraintMap(m, E, code, who) {
2706
+ var out = {};
2707
+ // getOwnPropertyNames below reads only OWN keys, so a restriction reached through the prototype --
2708
+ // Object.create({ serverAuth: pastDate }) -- would be silently dropped and its cutoff lost. Require the map to
2709
+ // be a plain object of THIS realm: its prototype is Object.prototype (the common case) or null. Anything else
2710
+ // -- a custom prototype, a null-prototype object carrying entries, a Proxy prototype, or a map from another
2711
+ // realm -- is refused, so a prototype-reached restriction is never silently dropped. Only IDENTITY is sound
2712
+ // here: a hostile object can mimic every STRUCTURAL signal of a genuine Object.prototype (a non-enumerable
2713
+ // data entry is indistinguishable from a built-in), so a structural "looks plain" test always leaves a bypass.
2714
+ // A cross-realm map is therefore refused fail-closed -- the operator passes a same-realm plain object (what
2715
+ // pki.trust.parseCertdata emits, or a literal in their own code), which is the norm for this security-critical
2716
+ // input. A name planted on Object.prototype ITSELF is still tolerated (proto === ObjectProto): the map is plain
2717
+ // and consumption reads OWN entries only (the hasOwn gates), so global pollution is ignored, not read. m is
2718
+ // non-Proxy (refused at capture) and this is a reference comparison, so no getPrototypeOf / ownKeys trap runs
2719
+ // even when the prototype is a Proxy.
2720
+ var proto = intrinsic.getPrototypeOf(m);
2721
+ if (proto !== null && proto !== intrinsic.ObjectProto) throw E(code, who + ": a trustAnchor constraint map must be a plain object (its own entries only), not one with a custom or cross-realm prototype");
2722
+ var keys = intrinsic.getOwnPropertyNames(m);
2723
+ // A constraint map is plain STRING-keyed data. A symbol-keyed entry (getOwnPropertyNames omits symbols) or an
2724
+ // own __proto__ data property (skipped below so it can't pollute the fresh map) would be DROPPED from the
2725
+ // snapshot -- and, keyed by neither a checkPurpose OID name, never applied. Refuse the map rather than silently
2726
+ // drop a caller entry, so the snapshot faithfully carries every restriction or the anchor is refused. ownKeys
2727
+ // (Reflect.ownKeys) counts strings + symbols; more than the string-only names means a symbol key is present.
2728
+ if (intrinsic.ownKeys(m).length !== keys.length) throw E(code, who + ": a trustAnchor constraint map must not have symbol-keyed entries");
2729
+ for (var i = 0; i < keys.length; i++) {
2730
+ if (keys[i] === "__proto__") throw E(code, who + ": a trustAnchor constraint map must not have an own __proto__ entry");
2731
+ var d = intrinsic.getOwnPropertyDescriptor(m, keys[i]);
2732
+ if (!d || !intrinsic.hasOwn(d, "value")) throw E(code, who + ": a trustAnchor constraint-map entry must be a data property, not an accessor");
2733
+ var v = d.value;
2734
+ if (intrinsic.types.isDate(v)) v = new intrinsic.Date(guard.time.instantOf(v));
2735
+ // Materialize every entry ENUMERABLE. A constraint map is plain data (purpose -> boolean / Date); a caller
2736
+ // entry defined non-enumerable would otherwise be copied non-enumerable and then be invisible to the
2737
+ // Object.keys-based purpose-scoped-metadata gate, letting a restriction slip the "supply checkPurpose" check.
2738
+ intrinsic.defineProperty(out, keys[i], { value: v, enumerable: true, configurable: true, writable: true });
2739
+ }
2740
+ return out;
2741
+ }
2742
+
2743
+ // Normalize a captured constraint MAP field for storage. An absent map (null / undefined) carries no
2744
+ // restriction and passes through. A present map MUST be a plain object and is snapshotted; a present non-object
2745
+ // -- a primitive, a Buffer, or (refused inside the snapshot by the plain-prototype check) an array -- cannot hold
2746
+ // a purpose -> boolean/Date restriction, so it is refused rather than passed through as an unusable value that
2747
+ // would silently apply no restriction.
2748
+ function _normalizeConstraintField(v, E, code, who) {
2749
+ if (v === null || v === undefined) return v;
2750
+ if (typeof v !== "object" || intrinsic.isBuffer(v)) throw E(code, who + ": a trustAnchor constraint map must be a plain object");
2751
+ return _snapshotConstraintMap(v, E, code, who);
2752
+ }
2753
+
2754
+ // Capture a trust-anchor constraint MAP field (purposes / distrustAfter) WITHOUT invoking a getter: read its
2755
+ // OWN DATA descriptor. An accessor-backed map (a `get purposes()`) is caller code that could mutate the sibling
2756
+ // map when read, so it is refused; an INHERITED map is refused too (own-only capture), never silently dropped,
2757
+ // so a restriction is never lost. A truly absent field is no restriction (undefined).
2758
+ function _captureConstraintMap(entry, key, E, code, who) {
2759
+ var d = intrinsic.getOwnPropertyDescriptor(entry, key);
2760
+ if (d === undefined) {
2761
+ if (key in entry) throw E(code, who + ": a trustAnchor " + key + " must be an own data property, not inherited");
2762
+ return undefined;
2763
+ }
2764
+ if (!intrinsic.hasOwn(d, "value")) throw E(code, who + ": a trustAnchor " + key + " must be a data property, not an accessor");
2765
+ // A constraint map that is a Proxy is refused, the same way the top-level anchor is: its ownKeys / descriptor
2766
+ // traps could answer inconsistently -- an empty key list drops a restriction the caller attached, and an
2767
+ // isBuffer-answering trap could skip the snapshot entirely -- so the map cannot be trusted to describe itself.
2768
+ if (intrinsic.types.isProxy(d.value)) throw E(code, who + ": a trustAnchor " + key + " map must not be a Proxy");
2769
+ return d.value;
2770
+ }
2771
+
2772
+ // Read a field's OWN DATA value from its descriptor, WITHOUT invoking a getter -- an accessor or an inherited
2773
+ // field yields undefined. Used for the optional identity metadata (subjectDer / label / mozillaCaPolicy): a
2774
+ // getter there, invoked before the key is pinned, could overwrite the key Buffer or mutate the name, so it is
2775
+ // never invoked. This metadata does not enter a validation decision, so an accessor / inherited form is simply
2776
+ // not carried (unlike a constraint map, which is refused).
2777
+ function _captureOwnData(entry, key) {
2778
+ var d = intrinsic.getOwnPropertyDescriptor(entry, key);
2779
+ return (d && intrinsic.hasOwn(d, "value")) ? d.value : undefined;
2780
+ }
2781
+
2782
+ // A fresh copy of a distinguished-name attribute record { type, value, name }, so the normalized anchor does
2783
+ // not share it with the caller. The consumed fields -- type + value drive the RFC 5280 sec. 7.1 dnEqual
2784
+ // comparison, name is the optional registry label -- are read by PROPERTY ACCESS, which follows the prototype
2785
+ // chain and reads an accessor once. The anchor API deliberately accepts an inherited / accessor name.rdns
2786
+ // (Object.create(base), a prototype getter), so an inherited attribute value must be SNAPSHOT here, not
2787
+ // dropped; a field the caller did not supply is omitted.
2788
+ function _copyAtv(atv) {
2789
+ var out = {};
2790
+ var t = atv.type;
2791
+ if (t !== undefined) intrinsic.defineProperty(out, "type", { value: t, enumerable: true, configurable: true, writable: true });
2792
+ var v = atv.value;
2793
+ if (v !== undefined) intrinsic.defineProperty(out, "value", { value: v, enumerable: true, configurable: true, writable: true });
2794
+ var nm = atv.name;
2795
+ if (nm !== undefined) intrinsic.defineProperty(out, "name", { value: nm, enumerable: true, configurable: true, writable: true });
2796
+ return out;
2797
+ }
2798
+
2799
+ // DEEP-copy a caller's rdns so the normalized anchor's DN is independent of the caller's object. A single
2800
+ // arraySlice copies only the outer RDN sequence, leaving each nested RDN array and attribute record shared, so
2801
+ // a caller mutating name.rdns[i][j].value after normalization (or while an async validate awaits signature
2802
+ // verification) would change the issuer identity name chaining re-reads. Copy the outer array, each RDN array,
2803
+ // and each attribute record (its type/value are strings, so an own-data copy is enough) -- matching the
2804
+ // trust-store name copy (lib/trust.js _copyName).
2805
+ function _copyRdnsDeep(rdns) {
2806
+ if (!intrinsic.isArray(rdns)) return rdns;
2807
+ // Copy only OWN indexed elements and PRESERVE holes -- never read an inherited Array.prototype[i] slot. A
2808
+ // slice reads inherited indices (HasProperty walks the chain), so on a SPARSE rdns a polluted Array.prototype
2809
+ // slot would be materialized as an own element, filling a hole that guard.name.dnEqual explicitly rejects and
2810
+ // letting a malformed name pass chaining. Keeping the array sparse leaves the hole for the downstream guard.
2811
+ // Read own indices only (hasOwn) and CREATE each copied index with defineProperty, never a plain assignment:
2812
+ // both the read side (a slice walks the chain) and the write side (`out[i] = v` respects an inherited
2813
+ // non-writable data property or accessor -- it can throw, fail silently, or invoke an inherited setter) touch
2814
+ // the prototype, so a polluted Array.prototype could fill a hole or corrupt a dense rdns into a sparse one.
2815
+ // defineProperty is CreateDataProperty: it always makes an own data property regardless of the prototype.
2816
+ var out = [];
2817
+ out.length = rdns.length;
2818
+ for (var i = 0; i < rdns.length; i++) {
2819
+ if (!intrinsic.hasOwn(rdns, i)) continue; // leave a hole where the source has one
2820
+ var rdn = rdns[i], val;
2821
+ if (intrinsic.isArray(rdn)) {
2822
+ var rdnOut = [];
2823
+ rdnOut.length = rdn.length;
2824
+ for (var j = 0; j < rdn.length; j++) {
2825
+ if (!intrinsic.hasOwn(rdn, j)) continue; // preserve inner holes for the same reason
2826
+ var atv = (rdn[j] !== null && typeof rdn[j] === "object") ? _copyAtv(rdn[j]) : rdn[j];
2827
+ intrinsic.defineProperty(rdnOut, j, { value: atv, enumerable: true, configurable: true, writable: true });
2828
+ }
2829
+ val = rdnOut;
2830
+ } else { val = rdn; }
2831
+ intrinsic.defineProperty(out, i, { value: val, enumerable: true, configurable: true, writable: true });
2832
+ }
2833
+ return out;
2834
+ }
2835
+
2836
+ function toAnchor(entry, verb) {
2837
+ var who = verb || "build";
2838
+ // Decide tuple-vs-certificate by the discriminator fields, read under try/catch: a throwing name /
2839
+ // publicKey / algorithm accessor is a malformed anchor and must surface as the documented path/bad-input
2840
+ // entry refusal, not a raw exception leaked past the typed guards. `alg` and the raw publicKey are PINNED
2841
+ // to ONE read here and used throughout, so a stateful accessor that returns a value now and throws (or
2842
+ // returns a different value) on a later read cannot slip past this guard at the shape check or the snapshot.
2843
+ var isTuple = false, alg = null, pubRaw = null, nameRaw = null, pk = null, purposesSnap, distrustSnap;
2844
+ var subjectDerRaw, labelRaw, mozRaw;
2845
+ // A Proxy anchor is refused outright. Every field read below -- whether by property access or from an own
2846
+ // data descriptor -- is answered by the anchor's own reflection, and a Proxy's traps let the same read
2847
+ // return different values on different lookups or report a field absent while forwarding its siblings, so a
2848
+ // Proxy could hide a purposes / distrustAfter restriction the operator attached and validate against a
2849
+ // weaker anchor than the one supplied. A legitimate anchor is a plain tuple, a parsed certificate, or an
2850
+ // Object.create(base) inheriting from one -- none is a Proxy -- so refusing it here costs no real use and
2851
+ // removes the whole class of trap-driven anchor forgery. isProxy is an internal-slot check that runs no
2852
+ // caller code, so it sits outside the accessor try below and its message is not re-wrapped as an accessor throw.
2853
+ if (intrinsic.types.isProxy(entry)) {
2854
+ throw E("path/bad-input", who + ": a trustAnchor must not be a Proxy");
2855
+ }
2856
+ // A Proxy anywhere in the anchor's prototype CHAIN is refused too. entry may legitimately inherit its fields
2857
+ // (an Object.create(base) anchor is supported), and those inherited reads -- the `in` tests and the property
2858
+ // accesses below -- walk the chain, so a Proxy prototype would run its has / get traps: the has trap could
2859
+ // report purposes / distrustAfter absent while the get trap still supplies inherited name / publicKey /
2860
+ // algorithm, dropping the restriction and validating a weaker anchor. Walk the chain refusing any Proxy,
2861
+ // checking isProxy BEFORE reading its prototype so a Proxy never runs a getPrototypeOf trap here. A legitimate
2862
+ // anchor -- a tuple, a certificate, or an Object.create(base) over a plain object -- has a Proxy-free chain.
2863
+ if (entry !== null && typeof entry === "object") {
2864
+ var proto = intrinsic.getPrototypeOf(entry);
2865
+ while (proto !== null) {
2866
+ if (intrinsic.types.isProxy(proto)) throw E("path/bad-input", who + ": a trustAnchor must not inherit from a Proxy");
2867
+ proto = intrinsic.getPrototypeOf(proto);
2868
+ }
2869
+ }
2870
+ try {
2871
+ // Recognize a parsed CERTIFICATE first and route it to coerceCert below -- by PROVENANCE, not by reading
2872
+ // its fields. guard.parsed.isRecorded is a getter-free WeakMap lookup: a certificate from
2873
+ // pki.schema.x509.parse carries a provenance record, a hand-built tuple does not. A certificate does not
2874
+ // carry name / publicKey / algorithm as own properties, but the tuple discriminator reads those by
2875
+ // property access, which FOLLOWS the prototype chain -- so a polluted Object.prototype supplying those
2876
+ // three would otherwise misclassify a real certificate as a tuple and bind the INHERITED (attacker) key
2877
+ // instead of the certificate's own. Recognizing the certificate by its forge-proof provenance mark routes
2878
+ // it to coerceCert and never binds an inherited key. Crucially the check is a WeakMap lookup that invokes
2879
+ // NO caller getter -- so, unlike a structural probe that would read tbsBytes / subject / ... , it cannot run
2880
+ // a tuple-controlled accessor (e.g. a polluted Object.prototype.tbsBytes getter) before publicKey is pinned
2881
+ // below, which would otherwise defeat the key-pinning guarantee.
2882
+ if (entry && typeof entry === "object" && !Buffer.isBuffer(entry) && !guard.parsed.isRecorded(entry)) {
2883
+ // Not a recorded certificate (isRecorded is a getter-free WeakMap lookup), so treat entry as a tuple
2884
+ // candidate. entry is not a Proxy (refused at the door above), so its own reflection is truthful: a
2885
+ // getOwnPropertyDescriptor read reports its real own data, and an absent field is really absent rather
2886
+ // than a trap hiding a forwarded one. The discriminator fields (publicKey / name / algorithm) are read
2887
+ // by PROPERTY ACCESS (follows the prototype chain, so an Object.create(base) inherited anchor is
2888
+ // recognized), each exactly once, so a stateful own accessor answers each at most once. The prototype
2889
+ // chain is never WALKED (Buffer.isBuffer / isRecorded above invoke getPrototypeOf a bounded number of
2890
+ // times only), so a hostile getPrototypeOf cannot be driven into a loop.
2891
+ //
2892
+ // Snapshot the constraint maps FIRST -- before ANY discriminator field (publicKey / name / algorithm) is
2893
+ // read -- so no discriminator getter can flip a denied purpose to allowed or move a distrust cutoff
2894
+ // between the map's capture and validation. A getter for publicKey or name runs when that field is read;
2895
+ // capturing purposes / distrustAfter beforehand puts them out of its reach (the same protection the
2896
+ // ordering already gave against the later algorithm getter, now extended to the earlier ones). The
2897
+ // identity passthrough (subjectDer, label, mozillaCaPolicy -- the closed producer-derived set) is read
2898
+ // once each here too. entry is not a recorded certificate, so these reads do not touch a cert's accessors.
2899
+ var pRaw = _captureConstraintMap(entry, "purposes", E, "path/bad-input", who);
2900
+ purposesSnap = _normalizeConstraintField(pRaw, E, "path/bad-input", who);
2901
+ var dRaw = _captureConstraintMap(entry, "distrustAfter", E, "path/bad-input", who);
2902
+ distrustSnap = _normalizeConstraintField(dRaw, E, "path/bad-input", who);
2903
+ // The identity passthrough (subjectDer, label, mozillaCaPolicy) is captured from its OWN DATA descriptor
2904
+ // too, never by property access: an accessor identity field, read before the key is pinned below, could
2905
+ // overwrite the key Buffer or mutate the name, so its getter is never invoked. An accessor / inherited
2906
+ // identity field is not carried (it is non-validation metadata), not refused.
2907
+ subjectDerRaw = _captureOwnData(entry, "subjectDer");
2908
+ labelRaw = _captureOwnData(entry, "label");
2909
+ mozRaw = _captureOwnData(entry, "mozillaCaPolicy");
2910
+ // Every mutable, validation-relevant field above (the constraint maps and the identity metadata) was
2911
+ // captured from a descriptor WITHOUT invoking a getter, so the publicKey read below -- and the name /
2912
+ // algorithm reads after it -- cannot have mutated them. publicKey is read (property access, so an
2913
+ // inherited publicKey is supported) and pinned to a private Buffer IMMEDIATELY, before the name /
2914
+ // algorithm accessors run, so a later getter cannot overwrite the key Buffer after it is read but before
2915
+ // it is captured. A non-tuple leaves pk null / isTuple false and falls through to coerceCert below.
2916
+ pubRaw = entry.publicKey;
2917
+ if (guard.bytes.isByteSource(pubRaw)) {
2918
+ pk = guard.bytes.snapshot(pubRaw, PathError, "path/bad-input", who + ": trustAnchor publicKey");
2919
+ }
2920
+ nameRaw = entry.name;
2921
+ alg = entry.algorithm;
2922
+ isTuple = !!(nameRaw && pubRaw && alg);
2923
+ }
2924
+ } catch (e) {
2925
+ throw E("path/bad-input", who + ": a trustAnchor accessor threw", e);
2926
+ }
2927
+ if (isTuple) {
2928
+ // publicKey was read FIRST and pinned to `pk` in the discriminator above, before the algorithm and name
2929
+ // accessors ran, so no caller getter can have mutated the key Buffer between its read and its capture.
2930
+ // (A non-Buffer publicKey leaves pk null and is refused by the Buffer.isBuffer shape check below.)
2666
2931
  // A ready anchor tuple: validate the shape build + validate consume -- name.rdns
2667
2932
  // (name matching), publicKey bytes and the algorithm OID (the sec. 6.1.4 key
2668
2933
  // hand-off). trustAnchors is a caller option, so a malformed tuple is a config
2669
2934
  // error and fails closed at entry, not a downstream no-path / soft verdict.
2670
- if (!intrinsic.isArray(entry.name.rdns) || !Buffer.isBuffer(entry.publicKey) || typeof entry.algorithm !== "string") {
2671
- throw E("path/bad-input", "build: a trustAnchor tuple must be { name: { rdns: [...] }, publicKey: Buffer, algorithm: OID string }");
2935
+ // `algorithm` is the anchor key's own algorithm: an OID string, or the SubjectPublicKeyInfo
2936
+ // `algorithm` object a parsed certificate carries ({ oid, parameters }), which validate has always
2937
+ // accepted. Built-in verification derives the key algorithm from the (valid) publicKey SPKI and never
2938
+ // consults this value, so any algorithm that slips through -- an object with no `oid`, or an oid that
2939
+ // is not a real OID -- would validate the path on the SPKI alone and leave the very fail-open this
2940
+ // check exists to close. A number, an object without an oid, or another non-shape is a malformed tuple
2941
+ // and fails closed; a MISSING algorithm skips this branch and is caught below as not-a-certificate.
2942
+ // Materialize the anchor name to a fresh plain object carrying only its CONSUMED sub-fields: rdns (used
2943
+ // for DN matching -- read through the chain, so an inherited / Object.create(base) rdns is kept, then
2944
+ // DEEP-copied (the outer array, each RDN array, and each attribute record) so a later mutation of the
2945
+ // caller's nested RDN cannot change the issuer DN name chaining re-reads) and bytes (the DN DER a
2946
+ // cert-derived name carries, kept for a lossless round-trip; not consumed in a name-matching decision, so
2947
+ // it is held by reference). Each is read ONCE as DATA; the name object's own shape is never enumerated. path
2948
+ // validation re-reads
2949
+ // workingIssuerName.rdns during name chaining, so a stateful rdns accessor that answered the shape check
2950
+ // with one array must not hand chaining a different issuer DN; the single read stores that one value. A
2951
+ // throwing name / name.rdns accessor is a malformed anchor -> path/bad-input, not a raw throw.
2952
+ // Capture the two NESTED tuple fields -- algorithm.oid and name.rdns / name.bytes -- GETTER-FREE, from their
2953
+ // own data descriptors, never by property access. An ACCESSOR nested field runs caller code that could mutate
2954
+ // the sibling before it is captured: a name.rdns getter rewriting a wrong algorithm.oid to the SPKI's real one
2955
+ // past the mismatch refusal, or an algorithm.oid getter rewriting the anchor DN. Because BOTH directions
2956
+ // cross-mutate, no read ORDER is safe -- so an accessor nested field is refused and no getter ever runs. A
2957
+ // Proxy-hosted algorithm / name object is refused before its traps can answer. A legitimate anchor -- a tuple
2958
+ // with a plain { oid } algorithm and a plain { rdns, bytes } name, or a parsed certificate's own -- carries
2959
+ // plain data and is unaffected.
2960
+ var algStr;
2961
+ try {
2962
+ if (typeof alg === "string") {
2963
+ algStr = alg;
2964
+ } else if (alg !== null && typeof alg === "object") {
2965
+ if (intrinsic.types.isProxy(alg)) throw E("path/bad-input", who + ": a trustAnchor algorithm must not be a Proxy");
2966
+ var algOidD = intrinsic.getOwnPropertyDescriptor(alg, "oid");
2967
+ if (algOidD && !intrinsic.hasOwn(algOidD, "value")) throw E("path/bad-input", who + ": a trustAnchor algorithm.oid must be a data property, not an accessor");
2968
+ var algOid = algOidD ? algOidD.value : undefined;
2969
+ algStr = typeof algOid === "string" ? algOid : null;
2970
+ } else {
2971
+ algStr = null;
2972
+ }
2973
+ } catch (e) { throw E("path/bad-input", who + ": a trustAnchor algorithm.oid accessor threw", e); }
2974
+ var nameOut = {};
2975
+ try {
2976
+ var nsrc = (nameRaw !== null && typeof nameRaw === "object") ? nameRaw : null;
2977
+ if (nsrc !== null && intrinsic.types.isProxy(nsrc)) throw E("path/bad-input", who + ": a trustAnchor name must not be a Proxy");
2978
+ var rdnsD = nsrc ? intrinsic.getOwnPropertyDescriptor(nsrc, "rdns") : undefined;
2979
+ if (rdnsD && !intrinsic.hasOwn(rdnsD, "value")) throw E("path/bad-input", who + ": a trustAnchor name.rdns must be a data property, not an accessor");
2980
+ var rdnsVal = rdnsD ? rdnsD.value : undefined;
2981
+ if (rdnsVal !== undefined) {
2982
+ intrinsic.defineProperty(nameOut, "rdns", { value: _copyRdnsDeep(rdnsVal), enumerable: true, configurable: true, writable: true });
2983
+ }
2984
+ var bytesD = nsrc ? intrinsic.getOwnPropertyDescriptor(nsrc, "bytes") : undefined;
2985
+ if (bytesD && !intrinsic.hasOwn(bytesD, "value")) throw E("path/bad-input", who + ": a trustAnchor name.bytes must be a data property, not an accessor");
2986
+ var nBytes = bytesD ? bytesD.value : undefined;
2987
+ if (nBytes !== undefined) {
2988
+ intrinsic.defineProperty(nameOut, "bytes", { value: nBytes, enumerable: true, configurable: true, writable: true });
2989
+ }
2990
+ // Preserve the parsed Name's dn (the rendered "CN=..., O=..." string a parser emits, used to display /
2991
+ // identify the selected root), so a pki.trust anchor or a cert-derived name round-trips its full shape --
2992
+ // { rdns, dn, bytes } -- through normalization. Only a STRING dn is carried: identity never flows through
2993
+ // this display string (name chaining compares rdns / bytes DER under RFC 5280 sec. 7.1), so an object- or
2994
+ // otherwise non-string-valued dn -- which could run coercion code in a display consumer -- is dropped, not
2995
+ // carried. Captured getter-free like the others (an accessor dn could otherwise mutate a sibling).
2996
+ var dnD = nsrc ? intrinsic.getOwnPropertyDescriptor(nsrc, "dn") : undefined;
2997
+ if (dnD && !intrinsic.hasOwn(dnD, "value")) throw E("path/bad-input", who + ": a trustAnchor name.dn must be a data property, not an accessor");
2998
+ var nDn = dnD ? dnD.value : undefined;
2999
+ if (typeof nDn === "string") {
3000
+ intrinsic.defineProperty(nameOut, "dn", { value: nDn, enumerable: true, configurable: true, writable: true });
3001
+ }
3002
+ } catch (e) { throw E("path/bad-input", who + ": trustAnchor name could not be materialized", e); }
3003
+ // hasOwn-gate the read: nameOut is materialized here, so its rdns is either an OWN data property (set above
3004
+ // from the caller's own rdns) or absent. Testing the OWN property -- not a plain nameOut.rdns, which would
3005
+ // resolve a polluted Object.prototype.rdns on a name that supplied none (and could invoke an inherited
3006
+ // throwing getter) -- means a name with no own rdns is refused, never accepted on an inherited array.
3007
+ var nameHasRdns = intrinsic.hasOwn(nameOut, "rdns") && intrinsic.isArray(nameOut.rdns);
3008
+ if (!nameHasRdns || !Buffer.isBuffer(pubRaw) || algStr === null) {
3009
+ throw E("path/bad-input", who + ": a trustAnchor tuple must be { name: { rdns: [...] }, publicKey: Buffer, algorithm: an OID string or a SubjectPublicKeyInfo algorithm carrying an oid }");
3010
+ }
3011
+ // Hold the stated algorithm to a canonical dotted-decimal OID: a shape-valid but non-canonical value
3012
+ // ("not-an-oid", { oid: "garbage" }) would otherwise pass and never match the SPKI-derived algorithm.
3013
+ guard.identifier.assertCanonicalOid(algStr, E, "path/bad-input", who + ": trustAnchor algorithm " + algStr);
3014
+ // The declared algorithm is redundant with the self-describing publicKey SPKI, and built-in verification
3015
+ // reads the SPKI's own key algorithm, never this field. A canonical but WRONG OID (declaring one key
3016
+ // algorithm for a key that is actually another) would otherwise be accepted and the path validate on the
3017
+ // SPKI alone. Reject a declared OID that does not equal the SubjectPublicKeyInfo's key-algorithm OID.
3018
+ // (publicKey was pinned to `pk` at the top of this branch, before any caller getter could mutate it.)
3019
+ //
3020
+ // Walk the whole SubjectPublicKeyInfo through the shared PKIX schema, not just its first OID: a structure
3021
+ // that carries a decodable AlgorithmIdentifier but no key BIT STRING would otherwise pass this gate and
3022
+ // slip to a soft valid:false at key-import time, bypassing the promised path/bad-input entry refusal.
3023
+ var spkiAlg;
3024
+ try { spkiAlg = schema.walk(ANCHOR_SPKI_SCHEMA, asn1.decode(pk), NS).result.algorithm; }
3025
+ catch (e) { throw E("path/bad-input", who + ": trustAnchor publicKey is not a valid SubjectPublicKeyInfo", e); }
3026
+ if (spkiAlg.oid !== algStr) {
3027
+ throw E("path/bad-input", who + ": trustAnchor algorithm " + algStr + " does not match its publicKey key algorithm " + spkiAlg.oid);
2672
3028
  }
2673
- return entry;
3029
+ // Return a SELF-CONTAINED anchor: a fresh plain object carrying ONLY the closed, producer-derived anchor
3030
+ // field set -- name, publicKey, algorithm, parameters, purposes, distrustAfter, subjectDer, label,
3031
+ // mozillaCaPolicy -- the shape pki.trust.parseCertdata documents and path validation + trust dedup
3032
+ // consume. Every field was read exactly once above (or is derived from the validated SPKI / pinned key),
3033
+ // so a caller getter or Proxy trap runs at most once per field and no second read can disagree with the
3034
+ // first; the anchor's own shape is NEVER enumerated (no ownKeys / getOwnPropertyDescriptor / `in`) and the
3035
+ // prototype chain is never walked, so a Proxy cannot use an enumeration trap to mutate state and a hostile
3036
+ // getPrototypeOf cannot be driven into a loop (Buffer.isBuffer invokes it at most once). A field the caller
3037
+ // attached OUTSIDE this set is not carried -- an anchor has a DEFINED shape, not an arbitrary passthrough --
3038
+ // and `__proto__` is never assigned, so no caller field reaches Object.prototype's setter.
3039
+ // - name, purposes, distrustAfter: materialized above from ONE read each as DATA (an inherited
3040
+ // restriction is kept, since those reads followed the prototype chain). Storing DATA is load-bearing --
3041
+ // assertAnchorConstraints reads distrustAfter several times (a truthy gate, a hasOwn gate, then the
3042
+ // value) and _hasPurposeScopedMetadata / the purpose block read purposes at more than one site, so a
3043
+ // live accessor could answer those reads inconsistently and silently drop a distrust or purpose control.
3044
+ // - subjectDer, label, mozillaCaPolicy: the trust-store identity fields, read once each in the
3045
+ // discriminator, so a store's own entry round-trips onto build's result.trustAnchor.
3046
+ // - publicKey is the pinned copy; algorithm and parameters come from the validated SPKI, never a
3047
+ // caller-declared field. Seeding the working key from a string OID keeps the sec. 6.1.4 key-algorithm
3048
+ // comparison working (an object never equals a cert's keyAlg.oid); taking parameters from the key's own
3049
+ // AlgorithmIdentifier stops a declared value that disagrees with it (e.g. P-384 params on a P-256
3050
+ // anchor) from being promoted into workingPublicKeyParameters and inherited by a child that omits its
3051
+ // own, which would validate a chain RFC 5280 inheritance from the real params rejects.
3052
+ var flat = {};
3053
+ intrinsic.defineProperty(flat, "name", { value: nameOut, enumerable: true, configurable: true, writable: true });
3054
+ if (purposesSnap !== undefined) intrinsic.defineProperty(flat, "purposes", { value: purposesSnap, enumerable: true, configurable: true, writable: true });
3055
+ if (distrustSnap !== undefined) intrinsic.defineProperty(flat, "distrustAfter", { value: distrustSnap, enumerable: true, configurable: true, writable: true });
3056
+ if (subjectDerRaw !== undefined) intrinsic.defineProperty(flat, "subjectDer", { value: subjectDerRaw, enumerable: true, configurable: true, writable: true });
3057
+ if (labelRaw !== undefined) intrinsic.defineProperty(flat, "label", { value: labelRaw, enumerable: true, configurable: true, writable: true });
3058
+ if (mozRaw !== undefined) intrinsic.defineProperty(flat, "mozillaCaPolicy", { value: mozRaw, enumerable: true, configurable: true, writable: true });
3059
+ intrinsic.defineProperty(flat, "publicKey", { value: pk, enumerable: true, configurable: true, writable: true });
3060
+ intrinsic.defineProperty(flat, "algorithm", { value: spkiAlg.oid, enumerable: true, configurable: true, writable: true });
3061
+ intrinsic.defineProperty(flat, "parameters", { value: spkiAlg.parameters, enumerable: true, configurable: true, writable: true });
3062
+ return flat;
3063
+ }
3064
+ // A certificate anchor carries no per-purpose constraints: the certificate branch below returns { name,
3065
+ // publicKey, algorithm, ... } only. Per-purpose purposes / distrustAfter come from a trust-anchor TUPLE (what
3066
+ // pki.trust emits), never from properties attached to a parsed certificate -- attaching them there would
3067
+ // silently drop the restriction and validate a path the caller meant to forbid. Refuse rather than drop. The
3068
+ // `in` check catches an OWN or an INHERITED purposes / distrustAfter (a name reached through the prototype, e.g.
3069
+ // a polluted Object.prototype), matching how the tuple branch refuses an inherited constraint field; it invokes
3070
+ // no getter (an existence test), and entry is not a Proxy (refused at the door), so no trap runs. A normal
3071
+ // certificate has neither on its chain, so this only catches the footgun of hanging a constraint on a cert.
3072
+ if (entry && typeof entry === "object" && (("purposes" in entry) || ("distrustAfter" in entry))) {
3073
+ throw E("path/bad-input", who + ": a trustAnchor certificate must not carry purposes / distrustAfter -- put per-purpose constraints on a { name, publicKey, algorithm, purposes, distrustAfter } tuple (or a pki.trust anchor), not on a parsed certificate");
2674
3074
  }
2675
3075
  var cert;
2676
3076
  try { cert = coerceCert(entry); }
2677
- catch (e) { throw E("path/bad-input", "build: a trustAnchor entry must be a { name, publicKey, algorithm } tuple or a certificate", e); }
3077
+ catch (e) { throw E("path/bad-input", who + ": a trustAnchor entry must be a { name, publicKey, algorithm } tuple or a certificate", e); }
2678
3078
  var spki = cert.subjectPublicKeyInfo;
2679
3079
  return { name: cert.subject, publicKey: spki.bytes, algorithm: spki.algorithm.oid, parameters: spki.algorithm.parameters, subjectDer: cert.subject.bytes };
2680
3080
  }
2681
3081
 
3082
+ /**
3083
+ * @primitive pki.path.anchorFromCert
3084
+ * @signature pki.path.anchorFromCert(cert) -> { name, publicKey, algorithm, parameters, subjectDer }
3085
+ * @status stable
3086
+ * @spec RFC 5280 sec. 6.1.1
3087
+ *
3088
+ * Turn a parsed certificate into the trust-anchor tuple `pki.path.validate` (`opts.trustAnchor`) and
3089
+ * `pki.path.build` (`opts.trustAnchors`) consume, so a root can be pinned directly instead of
3090
+ * hand-building `{ name, publicKey, algorithm }`. The subject becomes the anchor name and its
3091
+ * SubjectPublicKeyInfo the anchor key and algorithm. A value that is neither a parsed certificate nor a
3092
+ * ready anchor tuple is refused with `path/bad-input`. `validate` and `build` already normalize a
3093
+ * certificate passed directly; this exposes the same conversion for a caller that wants the tuple.
3094
+ *
3095
+ * @example
3096
+ * var pair = await pki.key.generate("Ed25519");
3097
+ * var rootDer = await pki.x509.sign({ subject: "Root CA", subjectPublicKey: await pki.key.export(pair.publicKey),
3098
+ * notBefore: new Date(0), notAfter: new Date("2999-01-01") }, { key: pair.privateKey });
3099
+ * var anchor = pki.path.anchorFromCert(pki.schema.x509.parse(rootDer));
3100
+ * // anchor is { name, publicKey, algorithm, ... } -- pass it as opts.trustAnchor to pki.path.validate
3101
+ */
3102
+ function anchorFromCert(cert) { return toAnchor(cert, "anchorFromCert"); }
3103
+
2682
3104
  // RFC 4158 sec. 2.4.2 loop key: the (subject DN + subjectAltName + subject
2683
3105
  // public key) tuple, NOT the DN alone. DN-alone keying wrongly prunes a
2684
3106
  // legitimate distinct cross-cert (cross-certs share a DN) and misses a same-key
@@ -2976,7 +3398,7 @@ async function build(leaf, opts) {
2976
3398
  for (var pdk = 0; pdk < pool.length; pdk++) intrinsic.setAdd(poolDerKeys, certDerKey(pool[pdk]));
2977
3399
 
2978
3400
  if (!intrinsic.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");
2979
- var anchors = opts.trustAnchors.map(toAnchor);
3401
+ var anchors = opts.trustAnchors.map(function (a) { return toAnchor(a, "build"); });
2980
3402
 
2981
3403
  guard.time.assertValid(opts.time, E, "path/bad-input", "build: opts.time (the validity-window check date, forwarded to validate)");
2982
3404
  // The chain-length bound is tied to the EFFECTIVE maxPathCerts the interleaved
@@ -3214,6 +3636,7 @@ async function build(leaf, opts) {
3214
3636
  module.exports = {
3215
3637
  validate: validate,
3216
3638
  build: build,
3639
+ anchorFromCert: anchorFromCert,
3217
3640
  crlChecker: crlChecker,
3218
3641
  ocspChecker: ocspChecker,
3219
3642
  verifyOcspResponse: verifyOcspResponse,
package/lib/webcrypto.js CHANGED
@@ -254,7 +254,20 @@ var SECRET_KEY_NAMES = { "AES-GCM": true, "AES-CBC": true, "AES-CTR": true, "AES
254
254
  function CryptoKey(type, extractable, algorithm, usages, handle) {
255
255
  this.type = type;
256
256
  this.extractable = !!extractable;
257
- this.algorithm = algorithm;
257
+ // The algorithm is immutable both ways. The PROPERTY is defined non-writable and non-configurable so
258
+ // it cannot be REPLACED wholesale (`key.algorithm = { hash: "SHA-512" }`), and its value is a frozen
259
+ // COPY -- a caller's importKey params object is left untouched -- so its fields, the nested `hash` an
260
+ // RSA/HMAC signature reads among them, cannot be MUTATED. This engine reads key.algorithm.hash live
261
+ // when it signs, and a W3C CryptoKey's [[algorithm]] is immutable for exactly this reason: neither
262
+ // swapping the object nor a field of it (e.g. from a microtask during a signing await) can make the
263
+ // hash the signature is computed under differ from the one checked against a header algorithm.
264
+ var frozenAlg = algorithm;
265
+ if (frozenAlg && typeof frozenAlg === "object") {
266
+ frozenAlg = _assign({}, algorithm);
267
+ if (frozenAlg.hash && typeof frozenAlg.hash === "object") frozenAlg.hash = intrinsic.freeze(_assign({}, frozenAlg.hash));
268
+ frozenAlg = intrinsic.freeze(frozenAlg);
269
+ }
270
+ _defineProperty(this, "algorithm", { value: frozenAlg, enumerable: true });
258
271
  // The copy is what the key will be asked about for the rest of its life, so the method that makes
259
272
  // it is the captured one: a replaced `slice` hands back a list the caller still controls. The
260
273
  // list-ness is checked rather than left to the copy to discover -- `Array.prototype.slice` on a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/pki",
3
- "version": "0.5.27",
3
+ "version": "0.5.29",
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:3e85b1a0-56f6-459d-a525-da732c14e1d6",
5
+ "serialNumber": "urn:uuid:6fc4808e-4377-42ae-a5a4-080dbff3e1df",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-08-23T02:01:07.534Z",
8
+ "timestamp": "2026-08-24T18:48:42.954Z",
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.5.27",
22
+ "bom-ref": "@blamejs/pki@0.5.29",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.5.27",
25
+ "version": "0.5.29",
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.5.27",
29
+ "purl": "pkg:npm/%40blamejs/pki@0.5.29",
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.5.27",
57
+ "ref": "@blamejs/pki@0.5.29",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]