@blamejs/pki 0.8.53 → 0.8.55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,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.8.55 — 2026-10-06
8
+
9
+ Tell a caller when the path search stopped looking, rather than reporting the chain it had.
10
+
11
+ ### Added
12
+
13
+ - `pki.path.build` puts `depthLimited: true` on a `{ valid:false }` return when `opts.maxDepth` stopped an expansion that still had candidates waiting. The chain reported alongside it is the best one the search ASSEMBLED, not the best one the pool holds, so a caller treating `valid:false` as a verdict about the certificates should read the field and raise `maxDepth` instead. It is absent from a successful return and from a failing one the search explored to its end, including a chain whose last hop lands exactly on the cap with nothing left to expand: the claim is that the bound truncated the search, not that the search reached it.
14
+
15
+ ### Changed
16
+
17
+ - `pki.sigstore.verifyBundle` reports `sigstore/chain-incomplete` where it reported `sigstore/chain-invalid`, for a bundle whose Fulcio chain the path search stopped expanding at its depth bound. A rotated CA reissues under one subject name, so a bundle can carry a short branch that assembles and fails beside a long one that reaches an anchor past the bound; the short branch's failure was being published as the verdict on the bundle. The candidate budget already answered this way, and the two bounds now agree. Nothing about a chain that was explored to its end changed: a bundle whose assembled path genuinely fails validation is still `sigstore/chain-invalid`.
18
+ - A reached bound stays a bound rather than becoming a refusal: a bounded depth-first search visits equal-scoring candidates in the order they arrive, so a bundle large enough to exhaust the expansion ceiling can still depend on its own ordering. That is a property of bounding the search at all. Raise `maxDepth` and `maxCandidatesConsidered` for a pool whose shape needs more of the graph walked.
19
+ - `depthLimited` reports a candidate the cap kept the search from expanding, and nothing weaker. A candidate already on the chain is one the cycle check would skip at any depth, so it is not counted: counting it would report a truncated search where the search was exhausted, and turn a chain this toolkit refuses into one it claims it never finished reading. Two cases it does not report, both of which leave the older behavior in place rather than overstating: a chain whose last hop lands on the cap with nothing left to match, and a cap that also suppressed an Authority Information Access fetch for the certificate at that hop. The second needs the fetch's own URL eligibility answered to be certain a fetch was available, so it is left unclaimed rather than guessed at.
20
+
21
+ ### Fixed
22
+
23
+ - `pki.ct` and `pki.path.build` take their declared options through one shared snapshot instead of two copies of the same loop. Both read every declared name once, before anything derives from one, and both keep an omitted option OMITTED rather than recording it as a name holding `undefined`, which is what lets an absent `transport` still mean "open the connection yourself, under the pinned anchors". Behavior is unchanged at both verbs; what the shared door adds is that the next caller to copy an options bag by hand is caught before it ships.
24
+
25
+ ## v0.8.54 — 2026-10-06
26
+
27
+ Refuse the universal tags X.680 assigns to no type, and tell a caller which those are.
28
+
29
+ ### Added
30
+
31
+ - `pki.asn1.reservedUniversalTag(tagNumber)` answers whether X.680 reserves a universal tag number, which is the question `pki.asn1.decode` asks before it accepts an element. It returns `false` for every assigned type, including the seven this codec carries no reader for, so it is not a statement about what can be read: a caller deciding whether bytes are worth passing on gets the same answer the decoder will give. A value that is not a tag number answers `false` rather than throwing, as `pki.asn1.isPrintableString` and `pki.oid.isDottedDecimal` do of a value of the wrong type, and the type is checked before anything is compared, so `37.5` and `Infinity` are not read as reserved for sitting inside the addenda range and no `valueOf` of a caller's object is invoked inside the predicate.
32
+
33
+ ### Changed
34
+
35
+ - `pki.asn1.decode` refuses a universal tag 15 element and any universal tag from 37 up, with `asn1/reserved-tag` naming the row of X.680 Table 1 that reserves it. Every reader in the toolkit inherits the refusal, so a message carrying such an element is refused at its own verb: `pki.schema.parse` and `pki.schema.detectFormat` report `schema/bad-der` before any format detector reads the children, which matters because a child nothing refuses is a child a detector may count. `pki.inspect.asn1` still prints the header walk beside the refusal, so an operator can see what the bytes claimed. Nothing that was accepted before and is assigned by X.680 changed: the four long-form time types and the two internationalized-resource-identifier types at tags 31 to 36 decode as they did.
36
+ - `pki.asn1.encode` and `pki.asn1.encodeIdentifier` refuse the same tags, with the same `asn1/reserved-tag` code, rather than emitting bytes this toolkit's own decoder calls malformed. The class is read off the masked identifier bits as the decoder reads it, so a `classBits` carrying only the constructed bit is the universal class here too. Every other class is untouched, which is what makes the rule narrow enough to be safe: a context `[15]` is how an optional field is written across X.509 and CMS, and it encodes as it did. So do the assigned universal types at tags 31 to 36. The DER form restrictions are deliberately not enforced at this door: a constructed OCTET STRING or character string is a legitimate BER encoding that DER alone narrows to the primitive form, and this low-level encoder is how those bytes are minted for the BER reader that accepts them. A tag number that encodes nothing is a different matter from a form one set of encoding rules permits and another does not.
37
+ - `pki.asn1.encode` and `pki.asn1.encodeIdentifier` require `classBits` to be a number naming a class: `0x00` universal, `0x40` application, `0x80` context or `0xc0` private, with `0x20` constructed allowed alongside. Anything else is `asn1/bad-tag`, as a tag number of the wrong type already was. The low five bits of that argument were being written into a single-octet identifier, so a value carrying any of them emitted a tag other than the one passed: `encodeIdentifier(0x0e, false, 1)` wrote `0f`, universal 15. A value that was not a number was narrowed into a class instead of refused, so `"128"` and `0x100000080` both encoded as a context `[1]`. For every argument still accepted the bytes are unchanged. A tag number past the four-octet high-tag form now reports `asn1/tag-too-large` ahead of any reserved-tag refusal, which is the order `pki.asn1.decode` has always refused that input in.
38
+ - `pki.asn1.TAGS` is frozen. Assigning to it reached the encoders that compose an identifier from a tag number and the comparisons that test one against it, so a single assignment moved what a SEQUENCE is for every caller in the process. The numbers are X.680's; read them, and build a structure with a tag of your own through `pki.asn1.encode`.
39
+ - Eight places that reported a failure by reading `message` off whatever was thrown now describe the thrown value the way the rest of the toolkit does. `pki.cmc.build`, `pki.cmp` session and verification, and `pki.crmf` are the verbs affected, and only the text of a message changes, in the case where what was caught is not an error or carries no readable message: it is named by its type instead of by a fixed phrase, and a `message` that answers from an accessor is read once rather than twice, so the text reported is the text that was tested. A `message` accessor that throws no longer replaces the failure being reported with its own. The error codes are unchanged.
40
+ - A caught value whose `message` is the empty string is described by its type as well. It was being reported as the empty string it was, which left the sentence built around it ending at its colon with nothing after it. This reaches every message assembled that way, including the ones `pki.ocsp.httpRequest` and the revocation fetcher build. A message of only whitespace is still a message and is reported unchanged.
41
+
7
42
  ## v0.8.53 — 2026-10-06
8
43
 
9
44
  Put a copy of a message's algorithm identifiers where its signature or its MAC covers them, and refuse one whose copy disagrees.
package/README.md CHANGED
@@ -237,7 +237,7 @@ comment blocks, is at [pkijs.com](https://pkijs.com).
237
237
 
238
238
  | Namespace | What it does |
239
239
  |---|---|
240
- | `pki.asn1` | Strict, bounded DER codec. `decode` returns a zero-copy node tree, `build.*` emits canonical DER values, `read.*` are typed leaf readers. Plus `encode`, `TAGS`, OID content encode/decode, and the `isPrintableString` string predicate |
240
+ | `pki.asn1` | Strict, bounded DER codec. `decode` returns a zero-copy node tree, `build.*` emits canonical DER values, `read.*` are typed leaf readers. A universal tag X.680 Table 1 reserves and assigns to no type is refused as `asn1/reserved-tag`, and `reservedUniversalTag` answers which tags those are without attempting a decode. Plus `encode`, `TAGS`, OID content encode/decode, and the `isPrintableString` string predicate |
241
241
  | `pki.cbor` | Strict, bounded deterministic CBOR codec (RFC 8949). `decode` returns a zero-copy node tree, plus `read.*` typed leaf readers including the keyed lookup `read.mapGet` (text or COSE-label integer key, with the map's major type asserted in the accessor). Fail-closed on every non-canonical shape: indefinite length, non-minimal argument, unsorted or duplicate map keys, non-shortest float, trailing bytes. The `build.*` encoders hold an integer argument to being an integer rather than converting one: a string in any radix, a padded or empty string, and a boolean are each refused, so an unsupplied value cannot encode as zero |
242
242
  | `pki.oid` | Two-way OID ↔ name registry, seeded with RFC 5280 and the NIST PQC arcs — `name`, `byName`, `register`, `toArcs`/`fromArcs`, `toDER`/`fromDER`, and the `isDottedDecimal` string predicate |
243
243
  | `pki.webcrypto` | A W3C `SubtleCrypto` engine over `node:crypto` — `sign`/`verify`/`encrypt`/`decrypt`/`deriveBits`/`digest`/`generateKey`/`importKey`/`exportKey` across RSA, ECDSA, ECDH, Ed25519/Ed448, AES, HMAC, HKDF, PBKDF2 and SHA, plus post-quantum ML-DSA-44/65/87, SLH-DSA, and ML-KEM-512/768/1024 key generation with certificate and PKCS#8 import. The RFC 9935 seed / expandedKey / both private-key CHOICE is validated fail-closed, so an OpenSSL-legacy bare seed or an internally inconsistent key is rejected with a typed error. `encapsulateBits` and `decapsulateBits` are the ML-KEM key-establishment pair the CMS `KEMRecipientInfo` arm rides on, with the FIPS 203 §7.3 ciphertext-length check enforced here so a direct caller inherits it. Cross-implementation use is undefined in the specification, so a `CryptoKey` from a different WebCrypto implementation is refused here with a typed fault naming its origin; the `pki.*` verbs adopt such a key instead, whether it came from the platform or from a separately installed copy of this toolkit |
package/lib/asn1-der.js CHANGED
@@ -64,6 +64,49 @@ function _asn1Error(c, m) { return new Asn1Error(c, m); }
64
64
  function _oidErr(c, m) { return new OidError(c, m); }
65
65
 
66
66
 
67
+ /**
68
+ * @primitive pki.asn1.reservedUniversalTag
69
+ * @signature pki.asn1.reservedUniversalTag(tagNumber) -> boolean
70
+ * @since 0.8.54
71
+ * @status stable
72
+ * @spec X.680, ISO/IEC 8824-1
73
+ * @related pki.asn1.decode
74
+ *
75
+ * Whether X.680 reserves this universal tag number, which means no ASN.1 type is assigned to it and
76
+ * no DER encoding of any type uses it. Table 1 reserves three rows: tag 0 for the encoding rules,
77
+ * tag 15 for future editions of the Recommendation, and tag 37 upward for its addenda. `decode`
78
+ * refuses an element carrying any of them with `asn1/reserved-tag`, so this answers the same
79
+ * question ahead of the attempt.
80
+ *
81
+ * The question is not "does this codec have a reader for the tag", which is a smaller set:
82
+ * ObjectDescriptor (7), REAL (9), RELATIVE-OID (13), the time type (14), VideotexString (21),
83
+ * GraphicString (25) and GeneralString (27) are all assigned by X.680 and all arrive here as opaque
84
+ * values, and each is legitimate inside an `ANY`. This returns `false` for every one of them.
85
+ *
86
+ * @example
87
+ * pki.asn1.reservedUniversalTag(0); // -> true (the encoding rules)
88
+ * pki.asn1.reservedUniversalTag(15); // -> true (future editions)
89
+ * pki.asn1.reservedUniversalTag(37); // -> true (addenda)
90
+ * pki.asn1.reservedUniversalTag(9); // -> false (REAL, assigned, no reader here)
91
+ * pki.asn1.reservedUniversalTag(31); // -> false (DATE, assigned)
92
+ */
93
+ function reservedUniversalTag(tagNumber) {
94
+ /** @internal The type check comes FIRST, and a value that is not a tag number answers `false`
95
+ * rather than throwing, which is how the other public predicates here answer: `isPrintableString`
96
+ * and `oid.isDottedDecimal` both say "not that" of a value of the wrong type. A range test alone
97
+ * calls `37.5` and `Infinity` reserved, since both sit inside the addenda range while being no
98
+ * tag at all, and `tagNumber >= 37` against a caller's object runs that object's `valueOf`, so a
99
+ * comparison reached before this check runs caller code inside a predicate.
100
+ *
101
+ * Then all three rows the table reserves, tag 0 included: this answers what X.680 says, and it
102
+ * says tag 0 is reserved. The decode door keeps a separate branch for tag 0 ahead of the general
103
+ * one so its refusal can cite the end-of-contents encoding X.690 sec. 8.1.5 gives that octet
104
+ * pair, which is the useful thing to tell whoever sent it; the answer to "is it reserved" is the
105
+ * same from either place. */
106
+ if (typeof tagNumber !== "number" || !intrinsic.isInteger(tagNumber) || tagNumber < 0) return false;
107
+ return tagNumber === 0 || tagNumber === 15 || tagNumber >= 37;
108
+ }
109
+
67
110
  var UNIVERSAL_TYPES = intrinsic.assign(intrinsic.create(null), {
68
111
  BOOLEAN: { tag: 0x01, form: "primitive" },
69
112
  INTEGER: { tag: 0x02, form: "primitive" },
@@ -89,14 +132,21 @@ var UNIVERSAL_TYPES = intrinsic.assign(intrinsic.create(null), {
89
132
  BMP_STRING: { tag: 0x1e, form: "primitive" },
90
133
  });
91
134
 
135
+ /** @internal Frozen, because it is exported and every structure in the toolkit names its tags
136
+ * through it. Left writable, `pki.asn1.TAGS.SEQUENCE = 15` reached the encoders that compose an
137
+ * identifier from it and the guard that compares one against it, so one assignment by anything
138
+ * sharing the process moved what a SEQUENCE is for the whole process. The numbers are X.680's and
139
+ * no caller has a reason to change one. */
92
140
  var TAGS = intrinsic.create(null);
93
141
  Object.keys(UNIVERSAL_TYPES).forEach(function (k) { TAGS[k] = UNIVERSAL_TYPES[k].tag; });
142
+ intrinsic.freeze(TAGS);
94
143
 
95
144
  var CLASS_UNIVERSAL = 0x00;
96
145
  var CLASS_APPLICATION = 0x40;
97
146
  var CLASS_CONTEXT = 0x80;
98
147
  var CLASS_PRIVATE = 0xc0;
99
148
  var CONSTRUCTED_BIT = 0x20;
149
+ var CLASS_MASK = 0xc0;
100
150
 
101
151
  var CONSTRUCTED_ONLY_UNIVERSAL_TAGS = Object.create(null);
102
152
  Object.keys(UNIVERSAL_TYPES).forEach(function (k) {
@@ -185,7 +235,7 @@ function _decodeTLV(buf, start, limit, depth, maxDepth, ber, strDepth, ctr) {
185
235
  var p = start;
186
236
  if (p >= limit) throw new Asn1Error("asn1/truncated", "expected an identifier octet");
187
237
  var first = buf[p]; p += 1;
188
- var tagClassBits = first & 0xc0;
238
+ var tagClassBits = first & CLASS_MASK;
189
239
  var constructed = (first & CONSTRUCTED_BIT) !== 0;
190
240
  var tagNumber = first & 0x1f;
191
241
 
@@ -221,6 +271,12 @@ function _decodeTLV(buf, start, limit, depth, maxDepth, ber, strDepth, ctr) {
221
271
  "universal tag 0 is the reserved end-of-contents encoding (X.690 sec. 8.1.5) and is not an element");
222
272
  }
223
273
 
274
+ if (tagClassBits === CLASS_UNIVERSAL && reservedUniversalTag(tagNumber)) {
275
+ throw new Asn1Error("asn1/reserved-tag",
276
+ "universal tag " + tagNumber + " is reserved by X.680 Table 1 (" + (tagNumber === 15
277
+ ? "for future editions" : "for addenda") + ") and is not assigned to any type");
278
+ }
279
+
224
280
  if (tagClassBits === CLASS_UNIVERSAL && CONSTRUCTED_ONLY_UNIVERSAL_TAGS[tagNumber] && !constructed) {
225
281
  throw new Asn1Error("asn1/bad-tlv", "a universal constructed-only type (SEQUENCE/SET/EXTERNAL/EMBEDDED PDV/CHARACTER STRING) must be constructed");
226
282
  }
@@ -707,9 +763,13 @@ function encodeLength(n) {
707
763
  return Buffer.concat([Buffer.from([0x80 | bytes.length]), Buffer.from(bytes)]);
708
764
  }
709
765
 
766
+ /** @internal The identifier comes from the one function that composes one. Writing the octet here
767
+ * instead put a second author of an identifier in the file, and it answered differently: with
768
+ * `TAGS.SEQUENCE` reassigned to 15 this emitted `2f00`, which `decode` refuses, while every
769
+ * `build.*` helper routed through `encodeIdentifier` and refused it. */
710
770
  function sequenceTlv(node) {
711
771
  var content = node.content != null ? node.content : Buffer.concat((node.children || []).map(function (c) { return c.bytes; }));
712
- return Buffer.concat([Buffer.from([TAGS.SEQUENCE | CONSTRUCTED_BIT]), encodeLength(content.length), content]);
772
+ return Buffer.concat([encodeIdentifier(CLASS_UNIVERSAL, true, TAGS.SEQUENCE), encodeLength(content.length), content]);
713
773
  }
714
774
 
715
775
  /** @internal A constructed value written into ONE allocation, rather than concatenating the children
@@ -738,12 +798,43 @@ function encodeIdentifier(classBits, constructed, tagNumber) {
738
798
  if (typeof tagNumber !== "number" || !isFinite(tagNumber) || tagNumber < 0 || Math.floor(tagNumber) !== tagNumber) {
739
799
  throw new Asn1Error("asn1/bad-tag", "tag number must be a non-negative integer");
740
800
  }
741
- var lead = classBits | (constructed ? CONSTRUCTED_BIT : 0);
801
+ /** @internal The class bits are taken ONCE, into the number the checks and the emitted octet all
802
+ * use, and then checked to be a class. The low five bits of this argument are OR'd into the
803
+ * single-octet identifier, so a `classBits` carrying any of them emits a DIFFERENT tag than the
804
+ * one handed in: `encodeIdentifier(0x0e, false, 1)` wrote `0f`, universal 15, past a reserved-tag
805
+ * check that had been shown tag 1. The four class values are the whole domain, with the
806
+ * constructed bit allowed alongside because a caller may fold it in rather than pass it. */
807
+ var cls = classBits | 0;
808
+ if (typeof classBits !== "number" || cls !== classBits ||
809
+ (cls & ~(CLASS_MASK | CONSTRUCTED_BIT)) !== 0) {
810
+ throw new Asn1Error("asn1/bad-tag", "class bits must be one of 0x00 universal, 0x40 application, " +
811
+ "0x80 context or 0xc0 private, optionally with 0x20 constructed, got " + _typeOf(classBits));
812
+ }
813
+ /** @internal The size bound before the reserved rows, which is the order the decoder refuses them
814
+ * in: a tag past the four-octet high-tag form it reads is unreadable whatever the table says
815
+ * about the number, so both directions name `asn1/tag-too-large` for it and the reserved code
816
+ * keeps meaning that X.680 reserved the row. 0x0fffffff is the largest number four 7-bit octets
817
+ * hold, so this is the bound the body-length check below expressed. */
818
+ if (tagNumber > 0x0fffffff) throw new Asn1Error("asn1/tag-too-large", "high-tag-number too large");
819
+ /** @internal The reserved rows, refused in BOTH directions. X.680 Table 1 assigns no type to
820
+ * universal 0, 15, or 37 upward, so there is no encoding of any type for `decode` to accept and
821
+ * it refuses all three; emitting them anyway hands a caller bytes this codec's own decoder calls
822
+ * malformed. The DER FORM rules are deliberately NOT enforced here: a constructed OCTET STRING or
823
+ * string type is a legitimate BER encoding (X.690 sec. 8.21.1) that DER alone restricts to the
824
+ * primitive form (sec. 10.2), and this encoder is the escape hatch those bytes are minted through
825
+ * for the BER door that reads them. A tag number that encodes nothing is a different thing from a
826
+ * form one encoding rule allows and another does not. */
827
+ if ((cls & CLASS_MASK) === CLASS_UNIVERSAL && reservedUniversalTag(tagNumber)) {
828
+ throw new Asn1Error("asn1/reserved-tag", "universal tag " + tagNumber + " is reserved by X.680 " +
829
+ "Table 1 (" + (tagNumber === 0 ? "for the encoding rules, which give 00 00 to the " +
830
+ "end-of-contents marker" : tagNumber === 15 ? "for future editions" : "for addenda") +
831
+ ") and is not assigned to any type, so no encoding of it is a DER element");
832
+ }
833
+ var lead = cls | (constructed ? CONSTRUCTED_BIT : 0);
742
834
  if (tagNumber < 0x1f) return Buffer.from([lead | tagNumber]);
743
835
  var body = [];
744
836
  var v = tagNumber;
745
837
  do { _unshift(body, v & 0x7f); v = Math.floor(v / 128); } while (v > 0);
746
- if (body.length > 4) throw new Asn1Error("asn1/tag-too-large", "high-tag-number too large");
747
838
  for (var i = 0; i < body.length - 1; i++) body[i] |= 0x80;
748
839
  return Buffer.concat([Buffer.from([lead | 0x1f]), Buffer.from(body)]);
749
840
  }
@@ -760,6 +851,22 @@ function encodeIdentifier(classBits, constructed, tagNumber) {
760
851
  * buffer. Most callers use the higher-level `build.*` helpers; this is the
761
852
  * escape hatch for context-tagged and implicitly-tagged constructions.
762
853
  *
854
+ * `classBits` is a number naming a class: `0x00` universal, `0x40`
855
+ * application, `0x80` context or `0xc0` private, with `0x20` constructed
856
+ * allowed alongside. Anything else is `asn1/bad-tag`, because the low five
857
+ * bits of this argument are written into a single-octet identifier and a
858
+ * value carrying any of them emits a tag other than the one passed.
859
+ *
860
+ * A universal-class tag number that X.680 Table 1 reserves is refused with
861
+ * `asn1/reserved-tag`, the code `pki.asn1.decode` gives the same bytes, so the
862
+ * escape hatch cannot author an element the decoder calls malformed. Ask
863
+ * `pki.asn1.reservedUniversalTag` which numbers those are. Every other class
864
+ * takes any tag number the 4-octet high-tag form holds, a context `[15]`
865
+ * included; past that form the code is `asn1/tag-too-large`, as it is on the
866
+ * way in. The DER form restrictions are not applied here: a constructed
867
+ * OCTET STRING or character string is a legal BER encoding, and this is the
868
+ * door it is written through.
869
+ *
763
870
  * @example
764
871
  * pki.asn1.encode(0x00, false, pki.asn1.TAGS.NULL, Buffer.alloc(0));
765
872
  */
@@ -1059,6 +1166,7 @@ function _asBufferArray(arr, who) {
1059
1166
 
1060
1167
  module.exports = {
1061
1168
  TAGS: TAGS,
1169
+ reservedUniversalTag: reservedUniversalTag,
1062
1170
  decode: decode,
1063
1171
  isPrintableString: isPrintableString,
1064
1172
  encode: encodeTLV,
package/lib/cmc-build.js CHANGED
@@ -253,7 +253,7 @@ function encodeRequest(req, ids, index) {
253
253
  try { csr.parse(_der(req.tcr, "a tcr certificationRequest")); }
254
254
  catch (e) {
255
255
  throw E("cmc/bad-input", "a tcr request must be a PKCS#10 CertificationRequest: " +
256
- (e && e.message ? e.message : "it did not parse as one"), e);
256
+ guard.text.describeThrown(e), e);
257
257
  }
258
258
  var id = ids.claim(req.bodyPartID, "a tcr request");
259
259
  return { bodyPartID: id, der: b.contextConstructed(0, Buffer.concat([b.integer(BigInt(id)), _der(req.tcr, "a tcr certificationRequest")])) };
@@ -263,7 +263,7 @@ function encodeRequest(req, ids, index) {
263
263
  try { crmf.parse(b.sequence([msg])); }
264
264
  catch (e) {
265
265
  throw E("cmc/bad-input", "a crm request must be an RFC 4211 CertReqMsg: " +
266
- (e && e.message ? e.message : "it did not parse as one"), e);
266
+ guard.text.describeThrown(e), e);
267
267
  }
268
268
  var certReqId = ids.claim(_certReqIdOf(msg), "a crm request");
269
269
  var node = asn1.decode(msg);
@@ -947,7 +947,7 @@ async function _build(spec, signer, opts) {
947
947
  } catch (e) {
948
948
  throw E("cmc/bad-input",
949
949
  "the assembled PKIData does not parse as one, so it would be refused by its recipient: " +
950
- (e && e.message ? e.message : "malformed"), e);
950
+ guard.text.describeThrown(e), e);
951
951
  }
952
952
 
953
953
  /** @internal RFC 5272 sec. 3.2 gives a Full PKI Request two carriers, and a request uses one: a
@@ -971,8 +971,8 @@ function session(opts) {
971
971
  var res;
972
972
  try { res = await _engine.build(leaf, { trustAnchors: _asCertList(opts.trustAnchors), intermediates: pool, validate: true, time: opts.time != null ? opts.time : new Date() }); }
973
973
  catch (e) {
974
- if (e.code === "path/bad-input") throw _err("cmp/bad-input", "invalid trust / validation options for issued-certificate validation: " + (e.message || e), e);
975
- throw _err("cmp/bad-cert-response", "the issued certificate could not be validated to a supplied trust anchor: " + (e.message || e), e);
974
+ if (e.code === "path/bad-input") throw _err("cmp/bad-input", "invalid trust / validation options for issued-certificate validation: " + guard.text.describeThrown(e), e);
975
+ throw _err("cmp/bad-cert-response", "the issued certificate could not be validated to a supplied trust anchor: " + guard.text.describeThrown(e), e);
976
976
  }
977
977
  if (!res || res.valid !== true) throw _err("cmp/bad-cert-response", "the issued certificate did not validate to a supplied trust anchor (its signature or chain is invalid) (RFC 5280 sec. 6.1)");
978
978
  }
package/lib/cmp-verify.js CHANGED
@@ -671,9 +671,9 @@ async function _chainSigner(signer, m, opts, extra) {
671
671
  return guard.verdict.of({ trusted: true, reason: null, chain: chain });
672
672
  } catch (e) {
673
673
  if (e && e.code === "path/bad-input") {
674
- throw _err("cmp/bad-input", "invalid trust / validation options for signer-certificate path validation: " + (e.message || e), e);
674
+ throw _err("cmp/bad-input", "invalid trust / validation options for signer-certificate path validation: " + guard.text.describeThrown(e), e);
675
675
  }
676
- return guard.verdict.of({ trusted: false, reason: "signer certificate path validation failed: " + (e.message || e) });
676
+ return guard.verdict.of({ trusted: false, reason: "signer certificate path validation failed: " + guard.text.describeThrown(e) });
677
677
  }
678
678
  }
679
679
 
package/lib/crmf-sign.js CHANGED
@@ -943,7 +943,7 @@ async function _assertEnclosedKeyCorresponds(privateKeyDer, templateSpki) {
943
943
  * failing, not the input. */
944
944
  if (e && e.isPkiError === true && e.code === "key/bad-input") {
945
945
  throw _err("crmf/bad-input", "pop.privateKey could not be read as a private key to check against " +
946
- "certTemplate.publicKey: " + (e.message || e), e);
946
+ "certTemplate.publicKey: " + guard.text.describeThrown(e), e);
947
947
  }
948
948
  throw _err("crmf/bad-popo", "the private key enclosed in the encryptedKey proof of possession could not be " +
949
949
  "exercised with certTemplate.publicKey, so it cannot be shown to be that key's private half (RFC 4211 sec. 4.2)", e);
package/lib/ct.js CHANGED
@@ -1353,18 +1353,16 @@ function _clientDoor(opts, keyName) {
1353
1353
  }
1354
1354
 
1355
1355
  /** @internal Every DECLARED option the bag can answer, read once into a record of plain values, before
1356
- * anything derives from one. `in` is used rather than an enumeration because the door accepts an option
1357
- * supplied through a caller's defaults object, and an enumeration omits an inherited FUNCTION member,
1358
- * which silently dropped a `transport` and fell through to opening a real connection. It consults the
1359
- * prototype chain exactly as the direct reads it replaces did, so it carries no new exposure, and
1360
- * absence survives: a name the bag cannot answer is left out rather than materialized as `undefined`,
1361
- * which a guard that tells absent from present-and-undefined depends on. */
1356
+ * anything derives from one. The presence-preserving snapshot is the one this module needs: absence
1357
+ * survives, so a name the bag cannot answer is left out rather than materialized as `undefined`,
1358
+ * which is what lets an absent `transport` still mean "open the connection yourself, under the
1359
+ * pinned anchors". It consults the prototype chain exactly as the direct reads it replaces did,
1360
+ * because the door accepts an option supplied through a caller's defaults object and an enumeration
1361
+ * omits an inherited FUNCTION member, which silently dropped a `transport` and fell through to
1362
+ * opening a real connection. */
1363
+ // @guard-via guard.identifier.snapshotPresentOptions
1362
1364
  function _takeTopLevel(bag, known) {
1363
- var o = intrinsic.create(null);
1364
- intrinsic.forEach(intrinsic.keys(known), function (k) {
1365
- if (k in bag) o[k] = bag[k];
1366
- });
1367
- return o;
1365
+ return guard.identifier.snapshotPresentOptions(bag, known);
1368
1366
  }
1369
1367
 
1370
1368
  /** @internal The two values a verdict rests on, made IMMUNE rather than merely taken. Holding a
package/lib/guard-der.js CHANGED
@@ -8,23 +8,42 @@ var _forEach = require("./guard-intrinsic").forEach;
8
8
  var _sizeOf = require("./guard-intrinsic").sizeOf;
9
9
  var _create = require("./guard-intrinsic").create;
10
10
  var _isBuffer = require("./guard-intrinsic").isBuffer;
11
+ var _isArray = require("./guard-intrinsic").isArray;
11
12
 
12
13
  var asn1 = null;
13
14
  function _asn1() { if (asn1 === null) asn1 = require("./asn1-der"); return asn1; }
14
15
 
15
- var VALUE_READERS = null;
16
- function _readers() {
17
- if (VALUE_READERS !== null) return VALUE_READERS;
18
- var asn1 = _asn1();
19
- var m = _create(null), R = asn1.read, T = asn1.TAGS;
20
- m[T.BOOLEAN] = R.boolean; m[T.INTEGER] = R.integer; m[T.ENUMERATED] = R.enumerated;
21
- m[T.BIT_STRING] = R.bitString; m[T.OCTET_STRING] = R.octetString; m[T.NULL] = R.nullValue;
22
- m[T.OBJECT_IDENTIFIER] = R.oid; m[T.UTC_TIME] = R.time; m[T.GENERALIZED_TIME] = R.time;
23
- m[T.NUMERIC_STRING] = R.numericString;
16
+ /** @internal Everything this file needs from the codec, captured in ONE step on first use: the two
17
+ * functions it calls, the two tag NUMBERS it compares against, and the reader table. Read per call
18
+ * instead, each is a property of a module object anything sharing the process can assign to, and
19
+ * the verdict follows the assignment: `reservedUniversalTag` replaced by a function answering
20
+ * `true` made `tlv` refuse a valid NULL, and `TAGS.SEQUENCE` reassigned made both `element` and
21
+ * `tlv` refuse a valid empty SEQUENCE. The tag numbers are copied out rather than the table kept,
22
+ * because freezing a record holding a reference leaves what it points at writable. One step rather
23
+ * than two because two initialize at different first calls, and the gap between them was itself a
24
+ * window: warming the codec through a context node left the readers uncaptured, and `read.nullValue`
25
+ * replaced in between turned a NULL with content from refused into accepted. The capture is lazy
26
+ * because the codec requires this file, so neither can be required at the other's load. */
27
+ var CODEC = null;
28
+ function _codec() {
29
+ if (CODEC !== null) return CODEC;
30
+ var m = _asn1();
31
+ var R = m.read, T = m.TAGS;
32
+ var readers = _create(null);
33
+ readers[T.BOOLEAN] = R.boolean; readers[T.INTEGER] = R.integer; readers[T.ENUMERATED] = R.enumerated;
34
+ readers[T.BIT_STRING] = R.bitString; readers[T.OCTET_STRING] = R.octetString;
35
+ readers[T.NULL] = R.nullValue; readers[T.OBJECT_IDENTIFIER] = R.oid; readers[T.UTC_TIME] = R.time;
36
+ readers[T.GENERALIZED_TIME] = R.time; readers[T.NUMERIC_STRING] = R.numericString;
24
37
  _forEach([T.UTF8_STRING, T.PRINTABLE_STRING, T.IA5_STRING, T.TELETEX_STRING, T.VISIBLE_STRING,
25
- T.BMP_STRING, T.UNIVERSAL_STRING], function (t) { m[t] = R.string; });
26
- VALUE_READERS = m;
27
- return VALUE_READERS;
38
+ T.BMP_STRING, T.UNIVERSAL_STRING], function (t) { readers[t] = R.string; });
39
+ CODEC = _freeze({
40
+ decode: m.decode,
41
+ reservedUniversalTag: m.reservedUniversalTag,
42
+ SEQUENCE: T.SEQUENCE,
43
+ SET: T.SET,
44
+ readers: _freeze(readers),
45
+ });
46
+ return CODEC;
28
47
  }
29
48
 
30
49
  var TAG_CLASS_RANK = _create(null);
@@ -48,30 +67,69 @@ function setOrderOk(kids) {
48
67
  return dup ? octetAsc : (octetAsc || tagAsc);
49
68
  }
50
69
 
51
- // @enforced-by guard-shape-reinlined
52
- // @guard-shape _ANY_VALUE_READERS\[
53
- function element(node, E, code, label) {
54
- if (node.tagClass === "universal" && node.tagNumber === 0) {
70
+ // @enforced-by behavioral -- validating a universal element's content through the codec's own reader
71
+ // @enforced-by behavioral -- for its tag has no rename-proof shape: a re-inline builds a table keyed
72
+ // @enforced-by behavioral -- by a TAGS member under any local name, and an anchor on one name goes
73
+ // @enforced-by behavioral -- silently green on a rename. The RED vectors in guard-der.test.js are the
74
+ // @enforced-by behavioral -- guard, and every format parser's strict-DER vectors route through tlv.
75
+ function element(node, E, code, label) { _element(node, E, code, label); }
76
+
77
+ /** @internal Every field of the node is taken ONCE, into the plain record every rule below decides
78
+ * on, every reader is handed, and the SET-order check compares. Read per rule instead, a node whose
79
+ * `tagNumber` answers from a getter clears one check as the tag it is not and is then handled as the
80
+ * tag it is: a node answering 15 and then 5 was ACCEPTED, over four reads. A Proxy saw two reads of
81
+ * each field, the content validator was handed the caller's object rather than what was checked, and
82
+ * a SET member's getter answered the order comparison with a value the member's own validation never
83
+ * saw. The record is TYPED because each field is read as something: the tag becomes a property key at
84
+ * the reader lookup, which runs a `Symbol.toPrimitive` an object carries, and the form decides a
85
+ * branch a truthy non-boolean enters. `decode` answers every field with a plain value for every
86
+ * input it accepts, so this refuses only a node the codec did not produce. The node `tlv` passes
87
+ * holds plain values; what the record closes is the next caller, which hands this a node it decoded
88
+ * elsewhere or built itself. Returned so the caller's walk compares the members it validated. */
89
+ function _element(node, E, code, label) {
90
+ if (!node || typeof node !== "object") throw E(code, label + " must be a decoded DER node");
91
+ var s = {
92
+ tagClass: node.tagClass,
93
+ tagNumber: node.tagNumber,
94
+ constructed: node.constructed,
95
+ content: node.content,
96
+ bytes: node.bytes,
97
+ };
98
+ var kids = s.constructed === true ? node.children : null;
99
+ if (typeof s.tagClass !== "string" || typeof s.tagNumber !== "number" ||
100
+ typeof s.constructed !== "boolean" || (s.constructed && !_isArray(kids))) {
101
+ throw E(code, label + " must be a node this codec decoded, carrying a tag class, a tag number, " +
102
+ "a form, and members when it is constructed");
103
+ }
104
+ var cls = s.tagClass, tag = s.tagNumber, C = _codec();
105
+ if (cls === "universal" && tag === 0) {
55
106
  throw E(code, label + " must not use the reserved end-of-contents encoding (tag 0)");
56
107
  }
57
- var T = _asn1().TAGS;
58
- if (node.constructed) {
59
- if (node.tagClass === "universal" && node.tagNumber !== T.SEQUENCE && node.tagNumber !== T.SET) {
60
- throw E(code, label + " of universal constructed type " + node.tagNumber + " has no strict DER structure validator here");
108
+ /** @internal The other reserved rows, asked of the codec's own predicate so the two cannot
109
+ * disagree about which universal tags X.680 Table 1 assigns to no type. The codec door refuses
110
+ * these before a node exists, so this answers for a node that reached here by another route. */
111
+ if (cls === "universal" && C.reservedUniversalTag(tag)) {
112
+ throw E(code, label + " must not use universal tag " + tag +
113
+ ", which X.680 Table 1 reserves and assigns to no type");
114
+ }
115
+ if (s.constructed) {
116
+ if (cls === "universal" && tag !== C.SEQUENCE && tag !== C.SET) {
117
+ throw E(code, label + " of universal constructed type " + tag + " has no strict DER structure validator here");
61
118
  }
62
- var kids = node.children;
63
- for (var i = 0; i < kids.length; i++) element(kids[i], E, code, label);
64
- if (node.tagClass === "universal" && node.tagNumber === T.SET && !setOrderOk(kids)) {
119
+ var n = kids.length, members = [];
120
+ for (var i = 0; i < n; i++) members[i] = _element(kids[i], E, code, label);
121
+ if (cls === "universal" && tag === C.SET && !setOrderOk(members)) {
65
122
  throw E(code, label + " has a SET whose members are in no canonical DER order (X.690 sec. 11.6 / X.680 sec. 8.6)");
66
123
  }
67
- return;
124
+ return s;
68
125
  }
69
- if (node.tagClass === "universal") {
70
- var reader = _readers()[node.tagNumber];
71
- if (!reader) throw E(code, label + " of universal type " + node.tagNumber + " has no strict DER content validator here");
72
- try { reader(node); }
126
+ if (cls === "universal") {
127
+ var reader = C.readers[tag];
128
+ if (!reader) throw E(code, label + " of universal type " + tag + " has no strict DER content validator here");
129
+ try { reader(s); }
73
130
  catch (e) { throw E(code, label + " is not a valid DER element for its type", e); }
74
131
  }
132
+ return s;
75
133
  }
76
134
 
77
135
  // @enforced-by guard-shape-reinlined
@@ -79,7 +137,7 @@ function element(node, E, code, label) {
79
137
  function tlv(content, E, code, label) {
80
138
  if (!content || _sizeOf(content) === 0) throw E(code, label + " must be a non-empty DER element");
81
139
  var node;
82
- try { node = _asn1().decode(content); }
140
+ try { node = _codec().decode(content); }
83
141
  catch (e) { throw E(code, label + " must be exactly one well-formed DER element (no trailing data)", e); }
84
142
  element(node, E, code, label);
85
143
  return content;
@@ -115,8 +173,13 @@ function paramsAbsentOrNull(params) {
115
173
  */
116
174
  function sameAlgorithmExactly(a, c) {
117
175
  if (!a || !c || a.oid !== c.oid) return false;
118
- var ap = a.parameters === undefined ? null : a.parameters;
119
- var cp = c.parameters === undefined ? null : c.parameters;
176
+ /** @internal Each operand's `parameters` is read ONCE, into the value the absence test and the
177
+ * comparison share. Read twice, the first value decided which branch ran and the second was the
178
+ * one compared, so a getter answering a DER NULL and then `null` made an algorithm carrying
179
+ * parameters compare EQUAL to one carrying none. */
180
+ var araw = a.parameters, craw = c.parameters;
181
+ var ap = araw === undefined ? null : araw;
182
+ var cp = craw === undefined ? null : craw;
120
183
  if (ap === null || cp === null) return ap === cp;
121
184
  return _isBuffer(ap) && _isBuffer(cp) && _sizeOf(ap) === _sizeOf(cp) && _compare(ap, cp) === 0;
122
185
  }
@@ -171,7 +171,8 @@ function _rawError(E) {
171
171
  * characters passes a URL the rule was meant to refuse.
172
172
  *
173
173
  * @enforced-by guard-shape-reinlined
174
- * @guard-shape /charCodeAt\s*\(\s*[\w$]+\s*,\s*[\w$]+\s*\)\s*>\s*(?:0x7f|127)\b/ */
174
+ * @guard-shape charCodeAt\s*\(\s*[\w$]+\s*,\s*[\w$]+\s*\)\s*>\s*(?:0x7f|127)\b
175
+ */
175
176
  function isAsciiText(s) {
176
177
  if (typeof s !== "string") return false;
177
178
  for (var i = 0; i < s.length; i++) {
@@ -101,6 +101,28 @@ function snapshotOptions(obj, known) {
101
101
  return out;
102
102
  }
103
103
 
104
+ /** @internal The same one-read capture, for a caller that tells an ABSENT option from one present
105
+ * and holding `undefined`. `snapshotOptions` materializes every declared name, which is correct for
106
+ * a caller testing `!== undefined` and wrong for one testing presence: an omitted option becomes an
107
+ * own key, and a presence-based guard then sees an option the caller never passed. Measured, a bag
108
+ * omitting `transport` is ACCEPTED by `assertCallableOption` and REFUSED after `snapshotOptions`,
109
+ * because the materialized `undefined` is a transport that is not callable.
110
+ *
111
+ * Presence consults the prototype chain, because a caller supplying options through a defaults
112
+ * object is supplying them, and an enumeration omits an inherited FUNCTION member: that is how a
113
+ * copy once dropped a `transport` and fell through to opening a real connection where the caller
114
+ * had passed a fake one. The VALUE is still read once per name, which is what the snapshot is for.
115
+ * The two live side by side rather than behind a flag so a caller chooses between them knowingly. */
116
+ // @enforced-by guard-shape-reinlined
117
+ // @guard-shape if \(\w+ in \w+\) \w+\[\w+\] = \w+\[\w+\];
118
+ function snapshotPresentOptions(obj, known) {
119
+ var out = _create(null);
120
+ if (obj === null || obj === undefined) return out;
121
+ var o = _Object(obj);
122
+ _forEach(_getOwnPropertyNames(known), function (k) { if (k in o) out[k] = o[k]; });
123
+ return out;
124
+ }
125
+
104
126
  /** @internal The option names a bag carries, read the way assertKnownKeys read them: every own
105
127
  * string key, whatever its descriptor says. Object.keys reports only the enumerable ones, so an
106
128
  * option written with `enumerable: false` clears the door and then vanishes from the read.
@@ -634,7 +656,8 @@ function _showSwitch(value) {
634
656
  * the object, so an accessor further up the chain cannot answer either.
635
657
  *
636
658
  * @enforced-by guard-shape-reinlined
637
- * @guard-shape /\.\s*blocksPrivateAddresses\s*===\s*true/ */
659
+ * @guard-shape \.\s*blocksPrivateAddresses\s*===\s*true
660
+ */
638
661
  function declaresOwn(obj, name) {
639
662
  if (obj === null || obj === undefined) return false;
640
663
  var d;
@@ -655,6 +678,7 @@ module.exports = intrinsic.freeze({
655
678
  optionsObject: optionsObject,
656
679
  refuseAccessorFields: refuseAccessorFields,
657
680
  snapshotOptions: snapshotOptions,
681
+ snapshotPresentOptions: snapshotPresentOptions,
658
682
  optionNames: optionNames,
659
683
  ownOptions: ownOptions,
660
684
  pickSettings: pickSettings,
package/lib/guard-name.js CHANGED
@@ -198,6 +198,7 @@ function escapeControlBytes(str) {
198
198
  var DN_SPECIAL = intrinsic.assign(intrinsic.create(null), { 0x2c: 1, 0x2b: 1, 0x22: 1, 0x5c: 1, 0x3c: 1, 0x3e: 1, 0x3b: 1 });
199
199
 
200
200
  var _hexNibble = require("./guard-encoding").hexNibble;
201
+ var _isAsciiText = require("./guard-encoding").isAsciiText;
201
202
  function _isAttrTypeStart(c) { return (c >= 0x41 && c <= 0x5a) || (c >= 0x61 && c <= 0x7a); }
202
203
  function _isAttrTypeChar(c) { return _isAttrTypeStart(c) || (c >= 0x30 && c <= 0x39) || c === 0x2d; }
203
204
  function _isDigit(c) { return c >= 0x30 && c <= 0x39; }
@@ -335,10 +336,12 @@ function emailEqual(a, b) {
335
336
  return lowerAscii(aHost) === lowerAscii(bHost) ? "match" : "no-match";
336
337
  }
337
338
 
339
+ /** @internal An all-ASCII host, asked of the one place that scans for a byte above 0x7f, with the
340
+ * non-empty requirement kept here because a host part is not optional and `isAsciiText` answers
341
+ * `true` of the empty string. */
342
+ // @guard-via guard.encoding.isAsciiText
338
343
  function _asciiHost(h) {
339
- if (h.length === 0) return false;
340
- for (var i = 0; i < h.length; i++) if (_charCodeAt(h, i) > 0x7f) return false;
341
- return true;
344
+ return h.length > 0 && _isAsciiText(h);
342
345
  }
343
346
 
344
347
  // @enforced-by guard-shape-reinlined
package/lib/guard-text.js CHANGED
@@ -102,13 +102,17 @@ function keyOf(v) {
102
102
  * by its type, which is what `showValue` would have said about it anyway.
103
103
  *
104
104
  * @enforced-by guard-shape-reinlined
105
- * @guard-shape /([\w$]+)\s*&&\s*\1\.message\s*\?\s*\1\.message\s*:|([\w$]+)\.message\s*\|\|\s*\2\b/ */
105
+ * @guard-shape ([\w$]+)\s*&&\s*\1\.message\s*\?\s*\1\.message\s*:|([\w$]+)\.message\s*\|\|\s*\2\b
106
+ */
106
107
  function describeThrown(e) {
107
108
  var m;
108
109
  try { m = e === null || e === undefined ? undefined : e.message; }
109
110
  catch (_readFailed) { return _thrownFallback(e); }
110
111
  if (typeof m === "string" && m.length > 0) return m;
111
- if (m === undefined || m === null) return _thrownFallback(e);
112
+ /** @internal An EMPTY message answers nothing readable, so it is described by its type like any
113
+ * other unreadable value. Returned as the empty string it was, it left the sentence built around
114
+ * it ending at its colon with nothing after it, which tells a reader less than the type does. */
115
+ if (m === undefined || m === null || m === "") return _thrownFallback(e);
112
116
  try {
113
117
  if (typeof m === "symbol") return intrinsic.String(m);
114
118
  return "" + m;
@@ -3301,7 +3301,7 @@ async function _fetchAiaIssuers(current, aia) {
3301
3301
 
3302
3302
  /**
3303
3303
  * @primitive pki.path.build
3304
- * @signature pki.path.build(leaf, opts) -> Promise<{ valid, path, trustAnchor, result, candidatesConsidered, aiaFetches }>
3304
+ * @signature pki.path.build(leaf, opts) -> Promise<{ valid, path, trustAnchor, result, candidatesConsidered, aiaFetches, depthLimited? }>
3305
3305
  * @since 0.3.7
3306
3306
  * @status stable
3307
3307
  * @spec RFC 4158, RFC 5280
@@ -3347,7 +3347,12 @@ async function _fetchAiaIssuers(current, aia) {
3347
3347
  * `validate` consumes (anchor-proximal first, leaf last, the anchor excluded). Fail-closed: bad
3348
3348
  * options throw `path/bad-input`; no chain to any anchor throws `path/no-path`; chains that
3349
3349
  * assemble but none validate return `{ valid:false }` with the best failing `validate` result;
3350
- * the search bound throws `path/build-limit`. By default `build` is OFFLINE (zero network), so supply
3350
+ * the search bound throws `path/build-limit`. A `{ valid:false }` return carries
3351
+ * `depthLimited: true` when the depth cap stopped an expansion that still had candidates waiting,
3352
+ * which means the chain reported is the best one ASSEMBLED rather than the best one that exists: a
3353
+ * caller that treats `valid:false` as a verdict about the certificates should read that field and
3354
+ * raise `opts.maxDepth` instead. It is absent on a successful return and on a failing one the
3355
+ * search explored to its end. By default `build` is OFFLINE (zero network), so supply
3351
3356
  * intermediates in `opts.candidates`. Set `opts.fetchAia: true` to opt in to fetching a MISSING intermediate
3352
3357
  * from a certificate's Authority Information Access `caIssuers` URL (RFC 5280 sec. 4.2.2.1) over
3353
3358
  * `pki.transport`: the fetch triggers only on a pool miss, every fetched certificate is UNTRUSTED pool material
@@ -3511,14 +3516,24 @@ async function build(leaf, opts) {
3511
3516
 
3512
3517
  var BUILD_ONLY_OPT = { candidates: 1, intermediates: 1, trustAnchors: 1, maxDepth: 1, maxCandidatesConsidered: 1, validate: 1,
3513
3518
  fetchAia: 1, transport: 1, tls: 1, maxAiaFetches: 1, maxAiaPerCert: 1, aiaTimeout: 1, maxResponseBytes: 1 };
3514
- var forwarded = Object.create(null);
3519
+ /** @internal The names this door forwards by VALUE: every validate option that is not consumed by
3520
+ * the builder and not one of the switches typed above. Absence has to survive the copy, so the
3521
+ * presence-preserving snapshot is the one to take: an option the caller omitted stays omitted
3522
+ * rather than arriving at `validate` as a name holding `undefined`.
3523
+ * @guard-via guard.identifier.snapshotPresentOptions */
3524
+ var FORWARD_BY_VALUE = intrinsic.create(null);
3515
3525
  intrinsic.forEach(intrinsic.keys(_VALIDATE_OPTS), function (k) {
3516
- if (intrinsic.hasOwn(BUILD_ONLY_OPT, k)) return;
3517
- /** @internal A switch this door typed is forwarded as the value it typed. Reading it off the
3518
- * options object again would ask a second time, and normalizing an anchor between the two runs
3519
- * caller accessors, so the value `validate` is handed need not be the one that passed here. */
3520
- if (intrinsic.hasOwn(forwardedSwitches, k)) { forwarded[k] = forwardedSwitches[k]; return; }
3521
- if (k in opts) forwarded[k] = opts[k];
3526
+ if (intrinsic.hasOwn(BUILD_ONLY_OPT, k) || intrinsic.hasOwn(forwardedSwitches, k)) return;
3527
+ FORWARD_BY_VALUE[k] = 1;
3528
+ });
3529
+ var forwarded = guard.identifier.snapshotPresentOptions(opts, FORWARD_BY_VALUE);
3530
+ /** @internal A switch this door typed is forwarded as the value it typed, overlaid after the copy.
3531
+ * Reading it off the options object again would ask a second time, and normalizing an anchor
3532
+ * between the two runs caller accessors, so the value `validate` is handed need not be the one that
3533
+ * passed here. All four are validate options and none is build-only, so the overlay adds no name
3534
+ * the loop above would have skipped. */
3535
+ intrinsic.forEach(intrinsic.keys(forwardedSwitches), function (k) {
3536
+ forwarded[k] = forwardedSwitches[k];
3522
3537
  });
3523
3538
  if (buildPermRaw != null) forwarded.initialPermittedSubtrees = buildPermSeeds;
3524
3539
  if (buildExclRaw != null) forwarded.initialExcludedSubtrees = buildExclSeeds;
@@ -3533,6 +3548,10 @@ async function build(leaf, opts) {
3533
3548
  var considered = 0;
3534
3549
  var anyChainAssembled = false;
3535
3550
  var bestFail = null;
3551
+ /** @internal Whether the depth bound stopped an expansion that had candidates waiting. A reached
3552
+ * bound is "stopped looking", not "the chain is bad", and the two left this verb returning the
3553
+ * same `valid:false`. */
3554
+ var depthStopped = false;
3536
3555
  var success = null;
3537
3556
  _assertUserInitialPolicySet(opts.userInitialPolicySet, "build");
3538
3557
  var uipsDistinct = null, selectByPolicy = false;
@@ -3570,7 +3589,6 @@ async function build(leaf, opts) {
3570
3589
  guard.list.append(accepted, racc);
3571
3590
  } else if (!bestFail) bestFail = { path: rpath, trustAnchor: rframe.anchor, result: rres };
3572
3591
  }
3573
- if (rframe.hop >= maxDepth) continue;
3574
3592
  var rscored = [];
3575
3593
  for (var rpi = 0; rpi < pool.length; rpi++) {
3576
3594
  if (nameMatchSoft(pool[rpi].subject.rdns, rframe.anchor.name.rdns) &&
@@ -3579,6 +3597,13 @@ async function build(leaf, opts) {
3579
3597
  guard.list.append(rscored, { cand: pool[rpi], score: scoreCandidateReverse(pool[rpi], frontierCert, leafCert, opts.time) });
3580
3598
  }
3581
3599
  }
3600
+ /** @internal The anchor-first search answers the same question the forward one does, for the
3601
+ * same reason: candidates scored before the bound is consulted, so reaching it can say whether
3602
+ * it stopped anything. */
3603
+ if (rframe.hop >= maxDepth) {
3604
+ if (_anyExpandable(rscored, rframe)) depthStopped = true;
3605
+ continue;
3606
+ }
3582
3607
  considered += _pushCandidates(rframe, rscored, rstack, counter, true);
3583
3608
  }
3584
3609
  } else {
@@ -3635,7 +3660,7 @@ async function build(leaf, opts) {
3635
3660
  guard.list.append(accepted, acc);
3636
3661
  } else if (!bestFail) bestFail = { path: frame.chain.slice(), trustAnchor: anchors[ai], result: res };
3637
3662
  }
3638
- if (success || frame.hop >= maxDepth) continue;
3663
+ if (success) continue;
3639
3664
 
3640
3665
  var childAki = childAkiKeyId(current);
3641
3666
  var scored = [];
@@ -3645,6 +3670,16 @@ async function build(leaf, opts) {
3645
3670
  ineligible: _forwardPathLenIneligible(pool[pi], frame.chain) });
3646
3671
  }
3647
3672
  }
3673
+ /** @internal The candidates are scored BEFORE the depth bound is consulted, so reaching the
3674
+ * bound can say whether it stopped anything. A chain whose last hop lands on the bound with
3675
+ * nothing left to expand was explored to its end, and a chain with candidates waiting was
3676
+ * TRUNCATED: the two produce the same `valid:false` otherwise, and a caller reading that cannot
3677
+ * tell a refused chain from one the search never assembled. Nothing is pushed either way, so the
3678
+ * reorder costs one pool pass on a frame at the bound and changes no outcome. */
3679
+ if (frame.hop >= maxDepth) {
3680
+ if (_anyExpandable(scored, frame)) depthStopped = true;
3681
+ continue;
3682
+ }
3648
3683
  if (aiaCtx && frame.hop < maxDepth && aiaCtx.fetches < aiaCtx.maxFetches) {
3649
3684
  guard.list.append(deferredAia, { chain: frame.chain, hop: frame.hop, keys: frame.keys, poolMark: pool.length, seq: deferSeq, fetched: false });
3650
3685
  deferSeq += 1;
@@ -3666,11 +3701,29 @@ async function build(leaf, opts) {
3666
3701
  return guard.verdict.of({ path: success.path, trustAnchor: success.trustAnchor, candidatesConsidered: considered, aiaFetches: aiaFetches });
3667
3702
  }
3668
3703
  if (anyChainAssembled) {
3669
- return guard.verdict.of({ valid: false, path: bestFail.path, trustAnchor: bestFail.trustAnchor, result: bestFail.result, candidatesConsidered: considered, aiaFetches: aiaFetches });
3704
+ /** @internal `depthLimited` is carried only on the FAILING return, and only when the bound
3705
+ * stopped an expansion. On a success the bound hid nothing that mattered, and a caller reading
3706
+ * the field on every verdict would have to ask what it meant for a valid path. */
3707
+ var failed = { valid: false, path: bestFail.path, trustAnchor: bestFail.trustAnchor, result: bestFail.result, candidatesConsidered: considered, aiaFetches: aiaFetches };
3708
+ if (depthStopped) failed.depthLimited = true;
3709
+ return guard.verdict.of(failed);
3670
3710
  }
3671
3711
  throw E("path/no-path", "build: no certification path from the leaf to any configured trust anchor could be assembled");
3672
3712
  }
3673
3713
 
3714
+ /** @internal Whether raising the depth cap could reach anything through these candidates. A
3715
+ * candidate already on the chain is skipped by `_pushCandidates` on the identity key, so a deeper
3716
+ * search would skip it too: counting it reports a truncated search where the search is exhausted,
3717
+ * which turns a chain this toolkit refuses into one it says it never finished reading. The identity
3718
+ * key and the frame's visited set are the same two the push uses, so the two cannot disagree about
3719
+ * which candidate is reachable. */
3720
+ function _anyExpandable(scored, frame) {
3721
+ for (var i = 0; i < scored.length; i++) {
3722
+ if (!intrinsic.setHas(frame.keys, identityKey(scored[i].cand))) return true;
3723
+ }
3724
+ return false;
3725
+ }
3726
+
3674
3727
  /** @internal The fetching checker composes the two gates below, so it loads after this module
3675
3728
  * rather than beside it. The require sits in the call so the cycle resolves at first use, which is
3676
3729
  * the documented reason an inline require is permitted. */
package/lib/sigstore.js CHANGED
@@ -1417,8 +1417,11 @@ async function _verifyChain(leaf, chainDers, roots, timeMs) {
1417
1417
  * reported `chain-invalid`. `build` ranks candidates by the RFC 4158 sec. 3.5 heuristics, the key
1418
1418
  * identifier among them, EXCLUDING none, and bounds the search by total candidate expansions rather
1419
1419
  * than by how many paths it has collected, so a later candidate stays reachable and an exhausted
1420
- * budget is reported as `path/build-limit` rather than as a verdict about the chain. Every assembled
1421
- * path still flows through `pki.path.validate`, so this delegates the search and duplicates no check.
1420
+ * budget is reported as `path/build-limit` rather than as a verdict about the chain, and a depth bound
1421
+ * that stopped an expansion as `depthLimited` on the failing return. Both reach the incomplete code
1422
+ * below, because a bound the search reached says nothing about the certificates it never read. Every
1423
+ * assembled path still flows through `pki.path.validate`, so this delegates the search and duplicates
1424
+ * no check.
1422
1425
  * The anchors go into the pool as well as the trust store, because a cross-signed root appears in a
1423
1426
  * chain as an intermediate under another anchor. */
1424
1427
  var anchorTuples = _map(inWindow, function (r) {
@@ -1462,6 +1465,14 @@ async function _verifyChain(leaf, chainDers, roots, timeMs) {
1462
1465
  throw _err("sigstore/chain-invalid", "the Fulcio certificate chain failed validation: " + e.message, e);
1463
1466
  }
1464
1467
  if (!built.valid) {
1468
+ /** @internal The third outcome that means "stopped looking". `build` reports `depthLimited` when
1469
+ * its depth bound stopped an expansion that had candidates waiting, so the chain it hands back
1470
+ * is the best one it ASSEMBLED rather than the best one that exists. Reporting that as invalid
1471
+ * denies a bundle whose path the search never reached, which is the same denial the candidate
1472
+ * bound above avoids, so it answers with the same incomplete code. */
1473
+ if (built.depthLimited) {
1474
+ throw _err("sigstore/chain-incomplete", "no chain to a caller-supplied Fulcio anchor was established before the path search reached its depth bound");
1475
+ }
1465
1476
  throw _err("sigstore/chain-invalid", "the Fulcio certificate chain is not valid as of the log time");
1466
1477
  }
1467
1478
  /** @internal `path` is anchor-proximal first and leaf last with the anchor excluded, so the certificate
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/pki",
3
- "version": "0.8.53",
3
+ "version": "0.8.55",
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:f287baca-f745-4ecd-8265-d6953251f0b6",
5
+ "serialNumber": "urn:uuid:f7650379-6444-462e-bd4b-7cfbdc879bc7",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-10-06T00:56:34.786Z",
8
+ "timestamp": "2026-10-06T04:32:24.064Z",
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.8.53",
22
+ "bom-ref": "@blamejs/pki@0.8.55",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.8.53",
25
+ "version": "0.8.55",
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.8.53",
29
+ "purl": "pkg:npm/%40blamejs/pki@0.8.55",
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.8.53",
57
+ "ref": "@blamejs/pki@0.8.55",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]