@blamejs/pki 0.8.30 → 0.8.48

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.
Files changed (87) hide show
  1. package/CHANGELOG.md +411 -0
  2. package/README.md +67 -16
  3. package/bin/pki.js +716 -30
  4. package/examples/issue-a-certificate.js +90 -0
  5. package/examples/post-quantum.js +66 -0
  6. package/examples/read-a-certificate.js +56 -0
  7. package/examples/revoke-and-check.js +86 -0
  8. package/examples/sign-and-verify-cms.js +63 -0
  9. package/index.js +18 -5
  10. package/lib/acme.js +223 -74
  11. package/lib/alt-sig.js +441 -0
  12. package/lib/asn1-der.js +48 -25
  13. package/lib/attrcert-sign.js +27 -19
  14. package/lib/cbor-det.js +24 -14
  15. package/lib/cmc-build.js +9 -2
  16. package/lib/cmc-verify.js +14 -3
  17. package/lib/cmp-build.js +17 -5
  18. package/lib/cmp-session.js +24 -11
  19. package/lib/cmp-verify.js +28 -19
  20. package/lib/cms-compress.js +3 -1
  21. package/lib/cms-decrypt.js +102 -7
  22. package/lib/cms-encrypt.js +59 -7
  23. package/lib/cms-sign.js +31 -9
  24. package/lib/cms-verify.js +22 -18
  25. package/lib/composite-kem.js +30 -18
  26. package/lib/composite-sig.js +29 -14
  27. package/lib/constants.js +28 -0
  28. package/lib/crl-sign.js +111 -19
  29. package/lib/crmf-sign.js +167 -15
  30. package/lib/csr-sign.js +117 -6
  31. package/lib/ct.js +939 -48
  32. package/lib/est.js +29 -16
  33. package/lib/framework-error.js +12 -0
  34. package/lib/guard-async.js +1 -1
  35. package/lib/guard-bytes.js +103 -20
  36. package/lib/guard-crypto.js +205 -3
  37. package/lib/guard-encoding.js +6 -1
  38. package/lib/guard-identifier.js +55 -8
  39. package/lib/guard-intrinsic.js +43 -0
  40. package/lib/guard-json.js +122 -9
  41. package/lib/guard-limits.js +12 -1
  42. package/lib/guard-list.js +63 -8
  43. package/lib/guard-name.js +167 -20
  44. package/lib/guard-range.js +43 -5
  45. package/lib/guard-text.js +41 -0
  46. package/lib/guard-verdict.js +39 -8
  47. package/lib/hpke.js +688 -42
  48. package/lib/http-transport.js +156 -4
  49. package/lib/identity-match.js +4 -4
  50. package/lib/inspect.js +448 -2
  51. package/lib/jose.js +93 -25
  52. package/lib/key.js +18 -13
  53. package/lib/lint.js +165 -1
  54. package/lib/merkle.js +233 -27
  55. package/lib/ocsp.js +27 -14
  56. package/lib/oid.js +14 -4
  57. package/lib/path-validate.js +143 -61
  58. package/lib/pkcs12-build.js +7 -2
  59. package/lib/pki-build.js +31 -7
  60. package/lib/possession.js +575 -0
  61. package/lib/related-cert.js +708 -0
  62. package/lib/revocation-fetch.js +6 -6
  63. package/lib/scep.js +15 -5
  64. package/lib/schema-c509.js +5 -2
  65. package/lib/schema-cmc.js +1 -1
  66. package/lib/schema-cmp.js +8 -8
  67. package/lib/schema-cms.js +2 -15
  68. package/lib/schema-csr.js +69 -0
  69. package/lib/schema-engine.js +10 -3
  70. package/lib/schema-pkix.js +267 -4
  71. package/lib/schema-tsp.js +7 -6
  72. package/lib/sign-scheme.js +22 -9
  73. package/lib/sigstore.js +684 -168
  74. package/lib/sleep.js +8 -1
  75. package/lib/smime.js +22 -1
  76. package/lib/tlog.js +1235 -0
  77. package/lib/tls-cert-compress.js +25 -8
  78. package/lib/tsp-sign.js +120 -35
  79. package/lib/tuf.js +1395 -0
  80. package/lib/validator-tpm.js +4 -1
  81. package/lib/verify-seam.js +7 -1
  82. package/lib/webauthn-mds.js +41 -4
  83. package/lib/webauthn.js +43 -25
  84. package/lib/webcrypto.js +48 -46
  85. package/lib/x509-sign.js +225 -19
  86. package/package.json +2 -1
  87. package/sbom.cdx.json +6 -6
package/CHANGELOG.md CHANGED
@@ -4,6 +4,417 @@ 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.48 — 2026-10-05
8
+
9
+ Assemble a verification verdict out of component results through the operation the package captured at load, and refuse the reserved end-of-contents encoding where an element belongs.
10
+
11
+ ### Fixed
12
+
13
+ - `pki.crmf.verifyPop` assembled its verdict from the per-message results with `Promise.all` read from the global binding at the call. `Promise.all` builds its promise from its receiver, so a replacement whose `all` resolves an array without awaiting the component verifications hands its own values back as the verdict, and an unverified proof of possession was reported `verified: true`. The composite signature arm failed the same way: its verdict is `ok` only when both the ML-DSA and the traditional component resolve true, so an `all` resolving `[true, true]` reported a signature whose halves were never checked as verified, reachable through `pki.cms.verify` and `pki.path.validate` wherever a composite algorithm is in use. The same shape decided more than the aggregations. `pki.webauthn.verify` returned its refusals as rejected promises and folded its attestation trust-path checks over a seed built the same way, so a replacement whose `reject` resolves turned an unsupported attestation format, an attestation missing its attested credential data, and a failed policy check into a pass, while one settling the seed without awaiting the chain produced a verdict for a path that was never walked. `pki.cmc` gated its verify body on one of these and returned its refusals through another. `pki.ocsp.buildRequest` and `pki.ocsp.sign` awaited the pair of CertID hashes that bind a response to the certificate it answers for, and `pki.cms.sign` awaited each signer's digest and signature, so a replacement settling early emitted a structure whose SignerInfos were never completed. `pki.kem.encapsulate` and `pki.kem.decapsulate`, the composite ML-KEM pair, settled their two component results through `Promise.allSettled` before either shared secret was used or wiped, so a replacement reporting both components fulfilled with values of its own handed the combiner material it chose and skipped the wipe of the real secrets, which is the hybrid's whole premise that one broken component does not open the message. Each component refusal there was returned as a rejected promise too, so a `kem/bad-key` or `kem/decapsulation-failed` verdict could be turned into a pass. The `pki.acme` and `pki.est` clients gated each verb's body on one of these and returned their refusals through another, so a replacement could let a verb run against a refused request or turn an enrollment refusal into a pass, and the HTTP transport under all of them did the same, including `peerChain`'s refusal to tunnel through a proxy. The certificate-transparency log fetch was gated the same way. The transport also constructed the promise wrapping each request with `new Promise` off the global binding, which is the widest of these: a replacement need not run the executor at all and can settle with a value of its own, so every revocation, enrollment, directory and log response a caller awaited could be one nobody fetched. Both request paths and the backoff timer now construct through the captured constructor, the spelling the crypto engine and the async guard already used. All of these now call operations captured when the module loads, as the signing verbs already did for their own self-checks, and no module that takes those captures reads a replaceable promise operation at call time any more. Nothing about the emitted encodings changes, and OpenSSL validates the same bytes as before.
14
+ - `pki.asn1.decode` accepted universal tag 0 as an element, and every parser built on it did the same. X.690 section 8.1.5 defines that tag only as the first octet of the two-octet end-of-contents pair that closes an indefinite-length encoding, which DER does not permit, so no DER element carries it. A `00 00` pair where a member was expected decoded as an empty element of universal type 0 and was passed on to whatever read that position. The decoder now refuses it at the tag-length-value boundary with `asn1/reserved-tag`, under both the strict and the BER-tolerant read, and every format parser inherits that. A context-class or application-class tag number 0 is untouched, so `[0]` members are unaffected. The long-form tag indicator `0x1F` is untouched as well: `1F 1F` still reads as universal tag 31. The long-form spelling of tag number 0, `1F 00`, draws `asn1/non-minimal-tag` instead, because a tag number below 31 has to use the short form. `pki.inspect.asn1` continues to render a refused file as a header walk, and names the refusal that produced it.
15
+
16
+ ## v0.8.47 — 2026-10-05
17
+
18
+ Run every signature check through the operation the package captured at load, return the plaintext the recipient's own key unwraps, sign the content a caller passed rather than one substituted after it, and bound the work a TUF document can ask for before anything has authenticated it.
19
+
20
+ ### Fixed
21
+
22
+ - `pki.tuf.checkExpiry` read the `expires` beside the signed body rather than the one inside it, so the freeze-attack check and the signature check could be about different documents. A parsed object carries both, and only the body's is covered by the signature: metadata correctly signed with an expiry in 2020, with that copy moved to 2099, was reported `verified` and passed the expiry check at a 2026 instant, which is the defense answering about a date the document does not carry. The expiry now comes from `metadata.signed`, and a copy the object also states must agree with it, as must a stated `specVersion`; either contradicting the body is `tuf/bad-input`. `metadata.signed` is required as a result, and no field of the metadata object either verb reads may be an accessor: reading one is caller code running mid-verb, and with several fields read in sequence a getter on one could change the body while another was being read, so the verb answered about a document the object carried neither before nor after. A document `pki.tuf.parseMetadata` returned carries plain values, so a conforming caller sees no change; an object supplying one through a getter is `tuf/bad-input`. Every convenience copy the object carries beside the body is checked against it, not only `specVersion`: `type`, `version` and `expires` too, since a caller reads `version` for a rollback comparison and `type` to choose which role a document belongs to, and a copy contradicting the body describes a document the caller does not hold. A `signedBytes` the object carries is held to the body as well, so the two verbs agree about which objects they will answer for: the expiry check read the body while the bytes beside it encoded another document, and reported on it. The object must be a plain record for the same reason: a Proxy answers from a trap rather than from a property, so one handing over a supported body when asked while carrying an unsupported one otherwise had both verbs answer about a document it never held. Each signature record is held to the same rule, its fields being read inside the loop that verifies, and a record that is not a plain object with plain fields is `tuf/bad-metadata`.
23
+ - `pki.relatedCert.requestSignedData` accepted any SEQUENCE as the `certID` issuer name, so bytes no verifier can read back as an issuer were signed as whatever they are: `30 02 05 00` is a SEQUENCE carrying a NULL, and `pki.csr.sign` under `profile: "none"` then emitted a request this toolkit's own parser refuses with `csr/bad-rdn`. The name is read with the shared X.509 Name schema now, which is the reader on the other side, so what is signed is what a verifier can read. The refusal names the part that failed, `relatedcert/bad-rdn` for a member that is not a RelativeDistinguishedName and `relatedcert/bad-atv` for one whose attribute is not an AttributeTypeAndValue. An empty Name is refused as well: it reads back as a Name and identifies no issuer, and `pki.x509.sign` already refuses an empty issuer, so accepting it signed an identifier for a certificate this toolkit would not emit. An empty subject stays legal, a certificate being allowed to carry its identity in a subjectAltName.
24
+ - `pki keygen --out <key> --pub <spki>` wrote the private half first, so a `--pub` that already existed reported failure with a freshly generated private key sitting at `--out`, and the retry an operator would run next refused because `--out` existed. The public half is written first now, which secures its name before any private key reaches the disk. Each file is created by an exclusive open that refuses an existing one and carries the file mode, and is written through that descriptor rather than by reopening the path: a path reopened between the create and the write can be replaced, and the write would then follow a replacement symlink or truncate a replacement file. A run that does not complete removes the files it created and only those, so neither destination is left holding a reservation that blocks the corrected retry, and a file that was already there is never removed: refusing to write over one is the whole point of the refusal. A file whose size or timestamp changed after this run created it is left where it is and named, so a path replaced in between is not deleted by the tidy-up. Once the keys are on disk no exit path removes them, whatever the command then fails to report, and a file the command created but cannot remove is named rather than passed over in silence.
25
+ - A `pki` command whose stdout could not be written ended with the runtime's own unhandled-error stack trace rather than a `pki:` line, because a closed descriptor or a full pipe raises an event on the stream instead of throwing where the write was called. It also decided the exit code for work that had already finished: `pki keygen` wrote the key, could not print the line saying so, and exited non-zero with the key on disk, which is indistinguishable from the failure that leaves a secret behind. The product's location decides it now. A verb whose product is a file reports the broken stdout and still exits zero, the file being intact, which covers `pki keygen` and `pki csr`, `pki issue` and `pki fetch` writing to `--out`; a verb whose product is stdout still fails, and says so in one line naming the reason.
26
+ - `pki.tuf.updateRoot` adopted a root that states a role only for itself. The TUF specification section 4.3 requires one for each of `root`, `snapshot`, `targets` and `timestamp`, calling the `mirrors` role optional, and a root missing any of the four leaves a client holding a trust anchor that cannot authenticate the rest of the repository's metadata: there is no key list and no threshold to check a targets, snapshot or timestamp document against. All four are required now, each stating a `keyids` array and a `threshold` that is a positive integer, since a role record naming neither is present without being usable. A root missing one, or carrying an unusable record for one, is `tuf/bad-metadata`. A record is unusable when its `keyids` list is empty, holds anything that is not a key identifier string, or names an identifier the root's own `keys` map does not carry, and when its threshold asks for more signatures than the list names distinct keys: a verdict counts each identifier once, so a role naming one key twice for a threshold of two can never be met. The specification states the first two of those, calling a key identifier the hexdigest of a hash and a threshold a positive integer of at least one; the rest is this build refusing a record it could never use, and the refusals say which is which. Each identifier is recomputed from the key filed under it, because carrying the identifier is not the same as being that key: only the root role is verified during a rotation, so a targets, snapshot or timestamp role naming a key filed under an identifier that is not its own was adopted and failed later, where the client had already trusted the root. That is `tuf/bad-key`, the code the same condition already draws where a role is verified. Each key is also held to the checks the verify makes before it looks at a signature: the keytype and scheme are ones this build reads, the material imports, the declared type matches what imported, and an ed25519 point is on the curve. An identifier is a hash of a key's canonical form and says nothing about any of that, so a role could name a key filed under its own identifier that verifies nothing.
27
+ - `pki.tuf.verifySignatures` counted key identifiers toward a role's threshold, and an identifier is a hash of the whole key object, so one public key can hold two of them: adding a member the hash covers and the cryptography ignores, such as a comment, yields a second identifier for the same key. A role naming both with a threshold of two was then met by one key, the same signature bytes verifying under each entry, which is a threshold of two satisfied by one signer. A comment is one of five spellings: a PEM with CRLF line endings, a space before each newline, an EC point written compressed rather than uncompressed, and the `ecdsa-sha2-nistp256` keytype where another entry says `ecdsa` each give the same point a different identifier. The count is over the key's own coordinates now, taken from the imported key, so every spelling of one key collapses to one, and `pki.tuf.updateRoot` compares a role's threshold against the same count when it adopts a root. An exported SubjectPublicKeyInfo would not do: the runtime preserves a compressed point through an export, 59 bytes against 91, while the coordinates of the two are identical. The identifiers that counted are still what the verdict reports, two genuinely different keys still meet a threshold of two, and a key carrying nothing but its material is unaffected.
28
+ - `pki.hpke`'s hybrid KEMs combined their two shared secrets through a concatenation that reached `Buffer.concat` as a live property, and the combiner's digest likewise reached the hash prototype's own `update`. A replacement of either decides what the combiner covers: one answering the combiner's five-part input with a constant makes encapsulation and decapsulation derive the same publicly known value instead of binding `ssPQ` to `ssT`, so every HPKE key that follows is predictable and the ciphertext's confidentiality is gone. Both go through operations captured at load now, and the module's single concatenation helper carries that for all nine of its call sites, the key schedule and the KEM suite identifier among them.
29
+ - `pki.cms.encrypt` and `pki.cms.authenticate` assembled the argument list they fix with `Array.prototype.concat`, so a replacement of that method chose the list: one that kept the caller's recipients and options while putting other bytes in the content slot made `encrypt` encrypt those bytes and `authenticate` compute a valid MAC over them. The content is what both verbs exist to protect, and the list is written out as a literal now, which is not a dispatch and has nothing to replace. The same assembly decided three more values and each goes through the captured operation: the sub-identifier list `pki.asn1` encodes an OID from, which names the algorithm a signature is made under; the candidate chain the certification-path search carries in `pki.path.validate`; and the signed attribute set in `pki.tsp.sign`, whose first entry is the signing-certificate attribute binding the token to the certificate it names. `pki.cms.authenticate` computed the `messageDigest` attribute through the hash prototype's own `update` and `digest`, which are writable too, so a replacement chose what that digest covered and the message then carried a digest of other bytes under a valid MAC: `pki.cms.decrypt` refuses such a message, so what it cost was a document that does not verify against the content it holds. The digest is taken through operations captured at load, which is `guard.crypto.digest`.
30
+ - `pki.tlog.verifyNote` read the caller's key records before it copied the note, so a record supplying its `name` or `publicKey` through an accessor ran while the caller's buffer was still the caller's and could replace the note under verification: the verdict then reported on a document that was not the argument. Measured, a getter that copied a validly signed note over an invalidly signed one turned `tlog/bad-signature` into `verified`. The note is copied first, before any key record is read, which is the order `pki.tlog.verifyCheckpoint` already used. Reading each key once bounds what a key can state about itself and says nothing about what reading it can do to the note.
31
+ - `pki.relatedCert.verifyRequest` accepted a correctly signed proof however old its `requestTime` was, so a captured `relatedCertRequest` attribute copied into a new certification request for another key verified indefinitely. The proof covers the `certID` and the `requestTime` and nothing else, which is what makes the time the whole replay defense. RFC 9763 section 3.2 makes the check the certification authority's, "MUST check that the BinaryTime indicated in the requestTime field is sufficiently fresh", and the same clause leaves the window to local policy, so the verb now requires `opts.maxAge` in seconds rather than choosing a window: a call without one is refused with `relatedcert/no-freshness-policy`, because a verifier that cannot make a required check must not report success. `opts.at` names the instant judged against, for reading a request recorded earlier. A `requestTime` outside the window, or ahead of the instant, is `relatedcert/stale-request`. The time is read once, into the value both the check and the verified bytes use.
32
+ - `pki.tuf.parseMetadata` read a document whose `spec_version` names a specification version this build does not implement, or which names none at all, under this build's rules, and `pki.tuf.updateRoot` adopted such a root. The TUF specification section 4.3 has clients verify that the field matches the version they implement and leaves the definition of a match to the adopter; this build implements 1.x, so the major version is the match and a later 1.x minor is still read, while another major version is refused with `tuf/unsupported-spec-version`. The same clause states the field's format, "Its format follows the Semantic Versioning 2.0.0 (semver) specification", so the whole string is held to that grammar and a value that is not a version is malformed rather than unsupported: `1.`, `1.0`, `1.foo`, `1.0.0oops`, `1.0.0.0` and a value with a trailing space are each refused, while a pre-release or build part is read, `1.0.0-rc.1` and `1.2.3+0010` included. A document omitting the field is refused as malformed too, the field being mandatory. The clause covers metadata rather than root metadata, so the match is made at the parse, which every role comes through: `pki.tuf.verifySignatures` and `pki.tuf.checkExpiry` can no longer be handed a targets, snapshot or timestamp document written to another major version, where a key identifier is a hash of this version's canonical form and an expiry is read in the format this version states. It is made before `_type`, `version` and `expires` are read, each of those being a rule of this version too. `metadata.specVersion` carries the matched string, so it is never null. `pki.tuf.verifySignatures` and `pki.tuf.checkExpiry` take a metadata object rather than bytes, and both are reachable with one a caller assembled rather than one `pki.tuf.parseMetadata` produced, so both make the match too: without it, a document carrying a real signature and declaring `2.0.0` was reported `verified`, and its expiry check returned `true`. An object with no version at all is `tuf/bad-input` there, the caller's object being incomplete rather than a document malformed.
33
+ - `pki.tuf.verifySignatures` read the bytes it verifies and the version it reads them under from two places. A parsed document is the caller's object once it is returned, and `signedBytes` is a Buffer whose contents can be overwritten, so `specVersion` named the document that was parsed while `signedBytes` named the document that got verified. Measured: a root declaring `1.0.31` whose bytes were replaced with a validly signed `2.0.31` document was reported `verified` with the field still reading `1.0.31`, which is the attack `pki.tuf` already refuses for the `version` field. The document verified is now derived from one read of `metadata.signed`, and `metadata.signedBytes` must equal that canonical form, so the bytes the signatures are checked over are the ones the object states and the version is read from the same copy. A mismatch is `tuf/bad-input`. `metadata.signed` is required as a result, and a `metadata.specVersion` that contradicts the body it carries is refused rather than preferred.
34
+ - Metadata that omitted the field parsed before this release and is refused now; a TUF repository publishes it on every document.
35
+ - `pki fetch --der` wrote several concatenated DER certificates when the chain had more than one, which is not a DER document: `pki parse` and `pki convert` reject the file with `x509/bad-der`, so the output could not be read back by the tool that produced it. A chain of more than one certificate is refused under `--der` now, naming the PEM form, and a single certificate is written as it always was.
36
+ - `pki.ct.getRoots` decoded however many certificates a log's accepted-roots response named. The element count was bounded only where an entry has a fixed width, which the proof verbs' entries do and the roots response's do not, so keying the bound on that width left this one caller outside it. A configured log is trusted for its signatures rather than for its resource use, and a response body well inside the transport's size cap carries about a million empty strings, each of which became its own buffer. The count is now bounded by `C.LIMITS.CT_MAX_ROOTS`, far above the few hundred roots a public log lists, and an oversized response is refused with `ct/bad-roots` before any entry is decoded. The proof verbs keep the bound they had.
37
+ - `pki.cms.decrypt` and `pki.smime.decrypt` refuse a message that uses RSAES-PKCS1-v1_5 key transport over a content with no integrity tag, such as AES-CBC, unless the caller passes `allowUnauthenticatedRsa15`. That combination offers no way to be both correct and safe: the content padding is its only integrity check, so either a recipient whose key unwrap failed can pass it and the verb returns the plaintext of a substituted key, or it cannot and acceptance then tells an attacker submitting chosen ciphertexts that theirs decoded, which is the Bleichenbacher distinction RSAES-PKCS1-v1_5 is vulnerable to (RFC 8017 sec. 7.2). Measured against an `openssl cms -encrypt -aes-256-cbc` message, 256 chosen ciphertexts per arm: one acceptance where the unwrap conformed against none where it did not. The refusal is made before the unwrap, so under the default nothing in the verdict depends on the unwrap at all, measured at zero acceptances on both arms. `openssl cms -encrypt` and `openssl smime -encrypt` emit exactly this combination when no algorithm is named, so reading one of their messages needs the option; the toolkit's own `pki.cms.encrypt` uses RSAES-OAEP with AES-GCM and is unaffected, and an AEAD content or an OAEP recipient needs no option because each carries its own integrity check. Re-encrypting to `aes-256-gcm` removes the question.
38
+ - `pki.tlog.verifyNote` and `pki.tlog.verifyCheckpoint` could accept a note carrying a signature that was never checked. A note separates its text from its signature lines with a blank line and ends with a newline, so splitting the signature half leaves one empty element after the last line, and the parser dropped it by calling `Array.prototype.pop`. That is an ordinary writable property of the array prototype: a replacement that pops twice takes the final signature line away with the empty element, so a note whose last line is a forged signature under a key the caller supplied verified instead of drawing `tlog/bad-signature`. The drop now goes through a captured operation, as the rest of the module's reads already did, and every signature line reaches the check whatever the prototype holds. The same applies to every other place in the toolkit that moved or reordered an element of a list through a live prototype method: the certification-path search stacks in `pki.path.validate`, the nonce pool in the `pki.acme` client, the DER length and OID encoders in `pki.asn1`, the signer-set search in `pki.tsp`, the trust-path ordering in `pki.webauthn` and its metadata chain walk, and the tile-path reader in `pki.tlog`.
39
+ - `pki.tuf.parseMetadata` and `pki.tuf.checkExpiry` read the metadata `expires` field with the runtime's own date parser, which accepts far more than the format the TUF specification states and, for an impossible date, rolls it forward rather than refusing it. Metadata expiring on `2026-02-30` was therefore read as expiring on March 2 and treated as usable for two days it does not state, and a date with no time or a time with no zone was accepted, where the instant then depends on the reader's timezone rather than on the document. The specification section 4.2.3 fixes the format as `YYYY-MM-DDTHH:MM:SSZ` in UTC, and the field is now held to it through the same RFC 3339 reader the rest of the toolkit uses, calendar validity and leap years included: February 29 is accepted in a leap year and refused outside one. A zero numeric offset is accepted alongside `Z`, both denoting the same instant.
40
+ - A value that holds the same subobject in more than one place could exhaust the process. Both the deep copy every verb takes of a caller's object and `pki.tuf.canonicalJson` walk each reference independently, so such a value is a graph rather than a tree and expands once per reference, while the only bound either had was on nesting DEPTH. Measured, an array built by nesting the same array in itself twenty-five times, twenty-five arrays in all, made `pki.tuf.canonicalJson` end the process with a fatal out-of-memory on a 2 GiB heap, and `pki.jose.thumbprint` and `pki.tuf.keyId` spend six seconds and doubling on the same value placed in a member beside the ones they read. Both walks now charge a shared budget and refuse before accumulating: the canonical form is bounded by the same 1 MiB a TUF document is read under, and a copy by the number of values it visits, `C.LIMITS.DEEP_COPY_MAX_NODES`. The worst case is a constant rather than a function of the input, measured at about 400 milliseconds for a copy and milliseconds for the canonical form, and the inputs an operator has are well inside both: a thousand-record trust-anchor list copies in 54 milliseconds. The refusals are `tuf/too-large` and each verb's own bad-input code.
41
+ - `pki.relatedCert.verifyRequest` refused a conforming request whose issuer name is encoded differently from the certificate's. It compared the `IssuerAndSerialNumber` the request carries against the certificate's by their DER bytes, so a certificate holding its issuer common name as a `PrintableString` and a request naming the same common name as a `UTF8String` were read as different certificates and the proof was rejected with `relatedcert/cert-mismatch`. RFC 9763 sec. 3.2 has the certification authority compare the value rather than the encoding, and the field is a `Name`, so RFC 5280 sec. 7.1 governs: the serial numbers are compared as integers and the issuer under the same distinguished-name rules the rest of the toolkit uses. A different issuer name is still refused. The bytes the proof signature covers are unchanged, being the request's own as sec. 3.1 states, so only the identity comparison folds encodings.
42
+ - `pki.altSig.subjectAltPublicKey` returned an issuer alternative key read out of a CRL. The `subjectAltPublicKeyInfo` extension is certificate-only, and the verb documents that a CRL is refused, but the refusal came from the extension being absent rather than from the structure being the wrong kind: a CRL carrying one, which the pre-encoded extensions array admits under `profile: "none"`, had it decoded with the certificate decoder and its key handed back. A key is an identity, so which structure it was read from decides what it means. The kind is checked before any extension is read now, and which kinds may carry the extension is a field on the structure's own registry row rather than a position in a list.
43
+ - `pki.merkle.root`, `pki.merkle.inclusionProof` and `pki.merkle.consistencyProof` held each leaf as a view of the caller's own buffer rather than a copy, and folded them in a later pass. Leaves supplied through accessors that hand out one reused buffer therefore all pointed at the same bytes by the time the fold ran, so collecting the second leaf overwrote the first: measured, the root of two distinct leaves came out as the root of the second leaf twice, with nothing reporting it. Each leaf, proof node and supplied hash is copied as it is read now, so every byte these verbs fold, hash or compare is their own. A leaf is 32 bytes, so the copy costs nothing measurable.
44
+ - `pki.merkle` assembled its proofs through the live array prototype, so a replacement installed after the module loaded chose which nodes a proof carried and in what order: one returning its receiver made `pki.merkle.inclusionProof` return an empty proof for a two-leaf tree. The module now takes every operation it decides with at load, as the rest of the toolkit does, which also brings it inside the gate that keeps it that way.
45
+ - `pki.merkle.root`, `pki.merkle.inclusionProof` and `pki.merkle.consistencyProof` could commit to a tree the caller never supplied. Each checked the leaf array's length against the leaf cap and then walked it with a loop that read the length again on every iteration, so a leaf supplied through an accessor that shortens the array left the cap approving four leaves and the walk collecting one: the root returned was the first leaf's, and both proof producers folded the same short tree. The count is now read once, before any leaf is touched, through the same helper that already read each leaf once, and an array that shrinks under the walk is refused rather than folded short. Reading it twice did not need a leaf accessor at all: `Array.isArray` is true for a `Proxy` whose target is an array, and such a proxy answers `length` afresh on each read, so measured, a cap that saw zero leaves passed while the fold built a one-leaf tree. Whichever count a caller's object reports is now the one the cap judges and the one the fold uses. The verifiers took a proof array through the same shape and were never exposed by it, a proof collected short folding to a root that does not match the one supplied, and they go through the helper too.
46
+ - `pki.x509.sign` ignored an `issuer.altPublicKey` supplied without an `issuer.altKey` beside it, emitting an ordinary certificate with no alternative signature and reporting success. An alternative signature needs both halves, the private key to sign with and the public half to resolve the algorithm from, and a configuration assembled a field at a time is where one of them goes missing. The pair is now refused with `x509/bad-input` naming the missing field, which is what `pki.crl.sign` already did for the same pair. The reverse pairing, an `altKey` with no `altPublicKey`, was already refused and is unchanged.
47
+ - `pki.cms.decrypt` could return the wrong plaintext for a recipient whose key unwrap had already failed. On an RSA PKCS#1 v1.5 fault the toolkit substitutes a fresh random content-encryption key and proceeds, so that the failure costs the same work and the same time as a success and no recipient, padding or structure oracle is left. The content decrypt then decided whether that candidate was the right one, and a random key leaves a final CBC block that is valid PKCS#7 padding about one time in 256, so a known-bad recipient won that often: measured 2 of 400 streamed decrypts of a two-recipient AES-256-CBC message returned the wrong plaintext and named the rejected recipient as the one used. A candidate the substitute stood in for is now never the answer, measured 400 of 400 correct on both the buffered and the streamed path. The substitute keeps doing its job: the decrypt still runs, and on the streamed path the chunks are still delivered and the failure still arrives on consumption, so the error code, the stage and the number of bytes delivered are the same as for any other wrong key, measured identical over 700 draws. Padding is the only check a non-AEAD content offers, which is why AES-GCM is the encrypt default: it rejects a wrong content key on the tag.
48
+ - Seven verbs that take content and a description of what to do with it held the content as a view of the caller's own buffer, and read the description afterwards. Reading it is caller code when a field is supplied through an accessor, so it runs between the content being accepted and being used, and over a view it replaced what the verb went on to sign, encrypt, authenticate or compress. `pki.cms.encrypt` encrypted substituted plaintext under both the CBC and the AEAD content algorithms; `pki.cms.authenticate` digested, MACed and embedded substituted content; `pki.smime.sign`, `pki.smime.encrypt` and `pki.smime.compress` did the same, including under `protectHeaders`; `pki.jose.sign` signed a substituted payload; and `pki.tls.compressCertificate` compressed a substituted certificate message. No timing is involved, the accessor running at a fixed point in each verb, and it matters for a caller that holds content it trusts beside a descriptor or an option object it does not. Every one of them now takes the content before any caller description is read, so they act on the content they were handed whichever object the accessor sits on, and `pki.cms.encrypt` and `pki.cms.authenticate` refuse an options object that supplies a field through an accessor with `cms/bad-input` rather than reading it. `pki.cms.sign` was never affected, having copied its content at entry since it shipped, and `pki.cms.decrypt` fails closed on the same route with `cms/decrypt-failed`. A content passed as a stream is a different case and is not covered: its bytes do not exist until the iterator is pulled, which is after the recipients have been read, so a caller that mutates a buffer it is about to yield yields the mutated bytes and there is nothing earlier for the verb to take. Pass such a content as a Buffer where the recipient descriptor is not trusted. Each chunk is handled as it arrives, so a buffer reused across yields carries the value it held at each one.
49
+ - `pki.jose.verify` read a supplied JWK's `crv` eight times and its `kty` nine times on one successful verification, and the first of those reads is what selects which curve the Edwards low-order point check validates the key against. A caller object answering that read differently from the later ones therefore had its key checked under one curve's parameters and imported under another: a JWK whose `crv` answered `Ed448` once and `Ed25519` afterwards verified, with the point check bypassed. That check is what stops a low-order key from verifying a forged signature. The key is now copied once, before any member of it is read, so the check, the import and the algorithm selection all read one key; a key whose members are accessors is refused as `jose/bad-key`.
50
+ - An HPKE context dropped its additional authenticated data instead of binding it, when that data was held in a `DataView` or an `ArrayBuffer`. `context.seal` and `context.open` decided whether to bind the AAD by testing `aad.length`, which is `undefined` for both containers, so the AEAD was set up with no additional data and the tag authenticated none. Both sides dropped it alike, so the message still opened and a caller exercising their own code saw the round trip succeed while the binding they relied on was absent: a ciphertext could then be opened under any other AAD. The AAD is now normalized before its width decides anything, so it binds whichever container holds it, and a message sealed under one AAD no longer opens under another. An absent or empty AAD still means no additional data, as RFC 9180 sec. 5.2 specifies. `pki.cms` and `pki.webcrypto` were unaffected: the CMS additional data is the DER this toolkit builds, and the WebCrypto surface already normalized it.
51
+ - `pki.tuf.canonicalJson` encoded any object by its own enumerable keys, so a `Date`, a `Map` and a `Set` each became `{}` and a Buffer became a map of its byte indices. Canonical JSON is the preimage a TUF signature covers, so a field encoding to `{}` is a field the signature does not cover while it remains in the caller's document: the signed form and the document the caller believes they signed differed, with nothing reporting it. The encoder already refused a float, a function, `undefined`, a BigInt, `NaN` and `Infinity`; a built-in object is now refused the same way. Plain objects, including null-prototype records, and arrays are unaffected.
52
+ - `pki.asn1.decodeOidContent` read its byte count from the input's `length` property. A Buffer accepts an own `length` that differs from its real size, and every other read of a buffer's bytes, indexing, `subarray`, `concat` and `toString`, uses the real count; only `length` can disagree. So a buffer whose bytes encode `1.2.840.3` and whose `length` reported 3 decoded as `1.2.840`, handing back an OID the bytes do not encode while an OID selects a signature algorithm and an extension decoder. The count is now taken once from the authoritative source, so the bytes decide.
53
+ - `pki.tsp.verify` checked only the `signingCertificateV2` attribute of a token that carries both binding attributes, so a token naming the real signer in its v2 attribute and a different certificate in its v1 attribute verified. Both sit among the signed attributes, so the timestamping authority signed the contradiction, and a verifier reading only the legacy attribute resolves a certificate this one never checked. A token whose two binding attributes name different certificates is refused with `tsp/cert-binding-mismatch` now, rather than resolved in favor of one of them, whichever order the signed attributes are sorted in and by every claim an attribute makes: an `ESSCertID` states the signer as a certificate hash and again as an issuer and serial, and both are compared against the certificate that signed the token. A token carrying both attributes and naming the same certificate in each still verifies, and the verdict still rests on the v2 attribute and its own digest.
54
+ - `pki.tsp.verify` accepted a SHA-1 `messageImprint`, and `allowWeakDigests` did not gate it. The imprint is the only thing binding a timestamp token to the data it timestamps, so a collision in that digest substitutes the data under a genuine token, and the option reached only the token's own signature digest: a SHA-1 imprint verified under the default, under `allowWeakDigests: false` and under `true` alike. It is now refused by default and verified under `allowWeakDigests`, which is the same opt-in an archived signature already needs, and it does not make a mismatched imprint verify. No fetched clause forbids SHA-1 here, the one quoted algorithm prohibition being ETSI EN 319 122-1 clause 6.2.1 on MD5, so this is stated as the toolkit's posture rather than as a requirement.
55
+ - `pki.jose.sign` read `opts.key` four times: once to test that it is a key, twice for the check that binds the key's hash to the header's `alg`, and once more to sign with. A caller object answering those reads differently had the binding checked against a key that did not sign, and the signed message then declared an algorithm its signature was not made under. The RSA algorithms were where it showed, since the registry's fixed signature widths catch the HMAC and elliptic-curve cases and two 2048-bit RSA keys produce the same signature length. A conforming verifier rejects such a message, including `pki.jose.verify`, so it is not a signature another party accepts; what was wrong is that the key the check approved was never the key that signed. The option is read once now, before anything inspects it, and the hash check, the signing and the width check all read that one key. A key whose `algorithm.hash` disappeared between two of those reads previously produced an untyped `TypeError` from a public verb rather than a `jose/*` fault.
56
+ - `pki.jose.thumbprint` checked each JWK member and then read it again to build the hash, so the RFC 7638 identifier could be computed over values the checks never approved. The thumbprint is how `pki.jose.verify` decides that an embedded key and a supplied key are the same key, and how an ACME key authorization names an account, so an identifier over unchecked values names a key that was never validated. The key is copied once at the door. The published RFC 7638 and RFC 8037 known answers are unchanged.
57
+ - `pki.possession.verifyRequest` compared the subject alternative names a request asks for against the signature certificate's by rendering each entry to a string, so one comparison rule stood in for every GeneralName form. A dNSName differing only in case reported `subjectAltNamesMatch: false`, and so `valid: false`, although RFC 4343 makes DNS comparison case-insensitive; an rfc822Name differing only in the case of its host and a URI differing only in the case of its scheme or host did the same; and a directoryName re-encoded to equivalent bytes read as a second name although RFC 5280 sec. 7.1 compares one attribute at a time. Each form is now compared under its own rule, so the pairs that differ only where their rule folds match, and the pairs that differ where their rule is exact, an rfc822Name local part and a URI path among them, still do not. A form with no comparison rule of its own is compared by its encoded bytes, and a value a rule cannot be applied to is reported as unmatched rather than resolved either way.
58
+ - `pki.lint.csr` under the `rfc9883` profile graded conforming two requests that `pki.possession.verifyRequest` refuses, so `pki.csr.sign(..., { profile: "rfc9883" })` emitted both although the profile advertises the requirement. A request whose subject key can only ever sign, naming no keyUsage at all, met neither the sec. 4 requirement that "the subjectPKInfo MUST contain the public key for the key establishment algorithm" nor the sec. 6 prohibition on obtaining a signature certificate, and was reported as having no findings; the row read only the key usages a request asks for. A request carrying more than one `statementOfPossession` attribute was likewise unreported, and which of several a CA should honor is stated nowhere, so every check the section asks for would have been made against one statement while the CA certified a request carrying another. Both are now errors, `lint/rfc9883/signature-certificate-requested` and the new `lint/rfc9883/statement-attribute-repeated`, and the linter and the verifier read one table of the signature-only key algorithms.
59
+ - `pki.sigstore.verifyBundle` reported an unattested log index for a Rekor v2 entry. A v1 entry's signed entry timestamp covers the entry's own `logIndex`, so that value is attested; a v2 entry carries no such signature, and the verdict read the entry's number anyway, so a bundle could write any value there and have it returned as the index of the entry that verified. The verdict now carries an attested index on both paths: the entry's own where the signed entry timestamp covers it, and the leaf index the inclusion proof folded against the checkpoint-attested root where it does not. The two are different quantities, the log's counter and a leaf position, so neither is compared against the other.
60
+ - The numbers `pki.ct`, `pki.sigstore` and `pki.webauthn` read as integers are now held to denoting exactly the integer they are read as. A token written `1.0000000000000001` becomes the number 1, so an integer check made after parsing sees a conforming document, and for a signed tree head that value is folded back into the bytes the signature covers: the rounded spelling verified while the value reported was not the one the log sent. The same applies to a Sigstore log index and integrated time, which enter the canonical form a signed entry timestamp covers, and to a FIDO metadata `no`, which is the rollback counter a relying party compares against the catalogue it already holds. An integer too large to represent exactly is refused for the same reason, since `9007199254740993` reads back as `9007199254740992`.
61
+
62
+ These are ordinary JSON formats where exponent form is legal, and nothing a conforming producer may emit is refused: `1e3`, `1.0` and `1500e-2` are read as their integers, and fields that are genuinely fractional are untouched. The rule names the members it applies to rather than the document, because these formats carry fractional values elsewhere by design: a FIDO metadata statement states biometric false-reject and false-accept rates as fractions, and an in-toto predicate inside a Sigstore bundle is arbitrary JSON.
63
+
64
+ A `tree_size` of `1.5` is now `ct/bad-json` rather than `ct/bad-sth`: a token that does not denote an integer is a malformed document, while one that denotes an integer the field cannot take, such as `-1`, is still a bad tree head.
65
+ - `pki.altSig.signedData` built the bytes an alternative signature covers as the whole structure with its outer signature BIT STRING removed, keeping the `signature` AlgorithmIdentifier that the `tbsCertificate` carries. ITU-T X.509 (2019) clause 7.2.2 says to exclude "the signature component" without saying which of the two members of that name it is, and `draft-truskovsky-lamps-pq-hybrid-x509` gives the excluded one a type: the `PreTBSCertificate` of section 4 is the `tbsCertificate` without its `signature` field, the third element, and the `PreTBSCertList` of section 5 is the `tbsCertList` without its second. Both creation procedures add that field after the alternative signature is computed, so at that point there is none to cover. `pki.altSig.signedData` and `pki.altSig.verify` now build those bytes and `pki.x509.sign` and `pki.crl.sign` sign them, so an alternative signature on a certificate or CRL from this toolkit verifies in Bouncy Castle, and one Bouncy Castle made verifies here. A signature over the previous bytes is refused rather than accepted under a second scope.
66
+ - `pki.tlog.verifyCheckpoint` read `opts.origin` four times, to see whether it was supplied, for its type, for its length, and for the comparison against the checkpoint. An accessor answers each read separately, so a caller object could present one origin to all three checks and the checkpoint's own origin to the comparison: the pinned origin was never the origin enforced, and the verdict reported a checkpoint as matching a log it does not name. The option is read once and the checks and the comparison both read that.
67
+ - `pki.tuf.verifySignatures` read `opts.role` four times, for the presence check, the type check, and then each of `keyids` and `threshold`. `role` carries the authorization, so an accessor could present a strict role to the checks and a lax one to the field reads, combining one role's authorized keys with another's threshold: a document carrying one signature was reported `verified` against a role that required two. The role is captured once and every field is read off that capture.
68
+ - `pki.tuf.checkExpiry` read `metadata.expires` three times: once to check it is a date string, once for the comparison, and once for the refusal message. A caller object could pass the type check with one date and hand the comparison a later one, and the verb then returned `true` for metadata that had expired. Expiry is the freeze-attack check, so a true verdict there answered about a date the document does not carry. The field is read once.
69
+ - `pki.tuf.keyId` validated a key's `keytype`, `scheme` and `keyval` and then hashed the key, reading every field a second time. The identifier it returns is what a role's `keyids` list authorizes, so a key could validate as one shape and be hashed as another, naming a key that never passed validation. The key is copied before it is validated, and a key whose members are accessors is refused as `tuf/bad-key`.
70
+ - `pki.acme.validate` read each field three times, for the closed status enum, the refusal message and the type check, while a conditionally-required field's predicate read the object again. A caller object could therefore pass the status enum as `valid`, answer the predicate so that a required field became optional, and present a third value to the type check, and the object was reported as conforming. Every field is now read once, before any check runs, and all of the checks decide from that reading. `pki.acme.validateProblem`, `pki.acme.identify` and `pki.acme.validateRenewalInfo` each read a member twice and now read it once; a non-string reaching the second read previously produced an untyped `TypeError` rather than an `acme/*` fault. The object is still returned as supplied.
71
+ - The `pki.acme` request builders validated an option and then serialized it into the payload they sign, reading it twice, so a caller object could satisfy the validator with one value and have the signed request carry another. `revokeCert` is the sharpest case: the RFC 5280 CRLReason check exists to exclude the single unassigned value 7, and that is the value which could reach the certification authority. The same gap put unvalidated contacts, identifiers and dates into signed `newAccount`, `newOrder`, `newAuthz`, `updateAccount`, `challengeResponse`, `deactivate` and `postAsGet` payloads, so the mailto hygiene, the RFC 3339 checks and the boolean gates these verbs document were each enforced against a value that was never sent. Every one of them now passes its options through the gate the client methods already used, which refuses an options object whose fields are accessors rather than reading them once. The client methods were never affected.
72
+ - `pki.acme` client `renewalWindow` validated `opts.previous` before fetching renewal information and read it three more times afterwards, so the prior window its stickiness decision compares against need not have been the one validated, and a value that stopped being an object across the fetch produced an untyped `TypeError`. The option is read once, before the fetch.
73
+ - `pki.tlog.inclusionProof` read its `read` callback twice, once to check that it is a function and once to fetch tiles with, so the function checked need not have been the function called. The callback is read once.
74
+ - `pki.crmf.verifyPop` read the first `statementOfPossession` control of a request that carries several and returned, so every later statement went unchecked: a request whose first statement is sound and whose second names a different certificate reported `verified: true` on the strength of the first. Which control a certification authority should honor is stated nowhere, so a request carrying more than one is now refused with `crmf/bad-controls`, as `pki.possession.verifyRequest` already refused the equivalent PKCS#10 attribute. A repeated CMS signed attribute was already refused by the shared parser; a CRMF control is a different structure and was not.
75
+ - `pki.possession.verifyRequest` and `pki.possession.statementOf` read only the first `statementOfPossession` attribute of a request that carries several, so every binding check on the rest was skipped. A request carrying a sound statement followed by one naming a different certificate was accepted on the strength of the first. A request carrying more than one is now refused with `possession/bad-request` at both doors, which is what `pki.schema.csr` and `pki.cms` already did for the attribute sets they read.
76
+ - `pki.possession.verifyRequest` read only the first `extensionRequest` attribute of a request that carries several, and every check RFC 9883 asks for reads the extensions a request asks for. A request naming `keyAgreement` in its first attribute and `digitalSignature` in its second was therefore reported as not requesting a signature certificate, which is the section 6 prohibition, and a later subjectAltName request could escape the section 3 comparison the same way. Which attribute a CA should honor is stated nowhere, so a request carrying more than one is now refused with `possession/bad-request`, as `pki.schema.csr.decodeExtensions` already refused it.
77
+ - `pki.possession.verifyRequest` did not treat a DSA subject key as signature-only, so a request to certify one, which can only ever sign, did not engage the RFC 9883 prohibition the way an EdDSA, RSASSA-PSS, ML-DSA or SLH-DSA key does. DSA is now in that set.
78
+ - The receipt `pki.ct.addChain` and `pki.ct.addPreChain` return omitted `logIdHex`, the field the log-list verbs key a log by, so a receipt those verbs had just verified could not be handed to them: `pki.ct.verifySctWithLogList` refused it with `ct/bad-input` and `pki.ct.verifySctList` dropped it and reported a policy failure. The field is present now, matching what `pki.ct.parseSctList` and `pki.ct.signSct` produce, so a receipt round-trips through every verb in the module that reads one.
79
+ - `pki.tsp.verify` reported `tsp/unsupported-algorithm` for a timestamp token bound to its certificate by an `ESSCertID` v1. That structure carries no hash algorithm field because the type fixes it at SHA-1, so nothing was unsupported: the algorithm is known and the binding is collision-weak, which the refusal already intended and the code contradicted. It reports `tsp/weak-digest` now, as does an `ESSCertIDv2` naming SHA-1 explicitly. The refusal itself is unchanged, and an `ESSCertIDv2` naming an algorithm this build cannot compute keeps `tsp/unsupported-algorithm`. An absent field carries the type's default; an unrecognized field names an algorithm nothing can compute.
80
+ - `pki.cms.sign` refused to emit two instances of one unsigned attribute type, citing RFC 5652 sec. 5.3. That clause states no such rule: `UnsignedAttributes` is a `SET SIZE (1..MAX) OF Attribute`, the clause says of the field only that it "is a collection of attributes that are not signed", and the uniqueness rules the RFC does state are per type, one instance of the message-digest attribute among the signed ones, while sec. 11.4's countersignature type "specifies one or more signatures". The refusal denied a conforming message, and the shape it denied is the ordinary one: a signature re-timestamped by several authorities carries one timestamp-token attribute per authority. Repeated unsigned attribute types are emitted now, and the per-type rules with a clause behind them are kept, so a content-type, message-digest or signing-time attribute is still refused among the unsigned ones. The signed side still emits at most one instance of each type, which is stricter than the RFC asks and is now described that way rather than attributed to sec. 5.3.
81
+ - `pki.tuf.verifySignatures` refused an RSA signature whose PSS salt is longer than the digest. The `rsassa-pss-sha256` scheme states the hash and the padding and says nothing about the salt length, which is the signer's to choose, and the reference implementation signs with the longest salt the modulus allows, so metadata that verifies elsewhere was reported unverified here and the root rotation it carried could not be applied. The salt length is now read from the signature, where it is carried, while the hash and the padding stay fixed by the scheme name: a PKCS#1 v1.5 signature under the same key is still refused. Every other RSASSA-PSS verifier in the toolkit keeps its pinned salt length, because the format declares one and the declaration is authenticated: `pki.cms.verify` reads it from the RSASSA-PSS AlgorithmIdentifier, `pki.path.validate` from the certificate, and `pki.jose.verify` from the RFC 7518 row for PS256, PS384 and PS512.
82
+ - The Certificate Transparency verbs copied a caller's bytes and then refused them against a size cap, so an input over the cap was allocated before it was rejected. Measured, a 64 MiB SCT-list extension value, log-list JSON, certificate, or SCT extensions or signature field each allocated its full size before drawing the refusal. The one door these verbs take their bytes through now measures the cap on a view that shares the caller's store, before the copy, so `pki.ct.parseSctList`, `pki.ct.verifyLogListSignature`, `pki.ct.reconstructSignedData` and `pki.ct.encodeSctList` refuse an oversized input without allocating it, and a log-list JSON passed as a string is sized with `Buffer.byteLength` rather than encoded first. Each refusal keeps the code it had, except that an over-cap SCT-list value now reports `ct/too-large` rather than whatever the discarded copy failed to decode as.
83
+ - `pki.altSig.verify` and the other `pki.altSig` verbs checked which format their input was without checking that it is a well-formed one of that format, then read extensions out of it directly. A certificate carrying the `altSignatureValue` extension twice was therefore verified successfully: the verb read the first of the two and the preimage it reconstructed removed both, while `pki.schema.x509.parse` refuses the same bytes with `x509/duplicate-extension`. The structure is parsed before any extension is read, so every rule the shared parser enforces holds here too.
84
+ - `pki.crmf.build` did not recognize a `statementOfPossession` supplied through the pre-encoded `controls` array, only through the object form, so it resolved the proof-of-possession algorithm from the key being certified rather than from the signature certificate the statement names. Building an X25519 request with a pre-encoded statement therefore failed with `crmf/unsupported-algorithm` while the identical request in object form succeeded. Both forms now resolve the signing certificate the same way, and a pre-encoded statement that does not decode, or that omits its certificate, is refused with `crmf/bad-controls` rather than passed over.
85
+ - Byte-taking verbs across the toolkit accepted a Buffer and a `Uint8Array` but refused a `DataView` or an `ArrayBuffer` carrying the same bytes, although each advertises the whole set and one names it outright: "ikm must be a byte source (Buffer / TypedArray / DataView / ArrayBuffer)". An `ArrayBuffer` is what `Response.arrayBuffer()` and `crypto.subtle.exportKey("raw", ...)` return, so the refused half is the half a caller is most often holding, and the refusal blamed the content for the container. In `pki.tlog` it was worse than a refusal: `parseTile`, `parseNote`, `parseEntryBundle`, `parseCheckpoint` and `keyId` threw a bare `TypeError` with no code for an `ArrayBuffer`, so a caller could not branch on it and a fuzz harness could not tell it from a crash. Fixed at `pki.tlog` (all five verbs), `pki.hpke` (`info`, `aad`, plaintext, `ikm` and the exporter context), `pki.sigstore.verifyBundle` (the pinned `rekorKeys` and `ctLogs` material), `pki.tuf.verifySignatures` (`metadata.signedBytes`), `pki.ct` (the pinned log key on `getSth`, `getProofByHash` and `fetchLogList`, the SCT fields, and an issuer named by its SubjectPublicKeyInfo) and every format's `decodeExtension`. The size cap still runs before any copy, because normalizing shares the caller's buffer rather than duplicating it. Shared memory stays refused, and a key is still a `KeyObject`, a Buffer or the `{ pkm }` wrapper, which the refusal now names exactly instead of saying "a raw buffer".
86
+ - `pki.path.crlChecker` and `pki.path.ocspChecker` take a list. Handed a single CRL or response, which is the natural slip, they mapped over whatever arrived: a `DataView`, an `ArrayBuffer` or a plain object produced a bare `TypeError` naming an internal expression, and a single CRL Buffer was mapped over its bytes and reported the first byte as a malformed CRL. Both now refuse a non-list with `path/bad-input` naming the argument. An omitted or empty list still means there is nothing to check.
87
+ - `pki.tlog.verifyNote` and `pki.tlog.verifyCheckpoint` chose the digest for an ECDSA signature from the key's curve, so a conforming note signed by a P-384 or P-521 log key was refused with `tlog/bad-signature` and only P-256 logs interoperated. Signature type `0x02` is defined by c2sp.org/signed-note as "ECDSA signatures as implemented by github.com/transparency-dev/witness", whose verifier computes a SHA-256 digest for every key it accepts; the digest belongs to the signature type, not to the key. All three curves the specification admits are now verified under SHA-256, and a signature made with the digest the curve would suggest is refused. The curve allowlist is unchanged: a key on any other curve is still `tlog/bad-input`.
88
+ - `pki.sigstore.verifyBundle` refused a bundle whose certificate chain carries more than one intermediate with the same subject where only one of them is the real issuer, which is the shape a CA key rotation produces. Three things had to change, and together they make the verdict independent of the order the bundle lists its certificates in. The search now backtracks instead of taking the first intermediate matching an issuer name, as `pki.path.build` already did. It offers every path it finds rather than one, so an intermediate that matches by name and then fails validation no longer hides the sibling that would have validated. And it tries a candidate that completes the path before one that does not, so a chain padded with same-subject intermediates cannot consume the search. The work is bounded: at most 8 paths, 256 search steps and a depth of 16, so a chain carrying many same-subject intermediates is abandoned rather than explored.
89
+ - `pki.transport.peerChain` and `pki fetch` described their chain as the certificates the endpoint presented. It is the chain the TLS session resolved, which is not the same thing: the runtime completes the chain from the trust anchors in scope, so an endpoint that sends only its leaf still yields the entries above it and those came from a configured anchor rather than from the wire. Node exposes no API for the transmitted list, so the distinction is stated rather than guessed at, and filtering entries that match an anchor would drop a root an endpoint really did send. The verb's documentation, the `pki fetch` usage text and the line it writes to stderr now say which chain this is, and point at `peerCertificate` for the one certificate the endpoint certainly sent. No behavior changed.
90
+ - The RFC 9883 citations named the wrong sections. The prohibition "The privateKeyPossessionStatement attribute MUST NOT be used to obtain a signature certificate" is in section 6, not section 4, and the two name comparisons whose resolution the RFC leaves to the certificate policy are in section 3, not section 4. The refusal messages `pki.possession.verifyRequest` reports, the `citation` field of `lint/rfc9883/signature-certificate-requested`, and the CRMF proof-of-possession note, which belongs to section 5, now each name the clause they enforce.
91
+ - The check that every internal fail-closed guard declares how its shape is kept from being re-inlined read a module's exported names with a pattern that recognized `module.exports = {` and `module.exports = Object.freeze({`. The guard modules export through a freeze reached by another name, so the check read 18 of the 19 as exporting nothing and passed over every function in them: 13 guards had no declaration and nothing said so. The pattern now matches the wrapper as any callee, and the 13 declarations are in place. No shipped behavior changes; the gate that was meant to prevent a guard shipping without enforcement now does.
92
+ - Every signature this toolkit checks or produces with the Node signature functions reached them as properties of the crypto module, read once per signature at the call site. A replacement installed after the package loaded therefore decided the verdict: one answering `true` made TUF metadata carrying a 64-byte all-zero signature meet its threshold against a genuine pinned key, and one answering with bytes of its own choosing made the key-pair probe in `pki.key.generate` approve a signature it did not make. All 24 sites across `pki.tuf`, `pki.tlog`, `pki.sigstore`, `pki.key`, `pki.webcrypto` and the certificate builders now go through the operations captured at load, and the gate that keeps them there names any new one.
93
+ - A verdict was returned as a plain object, which makes it a thenable the moment anything installs `Object.prototype.then`: resolving a promise reads `then` off the value it settles with, so an inherited one runs with the verdict as its receiver and resolves the caller with a verdict of its own choosing. Measured, an unsigned transparency-log note reported `verified: true` carrying a signer the note does not name. Each verdict a verb reports now owns a `then` of its own, which ends that lookup and is invisible to `Object.keys`, `JSON.stringify`, a spread and a deep comparison. A parsed document that carries a `then` member of its own keeps the value it wrote, since a member a document names is data rather than a lookup to end. The surface this reaches: `pki.tlog.verifyNote` and `pki.tlog.verifyCheckpoint`, `pki.scep.parse`, `pki.scep.enroll` and `pki.scep.renew`, `pki.est.fullcmc` and `pki.est.classifyResponse`, `pki.cmp.openKeyPackage`, `pki.jose.parseJson`, `pki.tsp.parseResponse`, the CMC status rows `pki.cmc.verify` reports, the log records and per-SCT rows of `pki.ct.parseLogList` and `pki.ct.verifySctList`, and the per-certificate results and per-check rows of `pki.path.validate`.
94
+ - The verifier was not the only operation a signature verdict rested on. It verifies against whatever key object it is handed, and the key came from a key import read live at the call, so a replacement returning another genuine key of the same family made the captured verifier check the signature against THAT key: a TUF role met its threshold on a signature by a key the root never named, and a transparency-log note verified under a key the caller never supplied. The import inside `pki.webcrypto`'s own `importKey` was live as well, so the same substitution was reachable one layer down from any verb that imports a key through it. Seven more of the same shape, each on a path that decides a signature: the SubtleCrypto `verify` a certification path and a log note call, and the `importKey` and `sign` a certificate, CRL and request are signed with, were fetched from the object per call rather than taken with it; the exporter that reads a `CryptoKey`'s material was read off the module at the call, so a replacement made a mismatched key pair report as corresponding; a key's own `export` decided the material a key identity is computed over, which gave two distinct authorized TUF keys one identifier and took a two-key threshold down to one signature; the digest a transparency-log key identifier is was taken through the hash prototype's own methods, so a replacement changed the four bytes a signature line is matched on and a note the key really did sign reported `verified: false`; the constant-time comparison a MAC verdict rests on was read off the crypto module at each comparison, so one answering true accepted any equal-length wrong MAC; the copy handed to an external signer was written with the typed-array `set` reached through the prototype, so a replacement there made the signer sign a message this toolkit never assembled; and `pki.sigstore` fetched the checkpoint, timestamp, embedded-SCT and certification-path verdicts from other modules at the moment of the call, so one answering `verified: true` passed a bundle nothing signed. Each of these operations is now taken once, when the package loads. Two more go with them. A WebCrypto verb returns an ArrayBuffer, so the signature it produced is converted from a view on the way out, and `buffer`, `byteOffset` and `byteLength` are getters on the TypedArray prototype: replacing them turned a valid 64-byte Ed25519 signature into 64 zero bytes. And `Promise.resolve` builds its promise from the receiver it is called on, so reading the global `Promise` binding at the call handed that construction to whatever the binding held: on the certificate-path verdicts a refusal was not necessarily a refusal, and on the signing path a replacement whose constructor rewrote the message buffer made a signature that verifies against the rewritten message and fails against the one the caller passed. One more goes the same way: a TUF key identifier is a digest over the key object, which is what binds a key to the identifier a role names, and a different key filed under an authorized key's identifier was accepted, so metadata it signed reported `verified: true` where the check otherwise refuses. The operations named here are the ones a signature verdict or a produced signature rests on, and this is not a claim that every replaceable property in the toolkit is now taken at load: the key-agreement, KEM and key-format paths still read several at the call, and so do verbs outside the list. One is beyond reach for any library: `await` calls `Promise.prototype.then`, so a replacement of it decides the result of any asynchronous call.
95
+ - `pki.x509.sign` accepted `subjectAltPublicKeyInfo`, `altSignatureAlgorithm` and an arbitrary `altSignatureValue` through the pre-encoded array form of `spec.extensions`. The object form refuses all three, because the value is a signature this verb computes and self-checks and the algorithm is resolved from `issuer.altKey`, but none of those guards was reached by the array form: the verb emitted and natively signed a certificate carrying an alternative signature it had never computed, and `pki.altSig.verify` then refuses that certificate for the migrated client the extension exists for. All three are refused in the array form now, after the extension's value is decoded so a malformed one is still reported as malformed. A caller that needs the alternative half passes `issuer.altKey` with the object form, which emits all three together as ITU-T X.509 (2019) clause 7.2.2 requires. Those per-element rules are also no longer reachable around: the walk over the array went through the ARRAY'S OWN `map`, so an own method on it handed the callback a list the callback never saw and bypassed every one of them at once, the duplicate check and the criticality rule along with the refusal above. `pki.x509.sign`, `pki.crl.sign` for both its own extensions and a revoked entry's, `pki.attrcert.sign` for its attributes and extensions, and `pki.csr.sign` for its attributes all take the list through a copy that reads `length` once and takes each element as an own property.
96
+ - `pki.tuf.updateRoot` let an unsigned candidate root spend the event loop before any of its signatures was checked. A root states a key list per role, and every occurrence of an identifier canonicalized and hashed the whole key twice, imported it and derived its material identity, so repeating one identifier across the role lists multiplied that work: measured, a 185 KB candidate carrying a 50 KB key member and repeating one identifier 500 times per role spent 3.1 seconds inside that walk before answering `tuf/root-unsigned`, and 588 KB with 2000 repetitions spent 12.6 seconds, both well inside the metadata size cap. Each identifier is now decided once for the whole walk, so the work follows the number of DISTINCT keys a root names: the same documents answer in 11 and 36 milliseconds.
97
+ - `pki.hpke.decap`, `pki.hpke.setupR` and the single-shot `pki.hpke.open` read the recipient's key record before copying the encapsulated key they were given, and reading that record runs caller code. An `skm` accessor fired while the encapsulation was still the caller's buffer, so one that copied a different encapsulation over it had `decap` return the shared secret for the other ciphertext, and the setup derive its context from it. Reproduced with two X25519 encapsulations. The encapsulation is copied at entry, before any caller record is read, so the secret answers for the ciphertext that arrived. The AEAD above the setup was already refusing the substituted case; the standalone verb has no AEAD above it and was returning the wrong secret outright. The single-shot `pki.hpke.seal` and `pki.hpke.open` copy their plaintext, ciphertext and additional data at entry for the same reason, the setup they run first reading the suite identifiers, the options bag and a key record before those were taken. And whether a private-key record carries `skm` is one question asked once: read as a presence test and then again as a value, an accessor answering differently chose a branch for one value and ran it on another.
98
+ - Every signature check in the toolkit now runs the WebCrypto verify operation it captured when the package loaded. `pki.path.validate` held it that way already; `pki.ct` (signed tree heads, log lists and SCTs), `pki.cms.verify` across all five signature families, `pki.jose.verify` on both its key routes, `pki.webauthn` assertions and attestations, the FIDO metadata BLOB, the composite signature halves and the AuthenticatedData MAC held the SubtleCrypto object and fetched its method per call, which leaves the method replaceable: reproduced on a signed tree head, a verify installed on the SubtleCrypto prototype after load turned a verdict of false into true for another log's signature. Every one of those verbs also imports its key through the captured operation, so the key a check runs under is not a replacement's to choose either.
99
+ - The comparator that orders a DER SET OF, and the one that answers whether two byte strings are equal, go through the operation captured when the package loads. The copy and the sort beside them were already captured; `Buffer.compare` was not, and it is what decides the answer: in `pki.asn1.build.set` and `build.setOf`, the schema engine's own set emission, a certification request's attribute set and a signed message's certificate set it decides the bytes emitted, and in a CMS detached-content check, a recipient's key identifier match, a timestamp token's element selection, a C509's byte-for-byte reconstruction claim, a CBOR deterministic map's key order and duplicate test, and the WebAuthn and TPM key comparisons it decides a verdict. `guard.bytes.compare` is the one place, and a detector refuses a new live read of it anywhere in the library.
100
+ - A list the toolkit copies or walks on a caller's behalf took the receiver's `length` once, which is not the same as taking a NUMBER. `Array.isArray` is true for a `Proxy` whose target is an array, and a `get` trap can answer `length` with an object: capturing an object captures no value, so a resource cap coerces it and every comparison in the walk coerces it again, each coercion free to answer differently. Measured, a length whose `valueOf` answered 0 and then 4 had `pki.merkle.root` approve an empty list and return the four-leaf root, and the same lever cleared the leaf cap. Every walk in that primitive now narrows the length where it is taken: a value that is not already a non-negative integer describes no array and is refused rather than coerced. The accessor DOOR shares that narrow, because it was walking a list the same way: a `Proxy` over two field names whose length answered 2 and then 0 had it pass the second field and return as though it had checked it, which is a door that can be walked past. So does the RFC 5280 section 7.1 name comparison, where the consequence is sharper: it read each side's length for the count test and again for the walk, so a length answering equal to its partner and then shorter had two distinguished names that differ in a later relative name compare EQUAL, and that comparison is what chains a certificate to its issuer, matches a CRL to its scope and applies a name constraint. The same comparison read each attribute TWICE, once for its type and once for its value, so a relative name answering a different attribute on each read had the type come from one and the value from another: an RDN whose reads answered a commonName of one value and an organizationName of another compared equal to a commonName carrying that second value, matching neither of the attributes it had answered with. Each attribute and each of its two fields is read once, into the value the comparison asks. Two directions stay as they are, both refusing: an attribute value that is not a primitive string is compared by identity rather than canonicalized, and one that answers differently on each read does not equal itself. The general-name comparison that applies a name constraint read each entry's tag, value and bytes once per arm rather than once, so the tag that was MATCHED need not have been the tag it DISPATCHED on: an entry reporting a registeredID to the equality test and a dNSName to the dispatch had two registeredID values compared under the case-insensitive dNSName rule, which is looser than the rule their own tag names, and in four arms the form check and the comparison were two reads as well. A match that verb reports is a subtree a certificate is allowed to speak for. Three more doors in the same guard: copying a caller's distinguished name refused a `Proxy` and never required an ARRAY, so a plain object carrying a `length` getter answered one count to the validation pass and another to the copy, and the copy carried relative names nothing had checked; the exported control-byte scanner had no type check of its own, so anything but a primitive string answered a length and a character through hooks free to differ across the two reads it makes per position; and the printable-IA5 scanner reads each byte once, a receiver answering a high byte and then a low one having otherwise cleared both bounds with neither printable, which the byte-view check that supplies its count already turns away. The deep copy of a caller's array sets the copy's length from the count it walked rather than from a second read of the source, a nested accessor having been able to shrink the source mid-walk and truncate a copy that had already collected every element. And a `Proxy` whose target is a function reports as a function, so one used as an options bag was returned on unexamined and hid an own name through its `ownKeys` trap; a callable receiver is now held to the same refusal an object one is.
101
+ - A DER SET OF is ordered by its members' encoded bytes, and `pki.asn1.build.set`, `pki.asn1.build.setOf`, the schema engine's set and trailing-field ordering, the certificate set in `pki.cms.sign` and a certification request's attribute set all took that ordering through `Array.prototype`'s own `slice` and `sort`. A replacement of either decides the bytes every SET OF in this toolkit emits, after whatever validated the members: measured on a request's attribute set, replacing either made it encode an attribute other than the one just validated, on bytes the verb then signs. The copy and the ordering go through operations captured when the package loads.
102
+ - `pki.cert.relatedCertificate` bound a request to one certificate through a digest it computed with the hash prototype's own `update` and `digest`. Capturing `crypto.createHash` and then chaining off it leaves those two replaceable, so a replacement decided what the comparison covered: measured with two certificates differing only in serial number, an `update` that hashed the first turned the second's `matchesCertificate` verdict from false to true, which is the binding the extension exists to make. Both the value it computes and the comparison it makes go through the captured digest. The same shape, a module that captures `createHash` and still chains off it, is gone from `pki.merkle`, `pki.tsp` and the CRMF Diffie-Hellman proof of possession as well, the last of which also takes its HMAC through a captured operation.
103
+ - `pki.possession.verifyRequest` documents `opts.time` as defaulting to now, and the default never applied: an omitted value reached `pki.path.validate` as `undefined`, whose always-on validity check requires a `Date`, so the call failed with `path/bad-input` and every caller had to supply a clock to get any verdict. The default is constructed now, and a `time` that is present and is not a usable `Date` is refused as `possession/bad-input` under this verb rather than reported against a validator the caller did not call.
104
+ - `pki.path.validate` read `opts.verifier.verify` once per certificate, and again for each trust anchor it retried, rather than once per call. A list of anchors whose names all match the first certificate's issuer walks the path once per anchor, so an accessor there could refuse both certificates on the first attempt and accept them on the second, and the call returned `valid: true` over a path no single verifier accepted. The function is read once, in the door, and carried through every attempt; a `verifier` carrying no `verify` function is now refused with `path/bad-input` there rather than failing per certificate.
105
+ - `pki.hpke` derived key material through the hash and MAC prototypes' own `update` and `digest`, read live at each call. A replacement decides what a derivation covers: one returning its receiver without forwarding made two X25519 setups with different encapsulations export the same secret, so the session key stopped depending on the exchange it was negotiated under. The single-stage KDF that draft-ietf-hpke-pq defines, HKDF-Extract and every HKDF-Expand round now go through operations captured at load.
106
+ - `pki.tuf.verifySignatures` skipped a key identifier only once a signature under it had succeeded, so a document repeating one identical failing signature record re-derived that key's material identity and re-ran the verify for every repetition: measured over a 414 KB body, 1000 repetitions of a single all-zero signature cost 2000 key imports, 1000 signature checks and 1617 milliseconds, all on metadata nothing has authenticated yet and all answering `verified: false`. A key is imported once per identifier and a verify is asked once per identifier-and-signature pair, so the same document answers in 5 milliseconds, and a good signature arriving after a bad one under the same key still counts. The pair is named by the decoded signature rather than by its text, because hexadecimal is read in either case and one 64-byte value therefore has up to 2^128 spellings: named by the text, 1000 spellings of one value bought 1000 signature checks over the same body. 1000 genuinely different values are still 1000 checks.
107
+ - `pki.tuf.verifySignatures` takes the KEYID to key map the same way it takes the document and the role: held to being a plain object carrying plain values at every identifier the role names, refused as `tuf/bad-input` otherwise. The map's members are read inside a loop that awaits a verification per signature, so a `Proxy` or an accessor there answered each read separately, and the identifier filed under a key is checked against the copy the first read produced. A conforming caller passes plain values and notices nothing.
108
+ - `pki.tuf.verifySignatures` resolved a role's identifiers to keys one occurrence at a time, and each resolution deep-copied the key and hashed its canonical form, so an identifier the role repeated asked for that work again although the answer was already filed. Measured, 7500 repetitions of one identifier beside a 500 KB key spent 10.2 seconds inside that resolution, synchronously, before the first await and before anything had authenticated a byte of the document, all inside the one-megabyte metadata size cap. The repeat is a lookup now, and the same document resolves in 6 milliseconds.
109
+ - A TUF key was imported twice per identifier, once to derive the identity a threshold counts it under and once to check a signature with it, so the asymmetric work a document could ask for before anything authenticated it was twice what it needed to be. Both come from one import now, in the role walk `pki.tuf.updateRoot` makes at adoption as well as in `pki.tuf.verifySignatures`, which also means the key counted and the key verified with cannot be two different keys.
110
+ - `pki.jose.parseJson` returned an object the toolkit's own option validation refused. The result carries a hidden `then` holding no value, which stops an `await` of it from reaching a replacement installed on `Object.prototype`, and the option check counted that name: a configuration document parsed with this verb and passed to `pki.jose.verify` failed with `jose/bad-input` naming `then` as an unknown option, while the identical document through `JSON.parse` was accepted. A hidden name holding no value is passed over now, at every door that enumerates names rather than only the one this was found through: the option check, the name list a verb builds its accepted forms from, and the copy a verb makes of a caller's options, that copy having no inherited `then` to stand in front of. A `then` that carries a value is still reported as an unknown option, in either visibility, and a parsed document carrying one still keeps it. So is an accessor named `then`, including one whose getter and setter are both absent, which answers each read separately and is the shape the mask exists to replace rather than to resemble. So is a name a verb's list of accepted options carries, since the mask is about a name nothing asked for, and so is one reported by a `Proxy`, whose descriptors say whatever its traps return rather than what it holds.
111
+ - `pki keygen` records what it wrote at each destination so a failed run removes only the files it created. That record carried no identity until the content was in, and a record with no identity was deleted with no comparison at all, so a run whose write failed removed whatever occupied the path, including a file that had replaced its own. The identity now comes from the creating descriptor, before any content, and the size and timestamp are still recorded once a write returns. A file that replaced the one this run created, changed since, or carries a second name for its content is left where it is and named on stderr, as is one the platform cannot identify after a failed write, and one the cleanup could not examine at all: a path that cannot be read is not a path whose file is gone, and that outcome used to be passed over in silence. The link test asks what is on disk rather than comparing against a count the run recorded, since a name added before the record was taken and removed afterwards made the count fall. An empty path is reported too, a file this run created and something else moved away leaving the same bare path while its content sits somewhere the operator has not been told about. `--out` and `--pub` naming the same file is refused before a key is generated, and a report naming a path keeps to one line, a newline in the name having ended the line early and put what followed where the tool's own output is read.
112
+ - The same record now covers every file the CLI writes, not only the keys. `pki csr`, `pki issue`, `pki fetch` and `pki sign` with `--out` wrote through a plain overwrite, so a write that failed part way through left a file the run had just created with nothing tracking it: measured, a failing write to a new path left the partial file in place. A path that does not exist is created exclusively and written through its own descriptor, as a key is, and a failed write removes it. A path that DOES exist is still overwritten, which is what those verbs have always done and what separates them from the key verbs, and a file the run did not create is never one it removes. That overwrite goes through a descriptor opened without permission to create, so a path that stops existing between the two calls is retried as the exclusive create it has become rather than written by name into a file nothing is tracking, and a write that fails part way through an existing file now says that the file was overwritten and what is there is incomplete. The bytes go out through the call that writes all of them rather than one that can report a short count and return, which a caller ignoring the count would close as a complete file. A destination that is not a regular file is still written: `/dev/null`, `/dev/stdout` and a FIFO hold no previous content to clear, and truncating one is an error rather than a no-op, so the truncation happens only where there is something to truncate and a destination that refuses a read-write open is written by name as these verbs always did.
113
+ - `pki issue --csr` now answers what the request asks for instead of dropping it. A request carries the extensions it wants through the RFC 2985 section 5.4.2 extensionRequest attribute, and this path read only the request's subject and public key, building the certificate's extensions from `--san` alone. So `pki csr --key k --subject CN=host.example --san host.example` followed by `pki issue --csr req.der` reported success and issued a certificate with no subjectAltName, which matches no host name: the failure arrived at whatever later presented the certificate rather than at the command that made it. Which requested extensions to honor is the CA's decision, so neither answer is taken by default. `--copy-requested-san` writes the requested subjectAltName into the certificate, converting each name through the same builder that validates a name given on the command line. `--ignore-requested-extensions` issues without what the request asked for. A request that asks for anything neither flag accounts for is refused, naming what was asked and both flags, so a certificate is never issued carrying less than the request asked for while reporting success. A requested name of a form the copy does not write, a directoryName or an otherName, is named and refuses the issuance rather than being left out of the copy. `--san` and `--copy-requested-san` together are refused, both naming the subjectAltName, and both flags require `--csr`, there being no request to answer without one.
114
+ - `pki <verb> --help` now exits 0 with the usage line on stdout. Every verb reached its usage text through the check for a missing argument, which writes to stderr and exits 1, so a script asking `pki csr --help` could not tell the help it asked for from a csr that failed to run, while top-level `pki --help` answered the same question and exited 0. Help is now answered before the verb runs, for `--help` and `-h` on all eleven verbs that have a usage line, including a verb whose arguments are otherwise complete and a help flag standing where a flag's value belongs. A genuinely missing argument still writes its usage to stderr and exits non-zero.
115
+ - `pki.tuf.verifySignatures` now reads the keys the role names rather than the signatures the document lists, so a document's signature list decides which signature each key offers and no longer how many checks run. Reading the document's list let it choose: a signature repeated, or spelled differently, shared a memo, but distinct signatures for one authorized key shared nothing, and each one hashed the whole signed body. Measured, a 1,013,726-byte document carrying a 500 KB body and 2,400 signatures made with a key nobody authorized bought 2,400 key imports and 2,400 signature checks and about a second of synchronous work, inside the one-megabyte size cap and before anything had authenticated a byte of it; 1,000 distinct signatures under one key now cost one check, and 40 keys a role names cost 40. The work follows the document's own size rather than the product of its body and its signature list. The role is the caller's argument, and a root rotation also verifies a candidate against the root role it states itself, which is the check a new root must pass, so that one call's count is the candidate's to state. The verdict is unchanged for a conforming document, which carries one signature per key. The two authorities differ on a key identifier listed more than once and this satisfies both: the specification caps the count at one verified signature from a key identifier and leaves the document readable, the reference implementation refuses it outright, and the first signature an identifier carries is now the one asked. A document that lists a wrong signature for a key ahead of a right one therefore reads as that key not having signed, and a second entry for a key cannot change a count the specification already caps at one.
116
+ - `pki.sigstore.verifyBundle` reads the artifact once per digest algorithm when checking a message signature. The comparison against the digest a transparency-log entry records runs per attempt, and an attempt that failed it sent the next one over the same bytes again, so the caller's whole file was read once per entry the bundle listed. The entry count is capped at 32, which bounded the factor rather than removing it: a 100 MB artifact was hashed 32 times. The same bytes under the same algorithm are the same digest, so each algorithm an entry names now costs one pass. The comparison against the `messageDigest` the bundle itself carries reads through the same answer: it names its own algorithm, so a bundle whose first entry records the wrong artifact under SHA-512, whose selected entry records the right one under SHA-256, and whose `messageDigest` claims SHA-512 read the artifact three times for two algorithms.
117
+ - Every byte input to a `pki.ct` verb is bounded before it is copied, not only the ones a caller passed a limit for. The one door they all enter through took its limit as an optional argument, so the inputs that passed none were copied at whatever size arrived: a 32 MiB value supplied as a 32-byte tree head hash allocated 32 MiB, and one supplied as its signature allocated 64 MiB across the two reads, with the same on the chain, pinned-key, leaf-hash and issuer-hash inputs of `verifySth`, `verifySct`, `addChain`, `getSth`, `getProofByHash` and `fetchLogList`. The door now applies the toolkit's DER ceiling when no narrower width is stated, so a verb added later is bounded without being bounded by hand, and a site that knows its own width still states it. An oversized value is refused with `ct/too-large` and nothing is copied.
118
+ - `pki.hpke.setupS` copies the recipient's key before it reads the options bag, so an option getter cannot change who a message is encrypted to. Reading that bag runs caller code, and a getter on `opts.info` that wrote another recipient's key over the buffer had the setup encapsulate to that other recipient while the caller had named the first: measured, a sender naming one recipient produced a message only the other could open, with every width check passing because both keys are the same width. The verdict was not a refusal, it was a message for the wrong party. `setupR` already took its ciphertext first for this reason; this is the same rule on the sender side.
119
+ - `pki.merkle.root` and the proof verbs check a hash's width before copying it, and `pki.tuf.verifySignatures` bounds `metadata.signedBytes` before copying it. Both copied first and then refused, so a value handed where a 32-byte hash belongs was duplicated in full: measured, one 64 MiB leaf allocated another 64 MiB, and a 32 MiB `signedBytes` allocated 32 MiB before the comparison rejected it. The signed bytes must equal the canonical form of a body that came from a document no larger than the one-megabyte cap, so anything above that cap cannot match and is refused with `tuf/too-large` rather than copied. A wrong-width hash is refused as before, with the same code.
120
+ - The `pki` command writes to an existing output destination write-only, so a FIFO reaches a reader rather than the command itself. Opening it for reading as well made the process its own reader: the open succeeded with nobody listening, the bytes went into a pipe no one drains, and `pki csr --out <fifo>` reported success with the output gone, while a larger result blocked. A write-only open waits for a reader, which is what naming a FIFO asks for. The destination is still opened without permission to create it, so a path that stops existing between the two attempts is retried as the exclusive create it has become.
121
+ - Every report the command line writes keeps to one line, and the escape it uses is unambiguous. A path is given on the command line and fifteen refusals name one, but only the exit handler's reports escaped a carriage return or newline, so a path carrying one printed it raw and whatever followed landed where an operator reads this tool's own output: `pki keygen --pub` over an existing file whose name holds a newline reported across two lines. The escape is now written with two hex digits as well. One digit ran straight into the next character of the path, so a newline followed by `a` printed as `\xaa`, which reads as an escape standing for one byte while it stands for two.
122
+ - `pki.tls.compressCertificate` and `pki.tuf.checkExpiry` read their size limit before the copy it governs. Both copy their input early on purpose, so that reading a caller option or comparing a body cannot substitute what the verb acts on, and the limit sat after that copy: a 64 MiB certificate message allocated another 64 MiB before the handshake framing limit refused it, and a 64 MiB `metadata.signedBytes` allocated 64 MiB before the canonical-form comparison rejected it. Viewing a value allocates nothing, so the limit is read on the view and only a value within it is copied, which keeps the early copy that closes the substitution window. An option getter still cannot change which message is compressed.
123
+ - `pki.tls.decompressCertificate` copies the compressed message before it reads its options, so an option getter cannot change which message is decompressed. It held a view of the caller's buffer while resolving `opts.maxOutputBytes`, and reading an option is caller code when it is an accessor: a getter that wrote another well-framed message of the same length over the buffer had the verb decompress the replacement, having framed the one it was handed. `compressCertificate` already took its copy before reading `opts.algorithm`; this is the other half of that pair. The report of a completed write escapes a carriage return or newline in a path as the refusals do, so the success line a script parses stays one line too.
124
+ - Eight promises the toolkit awaits were built through the live global instead of the constructor captured when the package loaded. `Promise.resolve` builds through its receiver, so handing it `globalThis.Promise` put a correctly captured operation straight back under a constructor a replacement installed afterwards could take over, and what that constructor settles with is the value the toolkit then reads. In `pki.possession.verifyRequest` that value is a certification-path result: a constructor settling with `{ valid: true }` made a correctly signed request report a validated path where there was none, and the RFC 9883 section 4 requirement that the signature certificate's path validate was reported as satisfied without being applied. The same shape sat on the alternative-signature paths of `pki.x509.sign` and `pki.crl.sign` and on three `pki.acme` renewal paths, eight places in all, each now passing the captured constructor.
125
+ - `pki.scep.parse` read an RSAES-PKCS1-v1_5 pkcsPKIEnvelope whatever the outer signature was. That combination over a content with no integrity tag, such as the AES-CBC a deployed SCEP peer sends, is refused by `pki.cms.decrypt` because a rejected key unwrap and a conforming one report different outcomes, and an attacker submitting chosen ciphertexts learns from the difference which theirs was. Reading it anyway rests on the envelope having arrived inside a signature an authorized peer made, and without `opts.signerCert` the signature is any signer's, which this verb states by reporting `signerAuthenticated: false`. An unauthenticated message's envelope is now refused as `scep/decrypt-failed` over `cms/unauthenticated-rsa-v15`. Pass `opts.signerCert` with the peer's certificate to read those messages; it is the same option that binds the transaction attributes to an authorized signer.
126
+ - `pki.sigstore.verifyBundle` could refuse a bundle that carried a valid certification path, because it searched for that path itself. The local search assembled at most eight paths and ranked candidates by name, so where a CA had rotated and more than eight certificates sharing one subject DN were issued directly by the trusted anchor, those eight were spent on whichever ones the bundle happened to list first and the certificate that actually signed the leaf was never validated: the result was `sigstore/chain-invalid` for a bundle holding a valid path. The search is `pki.path.build` now, which owns it. Candidates are ranked by the RFC 4158 section 3.5 heuristics and none is excluded, and the bound counts candidate expansions against a ceiling of 1000 rather than capping assembled paths at eight, so the same-subject siblings a rotation produces no longer exhaust it. A search that does reach its bound, or a bundle naming more certificates than the search will consider, reports `sigstore/chain-incomplete` rather than a verdict that the chain is invalid. Every assembled path still flows through `pki.path.validate`, so no check is duplicated or relaxed. A bounded depth-first search still spends its budget on the branches it is steered to, so a bundle large enough to exhaust the ceiling can still depend on the order it lists its certificates in; what changed is the size at which that begins.
127
+ - `pki.tuf.parseMetadata` and `pki.tuf.checkExpiry` accepted an `expires` carrying any time-zone offset, such as `2030-01-01T01:00:00+01:00`. The shared RFC 3339 scanner admits every numeric offset because RFC 3339 does, while the TUF specification section 4.2.3 states the form `YYYY-MM-DDTHH:MM:SSZ` and that time is always in UTC. Such a value names a real instant but not the one the specification asks for, so a conforming client refuses the document where this adopted it. Both verbs require `Z` or `+00:00` now, the two forms RFC 3339 section 4.3 says "imply that UTC is the preferred reference point for the specified time". The same clause reserves `-00:00` for a time whose UTC value is known but whose local offset is not, and calls that semantically different, so that spelling is refused as well: it names the right instant while declining to state the thing the specification requires it to state. The rule reaches `checkExpiry` as much as the parse, because that verb is reachable with a metadata object a caller assembled rather than one `parseMetadata` returned.
128
+ - `pki.sigstore.verifyBundle` folded a Rekor v2 inclusion proof at the index inside `inclusionProof`, and reported that number as the entry's. Rekor v2 states the index once, at the top level: its client guidance has the log index read from `TransparencyLogEntry.log_index`, and `inclusion_proof.log_index` among the fields repeated elsewhere that a client ignores, beside the proof's own `root_hash` and `tree_size` which this already took from the signed checkpoint. So a caller received a number that does not find the entry in the log, and a bundle could state any index at the top level without that claim being proven. The fold runs at the entry's own index now and the verdict reports it, which makes a top-level index the log never put the entry at a refusal rather than a field nobody read: `sigstore/bad-inclusion-proof`. A Rekor v1 entry is unchanged, carrying a global index and a shard-relative one, where the proof folds against the shard its checkpoint covers and the signed entry timestamp covers the entry's own.
129
+ - `pki.possession.verifyRequest` compared the names a request asks for against the names in the certificate it embeds without bounding the work, and that comparison runs before the request's signature is checked and before the certificate's path is validated. Both lists come from the request, so the work is their product: a request repeating the certificate's last name four thousand times spent about three and a half seconds inside one synchronous call with nothing yet authenticated, which is a certification authority's event loop held by an unauthenticated caller. The comparison is bounded now, against the same budget `pki.identityMatch` bounds its own nested name comparison with, and a request that exceeds it is refused as `possession/too-many-names`.
130
+ - `pki.ct.getSth`, `pki.ct.addChain`, `pki.ct.addPreChain` and `pki.ct.fetchLogList` read the caller-pinned key twice: once to check that it was supplied, and again to copy the value the signature is verified under. Reading an option twice lets an accessor answer the two reads separately, so a caller object that presented the key it meant to pin to the check and a different one to the copy had its tree head, its SCT or its log list verified under a key it never pinned, and an attacker-signed tree head came back as a result rather than as `ct/sth-untrusted`. Each verb takes the option once now, and both the check and the copy decide from that one read.
131
+ - `pki.path.build` coerced its candidate pool and forwarded its validation options through the live `Array.prototype.map` and `Object.keys`, so a replacement installed after the package loaded could make a build raise `path/bad-input` and deny a path the pool could assemble. Both take the operation captured at load now.
132
+ - `pki issue --copy-requested-san` refused every request whose subjectAltName was critical, and that is the one form a SAN-only request must take: RFC 5280 section 4.2.1.6 requires the extension critical where the subject is empty, and `pki lint` refuses the non-critical form as `lint/rfc2986/san-not-critical-empty-subject`. The only conforming empty-subject request was therefore the one this verb would not certify. Copying it loses nothing either, because the certificate builder takes the extension's criticality from the subject it encodes, so an empty subject writes the critical form. The refusal now applies where it is true, to a request that carries a subject, where writing the non-critical form would answer a different request than the one made.
133
+ - The package could not be required at all with Node FIPS mode enabled. `guard.crypto` captures the three KeyObject `export` methods when it loads, which it does by making one sample key pair to read their prototypes from, and the sample was Ed25519. OpenSSL's FIPS provider carries no Ed25519, so `crypto.setFips(1)` followed by requiring the package raised `ERR_OSSL_EVP_UNSUPPORTED` out of module initialization, and a deployment needing only approved RSA or EC operations could not load the package to reach them. The sample is a P-256 pair now, which is inside the FIPS 140-3 boundary, and one pair yields both prototypes where two were generated before. The prototypes do not depend on the algorithm, so nothing about what is captured changes.
134
+ - `pki.acme.validate` read every own member of the resource it was handed, including names no ACME specification lists. This surface ignores extension members, and ignoring one means not reading it: an accessor on an unknown name is caller code, so a throwing one left `validate` by a path carrying no `acme/` code, and a side-effecting one ran before the listed fields had been captured, which let it change one the reading had not reached yet, such as the `status` the closed-enum check decides from. Only the fields a resource's specification names are read now, each once. The same applies to the prior window `pki.acme.renewalWindow` compares against.
135
+ - `pki.cmp.openKeyPackage` opened an RSAES-PKCS1-v1_5 delivered key package over a content with no integrity tag without being asked to. The verb takes the container rather than the CMP message that carried it, so it cannot establish that the container arrived inside an authenticated exchange, and the same chosen-ciphertext distinction applies. The decision is now the caller's: pass `allowUnauthenticatedRsa15: true` where the containers this entity opens come from an authenticated exchange. `pki.cmp.session` needs nothing, because it can establish that premise itself: it reaches a delivered key only for a response whose protection verified and whose signer chained to a supplied trust anchor, so it opts in on the caller's behalf and a centrally generated key still arrives. A container using RSAES-OAEP, which this toolkit emits, needs nothing either; the password technique never reached the refusal, because it is not key transport.
136
+
137
+ ## v0.8.46 — 2026-10-04
138
+
139
+ Run HPKE over a SHAKE key schedule, and encrypt to a P-384 recipient.
140
+
141
+ ### Added
142
+
143
+ - The HPKE single-stage KDFs SHAKE128 (0x0010) and SHAKE256 (0x0011), with the one-stage key schedule of draft-ietf-hpke-hpke sec. 5.1 behind them. One LabeledDerive of Nk + Nn + Nh bytes replaces the two-stage schedule's extract-and-expand chain, and `Context.export` derives through LabeledDerive rather than LabeledExpand. Nh is 32 for SHAKE128 and 64 for SHAKE256, taken from the KDF's definition rather than an extract width. Both are verified through the shipped verbs against Appendix A.7, A.8, A.11 and A.12 of draft-ietf-hpke-pq: the key pair derived from each vector's ikm, the published encapsulated key decapsulated under it, then all ten opens and all five exports.
144
+ - `pki.hpke.suites.KEM.DHKEM_P384_HKDF_SHA384` (0x0011), in all four modes with every KDF and AEAD. Nsecret is 48, the public key and the encapsulated key are 97 bytes, and the private key is a 48-byte scalar; Appendix A.8 of draft-ietf-hpke-pq is its known-answer vector.
145
+ - `hpke/input-length`, thrown when `opts.info`, `opts.psk` or `opts.pskId` exceeds 65535 bytes on a single-stage KDF. The one-stage schedule prefixes each with a two-byte length (draft-ietf-hpke-hpke sec. 7.2.1 states the bound as a MUST), so a longer input has no encoding; refusing it keeps a sender and a recipient from deriving different keys from inputs each accepted. An export longer than 65535 is refused with `hpke/export-length` for the same reason. A two-stage KDF keeps its own limits and is unaffected.
146
+ - TurboSHAKE128 (0x0012) and TurboSHAKE256 (0x0013) are registered in the same table and are not offered: no released OpenSSL exposes either extendable-output function, and `node:crypto` on OpenSSL 3.5.8 answers "Digest method not supported" for both. A request for one is refused with `hpke/unknown-suite`. They ship when the runtime carries them.
147
+
148
+ ### Fixed
149
+
150
+ - `pki.possession.verifyRequest` accepted a signature certificate whose `keyUsage` does not permit signing. A certificate confined to `keyAgreement` could authorize a certification request, so a key never issued for signing could authorize issuance. RFC 5280 sec. 4.2.1.3 confines a key to the purposes a present keyUsage asserts, and the verdict now reports `signerMaySign` and makes `valid` false when it is false, with the signature's own verdict kept separate. Any of `digitalSignature`, `contentCommitment` or `nonRepudiation` permits it, a certificate carrying no keyUsage is unconfined and still accepted, and a keyUsage that does not decode denies.
151
+ - Where a verb has a size limit, the limit is now read before its input is copied rather than after. `pki.tlog.parseTile`, `parseEntryBundle` and `parseNote`, and the single-stage `info`, `psk` and `psk_id` of `pki.hpke`, each copy their input so the bytes checked are the bytes used; an oversized input was copied in full and only then refused. A 64 MiB buffer handed to a parser whose tiles are 8192 bytes now allocates nothing. Each keeps the verdict it had: an over-wide tile is still `tlog/bad-tile`.
152
+ - `pki.tlog.parseNote` checked a note for ASCII control characters but not for valid UTF-8, which C2SP signed-note requires of both. A malformed byte was replaced with U+FFFD instead of refused, so a correctly signed checkpoint beginning with 0xFF verified with its origin reported as U+FFFD and the text reported did not encode back to the bytes signed. The whole note is now decoded strictly before any of it is read, and a note that is not valid UTF-8 is refused with `tlog/bad-note`.
153
+ - `pki.tlog.parseEntryBundle` bounded an entry bundle by its byte length but not by its entry count. A zero-length entry costs two bytes on the wire and an object in memory, so a megabyte of zero bytes decoded into half a million entries and the permitted 16 MiB input could allocate hundreds of megabytes and block the event loop. The count is capped at 256, one tile's width, which is the bound C2SP tlog-tiles states; `pki.C.LIMITS.TLOG_MAX_ENTRY_BUNDLE_ENTRIES` names it.
154
+ - `pki.tlog` returned tile hashes and bundle entries as views into the bytes handed to it, and the bytes come from the caller's own `read` callback. A callback that reuses one scratch buffer between tile fetches, which a real one may, had a later fetch overwrite proof nodes already collected, so `pki.tlog.inclusionProof` returned a proof that did not fold to the tree head the log served. Every byte input to the module is copied at the door now.
155
+ - `pki.tuf.parseMetadata` accepted a fractional or exponent-form number where TUF's canonical JSON admits only integers. A version written `1.0000000000000001` converts to the number 1, so the integer check that followed saw a conforming document and the version reported was not the one the bytes stated. Numbers are now refused while they are still text, with `tuf/bad-json`, which is what the format requires; the shared JSON reader gained an integers-only policy for callers whose format forbids floating point.
156
+ - The ACME client read the transport's response body and status per use rather than once. The transport is a caller-supplied option, so its response is a caller object: the body whose length was measured against `maxResponseBytes` need not be the body handed back. Both are read once now. This one carries no behavioral vector, because the test transport normalizes the body before the client sees it.
157
+ - Several verbs read a value out of the caller's own object more than once, or after awaiting, so an object answering each read differently could pass a check with one value and be acted on with another. `pki.acme.verifyTlsAlpn01` validated the identifier's name and then compared a second read of it against the certificate's SAN, so a name nothing validated could be matched and the identifier reported as confirmed. `pki.acme.externalAccountBinding` validated one account JWK and could bind another; `pki.acme.finalize` validated one identifier list and could assert the CSR against another; `pki.acme.keyChange` could sign its inner and outer JWS for different `url` values. `pki.jose.sign` adopted one key for signing and computed the expected signature width from a second read of it. `pki.ct.verifySctWithLogList` reported a timestamp read after verification rather than the one the signature covered, and `pki.ct.fetchLogList` read `requireJsonContentType` only after the request returned. Each of those verbs now reads the members named once, at entry, and runs every check and every result off those values. The same pattern one level deeper, a field inside a record a caller supplies, is not covered by this release.
158
+ - `pki.ct.addChain` read `opts.chain[0]` twice, once to build the submitted body and once to build the entry the returned SCT is verified over. A caller whose array answered the two reads with different certificates would submit one and have the log's receipt accepted as a receipt for the other. Each element is copied once now and both the request and the check are derived from that copy.
159
+ - `pki.sigstore.verifyBundle` read `opts.fulcioRoots` and `opts.ctLogs` at the point of use, which is after the timestamp check has already awaited. A caller could pass no anchor, add one while verification was pending, and have the chain accepted against material the call was not made with. Both lists, and the Rekor keys with them, are normalized before the first await.
160
+ - `pki.ct` held a caller's bytes as a view onto their buffer rather than a copy, and its verbs import a key with an await before they verify. An RSA signed tree head whose signature buffer was overwritten in that window was verified against the replacement rather than against the bytes supplied at entry, so `pki.ct.verifySth` could return true for a signature the caller never passed. Every byte input to the module is now copied at the door, which covers `verifySth`, `verifySct`, `addChain`, the log-list check and the entry builders alike. An ECDSA signature was never exposed, its DER-to-P1363 conversion having copied it already.
161
+ - `pki.relatedCert.verifyRequest` encoded `certID` twice, once into the signed preimage and once for the comparison against the certificate. A caller whose `certID`, or whose `issuer` or `serialNumber` inside it, answered each read differently could have the proof hashed over one identifier and compared against another, so a proof made for one certificate would verify against a second certificate sharing its public key. The identifier is now encoded once and those bytes are used for both.
162
+ - `pki.sigstore.verifyBundle` read the pinned Rekor keys from the caller's own objects at each check, and the checkpoint and the signed entry timestamp are verified on either side of an await. A caller that replaced a key record in between could have the two authenticated under different key material filed under one log identifier. The pinned keys are now copied, field by field and window included, before the first await, and each field is read from the caller exactly once.
163
+ - `pki.sigstore.verifyBundle` took the first RFC 3161 timestamp that verified and refused the bundle if the Fulcio certificate was not valid at that instant. A signature re-timestamped while it is archived carries more than one token, and the one listed first need not be the one made at signing, so a bundle another token dates inside the certificate's life was refused with `sigstore/chain-invalid`. Every verified timestamp is now a candidate and each is tried, as the transparency-log entries already were. The number of combinations tried is bounded by the same per-list cap, and a bundle asking for more is refused.
164
+ - `pki.hpke.setupS` and `seal` accepted `opts.eph`, a fixed sender ephemeral key pair, on a PQ/T hybrid suite and then generated a random encapsulation instead, so a caller who named a key got a success that did not use it. A hybrid takes its encapsulation randomness from the runtime, as ML-KEM does, and now refuses `opts.eph` with `hpke/bad-input` the way the ML-KEM suites already did. The DHKEM suites honor it unchanged.
165
+ - `pki.hpke` checked the width of a raw key, seed or encapsulated key by reading its `length`, which a Buffer can carry as an own property that differs from its real byte count. A value longer than the suite's width could therefore pass the check and have its extra bytes used. `decap` accepted a 64-byte encapsulated key reporting 32 and folded the hidden suffix into the KEM context, so changing only those extra bytes changed the shared secret it returned; `encap` accepted a 64-byte DHKEM public key reporting 32; and `decap` accepted a 64-byte hybrid seed reporting 32. Every byte a caller supplies is now snapshotted before any width is read, so the length checked is the length used, and an over-long value is refused with `hpke/bad-key`. This covers `setupS`, `setupR`, `seal`, `open`, `encap`, `decap`, `opts.eph` and `opts.senderKey`, and it also means a caller holding a reference can no longer change those bytes after they are checked.
166
+ - Every byte source these verbs say they take is now one they read. A door would accept any `BufferSource`, then measure or copy the value through a primitive that wants a Buffer or a Uint8Array, so an `ArrayBuffer` or a `DataView` was refused one line after being accepted. `pki.tlog.parseNote`, `verifyNote`, `verifyCheckpoint`, `parseTile`, `parseEntryBundle` and `parseVkey` left an untyped `TypeError` on an `ArrayBuffer`, which is what `await response.arrayBuffer()` hands over, and refused a `DataView` or a `Uint16Array`. `pki.hpke.deriveKeyPair` refused an `ArrayBuffer` and a `DataView` while its own message named both as acceptable, and the same pairing sat on HPKE's `info`, `aad` and raw key doors. `pki.tuf.verifySignatures` refused a `metadata.signedBytes` in either form, `pki.schema.<format>.decodeExtension` refused an extension value in either form, and `pki.sigstore.verifyBundle` refused rather than copying a byte option. In `pki.cmp` a `certTemplate.publicKey` given as an `ArrayBuffer` was read as an ordinary request for a key the caller already holds, rather than as the request for a centrally generated key it was. Each door now normalizes to a byte view before it measures or copies, which allocates nothing, so the size cap is still read before any copy the size of the input. A string, a number and a plain object are refused as before, with the same typed codes.
167
+ - A size limit is read before the copy it bounds, so an oversized input is refused without being duplicated first. `pki.ct.verifyLogListSignature` copied the value it was given and then compared it against the 4 MiB log-list cap, so a 64 MiB input allocated another 64 MiB before the limit that refuses it was read; `pki.ct.parseSctList` did the same against the DER ceiling. Every other byte input to a `pki.ct` verb had no size limit before its copy at all, so the copy went ahead at whatever size was supplied: a 32 MiB value handed as a 32-byte tree head hash allocated 32 MiB, and one handed as its signature allocated 64 MiB across the two reads, the same on the certificate, issuer-hash, proof-node, pinned-key and chain inputs of `verifySth`, `verifySct`, `verifySctList`, `signSct`, `getProofByHash`, `getSthConsistency`, `addChain`, `addPreChain`, `getSth` and `fetchLogList`. The one door they all enter through now carries the toolkit's DER ceiling as its default, so each of those is bounded and a verb added later is bounded without anyone remembering to bound it. A value between a field's own fixed width and that ceiling is still copied before the check that names the width refuses it, which keeps each field's own error code rather than replacing it with a generic one. A log list given as a string is now measured as the UTF-8 it will encode to rather than by its code-unit count, which are different numbers for any text outside ASCII: 1,500,000 characters of three bytes each is 1.5 million code units and 4.5 million bytes, and only the second of those is over the cap. `pki.hpke.seal`, `open`, `setupS`, `setupR`, `encap` and `decap` likewise copied raw key material before checking it against the width the suite's keys are, so a value handed where a 32-byte key belongs was copied in full and then refused. An encapsulated key was the same: `open`, `setupR` and `decap` copied it before checking it against the width the KEM's encapsulated keys are, and the recipient setup copied it a second time, so a 64 MiB value against a 32-byte width allocated 128 MiB. Each limit now reads the input's authoritative byte length first, and the copy happens only for a value within it. A wrong-width key is refused as before, with the same typed code.
168
+ - A `pkm` supplied beside an `skm` is held to matching it whether the key pair carries it as its own property or inherits it. The rule read an own property only, so a key pair exposing `pkm` through its prototype had the check skipped rather than applied and the field read as absent, while the `skm` beside it was always read through the prototype: the two fields disagreed about what counts as supplied. `pki.hpke.open`, `setupR` and `decap` now read both the same way, once each. A matching `pkm` is accepted either way, a key pair carrying none is accepted as before, and a mismatching one is refused with `hpke/bad-key`.
169
+
170
+ ## v0.8.45 — 2026-10-03
171
+
172
+ Encrypt to a post-quantum and a classical key at once, so one broken primitive does not open the message.
173
+
174
+ ### Added
175
+
176
+ - The HPKE PQ/T hybrid KEMs MLKEM768-P256 (0x0050), MLKEM1024-P384 (0x0051) and MLKEM768-X25519 (0x647a), in the base and psk modes with every KDF and AEAD the module already offers. A hybrid decapsulation key is a 32-byte seed that both component keys expand from; its encapsulated key and ciphertext are the ML-KEM component followed by the group component. The shared secret is the CFRG C2PRI combiner, SHA3-256 over the ML-KEM secret, the group secret, the group ciphertext, the group public key and the suite label. All three are verified against the draft-irtf-cfrg-concrete-hybrid-kems Appendix B known-answer vectors, which pin the combiner's exact preimage: the ML-KEM ciphertext and public key are not bound, ML-KEM being ciphertext-collision-resistant already, and binding them would derive a different secret from the same exchange.
177
+ - `pki.hpke.deriveKeyPair(kemId, ikm)`, `generateKeyPair(kemId)`, `encap(kemId, publicKey)` and `decap(kemId, enc, privateKey)` expose the KEM without a key schedule over it, for a caller building its own. They work for every KEM the module registers, not only the hybrids. `deriveKeyPair` is the KEM's own `DeriveKeyPair`: input keying material, run through the derivation its specification defines, so the pair matches what any other conforming implementation derives from the same input. It is checked against the published ikm-to-key vectors of RFC 9180 and draft-ietf-hpke-pq for every suite. To use a private key you already hold, pass it as `{ skm }` to `decap`, `setupR` or `open`.
178
+ - MLKEM768-X25519 is the X-Wing construction. `draft-connolly-cfrg-xwing-kem` expired in September 2026 and `draft-irtf-cfrg-concrete-hybrid-kems` carries the same construction, under the same label, with published vectors; that is the document the implementation follows.
179
+ - A hybrid KEM refuses the auth and auth-psk modes with `hpke/auth-unsupported`, at the sender and the recipient, for the reason ML-KEM does: neither defines an AuthEncap. A wrong-length seed, a public key or encapsulated key of any other total length, a `pkm` that is not the derivation of the supplied seed, and on the two prime-curve suites a traditional half that is not a point on the curve, are each refused with `hpke/bad-key` before any decapsulation runs. MLKEM768-X25519 has no point check to make, RFC 7748 sec. 5 making every 32-byte string a valid X25519 public key, and refuses a half that drives the agreement to the all-zero output.
180
+ - `pki.possession.verifyRequest` takes `opts.intermediates`, the rest of the signature certificate's certification path, for the ordinary case where an intermediate CA issued that certificate rather than a trust anchor directly. It is ordered as `pki.path.validate` takes a path, from the certificate nearest an anchor down to the issuer of the signature certificate. Without it only a signature certificate an anchor issued itself can satisfy the RFC 9883 sec. 4 path-validation MUST.
181
+
182
+ ### Fixed
183
+
184
+ - `pki.possession.verifyRequest` and `pki.possession.statementOf` check that the certificate a statement carries is the one its `signer` names, whichever way the statement reaches them. A request parsed by `pki.schema.csr.parse` carries the statement already decoded, and that record was returned without the check, so a request whose statement named one certificate while carrying another was accepted with `valid: true` even though the `signer` it reported did not identify the key that signed the request. Both routes now refuse it with `possession/signer-mismatch`.
185
+ - `pki.x509.sign` and `pki.crl.sign` hold an alternative signing key to the same two checks the native one has always had. It must be the private half of `issuer.altPublicKey`: two different keys of one algorithm resolve to the same signature algorithm, so signing succeeded and emitted an artifact whose alternative signature did not verify under the key the issuer named. And `issuer.altPublicKey` must be the alternative key `issuer.cert` publishes in its `subjectAltPublicKeyInfo`, since that is the key a relying party reads to check an alternative signature; an issuer certificate that publishes none cannot sign an alternative signature at all. A self-signed certificate is held to the alternative key it publishes itself. Each is `x509/bad-input` or `crl/bad-input`.
186
+ - `pki.tuf.canonicalJson` refuses a string carrying an unpaired UTF-16 surrogate, in an object key as well as a value. Such a code unit has no UTF-8 encoding and the conversion to bytes substituted U+FFFD for it, so two documents differing only there had identical signing bytes and a signature over one verified over the other while the document read back was the other one. It is now `tuf/bad-input`. A well-formed surrogate pair is an ordinary code point and still encodes.
187
+ - `pki.tuf.verifySignatures` checks that a key's material is of the algorithm the key declares before verifying anything under it. A key's identifier is the hash of the whole key object, so its `keytype` and `scheme` are as authenticated as the material, but the PEM was imported and handed to the verifier as it stood: a key filed under `keytype: "ecdsa"` whose PEM held an RSA public key verified an RSA PKCS#1 v1.5 signature and reported `verified: true`. The keytype and scheme must now be a pair the specification lists, an EC key must be on the curve its scheme names, and an RSA key must be at least 2048 bits; a key that is not is `tuf/bad-key`.
188
+ - `pki.tuf.updateRoot` judges expiry on the root the chain finishes on rather than on the trusted root before the walk, which is where the specification puts the freeze-attack check. A client offline long enough for its pinned root to lapse could not adopt a correctly signed unexpired successor, so recovering meant replacing the trust anchor by some other means; the same check on every intermediate root stopped a client catching up across older rotations. A chain that ends on an expired root, and a lapsed root with no successor to move to, are still `tuf/expired`.
189
+ - `pki.sigstore.verifyBundle` holds a Rekor entry's `keyDetails` to the whole algorithm it names, not just the key family. Every NIST curve shares the `ecPublicKey` object identifier and every RSA size shares `rsaEncryption`, so comparing identifiers alone left the curve and the modulus size unchecked and an entry could state `PKIX_ECDSA_P384_SHA_384` over a P-256 key that signed with SHA-256. The curve an ECDSA name states and the modulus size an RSA name states are now checked against the leaf key, and a mismatch is `sigstore/entry-mismatch`.
190
+ - A `keyDetails` name states a signature scheme too, and `PKIX_ED25519_PH` and the three `PKIX_RSA_PSS_*` names are now refused with `sigstore/bad-tlog-entry` rather than checked under a scheme they do not name. Signatures are verified with RSA PKCS#1 v1.5 and ordinary Ed25519, so an entry declaring RSA-PSS had a PKCS#1 v1.5 signature accepted for it and one declaring Ed25519ph an ordinary Ed25519 signature. Each becomes available once its scheme is carried into verification: RSA-PSS needs the padding and salt length threaded to the verify call, and Ed25519ph a pre-hashed Ed25519 the runtime does not offer.
191
+ - `pki.tlog.verifyCheckpoint` and `pki.tlog.verifyNote` read the caller's bytes once, before anything is decoded, and verify that copy. Both took a view of the caller's buffer and held it across the Ed25519 key import the verification awaits, and `verifyCheckpoint` read the input a second time to decode the checkpoint, so a caller that reused or overwrote the buffer in that window could be handed `verified: true` alongside a tree size and root hash the signature never covered.
192
+ - `pki.ct.verifySct`, `pki.ct.getProofByHash` and `pki.ct.getSthConsistency` copy the trust inputs a caller supplies before anything is awaited. `verifySct` digested the log key to match `sct.logId` and then read that key again to import it, so a caller reusing the buffer could satisfy the log-identity check with one key and have the signature verified under another. The proof clients held the tree head's root hash and the leaf hash across the network fetch, so a buffer reused during the fetch decided what the proof was folded against: an empty proof folds a one-leaf tree to its own leaf hash, which is enough to make a root overwritten mid-fetch accept a proof nobody vouched for.
193
+ - `pki.ct.getSth` and `pki.ct.addChain` copy the pinned log key, and `addChain` the entry it submits, before the request goes out. Both read them again afterwards: the key is what the tree head or the returned SCT is verified under, and the entry is the preimage that SCT covers. A caller reusing either buffer during the request therefore had its receipt checked against a log it never pinned, or issued for bytes it never submitted.
194
+ - `pki.sigstore.verifyBundle` refuses a Rekor entry whose `keyDetails` is present but not one of the names this build reads, including a number or an object. Only a string entered the algorithm checks, so any other type was passed over as though the entry had declared nothing, which made it the way past every check that an unknown name is refused by. Protobuf JSON permits an enum as its numeric value, so a number is a shape a producer emits. An omitted `keyDetails` still states nothing to hold and still verifies.
195
+ - `pki.tuf.updateRoot` judges expiry at the instant supplied when validation began. A `Date` is mutable and the caller keeps theirs, and the freeze-attack check runs after a signature has been verified for every root in the chain, so a root that had expired by the instant given passed if that `Date` was moved back while the chain was walked.
196
+ - `pki.tlog.verifyNote` and `pki.tuf.verifySignatures` copy the KEY material a caller supplies, not just the document, and derive each key's identifier from that copy. A note or a document with several signatures awaits a verification for each one while the key list is held, so a caller replacing a later key's bytes in that window had its identifier checked against one key and its signature verified under another: in a note, a signature line could be attributed to a signer whose key never signed it, and in TUF metadata a role's threshold could be met by material nobody authorized. `pki.tuf.verifySignatures` also copies the role, whose threshold is compared against the count once every signature has been checked.
197
+ - `pki.possession.verifyRequest` decides the RFC 9883 section 4 prohibition on obtaining a signature certificate from the subject key as well as from the key usages a request asks for. It read only the usages, so omitting the `keyUsage` extension reported the prohibition as not engaged while asking to certify a key that can only sign, and such a key needs this mechanism for nothing. Every signature-only family the toolkit reads now engages it: EdDSA, RSASSA-PSS, the stateful hash-based algorithms, and the ML-DSA, SLH-DSA and composite ML-DSA families. A bare `rsaEncryption` key can do key transport and an EC key can agree, so those two are still decided by the usages the request names.
198
+ - `pki.relatedCert.verifyRequest` verifies a proof made with an RSASSA-PSS key whose SubjectPublicKeyInfo restricts its hash. Deciding whether naming a digest would change the signature algorithm was done by resolving the algorithm at two digests and comparing, and one of those probes asked for SHA-512, which such a key refuses: the probe failed before the digest the caller asked for was resolved, and a valid proof under the key's own hash was reported as an unsupported algorithm. A restricted key is asked what it pins instead of probed, `digestAlgorithm` naming that hash is accepted, and naming a different one says which hash the key is restricted to.
199
+ - `pki.ct.sthSignedData` accepts the tree-head record `pki.ct.getSth` returns. The record carries the log's own JSON document beside the decoded fields, and that field was refused as unknown, so asking for the bytes the log signed over a tree head just fetched threw instead of producing them.
200
+ - `pki.ct.addChain` and `pki.ct.addPreChain` return the SCT in the shape the rest of the module reads, the one `pki.ct.parseSctList` produces: the algorithm bytes decoded, and `signature` the raw signature rather than the TLS digitally-signed structure it arrived in. A receipt is useful only if it can be embedded and re-verified, and it was in neither shape, so `pki.ct.encodeSctList` and `pki.ct.verifySct` both refused a receipt the log had just issued.
201
+ - `pki.possession.verifyRequest` refuses a request that asks for a `subjectAltName` or `keyUsage` extension whose value does not decode, with `possession/bad-request`. An unreadable extension read as an absent one, so the two RFC 9883 section 4 name comparisons had nothing to compare and reported that the policy did not apply, while the request itself came back `valid: true`. A certification request's parser holds an extension's wrapper to its shape and not its decoded value, so nothing upstream refused it either.
202
+ - `pki.tlog.parseVkey` accepts a verifier key whose base64 material contains `+`. Only the first two plus characters are separators, the name and key ID coming before them, and the rest of the line is base64 whose alphabet includes `+`; rejecting a third one refused most real Ed25519 verifier keys, a 44-character field carrying a plus more often than not. A key name containing a plus is still refused, since the field between the separators must be eight hex digits.
203
+ - `pki.transport.peerChain` bounds a TLS handshake with an absolute deadline, the timer the request path already used, rather than with the socket's inactivity timeout. The two differ for a peer that keeps a connection busy without completing the handshake, and `timeout` is stated as a budget for the operation.
204
+ - `pki.tlog.parseTilePath` bounds a tile index to the same uint64 range `pki.tlog.tilePath` emits. It accepted any number of index segments and multiplied a growing integer once per segment, so it read indices no writer can produce and a long path from an untrusted server cost work in proportion to its length. A path with more index segments than a uint64 index occupies, or naming a value above that range, is `tlog/bad-tile`, and the segment count is checked before any of them is read.
205
+
206
+ ## v0.8.44 — 2026-09-29
207
+
208
+ Certify a key that cannot sign, by signing the request with one that can.
209
+
210
+ ### Added
211
+
212
+ - `pki.csr.sign` takes `spec.privateKeyPossessionStatement`, and signs with the key given rather than the key being certified. That is the one case where it accepts a signing key that is not the subject's and a subject key that cannot sign, and both remain refused for a request that declares no statement, so the relaxation reaches only the request that asks for it. The signature algorithm is resolved from the statement certificate's key, which is the key that signs. `includeCertificate: false` emits the compact form RFC 9883 allows, where the CA looks the certificate up; the builder still needs it, because it resolves the algorithm from that key and checks its own signature against it.
213
+ - `pki.crmf.build` takes the same thing as a registration control, `controls.statementOfPossession`, which RFC 9883 gives the value of its PKCS#10 attribute, so one OID carries one structure in both places. The proof of possession is signed by the statement certificate's key for the same reason.
214
+ - `pki.possession.verifyRequest` is the CA's side, and it enforces the two requirements RFC 9883 states as MUSTs. The signature on the request is validated with the public key from the signature certificate, which for a key-establishment key is the only key that could have signed anything. The signature certificate's certification path is validated, so `opts.trustAnchors` is required and the verb throws without it instead of returning a verdict that skipped a MUST.
215
+ - The two name comparisons RFC 9883 states as SHOULDs are reported rather than enforced, because each ends in "the certificate policy MUST describe how the CA can determine that the two subject names identify the same entity". `subjectMatches` and `subjectAltNamesMatch` carry the comparison, `valid` is `false` with a reason naming the policy decision when they differ, and a caller whose policy resolves it reads the fields and decides. A request asking for no alternative names reports `null` rather than a match.
216
+ - `requestsSignatureCertificate` reports the prohibition: "The privateKeyPossessionStatement attribute MUST NOT be used to obtain a signature certificate." A request carrying the statement while asking for `digitalSignature`, `nonRepudiation`, `keyCertSign` or `cRLSign` is that misuse, and `pki.lint.csr` with the new `rfc9883` profile grades it as an error.
217
+ - `pki.possession.parse` reads a `PrivateKeyPossessionStatement` from either carrier. A statement that carries a certificate its `signer` does not name is refused: the two would describe different certificates, and therefore different keys, while only one of them signed the request. The same check runs on the way out, so a statement this toolkit emits is one it would accept.
218
+ - `pki.crmf.verifyPop` checks the proof under the statement certificate's key when the control is present, and reports `subjectBound: false` for it. That is the trade the mechanism makes and the verdict says so: the proof demonstrates possession of the signature key, and nothing in the message demonstrates possession of the key-establishment key being requested, because that key cannot sign.
219
+
220
+ ### Changed
221
+
222
+ - The signed envelope every format composes now documents why its outer arity is exactly three. ITU-T X.509 (2019) added optional `altAlgorithmIdentifier` and `altSignature` components to the SIGNED type and then required them absent for every structure it profiles, so declaring either as an optional field would admit a second signature the text excludes. Certificates, CRLs, certification requests and attribute certificates each gained vectors for that refusal.
223
+
224
+ ## v0.8.43 — 2026-09-29
225
+
226
+ Issue one certificate carrying two signatures, so a PKI can change algorithms without reissuing to everyone at once.
227
+
228
+ ### Added
229
+
230
+ - `pki.x509.sign` takes `issuer.altKey` beside the issuing key, with `issuer.altPublicKey` and `extensions.subjectAltPublicKeyInfo` naming the issuer's and the subject's alternative keys, and emits all three extensions. Clause 7.2.2 fixes an order that is easy to get wrong and expensive to get wrong: the alternative signature is generated first, over the certificate WITHOUT it and without the outer signature, and the native signature then covers the certificate WITH it. Signing in the other order leaves a native signature that does not cover the alternative one. The verb does both passes, so the order is not the caller's to arrange. `altSignatureAlgorithm` is resolved from the alternative key and `altSignatureValue` is computed, so supplying either is refused rather than emitted unchecked, and either half of the mechanism without the other is refused, which clause 7.2.2 requires.
231
+ - `pki.crl.sign` takes `issuer.altKey` for the same mechanism on a CRL, which clause 7.10.3 gives the same procedure and the same two extensions. `subjectAltPublicKeyInfo` is not emitted on a CRL, the NOTE in clause 7.2.2 stating it is a certificate extension only.
232
+ - `pki.altSig.signedData` builds the bytes an alternative signature covers. Clause 7.2.2 requires a verifier to rebuild that encoding, since it never appears on the wire. This is the one place the toolkit re-encodes what it parsed, and it is done by keeping the original bytes of every component retained and recomputing only the SEQUENCE headers whose lengths change: nothing is re-serialized from a decoded model, because a model that normalized any byte would either fail every verification or accept an encoding the issuer never signed.
233
+ - `pki.altSig.verify` checks that signature under the issuer's alternative public key, which clause 9.8.4 requires, through the same engine certification-path validation uses. The algorithm is not derived: the structure states it in `altSignatureAlgorithm`, and that stated algorithm is bound to the key through the same machinery, so a key that does not match the stated algorithm resolves `false` rather than being read under some other algorithm. A structure that states no alternative signature throws rather than resolving `false`, since there is nothing to check and a `false` would read as a signature that failed.
234
+ - `pki.altSig.subjectAltPublicKey` reads a certificate's own alternative public key, which is the key that verifies a certificate this one issued. `SubjectAltPublicKeyInfo` carries the same two components in the same order as `SubjectPublicKeyInfo`, and a field name is not encoded, so the extension value is an SPKI on the wire and is returned unchanged rather than rebuilt from its parts.
235
+ - `pki.schema.x509.parse` and `pki.schema.crl.parse` decode the three extensions through the shared decoders, holding an alternative key and an alternative signature to whole octets and refusing an empty one. `pki.inspect.certificate` reports each by its algorithm and its length, which for a post-quantum key and signature is thousands of octets and is the case the extensions exist for.
236
+ - `pki.lint.certificate` gains the `x509-altsig` profile. Clause 9.8.3 states the pairing of `altSignatureAlgorithm` with `altSignatureValue` as a shall, and clause 7.2.2 requires a certificate carrying the mechanism to carry all three, so both are graded as errors. Criticality is stated only in NOTEs recommending non-critical, with the clauses saying either is permitted, so a critical extension is graded as a warning and still parses: grading a NOTE as an error would report a requirement level the text does not state.
237
+
238
+ ## v0.8.42 — 2026-09-29
239
+
240
+ Let one subject hold a classical certificate and a post-quantum one, and have each name the other.
241
+
242
+ ### Added
243
+
244
+ - `pki.relatedCert.requestSignedData` builds the bytes a related-certificate request is proved with. Section 3.1 fixes them as the DER `IssuerAndSerialNumber` followed by the DER `BinaryTime`, and nothing further. `locationInfo` travels in the attribute outside those bytes, so a proof that verifies says the requester holds the certificate and says nothing about where it can be fetched: a caller acting on `locationInfo` is acting on an unauthenticated field, and the toolkit's own vectors pin that scope rather than assume it.
245
+ - `pki.csr.sign` takes the whole attribute as `spec.relatedCertRequest`, and encodes its first two fields through `requestSignedData` itself, so the bytes signed and the bytes emitted cannot drift apart. `locationInfo` is a `SEQUENCE OF` IA5String URIs rather than a single URI, and the order the requester writes is the order carried; a non-ASCII URI, an empty list and a missing field are each refused. `pki.schema.csr.parse` reads the attribute back, and a pre-encoded one is refused so a producer cannot route around that validation, which is the rule the `extensionRequest` and `challengePassword` attributes already follow.
246
+ - `pki.relatedCert.verifyRequest` checks the proof through the same engine that verifies a certificate path, so the algorithm it resolves is bound to the key the same way. The structure carries no algorithm identifier, so one is derived: the key decides the algorithm, resolved through the same resolver `pki.x509.sign` and `pki.csr.sign` use, and the digest comes from the hash the certificate's signature OID indicates, which is the relation section 4.1 names. Every algorithm those verbs sign with is therefore reached, RSA PKCS#1 v1.5, RSASSA-PSS, ECDSA, Ed25519, Ed448 and all three ML-DSA parameter sets, each with a vector. `opts.digestAlgorithm` names the digest instead, and a key whose algorithm fixes its own digest refuses that option instead of ignoring it; `opts.signatureAlgorithm` takes a DER `AlgorithmIdentifier` outright. A key that cannot sign, such as an ML-KEM or X25519 key, is refused. Handing over a certificate other than the one `certID` names throws, since verifying a proof against a certificate the request did not name answers a different question.
247
+ - `pki.relatedCert.certificateHash` and `pki.relatedCert.matchesCertificate` are the extension side, over the whole related certificate rather than its `tbsCertificate`. With no algorithm named, the digest is the one the related certificate's own signature OID indicates, which is what section 4.1 directs; a certificate signed with Ed25519 or ML-DSA indicates none, and that throws `relatedcert/no-digest` instead of falling back to a hash the document does not name. `sha1` is refused, a chosen-prefix collision on a certificate being a demonstrated attack. A value this build cannot compute throws instead of comparing to nothing and reporting no match.
248
+ - `pki.x509.sign` takes `extensions.relatedCertificate`. Handed the related certificate it computes the digest, which is the form in which no caller can name one algorithm while carrying the output of another; handed a `hashAlgorithm` and `hashValue` it emits a digest computed elsewhere, held to that algorithm's output length. Supplying both is refused, the two being able to disagree while only one is emitted. The extension is emitted non-critical, which section 4.1 asks for at SHOULD NOT.
249
+ - `pki.lint.certificate` gains the `rfc9763` profile. A critical `relatedCertificate` extension is graded as a warning, at the strength the SHOULD NOT clause states, and still parses: refusing it would report that clause as a prohibition. The extension on a `cA: true` certificate is graded as an error, section 4.1 requiring that a chain carry it only in the end-entity certificate. `pki.inspect.certificate` renders the extension as its digest algorithm and the full digest.
250
+ - An ECDSA proof goes into the attribute's BIT STRING as the DER `SEQUENCE { r, s }` of RFC 5480 section 2, as in every other ASN.1 signature field. A WebCrypto `sign` returns the fixed-width `r || s` instead, and handing that form over resolves `false`; the documented example re-encodes it.
251
+
252
+ ### Changed
253
+
254
+ - `IssuerAndSerialNumber` is now defined once and read by both CMS and the RFC 9763 attribute, so the two cannot diverge on the structure they share. The signature-algorithm-to-digest relation and the digest output lengths are likewise defined once for the readers that need them.
255
+
256
+ ## v0.8.41 — 2026-09-29
257
+
258
+ Verify a TUF trust root, and walk its key rotations.
259
+
260
+ ### Added
261
+
262
+ - `pki.tuf.canonicalJson` is the encoding TUF signatures cover, so it is a wire format rather than a formatting choice: object keys sort by code point, no whitespace is emitted, and inside a string only backslash and quote are escaped. A literal control character stays itself, which is where a general JSON writer differs by writing an escape and signing other bytes. Keys sort by code point rather than by UTF-16 code unit, which are not the same order for a key outside the basic multilingual plane. A float, a number past the exactly-representable integers, `undefined` and a function are refused rather than approximated, because the reference encoder cannot write any of them.
263
+ - `pki.tuf.keyId` derives a key's identifier as the hex SHA-256 of the key's own canonical form, and `pki.tuf.verifySignatures` recomputes it for every key a role names before checking any signature. A key filed under an identifier that is not its own is refused as malformed metadata rather than treated as a signature that failed.
264
+ - A threshold counts one verified signature per distinct key identifier. A key listed twice, or signing twice, counts once, so a threshold cannot be met by repetition; a signature from a key the role does not name counts for nothing whatever it verifies over. A shortfall resolves `verified: false` rather than throwing, since whether a threshold was met is a verdict about the metadata, while a key or signature this build cannot read is a fault in the document and throws.
265
+ - `pki.tuf.updateRoot` walks the root chain under the two rules that make a rotation safe: each new root must be signed by a threshold of the keys the previous root names AND a threshold of the keys it names itself, and its version must be exactly one more than the trusted one. A root meeting only one threshold is refused, a candidate that skips a version is refused so no intermediate can be passed over, and one at or below the trusted version is refused by the same rule, which is the rollback check. The trusted root is verified against its own role and expiry before any candidate is read.
266
+ - `pki.tuf.checkExpiry` is the freeze-attack check: a repository that stops publishing leaves a client holding metadata that stays valid forever unless its expiry is read. The instant is a caller value rather than the system clock read inside the check, so a verdict is reproducible.
267
+ - `pki.tuf.parseMetadata` refuses a duplicate JSON member before reading any field. The canonical form would carry only one of a repeated member, so the signature would cover a document different from the one delivered.
268
+
269
+ ## v0.8.40 — 2026-09-29
270
+
271
+ Talk to a Certificate Transparency log: ask for its tree head, its proofs, and its entries.
272
+
273
+ ### Added
274
+
275
+ - `pki.ct.getSth` fetches a log's latest signed tree head and verifies it under the key `opts.logKey` pins. A head whose signature does not verify is `ct/sth-untrusted` and no field of it is returned: everything a client does next is measured against that root, so a root nobody vouched for is not a result. `opts.logKey` is required; there is no unverified mode and no baked-in key.
276
+ - `pki.ct.sthSignedData` builds the 50 bytes a log signs, which RFC 6962 sec. 3.5 fixes as the version, the signature type `tree_hash`, the timestamp and tree size as big-endian uint64s, then the 32-byte root. `pki.ct.verifySth` checks a head a caller already holds against it, resolving a boolean rather than throwing, since whether a given key signed a given head is a verdict about the head.
277
+ - `pki.ct.getProofByHash` fetches a leaf's audit proof and folds it to the root of the tree head it was requested at. The head is a caller value from `getSth`, so the tree size the query needs comes from it and a size and a head cannot disagree. A proof that does not fold, or that does not support the leaf index the log states, is `ct/proof-mismatch`.
278
+ - `pki.ct.getSthConsistency` fetches the proof between two tree heads and reconstructs both roots, which is the append-only check. Both sizes come from the two heads. A proof that does not reconstruct them is `ct/consistency-mismatch`: the log rewrote history, or served a proof for a different pair.
279
+ - `pki.ct.getEntries` fetches entries by index range and `pki.ct.getRoots` fetches the roots a log accepts. Neither response carries a signature, which the specification says outright of get-entries, so neither is verified and both say so. A log may serve fewer entries than the range asks for, which the specification permits, and that is reported as `truncated` beside `requested` rather than treated as a fault; more entries than the range asks for is refused.
280
+ - `pki.ct.addChain` and `pki.ct.addPreChain` submit a chain and verify the signed certificate timestamp the log returns against the chain it was issued for. An SCT that does not verify, or that names a different log than the pinned key, is `ct/sct-untrusted` and is not returned: a receipt nobody can check is not a receipt. The precertificate form verifies over the `precert_entry` shape, whose signed data is the issuer key hash and the TBSCertificate.
281
+ - Every message reaches the log over the same injectable transport `pki.ct.fetchLogList` uses, with the same response cap, timeout and header filtering, and the same refusal to reach an unpinned server: with no transport and no TLS anchor, `ct/no-trust-anchors`. A log's base URL is accepted with or without the `ct/v1/` prefix the specification fixes, and neither spelling doubles it.
282
+
283
+ ## v0.8.39 — 2026-09-29
284
+
285
+ Verify a bundle logged in a Rekor v2 tiled transparency log.
286
+
287
+ ### Added
288
+
289
+ - `pki.sigstore.verifyBundle` reads a Rekor v2 `hashedrekord` v0.0.2 entry, on a DSSE envelope or a message signature. The entry is bound to the bundle by three separate comparisons, because dropping any one leaves inclusion proving nothing about this artifact: the signature the entry records equals the bundle's, the certificate it names is the bundle's Fulcio leaf byte for byte, and the digest it records is the hash of what was actually signed. For a DSSE envelope that digest is over the signing preimage, recomputed from the bundle's own envelope; for a message signature it is the artifact digest the caller confirms against the bytes it supplies.
290
+ - Where a v0.0.2 entry states `keyDetails`, that statement is held to the leaf key rather than read in its place: an entry naming one algorithm while the signature was made with another is refused. An algorithm name outside the set this build reads is refused rather than passed over, since passing over it would accept the statement unchecked.
291
+ - `opts.tsaRoots` pins timestamp-authority anchors, and an RFC 3161 token over the signature bytes dates the bundle. A Rekor v2 entry writes `integratedTime` as zero, so it is ignored and never read as a log time; with no token this caller can check and no `opts.time`, the verdict is `sigstore/no-attested-time`, because a Fulcio certificate lives about ten minutes and cannot be validated against an unattested instant. The token must cover the signature bytes: one over the payload would date the thing signed rather than the signing. An empty `tsaRoots` list is refused rather than read as a timestamp policy that checks nothing.
292
+ - The verdict reports `timestampSource`, naming which of the caller's instant, a signed entry timestamp, or an RFC 3161 token dated the certificate, and `logOrigin`, `treeSize` and `checkpointKeyId`, each read from the verified checkpoint.
293
+ - The entry version is part of the dispatch, so a v0.0.1 body relabelled 0.0.2 is not read with v0.0.1 field names, and a version this build does not read is refused rather than parsed under the nearest one it does.
294
+
295
+ ## v0.8.38 — 2026-09-29
296
+
297
+ A Sigstore bundle's tree root must be signed by the log the entry names.
298
+
299
+ ### Fixed
300
+
301
+ - A checkpoint is verified against the log the entry names. `verifyBundle` resolved the Rekor key by matching the first four bytes of a pinned key ID against the checkpoint's key hint and never read `logId.keyId`, so with two logs pinned a valid checkpoint signed by either one satisfied an entry claiming the other. A signed tree root from one log is not evidence about another log's tree. Only the key the entry names is now offered, and an entry naming a log the caller did not pin is `sigstore/unsigned-root`.
302
+ - The inclusion proof folds against the tree size and root the VERIFIED checkpoint carries. `inclusionProof.rootHash` and `inclusionProof.treeSize` are covered by no signature, and folding against them and comparing afterwards decided the proof against numbers the bundle chose. A bundle whose unsigned `rootHash` disagrees with the attested one now verifies on the attested value rather than being judged against its own copy.
303
+ - Every Rekor key the entry's identifier selects is tried, for both the checkpoint and the signed entry timestamp. Both legs stopped at the first key whose identifier matched and reported that key's failure, so a caller pinning a rotated pair, or a stale key beside a current one, could have a sound bundle refused as unattested.
304
+ - The checkpoint body is held to the shape its specification fixes, which one reading of it now enforces for every caller: the body is three non-empty lines, the tree size carries no leading zero, the root is 32 bytes, no ASCII control byte appears outside a newline, and the signature lines are capped. A malformed signature line refuses the note instead of being passed over; a well-formed line from a key the caller did not pin is still ignored, which is what a witness cosignature needs.
305
+ - A signed note's text ends at the LAST empty line in it, not the first. The specification allows the text to contain empty lines, so taking the first made the verified range a prefix of what the signer signed: the remainder was then read as signature lines and a conforming note refused. `pki.tlog.parseNote` now scans from the end, and a note whose text carries an empty line verifies under the signature over its whole text.
306
+ - An empty extension line in a checkpoint is refused. With the text separated at the last empty line such a line is inside the body and reaches the check, where before it could not: `pki.tlog.parseCheckpoint` surfaced it as an empty extension. The specification requires every extension line to be non-empty.
307
+ - `pki.tlog` takes its string and array operations from the captures bound at load, so a `String.prototype.split` or `indexOf` replaced after the module loads cannot steer which line of a checkpoint reads as the origin, the tree size or the root. It is now composed inside a verifier already hardened that way.
308
+
309
+ ## v0.8.37 — 2026-09-29
310
+
311
+ Read a transparency log's key whatever algorithm it signs with, and reject a note whose known signer does not verify.
312
+
313
+ ### Added
314
+
315
+ - `pki.tlog.keyId` and `pki.tlog.verifyNote` take an ECDSA key over P-256, P-384 or P-521, and an RSA key, alongside Ed25519. The derivation is read off the key material, so a key cannot be hashed under an algorithm it is not: Ed25519 is `SHA-256(name || 0x0A || 0x01 || raw32)`, ECDSA is `SHA-256(spkiDer)` with neither the name nor a type byte, and RSA is `SHA-256(name || 0x0A || 0xFF || "PKIX-RSA-PKCS#1v1.5" || spkiDer)`. An ECDSA signature is checked with the hash its curve calls for, so a P-384 log key is not verified with SHA-256. An algorithm with no stated derivation is refused rather than hashed under an assumed one.
316
+ - An Ed25519 key may be supplied as its raw 32 bytes or as a SubjectPublicKeyInfo, and both give the same identifier, because one key has one identity.
317
+ - `pki.tlog.parseVkey` reads the `<name>+<hex key ID>+<base64 signature type and key>` form a log publishes to identify itself. The stated key ID is checked against the one the key material derives and the stated signature type against the algorithm that material is, so a verifier key cannot name a key it does not carry. A key name may not contain a plus, so the name ends at the first separator.
318
+ - `pki.tlog.verifyCheckpoint` takes `opts.origin` and refuses `tlog/origin-mismatch` when the checkpoint names a different one, even where the signature verifies. A valid signature from one log over its own tree is not evidence about another log's tree, and with more than one log pinned that is the whole difference. The expected origin is the caller's value and never the key name from the signature line: a Rekor v1 origin appends the tree ID after a space, which a key name may not contain.
319
+
320
+ ### Fixed
321
+
322
+ - A signature line naming a supplied key by both name and key ID, which no such key verifies, now rejects the whole note with `tlog/bad-signature`. It was skipped, so a note carrying a forged line from a known signer resolved `verified: true` on the strength of a different line. A line naming a key the caller did not supply is still ignored, which is the separate rule for a co-signature, and a note carrying no signature from a supplied key still resolves `verified: false` without throwing.
323
+ - An Ed25519 key passes the Edwards-point low-order gate where it is resolved rather than only where it is used, so a key that is not a full-order point is refused before it can be given an identifier or become a verification candidate.
324
+
325
+ ## v0.8.36 — 2026-09-29
326
+
327
+ Assemble a transparency-log inclusion proof from the tiles a log serves.
328
+
329
+ ### Added
330
+
331
+ - `pki.tlog.inclusionProof({ index, size, read })` assembles the RFC 6962 audit path for one leaf of a tiled log and returns what `pki.merkle.verifyInclusion` folds against a verified checkpoint's root. `read(level, index, width)` takes the same three values `pki.tlog.tilePath` takes, so a caller wires it straight to a fetch of that path, and it may return bytes or a promise of them. Tiles are cached for the life of one call, because a tile carries 256 hashes and an audit path walks neighbors.
332
+ - `pki.tlog.tileWidth(treeSize, level, index)` reports how wide a tile is: `null` when it is full, which is the form `tilePath` takes, or 1 to 255 when it is partial. A client has to know this before it asks, since a log serves a full and a partial tile at different paths and the full path for a partial tile is a request for something the log does not have. A tile the tree does not reach is refused rather than answered with a width of zero, because the specification says an empty tile is not served.
333
+ - A tile served narrower than the verified tree size requires is refused as `tlog/bad-tile` rather than folded into a root computed from short data, and an `index` at or above `size` is `tlog/index-out-of-range` before any tile is read.
334
+
335
+ ### Fixed
336
+
337
+ - A tile, an entry bundle and a public key are binary, and a string handed to `pki.tlog.parseTile`, `parseEntryBundle`, `keyId` or `verifyNote` was decoded as UTF-8 instead of refused. That rewrites every byte above 0x7f, so the result could never be the data it stood for, yet a 32-character string is the length of one hash and parsed as a valid one-hash tile. Each now refuses a string with `tlog/bad-input` and says to read the response as bytes. The documented signatures already said bytes; nothing that passes bytes is affected.
338
+
339
+ ## v0.8.35 — 2026-09-29
340
+
341
+ A builder encodes the number you wrote, or refuses it.
342
+
343
+ ### Changed
344
+
345
+ - `pki.cbor.build.uint`, `int`, `nint`, `biguint`, `time` and `tag` refuse a string, a boolean, an array, `null` and a fractional or out-of-range Number where they previously converted some of them. A caller passing an integer, a BigInt, or a Date to `build.time` is unaffected. This is the shape those builders were documented to take; nothing in the toolkit passed anything else.
346
+
347
+ ### Fixed
348
+
349
+ - A CBOR builder no longer converts a non-integer argument. `build.uint("")` encoded CBOR `0`, so a value that was never supplied became a legitimate zero; `build.uint("0x10")` encoded 16; `build.uint(" 16 ")` and `build.uint("007")` encoded 16 and 7; `build.uint(true)` encoded 1; and `build.tag("", inner)` produced tag 0, the RFC 8949 date-time tag, over content that was not a date. Each is now `cbor/bad-argument`, naming the value. A safe-integer Number, a BigInt and a Date where one was already accepted are unchanged.
350
+ - A non-integer Number reaching a CBOR builder is a typed `cbor/bad-argument` rather than a raw `RangeError` escaping from the conversion, so a caller catching the toolkit's own error class sees it.
351
+ - A checkpoint tree size is bounded to a uint64. `pki.tlog.parseCheckpoint` accepted a size of any width while `pki.merkle` refuses a coordinate at or above 2^64, so a checkpoint parsed and then failed at the fold, and a caller reading `treeSize` in between held an unbounded value. A size past the ceiling, or a run of digits too long to be one, is now `tlog/bad-checkpoint` at the parse.
352
+
353
+ ## v0.8.34 — 2026-09-29
354
+
355
+ Build the tree, and produce the proofs a verifier folds back to its root.
356
+
357
+ ### Added
358
+
359
+ - `pki.merkle.root(leafHashes)` computes the RFC 6962 Merkle Tree Hash over a whole tree and returns the 32-byte head to pass to `verifyInclusion` as `rootHash`. An empty array is the empty tree and answers `emptyRootHash()`; a single leaf is its own root, with no interior hashing.
360
+ - `pki.merkle.inclusionProof({ leafHashes, leafIndex })` produces the audit path of RFC 6962 sec. 2.1.1 for one leaf, and `pki.merkle.consistencyProof({ leafHashes, oldSize })` produces the append-only proof of sec. 2.1.2 between an older size and the tree it is given. Each returns the array the matching verifier takes as `proof`.
361
+ - The tree size is the length of the leaf array and is not a separate option on either verb. A caller who passes one is told the option is unknown rather than having a stated size and an actual array disagree, which on the consistency proof decides whether the old tree's own root belongs in the output.
362
+ - Both verbs take leaf HASHES, the form `verifyInclusion` already takes as `leafHash`, and a leaf that is not exactly 32 bytes is refused. Entries map through `pki.merkle.leafHash` first. A 32-byte buffer cannot be read as an entry in one verb and a hash in another.
363
+ - Every leaf slot is read once, into the buffer the length check and the fold both use, so an array whose accessor answers differently on a second read cannot put one value past the check and another into the tree. The leaf count is bounded by `C.LIMITS.MERKLE_MAX_LEAVES` before any slot is touched.
364
+ - An `oldSize` of 0 against a non-empty tree is refused as `merkle/no-consistency-claim`, the same refusal `verifyConsistency` makes and for the same reason: RFC 6962 sec. 2.1.2 defines the proof for `0 < oldSize < newSize`, so there is no proof to produce.
365
+
366
+ ## v0.8.33 — 2026-09-29
367
+
368
+ Read a transparency log's checkpoint, and the tiles it serves beside it.
369
+
370
+ ### Added
371
+
372
+ - `pki.tlog.parseNote` and `pki.tlog.verifyNote` read and check a C2SP signed note: text, a blank line, then one signature line per signer. The signature covers the text including its trailing newline, so `signedBytes` is returned as a view of the input and never as a re-serialization of what was parsed; a verifier that rebuilt the text from its lines would accept a note altered in the delimiter byte. A signature from a key not supplied is ignored, and a note carrying none from a supplied key resolves `verified: false` without throwing, because that is a verdict about the note and not a fault in it.
373
+ - Every Ed25519 key passes the shared Edwards-point gate before it reaches a verify. Node imports a low-order key without complaint and such a key verifies a forged signature, so a key list that picked one up would otherwise make every note verify under it.
374
+ - `pki.tlog.keyId` derives the four-byte identifier a signature carries in front of itself, from the key name and the raw 32 key bytes. The specification calls these identifiers and not strong hashes, so `verifyNote` treats a match as selecting a candidate and checks every supplied key whose identifier matches before reporting.
375
+ - `pki.tlog.parseCheckpoint` and `pki.tlog.verifyCheckpoint` read the body a log signs into that note: the origin, the tree size as a `BigInt`, the 32-byte root hash ready for `pki.merkle.verifyInclusion`, and any opaque extension lines. A tree size carrying a leading zero, an empty origin, a root that is not 32 bytes, or a body of fewer than three lines is refused. The origin is not checked as a URL: the specification recommends a schema-less one and then says a client must not assume the origin follows that shape or names a reachable endpoint.
376
+ - `pki.tlog.tilePath`, `pki.tlog.entryBundlePath` and `pki.tlog.checkpointPath` build the paths a tiled log serves, with the index in the zero-padded three-digit segments that let a log hold its tiles in a filesystem without a directory and a file colliding on one name. `pki.tlog.parseTilePath` reads one back, refusing a level above 63 or carrying a leading zero, a segment that is not three digits, a leading segment without its `x`, a final segment with one, or a partial width outside 1 to 255.
377
+ - `pki.tlog.parseTile` splits a tile into its 32-byte hashes, holding a full tile to exactly 256 of them, and `pki.tlog.parseEntryBundle` splits the big-endian uint16 length-prefixed entries a bundle carries, refusing a prefix that overruns the buffer instead of reading a short final entry. Each entry's `pki.merkle.leafHash` is the matching hash in the level-0 tile, which is what makes a bundle checkable against the tree.
378
+ - Verifying only: nothing here signs a checkpoint and nothing fetches one. Producing a tree is `pki.merkle`'s side, and a transport is the caller's.
379
+
380
+ ## v0.8.32 — 2026-09-29
381
+
382
+ The command line makes certificates, and the tarball carries programs you can run.
383
+
384
+ ### Added
385
+
386
+ - `pki keygen --out <file>` generates a key pair, defaulting to ML-DSA-65 with `--alg` for any suite the engine generates. The private key goes to the file `--out` names and never to stdout, because a terminal's scrollback is a copy of the key and so is the shell history of the pipeline it ran in. An existing file is never written over, since a key written over another destroys the only copy of the first, and the refusal names the file in the way. The file is created with owner-only permissions in the same call that creates it, rather than adjusted afterwards, which would leave a window where it is readable; on Windows Node does not apply a file mode, so the help text says the file inherits the directory's permissions there rather than claiming a guarantee the platform does not give. `--pub` writes the public half to a separate file, so sending a public key does not mean handing over the file that holds the private one.
387
+ - `pki csr --key <key> --subject <dn>` builds a PKCS#10 request over the key's public half, which it derives from the key rather than asking for a second file. `--san` requests names through the RFC 2985 extensionRequest attribute a CA copies into the certificate it issues.
388
+ - `pki issue` writes a certificate, self-signed from `--key` or signed by `--issuer-cert` and `--issuer-key`. An issuer needs both halves: a certificate with no key, or a key with no certificate, is refused rather than quietly self-signed, because a certificate asked to be signed by a CA and returned signed by its own subject key is the failure that looks like success. `--csr` certifies the subject and key a request carries, and verifies the request's signature first, since certifying an unverified request certifies a key the requester may not hold. `--ca`, `--days`, `--serial` and `--san` shape what is issued.
389
+ - `pki fetch <https-url>` prints the certificate chain a live TLS endpoint presents, leaf first. It sends no request, so nothing reaches the application behind the endpoint, and it reports what the handshake checked and what it did not: a fetched chain is not a validated one, and `pki verify` is the verb that validates a path.
390
+ - Five runnable example programs ship in the package: `issue-a-certificate.js` builds a CA, issues a leaf from it and validates the path, `read-a-certificate.js` reads one certificate four ways, `sign-and-verify-cms.js` signs and verifies attached and detached, `post-quantum.js` puts ML-DSA, SLH-DSA and ML-KEM through the same verbs as everything else, and `revoke-and-check.js` publishes a CRL and shows the path stop validating. Run one with `node node_modules/@blamejs/pki/examples/<name>.js`. Each generates what it needs, uses no network, writes nothing to disk, and opens with the `require("@blamejs/pki")` line a reader copies rather than a relative path that would only work inside the repository. Every line they print is asserted, so an example that stops demonstrating what it says fails the build rather than going stale in the tarball.
391
+ - `pki.transport.peerChain(request, defaults?)` is the library primitive behind that verb: it negotiates TLS and returns the channel, including `peerChain`, the certificates the endpoint presented as DER walked up from the peer certificate to the one that issues itself. It prepares the connection through the same code `pki.transport.https` prepares a request with, so the trust anchors, the SNI, the version floor, the identity hook, the private-address block and the https-only rule are one policy rather than a second copy of one. A request with no anchor is refused before any socket opens, and a configured proxy is refused rather than bypassed.
392
+
393
+ ### Changed
394
+
395
+ - `pki inspect <file>` routes through `pki.inspect.any`, so it renders every format the toolkit detects rather than certificates alone: a CRL, a request, a CMS message, a key, a store, an OCSP exchange, a timestamp response. `--asn1` dumps the TLV tree instead. A report on a file holding a private key names the key and prints none of its bytes, which the command line inherits from the library only while inspect routes this way.
396
+ - `pki lint <file>` covers all seven structures `pki.lint` has verbs for, where it covered three: a PKCS#10 request, a CMS message, a timestamp token and an attribute certificate join the certificate, CRL and OCSP response. The usage line and the refusal message are both built from the verb table, so neither can name a set the command does not have.
397
+ - `pki oid`, `pki parse` and `pki inspect` answer `--help` with their own usage line. They took a bare positional argument, so `pki oid --help` reported an unknown OID name and the other two tried to open a file called `--help`.
398
+ - A file that reads as both one complete DER value and a well-formed PEM block is refused by the command line with the command that settles it, where the library's advice, to pass PEM as a string, is not something a reader holding a file can act on.
399
+
400
+ ## v0.8.31 — 2026-09-29
401
+
402
+ A structural dump of any DER file, and it still reads the file when the strict decoder refuses it.
403
+
404
+ ### Added
405
+
406
+ - `pki.inspect.asn1(input, opts?)` takes a PEM string or a DER Buffer and returns the structural dump as a string: per value the byte offset, the nesting depth, the header length, the content length, whether the value is constructed, the tag name, and for a primitive its rendered content. The offset, depth, header length, content length and form are written in the same fixed columns `openssl asn1parse` uses and are compared against it line for line across every seed corpus the toolkit ships, so the two outputs can be diffed directly. An OBJECT IDENTIFIER carries its dotted form and the name the registry has for it, which is the part `openssl` shows only for the OIDs its own table knows. An INTEGER carries its exact magnitude, read from the content bytes, with the bytes beside it once the value passes what a machine integer holds.
407
+ - A UTCTime or a GeneralizedTime renders the bytes as written rather than a formatted date. A dump is what you reach for when a file is wrong, and a time normalized through a Date hides the malformation being looked for: a month of 13 rolls into January of the next year and reads as valid.
408
+ - The report says which route produced it. `pki.asn1.decode` reads the input first. If it refuses, the first two lines name the walk and the refusal with its code and message, so a file that is not DER is explained rather than rejected: a truncated file, bytes after the top-level value, an indefinite length with its end-of-contents octets, a segmented OCTET STRING, a non-minimal long-form length, a SEQUENCE encoded primitive. The last line always states the outcome: the offset where the structure ends, or the offset of the value the walk could not read through and why, or the bytes that follow the top-level value, or the cap that stopped the report and where the next value begins. A partial dump cannot be read as a whole structure.
409
+ - The walk returns text and never a node tree, so nothing downstream can consume a tolerantly read structure and no verify path is reached by it. It carries the same caps a parse carries, and the report is bounded on a hostile file rather than becoming the denial of service the parsers refuse: nesting past the depth cap and a report past the node cap each stop and say so on the last line, and a value past the byte cap renders the bytes the cap allows and states how many it left out. The node cap reaches the decoder as its item cap too, so a file of many small values never builds a tree larger than the report will print. `opts.maxBytes`, `opts.maxDepth`, `opts.maxNodes` and `opts.maxValueBytes` set the four per call, defaulting to `C.LIMITS.DER_MAX_BYTES`, `C.LIMITS.DER_MAX_DEPTH` and the new `C.LIMITS.DUMP_MAX_NODES` and `C.LIMITS.DUMP_MAX_VALUE_BYTES`. `maxBytes` tightens only: the shared parse door carries its own byte ceiling and applies it first, so a value above `C.LIMITS.DER_MAX_BYTES` is refused rather than accepted and then contradicted.
410
+ - This verb withholds no value, which is what a byte-level dump is for and the one place in `pki.inspect` where it is true: where the other reports name a key and print none of it, this one prints bytes, private key material included. The value cap bounds how much of a value is rendered rather than whether it is, so it controls report size and not disclosure. `pki.inspect.any` does not route to it, so a report reached by format detection still names a key without printing it, and `pki.inspect.pkcs8` remains the report for a key file.
411
+
412
+ ### Fixed
413
+
414
+ - Every verb that accepts a DER Buffer or a PEM block in the same argument now reads a Buffer that is valid DER as DER, even where the bytes could also pass for armored text. A short DER header can be made entirely of printable octets, so a value whose content carried a `-----BEGIN` line was classified as PEM and then refused: an APPLICATION 3 primitive is identifier `0x43` and any content length from 32 to 126 is a printable length octet, which is enough to satisfy a byte scan looking for armor. Bytes that do not decode as one complete DER value are armor, which is the resolution `pki convert` already took, so a PEM file read into a Buffer still decodes as PEM, including one carrying a byte-order mark or explanatory text before its armor.
415
+ - A PEM block whose preamble carries a vertical tab or a form feed is recognized as PEM when the file is read into a Buffer. The rule has always been that a boundary preceded by a byte no text file carries is not armor, since DER is binary, but the byte set behind that rule left out two of the six ASCII whitespace characters, so such a file was handed to the DER parser and refused there. All six now pass. Every other byte stays outside the set: a preamble holding a NUL or a high byte means the buffer is not text, and a PEM file that genuinely carries one is passed as a string.
416
+ - Where a Buffer satisfies BOTH readings it is now refused with `<prefix>/bad-input` rather than read as one of them. A primitive tag makes any content valid, so a value whose declared length covers exactly the armored text after it is at once one complete DER value and a well-formed PEM block, and either choice hands a caller the other reading's bytes without saying so. The message names the two unambiguous ways to state which was meant: pass PEM as a string, or pass the DER value on its own. This is a coercion behavior for a byte source only; PEM passed as a string is unaffected.
417
+
7
418
  ## v0.8.30 — 2026-09-29
8
419
 
9
420
  The 0.8.30 tarball is the one to install for everything 0.8.29 added.