# Changelog

All notable changes to `@blamejs/pki` are documented here. The format
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this
project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## v0.9.5 — 2026-10-12

Refuse a non-boolean switch option instead of reading it as the state the caller did not ask for.

### Changed

- For every one of these switches, `true` and `false` pass, `null` and an omitted option select the documented default, and any other value is refused with that module's own `domain/reason` code naming the option. For every value that was already valid the emitted bytes are unchanged. A call that passes a non-boolean to one of these options now throws instead of building a message the other way: pass the boolean, or omit the option to take its default.
- `opts.pem` is unchanged and still takes `true` or a PEM label string where a verb documents one, as do `signingTime` (a time or `false`) and `pki.pkcs12.build`'s `mac` (a descriptor or `false`). These carry a value as well as an off form, and each is held to the type its own value needs.

### Fixed

- `pki.cms.sign` refuses a non-boolean `detached`. The option decided the message shape in one place and whether the content was carried in another, so `detached: "false"` took the attached branch and then omitted the content from it, emitting an attached SignedData with nothing in it.
- `pki.cms.authenticate` refuses a non-boolean `authenticatedAttributes`. With `false` the MAC covers the content octets directly (RFC 6211 sec. 2), so the two values are structurally different messages and a mistyped one produced the message the caller had declined.
- `pki.cms.digest` refuses a non-boolean `detached`; `pki.cms.sign` and `pki.cms.countersign` refuse a non-boolean `signedAttributes`; `pki.ocsp.sign` refuses a non-boolean `embedCert` or `extendedRevoke`; `pki.tsp.sign` refuses a non-boolean `ordering`; `pki.pkcs12.build` refuses a non-boolean `integrity.certificates`; `pki.path.crlChecker` and `pki.path.fetchingChecker` refuse a non-boolean `useDeltas`.
- `pki.est.serverkeygen` reads a `null` `requestedEncryption` as the absent option it documents rather than as a stated `false`. It tested whether the caller had stated an override by presence alone, so a `null` counted as stated and coerced to `false`, and a request against a CSR that does advertise key encryption was refused for contradicting a CSR the caller never disagreed with.

### Security

- `pki.smime.sign` and `pki.smime.encrypt` refuse a non-boolean `protectHeaders`. Read by comparison to `true`, a value of any other type left RFC 9788 header protection off and the message went out unprotected with no error, so a caller that asked for the protection did not get it and was not told.
- The `pki.est` and `pki.scep` verbs refuse a non-boolean `allowCrossOriginRedirect`. Read by truthiness, `"false"` allowed the cross-origin redirect on an unsafe method that the check exists to refuse (RFC 7030 sec. 3.2.1), which is the opposite of what that value states.
- `pki.cms.sign` and `pki.cms.countersign` refuse a non-boolean `certificates`, the switch that withholds the signer chain. A value of any other type embedded the certificates the caller asked to leave out.
- `pki.x509.sign` and `pki.csr.sign` refuse a non-boolean criticality flag (`keyUsageCritical`, `extendedKeyUsageCritical`, `certificatePoliciesCritical`, `policyMappingsCritical`, `qcStatementsCritical`, `tlsFeatureCritical`). A value of any other type could emit an extension critical that the caller asked to be non-critical, and RFC 5280 sec. 6.1 requires a relying party that does not recognize a critical extension to reject the certificate.

## v0.9.4 — 2026-10-12

Refuse to issue an elliptic curve certificate asserting a key usage its own algorithm forbids.

### Added

- `rfc5480` joins `pki.lint.profiles()`, naming the elliptic curve rows on their own so a caller can ask for them alone. They also ride with the `rfc5280` profile, because that is the profile whose own clause delegates the question and the one every authoring call is held to.

### Security

- `pki.x509.sign` refuses a spec whose elliptic curve subject key asserts `keyEncipherment` or `dataEncipherment`, and `pki.lint.certificate` reports it as `lint/rfc5480/ec-key-usage-forbidden`. RFC 5280 sec. 4.2.1.3 delegates the per-algorithm key usage values ("appropriate values for keyUsage extensions for particular algorithms are specified in [RFC3279], [RFC4055], and [RFC4491]"), RFC 5480 updates RFC 3279 for elliptic curve, and RFC 8813 sec. 3 states the prohibition outright for a certificate indicating `id-ecPublicKey`. Measured before this: both bits were emitted and no error-severity finding was reported, so a certificate violating a MUST NOT left the signer unremarked. One finding names both bits where both are set, since an operator clearing them clears them together. The RSA side has no such row: RFC 3279 sec. 2.3.1 states a MAY list and no prohibition, so an error-severity finding there would promote a MAY-list omission to a MUST NOT and stop the signer emitting a certificate a caller may want. `opts.profile: "none"` still emits what a spec describes, which is what reproducing a real misissuance needs.

## v0.9.3 — 2026-10-09

Carry out the signature a caller asked for, compare a name by its own matching rule, and answer a proof of possession with which check failed.

### Added

- `pki.schema.c509.parse` reports `signatureValueMinimalWidth` on a type-3 certificate. A fixed-width `r || s` names no curve, so the same signature padded to a wider accepted width reconstructs byte-identical DER: 64, 96 and 132 are three spellings of one certificate, and a C509 byte string is therefore not a unique identifier for what it encodes. The field is the narrowest width that represents the value, so `signatureValueMinimalWidth === signatureValue.length` answers whether this encoding is the canonical one, and re-narrowing to it gives a key that is the same for every spelling. Nothing is refused, and a genuine P-384 or P-521 signature reports its own width.

### Changed

- A signer descriptor is held to the fields the verb reads. `pki.cms.sign` and `pki.cms.countersign` refuse a field they do not recognize, which `pki.smime.sign` inherits by signing through them, and which `pki.pkcs12.build` already applied to the same descriptors. Previously `{ cert, key, digestAlgoritm: "sha384" }` was accepted and signed under SHA-256, so a caller who believed they had configured SHA-384 got the default with nothing reported, while the identical misspelling one argument along in `opts` was already a hard `cms/bad-input`. A signer takes `{ cert, key }` or, where the signer is a key the message itself identifies, `{ spki, keyIdentifier, key }`; a countersigner takes `{ cert, key }`. Either form takes `pss`, `digestAlgorithm` and `combinedRsaSig` beside it, and anything else raises `cms/bad-input` naming the field and the form it was held to. A descriptor may be a function carrying those fields, of any callable kind, and the names a function carries by construction are not fields the caller named.
- A name field takes a common name, and a distinguished name written there is refused rather than certified whole. `subject: "CN=Example CA, O=Example"` produced one RDN whose commonName was that entire string, escaped comma and all: the subject read back as `CN=CN=Example CA\, O=Example`. The refusal is in the shared name encoder, so it covers `pki.x509.sign`, `pki.csr.sign`, `pki.crl.sign`, the CMP, CRMF, attribute-certificate and trust-anchor builders, and a `directoryName` in a GeneralName. A common name is unaffected wherever RFC 4514 sec. 3 reads no attribute type at its opening: one that contains an `=` further along, such as `Example CA (v=2)`; one whose numeric opening is not an object identifier, `attributeType` being `descr / numericoid` and a numericoid carrying at least one dot, so `1=1` is a common name while `2.5.4.3=Example CA` is a name; and one opening with whitespace, which the grammar admits at no position. Pass `pki.x509.parseDn(s).bytes` for a distinguished name, or `[{ commonName: s }]` to certify the text as one common name. OpenSSL refuses the same string for the same reason, and the message names both forms.
- `pki.relatedCert.verifyRequest` returns a verdict object rather than a bare boolean: `{ valid, verified, signerMaySign, signatureAlgorithm }`, plus `code` and `reason` when it did not pass. Read `res.valid` where you read the boolean. A bare boolean could not say which check failed, and there are now two: `verified` is the proof's own signature, and `signerMaySign` is whether the related certificate's keyUsage authorizes that signature at all, which RFC 5280 sec. 4.2.1.3 decides. `signatureAlgorithm` names the algorithm the proof was checked under, which the request does not carry.

### Fixed

- An S/MIME signer descriptor that carries its fields on a prototype is read the way a property read reads it. A method on the descriptor's class is not a field, which `pki.smime.sign` already held, and neither is a same-named value that method shadows: a class defining a `digestAlgorithm` method over a base class that holds a `digestAlgorithm` string sent the string through as the field while the readable value was the method, and signing failed as `cms/unsupported-algorithm` naming an undefined digest. Such a descriptor now signs under the digest it asked for, or the default where it asked for none.
- A distinguished name is compared under the matching rule its own attribute carries. Every attribute value was compared case-insensitively, which is correct for every attribute RFC 5280 sec. 4.1.2.4 requires support for and wrong for the ones whose rule is case-exact: `challengePassword` (RFC 2985 sec. 5.4.1), `localKeyId` (sec. 5.5.2) and `role` (RFC 5755 sec. 4.4.5) are all resolvable by name, mintable through `pki.x509.parseDn`, and compared EQUAL for values differing only in case. Name chaining, revocation scope and name constraints all rest on that comparison. Everything else keeps the case-insensitive rule, so a conforming PKIX name compares exactly as before.

### Security

- A signature verdict is assembled through the continuation the toolkit captured when it loaded, so a `Promise.prototype.then` replaced afterwards cannot substitute the result the verdict rests on. `p.then(f)` reads `then` off the promise at the call, and these verbs each computed `valid` inside such a continuation from the one boolean a cryptographic verify resolved: measured, a replacement substituting `true` for a `false` fulfillment value turned a tampered CSR signature, a CRL held against a key that did not sign it, a proof of possession over the wrong bytes and an attribute certificate held against a key other than its issuer's all into passing verdicts, each with the replacement reaching exactly that one continuation. `pki.csr.verify`, `pki.crl.verify`, `pki.attrcert.verify`, `pki.relatedCert.verifyRequest`, `pki.possession.verifyRequest`, `pki.cmc.verify`, `pki.est` enrollment and the Sigstore signed-certificate-timestamp check now take theirs from the capture, which `pki.cms.verify`, `pki.path.validate`, `pki.webauthn.verify` and `pki.crmf.verifyPop` already did. An OCSP CertID digest is taken the same way, a substituted digest being able to match a certificate the response does not name.
- An authorization gate can no longer be answered by the object prototype. `pki.possession.verifyRequest` and `pki.cms.verify` ask the decoded KeyUsage extension whether the certificate's key may make a signature, and one of the names they ask for is `contentCommitment`, the X.509 spelling of the `nonRepudiation` bit. The decoder emits the RFC 5280 sec. 4.2.1.3 names and never that one, and the record it returned inherited `Object.prototype`, so a value set there answered instead: a certificate confined to `cRLSign` went from unauthorized to authorized under a single assignment. The decoded record now inherits nothing, so a name the decoder does not define answers nothing, and the two gates ask only for names it does define.
- An establishment key usage authorizes a proof of possession only where the subject key's own family performs that establishment mechanism. RFC 9763 sec. 3.1 has a key establishment certificate's private key sign once for proof of possession, so `pki.relatedCert.verifyRequest` admits such a usage where it admits no other signature; it accepted either establishment bit from any key, and a bit the key cannot perform does not make an establishment certificate. Measured: an EC certificate asserting only `keyEncipherment`, which RFC 8813 sec. 3 makes a MUST NOT for `id-ecPublicKey`, and an RSA certificate asserting only `keyAgreement`, which RFC 3279 sec. 2.3.1 does not list, both authorized the proof. `keyEncipherment` now authorizes it for an RSA or ML-KEM key and `keyAgreement` for an EC, X25519, X448 or Diffie-Hellman key, a family in neither column needs a signature usage, and a certificate carrying no keyUsage extension stays unconstrained as sec. 4.2.1.3 leaves it.

## v0.9.1 — 2026-10-08

Refuse a MIME entity that two conforming readers would read differently, and hold one entity to a bounded number of parts, header lines and parameters.

### Changed

- `pki.smime.verify` and `pki.smime.decrypt` refuse an entity whose `Content-Type` declares one parameter twice, or whose header area declares one field twice. Both readings of such an entity are conforming and they differ, so the signature's own coverage is in question: `boundary` sets where the parts begin, `protocol` and `smime-type` decide whether the entity is accepted, and `micalg` decides which digest is reported back. Previously the verdict followed the order the two occurrences appeared in. `boundary="DECOY"; boundary=<real>` returned `valid: true` while the same two values swapped was refused, and a second `Content-Type: text/plain` placed after the real field returned `valid: true` while the same field placed before it was refused, which left a reader taking the last field seeing `text/plain`, finding no signature part, and reading the message as unsigned. Both orders of both shapes now raise `smime/bad-mime`. An entity naming each parameter and each field once is unaffected, and every occurrence of a field that may legally repeat, such as `Received`, is still readable from `headers`: only resolving a repeated name to a single value is refused.
- One MIME entity is bounded at 1024 body parts (`pki.constants.LIMITS.MIME_MAX_PARTS`), 1024 header lines (`MIME_MAX_HEADERS`) and 256 `Content-Type` parameters (`MIME_MAX_PARAMS`), and each count is charged as the entity is scanned rather than after it has been taken apart. A 1.4 MiB body declaring 200,000 empty parts previously split into all 200,000, and a 16 MiB header area took 1114 ms and 653 MiB of resident memory to read. A folded field spends one of the 1024 lines per physical line it occupies. `pki.est.splitMultipartMixed` and `pki.est.parseServerKeygenResponse` are held to the same part and header-line bounds, and raise `est/bad-multipart`. From `pki.smime.verify`, an entity over the part bound raises `smime/bad-multipart` and one over the header-line or parameter bound raises `smime/bad-mime`. The 16 MiB entity cap is unchanged and is still applied before any of the bytes are copied.
- The record `pki.smime.verify` returns, and its `content`, no longer share the buffer the caller passed in. The parsed entity, its header area and its body were views into the caller's own bytes, so a write into that buffer after the verdict returned changed what the caller read back as verified. The entity is copied before it is read, the copy is byte-for-byte the input, and the octets a signature covers are unchanged.
- The reference at [pkijs.com](https://pkijs.com) reads on a phone. At a 375px viewport the page scrolled 771px wide, and the nineteen navigation groups stood 659px tall even collapsed, so the first screen held navigation and no documentation. The content now starts under a compact header carrying the brand and a menu button, the navigation opens from that button, and tables and code blocks scroll inside their own box instead of widening the page. The menu is a label and a checkbox, so it works with JavaScript off, as the search form does. The layout at 721px and above is unchanged.

## v0.9.0 — 2026-10-08

Import any namespace by name from an ESM caller, and give every record the toolkit builds for an omitted argument no prototype.

### Added

- Every namespace is an ESM named import. `import { x509, cms, path, schema, lint, webcrypto } from "@blamejs/pki"` resolves, as does any of the other 41; `import pki from "@blamejs/pki"` is unchanged and gives the same object `require` does. The package remains CommonJS, runs on Node LTS as shipped, and adds no build step and no transpilation: every value in the export object is a bare identifier bound above it, which is the shape Node's export reader can follow. A `.d.ts` generated from the source comment blocks is still to come.

### Changed

- A record the toolkit defaults for an omitted argument no longer inherits from `Object.prototype`. A caller passing its own object is unaffected: only the record built when the argument is absent changed, and a field read off it that the caller did not supply now reads as absent.

### Fixed

- A revocation deadline is measured against the toolkit's own clock. `pki.path.fetchingChecker` builds its deadline with no options record, so the record it defaulted carried `Object.prototype` and the clock came from an inherited `now`: one reporting a time far ahead made the deadline expired before the first fetch, and the fetch that would have retrieved a CRL or an OCSP response was never made. The same record supplies the bound a decoded size is held to, where an inherited `max` of zero refuses every value.
- `pki.inspect.asn1`, `pki.inspect.certificate` and the other report verbs are held to the caps their caller passed. With the options argument omitted the record they defaulted carried `Object.prototype`, so an inherited `maxBytes` became the cap the render was held to and a report that renders normally came back refused.
- A signer signs with the identity its caller named. `pki.x509.sign`, `pki.crl.sign` and `pki.attrcert.sign` default an omitted issuer or extension record, so an inherited `cert`, `key`, `name`, `publicKey`, `basicConstraints` or `keyUsage` was read as if supplied. Reaching the read required getting past the spec's unknown-field door and its copy depth cap, both of which refused it, so the effect was a refused signing call rather than a signature under another identity. The record has no prototype now, which is where the answer belongs.
- A client believes what a server actually sent. `pki.acme`, `pki.cmp`, `pki.ct`, `pki.est`, `pki.scep` and the revocation fetcher each default an omitted response record, where an inherited `status`, `body` or `headers` stands in for what arrived. The content type a CMP transfer and a CT fetch check, and the declared content length the HTTP transport enforces its response cap against, were each read through a header record defaulted the same way.
- The records every other verb defaults are the same shape and are converted with them: the options records of `pki.asn1.decode`, `pki.cbor.decode`, the schema engine and the shared PKIX sub-schemas, the signer and countersigner records in `pki.cms.sign`, the MAC and bag encryption records in `pki.pkcs12.build`, the message-imprint records in `pki.tsp.sign`, the header records in `pki.est`, the request and defaults records in the HTTP transport, the setup records in `pki.hpke`, the client option records in `pki.ct`, the certificate-transparency policy `pki.path.validate` passes on, the fallback key in `pki.jose`, the algorithm records the WebAuthn verifiers read a name off, and the algorithm record a signature scheme reads off a key.

### Documentation

- `THREAT-MODEL.md` states the boundary the toolkit's use of captured operations rests on. The operations a check decides through are copied from the runtime while the modules load, which is what makes a later replacement unable to change a verdict, and that also means code running before the first `require` of the package can replace an operation before the copy is taken. Load the package before running untrusted code in the same realm.

## v0.8.61 — 2026-10-07

Take every cryptographic operation, and every question about which key a key is, through an operation captured when the toolkit loads, so a replacement installed afterwards cannot decide a secret, a tag, a key type or a verdict.

### Added

- The internal crypto guard gained the operations the library needed to stop reading `node:crypto` at call time: randomness, the password-based and extract-and-expand derivations, the RSA transforms, the elliptic-curve point derivation and conversion, the AEAD transform methods, the hash handle for a caller that cannot hold its whole input, and the four key-identity accessors. The digest primitive takes input and output encodings. None of this is on the public surface.

### Changed

- `pki.webcrypto.subtle.exportKey` and `pki.key.export` read a key's type at most twice per call, once to admit the key and once for every decision that follows. Nothing in the public surface changed shape, and no error code was added or removed.

### Fixed

- An imported key is bound to the algorithm the caller named it under. `pki.webcrypto.subtle.importKey` settles that by asking the key which algorithm family it belongs to, and that answer came from an accessor on a node prototype, read at the moment of the check. An accessor answering `rsa` for an Ed25519 key therefore made the import accept it as RSA-PSS, and every later operation ran under an algorithm the key does not belong to. The accessor is captured when the engine loads. The same read also decided a curve, a modulus length and whether a key pair corresponds, across `pki.key`, `pki.tuf`, `pki.tlog`, `pki.sigstore`, `pki.cmc`, `pki.crmf`, `pki.ct` and the signature-scheme check that matches a signer to a certificate.
- A plain object carrying an `asymmetricKeyType` field is no longer accepted as a key. The sites that asked whether a caller had handed them a key object tested for that one field, which any object can carry, so a forged object passed the test and was then used as a key. The question is asked of the object's prototype now, and the answer settles both whether it is a key and which kind.
- A shared secret is derived through the agreement the toolkit captured, not one replaced afterwards. `crypto.diffieHellman` returns the traditional half of a hybrid secret outright, so a replacement supplies that half: driving `pki.hpke.decap` with one returning a fixed buffer derived a secret from the value the replacement chose. The post-quantum half, through `encapsulate` and `decapsulate`, had the same shape, as did the key generation that decides the ephemeral key an encapsulation is made under. `pki.hpke`, `pki.kem`, `pki.key`, `pki.cms` and `pki.webcrypto` all take these through operations captured at load.
- A content-encryption key, a MAC key, an IV, a nonce and a salt are drawn through the randomness the toolkit captured. Twenty-seven sites read it off the module handle when they needed it, so a replacement returning a value it already knows handed it every message those protect, and nothing downstream can tell a chosen key from a random one.
- An AEAD message is authenticated against the tag it carries. The tag is set and read through methods on the cipher object, which were reached through that object: a replacement decides which tag a ciphertext is checked against, so the verdict stops being a function of the message. The additional authenticated data, the padding mode, and the bytes a transform produces are reached the same way and are now taken through operations captured at load. The streaming paths in `pki.webcrypto` already did this; the one-shot encrypt and decrypt did not.
- A key-transport ciphertext is produced, and the secret inside one recovered, through the RSA transforms captured at load. A password-derived key-encryption key, an HKDF expansion, and the elliptic-curve point a scalar generates are the same shape: each returns a value rather than a claim about one, so a replacement returning a fixed value makes every password open the message or puts a point of its own choosing where a public key was expected.
- `pki.key.export` emits a PEM label that agrees with the bytes in the block. It asked the key its type once to choose the label and again to choose the encoding, and on a `CryptoKey` from node's own WebCrypto that is an accessor, so an accessor answering differently across those reads picked the two separately and a public key came back inside a `PRIVATE KEY` block. The type is read once and every decision in an export comes from that one answer.
- A digest is computed through the hash operation the toolkit captured, across every path that takes one: the certificate and CRL digests, the CMS message digests and countersignature imprints, the transparency-log identifiers, the PKCS#12 key derivation, the hash-based signature trees, the WebAuthn attestation comparisons and the HTTP Digest credential. `pki.scep`'s check that a named digest is available goes through the same operation, so the name it accepts is the name it would use.
- The RFC 7616 HTTP Digest credential is computed over the octets of the latin1 encoding the specification names, through one operation rather than a copy of the hash construction kept in the module. Three modules held their own copy of what the digest primitive already does; all three route through it now, and the input and output encodings are named at the call rather than defaulted.

## v0.8.60 — 2026-10-07

Build every list a parse or an encode reads from by defining its indexes, so an accessor inherited from the array prototype cannot take an element as it is added.

### Changed

- The library no longer has a storing append to reach for, and a check refuses one that is written anyway: a module that reads `push` or `unshift` off a prototype as a value, quotes either inside brackets, or binds either by destructuring fails the static gates with the non-storing form named in the message. A binding is read from the parse rather than from the text, so the form it is written in does not matter: a declaration, an assignment however deeply nested, a `for`-`of` or `for`-`in` header, a catch parameter, and the parameter list of any function, generator, method or arrow, with a computed, quoted or escaped key read as the name it denotes. An object literal is a different construct and is never read as one, including a literal used as a default inside a pattern. Both grow the list, so both store at an index the array has no own property at; the mutators that remain move or drop elements that already have own slots and reach no inherited accessor. A module prepending to a list counts its elements and appends them in order instead. An escaped spelling of either name is read as the name it denotes, since `p\u0075sh` and `"\x70ush"` are the same property. The ordinary call position is held to a per-module figure instead of a flat refusal, because the module that builds report lines still carries those calls: every other module in the library is held to zero and a new call in any of them fails, the one that is listed cannot grow, and converting calls without lowering a figure fails too. A module may only hold such a figure while nothing else in the library reads it, which is checked rather than asserted: a module another module requires decides something for that caller, so it is held to zero like the rest. What the check cannot settle is a property reached without naming it, through a runtime-assembled key or reflection, or a computed key whose value the program does not determine. What the rest rests on is that neither name is captured or exported anywhere in the library, so there is nothing to bind by habit, and that the non-storing primitives carry the tests for the behavior.
- The same check now recognizes the variable-width and 64-bit integer writes (`writeUIntBE` and its siblings) alongside the fixed-width ones it already knew, so a module that reads one of those off the prototype at the moment it writes a field is reported like any other.

### Fixed

- The DER codec encodes the bytes a value describes. Four of its byte lists were built by prepending or by assigning at an index, and both are stores: an accessor inherited at an array index took the octets as they were produced, and the decisions read back off those lists were then read through the hole a store leaves. Measured: `pki.asn1.build.integer(128n)` emitted the INTEGER 0, a nine-digit integer emitted as all zeroes, -300 emitted as +212, `pki.asn1.build.oid("1.2.840.113549.1.1.11")` emitted a different OBJECT IDENTIFIER, a SEQUENCE wrapping 300 bytes emitted as `3000`, and tag 31 emitted a truncated identifier. The length octets, the identifier octets and an INTEGER's content octets are written into the output directly now, and an OID's sub-identifier octets are appended by defining each index, so all four describe the value they encode.
- A decoded value's children are appended by defining the index, in both codecs. Those children are what every format module reads its fields out of. On a SEQUENCE of the integers 1 and 2 the first value came back as 9, and a certificate parsed with the accessor's value in place of its first relative distinguished name, throwing a bare TypeError rather than a typed refusal. The segments a constructed string is joined from, an identifier's arcs, and the checked children of every constructed element are built the same way.
- Every list the schema engine builds is appended to by defining its index. The engine walks each SEQUENCE OF and SET OF into the list a format's own reader takes its elements from, and collects a structure's fields into the list that is concatenated in order into the bytes it emits, so an accessor at an array index decided both what a parse bound and what an encode wrote. The elements are also mapped through an operation captured at load, which neither dispatches through a replaceable `map` nor builds its result through the receiver's `Symbol.species`.
- The tables that decide whether a string is a token, a label, a decimal-digit string or a hexadecimal escape held an entry per character they allow and nothing at the codes they do not, and reading one at a code it had no entry for answered from the array prototype. A value installed at index 47 therefore made `pki.acme.keyAuthorization` accept a challenge token carrying `/`, which the base64url set does not allow, instead of refusing it with `acme/bad-token`. The tables have no prototype now, so there is nothing to inherit. The same helper in the name guard was already written this way.
- The certificate-policy tree is read at the depth a node was placed at. The per-depth layers were held in an array, so a depth with no node yet was a hole and reading it answered from the array prototype, which handed the policy processing a layer of the caller's choosing along with the expected policy sets its nodes carry. Creating a layer was a store at the same index. The layers are keyed in a record with no prototype now, and a generated node takes a qualifier only from a list that holds one.
- Removing a policy node constructs nothing. The removal went through `splice`, which returns what it removed and therefore builds that result through the receiver's construction protocol: it reads the list's `constructor` and then that constructor's `Symbol.species`, so an accessor installed on `Array.prototype.constructor` ran during the unlink with the live list of policy-node records as its subject, which is a window to change a surviving node's expected-policy set before the walk reads it. Binding `splice` at load does not close that lookup, because the lookup is part of what the function does. The removal now defines the surviving slots and returns nothing. The same change covers the certificate list `pki.cmp` reorders while it accumulates authenticated CA certificates, the extension list every certificate parse builds, and the key-usage list `pki.webcrypto` copies from a caller.
- An extension or an attribute is found by searching, not by indexing a selection. Written as a filter whose first element is taken, the selection is an empty list when nothing matched, and reading index 0 of an empty list answers from the array prototype: a value installed there stood in for an extension or attribute the input does not carry, and the refusal that follows the lookup was not reached. That covers the subject key identifier a CMS signer is matched by, the message-digest attribute a countersignature and an authenticated-attribute set are checked against, the key usage that decides whether an issuer may sign a CRL, the acmeIdentifier and SubjectAltName extensions a TLS-ALPN validation certificate must carry, and the authority key identifier `pki.acme.ariCertId` reads.
- Every list a parse builds is appended to by defining its index, across the certificate, CRL, CSR, PKCS#8, CMS, OCSP, TSP, CMP, CMC, PKCS#12, attribute-certificate, trust-anchor and C509 readers, the CBOR codec under C509, and the content-key recovery in `pki.cms.decrypt`, whose key-derivation input is assembled the same way. A SCEP response's certificates and CRLs are checked one at a time and collected the same way, so the certificate returned is the one the check accepted. The trust anchors a path is built against, the addresses a request is allowed to connect to, the anchors a handshake runs under, the distribution-point names a revocation check compares, and the RC2 key schedule a legacy PKCS#12 import derives are all built this way too.
- The lists built outside the readers are appended to the same way: a certificate's extension rows in the CSR, CRL and CRMF readers, the byte strings an HPKE key schedule is computed over, the peer chain `pki.transport.peerChain` returns, and the lists `pki.acme`, `pki.cmp`, `pki.est`, `pki.ct`, `pki.tuf`, `pki.tlog`, `pki.jose`, `pki.webcrypto` and the PKCS#11 URI reader build while they work.
- A transparency-log tile is requested from the path its index describes. The path segments were prepended into a list and the per-segment prefix was then applied by a read-modify-write through the hole a prepend leaves, so an accessor at an array index reduced `pki.tlog.tilePath(0, 1234067n)` to `tile/0/`.
- A C509 iPAddress constraint carries the address it encodes. Its octets were prepended into a list whose leading entry is both the prefix length checked for range and a byte the result carries, so an accessor at an array index decided the check and the emitted address together.
- A length-prefixed vector declares the bytes it writes. The wire writer under `pki.ct`'s signed-certificate-timestamp list measured the caller's view and then wrote from that same view, so a body on a resizable buffer could shrink in between: the frame then declared more bytes than it carried, and the concatenation that assembles the output failed with an error from the runtime rather than a verdict from the writer. A bound was also converted inside the comparison the length is checked by, which ran the bound's own conversion before the prefix was written and let it replace the write that states the length: measured, a vector declared a length of zero over a payload of two bytes. The body is copied into the writer's own allocation before it is measured, the bounds are converted and typed up front, and the writes that place an integer in its field are bound when the module loads.
- Every component of the preimage `pki.ct.reconstructSignedData` rebuilds is appended by defining its index, and the concatenation is bound at load. Those bytes are what a log's signature is verified over, so an accessor inherited at an array index replacing any one of them, the version, the signature type, the timestamp, the entry type, the certificate or the extensions, would put the verification on bytes the log never signed.
- The lists `pki.cms.sign` builds while it works are appended to by defining their indexes: a message's signed attributes, its certificate set and its signer infos, including a countersignature's own signing-time attribute, signed-attribute bytes, signature algorithm and signature as they are assembled into the SignerInfo that is then signed.
- A message carries the fields its caller asked for. The verbs that assemble a structure for signing collected its fields by storing at an index, so an accessor inherited at that index took a field as it was added and the structure was then signed over whatever the accessor answered. Two measured through their own verbs: `pki.cmp.build` returned a protected message carrying a transaction identifier of `eeee...` where the caller had supplied `0707...`, and the failure reason a SCEP CertRep states is a signed attribute that landed on such an index, so a FAILURE response could be signed stating a reason the caller never gave. Every list outside `pki.inspect` is appended to by defining its index now: `pki.cmp`'s messages and its accumulated CA certificates, `pki.ocsp`'s signed requests and responses, `pki.scep`'s responses, the certificate, CRL, attribute-certificate and certification-request signers, `pki.cms.encrypt`'s recipient infos, `pki.cmc`'s controls, the shared extension and name builders every signing verb embeds, the bags a PKCS#12 file carries under its MAC, the headers and parts of an S/MIME message, the key-derivation parameters an encrypted structure declares, the authentication path a hash-based signature is formed from, and the lists `pki.est`, `pki.acme`, `pki.webcrypto` and the TLS and key-description validators build while they work.
- `pki.path.fetchingChecker` only fetches a revocation location whose address it is allowed to reach, and both halves of that decision are now bound when the module loads. The test for whether a host is an address literal, which decides whether the blocked-address check runs at all, and the blocked-address check itself were each read off their module at the moment they were used. Replacing the first made every host look like a name so the check was skipped; replacing the second made it answer that nothing is blocked. Either way a distribution point naming a loopback or link-local address was fetched. The operations that read a host, a response body and a media type are bound at load for the same reason, and the bracket test around an IPv6 literal reads character codes rather than comparing substrings.
- A revocation response is read for the fields it carries itself. The transport seam returns a record of a status, headers and a body, and a transport handing back an object carrying none of them had every one of those reads answered from the object prototype, so an inherited status of 200 with an inherited body and headers was accepted as a revocation response. The three fields are read as the response's own now.
- A revocation response body is bytes, a string, or absent; a status is the number 200; a media type is a string. Anything else was converted, which ran the value's own conversion: a transport handing back an object decided the bytes a CRL or an OCSP response was then parsed from, a header holding one named the media type from itself, and a status holding one ran during the refusal that reports it. Binding the conversion does not close this, because what runs is the value's hook rather than the conversion. A body that is neither bytes nor a string is refused now and names that as the reason, a status is reported through the formatter that does not convert it, and a media type that is not a string reads as absent.
- A revocation response's media type is read. It was parsed with the MIME entity reader, which takes the bytes of a whole entity rather than one header value, so the attempt failed for every response and the media type was reported as absent each time. The notes that compare it could therefore never appear, including the one stating that a CRL or an OCSP response arrived under an unexpected media type. The header value is read directly now, taking the span before any parameter and folding its case. The media type still only annotates a verdict and never selects a parser, which is chosen by where the location came from.
- `pki.path.fetchingChecker` asked a certificate for its extension list by a read that answered from `Object.prototype` when the certificate carries none, so a list installed there was decoded and reported on. It is an own read now, and a value that is not an array is treated as no extensions.
- A signer refuses to emit an artifact that violates the profile it is signing under, and that refusal is now reached. `pki.x509.sign`, `pki.csr.sign` and `pki.crl.sign` lint what the spec describes before they sign and throw `x509/profile-violation`, `csr/profile-violation` or `crl/profile-violation` naming the rule. The findings that decision reads were collected by storing at an index, so an accessor inherited from the array prototype took each finding as it was recorded and the index answered with the accessor's value: a benign severity there stood in for the error finding and the signer emitted the artifact instead. Measured, `pki.x509.sign` returned a signed CA certificate whose key usage omits keyCertSign. The profile-specific report the check adds for a caller-named profile was substituted whole by the same means. Every list in the linting engine is appended to by defining its index now, so the rules a signer refuses on are the rules that ran. The records that decision is read from were plain objects too, so a field a record does not carry was both written and read through the prototype chain: a finding's severity could be taken off it by an inherited setter, a byte reader's success could be answered as a fatal error, and a rule row that omits an optional field read it from `Object.prototype`, where a value supplied for `appliesTo` or `effectiveDate` stops the rule running at all. Those rows now carry no prototype, the optional reads are own reads, and a report's fields are defined rather than assigned.

## v0.8.59 — 2026-10-07

Render an object identifier, match a header name and read a media type through operations a replacement cannot answer.

### Changed

- The check that holds these modules to captured operations now counts a method reached through an index, not only one reached through a call. It is the same receiver one bracket over, and the earlier form could not see it: two live copies in the RFC 5280 policy-mapping walk, where the copy becomes a node's expected policy set, were found by other means.

### Fixed

- An object identifier is rendered to its dotted form through operations bound when the module loaded, and its arcs are collected by defining each index rather than by appending to the array. Three different replacements each decided that string, which is the value `pki.oid` resolves, which selects a signature algorithm and which names an extension: a replaced integer conversion made the content octets of `1.2.3` render as `1.3.4`, and an accessor inherited at the first index of every array took each arc as it was collected and made the same octets render as `0.1.3`. Appending stores at an index, and a store consults the prototype chain for a setter, so a list this build assembles is now built by defining its indexes.
- A media type is read on the delimiter character and folded through a captured conversion, in `pki.acme`, `pki.cmp` and `pki.ct`. The token decides which parser a response body is handed to, and it was produced by splitting, trimming and folding through live operations. Splitting is now a character scan, which this toolkit requires in its libraries because a separator's own split hook is part of that operation.
- `pki.est` reads a content-type parameter through captured operations, and collects the segments it searches by defining each index. That is where the RFC 7030 multipart boundary comes from, so the value decides where a multi-part response is divided: measured, a header declaring `boundary=wire` yielded `forged`, both through a replaced append and through an accessor inherited at an array index. The name comparison that selects a parameter, the slicing that extracts it and the quote stripping around it were each live, as were the name and value of every header on a part, which is where a part's own content type comes from: measured, a part declaring `application/pkcs8` was read as `application/pkcs7-mime` carrying `smime-type=certs-only`. Its two case-insensitive header lookups are folded through the capture on both sides of the comparison.
- `pki.acme` parses a Link header through captured operations: the header is divided into links, each link into parameters, and the target cut out of its angle brackets, all through operations bound when the module loaded. The relation decides whether a header names a resource the client will follow and the target is the URL it would fetch, and both were produced by methods read off the live prototype: measured, a header stating one alternate chain resolved to a different URL.
- `pki.cmc.build` selects which request arm a message carries through a selection that reads each candidate once and builds its own result, rather than through a method that is both read off the prototype and specified to construct its result through a replaceable protocol.
- The algorithm, extension and criticality tables that `pki.path.validate`, `pki.cms`, `pki.crl`, `pki.key`, `pki.webcrypto`, `pki.x509` and the signature-scheme registry build when they load are built through captured walks, so a replacement installed before a module loads cannot decide which rows exist.

## v0.8.58 — 2026-10-07

Connect under the trust anchors, the address and the identity check the request named, not the ones a second read answers with.

### Fixed

- `pki.transport.https` applies the private-address refusal the request asked for. `blockPrivateAddresses` was read once to test for presence and again for the value, so a value that answered `true` and then `false` left the refusal unarmed: measured, a request to `127.0.0.1` completed with the block in force. The same split applied to `tls.servername`, which is the name the server's certificate is verified against, to `tls.checkServerIdentity`, where the caller's hook was found to be a function and then not installed, to `tls` and `tls.anchors` themselves, to the transport's own `defaults.tls`, to `proxy`, and to `timeout` and `maxResponseBytes`. Each is now read once.
- `pki.transport.https` connects under the trust anchors the request named. The list is assembled by appending to an empty array and the appending operation was read off the live prototype, so one answering with an empty array left the connection with no trust list of its own and the platform's certificate store decided instead: a caller that passed `tls.anchors` to pin one issuer was pinned to none of them. Measured against a loopback endpoint whose certificate only the named anchor trusts, the request then failed on the platform store's verdict rather than succeeding on the caller's. The proxy CONNECT path assembled its own trust list the same way.
- The address a private-address check cleared is the address the connection is made to. A resolver that returns a list had its own entries forwarded to the socket layer, so an entry whose address answered a public value to the check and a loopback one afterwards reached loopback with `blockPrivateAddresses` set. Each address is now read once, that read is what is classified, and the snapshot is what is handed on.
- A sparse proxy trust-anchor list is refused rather than read. The eligibility check walks the array's own property descriptors, which cannot see a gap, so a list whose gap was answered from the array prototype was validated as holding one anchor and converted as holding another: reading an array by index is specified to consult the prototype at a hole. A list with a gap is now refused as `transport/bad-proxy`.
- The platform trust store is read through the provider bound when the module loads, so a replacement installed afterwards cannot answer with its own certificate list for a `useSystemStore` connection.
- Every connection is opened through the function bound when the module loaded, not a property read off `node:https`, `node:http` or `node:tls` at the moment of the call: the request itself, the proxy CONNECT, the TLS connection made over a tunnel, and the one `pki.transport.peerChain` opens. A replacement installed afterwards sent a request prepared for one destination to another and handed the caller that peer's answer.
- The socket itself is opened by this module rather than by the agent node builds, on the request and on the proxy CONNECT alike. `agent: false` does not mean no agent, which is what it had been relied on for: node builds one from the default agent's constructor, and that agent's own connection builder opened the socket, so replacing it returned a connection to another destination and the request went out over it. Measured, an `https:` request with `blockPrivateAddresses` set and certificate verification on reached a local plaintext listener that presented no certificate, and its body reached the caller. On the CONNECT path the same replacement could answer with a plain socket in place of the proxy's TLS connection, which is the request that carries `Proxy-Authorization`. The request options node computed for the hop are forwarded unchanged, so every TLS setting still applies, and there is still no connection pooling.
- A per-request `tls.checkServerIdentity` keeps the transport's default from being read at all. The default was read whether or not the request overrode it, so a default supplied as an accessor ran even for a request that replaced it, and one that throws refused the request before it connected.
- A response is as long as the bytes that arrived, and holds those bytes. It is allocated at the counted length and written through operations bound at load, because three earlier spellings were each steerable: a copy read off the prototype, a slice whose result is built through the species constructor, and a conversion that reads the received view's own `valueOf` and `length`. The last of those decided the length as well as the content: measured, an eight-byte response came back as 64 bytes with a `maxResponseBytes` of 8, so the size cap was reported as enforced on a body that exceeded it.
- Every remaining operation `pki.transport.https` reaches a socket through is bound when the module loads: the PEM armor and IPv6 bracket scanners, the peer-chain walk, the anchor conversion, the TLS error classifier that decides whether a failure is reported as a trust failure or a transport one, the header names a proxy credential is stripped from, and the CONNECT tunnel's own options. The trust lists are assembled by copying and appending index by index, because appending with the built-in consults a spreading hook each argument can inherit, which replaced a validated anchor with another one.
- `guard.limits.cap` compares a value against the bound it validated. `min` and `max` were each read more than once, with the integer check reading one value and the comparison another, so a bound answering a small integer to the check and a large one to the comparison configured a cap that admitted a value above its own limit. Each bound is now read once. No shipped verb could reach this, because every caller in the toolkit passes a fixed bound; the guard is what has to hold for the next one.

## v0.8.57 — 2026-10-07

Read the OCSP response a CMS message carries in its crls field, instead of accepting it unread.

### Added

- A `crls` entry in the `other [1]` arm is read as RFC 5652 sec. 10.2.1 `OtherRevocationInfoFormat` and carries `otherRevInfoFormat` (dotted), `otherRevInfoFormatName` (or `null` for a format no registry names) and `otherRevInfoBytes`, the raw ANY TLV exactly as it arrived. A CertificateList entry is unchanged. An operator can now tell an RFC 5940 OCSP entry from an SCVP one, and either from an unregistered format, which nothing in this build could do before.
- For `id-ri-ocsp-response`, the embedded OCSP response is surfaced in `ocspResponse` as a parsed record, with the byte ranges a verifier hashes left raw. `pki.path.ocspChecker`, `pki.path.verifyOcspResponse`, `pki.ocsp.verify` and `pki.lint.ocsp` all accept it as it stands: harvest the entries with `crls.map(e => e.ocspResponse).filter(Boolean)` and hand them over. The same verbs take the record and the caller re-encodes nothing; a rebuilt copy of it is refused exactly as a rebuilt parse result is.
- A carried response whose `responseType` names a body this build does not decode keeps its envelope and leaves the body alone: `ocspResponse.basicResponse` is `null` and `ocspResponse.responseBytes.response` holds the bytes as they arrived. RFC 6960 sec. 4.2.1 dispatches the response body on that OID and RFC 5940 sec. 3 asks for a successful status rather than a body a reader can decode, so the message parses and a caller who only needs its signature verified is not stopped by a response type nothing in the path consults. The verbs above are handed a record with no response body and determine nothing from it, so a certificate's status stays undetermined rather than reading as good. `pki.schema.ocsp.parseResponse` still refuses the same bytes with `ocsp/unsupported-response-type`: a client that asked a responder for an answer needs to be told it cannot be read.
- The same reading applies to `originatorInfo.crls`, so EnvelopedData (RFC 5652 sec. 6.1), AuthenticatedData (sec. 9.1) and AuthEnvelopedData (RFC 5083 sec. 2.1) carry it too, not SignedData alone. RFC 5940 sec. 1 names all four.
- `pki.oid` resolves the two id-ri formats a reader can act on: `id-ri-ocsp-response` (1.3.6.1.5.5.7.16.2) and `id-ri-scvp` (1.3.6.1.5.5.7.16.4).
- `pki.lint.cms` reports a notice when a `crls` entry carries revocation information this build does not read, naming the format, the response type where there is one, and where the entry sat. An `id-ri-ocsp-response` entry whose response was read draws nothing; one whose `responseType` named a body this build does not decode draws the notice, because the revocation information it carries went unconsulted either way.

### Changed

- `pki.schema.cms.parse` reads these entries by default. An entry that was accepted unread before may now be refused if it is malformed under RFC 5652 sec. 10.2.1 or RFC 5940 sec. 3; an unrecognized format is still accepted and surfaced opaquely, because sec. 10.2.1's ANY DEFINED BY is an extension point and refusing an unknown format would refuse a future registered one. The reading is not optional, because an opt-in would leave the entry unread for every caller who does not know to ask for it.

### Fixed

- An `other [1]` crls entry is held to its stated shape. `OtherRevocationInfoFormat` is exactly an OBJECT IDENTIFIER and an ANY, so an empty arm, a one-element arm carrying a format and no revocation information, and an arm with a third element are each refused as `cms/bad-other-rev-info`. The one-element form is what OpenSSL both accepts and can emit, because its template marks the value optional.
- An OCSP response carried in that arm must state a status. A `tryLater` or `malformed` response is a well-formed OCSPResponse that conveys no revocation information, so it is refused here as `cms/bad-ocsp-rev-info` (RFC 5940 sec. 3). `pki.schema.ocsp.parseResponse` still accepts one, because a client reading a live responder's answer needs to see it.
- A BasicOCSPResponse where an OCSPResponse belongs is refused. Both are SEQUENCEs, so the confusion RFC 5126 sec. 6.3.4 invites by carrying `SEQUENCE OF BasicOCSPResponse` in a signed attribute passes any tag check and fails only on the first field, where a status is read and a ResponseData arrives.
- An OCSP response placed in the CertificateList position is refused as `cms/misplaced-ocsp-response`. RFC 5940 sec. 2 states that such a response is carried in the `other` arm, and a universal SEQUENCE in `crls` was previously surfaced raw whatever it held.
- A caller's decode caps reach the embedded response. The walk runs under the parse's own budget rather than starting a fresh one, so a tightened cap refuses the message with the caller's typed `cms/too-large` instead of letting the nested work run at defaults.
- `pki.cms.parseCertsOnly`'s `maxCerts` bound no longer decides which messages it accepts. The bound caps the certificates and CRLs parsed and returned, but it was applied by shortening the lists before they were checked, so a cap leaving no room for the CRLs dropped a tagged entry silently: a capped reader accepted a message an uncapped reader refuses and handed back an empty CRL list where revocation information had been. The structural check now runs over every entry, and only the certificate and CRL parsing stays bounded, which is the work the bound exists for.
- `pki.ocsp.verify` takes the record a `crls` entry surfaces, which it did not. Every verb that accepts a parse result re-reads it from the bytes it was parsed from, so that an edited copy cannot be verified in place of what was parsed; a record produced by walking a structure a message carries was not attributed to those bytes, so the re-reading produced an object that the next verb in the chain read as rebuilt and refused with `path/bad-input`. A record is now attributed the same way whether it was parsed from its own bytes or walked inside a message that carried it, and the refusal of a genuinely rebuilt record is unchanged.
- The three readers that require a plain CertificateList still refuse a tagged entry now that a well-formed one parses: `pki.cms.parseCertsOnly` (`cms/bad-crl`), `pki.est.parseCertsOnly` (`est/bad-crl`) and a SCEP SUCCESS CertRep (`scep/bad-crl`). The SCEP refusal had no test before this change.

## v0.8.56 — 2026-10-06

Decide a name, an address, a revocation and a trust bit through operations and values no caller can replace.

### Changed

- The case folds that remain read protocol details rather than deciding whether something is admitted: a response charset, a challenge flag, the relation name on a `Link` header, a contact's URI scheme. Each is read from a peer that has already been authenticated by the time it is consulted, so these are being converted for consistency rather than because a verdict turns on them.

### Fixed

- `pki.path.validate` holds an `excludedSubtrees` name constraint whatever `String.prototype.toLowerCase` answers. The matcher folded both the constraint and the certificate's name before comparing them, so a replacement that mangled the constraint alone made the exclusion stop matching, and a leaf naming `www.bad.example` under an exclusion of `bad.example` was admitted with no failure code at all. The three name-constraint matchers now read the captured fold, along with the captured slice and index operations they mixed with it.
- `pki.webauthn.verify` holds the android-safetynet hostname requirement of WebAuthn 8.5. The check answers yes when the x5c leaf is issued to `attest.android.com`, and it folded the leaf's name first, so a replacement answering with that name admitted any leaf: an attestation whose leaf named `other.example` verified.
- `pki.webauthn.verify` reads that leaf's name from its subject alternative name, which is the authoritative one, and falls back to the common name only for a leaf that carries no such extension at all. Two faults are fixed. It had been looking for a shape the decoder does not produce, so it selected no name and every leaf fell through to the fallback: a leaf whose SAN named another host was accepted on a common name that said `attest.android.com`, and a leaf that correctly named the host in its SAN alone was refused when its common name differed. And the fallback was reached whenever no DNS name was selected rather than only when the extension is absent, so a leaf whose SAN holds just a URI or an address also fell through to its common name, which is the one place that does not bind a host. A leaf carrying no SAN at all is still read by its common name, which is how the service issued its certificates.
- `pki.transport.https` keeps refusing a private, loopback or link-local address literal under `blockPrivateAddresses`. Both families were steerable: the IPv6 arm folded the address before classifying it, so a private address answered as a global-unicast one cleared the refusal, and the IPv4 arm split the literal into octets, so a replacement answering with a public address's octets did the same. `127.0.0.1` and `fc00::1` both reached a connection attempt.
- `pki.trust.parseCcadbCsv` grants an anchor only the purposes its Trust Bits cell names. The cell was split, trimmed and folded through live operations before each token was looked up, and the lookup grants a purpose, so a replacement answering with a recognized token widened what the anchor was trusted for: a row stating only `Email` produced an anchor carrying `serverAuth`.
- `pki.path.validate` holds a `permittedSubtrees` constraint as well, which opens in the opposite direction from an exclusion: it refuses when no subtree matches, and an empty list of subtrees for a name form reads as "this issuer constrains no name of that form" and skips the check. A replacement that answered with an empty list therefore admitted a name no subtree permits. The subtree selection and the walk over it are both converted, and the test asserts the stronger property that neither consults a replaced operation at all rather than only that the verdict came out right.
- `pki.path.validate` holds an excluded `rfc822Name` constraint as well. A constraint naming a whole mailbox is told apart from one naming a domain by whether it contains an `@`, so a replacement answering that it does not routed a mailbox constraint down the domain branch and the exclusion stopped matching: a leaf naming `alice@bad.example` under an exclusion of that same mailbox was admitted. Every name-constraint matcher reads the captured operations, whichever name form it tests.
- `pki.trust.parseCcadbCsv` still refuses a distrust date it cannot parse. An empty cell means there is no distrust date, and the cell was trimmed through a live operation first, so a replacement answering with the empty string removed the date rather than failing: a row carrying `not-a-date` parsed as an anchor with no distrust date at all.
- `pki.transport.https` still refuses a colon in a Basic proxy user-id (RFC 7617 sec. 2). The check asked a live index operation, so a replacement answering that the colon is absent accepted a credential the scheme cannot encode unambiguously.
- `pki.trust.parseCcadbCsv` reads each field from the column its header names. The header names were trimmed through a live operation, so a replacement that renamed the real `Trust Bits` header and answered `Trust Bits` for another column made an anchor's purposes come from that other cell: a row stating `Email` produced an anchor carrying `serverAuth` taken from a decoy column.
- The Authority Information Access fetch decides whether a URL's host is an address literal through a captured reference. A host the classification calls a literal is fetched without requiring the transport to declare that it filters the addresses it resolves, so a replacement answering that a DNS name is a literal fetched it under a guarantee nothing had made. That classification runs before the private-address blocklist it feeds.
- `pki.trust` reads a CA bundle through captured file operations. The bytes they return become the anchors the host is trusted to use, which is a wider result than any single verdict: a replaced read substitutes the anchor set itself.
- Everything the proxy Digest exchange is decided by now reads a captured operation. That covers the quality-of-protection selection and the refusal of a challenge offering none this client supports, the protection-space test that decides whether a credential may be sent to a request's origin at all, the normalization of the credentials that are hashed, and the assembly of the header itself, where a dropped `cnonce` or nonce count is a credential the server reads under different rules than the one it was computed under.
- A Digest challenge is read character by character through captured operations, so a refusal names the fault it actually found. Rewriting the algorithm as the lexer copies it does not reach an accepted credential either way, which is worth saying plainly rather than overstating: the policy still refuses. What moved was the reason, from naming the weak algorithm to reporting an unsupported one, and a refusal that misreports why is a refusal an operator cannot act on.
- A proxy Digest credential is computed with a captured hash and a captured random source, and the random bytes are encoded through a captured operation as well. The hash is what the response value is, so a replacement chooses what is sent in place of the password-derived proof. For the client nonce, capturing the source alone was not enough: the bytes are encoded to text before they reach the header, and a replaced encoding answered with a fixed string, which makes the response replayable. Unpredictability is a property of what reaches the header, not of where the bytes came from. A caller's own random source still takes precedence.
- `pki.transport.https` answers the TLS identity question through a captured reference, and it now passes that reference to the connection rather than leaving the option unset. Left unset, the TLS stack looks the check up on its own module export when the handshake completes, and that export returns the verdict on whether a certificate belongs to the host that was asked for: a replacement answering with no error is server authentication reporting success for a certificate issued to someone else. A request asking for a name the server's certificate does not carry was admitted that way. The function passed is the same default the stack would have used, taken when this module loaded, so no verdict changes for a caller running ordinary code, and a caller's own `checkServerIdentity` hook still runs after it as before.
- `pki.transport.https` classifies an address family through a captured reference, which is the operation consulted before any of the others. The classification comes from a writable module export, so answering that `127.0.0.1` is not an address at all made the blocklist fall through to permitting it, leaving every captured operation after it deciding nothing.
- The android-safetynet hostname requirement is decided by captured membership walks as well as a captured fold. The fold alone was not the gate: a walk answering yes passes the requirement whatever the names compare as, and a selection that drops the certificate's DNS names sends the check down its common-name fallback instead. Both reach the same acceptance the check exists to withhold.
- `pki.transport.https` applies its Digest policy to the challenge the proxy actually sent. The challenge was read, split, trimmed and classified through live operations before the policy saw it, so a replacement that rewrote an `MD5` challenge as a strong one turned the weak-algorithm refusal into an issued credential. The parse is now captured end to end, which covers the algorithm, the quality-of-protection list, the charset and the stale and userhash flags.
- The global conversions these checks pass values through are captured too, not only the methods called on them. The private-address classification reads its address hextets with a numeric conversion, and the Trust Bits reader converts a cell to text before splitting it: with the methods captured and those two left alone, both gates were exactly as open as before, because an operation that decides which number or which token the safe code is handed decides the answer. A private address answered as global unicast and a cell reading `Email` answered as `Websites` each reproduced the bypass in full.
- Every operation `pki.trust` reads a CCADB CSV or an NSS `certdata.txt` through now comes from the toolkit's own captures. Securing a cell reader is not enough on its own while the lexer that feeds it is open, nor a token lookup while the header-to-column mapping that chose the cell is open, and the pairing check that refuses two disagreeing rows for one root could be answered `true` from outside. A row's purposes, its distrust dates, which column each came from, and the certificate bytes themselves are all decided on captured operations, so the module cannot be steered a level at a time.
- `pki.trust.parseCertdata` joins an NSS trust object to its certificate on an encoding of the pair's issuer and serial bytes, and that encoding is now captured. The join is only as byte-exact as the text it compares, so a replacement answering with another certificate's issuer or serial attached an otherwise orphaned trust object's delegator purposes to an unrelated root: a certificate with no trust object of its own was trusted for server authentication by a trust object naming a different serial.
- `pki.webauthn.verify` binds an android-safetynet response to the ceremony it was produced for through a nonce, and the digest behind that nonce is encoded to text before the comparison. The encoding is now captured along with the hash: capturing the hash alone left the encoding choosing the operand, so a replacement answering with the nonce the response carries verified a correctly signed response against authenticatorData and client data it was never made over. The same response then replays against any registration.
- `pki.transport.https` applies its Digest algorithm policy to a directive map the challenge parse builds, and that map is now constructed from a capture. RFC 7616 sec. 3.3 makes an absent `algorithm` mean MD5, which `allowMD5: false` refuses, and the default is read out of the map: a replaced construction handed back a map already naming SHA-256, so a challenge that names no algorithm at all produced a credential instead of the refusal.
- The integer readers a wire structure's lengths and counters are read with are captured too. A length read this way is what the bounds check beside it and the slice after it are computed from, so a replaced reader decides which bytes a check runs over: the credential-id length in authenticatorData chose the slice the COSE credential key was then decoded from, the signature counter answered the replay rule that compares it with the stored one, and the length prefix in a transparency-log bundle chose where one entry ended and the next began. The key-length and counter blocks that go into the CMS key-agreement and X9.63 key derivations are written through captures for the same reason, since both are derivation input: a replaced write derives a different key-encryption key from the same shared secret, and a repeated counter block makes two blocks of derived key material identical.
- `pki.webauthn.verify` accepts metadata only as a `verifyMetadataBlob` result, and the marker that distinguishes one from a raw object a caller assembled is now read through a capture. Membership in that marker is the whole check, so a replacement answering yes admitted unverified metadata, after which the status policy and the attestation roots both came from a BLOB whose signature was never checked. The marker that binds an individual entry to the result it was read out of is captured with it.
- `pki.trust.anchor` tells a store-produced entry from a rebuilt one through a capture. The lookup that answers that question both supplies the per-purpose metadata the purpose gate reads and waives the refusal that stops a rebuilt entry carrying such metadata at all, so a replacement answering with an object of its own produced an anchor trusted as a delegator for a purpose no trust store ever granted. `pki.identity.snapshotReferences` marks its frozen output the same way, and that marker is captured as well: a replacement answering yes returned a caller's live array as though it had been snapshotted.
- The key import that precedes an attestation or assertion signature check is captured alongside the verify itself. The import produces the key the check runs under, so capturing only the verify left a replacement able to answer with a key of its own: a signature made by whoever holds that key verified, while the SPKI bytes handed in were never the credential's.
- `pki.webauthn.verifyMetadataBlob` and the lookups that read its result reach their decisions through captures as well. The status-report check is what refuses a revoked or compromised authenticator, and it works by selecting the reports in force and then testing them for a disqualifying status, so each operation that narrows the set decided the refusal: one answering with an empty list left nothing disqualifying to find, and the membership test answering no permitted the entry whatever its reports said. A `REVOKED` entry was permitted either way, after which `metadataAnchors` handed back the roots it registers. Under `statusPolicy: "latest-by-date"` the fold that picks the newest report decided it too, since only the reports sharing that date are considered.
- `pki.transport.https` keeps refusing a private, loopback or link-local address literal under `blockPrivateAddresses` whatever a `Symbol.split` method installed on the string prototype answers. Dividing a string on a separator is specified to look that method up on the separator and call it, so the separator's prototype chain is part of the operation and taking the split operation from a capture does not close it. Measured on Node 24: a hook answering with a public address's octets made `127.0.0.1` classify as global unicast and a request to it proceeded. The parsers that divide a value on a single delimiter now scan for that character instead, which consults no separator at all. Those are the ones where the pieces decide something: the octets an address literal is classified by and the hextets of its IPv6 form, the Trust Bits tokens that grant an anchor its purposes, the quality-of-protection list a Digest credential is computed under, the segments an attestation JWS is divided into, which decide the signed input, the sub-identifiers of an object identifier, and the labels a host name is compared by.
- `pki.path.validate` builds its name-constraint collections, its policy-set intersections and the chains it validates through values this toolkit constructs itself. A collection built through a replaceable construction answers as empty while still holding its elements, and empty is the dangerous direction for a permitted subtree: an issuer's `permittedSubtrees` generation that reads as empty is discarded as though that issuer constrained no name of that form, so a certificate outside it is accepted. The same construction decides whether an excluded-subtree list survives being joined with another, where a dropped list loses the exclusion, and whether a chain keeps every element it is validated over.
- The selections and byte slices these checks depend on consult no construction protocol. Taking an array operation from a capture closes the method and leaves open what the method asks on the way: a selection builds its result through a constructor read off the receiver, and one answering with an ordinary object collects every match as an indexed property while the length stays zero, so the result reads as empty. Empty is rarely inert. An empty subject alternative name selection sends the android-safetynet hostname check to its common-name fallback, where a leaf whose authoritative name is another host passes on a matching common name; an empty permitted-subtree list reads as an issuer that constrains no name of that form and skips the check; and an empty quality-of-protection list reads as a Digest challenge offering none. A selection answering with elements of its own substitutes them instead, which is how a fabricated certificate chain or anchor set reaches a signature check. Slicing bytes asks the same question: a slice of authenticatorData answering with the expected RP ID hash made an assertion produced for another relying party satisfy `expectedRpId` while the signature was verified over the original bytes. Every selection and slice behind these checks now appends into, or builds, a value this toolkit constructs itself. The element count those operations iterate by is read once and required to be a plain non-negative integer, because a proxy over an array satisfies every array test while answering `length` with an object whose value changes each time it is read. A loop re-reading it then visits fewer elements than the first read promised, which drops the tail in silence: a two-certificate path presented that way reported valid with the second certificate never checked. Appending is the other half of the same question: appending with `push` writes through an assignment that walks the prototype chain for a numeric setter, so an accessor installed there takes the value, no property lands on the list, and the index reads back as whatever that accessor answers. That was enough to substitute a quality-of-protection token into a Digest credential, so these lists are built by defining each element as an own property, which consults no accessor.
- The refusal that stops a revoked or compromised authenticator being trusted selects its reports with plain index loops, so no part of it runs through an operation that consults a replaceable protocol. Taking the array operations from a capture is not sufficient here: a selection still builds its result through the constructor it reads off the receiver, so one answering with an ordinary object collects the matching reports as indexed properties while the length stays zero and the test at the end visits nothing, and the join that merges two selections reads a spreadability flag the same way, leaving both nested where no report has a status to recognize. Each report's status and effective date are read as its own properties too, so a caller-supplied record cannot inherit a date and have an undated revocation read as one not yet in force. Every route ended in a permit for an entry the catalogue has revoked.
- The lookup that finds a metadata entry builds its key out of an AAGUID or an attestation certificate key identifier, and every operation that key is built from is captured: the digest, the encoding that renders it, the slices that punctuate an AAGUID and the case fold applied to both sides. The entry a key finds supplies the status reports and the attestation roots, so an operation answering with another authenticator's identifier governed the attestation by another entry. The same digest tells two certificates apart where a status report names one, so a replacement also made a certificate-scoped compromise report apply to the wrong leaf.
- `pki.webauthn.verifyAssertion` snapshots the bytes its verdict is computed over, and the walk that takes those snapshots is captured. A walk visiting nothing left the caller's own buffers in place, so they could be rewritten between the call returning its promise and the verification running: authenticatorData substituted along with a signature matching it verified, and the verdict described bytes the call never saw. `pki.webauthn.verify` validates its format-scoped boolean options by walking them too, and a walk that visits nothing left a mistyped value in place for an arm that wants exactly `true`.
- `pki.webauthn.verify` holds the requirement that an Apple attestation certificate carry the anonymous-attestation extension. The extension was taken as index 0 of the selection that looks for it, and index 0 of an EMPTY selection is not absence: it reads through to the array prototype, so a value installed there answered for an extension the certificate does not carry. A leaf with no extensions at all verified, against a nonce that was never in the signed certificate. The selection is indexed only once it is known to hold something.
- The algorithm tables `pki.webauthn.verifyMetadataBlob` reads its parameters out of carry no prototype and nothing writable, row by row. Freezing a table is not enough on its own when its rows are object literals, because a row still inherits: a `fromLeaf` property installed on the base object prototype was read off every row and sent the strongest algorithm down the branch that takes its parameters from the leaf key instead, and a scheme name installed the same way made a key type read as supporting a scheme it does not. The table a leaf key's permitted schemes are looked up in, the status tables a report is judged against, and the header parameters a `crit` refusal is read from are all closed the same way. `pki.webauthn.verify` and `pki.trust`'s own option bags default to a record with no prototype as well, so a property installed on the base object prototype no longer reads as an option a caller never set. The Digest policy goes further, because every field of it is read off the record it is given: the bag is asked for `policy` by own key and the policy is copied by own key, so neither an inherited `policy` supplied for a caller who passed none nor an inherited `allowMD5` supplied for a caller who passed an empty one turns the default MD5 refusal into an issued credential. The whole parameter bag is taken as an own-key copy for the same reason, since every field of it is another value an inherited read can supply: an inherited `rng` answered for the client nonce, and a client nonce that is the same on every exchange makes the credential replayable, which is the one property that value exists to have.
- `pki.path.validate` is reached by name from the metadata reader's anchoring fold, and that module now freezes what it exports. The validator read that way IS the check at the point it is read, so a replacement reporting a valid path made a metadata BLOB whose signing certificate chains to none of the supplied roots pass anchoring, after which the catalogue was branded verified and its status entries and attestation roots governed WebAuthn verification. The reference cannot be substituted now.
- The modules behind these checks freeze what they export, so a cross-module call cannot be answered by a replacement. Capturing a module's own operations is not enough on its own when the consumer reads the operation off the producing module at the call: `pki.webauthn.verify` reaches the metadata lookup and the chain validation that way, and both are the whole check at the point they are read. A lookup answering with nothing turned a disqualified authenticator into one with no catalogue entry, which takes a different branch and skips the status refusal, and a chain validation that settles without walking a path reported the attestation anchored with nothing compared. Neither can be substituted now.
- A metadata BLOB's own chain is anchored through captures. The fold over the candidate roots is what walks each one until a path validates, and the slices inside it are the chain a root is tried against and what is left once a self-presented anchor is stripped: a replacement answering with a single-element list left nothing to validate, and the empty path reported trusted on the strength of the strip alone. The key comparison that decides a certificate IS the anchor is captured with them, so a certificate carrying the anchor's subject but a different key no longer counts as the anchor.
- `pki.transport.https` refuses a Digest challenge whose `qop` directive lists no usable value, and that refusal no longer depends on a global conversion. The empty-member test was the global `Boolean` passed as a predicate, so a replacement answering true for the empty string left `qop=""` with one member and returned a challenge whose quality of protection is not a value the scheme defines. The predicate is written out instead.
- The rest of `pki.webauthn`'s decisions read captured operations too, not only the folds and walks in the hostname check. That covers the slices authenticatorData is cut into, which are the RP ID hash, AAGUID and credential id each check runs against; the assembly of the bytes every attestation format and the assertion path verify a signature over; the comparisons that bind an Apple nonce, a TPM quote and a TPM attested name; the origin and top-origin allow-lists, where the membership test is what refuses a response completed on another site; and the COSE algorithm allow-list, including the explicit opt-in that is the only way SHA-1 is accepted.
- The ASN.1 reader and builder tables are frozen, and so is the module object that names them. Every module in this toolkit reaches a leaf reader off that table by name, hundreds of times across sixty-odd modules, and each one is the step that turns bytes into the value a check compares. A replaced time reader made a CCADB row stating a 2020 distrust date produce an anchor with a 2099 cutoff, so a root stayed trusted long past the date the store names. Freezing the two tables left the properties naming them assignable, which reaches the same reader one level out; both levels are closed now, for every consumer at once.
- Every module that answers a verdict for another module freezes what it exports: the signature, certificate, CRL, CSR, attribute-certificate, CMC, CMP, CRMF, timestamp and certificate-transparency verifiers, the Merkle proof checker, the Edwards point validator, and the schema modules whose `verify` is the structural half of those checks. Each is reached by name at the call rather than captured, so the reference is what the check amounts to at the point it is read. `pki.cms.verify` hands a signature timestamp to the timestamp module that way, and a replacement adding the archive opt-in on the way through reported a SHA-1 imprint as a valid timestamp while the caller had asked for no such thing.
- An allow-list a caller leaves empty admits nothing. `pki.webauthn.verify` reads `expectedOrigin`, `expectedTopOrigin` and `allowedAlgorithms` as the list's own entries: a sparse array answers from the array prototype at the index it has a gap at, and the test that requires every entry to be well-formed skips that gap, so a one-element list holding no origin of its own passed the door and then matched an origin installed on the prototype, with the origin reported as checked. Those lists are now held to their own entries at the door and at the comparison.
- The results of the two component verifications a composite ML-DSA signature is the conjunction of are awaited through an aggregate that consults nothing replaceable. The standard aggregate reads `Promise.resolve` off the constructor on every call, so a replacement there answers for each component and none is awaited: measured, a certificate whose classical half is corrupted reported as valid. The same aggregate now carries the composite KEM combiner's two shared-secret halves, the proof-of-possession verdict for a set of certificate requests, the signer infos a CMS signature is assembled from, and the certificate identifiers an OCSP request is built around.
- The composite ML-KEM combiner derives both halves of a shared secret through captured continuations, and so does every step that produces one. A continuation is read off the promise at the call, so a replacement settling the decapsulation step handed the combiner bytes the key never produced and the secret this side derived was not the secret the other side holds. The step that wipes the component halves once the combiner is done runs through a capture as well.
- `pki.ocsp` builds the certificate identifier a request names, and the key hash a responder names itself by, through captured continuations. A replacement settling the digest step named another certificate's identity in bytes that are then signed. The verbs that sign a request or a response hold the signature to verifying under the certificate they carry before emitting it, and that step is captured too, as are the one that wipes a key the verb owns and the one that turns a response's status into the verdict a caller reads.
- `pki.crmf.verifyPop` reads each message's proof-of-possession verdict through a captured continuation, and the verb that builds a proof uses the same ones. The continuation is where the signature answer becomes a message's verdict, so a replacement settling it handed back a record stating the proof verified and the set-wide answer reported every request as verified. On the building side those steps are what emit a proof only once its signature has been checked against the key being certified, and what wipe an enclosed private key whichever way the work settles. The verb still hands back an ordinary array of message verdicts.
- `pki.path.validate` reports a signature verdict it computed and builds its policy graph from the policies a certificate actually asserts. Two operations decided both and neither was the toolkit's: the continuation that delivers the signature result, where a replacement settling the key-import step reported a verified signature with nothing checked and a chain whose issuer name matches a supplied root was accepted on a signature that does not verify; and the walk over a certificate's certificate-policies extension, where a replacement supplying a fabricated policy identifier built a graph node for a policy the certificate never asserted, which under `initialExplicitPolicy` with a stated `userInitialPolicySet` is what makes an otherwise policy-invalid chain acceptable. The same module's revocation checkers, policy mappings, graph pruning and user-policy intersection are taken from captures alongside them, and the CRL and OCSP lists a caller supplies are mapped without consulting a construction protocol. The composite signature verifier the path validator calls for an ML-DSA plus classical algorithm is covered too: both component verdicts and the conjunction over them are read through captures, so a composite signature cannot report as verified with one half, or neither, actually checked.
- `pki.webauthn.verify`, `pki.webauthn.verifyAssertion` and `pki.webauthn.verifyMetadataBlob` continue every step of their work through captured operations, which covers the step that delivers each verdict. Three things stood between a check and its result, and each one was enough on its own. A promise's continuation is read off the promise at the call, and the callback holding `if (!ok) throw` is where a signature refusal lives, so a replacement that settles without running it carries the response past the refusal. The promise a continuation hands back is built through a constructor read off the promise and a species read off that. And resolving with a promise is specified to read that promise's own continuation and call it, so a callback that returns one is routed through the same replaceable step. Measured on each: a packed self-attestation whose signature does not verify returned as verified, an assertion with no input at all fulfilled with a fabricated verdict instead of being refused, and a chain reaching none of the supplied roots returned a verified catalogue. These routes now settle through operations taken at load, and they settle identically to the method they stand in for across the eighteen outcomes a caller can produce. Where they do deviate it is toward a refusal: a value presenting as a promise without being one is refused rather than passed along as a result. Two things remain outside what this can reach, and neither is on a route here: the caller's own `await`, by which point every check has run and any refusal has been raised, and a value that is a thenable without being a promise, which the language's own resolution procedure adopts by calling the `then` it finds.
- `pki.transport.https` requires `tls.servername` to be a string, whether it arrives with the request or as a transport default. The name is read twice more after it is accepted: by the TLS layer for the name sent in the handshake, and as the identity the server certificate is matched against. A value that converts to a different string per read sent one name and verified another. The proxy arm had always typed this option; the origin arm now does too.
- `pki.transport.isBlockedIp` treats a value it cannot classify as blocked, which is what `blockPrivateAddresses` turns on. The classifier converts its argument, so a value whose conversion answered a loopback literal to the address-family check and a public one to the octet scan was measured twice and the second answer decided: it came back not blocked while the family check had seen `127.0.0.1`. A resolver supplied through `opts.lookup` reaches this with whatever it yields. A hostname that is not an address literal still answers false.
- `intrinsic.byteSlice` and `intrinsic.copyList` coerce each window bound once. Read again by the clamping arithmetic, an object bound answers one value to the comparison and another to the slice, so the window returned covers bytes the bound excluded; a fractional bound is now refused rather than returning a window of absent values. `intrinsic.splitChar` requires a string receiver and a numeric delimiter, because a receiver converted afresh per character is scanned as one value and cut as another, and a delimiter that is not a number matches nothing and hands back the whole input as a single field.
- The tables that say which digests are weak and which a signature timestamp may take an imprint under are frozen together with the module object that names them. `pki.cms.attachTimestamp` and `pki.cms.sign` ask the weak-digest list by name at the call, so a copy of it with its one entry removed turned the default refusal of a SHA-1 timestamp imprint into an acceptance. That imprint carries the whole binding between a token and the signature it covers, and a collision in it substitutes one signature under a genuine token.

## v0.8.55 — 2026-10-06

Tell a caller when the path search stopped looking, rather than reporting the chain it had.

### Added

- `pki.path.build` puts `depthLimited: true` on a `{ valid:false }` return when `opts.maxDepth` stopped an expansion that still had candidates waiting. The chain reported alongside it is the best one the search ASSEMBLED, not the best one the pool holds, so a caller treating `valid:false` as a verdict about the certificates should read the field and raise `maxDepth` instead. It is absent from a successful return and from a failing one the search explored to its end, including a chain whose last hop lands exactly on the cap with nothing left to expand: the claim is that the bound truncated the search, not that the search reached it.

### Changed

- `pki.sigstore.verifyBundle` reports `sigstore/chain-incomplete` where it reported `sigstore/chain-invalid`, for a bundle whose Fulcio chain the path search stopped expanding at its depth bound. A rotated CA reissues under one subject name, so a bundle can carry a short branch that assembles and fails beside a long one that reaches an anchor past the bound; the short branch's failure was being published as the verdict on the bundle. The candidate budget already answered this way, and the two bounds now agree. Nothing about a chain that was explored to its end changed: a bundle whose assembled path genuinely fails validation is still `sigstore/chain-invalid`.
- A reached bound stays a bound rather than becoming a refusal: a bounded depth-first search visits equal-scoring candidates in the order they arrive, so a bundle large enough to exhaust the expansion ceiling can still depend on its own ordering. That is a property of bounding the search at all. Raise `maxDepth` and `maxCandidatesConsidered` for a pool whose shape needs more of the graph walked.
- `depthLimited` reports a candidate the cap kept the search from expanding, and nothing weaker. A candidate already on the chain is one the cycle check would skip at any depth, so it is not counted: counting it would report a truncated search where the search was exhausted, and turn a chain this toolkit refuses into one it claims it never finished reading. Two cases it does not report, both of which leave the older behavior in place rather than overstating: a chain whose last hop lands on the cap with nothing left to match, and a cap that also suppressed an Authority Information Access fetch for the certificate at that hop. The second needs the fetch's own URL eligibility answered to be certain a fetch was available, so it is left unclaimed rather than guessed at.

### Fixed

- `pki.ct` and `pki.path.build` take their declared options through one shared snapshot instead of two copies of the same loop. Both read every declared name once, before anything derives from one, and both keep an omitted option OMITTED rather than recording it as a name holding `undefined`, which is what lets an absent `transport` still mean "open the connection yourself, under the pinned anchors". Behavior is unchanged at both verbs; what the shared door adds is that the next caller to copy an options bag by hand is caught before it ships.

## v0.8.54 — 2026-10-06

Refuse the universal tags X.680 assigns to no type, and tell a caller which those are.

### Added

- `pki.asn1.reservedUniversalTag(tagNumber)` answers whether X.680 reserves a universal tag number, which is the question `pki.asn1.decode` asks before it accepts an element. It returns `false` for every assigned type, including the seven this codec carries no reader for, so it is not a statement about what can be read: a caller deciding whether bytes are worth passing on gets the same answer the decoder will give. A value that is not a tag number answers `false` rather than throwing, as `pki.asn1.isPrintableString` and `pki.oid.isDottedDecimal` do of a value of the wrong type, and the type is checked before anything is compared, so `37.5` and `Infinity` are not read as reserved for sitting inside the addenda range and no `valueOf` of a caller's object is invoked inside the predicate.

### Changed

- `pki.asn1.decode` refuses a universal tag 15 element and any universal tag from 37 up, with `asn1/reserved-tag` naming the row of X.680 Table 1 that reserves it. Every reader in the toolkit inherits the refusal, so a message carrying such an element is refused at its own verb: `pki.schema.parse` and `pki.schema.detectFormat` report `schema/bad-der` before any format detector reads the children, which matters because a child nothing refuses is a child a detector may count. `pki.inspect.asn1` still prints the header walk beside the refusal, so an operator can see what the bytes claimed. Nothing that was accepted before and is assigned by X.680 changed: the four long-form time types and the two internationalized-resource-identifier types at tags 31 to 36 decode as they did.
- `pki.asn1.encode` and `pki.asn1.encodeIdentifier` refuse the same tags, with the same `asn1/reserved-tag` code, rather than emitting bytes this toolkit's own decoder calls malformed. The class is read off the masked identifier bits as the decoder reads it, so a `classBits` carrying only the constructed bit is the universal class here too. Every other class is untouched, which is what makes the rule narrow enough to be safe: a context `[15]` is how an optional field is written across X.509 and CMS, and it encodes as it did. So do the assigned universal types at tags 31 to 36. The DER form restrictions are deliberately not enforced at this door: a constructed OCTET STRING or character string is a legitimate BER encoding that DER alone narrows to the primitive form, and this low-level encoder is how those bytes are minted for the BER reader that accepts them. A tag number that encodes nothing is a different matter from a form one set of encoding rules permits and another does not.
- `pki.asn1.encode` and `pki.asn1.encodeIdentifier` require `classBits` to be a number naming a class: `0x00` universal, `0x40` application, `0x80` context or `0xc0` private, with `0x20` constructed allowed alongside. Anything else is `asn1/bad-tag`, as a tag number of the wrong type already was. The low five bits of that argument were being written into a single-octet identifier, so a value carrying any of them emitted a tag other than the one passed: `encodeIdentifier(0x0e, false, 1)` wrote `0f`, universal 15. A value that was not a number was narrowed into a class instead of refused, so `"128"` and `0x100000080` both encoded as a context `[1]`. For every argument still accepted the bytes are unchanged. A tag number past the four-octet high-tag form now reports `asn1/tag-too-large` ahead of any reserved-tag refusal, which is the order `pki.asn1.decode` has always refused that input in.
- `pki.asn1.TAGS` is frozen. Assigning to it reached the encoders that compose an identifier from a tag number and the comparisons that test one against it, so a single assignment moved what a SEQUENCE is for every caller in the process. The numbers are X.680's; read them, and build a structure with a tag of your own through `pki.asn1.encode`.
- Eight places that reported a failure by reading `message` off whatever was thrown now describe the thrown value the way the rest of the toolkit does. `pki.cmc.build`, `pki.cmp` session and verification, and `pki.crmf` are the verbs affected, and only the text of a message changes, in the case where what was caught is not an error or carries no readable message: it is named by its type instead of by a fixed phrase, and a `message` that answers from an accessor is read once rather than twice, so the text reported is the text that was tested. A `message` accessor that throws no longer replaces the failure being reported with its own. The error codes are unchanged.
- A caught value whose `message` is the empty string is described by its type as well. It was being reported as the empty string it was, which left the sentence built around it ending at its colon with nothing after it. This reaches every message assembled that way, including the ones `pki.ocsp.httpRequest` and the revocation fetcher build. A message of only whitespace is still a message and is reported unchanged.

## v0.8.53 — 2026-10-06

Put a copy of a message's algorithm identifiers where its signature or its MAC covers them, and refuse one whose copy disagrees.

### Added

- `algorithmProtection` on `pki.cms.sign`, `pki.cms.countersign` and `pki.cms.authenticate` emits the RFC 6211 `CMSAlgorithmProtection` attribute: a copy of the structure's digest algorithm identifier plus its signature algorithm identifier for a `SignerInfo` or its MAC algorithm identifier for an `AuthenticatedData`, each copied whole with its parameters from the value the field itself is built from. RFC 8933 section 4.1 makes including it a SHOULD, and RFC 9882 section 4 and RFC 9814 section 5 repeat that for ML-DSA and SLH-DSA, whose signatures carry no digest identifier of their own. It is off by default, because turning it on changes the bytes of every signature a caller emits. `true` writes the identifier RFC 6211 section 2 and Appendix A state, which OpenSSL registers as `id-aa-CMSAlgorithmProtection` and Bouncy Castle uses; `"registry"` writes the S/MIME arc value the IANA registry and `draft-ietf-lamps-rfc6211-update` give it.
- `pki.cms.verify` compares the attribute against the fields it protects on every signer and every countersignature, and reports `algorithmProtection` per signer: `{ present, oid, compared, mismatch }`, where `compared` lists the identifiers actually checked. Both identifiers the attribute is assigned are read, because a verifier that recognizes one spelling and not the other checks nothing on a message using the other, which is the interop failure the update draft records between two shipped implementations. A disagreement fails the signer with `cms/algorithm-protection-mismatch`: section 3.1 makes both comparisons MUSTs once a validator supports the attribute. Both comparisons are modulo encoding, in the two ways section 3 names: a parameter field with an ASN.1 default compares the same written out as omitted, which that section makes a MUST and says a binary compare of the bytes cannot satisfy, and RSASSA-PSS is where it arises because RFC 4055 section 3.1 defaults all four of its fields; absent and DER NULL parameters read as one algorithm on the digest arm, which RFC 3370 section 2.1 and RFC 5754 section 2 require an implementation to accept. Anything else in a parameter field is read as written, since that is where RSASSA-PSS keeps its hash, mask generator, salt length and trailer field and the signature covers none of them. `requireAlgorithmProtection` turns an absent attribute into a failure for an operator whose profile asks for one, on every signer and on every countersignature at any depth.
- `pki.cms.decrypt` compares the attribute on an `AuthenticatedData`, against the outer `digestAlgorithm` and `macAlgorithm` the MAC input does not cover, and reports the same `algorithmProtection` field on its result. The comparison runs before the MAC, because a mismatch is a statement about the message rather than about the key. `requireAlgorithmProtection` applies here too.
- `pki.schema.cms.parseAlgorithmProtection(der)` decodes a `CMSAlgorithmProtection` value held on its own. A value carrying both arms, or neither, is refused `cms/bad-algorithm-protection-attr`, which is section 2's rule that exactly one of them is present; each failure says which. `pki.schema.cms.parse` decodes the attribute during its own walk, so a message carrying a malformed one, more than one value, more than one instance, or an instance in an unsigned, unauthenticated or unprotected position is refused there.
- `pki.lint.cms` reports `lint/rfc8933/algorithm-protection-absent` as a notice on an originator that carries attributes and no such attribute. Section 4.1 states the requirement for both content types, so the row reads a `SignedData` signer's signed attributes and an `AuthenticatedData`'s authenticated attributes, the second being where the algorithm fields are covered by nothing at all. The SHOULD is conditioned on attributes being present, so an originator that signs or authenticates the content directly draws nothing. RFC 8933 updates RFC 5652 rather than restating it, so the row is its own profile: `pki.lint.cms(der, { profile: "rfc8933" })` asks only what that document added, `profile: "rfc5652"` asks only what that one states, and the default runs both. `pki.lint.profiles()` lists `rfc8933` alongside the rest.

### Changed

- Every verb that emits attributes refuses a caller-supplied algorithm protection attribute that this package's own verifier would then reject, with `cms/bad-input`. `pki.cms.sign`, `pki.cms.countersign` and `pki.cms.authenticate` refuse a copy contradicting the identifiers being emitted, a copy carrying more than one value, and a second one beside the attribute the verb itself emitted; caller-supplied attributes otherwise reach the wire unread. A copy that agrees is admitted. The attribute is refused in `unsignedAttributes`, which RFC 6211 section 2 forbids outright, and in an `AuthEnvelopedData`'s authenticated attributes, which RFC 8933 section 6 says it protects nothing in: that content type has no digest or MAC algorithm field for a copy to name. `pki.schema.cms.parse` refuses it in that set too, under either identifier, so a message from another producer is read by the same rule this package writes by; every restriction an authenticated-attribute set already carried still applies there, a countersignature among them. Which arm the value carries stays the validator's question: RFC 6211 section 2 says which arm a producer fills and sections 3.1 and 3.2 give the consequence to whoever validates, so a message holding the other arm parses, to be read, and `pki.cms.verify` or `pki.cms.decrypt` refuses it as the mismatch it is. Asking for `algorithmProtection` together with `signedAttributes: false` is refused, since the attribute rides in the signed attributes and a message cannot carry it without them, and so is asking for it with `authenticatedAttributes: false`, where the MAC covers the content octets directly and no attribute set is built. The off forms stay accepted in both of those modes: a caller passing one options object to several messages does not have to strip a switch it had already turned off.
- A verified countersignature row carries one more field, `algorithmProtection`, beside `ok`, `sid`, `cert`, `digestAlgorithm`, `unsignedAttrs` and `countersignatures`.
- An option that turns a check on is read as the boolean it documents, and any other value is refused at the door with the module's `bad-input` code. This covers `requireAlgorithmProtection` on `pki.cms.verify` and `pki.cms.decrypt`, `requireRevocation` and the RFC 5280 section 6.1.1 policy inputs `initialExplicitPolicy`, `initialAnyPolicyInhibit` and `initialPolicyMappingInhibit` on `pki.path.validate`, `requireJsonContentType` on `pki.ct.fetchLogList`, and `expectSCEPStandard` and `requireStrongProfile` on `pki.scep.getCACaps`. The off forms are `false`, `null` and an absent option; every other value is refused, in both directions. A `"true"` read out of a config file is unequal to `true`, and a boundary comparing against `true` ran with the check disabled, while a truthiness test turned one on for `"false"`. A trust anchor's own copies of those three policy flags were already typed where they are copied, and a caller's values are compared against them. `algorithmProtection` takes `true`, `"registry"`, `false`, `null` or an absent option, and refuses every other value.
- Each of those options is typed before the thing it governs happens, and the value that passed is the value the check uses. `pki.path.build` types the four switches it forwards, and its own `validate`, at its own door, because a build reaches an AIA fetch before it reaches `pki.path.validate` and a build that assembles no path never calls it. Normalizing a trust anchor reads its name, key and algorithm, and those reads run caller code when the anchor carries accessors, so the typed values are what each anchor attempt is handed rather than whatever an options object the caller still holds says by then. `pki.cms.verify` reads its policy switches ahead of deriving the message and copying the trust material for the same reason. `pki.cms.sign`, `pki.cms.countersign` and `pki.cms.authenticate` resolve `algorithmProtection` at their own door as well: signing an async iterable hashes the stream to build the attributes, and a value refused only there is refused after the stream has been drained, which a caller cannot act on for an input that cannot be replayed and never arrives at all for one that does not end; `pki.cms.authenticate` likewise refuses before it hashes the content and wraps each recipient's key. `pki.path.build` refuses `requireRevocation` or any of the three policy inputs asked for together with `validate: false`, which returns the ordered path without validating and so applies none of them.
- `pki.cms.decrypt` refuses `requireAlgorithmProtection` on a content type that cannot carry the attribute. RFC 8933 section 6 gives it a place in an `AuthenticatedData` alone, so an `EnvelopedData`, `AuthEnvelopedData` or `EncryptedData` cannot satisfy the requirement, and the plaintext came back before without the requirement having been applied or reported. The refusal is `cms/bad-input` and names the content type.

## v0.8.52 — 2026-10-05

Stamp a signature with a time-stamping authority's token, verify the stamp beside the signature it covers, and build the signing-certificate attribute a CAdES signature carries.

### Added

- `pki.cms.timestampImprint(der, opts?)` returns `{ imprint, signerIndex }` for one signer of a `SignedData`: `imprint` is the `{ hashAlgorithm, hashedMessage }` argument `pki.tsp.request` and `pki.tsp.sign` take, and `signerIndex` the signer it was taken for, which is what `pki.cms.attachTimestamp` is then given. The preimage is the contents octets of that signer's signature OCTET STRING with the tag and length excluded, which is what ETSI EN 319 122-1 clause 5.3 specifies and what RFC 5652 clause 11.4 states for the sibling countersignature. `opts.digestAlgorithm` chooses the digest (`sha256` by default) and `opts.signerIndex` the signer. The digest must be one a timestamp is verified under, so sha256, sha384 and sha512 are admitted and anything else is refused rather than producing an imprint whose token no verifier here would read.
- `pki.cms.attachTimestamp(der, token, opts?)` attaches an RFC 3161 `TimeStampToken` as the signature-time-stamp unsigned attribute `{ 1 2 840 113549 1 9 16 2 14 }`. It splices rather than re-signs: the encapsulated content and its type, and per signer the version, the signer identifier, the digest algorithm, the signed attributes, the signature algorithm and the signature, plus every embedded certificate and every attribute already present, are preserved byte for byte. Several tokens may be attached, one per authority, each as its own attribute carrying exactly one value. `opts.signerIndex` names the signer, `opts.pem` returns a PEM block.
- `pki.cms.verify` reports `signatureTimeStamps` on each signer, one row per attached token: `{ valid, trusted, genTime, accuracy, serialNumber, policy, revocationChecked, tsa, signer }`, or `{ valid: false, code, message }` where the token does not hold up. A token that fails does not take the signature's own verdict down, since the signature and the time it was made at are separate claims. Each row's imprint is checked against the signature octets the signer verdict was computed over, and a row is refused when its `genTime` precedes the `signing-time` the signature itself asserts or when the latest instant the token could have been created at falls after the signing certificate's `notAfter`, the half of EN 319 122-1 clause 6.3 requirement m that certificate bytes can decide. That bound is the instant the token names plus the accuracy it states, since RFC 3161 section 2.4.2 makes accuracy a window around `genTime` rather than a point, and it keeps the sub-second fraction a `Date` cannot hold. `timestampTrustAnchors`, `timestampCerts` and `timestampRevocationChecker` are the authority's own trust inputs, named apart from the signer's because anchoring a time-stamping authority is a different decision from anchoring a signer.
- `pki.cms.verify` reports `cadesBaseline` on each signer, `{ conformant, signingCertificateAttribute, findings, requirementsChecked }`, because a sound signature is not by itself a CAdES signature. The content-type attribute must have value `id-data` (ETSI EN 319 122-1 clause 6.3 requirement f) and the signing certificate must be protected by a `signing-certificate` or `signing-certificate-v2` attribute (clauses 5.2.2.2 and 5.2.2.3). That second requirement is the attribute's claim checked against the certificate the signer resolved to, by the certificate hash and by the issuer and serial where the identifier carries them (RFC 5035 clause 5), not the attribute's presence; a superseded v1 attribute is held to the same claim as the v2 beside it. Each attribute is also held to the digest it binds with, which is what defines the two forms: clause 5.2.2.2 is the attribute using SHA-1 and clause 5.2.2.3 the one using another algorithm, so a v2 identifier binding with SHA-1 is reported against requirement h. Every finding carries the `level` of the requirement it is against and `conformant` answers for the shall-level ones alone, so the two guidance requirements this verdict reads, the issuerSerial field that should not be included (requirement g) and the migration to the v2 form (requirement j), are reported without reading as non-conformance. `requirementsChecked` names the requirements the verdict answered for, so `conformant` is read against that list rather than as a full clause 6.3 audit, and it is reported beside a failed signature rather than withheld.
- `pki.schema.smime.buildSigningCertificateV2(certs, opts?)` encodes the ESS `SigningCertificateV2` attribute value (RFC 5035 clause 5.4.1) that binds a signature to the certificate that made it, which is what `openssl cms -verify -cades` requires a signature to carry. `opts.hashAlgorithm` selects `sha256` (the default), `sha384` or `sha512`; SHA-1 is refused, since the verifier reads a SHA-1 binding as a weak one. The SHA-256 form omits the DEFAULT algorithm field and the other two emit the identifier with its parameters field absent.

### Changed

- `pki.cms.verify` selects the signer certificate an ESS signing-certificate attribute names where several certificates match the SignerInfo's identifier. A `subjectKeyIdentifier` names a key, so two certificates holding that key verify the same signature equally and the certificate reported was whichever matched first, taking with it every decision keyed to it: the validity window, the key usage, and the chain built from it. RFC 5035 clause 5.4 makes the attribute's first identifier the certificate used to verify the signature, which is this question; a named candidate must still verify the signature like any other, and a signature carrying no such attribute resolves as before.
- `pki.cms.attachTimestamp` refuses a token whose message imprint is a weak digest, and `allowWeakDigests: true` admits it. The verifier refuses such a token by default, so the attach verb holds the same posture rather than writing an attribute whose row would then be refused. An archived token needs the option at both ends.
- A time-stamp token whose `hashedMessage` is not the output length of the digest it names is refused as `tsp/imprint-length` rather than `tsp/imprint-mismatch`. The two are different faults: the first says the token is malformed, the second that it is well formed and stamps other bytes. A caller matching on the old code for a length fault reads the new one.

### Fixed

- The SHA-2 algorithm identifiers `pki.tsp.sign` and `pki.tsp.request` write carried NULL parameters. RFC 5754 section 2 requires an implementation to generate SHA-2 algorithm identifiers with the parameters field absent, which is now what both emit, in the `TSTInfo` message imprint, the `TimeStampReq` message imprint, and the ESS signing-certificate identifier under SHA-384 or SHA-512. Both forms are still accepted on the wire, as that section also requires, and an imprint is matched by the algorithm it names and the digest it carries rather than by the identifier's bytes. The SHA-256 ESS identifier, where the field is the DEFAULT and must be omitted outright, is unchanged.

## v0.8.51 — 2026-10-05

Issue and request the TLS Feature extension, hold a chain to the features its issuer advertises, and read a parsed destination through accessors taken at load.

### Added

- `extensions.tlsFeature` on `pki.x509.sign`, `pki.csr.sign` and `pki.x509.extension` writes the RFC 7633 TLS Feature extension. The value is a list of TLS ExtensionType values, each a number, a bigint, or one of the registry names this build knows, `status_request` and `status_request_v2`. It is emitted non-critical unless `tlsFeatureCritical` is set: section 4 says the extension SHOULD NOT be marked critical and then names the case where the critical form is the intent, so the knob exists rather than the critical form being refused. A request carries it in the certification-request extension attribute, which is where OpenSSL's `req -addext "tlsfeature=status_request"` puts it, and the bytes the two produce are identical. An empty list is refused, because the extension it would emit advertises nothing; one that arrives on the wire still decodes, as it did before.
- `pki.lint.certificate(der, { profile: "rfc7633" })` reads the extension's values. RFC 7633 states no range, no ordering and no prohibition on repeats, so none of those is refused by the encoder and all are reported here: a feature outside the 16-bit width of the IANA registry that section 4.1 points at, and a repeated feature, which names the same set it would without the repeat. Two rows come from elsewhere. RFC 9162 section 7.2 asks a certification authority not to issue a certificate naming the transparency_info feature, because a TLS server need not support it to take part in certificate transparency. And a certificate naming status_request_v2 asks of a TLS 1.3 server something that version does not define: RFC 9846 obsoletes RFC 6961, which defined that extension, and leaves it out of the ExtensionType enum in its section 4.3. The IANA registry still lists it as recommended and marks it neither obsolete nor deprecated, so that row is a notice and says only what those documents say. A critical TLS Feature extension is reported by the default rule set and needs no profile.
- `pki.path.validate` enforces RFC 7633 section 4.2.2 at every issuer-to-subject boundary of a path. A certificate signed by a signing certificate that asserts keyCertSign and carries the extension must carry one offering the same set or a superset, or the path fails with `path/tls-feature-not-offered`; a subject carrying no extension at all under such an issuer is the same failure, because the clause requires it to carry one. The comparison is of sets, so neither order nor a repeat on either side changes it. An issuer that carries no extension constrains nothing. A trust anchor's own extensions are not read, so the rule applies from the first certificate in the path downward, and a boundary that was not checked reports no result rather than a passing one.

### Fixed

- A parsed request destination is read through accessors the package captured at load, in `pki.acme`, `pki.est`, `pki.scep`, `pki.cmp`, the revocation fetcher, the certificate-path builder's authority-information-access fetch and the HTTP transport's Digest authentication. Capturing the `URL` constructor, which v0.8.50 did, does not capture the prototype the value it returns reads its parts off: redefining `href` there answered every read of a destination, so a caller's code running mid-request could still move it. Each part is now read once into a local and every decision made from that local, which also closes the case of a part that answers two reads differently: the scheme that decides whether a request is allowed is the scheme the port defaults from, the host the address filter is applied to is the host the request is sent to, and the request target a Digest credential is computed over is the target it is sent to.
- The citation on the TLS Feature renderer named RFC 7633 section 6 for the ExtensionType values. Section 6 is IANA Considerations and registers the extension's own object identifier; section 4.1 is the clause that points at the IANA registry for the values.

## v0.8.50 — 2026-10-05

Decide where a certificate-transparency request goes and which trust anchors it is made under before reading anything else the caller passed, and keep a copied byte argument out of the caller's reach.

### Fixed

- `pki.ct.getProofByHash` and `pki.ct.getSthConsistency` read the fields of the signed tree heads they were given before the request was shaped, and `pki.ct.addChain` and `pki.ct.addPreChain` read the elements of the certificate chain. A getter on any of those fields runs the caller's code at that moment, so it could rewrite the URL and send the request to another host, or empty `tls.anchors` after the check that refuses an unpinned log had already approved them, which opened the connection with no anchors at all while leaving the URL it reported unchanged. `pki.ct.fetchLogList` had the same ordering for a different reason: it parsed the URL, which converts it and so can run a caller's `toString`, before it read the TLS configuration. No order of reads fixes this on its own, because whichever option is read first, reading a value nested inside it runs the caller's code and that code can rewrite the ones not yet read: reading the TLS first lets a getter on `tls.anchors` rewrite the URL and the pinned key, and shaping the request first lets a header getter replace the key a tree head is verified under, in place if it is a buffer. So two shapes are refused rather than ordered around. `opts.url` has to be a string, because asking an object for its text form runs the caller's code, and so does `opts.sigUrl`, the detached-signature URL `pki.ct.fetchLogList` takes, which is a destination on the same footing. `opts.tls` may not carry an accessor, because reading its members has to be inert. All three draw `ct/bad-input`. With those refused, every option a verb declares is taken at its entry, the pinned key is copied there, and the request is sent and the signature checked from what was taken. A caller passing plain values and a string URL sees no change, and options supplied through a defaults object on the prototype chain still work, including the transport.
- The trust anchors, the client certificate and the client key a `pki.ct` verb is given are copied where they are taken, not referenced. Copying the anchor list copied the references in it, and an anchor is a mutable buffer, so a getter elsewhere in the options could write other bytes over an anchor after the check that refuses an unpinned log had approved it: every reference still pointed where it had, the URL was unchanged, and the connection was made under trust material no check had seen. A PEM string cannot be altered after the fact and is taken as it is; a value that is neither bytes nor a string reaches the transport unchanged, so a shape it does not accept still draws the refusal it draws today. Two side effects of taking the copy: a byte source that is not a Buffer, such as the `ArrayBuffer` that `crypto.subtle.exportKey` returns, is normalized to one and now reaches the connection as a usable anchor, and an anchor larger than the DER ceiling is refused as `ct/too-large` where it was previously passed on.
- A TLS setting nobody set can no longer be answered by `Object.prototype`. A verb that fetches builds a record of the TLS settings for the request, and where the caller named none that record was an empty object literal, which inherits. A value installed on `Object.prototype` was therefore read from it, and one of the settings read that way is `useSystemStore`, the opt-in that turns off the refusal of a server with no pinned trust anchor: with it installed, a call that had named neither a transport nor an anchor opened a connection trusting the system store where it should have been refused. The record now has no prototype, so nothing can be inherited into it. Fixed in `pki.ct`, `pki.acme`, `pki.est`, `pki.scep`, `pki.cmp`, the certificate-path builder's authority-information-access fetch, and `pki.transport` itself, which is the refusal every one of those delegates to.
- A byte input copied at a verb's entry is copied into storage of its own. The copy was taken with `Buffer.from`, which for a small result allocates out of the shared 64 KiB pool, so a caller whose own value came from that pool holds a view of the whole store and the copy lands inside it at another offset. A caller's code running after the copy and before the copy is used could then find it and overwrite it: supplying the pinned log key as a pooled buffer and rewriting the copy from inside the transport had `pki.ct.getSth` accept a tree head signed by another key. This is the copy every verb in the package takes of a byte argument, so the fix reaches each of them, not only the certificate-transparency ones. Copies of a caller's keys, hashes and signed bytes are now out of the caller's reach once taken, which is what taking them is for.
- A request URL is parsed with the `URL` constructor and the string conversion the package captured at load, in every module that builds a destination from one: `pki.ct`, `pki.acme`, `pki.est`, `pki.scep`, `pki.cmp`, `pki.ocsp`, the revocation fetcher, the path validator, the HTTP transport and its Digest authentication. Reading either off the global object at the moment of the parse read whatever had been installed there by then. A caller's code running mid-request, through a getter on an option the verb had yet to read, could replace the constructor with one answering a genuine URL that named another host, or replace the conversion so it answered with another host's URL outright, and the endpoint was built from that answer while the URL the caller passed was still the one the call reported. Capturing the constructor is not enough on its own, because the value it returns reads its parts through `URL.prototype`, which is also replaceable: redefining `href` there answered every read of the destination. So `pki.ct` and the HTTP transport read each part of a parsed destination, the scheme, host, port, path and query, through accessors captured at load, and the transport reads the scheme once and decides from that one string whether the request is allowed, whether a trust anchor is required and which port it defaults to.

## v0.8.49 — 2026-10-05

Contact the certificate-transparency log the caller named, and refuse an options object that answers a second read differently.

### Fixed

- `pki.ct.getSth`, `pki.ct.getProofByHash`, `pki.ct.getSthConsistency`, `pki.ct.getEntries`, `pki.ct.getRoots` and `pki.ct.addChain` read `opts.url` once to check it was present and again to build the endpoint. An options object whose `url` is an accessor therefore answered the two separately: the check saw the log the caller named and the request went wherever the second read pointed, which for a measured case was a different host entirely. `opts.tls` was read the same way, once for the trust-anchor check that refuses an unpinned log and once more for the connection, so the anchors that satisfied the check need not have been the anchors the socket used. Each verb now takes its options through one door at its own entry, and that door refuses an options object carrying an accessor, which is the rule `pki.ct.signSct` in the same module already applied. It also refuses an object that changes which options it carries while they are being read, since no set of checks can answer for one. A caller passing a plain object sees no change. The refusal is `ct/bad-input` and it happens before any request is sent, so a rejected options object cannot reach a log. Refusing an accessor on the options object is not the whole rule, because the door never inspects the members of a value, and a getter on a header name runs while the request headers are being copied. Three things it could reach from there are now taken before it runs. The URL is taken and converted to text at the same point, since holding the value alone is not enough when the value is an object whose text form names the host: a getter that rewrote an object-valued URL's `href` moved the request just as a second read would have. The TLS settings are flattened at that point too, and the anchor list is copied rather than shared, because a getter that emptied the pinned list after the trust-anchor check had approved it left the connection pinned to nothing. A caller that supplies options through a defaults object on the prototype chain is unaffected, including a function-valued `transport`. `pki.ct.fetchLogList` checked which option names it had been given and never entered that door at all, so its `url` was read once to parse and once to fetch, its `sigUrl` twice, and its TLS configuration once for the anchor check and once for the connection; it enters the same door now, over its own option set. Note that this is stricter than before: an options object with an accessor property was previously accepted, and the toolkit answered from whichever read it happened to make. Pass plain values.

## v0.8.48 — 2026-10-05

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.

### Fixed

- `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.
- `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.

## v0.8.47 — 2026-10-05

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.

### Fixed

- `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`.
- `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.
- `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.
- 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.
- `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.
- `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.
- `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.
- `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`.
- `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.
- `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.
- `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.
- `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.
-  Metadata that omitted the field parsed before this release and is refused now; a TUF repository publishes it on every document.
- `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.
- `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.
- `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.
- `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`.
- `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.
- 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.
- `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.
- `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.
- `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.
- `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.
- `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.
- `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.
- `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.
- 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.
- `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`.
- 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.
- `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.
- `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.
- `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.
- `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.
- `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.
- `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.
- `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.
- `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.
- `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.
- 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`.

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.

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.
- `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.
- `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.
- `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.
- `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.
- `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`.
- `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.
- 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.
- `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.
- `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.
- `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.
- `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.
- `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.
- `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.
- 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.
- `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.
- `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.
- `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.
- 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.
- `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.
- `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.
- 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".
- `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.
- `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`.
- `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.
- `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.
- 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.
- 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.
- 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.
- 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`.
- 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.
- `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.
- `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.
- `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.
- 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.
- 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.
- 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.
- 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.
- `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.
- `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.
- `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.
- `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.
- `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.
- `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.
- `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.
- 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.
- `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.
- `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.
- 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.
- `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.
- `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.
- `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.
- `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.
- 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.
- `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.
- `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.
- 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.
- 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.
- `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.
- `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.
- 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.
- `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.
- `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.
- `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.
- `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.
- `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`.
- `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.
- `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.
- `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.
- 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.
- `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.
- `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.

## v0.8.46 — 2026-10-04

Run HPKE over a SHAKE key schedule, and encrypt to a P-384 recipient.

### Added

- 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.
- `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.
- `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.
- 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.

### Fixed

- `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.
- 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`.
- `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`.
- `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.
- `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.
- `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.
- 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.
- 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.
- `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.
- `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.
- `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.
- `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.
- `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.
- `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.
- `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.
- `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.
- 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.
- 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.
- 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`.

## v0.8.45 — 2026-10-03

Encrypt to a post-quantum and a classical key at once, so one broken primitive does not open the message.

### Added

- 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.
- `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`.
- 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.
- 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.
- `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.

### Fixed

- `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`.
- `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`.
- `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.
- `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`.
- `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`.
- `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`.
- 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.
- `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.
- `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.
- `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.
- `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.
- `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.
- `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.
- `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.
- `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.
- `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.
- `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.
- `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.
- `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.
- `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.
- `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.

## v0.8.44 — 2026-09-29

Certify a key that cannot sign, by signing the request with one that can.

### Added

- `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.
- `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.
- `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.
- 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.
- `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.
- `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.
- `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.

### Changed

- 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.

## v0.8.43 — 2026-09-29

Issue one certificate carrying two signatures, so a PKI can change algorithms without reissuing to everyone at once.

### Added

- `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.
- `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.
- `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.
- `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.
- `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.
- `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.
- `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.

## v0.8.42 — 2026-09-29

Let one subject hold a classical certificate and a post-quantum one, and have each name the other.

### Added

- `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.
- `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.
- `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.
- `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.
- `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.
- `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.
- 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.

### Changed

- `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.

## v0.8.41 — 2026-09-29

Verify a TUF trust root, and walk its key rotations.

### Added

- `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.
- `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.
- 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.
- `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.
- `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.
- `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.

## v0.8.40 — 2026-09-29

Talk to a Certificate Transparency log: ask for its tree head, its proofs, and its entries.

### Added

- `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.
- `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.
- `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`.
- `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.
- `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.
- `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.
- 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.

## v0.8.39 — 2026-09-29

Verify a bundle logged in a Rekor v2 tiled transparency log.

### Added

- `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.
- 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.
- `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.
- 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.
- 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.

## v0.8.38 — 2026-09-29

A Sigstore bundle's tree root must be signed by the log the entry names.

### Fixed

- 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`.
- 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.
- 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.
- 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.
- 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.
- 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.
- `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.

## v0.8.37 — 2026-09-29

Read a transparency log's key whatever algorithm it signs with, and reject a note whose known signer does not verify.

### Added

- `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.
- 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.
- `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.
- `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.

### Fixed

- 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.
- 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.

## v0.8.36 — 2026-09-29

Assemble a transparency-log inclusion proof from the tiles a log serves.

### Added

- `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.
- `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.
- 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.

### Fixed

- 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.

## v0.8.35 — 2026-09-29

A builder encodes the number you wrote, or refuses it.

### Changed

- `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.

### Fixed

- 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.
- 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.
- 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.

## v0.8.34 — 2026-09-29

Build the tree, and produce the proofs a verifier folds back to its root.

### Added

- `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.
- `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`.
- 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.
- 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.
- 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.
- 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.

## v0.8.33 — 2026-09-29

Read a transparency log's checkpoint, and the tiles it serves beside it.

### Added

- `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.
- 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.
- `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.
- `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.
- `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.
- `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.
- 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.

## v0.8.32 — 2026-09-29

The command line makes certificates, and the tarball carries programs you can run.

### Added

- `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.
- `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.
- `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.
- `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.
- 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.
- `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.

### Changed

- `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.
- `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.
- `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`.
- 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.

## v0.8.31 — 2026-09-29

A structural dump of any DER file, and it still reads the file when the strict decoder refuses it.

### Added

- `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.
- 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.
- 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.
- 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.
- 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.

### Fixed

- 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.
- 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.
- 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.

## v0.8.30 — 2026-09-29

The 0.8.30 tarball is the one to install for everything 0.8.29 added.

### Added

- Ten reports, one per format that had none: `pki.inspect.pkcs8`, `pki.inspect.pkcs12`, `pki.inspect.crmf`, `pki.inspect.cmp`, `pki.inspect.csrattrs`, `pki.inspect.trustanchor`, `pki.inspect.ocspRequest`, `pki.inspect.ocspResponse`, `pki.inspect.tsp` and `pki.inspect.attrcert`. Each takes a PEM string, a DER Buffer, or that format's own parse result, refuses a wrong-format input with its own `inspect/bad-*` code, and is best-effort in the same way the certificate report is: a valid but unusual structure renders rather than throwing.
- Every one is reachable by name as well as by detection, and renders the same text either way. A report only `pki.inspect.any` could reach would be a feature nobody could ask for, and an operator who knows what a file is should not have to rely on detection to read it.
- No report prints private key material, and for a PKCS#8 key that is structural rather than a check on the private key: the report gives the version, the key algorithm and its parameters, the presence and length of the private key and of a public half, and the type of each attribute, and renders no byte string from inside the file. RFC 5958 has the parser confirm that a version 2 `OneAsymmetricKey` carries a public half and well formed attributes, never that either is unrelated to the private key, so private bytes can sit in the public `BIT STRING` or in any attribute value; a report that wrote either out could be made to write the key out. The algorithm's parameters are named where the registry knows them and withheld otherwise, since the parameters of an unknown algorithm are another place the key could sit, and `pki.key.export` is the verb for the bytes themselves. A PKCS#12 report follows the same rule: the version, the integrity mode, the MAC algorithm with its iteration count and the LENGTH of its salt, and one line per safe bag naming its type, nested bags included. A salt is caller-supplied, so its value is reported as a length.
- A PKCS#12 report says what reading the contents needs. These verbs take no password, so an encrypted safe is named and not decrypted and the report points at `pki.pkcs12.open`. A report that silently showed nothing would read as an empty store.
- `pki.inspect.tsp` renders an RFC 3161 timestamp response: the PKI status with its status strings and failure info, and the TSTInfo inside the token with its policy, message imprint, serial, time, accuracy, ordering, nonce and TSA name. A bare timestamp token is a CMS ContentInfo rather than a response, and `pki.inspect.cms` has always rendered one of those.

### Changed

- `pki.inspect.any` on an X.509-1997 v1 attribute certificate now throws `attrcert/legacy-v1-not-supported`, the reason that format's own parser gives, where it previously threw `inspect/unsupported-format`. The v1 form is detected so it can be named: RFC 5755 section 1 obsoletes it and this build does not parse it, and a reader of one needs to know which form they hold rather than to go looking for a renderer that was never missing.
- The `inspect/unsupported-format` message enumerates the formats that do render, read from the dispatch table rather than from a list written beside it, so it cannot name a set the code does not have.

### Fixed

- `pki.inspect.cms` on an EnvelopedData, an AuthenticatedData, a CompressedData or a DigestedData now has conformance vectors; before, every CMS report the suite exercised was a SignedData, so the arm reading the other four content types was never driven and a field named wrongly there would have rendered quietly.

## v0.8.29 — 2026-09-29

Every format the toolkit detects now renders a report, and a report never prints a private key.

### Added

- Ten reports, one per format that had none: `pki.inspect.pkcs8`, `pki.inspect.pkcs12`, `pki.inspect.crmf`, `pki.inspect.cmp`, `pki.inspect.csrattrs`, `pki.inspect.trustanchor`, `pki.inspect.ocspRequest`, `pki.inspect.ocspResponse`, `pki.inspect.tsp` and `pki.inspect.attrcert`. Each takes a PEM string, a DER Buffer, or that format's own parse result, refuses a wrong-format input with its own `inspect/bad-*` code, and is best-effort in the same way the certificate report is: a valid but unusual structure renders rather than throwing.
- Every one is reachable by name as well as by detection, and renders the same text either way. A report only `pki.inspect.any` could reach would be a feature nobody could ask for, and an operator who knows what a file is should not have to rely on detection to read it.
- No report prints private key material, and for a PKCS#8 key that is structural rather than a check on the private key: the report gives the version, the key algorithm and its parameters, the presence and length of the private key and of a public half, and the type of each attribute, and renders no byte string from inside the file. RFC 5958 has the parser confirm that a version 2 `OneAsymmetricKey` carries a public half and well formed attributes, never that either is unrelated to the private key, so private bytes can sit in the public `BIT STRING` or in any attribute value; a report that wrote either out could be made to write the key out. The algorithm's parameters are named where the registry knows them and withheld otherwise, since the parameters of an unknown algorithm are another place the key could sit, and `pki.key.export` is the verb for the bytes themselves. A PKCS#12 report follows the same rule: the version, the integrity mode, the MAC algorithm with its iteration count and the LENGTH of its salt, and one line per safe bag naming its type, nested bags included. A salt is caller-supplied, so its value is reported as a length.
- A PKCS#12 report says what reading the contents needs. These verbs take no password, so an encrypted safe is named and not decrypted and the report points at `pki.pkcs12.open`. A report that silently showed nothing would read as an empty store.
- `pki.inspect.tsp` renders an RFC 3161 timestamp response: the PKI status with its status strings and failure info, and the TSTInfo inside the token with its policy, message imprint, serial, time, accuracy, ordering, nonce and TSA name. A bare timestamp token is a CMS ContentInfo rather than a response, and `pki.inspect.cms` has always rendered one of those.

### Changed

- `pki.inspect.any` on an X.509-1997 v1 attribute certificate now throws `attrcert/legacy-v1-not-supported`, the reason that format's own parser gives, where it previously threw `inspect/unsupported-format`. The v1 form is detected so it can be named: RFC 5755 section 1 obsoletes it and this build does not parse it, and a reader of one needs to know which form they hold rather than to go looking for a renderer that was never missing.
- The `inspect/unsupported-format` message enumerates the formats that do render, read from the dispatch table rather than from a list written beside it, so it cannot name a set the code does not have.

### Fixed

- `pki.inspect.cms` on an EnvelopedData, an AuthenticatedData, a CompressedData or a DigestedData now has conformance vectors; before, every CMS report the suite exercised was a SignedData, so the arm reading the other four content types was never driven and a field named wrongly there would have rendered quietly.

## v0.8.28 — 2026-09-28

The three signing verbs lint what they are about to emit, and refuse it before the key is used.

### Added

- `pki.x509.sign` refuses a spec whose certificate violates an error-severity rule of the RFC 5280 profile, with `x509/profile-violation` naming the rule and its clause. `pki.csr.sign` does the same against RFC 2986 with `csr/profile-violation`, and `pki.crl.sign` against the RFC 5280 section 5 profile with `crl/profile-violation`. The check runs on the assembled artifact rather than on the spec, so it reports what would have reached the wire.
- The refusal precedes the signature. A spec that violates a rule never reaches the signing key, which matters where that key is a hardware token, a remote signer, or anything that counts or logs a use.
- `opts.profile` on all three verbs names a further profile from `pki.lint.profiles()` whose error-severity rules run beside the always-on set, so a certificate can be held to `cabf-tls` or `cnsa-2.0` at the moment it is built rather than after. A name that verb does not run is refused with its own `bad-input` code and the sentence `pki.lint.rules` gives for the same name.
- `opts.profile: "none"` runs no rules and emits what the spec describes. A toolkit that cannot emit a non-conforming artifact cannot produce a test corpus, reproduce a real misissuance, or build the fixtures a linter is exercised against, so the escape is a name the caller writes rather than a default: the gate is closed unless somebody asked for it, and every refusal names it.
- Two kinds of finding are passed over. A rule reading the signature value cannot apply, because at the moment of the check the signature does not exist. `lint/rfc5280/unknown-critical-extension` does not apply either: RFC 5280 section 4.2 places that requirement on a certificate-using system, and the same section states that conforming CAs may support extensions the specification does not identify, so a private critical extension signs and the relying party still decides what to do with it.

### Changed

- A spec that describes a non-conforming artifact is now refused rather than signed. What changes in practice: a CRL spec with no `crlNumber` or no `nextUpdate` is refused, since RFC 5280 section 5.2.3 and section 5.1.2.5 require both of a conforming CRL issuer and neither can be derived, a CRL number being issuer state; a certificate whose basicConstraints sets cA without a keyUsage asserting keyCertSign is refused, per section 4.2.1.3; a request with an empty subject that does not ask for a subjectAltName is refused, since it names nothing to certify; and a certificate or CRL built through the pre-encoded extension array is refused when that array carries no key identifier the artifact needs, because that form emits only what is written into it. The object form still derives what its RFC places on the issuer: a subjectKeyIdentifier on every certificate, an authorityKeyIdentifier on every certificate that is not self-signed and on every CRL. To upgrade: add the missing field, write the identifier into the array, or pass `{ profile: "none" }` where the artifact is meant to be non-conforming. The refusal message names the rule and the clause in every case.
- `pki.lint` is assembled from its public verbs rather than passed through as a module, so the namespace holds `certificate`, `csr`, `crl`, `ocsp`, `cms`, `tsp`, `attrcert`, `rules` and `profiles` and nothing else. No name was removed.

### Fixed

- The documented examples for `pki.crl.verify`, `pki.crl.isRevoked`, `pki.inspect.crl`, `pki.schema.crl.parse`, `pki.schema.crl.pemDecode`, `pki.schema.crl.pemEncode`, `pki.schema.crl.decodeExtension` and `pki.schema.crl.decodeExtensions` built artifacts this toolkit's linter grades an error, and an operator copying one got a CRL with no nextUpdate or a CA certificate asserting no keyCertSign. Each now carries what its RFC requires.

## v0.8.27 — 2026-09-28

Two algorithm-policy profiles, and a two-digit year read against the clock rather than a constant.

### Added

- `pki.lint.certificate(der, { profile: "nist-pqc-transition" })` reports NIST IR 8547's two transitions. A classical subject public key or signature algorithm in a certificate issued from 2036 is an error, which Table 2 and Table 4 make disallowed at every parameter set they list. A classical subject key at or below the 112-bit security strength issued from 2031 is a warning, which is the strength those tables deprecate and which SP 800-57 Part 1 Rev 5 Table 2 maps onto an RSA modulus of 2048 bits, a curve whose field is 224 to 255 bits, and a finite-field group stated as a pair, L of 2048 with N of 224. Either finite-field parameter caps the strength, so a group with a 3072-bit prime and a 224-bit subgroup order is reported, and a group whose encoding states no subgroup order is read on its prime. "After 2030" and "after 2035" name whole years, so each transition begins on 1 January of the year that follows and is compared against the certificate's own notBefore. IR 8547 is an initial public draft, so these are a draft's dates and the citation says so.
- Ed25519 is deliberately outside the 2031 row. Its field is 255 bits, which SP 800-57's ECC column places at the 112-bit strength, so reading the two documents together would deprecate it after 2030; IR 8547's own EdDSA row carries a 128-bit entry alone, with no 112-bit entry and no 2030 date, and the profile follows the document it cites. A composite signature or subject key is passed over in both rows, because the tables list pure families and the same guidance endorses dual-algorithm operation through the transition.
- `pki.lint.certificate(der, { profile: "cnsa-2.0" })` reports the CNSA 2.0 certificate profile of `draft-jenkins-cnsa2-pkix-profile-05`, which states what a certificate must carry where the NSA algorithm notice lists algorithms. Seventeen rows: the certificate must be X.509 version 3, the subject public key must be ML-DSA-87 or ML-KEM-1024 and the signature must be id-ml-dsa-87, which also answers the clause forbidding HashML-DSA, since a HashML-DSA identifier is not that one; a keyUsage extension must be critical in all three scopes; a self-signed CA must carry a subjectKeyIdentifier, a keyUsage and a basicConstraints, a non-self-signed CA an authorityKeyIdentifier, a keyUsage and a basicConstraints, where a certificate whose keyUsage asserts keyCertSign is read as a CA whether or not it carries basicConstraints, since RFC 5280 section 4.2.1.3 requires the cA boolean of any certificate asserting that bit and a certificate omitting the extension is the one this requirement is about; a CA reissued in its own name under a new key is read as the second of the two CA kinds, since its authorityKeyIdentifier names a key other than its own subjectKeyIdentifier and the section governing it permits the pathLenConstraint the self-signed section forbids; and an end entity an authorityKeyIdentifier and a keyUsage, with a missing subjectKeyIdentifier on an end entity reported as a warning where that clause says SHOULD; a CA's basicConstraints must be critical and must set the cA boolean, and a self-signed CA's must carry no pathLenConstraint, which the non-self-signed section calls optional; a CA's keyUsage must set keyCertSign and cRLSign, may set digitalSignature and nonRepudiation, and must set nothing else; an end entity's extKeyUsage must be present and must not name anyExtendedKeyUsage; and a certificatePolicies extension must be non-critical and should carry no policy qualifiers. Each departure from a closed key-usage set is its own finding, so a certificate setting two forbidden bits reports both.
- The end entity key-usage set follows the kind the subject public key declares, which is what makes that clause readable from the bytes: an ML-DSA-87 certificate must set digitalSignature and may set nonRepudiation, an ML-KEM-1024 certificate must set keyEncipherment alone, and a key outside the suite has no set to be held to and is reported as the key instead.
- `pki.lint.crl` and `pki.lint.ocsp` take the same `cnsa-2.0` name for the one rule the document states about them: the signature on a CRL and on an OCSP response follows the same rule as a certificate's. Both halves of it reach them, the algorithm the artifact was signed with and the length FIPS 204 fixes for a signature of that algorithm, the second of which a certificate is told by the RFC 9881 row its own profile runs.
- The profile also runs the RFC 9881 and RFC 9935 subject-key rows, which section 4 of the draft names for "the algorithm identifier, and the syntax and semantics for the Subject Public Key Information field" of the two keys the suite fixes. So a subject key whose algorithm says ML-KEM-1024 or ML-DSA-87 while its bytes are not a key of the size FIPS 203 or FIPS 204 gives that parameter set is reported, where reading the algorithm identifier alone called it conforming.
- `pki.lint.certificate` gains `lint/rfc9881/mldsa-key-length` and `lint/rfc9881/mldsa-signature-length` for that second half, under every profile that carries the RFC 9881 rows. FIPS 204 fixes both an ML-DSA public key's length and a signature's per parameter set, and RFC 9881 section 4 carries each directly in its BIT STRING, so a value of any other length is not what the identifier names. The ML-KEM row beside them has read its own sizes since that profile shipped.
- Every clause of that profile which this toolkit's parsers settle before a row could read it has a conformance vector asserting the parser's verdict instead: a present parameters field on either suite algorithm arrives as `x509/bad-algorithm-parameters`, and an inner TBSCertificate or TBSCertList signature identifier that differs from the outer signatureAlgorithm as `x509/bad-signature-algorithm` or `crl/bad-signature-algorithm`, so the row reading one of those two fields answers for both. Four further clauses are recorded without a row, because the bytes do not settle them: which extended key purposes are consistent with which key-usage bits is enumerated nowhere in the document, a certificatePolicies extension that is present asserts a policy, the responder authorization an OCSP response also owes is a trust decision over a chain that `pki.ocsp.verify` makes, and a CA reissued in its own name that carries no authorityKeyIdentifier at all cannot be told from a root, since a root omits that extension legitimately and RFC 5280 section 4.2.1.1 permits its absence only there, which the default rule set reports.

### Changed

- `pki.lint.profiles()` lists `nist-pqc-transition` and `cnsa-2.0` beside the others. `cnsa-2.0` appears once although three verbs run it, because that call enumerates profile names rather than the places they are registered.

### Fixed

- `pki.lint.certificate`, `pki.lint.crl`, `pki.lint.ocsp`, `pki.lint.csr`, `pki.lint.cms`, `pki.lint.tsp` and `pki.lint.attrcert` all answer the same way about where a profile name lives. A verb handed a profile it does not run refuses it with `lint/unknown-profile` naming every verb that does run it, read from the registry rather than from a list written into each verb. Before, each verb carried its own chain of checks in its own order, so a name carried by more than one artifact drew a different answer depending on which verb was asked, and each chain omitted the artifacts nobody had added a branch for: a CMS profile name reached `pki.lint.crl` as simply unknown. `pki.lint.rules(profile, artifact)` gives that same sentence, so asking where a profile lives and asking a verb to run it are one answer.
- An HTTP `Retry-After` header carrying an rfc850-date, the obsolete format with a two-digit year, is read against the time the value arrives. RFC 7231 section 7.1.1.1 requires a recipient to read a year more than 50 years in the future as the most recent past year with the same two digits, which depends on when the value is received. A caller that supplied a reference time already got that; one that did not fell back to a fixed cutoff at 70, which is the same rule evaluated once for a reference year around 2020 and which misreads a band of years that widens by one every year. At a 2026 receipt the years 70 through 76 were read a century early. `pki.est`'s retry handling is the reachable caller, since its own `now` option is optional. The four-digit formats are unaffected.
- `pki.rfc3339`'s description of why it validates the calendar names the case that needs it. `2026-02-30T00:00:00Z` is the one the runtime accepts and reads as 2 March, two days from the date the value names; month 13, hour 25 and a `+25:00` offset are refused by the runtime as well, so the check agrees with it there. A `:60` leap second, which the description gave as its example, is one of those, and refusing it is a decision rather than a malformation: RFC 3339 section 5.7 permits the value and JavaScript cannot represent it. Each of those statements now has a vector asserting it against the runtime, so the reason the check exists cannot quietly stop being true. No behavior changed.

## v0.8.26 — 2026-09-28

Three lint verbs, and each is short because the parser that feeds it is strict.

### Added

- `pki.lint.cms(pem | der | parsed, opts?)` lints a CMS ContentInfo against the RFC 5652 profile, under `profile: "rfc5652"`. A content type this parser reads that is not signed-data carries no signerInfos, so every row is not applicable to it rather than reported against it. A content type the parser does not read, which includes id-data, arrives as a single fatal `lint/unparseable` carrying the parser's own code.
- Its rows are what the parser leaves: a signing-time naming a year from 1950 to 2049 encoded as GeneralizedTime where section 11.3 requires UTCTime, a signer whose digest algorithm the digestAlgorithms set does not list, which section 5.1 has list the digests every signer employs and so covers a countersigner's digest as well, a version 1 attribute certificate in the certificates field, and two notices, a SignedData carrying no signers and a detached signature whose content a verifier must be handed separately. The other direction of section 11.3 cannot be broken at all: a UTCTime carries two year digits that the clause reads as 19YY or 20YY, so every one of them names a year inside the window.
- The signing-time rule reads the message's own signers and the countersignatures section 11.4 places in an unsigned attribute, including any nested inside those. A rule about a signed attribute governs one wherever it appears, so a signer cannot put the shape the clause forbids inside a countersignature and escape it. An encapsulated content that is itself a CMS message is linted by running the verb on that message, the way the certificates a message carries are linted with `pki.lint.certificate`. The finding names the message's own signer and says whether the attribute sat on a countersignature under it, rather than a position in that walk, which would send a reader to a different signer. Two digest identifiers naming the same algorithm are compared as RFC 5754 section 2 requires: absent parameters and an explicit NULL are the same digest, so a set that writes one beside a signer that writes the other is listing that signer's algorithm.
- `pki.lint.tsp(pem | der, opts?)` lints an RFC 3161 timestamp token under `profile: "rfc3161"`. Its rows are a tsa hint naming a subject the token's own certificate does not carry, a serial number wider than the 160 bits a conforming consumer must accommodate, and a token stating no accuracy. The tsa row resolves which certificate verifies the token from the hash the ESS SigningCertificate or SigningCertificateV2 signed attribute carries, which names one certificate and which section 2.4.2 requires every token to hold. Either attribute answers: the original one carries no hash algorithm and RFC 2634 section 5.4 implies SHA-1 for it, so a token written that way resolves its signer as a v2 one does. A token carrying both, which RFC 5035 section 7 permits so a reader that knows only the original keeps its protection against certificate substitution, has each read on its own as that section requires, so whichever of them names a certificate the token embeds resolves the signer; two naming two different embedded certificates leave which one signed the token unsettled, and the row is passed over. RFC 5035 section 4 fixes no set of algorithms for the v2 attribute's hashAlgorithm, so the row reads a certHash under any digest this build computes, at the output lengths RFC 8702 section 2 fixes for id-shake128 and id-shake256. That hash says which of the certificates a token already carries the token names, which is a different question from whether a binding is strong enough to trust, and `pki.tsp.verify` keeps its own narrower set for the latter. The SignerInfo identifier is not that fact: an issuer with a serial number, and a subject key identifier, are both values an issuer wrote, two certificates can carry either, and the certificate set a token embeds sits outside the token's signature, so a hint read against the first identifier match is a hint read against whichever certificate the sender put first. A token whose signer certificate is not embedded is passed over, to be supplied out of band. Carrying one certificate is not the same fact as carrying the signer's, since a token may embed an intermediate, and comparing a hint against that would report a conforming token. The clause says subject names, plural, so the row reads the subject and every subjectAltName entry alike. A distinguished name is compared under the RFC 5280 section 7.1 rules wherever it appears, since two encodings of one name are one name, a DNS name and the text of a mailbox and a URI are compared without regard to ASCII case, since DER canonicalizes the encoding and not the letters, and every other form is compared as its exact encoding, since a structured name such as an otherName is not settled by comparing decoded values.
- `pki.lint.attrcert(pem | der, opts?)` lints an X.509 attribute certificate under `profile: "rfc5755"`. Its rows are the criticality each clause fixes for the extensions the profile defines, section 4.3's six and the ProxyInfo of section 7.2, a critical extension the profile does not define, a Holder naming more than one of its three options, a Holder entityName carrying more than one name where section 4.2.2 asks for one, a Holder baseCertificateID issuerUID, and an issuerUniqueID. The last two are permitted only where another certificate carries the matching field, so each is a warning naming that condition rather than an error. Section 4.2.2's requirements that these bytes DO settle are refused at parse instead, so they reach an operator as the parser's own code: a Holder baseCertificateID whose issuer is anything other than one non-empty directoryName is refused, which section 4.2.2 requires in the same words section 4.2.3 uses for the issuer. The remaining clauses of that section describe the public-key certificate the Holder names rather than the attribute certificate, so no row can answer them. What counts as outside the profile is membership in that set rather than whether this toolkit can decode the value: the attribute certificate decoder also reads AAControls, which section 7.4 places in a CA or AC issuer public-key certificate rather than in an attribute certificate.
- `pki.lint.rules()` and `pki.lint.rules(profile, artifact)` reach the three new sets, with `"cms"`, `"tsp"` and `"attrcert"` joining the artifacts a profile name can be read under. `pki.lint.profiles()` lists the three beside the others, and now enumerates the same registry the verbs dispatch on, so a profile a verb can run is one that call answers with.
- A fuzz target for each of the three, holding the contract every lint verb holds: hostile bytes return a report and never raise. Each drives the raw bytes at the ingestion boundary, splices them over a well-framed structure so the rules run on attacker-controlled values, and then puts them in each attribute or extension a rule reads a value out of.

### Changed

- Each of the three verbs refuses a profile name belonging to another one by name, the way the existing verbs do, rather than running a certificate's rows against a message.
- `pki.cms.countersign` adds the countersigner's digest algorithm to the SignedData `digestAlgorithms` set. RFC 5652 section 5.1 has that set list the digests every signer employs so a consumer can process the message in one pass, and section 11.4 makes a countersignature a SignerInfo. Whether the digest is already listed is decided by RFC 5754 section 2, under which absent parameters and an explicit NULL are one algorithm, so a message whose producer wrote the NULL spelling does not gain a second entry for the digest it already lists. The set is covered by no signature in the message, so every signature a countersigned message already carried still verifies.
- `pki.attrcert.sign` writes an `aaControls` extension non-critical, where it wrote it critical before, and accepts a pre-encoded one at either criticality. RFC 5755 section 7.4 calls AAControls a PKC extension "intended to be used in CA and AC issuer PKCs" and fixes no criticality for an attribute certificate, so section 4.2.9 governs it there: an extension outside section 4.3 conforms when it is non-critical and does not when it is critical. An attribute certificate carrying a critical one is still refused at `pki.attrcert.verify`, and `pki.lint.attrcert` reports it.
- `pki.attrcert.verify` compares a `uniformResourceIdentifier` target under RFC 5280 section 7.4, where it compared the two values as written before. A caller naming a target whose scheme case, default port, dot segment or percent-encoded unreserved character differs from the one the attribute certificate carries now matches it, which the clause's five preparation steps make the same URI. A target differing anywhere the clause leaves case-sensitive, a path among them, is still refused.
- `pki.schema.attrcert.decodeExtension` cites RFC 5755 section 7.2 for the criticality it fixes for `acProxying`, where it cited section 7.3 before. Section 7.2 is Proxying; section 7.3 is the use of ObjectDigestInfo. The same call no longer states a fixed criticality for `aaControls`, because no clause fixes one.

### Fixed

- `pki.lint.tsp` compares a tsa hint against each form of name under the RFC 5280 section 7 rule for that form. A mailbox matches when its local part matches exactly and its host part matches without regard to ASCII case (section 7.5), and two URIs are prepared as section 7.4 requires, which lowercases the scheme and host, normalizes percent-encoding and path segments, and drops a default port for the ldap, http, https and ftp schemes that clause names, before the exact match it then calls for. Before, the whole value was folded to lowercase for both forms, so a hint differing from every name the certificate carries in a mailbox local part or a URI path read as one of them and went unreported. A dNSName is unchanged: section 7.2 makes the whole name case-insensitive. A hint in a form neither rule can read is passed over rather than reported, which is the verdict the row already gives where the signer's certificate is absent.
- `pki.cms.decrypt` reads RFC 4055 section 4.1's OAEP defaults as the values that section assigns rather than inferring one from another. An absent `maskGenFunc` names mgf1SHA1, so a parameter set whose `hashFunc` is SHA-256 and whose `maskGenFunc` is omitted is the same set as one that writes MGF1-SHA-1 out, and both are now refused with `cms/unsupported-algorithm`. Before, the rule that the two hashes must agree was applied to the spelled-out form and skipped when the field was omitted, so a sender could step around it by leaving a field out. A set whose fields do agree, including one that omits `maskGenFunc` beside a SHA-1 `hashFunc`, still decrypts.
- `pki.inspect` renders an IPv6 address in the form RFC 5952 fixes, lowercase with the longest run of zero groups compressed, wherever it appears. A subjectAltName entry and an RFC 3779 address block carrying the same octets now read the same; before, the first printed `2001:DB8:0:0:0:0:0:1` where the second printed `2001:db8::1`, so one report could show one address two ways. A name constraint's address and mask is still written as both halves joined by a slash, since that pair is not an address.
- `pki.schema.x509.parse`, `pki.schema.cms.parse` and every other parser that reads an AlgorithmIdentifier refuse a present parameters field on nine identifiers whose specification says to omit it: `ecdsaWithSHA1`, `ecdsaWithSHA256`, `ecdsaWithSHA384`, `ecdsaWithSHA512` and `dsaWithSha1` (RFC 3279 sections 2.2.2 and 2.2.3, RFC 5758 section 3.2), and `shake128`, `shake256`, `id-RSASSA-PSS-SHAKE128` and `id-RSASSA-PSS-SHAKE256` (RFC 8702 sections 2, 3.1 and 3.2). The verdict is `<domain>/bad-algorithm-parameters`. The ECDSA and SHAKE shapes were already refused, a layer later and under a less specific code; they are now refused where the encoding is read. An identifier whose specification requires the field PRESENT and NULL, which is every `*WithRSAEncryption` and `rsaEncryption` itself under RFC 4055, is unaffected.

## v0.8.25 — 2026-09-24

A dated permission that lapses is a requirement, and a certificate's own extended key usages name the purpose it was issued for.

### Added

- `pki.lint.certificate(der, { profile: "apple-root" })` runs the Apple Root Program Policy v2.0 profile. Naming it is the only way to reach these rows, and passing no profile leaves every one of them inert.
- Appendix A's seven Trust Purposes, each a required extended key usage set: Server Authentication, Legacy TLS, Secure Email, Legacy S/MIME, Client Authentication, Time Stamping and VMC. A certificate is dedicated to a purpose when its extKeyUsage is not empty and names nothing outside that purpose's set, which is how the appendix states it: a subordinate CA "MUST contain only the EKU(s) specified for that Trust Purpose".
- The section 1.7 rules over a subordinate CA certificate, each gated on the signing date the clause defines for itself, which it says a renewal, a re-key and a cross-sign each produce afresh. From 1 August 2026 the extKeyUsage extension is required and `anyExtendedKeyUsage` is not permitted. In the window that runs to 1 July 2027 the clause admits either dedication to one purpose or three conditions together, of which one is in the certificate: that it names no extended key usage belonging to a purpose. So a certificate naming one of those identifiers has already failed that arm, and if its set spans two purposes it has failed the other, which is what a single certificate settles. From 1 July 2027 the second arm is gone and dedication is required outright, so a certificate for a use the appendix does not list is reported then and passed over before.
- Section 2.3's rule that a subscriber certificate carrying `id-kp-emailProtection` and signed on or after 1 February 2027 names at least one rfc822Name in its subjectAltName, and Appendix A's last note read as what it is: a permission for a subscriber certificate signed before 1 July 2027 to carry extended key usages spanning more than one purpose. A permission bounded by a date binds once the date passes. `anyExtendedKeyUsage` is in scope for that rule even though the appendix's table does not list it, because RFC 5280 section 4.2.1.12 makes it a certificate not restricted to any specific key purpose: it names every purpose rather than none, and conforms to no single one. That is what separates it from a purpose the table does not list at all, such as code signing, which leaves a certificate under no trust purpose and outside the rule.
- Section 1.5's floor for a root certificate, an RSA modulus of at least 4096 bits or a curve of at least NIST P-384. The clause names those two families and no others, so a key outside both is reported as well.
- `id-kp-BrandIndicatorforMessageIdentification` (`1.3.6.1.5.5.7.3.31`) joins the OID registry, under IANA's own spelling for it, so the VMC purpose resolves by name like the other six.

### Changed

- `pki.lint.rules()` enumerates the new rows, which stay inert under the default rule set because each one asks whether the caller named the profile.

## v0.8.24 — 2026-09-24

A requirement this toolkit already refuses at parse is not one a profile owes a rule.

### Added

- `pki.lint.certificate(der, { profile: "microsoft-root" })` runs the Microsoft Trusted Root Program profile. Naming it is the only way to reach these rows, and passing no profile leaves every one of them inert.
- Section 3.A.2 states the scope of the root rules outright, that a certificate added to the store is a self-signed root, so those rows read matching issuer and subject names together with the key identifiers. A CA reissued in its own name under a new key carries both names and was signed by its predecessor. The root rows read the key identifiers and pass over it, and the issuing CA rows read the names and pass over it too, because the bytes do not tell it from the succession certificate a root issues to its replacement. That is the reading `mozilla-root` and `chrome-root` already carry, so the three agree.
- The section 3.A.1.4 keyUsage rules over a root: the extension is required, it must be critical, and `keyCertSign` and `cRLSign` must both be set, each missing bit naming itself. Its remaining sentence conditions `digitalSignature` on the root key signing OCSP responses, which is what the private key is used for rather than anything the certificate carries. Section 3.A.1.1 requires a commonName; that it identifies the publisher and is unique are not settled by one certificate.
- The section 3.A.3 term of eight to 25 years, measured in months so a day-of-month with no counterpart clamps. The clause measures from the date of submission, which is at or after notBefore, so a term measured from notBefore is the longest the certificate can offer from any submission date: under eight years is under eight years from every one of them and is an error, while over 25 years from notBefore can still meet 25 years from a later submission and is a warning that says so.
- The section 3.A.5 requirement that an issuing CA certificate carry a distribution point or an authorityInfoAccess naming an OCSP responder at a URI. The accessMethod alone does not settle it, because an accessLocation admits any GeneralName, so an entry naming `id-ad-ocsp` beside a dNSName satisfies the extension's syntax and points at no responder. No URI scheme is asked for, since this clause names one only for the end entity half it does not require. A distribution point is left to its own forms, because a relative name or a cRLIssuer is a pointer the clause admits. Section 3.A.8's separation of uses goes with it. Its sentence that no issuing CA may combine server authentication with S/MIME, code signing or time stamping is an error, naming the purpose found beside it. Its other two sentences are broader, asking that all four uses be separated and that a separate intermediate serve each, so a pair they forbid and that sentence does not is a warning.
- The section 3.A.10 requirement that an end entity certificate declare one of the seventeen reserved policy identifiers the clause lists, all of which were already in the OID registry, and the section 3.A.14 rule that a certificate naming `id-kp-OCSPSigning` name no other extended key usage.
- The section 3.B signature and key table: a signature algorithm outside the RSA and ECDSA forms of SHA-256, SHA-384 and SHA-512, an RSA modulus under 2048 bits, an elliptic curve outside NIST P-256, P-384 and P-521, and a key of a family the table does not name. For code signing and time stamping use, which a certificate declares through its own extended key usage, the table adds that elliptic curve keys are not supported, that an RSA modulus above 4096 bits is not, and that a root needs at least 4096.
- The section 3.E.2 list of extended key usages the program enables on a root, reported as a warning naming the purpose. The document disagrees with itself here: that list omits `id-kp-codeSigning` while all of section 3.D governs code signing roots and states that all code signing certificates are treated equally. Reporting a code signing root would contradict that section, so the admitted set takes the reading that reports neither.

### Changed

- `pki.lint.rules()` enumerates the new rows, which stay inert under the default rule set because each one asks whether the caller named the profile.

### Fixed

- A CMC conformance vector for a `NoSignatureValue` that is not an OCTET STRING drew its value from a SHA-256 digest, and random bytes read as a complete OCTET STRING whenever the first two are `04 1E`, so about one run in 65536 the vector presented the very shape it asked the reader to refuse. It now uses an INTEGER, which is never an OCTET STRING. Nothing in the published tarball changes.

## v0.8.23 — 2026-09-24

A root program's requirement is graded by the clause that states it, so the same key purpose is a warning in one place and an error in another.

### Added

- `pki.lint.certificate(der, { profile: "chrome-root" })` runs the Chrome Root Program Policy v1.8 profile. Naming it is the only way to reach these rows, and passing no profile leaves every one of them inert.
- The section 1.3.2 rules for a subordinate CA certificate, which the policy states as two admitted forms, `id-kp-serverAuth` alone or `id-kp-serverAuth` with `id-kp-clientAuth`. Both forms require the extKeyUsage extension and require `id-kp-serverAuth` in it, and neither admits any other purpose, so those three are errors and `anyExtendedKeyUsage` is reported among the others. Each offending purpose draws its own finding naming that purpose. The second form is open only where the certificate was disclosed to the CCADB before 15 June 2026, which a certificate does not carry, so `id-kp-clientAuth` is reported as a warning whose message names the date that settles it.
- Those four rows reach the unexpired subordinate CA certificates the clause names, so a certificate whose notAfter precedes 15 June 2026 is passed over: the phase-out begins that day and the certificate was not among the unexpired ones then. They do reach a branch serving another purpose, which is the point of the section rather than an overreach, since it governs every subordinate CA certificate beneath a root in the store and exists to phase out the multi-purpose hierarchies. Whether a certificate is revoked is not in the bytes and stays outside the reading.
- The same section's rule for a subscriber certificate, which admits `id-kp-serverAuth` alone and is gated on issuance rather than disclosure. The three rows are silent on a certificate whose notBefore precedes 15 March 2027 and report on the same bytes after it, and `id-kp-clientAuth` is an error there rather than the warning it draws on a subordinate CA. A subscriber certificate is not every end entity: a delegated OCSP responder has its own profile in the Baseline Requirements that section 1.1.1 binds, asking for `id-kp-OCSPSigning` and no other purpose, so a certificate whose extKeyUsage names that purpose and nothing else is passed over. One naming it beside others is not a responder under that profile and is read here as the subscriber certificate it is.
- The section 1.3.1.3 ceiling of three years on a subordinate CA certificate's validity period, reported as a warning at the strength the clause states it. A ceiling given in years is measured in months, so a 29 February notBefore clamps to 28 February rather than moving into March.
- The section 1.3.1.2 root CA term limit. The clause determines the age of key material from the earliest certificate that carries the key, and a self-signed root's own notBefore is at or after that date. The schedule is monotone, so the removal date computed from notBefore is the latest one the schedule can give, and a notAfter past it runs beyond the term whatever earlier certificate exists. The finding names a self-signed CA certificate rather than a root, which is as far as the bytes reach: a CA outside any store issues itself one of these during its own key rollover and it matches a root in every field. What the finding says is that if this certificate is a root in the store the caller named, its term ends before its validity does, and the notice grade is that conditional. It carries the removal date.
- Matching issuer and subject names make a certificate self-issued, which is not the same as self-signed: a CA reissued in its own name under a new key carries the same two names and was signed by its predecessor. The term limit governs a root, so it reads the key identifiers as well, and passes over a certificate whose authorityKeyIdentifier names a key other than its own subjectKeyIdentifier. A certificate that omits the extension, or names its own key in it, is held to the clause. A keyIdentifier with no readable subjectKeyIdentifier beside it settles nothing, and the row rests on settling it, so that reads as not a root too. What remains outside that reading is a reissued CA carrying no authorityKeyIdentifier keyIdentifier at all, which RFC 5280 section 4.2.1.1 permits only in a self-signed certificate and which the `rfc5280` profile reports on its own row.
- A subordinate CA is a role in a hierarchy rather than something a certificate states, so the rows that govern one read the certificate's own issuer and subject names and pass over a certificate issued in its own name. Section 1.3.1.1 requires a cross-certificate from a root to its replacement, which carries one name in both fields, so those rows under-report rather than reporting a succession certificate for the extended key usage a root does not need.

### Changed

- `pki.lint.rules()` enumerates the new rows, which stay inert under the default rule set because each one asks whether the caller named the profile.

## v0.8.22 — 2026-09-24

An algorithm identifier is compared as the bytes a root program fixed, not as the algorithm it names.

### Added

- `pki.lint.certificate(der, { profile: "mozilla-root" })` runs the Mozilla Root Store Policy v3.1 profile. Naming it is the only way to reach these rows, and passing no profile leaves every one of them inert.
- The section 5.1 algorithm encodings, compared as DER: a subjectPublicKeyInfo AlgorithmIdentifier must be one of the four the policy writes out, for RSA and for NIST P-256, P-384 and P-521, and a signature AlgorithmIdentifier one of the ten, covering RSASSA-PKCS1-v1_5, RSASSA-PSS at its matching mask and salt lengths, and ECDSA. Only one of a certificate's two algorithm fields is read, because the parser already refuses a certificate whose outer `signatureAlgorithm` differs from `tbsCertificate.signature`.
- An RSA modulus must be at least 2048 bits and a whole number of octets, and a public exponent of 1 is reported as the unusable key the policy names. An EdDSA key is admitted only in a certificate carrying `id-kp-emailProtection`, and is exempt from the encoding rows, since the policy gives no EdDSA encoding to hold it to. An EdDSA signature is exempt for the same reason and on the issuer's behalf: a permitted EdDSA intermediate signs with that key, and reporting the certificates it signs would fail them for a key they do not carry.
- The section 5.1.3 SHA-1 prohibitions, each gated to the date it carries: from 1 July 2022 over an end entity certificate carrying `id-kp-emailProtection`, and from 1 July 2023 over a certificate carrying `id-kp-OCSPSigning` or over an intermediate certificate. That clause names intermediates rather than certificate authorities, so a root's own self-signature is not one of the four things it lists. The clause also allows SHA-1 over an end entity only where a list of conditions all hold, so a certificate carrying no extKeyUsage, or one naming `id-kp-serverAuth` or `anyExtendedKeyUsage`, is reported as outside that allowance. Its remaining conditions name the issuing certificate and the Baseline Requirements scope, which are not in these bytes.
- The section 5.2 practices a certificate's own bytes answer for: an extKeyUsage extension on every end entity certificate with no `anyExtendedKeyUsage` in it, and a serial number greater than zero. The rest of that sentence asks for at least 64 bits of CSPRNG output, which constrains the draw rather than the integer, and a DER INTEGER drops the leading zero octets a draw produces: eight random octets beginning with zero encode to seven of magnitude, which a conforming certificate does about once in 256. So a short magnitude is reported as a warning and the positive-serial half as an error, each at the strength its own clause supports.
- The section 5.3 rules for intermediate certificates created after 1 January 2019, gated to that date: an extKeyUsage extension is required, `anyExtendedKeyUsage` is not permitted, and `id-kp-serverAuth` and `id-kp-emailProtection` may not appear together. Being an intermediate is a role in a hierarchy that a certificate does not state, so these three read the one thing the bytes give and pass over a certificate issued in its own name. That is every root, which the clause does not govern, and it is also a rollover intermediate reissued in its own name, which the clause does govern, so the three under-report rather than reporting a root for carrying no extended key usage.

### Changed

- `pki.lint.rules()` enumerates the new rows, which stay inert under the default rule set because each one asks whether the caller named the profile.

## v0.8.21 — 2026-09-24

A qualified certificate is linted against the statements it carries, not just their syntax.

### Added

- `pki.lint.certificate(der, { profile: "etsi-qc" })` runs the ETSI EN 319 412-5 v2.4.1 profile, and the rows reach a certificate unprompted because carrying the extension is what the clause governs.
- The qcStatements extension must not be marked critical, and a certificate names one and only one of the three purposes the clause defines: electronic signature, electronic seal and web site authentication. The clause limits the certificate, so the purposes are counted across every QcType statement it carries and two statements naming one each break it as surely as one naming two. The syntax admits identifiers beyond those three, so one of them is not a second purpose and is not counted.
- A QcPDS statement must provide an https URL. The clause asks for one as a minimum, so a plain URL beside a secure one satisfies it. Under the requirements an EU qualified certificate carries, that statement must also name a document in English and must not name more than one per language.
- The semantics identifiers of ETSI EN 319 412-1 v1.5.1 are read, and a subject attribute they govern is held to the structure they require: a three-character identity type reference, a two-character country code, a hyphen-minus and an identifier. A natural person identifier governs the subject serialNumber and admits PAS, IDC, PNO, TAX and TIN; a legal person identifier governs the subject organizationIdentifier and admits VAT, NTR, PSD and LEI. An LEI value must carry the country code XG. A TAX reference is reported as deprecated, which the standard states at a lower strength than the rest, so the finding is a warning.
- Either identifier may instead name a locally defined reference, two characters and a colon, and one of those requires a nameRegistrationAuthorities element carrying a uniformResourceIdentifier, since that is what makes the local scheme resolvable.

### Changed

- The four semantics identifiers of ETSI EN 319 412-1 section 5.1.2 resolve through `pki.oid`, and a decoded SemanticsInformation carries `semanticsIdentifierName` beside the identifier, the way a QcType carries `typeNames` and a QcIdentMethod `methodNames`. It is null for an identifier the registry does not name.
- Every statement of a kind is graded, not the first of that kind. RFC 3739 section 3.2.6 puts no cardinality on QCStatements, so a certificate may carry two disclosure statements or two semantics statements, and the order they sit in the extension decides nothing.

## v0.8.20 — 2026-09-24

An extended validation code signing certificate is held to the subject its own section requires.

### Added

- The subject fields both arms share (section 7.1.4.2.2): a `commonName` is required, a `domainComponent` is prohibited, and a subject attribute carrying only the metadata characters the clause names, a full stop, a hyphen and a space, is reported. An empty value states the same absence and is read the same way.
- The subject fields a code signing certificate must carry (sections 7.1.4.2.3 and 7.1.4.2.4): an `organizationName`, a `countryName` that is a two-letter code, and at least one of `localityName` and `stateOrProvinceName`, since each of those two is required exactly when the other is absent. Section 7.1.4.2.4 carries the address fields onto an extended validation certificate, so both arms answer for them.
- The subject fields an extended validation certificate adds (section 7.1.4.2.4): a `businessCategory` that is one of the four strings the clause names, a `jurisdictionCountryName`, and a `serialNumber`. The clause also bounds the jurisdiction fields against the level the incorporating or registration agency operates at, and requires the state or province at the locality level only where the state or province regulates registration there. A certificate carries neither fact, and a country with a locality and no state is conformant for such an agency, so no row grades the relationship and a valid certificate is not failed for it.
- The three reserved policy identifiers are read apart rather than together, so `2.23.140.1.3` selects the extended validation arm and `2.23.140.1.4.1` the ordinary one. The extension rules of section 7.1.2.3 ask the same question of both, and the subject rules do not.

### Changed

- A subject attribute is read through one reader shared by every rule that asks for one, flattened across the relative distinguished names, because a distinguished name says the same thing whether it carries one multi-valued element or several single-valued ones.

### Fixed

- Which rules a certificate answers for no longer depends on the order its policies sit in. A certificate asserting two reserved identifiers claims both, and the profile read only the first, so one asserting the ordinary code signing identifier ahead of the extended validation one escaped every extended validation rule while the same certificate with those two reversed did not. Section 7.1.6.4 reports more than one identifier, but only for a certificate issued on or after 15 September 2026, so nothing else caught it. A certificate claiming both kinds is now held to both, and to the tighter of the two validity ceilings.

## v0.8.19 — 2026-09-24

A code signing certificate is linted against the kind it declares itself to be.

### Added

- `pki.lint.certificate(der, { profile: "cabf-cs" })` runs the Code Signing BR v3.11.0 subscriber profile. The rows reach a certificate unprompted too, because one asserting a reserved identifier has declared itself to be one. Detection reads the decoded extension, so a certificate whose certificatePolicies does not decode declares nothing and is reported for the encoding instead. A certificate issued to a subordinate CA carries a reserved identifier as well under section 7.1.6.3, so it is not read as a subscriber; naming the profile in the call still lints it as one.
- certificatePolicies (a): the extension is required and should not be critical, and an `id-qt-cps` qualifier must be a HTTP URL. Section 7.1.6.4 requires exactly one reserved identifier for a certificate issued on or after 15 September 2026, gated to the issuance date the certificate states.
- cRLDistributionPoints (b): the extension is required, must not be critical, and must carry a HTTP URL for the CRL service.
- authorityInformationAccess (c): the extension is required, must not be critical, must carry an `id-ad-caIssuers` accessMethod, and each location the clause names must be a HTTP URL. Unlike the S/MIME profile, one rule covers both accessMethods, because this clause states one.
- basicConstraints (d) must not assert `cA`. authorityKeyIdentifier (g) is required and must not be critical.
- keyUsage (e): the extension is required, must be critical, and must assert `digitalSignature`. `keyCertSign` and `cRLSign` are reported as errors and every other bit as a warning, because the clause states the two lists at different strengths in consecutive sentences.
- extKeyUsage (f): a code signing certificate must carry `id-kp-codeSigning` and a timestamp certificate `id-kp-timeStamping`, and a timestamp certificate's extKeyUsage must be marked critical, which is the one place this profile requires a critical extKeyUsage. `anyExtendedKeyUsage` and `id-kp-serverAuth` are forbidden. Any other purpose is reported as a warning, leaving the three the clause names as permitted: Lifetime Signing, `id-kp-emailProtection` and Document Signing. Document Signing is read under both identifiers, the `1.3.6.1.4.1.311.10.3.12` Microsoft assigns it and the `1.3.6.1.4.1.311.3.10.3.12` the clause prints, so a certificate carrying either is passed over.
- The section 6.3.2 ceilings: 39 months for a code signing certificate issued before 1 March 2026, 460 days for one issued on or after it, and 135 months for a timestamp certificate. A ceiling stated in months is measured in months, since how many days 39 months hold depends on which 39.
- The section 6.1.5.2 floors: an RSA modulus of at least 3072 bits, one of NIST P-256, P-384 and P-521, or a DSA key whose parameters are a length of 2048 bits with a modulus length of 224 or 256. Each family the section names is read on its own terms, and a family it names none of is what the row reports.

### Changed

- The three reserved code signing policy identifiers of section 7.1.6.1 resolve through `pki.oid`, named for the kind each one declares, along with the Microsoft extended key purposes the section 7.1.2.3 (f) clause names as permitted.
- Severities in this profile are graded against its own clauses, so a certificate can draw an error here and a warning under `cabf-smime` for the same shape: this document requires a non-critical cRLDistributionPoints, a present authorityInformationAccess and a critical keyUsage where the S/MIME one recommends them.

## v0.8.18 — 2026-09-24

An S/MIME certificate is linted against the generation it declares itself to be.

### Added

- `pki.lint.certificate(der, { profile: "cabf-smime" })` runs the S/MIME BR v1.0.15 subscriber profile of section 7.1.2.3. The rows reach a certificate unprompted too, because one that asserts a reserved policy identifier has declared itself to be one; a certificate asserting none is left alone. A certificate issued to a subordinate CA carries a reserved identifier as well under section 7.1.6.3, so it is not read as a subscriber; naming the profile in the call still lints it as one.
- certificatePolicies (a): the extension is required, should not be critical, and must include exactly one reserved policy identifier, which is also what leaves the generation unambiguous. An `id-qt-cps` qualifier must be a HTTP or HTTPS URL, where the URI scheme tables elsewhere in the section name HTTP alone. This clause asks for a URL rather than a scheme, so a bare `http://` naming no host is reported. An `id-qt-unotice` qualifier must carry `explicitText` and must not carry a `noticeRef`. A certificate issued under a Legacy generation profile on or after 15 July 2025 is reported, gated to the issuance date it states.
- cRLDistributionPoints (b): the extension is required and should not be critical, and the URI scheme rule follows the generation. Strict and Multipurpose require every `uniformResourceIdentifier` to be HTTP; Legacy requires at least one and permits others beside it. HTTP means HTTP: the profile writes "a HTTP or HTTPS URL" where both are meant and "the URI scheme HTTP" here, so an HTTPS distribution point does not satisfy it. Having that scheme means being a URI of it, which RFC 7230 section 2.7.1 writes with an authority and no empty host, so `http:crl.example/a.crl` and `http://:80/a.crl` do not satisfy it either. An extension carrying no `uniformResourceIdentifier` at all is reported. A distribution point without one is not, where another point in the same extension carries a conforming URI.
- authorityInformationAccess (c): the extension should be present and must not be critical, and it should carry an `id-ad-caIssuers` accessMethod. `id-ad-ocsp` and `id-ad-caIssuers` get a table each, unlike the distribution points, whose single table covers the whole extension. Strict and Multipurpose require every URI of a method to be HTTP; Legacy requires at least one of that method's own, so an HTTP OCSP URL does not answer for a caIssuers list that carries only an LDAP one. An accessLocation that is not a `uniformResourceIdentifier` carries no scheme, so it is reported rather than passed over. The profile asks for this extension at SHOULD where the TLS profile asks at SHALL, so the two report the same absence at different strengths.
- basicConstraints (d) must not assert `cA` and must not carry a `pathLenConstraint`.
- keyUsage (e): the extension is required, should be critical, and a bit outside the cell for the certificate's own key algorithm is reported. The cells differ, so `keyEncipherment` is conformant on an RSA key and reported on an elliptic-curve one, whose cell gives `keyAgreement` instead, and an ML-KEM key permits `keyEncipherment` alone. The RSA cell also differs by generation: Multipurpose and Legacy admit `dataEncipherment` where Strict does not. A bit set past the nine RFC 5280 named positions is outside every cell and is reported for every key algorithm. A key algorithm the section gives no cell for is held to none.
- keyUsage (e) is read as the usage modes the cell states, not as a flat set of permitted bits. A conforming certificate asserts every bit of one mode and no bit outside that same mode, so bits the cell names can still combine into nothing it allows: `nonRepudiation` alone matches no mode, and on RSA `digitalSignature` with `dataEncipherment` matches none either, because `dataEncipherment` belongs to the key-management modes and those require `keyEncipherment`. On an elliptic curve `encipherOnly` and `decipherOnly` appear only in the modes that require `keyAgreement`, which is how the cell states "only if keyAgreement is set". A bit the cell names nowhere is reported by the row above this one, so one violation draws one finding.
- extKeyUsage (f): `id-kp-emailProtection` is required, and `id-kp-serverAuth`, `id-kp-codeSigning`, `id-kp-timeStamping` and `anyExtendedKeyUsage` are forbidden at every generation. Under Strict alone, any other purpose is forbidden too.
- authorityKeyIdentifier (g): the extension is required and must not be critical, its `keyIdentifier` field must be present, and `authorityCertIssuer` and `authorityCertSerialNumber` must not be. An extension naming only an issuer and serial satisfies the presence rule and is reported by the field rules.
- subjectAltName (h and 7.1.4.2.1): the extension is required, must carry at least one `rfc822Name` or an `otherName` of type id-on-SmtpUTF8Mailbox, and should not be critical unless the subject is an empty sequence, which is the one condition that lifts it. An otherName is read for its type identifier, since every otherName carries the same tag and only the type tells a mailbox from anything else.
- The optional extensions and the tables that admit them: smimeCapabilities (i), qcStatements (k) and subjectKeyIdentifier (n) must not be critical, and a subjectKeyIdentifier should be present. subjectDirectoryAttributes (j) is prohibited under Strict and Multipurpose and must not be critical under Legacy. The Adobe timestamp and ArchiveRevInfo extensions (m) are prohibited under Strict and must not be critical elsewhere. The Legal Entity Identifier extensions (l) are keyed on the certificate type rather than the generation: both are prohibited on a Mailbox-validated or Individual-validated certificate, the role identifier is prohibited on an Organization-validated one, and neither may be critical where it is admitted.
- A cell that reads "Prohibited" carries no criticality sentence, so a prohibited extension that is also critical draws the prohibition alone. A certificate that declares no generation names no cell, and every table the section keys on the generation reads it against Strict, the narrowest of the three.

### Changed

- The twelve reserved S/MIME policy identifiers of section 7.1.6.1 resolve through `pki.oid`, named for the certificate type and generation each one declares, so an S/MIME certificate reads back with the profile it asserts rather than a bare dotted string. The Legal Entity Identifier and Adobe extension identifiers the profile reads resolve there too.
- The fuzz harness for `pki.lint.certificate` drives every profile a caller can name as well as the default rule set, and `pki.lint.csr` has a harness of its own. Both hold the same contract: hostile bytes return a report carrying a `lint/unparseable` finding, never a throw.

### Fixed

- `pki.lint.certificate`, `csr`, `crl` and `ocsp` take any BufferSource, as every parser in the toolkit already did. They took a `Buffer` alone, so bytes read over the network as a `Uint8Array`, or handed over as an `ArrayBuffer`, raised `lint/bad-input` from the one surface whose data path is documented never to throw. The bytes are snapshotted at the door, so a caller cannot neuter the backing buffer between the check and the read, and a source whose buffer was already detached returns a `fatal` finding like any other unreadable input rather than aborting a scan. A value that is neither bytes, a PEM string nor a parsed object still raises `lint/bad-input`, which is caller error rather than a verdict on a certificate.
- `pki.lint.rules("all")` and `pki.lint.rules("default")` return every row rather than throwing `lint/unknown-profile`. The lint verbs read both names as naming no profile, so a caller who linted under one and then asked which rows had run was refused the answer by the registry that ran them.
- Naming a profile no longer reads an extension whose value does not decode as one that is absent. Every row asks for extensions by name and the shared decoder answers null for both cases, so `pki.lint.certificate(der, { profile: "cabf-tls" })` returned a clean report for a certificate whose basicConstraints, keyUsage or subjectAltName was malformed, and so did the RFC 9881, RFC 9909 and RFC 9935 profiles. The RFC 5280 row that reports the encoding runs under every named certificate profile now, as it already did for `pki.lint.csr`. Callers who pass no profile were never affected.

## v0.8.17 — 2026-09-24

The CA/Browser Forum subscriber certificate profile is linted against its own tables, not a subset of them.

### Added

- The extension presence and criticality table (section 7.1.2.7.6): `authorityInformationAccess` and `certificatePolicies` are required, `nameConstraints` is forbidden, `authorityInformationAccess` must not be critical, and a `subjectKeyIdentifier` is reported at notice as NOT RECOMMENDED. That last one is the opposite of what RFC 5280 asks of an end-entity certificate, and both ship, each under the profile that states it.
- The authorityInformationAccess table (section 7.1.2.7.7): an accessMethod outside `id-ad-ocsp` and `id-ad-caIssuers` is reported, as is an accessLocation that is not a `uniformResourceIdentifier`.
- basicConstraints (section 7.1.2.7.8): `cA` must be FALSE and `pathLenConstraint` must be absent.
- certificatePolicies (section 7.1.2.7.9): `anyPolicy` is forbidden, exactly one reserved policy identifier must be asserted, counted across all four the profile defines, which are the domain-, organization- and individual-validated identifiers and the Extended Validation one (naming none and naming two are the same fault, and the finding says which), and a policy qualifier other than `id-qt-cps` is forbidden, which includes the userNotice RFC 5280 otherwise allows.
- The extKeyUsage table (section 7.1.2.7.10): `id-kp-codeSigning`, `id-kp-emailProtection`, `id-kp-timeStamping`, `id-kp-OCSPSigning`, `anyExtendedKeyUsage` and the precertificate signing purpose are each forbidden.
- The two Key Usage tables (section 7.1.2.7.11), which differ by key type: a bit the table marks unpermitted for the certificate's own key is reported, so `keyEncipherment` is accepted on an RSA certificate and reported on an ECC one. An ECC certificate must assert `digitalSignature`, and an RSA certificate must set at least one of the bits its table permits. The section gives a table for an RSA key and a table for an ECC key and none for anything else, so a certificate on another key, a post-quantum one among them, is held to neither rather than to the RSA one by default.
- The subjectAltName table (section 7.1.2.7.12): an entry of a type outside `dNSName` and `iPAddress` is reported, a `dNSName` carrying the root zone's zero-length trailing label is reported, and the extension must be critical where the subject is an empty sequence and non-critical otherwise, which one rule measures in both directions.

### Changed

- `pki.lint.rules("cabf-tls", "certificate")` returns twenty-five rules where it returned six.
- Selecting `opts.profile: "cabf-tls"` is documented as what it is: an assertion that the input is a TLS subscriber certificate, not a question asked of it. The section 7.1.2.7 rows then apply whatever the certificate says it is, so naming the profile for a CA certificate reports that CA against subscriber requirements. Left unnamed, those rows reach a certificate only where it asserts id-kp-serverAuth and is not a CA. The CA profiles of section 7.1.2.10 are a separate set this does not yet carry.

## v0.8.16 — 2026-09-24

A certification request is linted before it is issued, against the rows its certificate will be held to.

### Added

- `pki.lint.csr(pem | der | parsed, opts?)` lints a PKCS#10 certification request and returns the `LintReport` shape the other lint verbs return. It never throws on the data path: bytes that are not a well-formed request produce a single `fatal` finding `lint/unparseable` carrying the strict parser's own code.
- The `rfc2986` profile covers what the request asks for: an empty subject that names no identity, an empty subject whose subjectAltName is not critical (RFC 5280 section 4.1.2.6), a request for an extension the issuer determines rather than takes from a request, a request asking for a CA certificate, a request signed under a broken digest, and a challengePassword traveling inside the request. The broken digests are named outright rather than read out of the algorithm name, because `shaWithRSAEncryption` is the original SHA, now SHA-0, and its name carries no digest to read.
- `opts.profile: "cabf-tls"` adds the CA/Browser Forum rows: a commonName no subjectAltName covers, a malformed requested dNSName, a request naming no subjectAltName, and a key below the TLS minimum. They run only when named, because a request carries no extKeyUsage and nothing in its bytes says it is for TLS. Each is the certificate profile's own row, applied to what the request asks for.
- A request carrying more than one extensionRequest attribute is reported, and each is linted as the request it states. Two parse where they sort in DER order, and a reader asking for an extension by name takes the first match, so a basicConstraints of cA FALSE in one attribute would otherwise hide a cA TRUE in the next and the answer would be decided by how the bytes happened to sort.
- A requested extension whose value does not decode under its own syntax is reported, by the RFC 5280 section 4.2 row the certificate profile already carries. It counted as a passing check before, because the shared decoder returns null for a value it cannot read and every row asking for an extension by name reads that null as absence.
- The weak-digest row reads an RSASSA-PSS request's digest from its parameters rather than its algorithm name. RFC 4055 section 3.1 makes the hashAlgorithm field default to SHA-1, so absent parameters and an empty parameter sequence both name SHA-1: the encoding that appears to say nothing names the weakest digest.
- `pki.lint.rules("rfc2986")` lists the new rows with their citations, and `pki.lint.profiles()` enumerates `rfc2986` alongside the existing names.
- `pki.lint.rules(profile, artifact)` takes an optional second argument, one of `"certificate"`, `"csr"`, `"crl"` or `"ocsp"`. `"cabf-tls"` names a set for both a certificate and a request and the two are not the same rows, so without it a reader asking about the request was given the certificate-only extKeyUsage and validity rows and none of the structural ones. Calls passing one argument are unchanged. Each id now appears once in the listing, including where two artifacts run the same rule.

### Changed

- `md2WithRSAEncryption`, `md4WithRSAEncryption-pkcs1`, `md5WithRSAEncryption`, `sha1WithRSAEncryption`, `ecdsaWithSHA1` and `dsaWithSha1` resolve through `pki.oid`, so an artifact signed under one reads back with a name instead of a bare dotted string. The weak-digest row reads that name, so an algorithm the registry could not name passed it silently. Nothing signs or verifies from that registry: the signing and verifying paths carry their own tables and neither has a row for these, so naming one does not make it usable. A request or certificate carrying one still fails to verify.
- Every name in the OID registry resolves back to the OID it names, which is now checked. The registry is two-way and its directions did not agree about a repeated name: the first `name -> OID` registration was kept while `OID -> name` took the last, so adding a name a family already held would silently repoint every lookup of it and leave the original OID still answering with that name. X.501 and X.520 both standardize `clearance`, at 2.5.1.5.55 and 2.5.4.55, and that pair is recorded as the one intended duplicate. PKCS#1's MD4 signature is registered as `md4WithRSAEncryption-pkcs1` for the same reason: the plain name already belongs to the OIW arc's 1.3.14.3.2.4.

## v0.8.15 — 2026-09-24

The host's own CA bundle becomes trust anchors, and says which set you got.

### Added

- `pki.trust.systemAnchors(opts?)` returns `{ anchors, source, path, skipped }`. `source` is `"ca-bundle"` for a file on this host, with `path` naming it, or `"node-bundled"` for the Mozilla set compiled into Node, whose `path` is `null`. The anchors are the same shape `parseCertdata` and `parseCcadbCsv` produce, so they reach `pki.path.validate` without being mapped by hand. `opts.path` reads a named file, `opts.maxBytes` caps the read.
- The searched locations are the bundles a Unix host keeps: `/etc/ssl/certs/ca-certificates.crt`, `/etc/pki/tls/certs/ca-bundle.crt`, `/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem`, `/etc/ssl/ca-bundle.pem`, `/etc/ssl/cert.pem` and `/usr/local/share/certs/ca-root-nss.crt`. Each candidate is tried by reading it, so one that exists but cannot be opened does not strand a readable one below it.
- `pki.trust.discoversBundles(platform)` says whether that search runs. It does not on macOS or Windows: `/etc/ssl/cert.pem` can exist on macOS as OpenSSL's own copy, and returning it would report anchors that omit both locally installed roots and the Keychain trust decisions the host actually applies. Those platforms are refused instead, and `opts.path` still reads an exported bundle anywhere.
- A host with no readable bundle is refused with `trust/no-readable-store`, and the message names the command that exports its roots. macOS keeps them in a binary keychain and Windows in a registry-backed store; reading either means running another program, which this toolkit does not do. `opts.allowNodeBundle: true` returns Node's set instead, reported as `"node-bundled"` so the difference is visible.
- A block the bundle carries that is not a certificate, and a certificate the strict reader refuses, are both listed in `skipped` with the label, the byte offset and the reason. A CA bundle is distribution-managed and carries certificates that do not conform: the set Node ships has a root whose validity dates are before 2050 and encoded as GeneralizedTime, which RFC 5280 section 4.1.2.5 requires to be UTCTime. Refusing the whole file over one such root would leave you with no anchors, so it is named instead. A bundle from which no certificate parsed is still refused. A `BEGIN` boundary that does not start its line is reported too: RFC 7468 section 2 reads it as text rather than as a block, which in a trust bundle is an anchor you believe you installed and do not have.
- An OpenSSL `TRUSTED CERTIFICATE` block is reported rather than ingested. It carries the purposes the operator trusted the root FOR in a trailing `X509_CERT_AUX`, and reading the certificate while dropping that would turn a root restricted to one purpose into an unrestricted anchor. The reported reason names the conversion, `openssl x509 -in <file> -out <file>.pem`. Reading the auxiliary data and keeping its restrictions is not yet implemented.
- `opts.maxBytes` bounds what is read rather than what is kept, so a bundle far above the cap is refused without being loaded. A bundle that exceeds it is refused even under `opts.allowNodeBundle`: the cap is a fault about the file you named, and a resource refusal must not quietly swap the trust set a validation runs against.

## v0.8.14 — 2026-09-24

The 0.8.14 tarball is the one to install for everything 0.8.13 added.

### Fixed

- The changelog entry for this release is dated by the UTC day its commit landed on, which is the day the release tooling and the entry now agree on.

## v0.8.13 — 2026-09-24

A trust anchor list published as RFC 5914 drives path validation, constraints and all.

### Added

- `pki.schema.trustanchor.parse(input, caps?)` reads an RFC 5914 `TrustAnchorList` into `{ anchors }`, each entry naming which of the three `TrustAnchorChoice` arms it came from in `kind`: `certificate`, `tbsCert` (a bare `TBSCertificate`, an anchor having no signature to check) or `taInfo`. `parseInfo` reads a bare `TrustAnchorInfo`. `pemDecode` and `pemEncode` carry the `TRUST ANCHOR LIST` label, and `pki.schema.parse` detects and routes the format.
- A `taInfo` entry is `{ version, pubKey, keyId, taTitle, certPath, exts, excludedExtensions, taTitleLangTag }`. `certPath` carries the anchor's name and its constraints: `policySet`, `policyFlags`, `nameConstr` and `pathLenConstraint`. Every rule RFC 5914 states is enforced: an empty `taName`, a `policySet` carrying policy qualifiers, a negative `pathLenConstraint` and a `requireExplicitPolicy` with no `policySet` are each refused, and where `certPath` carries a certificate its subject, its public key and its `subjectKeyIdentifier` must match `taName`, `pubKey` and `keyId`. The four extension types section 3 excludes from `exts` are listed in `excludedExtensions` and left out of `exts`, because the RFC ignores them rather than refusing the anchor.
- `pki.trust.parseTrustAnchorList(input, opts?)` turns a list into the trust-store entries `pki.trust.anchor` takes, the same shape `parseCertdata` and `parseCcadbCsv` produce, so a published list drives `pki.path.validate` without being mapped by hand.
- `pki.path.validate` applies an RFC 5914 anchor's constraints as RFC 5937 section 3.2 states. The anchor's name constraints intersect the caller's permitted subtrees and union its excluded ones, its `policySet` intersects `opts.userInitialPolicySet`, each of its `policyFlags` may turn the matching `initialExplicitPolicy` / `initialPolicyMappingInhibit` / `initialAnyPolicyInhibit` on and never off, and its `pathLenConstraint` bounds the path. An anchor can only narrow what the caller asked for, which is what makes the format's "always enforced" safe to apply.
- `pki.trustanchor.build(spec, opts?)` writes the list a root program publishes, with `opts.pem` for the PEM form. Every rule the parser enforces is enforced here, so the toolkit does not emit an anchor it would refuse to read, and the result round-trips through the parser before it is returned. The `version` is not a field a caller sets: v1 is the ASN.1 DEFAULT and DER omits it, so each v1 anchor has one encoding.

### Changed

- `pki.trust.anchor` keeps an entry's `purposes` as `null` where the entry states it as `null`, rather than reading it as a set granting nothing. A store that lists a root and grants it no purpose still produces the all-false set, which is what `pki.path.validate` requires `opts.checkPurpose` to judge; a format carrying no purpose bits at all, such as an RFC 5914 anchor, now says nothing instead of saying "trusted for no purpose". An entry that omits `purposes` altogether is unchanged and still defaults to all-false.

### Fixed

- An option supplied as a non-enumerable own property now reaches the code that acts on it. Every verb accepted such an option and some then skipped it, with nothing reported. Two of them let a check pass that should have failed: `pki.webauthn.verifyAssertion` ignored an `expectedRpId` written that way and reported the assertion valid against the wrong relying party, and an `opts.tpmPolicy.objectAttributes` entry went unchecked in `pki.webauthn.verify`. The rest changed what was produced or refused: `pki.attrcert.sign` left the named extension or attribute out of the certificate it signed, `pki.crmf.build` left the control out of the request, and `pki.cmp.session` answered that no enrollment arm or support operation had been named, or classified an `ir` request as central key generation.
- The same read now applies wherever the toolkit copies or enumerates an object it was handed: `pki.path.validate`, `pki.trust`, `pki.cms.verify`, `pki.cmc`, `pki.est`, `pki.ocsp`, `pki.acme`, `pki.ct`, `pki.scep`, `pki.smime`, `pki.webauthn` and the WebCrypto algorithm surface. It covers the response an `opts.transport` returns, so a `Location`, `WWW-Authenticate`, `Retry-After` or `Content-Type` field a custom transport defines non-enumerably is now found. A caller passing plain objects sees no change.
- In `pki.path.validate`, an anchor carrying any RFC 5914 constraint could accept a path that the same call without the anchor refused, because the caller's `initialExplicitPolicy`, `initialPolicyMappingInhibit`, `initialAnyPolicyInhibit` and `userInitialPolicySet` were re-read from a copy that kept only the enumerable ones. The caller's options are now read where they were passed.
- A trust anchor's own `basicConstraints` `pathLenConstraint` now bounds the path below it, as RFC 5937 section 3.2 requires of a basic constraints extension associated with the anchor. It applies wherever the anchor states one: a certificate passed to `pki.path.validate` or `pki.trust.anchor`, the `exts` of an RFC 5914 `TrustAnchorInfo`, and that anchor's `certPath.pathLenConstraint`. Where two of them state a limit, the lower applies. **A path that runs deeper than an anchor certificate's `pathLenConstraint` allows is now refused where it previously validated.** Check the certificates in your trust store for a `pathLenConstraint` if you validate chains with intermediates.
- `pki.trustanchor.build` refuses a malformed nested record with its own `trustanchor/bad-input` error. A `null` or non-object `taInfo`, `certPath` or `certPath.policyFlags` raised a native `TypeError` from the first field read below it.
- Parsing no longer writes a decoded field through an accessor planted on `Object.prototype`. Every format parser builds its record in the schema engine by the field names its structure declares, and those writes reached the prototype chain: a getter-only `signatureAlgorithm`, `issuer`, `extensions` or any other declared name made an ordinary `pki.schema.x509.parse` of a valid certificate throw a native `TypeError`, and a setter took the decoded value instead of the record.

## v0.8.12 — 2026-09-23

A certification path whose policies map to each other validates instead of exhausting the policy-node budget.

### Changed

- The `pki.path.validate` result carries `validPolicyGraph` where it carried `validPolicyTree`, and the structure it holds is a graph rather than a tree. It is `{ nodes }`, a flat list linked by index, each entry `{ id, depth, validPolicy, qualifierSet, expectedPolicySet, parents, children }`, where `parents` and `children` hold the ids of other entries and the root is id 0. A caller that walked `.children` from a root and read `.parent` reads the list and follows ids instead. The list is flat because a graph handed back as a root to walk re-expands into the exponential structure the moment anything recurses into every child, `JSON.stringify` included.
- `C.LIMITS.PATH_MAX_POLICY_NODES` is unchanged at 4096 and still fails closed with `path/policy-tree-cap`, and `opts.maxPolicyNodes` still tightens it. What it bounds has changed with the structure: the graph grows with the number of distinct policies and mappings the path actually carries, so the cap is reached by a path that names that many policies, not by a path that names a few and is several certificates long. A single certificate carrying 4096 distinct policies still reaches it.

### Fixed

- `pki.path.validate` accepts a conforming certification path whose certificate policies map to each other across several levels. Such a path was refused with `path/policy-tree-cap` once its policy tree exceeded `maxPolicyNodes`, which the tree reached at six certificates carrying four mutually mapped policies. Certificate policies are now processed as the RFC 9618 `valid_policy_graph`, whose size is linear in the policies and mappings on the path rather than exponential in its length.
- `userConstrainedPolicySet` reports the policies of the nodes whose only parent is the `anyPolicy` node, which is what RFC 5280 section 6.1.5 (g)(iii)(1) and RFC 9618 section 5.5 step (g)(2) both define it as. It reported the policies of the deepest nodes instead. The two answers differ wherever a policy mapping is in play: for a chain where an issuer domain policy is mapped to a subject domain policy, the set now names the issuer domain policy, which is the domain the relying party asked about and the reason mapping exists.

## v0.8.11 — 2026-09-23

What a certificate, CRL, request or attribute certificate asserts in its extensions is read in one call.

### Added

- `pki.schema.x509.decodeExtensions(certOrBytes, opts?)` and `pki.schema.x509.decodeExtension(ext, opts?)` return a certificate's extensions as decoded records. Each row is `{ oid, name, critical, value, scope, index, containerIndex, state, decoded, code, profile }`. `state` is `"decoded"`, `"unrecognized"` for an identifier no decoder is registered for, or `"undecodable"` for a value that is not the structure its identifier names; `code` carries the typed error in that last arm. `value` is the raw extnValue octets in every arm, for a caller recomputing a hash or comparing an encoding. Read `state` rather than testing `decoded`, because a valid `ocspNoCheck` decodes to null.
- The same pair on `pki.schema.crl`, `pki.schema.csr`, `pki.schema.attrcert` and `pki.schema.crmf`. The CRL table spans the CRL's own extensions and every revoked entry's in one array, each row naming its scope and, for an entry, its position in `containerIndex`. `pki.schema.crl.decodeExtension` takes a required `opts.scope` of `"crl"`, `"crl-entry"` or `"ocsp-single"`, since RFC 6960 section 4.4.5 carries a CRL entry extension into an OCSP SingleResponse and the same identifier means different things at those three scopes.
- `pki.schema.csr.decodeExtensions` refuses a request carrying more than one `extensionRequest` attribute with `csr/ambiguous-extension-request`, because nothing states which of them a CA should honor. Pass `groups: true` to read each attribute separately, as `[{ attributeIndex, extensions }]`.
- Both verbs report what they find and refuse nothing on the strength of it. An extension against the criticality its RFC fixes still decodes, and the deviation is reported on `profile` with the clause that fixes it. An unrecognized critical extension comes back as a row, whatever `opts.strict` says. RFC 5280 section 4.2's rule about one belongs to the relying party, and the two facts that rule needs, `critical` and `state`, are on the row.
- The certificate extension table decodes the RFC 3779 address and AS number blocks (`ipAddrBlocks`, `autonomousSysIds`) and the RFC 8360 identifiers for the same syntax, plus the RFC 7633 TLS feature extension. `pki.inspect.certificate` renders all five. An AS number and a TLS feature are read as the `INTEGER` each type declares, so both come back as a `BigInt` and a value outside its registry's width is reported rather than refused.
- The CRL extension table decodes the issuing distribution point and the delta CRL indicator, along with the authority key identifier, issuer alternative name, freshest CRL and authority information access that RFC 5280 section 5.2 profiles, and the certificate issuer at entry scope.

### Changed

- `pki.schema.crl.parse` puts the raw extnValue octets on every extension record as `valueBytes`, beside the `value` it already carried. The three extensions the CRL parse decodes in place, `cRLNumber`, `reasonCode` and `invalidityDate`, previously left a caller no way back to the bytes those values came from.
- Every signer that validates a pre-encoded extension now validates a pre-encoded `ipAddrBlocks`, `autonomousSysIds` or `tlsFeature` too, and refuses one whose value is not the structure its identifier names. A certificate carrying such a value can still be read: the refusal is on the writing side.
- `pki.attrcert.sign` and `pki.schema.attrcert.decodeExtensions` read the criticality RFC 5755 fixes from one table, so the signer's refusal names the section it refuses under where its message previously cited the RFC alone.

## v0.8.10 — 2026-09-23

A certificate's names are read the same way wherever the toolkit reads them.

### Fixed

- `pki.tsp.verify` reports `tsp/bad-eku` for an empty extendedKeyUsage. RFC 5280 section 4.2.1.12 has at least one KeyPurposeId, and an empty one decodes as a SEQUENCE, so a reader checking only the tag called it well-formed and reported it as naming the wrong purpose, `tsp/eku-not-exclusive`. Every other consumer of that extension already answered with the malformed-extension code.
- The subjectAltName a timestamp token names its TSA through is read under the same rules every other consumer reads a name under, so a name this toolkit refuses elsewhere cannot be a source of TSA identity. The match itself is still a comparison of the encoded GeneralName bytes, which is what naming a TSA means: the name in the token and the name in the certificate have to be the same name.

### Security

- `pki.sigstore.verifyBundle` refuses a Fulcio certificate whose subjectAltName carries more than one name, whatever forms those names take. It counted only rfc822Name, dNSName, uniformResourceIdentifier and otherName, and dropped every other form before counting, so a certificate pairing a URI with an iPAddress or a directoryName was accepted as binding one identity. A caller identity policy matched the form the verifier reported and never saw the other, which is the outcome the one-identity rule exists to prevent. The rule now counts every name the extension carries. A certificate carrying a single name in a form with no identity string still reports `identity.san` as `null`, unchanged, so a policy gates on the null rather than on a name nothing read.

## v0.8.9 — 2026-09-23

The 0.8.9 tarball is the one to install for everything 0.8.8 added.

### Changed

- `npm install @blamejs/pki@0.8.8` resolves nothing; 0.8.9 is the published version carrying that surface. The two differ in one test file and nothing the tarball ships.

## v0.8.8 — 2026-09-23

A key moves in and out in the encodings operators hold it in, and a PEM file is walked once from the front.

### Added

- `pki.schema.pem.decodeBundle(text, opts)` reads every object in a PEM file: a `fullchain.pem` of a leaf and its intermediates, a `ca-certificates.crt` of hundreds of anchors, a file holding a key beside its certificate. Each row carries the label that named the object, the DER it decoded to, its position in the file and the offset its boundary started at, so a fault in a file of hundreds names the one object it is about. The offset counts into the text this verb read: a BufferSource is read as latin1, so one character is one byte and the offset is the byte offset into the file, while a string a caller decoded itself is counted in that string's own characters. Explanatory text before, between and after the blocks is text, which is what a bundle a real tool wrote carries, and a label this toolkit has no parser for is a row like any other. `opts.route` checks each label's claim against the structure its bytes carry and adds `format` to every row, covering the certificate, CRL, request, CMS, attribute-certificate and PKCS#12 labels and the four key labels, `PRIVATE KEY`, `ENCRYPTED PRIVATE KEY`, `RSA PRIVATE KEY`, `RSA PUBLIC KEY` and `EC PRIVATE KEY`, each against its own structure, so bytes armored under the label of something they are not are refused rather than routed. `opts.maxBlocks` and `opts.maxDecodedBytes` bound how many objects a file may hold and how many bytes they may decode to across the whole file; each defaults to a new `C.LIMITS` figure, may be tightened by a caller, and may not be raised above the toolkit's own.
- `pki.schema.pem.encodeBundle(objects)` writes an ordered list of `{ label, der }` back to one text, which `decodeBundle` reads to the list that went in. The reader takes every label RFC 7468 sec. 3 admits, including lowercase and the empty one; the writer takes the uppercase form this toolkit emits, so a label carrying a boundary cannot put a block into the file that the caller never named.
- `pki.schema.pkcs1` reads and writes the RFC 8017 Appendix A.1 `RSAPrivateKey` and `RSAPublicKey`, the `RSA PRIVATE KEY` and `RSA PUBLIC KEY` blocks OpenSSL wrote by default for years and appliances still emit. Every relation the specification states between the components is checked in the pass that reads them: the version against whether other prime infos are present, each component as a positive integer, the public exponent inside `3 <= e < n` and odd, and the private exponent, the two CRT exponents and the coefficient below the modulus or their own prime.
- `pki.schema.sec1` reads and writes the RFC 5915 `ECPrivateKey`, the `EC PRIVATE KEY` block. The curve is what fixes the scalar's width, so the parameters field is required for a key read on its own and a scalar of the wrong width is refused rather than padded into place. The field carries a named curve and nothing else: a specified curve and an implicit curve are each refused by name, which RFC 5480 sec. 2.1.1 states as a MUST NOT. The stored public point is surfaced as the bytes it carries, and a verb that needs the public half derives it from the scalar.
- `pki.key.import` and `pki.key.export` take `opts.format` of `pkcs1`, `sec1` or `jwk`, so a key an operator holds in one of those encodings moves in and out without a conversion step outside this toolkit. Neither DER encoding names an algorithm, so `opts.algorithm` is required with both and nothing is inferred. A SEC1 key written out is given the `[0] parameters` field the standalone form needs, which RFC 5915 sec. 3 omits while the key sits inside a PKCS#8. A multi-prime RSA key reads as a structure and is refused on import with `key/unsupported-key`. Both PKCS#1 armor labels are read, `RSA PRIVATE KEY` and `RSA PUBLIC KEY`, with the structure the bytes carry required to agree with the label, so a key armored under the label of the half it is not is refused instead of being read as the other. A JWK export reaches a `CryptoKey` from another WebCrypto implementation the same three ways a DER export does, so a key that exports one way exports the other.
- `pki.identity.match(cert, references, opts)` answers whether a certificate presents a name the client was trying to reach (RFC 9525, which obsoletes RFC 6125). It is a separate answer from whether the certificate chains, and section 1.2 has an application need both: `pki.path.validate` takes the same reference forms as `opts.identity`, reports the outcome as `identityChecked` beside a named check row on the leaf, and runs it in addition to the RFC 5280 section 6.1 checks rather than in place of any of them. Four reference forms are read. A domain name is a DNS-ID, compared label by label under an ASCII-only fold with one trailing dot normalized on both sides; an A-label is compared as the ASCII it is and never decoded. A textual IP address or a 4- or 16-octet BufferSource is an IP-ID, compared octet for octet against an `iPAddress` entry, never against a `dNSName` carrying the same address as text, and with no prefix or mask. `{ type: "srv", service: "_imap", value: "example.com" }` is an SRV-ID read against an RFC 4985 SRVName, the leading underscore part of the service name. `{ type: "uri", scheme: "https", value: "www.example.com" }` is a URI-ID read against the scheme and host of a `uniformResourceIdentifier`, ignoring userinfo, port, path, query and fragment. A record carries its service type and its domain together, so no comparison can pair the service of one reference with the domain of another. The subject `commonName` is never consulted, under any option or fallback, including when the certificate carries no subjectAltName at all, and neither is any other relative distinguished name. Wildcards are on and `opts.wildcards: false` turns them off: one wildcard, the whole of the left-most label, reaching exactly one reference label, and a presented name failing those rules is ignored with the search continuing rather than refusing the certificate. The verdict names which reference matched, so a caller can use it as the validated identity of the service, and lists every entry that was skipped with the reason. Two limits are stated rather than implied: a reference identity carrying a character outside ASCII is refused with `identity/unsupported-reference`, this toolkit shipping no IDNA implementation to perform the section 6.3 U-label conversion, and a wildcard spanning an administrative boundary such as `*.co.uk` is out of scope as it is in section 7.1, no public-suffix list shipping here.
- `pki.path.fetchingChecker(opts)` is a revocation checker that reads the revocation locations out of the certificate and asks for them over `opts.transport`, for `pki.path.validate`'s `opts.revocationChecker`. The checkers that shipped before it are handed their CRLs and responses; this one fetches. It is off unless an operator builds it and passes it, the way opts.fetchAia is, so the default validation touches no network, and it runs during validate and not during build, because RFC 5019 section 3.2 has a status check requested only after the chain signatures are validated. OCSP is attempted first and a CRL after it, which section 3.1 directs. The responder URL comes from an authorityInfoAccess AccessDescription whose accessMethod is id-ad-ocsp; the CRL URLs come from cRLDistributionPoints and never from authorityInfoAccess, which RFC 5280 section 4.2.2.1 states outright. A distribution point is fetched only as a fullName carrying a URI whose scheme this client speaks, so an ldap or ftp point, a nameRelativeToCRLIssuer and a point naming a cRLIssuer and no location are each counted as a skip: a certificate naming nothing fetchable reads as that and never as a failed fetch. Nothing is trusted by having been fetched. A CRL goes to pki.path.crlChecker and a response to pki.path.verifyOcspResponse, which are the one place the issuer match, the cRLSign authorization, the IDP scope, the section 6.3.3(b) correspondence, the CertID match, the responder authorization and both currency windows live, and the structure a body is parsed as is decided by which extension named the URL, so a Content-Type mismatch is reported and never selects a parser. Every fault is an undetermined status carrying its reason and check throws nothing, which is what leaves opts.softFail able to waive one. Plaintext http is accepted by default, a CRL and an OCSP response being signed objects this toolkit verifies and an https-only fetch needing the responder certificate validated first; allowPlaintextHttp: false narrows it to https, and the SSRF guards do not move either way: no private, loopback or link-local destination, no redirect followed, a response-size cap, a per-certificate URL cap, a total fetch count and a wall-clock deadline across the validation. The two destination forms are decided in different places, because an address literal is in the URL and a name is whatever resolves it: a literal is refused here when it is private, loopback or link-local, and a hostname is a skip unless the transport declares blocksPrivateAddresses, which pki.transport.https does and backs by filtering the resolved address and pinning it for the connection. maxPerCert counts the distinct destinations one certificate names across the responder and distribution-point routes together, and maxFetches and totalDeadlineMs bound a validation rather than a certificate, so the certificates of one path and every anchor tried share one budget while a reusable checker starts each validation from the figures again. Nothing a fetched CRL says is acted on before the CRL is shown to be the issuer's: its signature is checked against the key the path is working with, and only then are the delta locations it names followed or the object kept. A base that merely parsed would otherwise choose the next destination, which over a plaintext hop is whoever replaced it. A verified response is cached in process on its CertID and responder URL, and a CRL on its request URL together with the issuer the certificate names and the key that has to verify it, so neither one URL serving several issuers' lists nor a CA that rotated its key hands one certificate's list to another; reuse is decided from the signed thisUpdate and nextUpdate and from nothing the HTTP caching headers say. A certificate naming a freshestCRL and no distribution point has that location asked for on its own, because an unmerged delta listing the serial is a revocation the checkers already treat as authoritative. nonce is true or false and anything else is `path/bad-input`, a control read as off being one an operator asked for and did not get; nonce: true asks the responder for a fresh nonce on every request, which the response must echo or the status is undetermined, and a nonce-bearing request is asked each time rather than answered from the cache, the two being alternatives in RFC 5019. The timeout each request carries is a field the transport is asked to honor, and the deadline is enforced here as well, so a transport that never settles is refused at the bound rather than holding the validation open. An ldap distribution point is not fetched, which is a second protocol client rather than a flag, and re-opens when an operator presents a deployment whose only distribution point is one; ftp is a permanent non-goal.
- `pki.ocsp.httpRequest(der, responderUrl, opts)` shapes a DER OCSPRequest into the HTTP request RFC 6960 Appendix A.1 defines, without opening a socket: the method, the URL, the headers and the body a caller hands to its own transport. A GET carries the request as the base64 of the DER percent-encoded into one path segment, and RFC 5019 section 5 requires GET when the whole encoded URL is 255 bytes or fewer counting the scheme, the delimiters, the server name and the encoded request, which is what is measured; anything longer is a POST carrying content-type application/ocsp-request. The base64 carries no CR and no LF, which that section also requires, and the encoding escapes every byte outside RFC 3986 section 2.3's unreserved set, so the plus, slash and equals of base64 become triplets and a request cannot reach past the segment it was placed in. The size bound counts bytes, and a URI is US-ASCII by RFC 3986 section 2, so a responder URL carrying anything above that is refused with `ocsp/bad-input` rather than measured as though a character were a byte; percent-encode it first. The URL that comes back is the one a transport will open: the responder URL is normalized before the request is appended, so a path carrying a `.` or `..` segment, which RFC 3986 section 5.2.4 removes when any client parses the URL, does not end up building one address and sending another.

### Changed

- A verb that reads one object refuses a PEM file holding several, with `pem/multiple-blocks` naming how many it holds and the verb that reads a file of them. Every parse door, every `pemDecode`, and every verb taking a certificate or a message as PEM previously read the first block and reported nothing about the rest, so a `fullchain.pem` passed where a certificate was wanted was read as its leaf and the chain was dropped in silence. A boundary that opens after the first object and never closes is an object too, and is refused as `pem/unterminated-block`. Read a file of several objects with `pki.schema.pem.decodeBundle`, and pass one object where one is wanted. Explanatory text around a single block is unaffected, which is what an `openssl x509 -text` dump carries.
- `pki.cms.sign`, `pki.cms.verify` and `pki.tsp.sign` read a certificate or a message given as PEM through the same scanner every parse door reads through. They used a second scanner that searched the raw text, so a boundary marker sitting inside explanatory text opened a block, a body carrying characters outside the base64 alphabet was stripped down to what decoded rather than refused, and a file of several objects was read as its first. Each of those is a file every parse door already refused, so the same bytes were an object to one verb and malformed to the next. The verbs keep their own error codes (`cms/bad-input`, `tsp/bad-input`) and the message names what the file is.
- `pki.path.validate` does not ask a revocation checker about a certificate no signature on the path has authenticated. A certificate is a claim until the chain above it verifies, so the serial and issuer a responder would answer about are ones nothing has bound to a key, and a checker that reaches the network would let that certificate choose the destination and spend the fetch budget. A forged issuer is the case that matters: its own check fails and its key still becomes the working key, so everything under it verifies against a key nothing vouched for. The `revocation` row reports `path/revocation-undetermined` with a reason naming whether it was this certificate's signature or one above it, and `revocationChecked` reads `"undetermined"`. The path already fails as `path/bad-signature`, so `valid` is unchanged; what changes is that an unauthenticated certificate no longer produces a revocation answer.
- `pki.path.verifyOcspResponse` reads its options through the door every other verb's go through: an unknown option is `path/bad-input` naming what was passed, and a field supplied through an accessor is refused rather than read more than once. It took its options unchecked, so a misspelled `requestNonce` left the nonce unasked and the response was accepted against bytes nothing compared, and an accessor could answer one value to the check and another to the comparison. Every verdict it returns now carries `nonceMatched`, including the ones decided before the response is read, where the field was absent rather than reporting an outcome.
- `pki.path.verifyOcspResponse` takes `opts.requestNonce`, the nonce bytes the request carried, and reports `nonceMatched`. A response that does not echo it answers an earlier question, so a `good` becomes undetermined; a `revoked` keeps its status and carries `nonceMatched: false`, a stale revocation being no safer to accept. The comparison is constant-time. `pki.ocsp.verify` reads the rule from there rather than applying its own, so every caller reaching the acceptance gate gets it: before, a caller using the lower-level verb passed a nonce that nothing compared.
- A request to `pki.transport.https` takes `allowPlaintextHttp`, which is the one thing that moves the https floor, per request and never as a default. It exists for the two objects whose integrity does not come from the transport: a CRL and an OCSP response carry their own signatures, which this toolkit verifies after the fetch, and an https-only revocation fetch needs the responder's own certificate validated first, which is the loop the plaintext form breaks. A request that does not set it is refused with `transport/insecure-url` exactly as before. Every other guard holds on a plaintext hop: the private, loopback and link-local address refusal, the response-size cap, the declared content-length pre-check, the timeout, and the refusal to follow a redirect, which is what would otherwise turn one plaintext hop into a downgrade of the next. A trust anchor is required only where a certificate is negotiated, so the `transport/no-trust-anchors` refusal applies to an https request and not to a plaintext one, which pins nothing because there is nothing to pin. Through a configured proxy, the CONNECT names the origin's own authority with its scheme's default port, and the request rides the tunnel as plain HTTP rather than starting a TLS handshake inside it.

### Fixed

- A `uniformResourceIdentifier` name constraint whose base names a single label, such as `.com` or `com`, is accepted by `pki.trust.anchor` and by the `pki.path.validate` subtree seeds, and enforced by the comparison. It was refused with `trust/bad-input`, so a delegation to a single top-level domain could not be written at all. A base naming an IP address is still refused, that being what sec. 4.2.1.10 excludes by name, and the `dNSName` arm is unchanged.
- A certificate whose URI subject alternative name carries a single-label host, such as `https://localhost/`, is compared against a URI constraint rather than reported as `path/name-constraint-unsupported`. It is excluded by a base that names it and left alone by one that does not, which is what OpenSSL and Go both do.
- A PEM file is recognized as PEM however long the text before its first block is. The reader decided from the first 4096 bytes, so a certificate behind a longer header was handed to the DER decoder and refused as malformed DER when it was passed as a `Buffer`, while the same bytes passed as a string parsed.
- A certificate's `revocation` check row carries what the checker decided on rather than the status alone. `reason` names the source that answered, the locations that were skipped and the faults that were noted; a revoked row adds `revocationReason`, the CRLReason integer, and `revocationTime`. A `softFail` waiver now names what it waived, and a revocation says why and when. `pki.path.crlChecker` reports `revocationTime` from the revoked entry's revocationDate, and a revocation `pki.path.fetchingChecker` establishes from a CRL carries its CRLReason, which was reported as absent.
- A block whose closing boundary names a different label than its opening one is refused as `pem/label-mismatch`. The reader looked for the opening label's own end marker, so such a block ran on to the next matching end, swallowing any block between them, and the file was reported as `pem/bad-base64`. One legacy-labeled block in a bundle no longer decides the verdict for a well-formed neighbor. A boundary is read only when it is the whole of its own line, so text quoting one mid-sentence is text, and an opening boundary with no closing one is refused rather than skipped in favor of a later block. Whitespace after a boundary's closing hyphens belongs to the line ending, which is what RFC 7468 section 3's parsing grammar writes, so a file whose boundaries carry trailing spaces or tabs reads as the objects it holds rather than as prose. A byte-order mark before the first boundary is read in either form it arrives in, the three bytes of a latin1 read or the single character of a UTF-8 one.

### Security

- Reading a PEM file costs time proportional to its length. The scanner advanced one character past a boundary it could not close and searched the rest of the text again, so a file of unterminated boundary lines cost time proportional to the square of its length: 3200 such lines took 34 ms, and the 16 MiB a parse accepts holds about 600,000 of them. Every parse door takes PEM from wherever its caller got it. The scanner now makes one forward pass over the lines, and the same 3200 lines take 1 ms.
- The host a URI constraint is compared against is read by its labels rather than by which characters it holds. A host carrying an empty label, such as `a..b.example`, or a hyphen at a label edge, such as `bad-.example`, was accepted by the comparison while the configuration door refused the same value. Under a permitted subtree of `.b.example` the first of those matched by suffix and was permitted, although no such name exists. Both are now refused at the comparison, and the door and the comparison read one definition, so neither can accept what the other refuses.
- An option a verb was never passed is no longer read from a prototype the caller does not own. A verb reads its options with `opts.name`, which walks the prototype chain, so a name written to `Object.prototype` answered as though it had been supplied: `pki.path.validate` with a planted `historicalMode` treated a revocation as not yet in force and reported a revoked certificate as a valid path, and a planted `softFail` waived an undetermined revocation status. The unknown-option refusal already walked the chain and caught a planted name that is not an option at all, but a name spelled like a real option passed it. Such a name now reads as unset, so the verb behaves as though nobody supplied it, while the caller's own field of that name still wins and the call still runs. A prototype the caller supplied is untouched, which is how a defaults object under an options bag keeps supplying its options. Every verb that takes options is covered, not only the path verbs.
- A transport is asked whether it filters the address it resolved by reading the declaration off the transport itself. `blocksPrivateAddresses` was read through the object, so a value written to `Function.prototype` answered for every function in the process, and a transport that declares nothing was handed a hostname destination taken from a certificate. That reading covered the revocation fetcher and the `pki.path.build` caIssuers fetch alike; both now read the slot. A transport that sets the flag on itself is unaffected.
- Every verb that takes a list in its options copied that list with the list's own slice or map before using it, so a method the caller's array carried, or a replacement installed on Array.prototype by anything sharing the process, decided the contents of the copy the rule was then applied to. pki.cms.verify holding requiredEku: ["clientAuth"] reported a signer asserting emailProtection alone as trusted when the copy came back carrying emailProtection, and the same reach covered the trust anchors beside it. Seventeen copies across eight modules now read the list length once and write each element as an own property: the CMS trust anchors and required key purposes, the CMC body part identifiers, paths and returned certificates, the CMC signer list, the EST responder certificates, the CMP certificate lists, the transport trust anchors, the WebAuthn root certificates, expected origins and allowed algorithms, and the relative distinguished names of a copied trust-store name.

## v0.8.7 — 2026-09-23

A PKCS #11 URI names a token and an object on it, read and written to the RFC 7512 grammar.

### Added

- `pki.pkcs11.parseUri(uri)` reads an RFC 7512 PKCS #11 URI into `{ path, vendorPath, query, vendorQuery }`. The path carries what identifies the object (`token`, `manufacturer`, `serial`, `model`, the three `library-*` attributes, the three `slot-*` attributes, `object`, `type` and `id`) and the query what tells a consumer how to reach it (`pin-source`, `pin-value`, `module-name`, `module-path`). A vendor attribute in either component is kept under its own name. A vendor query attribute carries a list of its values, which sec. 2.4 permits to repeat and leaves to the consumer to interpret; a vendor path attribute carries one value, sec. 2.3 admitting no repeat. Each record is prototype-less, so a computed read answers from the URI and never from `Object.prototype`. `id` is a `Buffer`, because the attribute names bytes rather than text, and `slot-id` and `library-version` are read as numbers.
- `pki.pkcs11.formatUri(record)` writes the record back in canonical form: attributes in the order the RFC lists them, vendor attributes sorted, each value percent-encoded to what its own component admits unencoded, `id` encoded whole with uppercase hexadecimal, `slot-id` without leading zeros and `library-version` stating its minor. That is what lets two URIs naming one object be compared as strings, which sec. 2.6 asks of a consumer, and the form is stable: writing it again changes nothing. One difference it keeps is the order of a repeated vendor query attribute's values, since sec. 2.4 leaves what a repeat means to the consumer and the order may carry part of it. An attribute name and a `type` value are read whichever case they arrive in, RFC 5234 sec. 2.3 making an ABNF quoted literal case-insensitive, and carried in one spelling; so `PIN-SOURCE` is the `pin-source` attribute and is held to the rules `pin-source` is held to, rather than passing as vendor data. The fold is the ASCII one the grammar is written in, not the Unicode one, so a name carrying a character that Unicode folds onto an ASCII letter, such as the Kelvin sign onto `k`, is refused rather than read as the attribute it resembles.

### Changed

- A builder that encodes text now requires text. `pki.asn1.build.utf8`, `.ia5`, `.printable` and `.bmpString` refuse a value that is not a string with `asn1/bad-string`, `pki.cbor.build.textString` refuses one with `cbor/bad-argument`, and a `pki.pkcs12.build` bag whose `friendlyName` is not a string is refused with `pkcs12/bad-input`. Each of those used to turn the value into text first, so an object was encoded as `[object Object]` and a list as its members joined with a comma, and the certificate, message or key store carried that instead of anything the caller named. Pass the string; where the value came from bytes, decode it yourself and pass the result.
- `pki.asn1.encode` takes bytes for its content: a `Buffer`, a typed array, a list of byte values, or nothing for an empty value. A string, an object, a number, and a list carrying an element outside 0 to 255 are refused with `asn1/not-buffer`. Each of those used to be converted, so a string became its UTF-8 encoding, an object went through its `valueOf`, an element past 255 became that element modulo 256, and a number ended the call with a Node argument fault rather than a `PkiError`.

### Security

- Parsing follows the sec. 2.3 and sec. 2.4 grammar rather than a general URI reader, and refuses with its own `pkcs11/*` code rather than guessing: a character the component admits only percent-encoded, a truncated or non-hexadecimal escape, a value that is not valid UTF-8, an empty attribute between two delimiters, an attribute that appears twice where the RFC admits it once, a percent-escape inside `type`, `slot-id` or `library-version`, whose grammar is literal alternatives and digit runs, a `type` outside the five the RFC enumerates, a `library-version` component above 255, each being one byte in the `CK_INFO` structure sec. 2.3 points at, a URI carrying both `pin-source` and `pin-value`, and a `module-path` that is not absolute on any platform this toolkit runs on. The last two are what sec. 2.4 asks a consumer to treat as invalid: one leaves which PIN applies undecided, and the other decides which shared object a process loads from wherever it happens to be running.
- Writing is held to the same rules, so the formatter never emits a URI the parser would refuse. A vendor attribute name is held to the vendor grammar and refused when a standard attribute owns it, which is what keeps a name carrying a delimiter from putting an attribute into the URI that the caller never named and that the standard attribute's own checks never saw. A vendor name given no value, or given an empty list, is refused rather than dropped, so the URI that comes back carries every attribute that was asked for or the call fails. Every component of the record is read once, before anything is written, and a text attribute must be given a string rather than a value turned into one, so nothing a caller passes can run code partway through a write and add an attribute to a component whose checks have already passed.

## v0.8.6 — 2026-09-20

A node:crypto KeyObject signs, without being asked to export its private half.

### Added

- A `node:crypto` `KeyObject` is accepted wherever a signing verb takes a private key, and signs through `node:crypto` rather than being exported to PKCS#8 first. RSA with PKCS#1 v1.5 and with PSS padding, ECDSA on P-256, P-384 and P-521, Ed25519, Ed448, ML-DSA and SLH-DSA all sign this way, and the bytes produced are the bytes WebCrypto would have produced: an ECDSA signature is taken in the fixed-width form of RFC 9053 sec. 2.1 and encoded by the toolkit, so the artifact is byte-identical to one signed by the equivalent `CryptoKey`.
- A `KeyObject` is held to the key the artifact names exactly as a `CryptoKey` is. Its algorithm must match the certificate's key algorithm and, for an EC key, its curve must match the certificate's curve; a public or a secret key is refused by type; and the signature is verified against the declared key before the artifact is returned, so a `KeyObject` for a different key of the same algorithm is refused rather than producing something no relying party can validate.

### Changed

- `pki.x509.sign` and the other signing verbs no longer refuse a `node:crypto` `KeyObject` with the message naming it as not a `CryptoKey`. A caller matching on that message to detect the refusal will stop seeing it, because the key now signs.

## v0.8.5 — 2026-09-20

A key this process cannot export can sign, and every verb that signs proves its signature first.

### Added

- A signer `{ algorithm, publicKey, sign }` is accepted wherever a signing verb takes a private key: `pki.x509.sign`, `pki.crl.sign`, `pki.csr.sign`, `pki.cms.sign` and its countersignatures, `pki.attrcert.sign`, `pki.cmp.build`, `pki.crmf.build`, `pki.ocsp.buildRequest`, `pki.ocsp.sign` and `pki.tsp.sign`. `sign(bytes)` is handed the bytes WebCrypto would be handed and returns the bytes WebCrypto would return, or a promise of them; for ECDSA that is the fixed-width `r || s` of RFC 9053 sec. 2.1, and a DER `SEQUENCE` is refused by name rather than encoded a second time. The private half never reaches the toolkit.
- A signer's declared `algorithm` is held to the algorithm of the key the artifact certifies, and its declared `publicKey` to the key the signature is verified against, both before the callback is asked to sign. The caller's object is read once, in the synchronous prologue, so an accessor cannot answer one way to the check and another to the use. For an ECDSA key, whose curve is named but whose digest is chosen per signature, the digest the signer declares is checked on its own, before any remote signing request. The bytes the callback is handed are a copy in an allocation of its own, so a callback that rewrites them rewrites only its own copy and cannot reach the bytes the artifact carries. The bytes it returns are copied before use. Hold a client or any mutable state in the callback's closure: a signing verb snapshots its options, so the signer object the callback sees is a copy of the one you passed. A composite ML-DSA signature is refused by name, since it is produced from both component private keys and one callback cannot do that.

### Fixed

- `pki.ocsp.sign`, `pki.ocsp.buildRequest` and `pki.cms.sign`'s countersignature path verify the signature they produced against the public key the artifact declares, which the other signing verbs already did. A signer holding a key that does not match the certificate it signs under produced an OCSP response, an OCSP request or a countersignature that no relying party could validate, and the emitting side reported success.

## v0.8.4 — 2026-09-20

A decode cap bounds every structure a parse reads, and a refusal caused by a cap is no longer reported as malformed input.

### Added

- `pki.schema.engine.decodeNested(bytes, ctx, label, opts?)`, the decode to perform on a value already read out of a parse's input. It applies the caps carried on the walk context and records a resource refusal against the parse before re-throwing, which is what a format parser composed on the engine needs so its embedded payloads answer to the caller's caps.
- `pki.schema.engine.withResources(ns, source)` carries a parse's caps and budget onto another namespace, for a format whose structure is carried inside another one. The structure is still walked in its own namespace, so a fault in it keeps that format's error class and code. `pki.schema.engine.isResourceRefusal(err)` reports whether a codec error was a limit rather than a claim about the bytes.

### Changed

- An input refused for exceeding a decode limit reports `<format>/too-large` in cases where it previously reported the format's own malformed-value code, such as `cms/bad-aead-params` or `cmp/bad-body`. A parser that decodes a value out of its input wraps a decode failure in a code of its own, and that code sent a reader to inspect bytes that were fine and a cap that was too small. A caller matching on one of those codes to detect malformed input will stop seeing over-budget inputs under it. The wrapped error is carried on the refusal as its `cause`.

### Fixed

- A decode cap bounds every structure a parse reads, not the input alone. An embedded `OCTET STRING` payload, an encapsulated protocol body, a `SafeContents` inside a PKCS#12 store and an attribute value are all decoded under the caps the caller named. The 0.8.3 notes recorded that a structure decoded from inside the input kept the built-in `pki.C.LIMITS` defaults; it no longer does.
- `pki.schema.cmc.parse` applies the caller's caps to the `PKIData` or `PKIResponse` body. It read the caps, bounded the CMS carrier with them, and then decoded the body that carries the request or response with the built-in defaults.
- A parse cannot return a result after a decode limit has refused something inside that parse, whatever the code between the two does with the failure. A parser that catches a resource refusal and re-throws its own typed error is the common shape, and that shape previously carried the parse past the latch the budget sets. The door now reads the latch on the failure path as well as on the success path.

## v0.8.3 — 2026-09-20

A parser takes decode caps from its caller, and a resource refusal says so instead of reading as malformed input.

### Added

- Every parse function takes an optional second argument of decode caps: `maxBytes`, `maxDepth` and `maxItems`. That includes `pki.schema.parse`, the format-detecting door, where the caps bound both the detection decode and the decode of whichever format matches. Each may tighten below the corresponding `pki.C.LIMITS` default and is refused with `<format>/bad-input` if it names a value above it, so a caller can bound a parse more strictly than the toolkit does and cannot grant itself more resource than the built-in refusal allows. An option outside that set is named in the refusal rather than ignored.
- A new `<format>/too-large` code on each format, reported when a decode limit refuses the input.

### Changed

- An input refused for exceeding a decode limit reports `<format>/too-large` where it previously reported `<format>/bad-der`. The two say different things to whoever reads them: one is answered by raising a cap, the other by fixing the bytes. A caller matching on `bad-der` to detect malformed input will stop seeing oversized and over-nested inputs under that code.

### Fixed

- A parse that exhausts its decode budget cannot report anything but a refusal, even when code inside the parse catches the failure. The budget latches on exhaustion, and each parser door re-reads that latch after its walk and before returning a result, so a `catch` between the two can absorb the exception and still not produce a value. This is the guarantee that makes the caps worth relying on: without it any of the toolkit's error-absorbing paths could turn `over budget` into an ordinary absent value and change a verdict.
- The caps a caller names are read into a fresh object, and no flag is ever written onto the object the caller passed or onto the one the decode receives. A resource option therefore cannot alter which encodings a parse accepts; the strictness of a parse is the same whatever caps are asked for.

### Documentation

- The caps bound the decode of the input a parser is given, which is what bounds a parse overall, since everything a parse reads comes from those bytes. A structure decoded separately from inside that input keeps the built-in `pki.C.LIMITS` defaults rather than a tighter figure the caller named.

## v0.8.2 — 2026-09-20

A distinguished name can be read back from its string form, and twelve attributes the name table published can finally be encoded.

### Added

- `pki.x509.parseDn(dn)` reads a distinguished-name string into `{ rdns, dn, bytes }`, using the attribute names and value escaping of RFC 4514 sections 2.4 and 3, including the `#`-prefixed hex form for a value whose ASN.1 type has no string form. `bytes` is raw `Name` DER, the form `spec.subject` and `spec.issuer` already take, so a name read from text can be signed without rebuilding it. Components are read in the order they are written, which is the order every `dn` here is emitted in and the order `openssl x509 -subject` prints; RFC 4514 section 2.1 specifies the opposite order, so a string from a directory tool that follows it names the components from last to first. Malformed input throws `x509/bad-dn`.
- Twelve distinguished-name attributes are registered: `initials`, `generationQualifier`, `distinguishedNameQualifier`, `pseudonym`, `userId`, `businessCategory`, `postalCode`, `name`, `organizationIdentifier`, and the three CA/Browser Forum jurisdiction attributes. Each already had a short name in `pki.C.NAMES.DN_SHORT`, and `pki.oid.byName` returned undefined for all twelve, so `pki.x509.sign` refused a subject naming any of them.

### Changed

- The `dn` string of a name carrying one of those twelve attributes now labels it by name rather than by dotted OID, so a subject that read `2.5.4.15=Private Organization` reads `businessCategory=Private Organization`. The encoded certificate is unaffected; this is the rendered string, which `pki.inspect` and the `lint` findings also show.

### Fixed

- A string value beginning with U+FEFF keeps it. Reading a `UTF8String` went through a decoder that treats a leading U+FEFF as a byte-order mark and removes it, so the decoded value differed from the bytes that encoded it and two certificates whose subjects differed only by that character produced names that compared equal. X.690 gives U+FEFF no special meaning inside a string, and the same correction applies to the deterministic CBOR reader and the shared text decoder behind the protocol clients.
- A `#`-prefixed attribute value is held to everything the plain string form is held to. It carries the value's own DER, so it previously bypassed the attribute's string type, its alphabet, its size bounds and the non-empty rule; a `countryName` of five characters, a `serialNumber` containing an underscore and an empty `commonName` could each be encoded that way and produced a name whose own emitted string would not parse. The supplied DER must now be the string type the attribute's module entry names, and its content is held to the same checks.
- Four distinguished-name attributes are encoded with the ASN.1 string type their profile gives them, rather than as `UTF8String`: `domainComponent` and `emailAddress` as `IA5String`, and `distinguishedNameQualifier`, `serialNumber`, `countryName` and `jurisdictionCountryName` as `PrintableString`, the last two bounded to the two characters of a country code. A certificate carrying a `DC=` or `SERIALNUMBER=` component was non-conforming, and the alphabet each type restricts a value to was not applied, so a value those types cannot hold was accepted and encoded anyway. The size bounds the same module states are enforced with them: a `serialNumber` of 1 to 64 characters, an `emailAddress` of 1 to 255. Certificates already issued are unaffected; this changes the bytes newly issued ones carry.
- `pki.x509.sign` accepts an attribute value in the two forms a parsed name hands back. A value whose type has no string form comes back as `#` followed by the hex of its DER, and a literal value beginning with `#` or a backslash comes back with a leading backslash; both were encoded as those literal characters instead of being read, so a name taken out of one certificate could not be written into another unchanged.

### Documentation

- Every obsoleted standards citation in README.md, ROADMAP.md and SECURITY.md names its successor beside it, so a reader is not sent to a number that is no longer current for its subject. Among them: RFC 4210 and RFC 9480 for CMP, now RFC 9810 and RFC 9811; RFC 5272, RFC 5273, RFC 5274 and RFC 6402 for CMC, now RFC 10002, RFC 10003 and RFC 10004; RFC 5019 for the lightweight OCSP profile, now RFC 9919; RFC 9579 for PBMAC1, now RFC 9879; RFC 8398 and RFC 8399 for internationalized names, now RFC 9598 and RFC 9549; RFC 2875 for Diffie-Hellman proof of possession, now RFC 6955; and RFC 8446 for TLS 1.3, now RFC 9846.
- The roadmap's vulnerability table gains the CVE-2020-0601 row for the class refused at key import since 0.8.0, and states where the named-curve identifiers come from: RFC 5480 section 2.1.1.1 and RFC 5639 section 4.1, rather than the TLS cipher-suite document previously cited.

## v0.8.1 — 2026-09-19

CMS gains its last RFC 5652 content type, and the OID registry names every curve a certificate can declare.

### Added

- `pki.cms.digest(content, opts)` builds an RFC 5652 DigestedData under SHA-256, SHA-384 or SHA-512, attached or detached, and returns DER or PEM. `pki.cms.verifyDigest(message, opts)` recomputes the digest and compares it in constant time, returning `{ valid, digestAlgorithm, content, contentType }` with `code` set to `cms/digest-mismatch` when the content does not match. The verdict carries no signer and no trusted field: a DigestedData establishes that content matches a digest and nothing about who produced either, so its worth is the worth of the channel the two traveled over. Use `pki.cms.sign` to establish origin, or `pki.cms.authenticate` to establish it under a shared key.
- `pki.schema.cms.parse` decodes a DigestedData ContentInfo instead of reporting it unparsed, and enforces the RFC 5652 section 7 version rule across fields: version 0 when the encapsulated content type is id-data, version 2 otherwise. A version that disagrees with the content type is `cms/bad-version`.
- The OID registry names thirty-three elliptic curves, covering RFC 5480 section 2.1.1.1, RFC 5639 section 4.1 and secp256k1. `pki.oid.name` and `pki.oid.byName` resolve each in both directions, under the name `node:crypto` knows the curve by.

### Changed

- A `lint/cabf-tls/weak-key` finding raised against a non-approved elliptic curve names that curve in its context; the field was null for every curve outside the approved three. `pki.inspect.certificate` prints an `ASN1 OID:` line for those curves as well. Neither changes what the crypto engine accepts: importing a key on a curve the engine does not carry still fails with `webcrypto/not-supported`.

### Fixed

- A digest AlgorithmIdentifier carrying parameters that are neither absent nor DER NULL is refused with `cms/unsupported-algorithm` on the two paths that read one without checking it: the `digestAlgorithm` of a DigestedData, and the `digestAlgorithm` beside the `macAlgorithm` of an AuthenticatedData, which `pki.cms.decrypt` reads to check the message-digest attribute. RFC 5754 section 2 allows only those two forms, so a parameter value that is neither does not name the algorithm the message claims; both paths would otherwise have reported the content verified or authenticated under it. The `macAlgorithm` beside it, and every SignerInfo digest, were already held to the rule.

### Documentation

- `pki.path.validate` documents `opts.verifier`, which replaces the built-in signature check with `verifier.verify({ cert, workingPublicKey, workingPublicKeyAlgorithm, workingPublicKeyParameters })`. It is how a chain on a curve the engine does not carry, such as a brainpool one, is validated. It replaces the signature step alone: the RSA key-strength floor and every other RFC 5280 check still apply.
- `pki.hpke` is described as carrying the three ML-KEM suites alongside the four DHKEM suites, which is what shipped.
- The reference site at pkijs.com renders all 256 documented primitives; 46 of them reached no page. A page is identified by its URL, and a page built from two source files kept only one of them, so `pki.x509.sign`, the seven `pki.smime` verbs, `pki.tsp.sign` and every `pki.schema.*` parser were documented in the source and absent from the site. The symbol search and the API index resolve a primitive the same way the page does, so a search result and the section it links to agree.
- Roadmap rows that claimed capability the code does not have are corrected. RFC 6961 multi-stapling, RFC 6979 deterministic (EC)DSA and the RFC 5915 `ECPrivateKey` structure are not implemented. A distinguished name is emitted as an RFC 4514 string and is not read back from one. AES-CCM content encryption parses but is not produced. The OpenSSL interop statement names the seven algorithm arms it covers rather than claiming all of them. No registry rows are reserved for FN-DSA or HQC. The CMC rows record that RFC 5272, RFC 5273 and RFC 5274 were obsoleted by RFC 10002, RFC 10003 and RFC 10004 in July 2026, which the shipped surface predates. The CVE-2022-0778 row states that a named curve is required when a key is imported to verify with, not when a certificate is parsed, since parsing stays deliberately permissive so a non-conforming certificate can be inspected and linted.

## v0.8.0 — 2026-09-19

Verification is held to the rules signing already applied.

### Added

- `pki.C.LIMITS.OID_MAX_SUBIDENTIFIERS` (128) bounds how many sub-identifiers an OBJECT IDENTIFIER may carry, alongside the existing per-sub-identifier octet bound.
- `pki.C.LIMITS.RSA_MIN_MODULUS_BITS` (2048) and `pki.path.validate` option `minRsaModulusBits`, which may only raise the floor.
- `allowWeakDigests` on `pki.cms.verify`, `pki.smime.verify` and `pki.tsp.verify`, for verifying an archived signature made under a digest that is no longer collision resistant.

### Changed

- `pki.cms.verify` reports a SignerInfo whose message digest is SHA-1 as `cms/weak-digest` instead of verifying it, and does the same for a countersignature at any nesting depth. `pki.smime.verify` and `pki.tsp.verify` inherit this through the same signature check. Pass `allowWeakDigests: true` to verify an archived signature under it. A message digest of MD5, MD4 or MD2 is reported `cms/unsupported-algorithm` and the opt-in does not apply, there being no verification path for those digests. The RFC 3161 message imprint is unaffected: that digest is the requester's choice and an archived timestamp under SHA-1 remains readable.
- `pki.path.validate` refuses an RSA issuer key below 2048 bits with `path/weak-key`. Raise the floor with `minRsaModulusBits`; it cannot be lowered.
- A key the crypto engine cannot import now reports `path/unsupported-algorithm` instead of `path/bad-signature`, so a certificate on a curve the engine does not carry no longer reads as forged.
- `pki.webcrypto.subtle.importKey("spki", ...)` refuses an ECDSA or ECDH key whose parameters are explicit domain parameters or the implicitlyCA form, as RFC 5480 section 2.1.1 requires a namedCurve. Certificate parsing still accepts such a key so it can be inspected and linted.
- `lint/rfc5280/ski-missing` and `lint/rfc5280/aki-missing` are graded `error` rather than `notice`, matching the RFC 5280 section 4.2.1.1 and 4.2.1.2 MUST clauses and the grade the CRL profile already gives the same absence. `lint/rfc5280/ski-missing-ee` is graded `warn`, its clause being a SHOULD. A pipeline gating on `--severity error` will see findings it did not see before.
- A `PkiError` whose reason is `timeout` carries `permanent: false`, so a caller can decide to retry from the error itself rather than from a code list of its own. The reason is read without its domain, so a timeout raised by a protocol client under its own prefix (`acme/timeout`, `est/timeout`) carries the flag too. Every other code stays `permanent: true`, including a proxy that refuses CONNECT with a definite status.

### Fixed

- Reading an OBJECT IDENTIFIER with more than 128 sub-identifiers throws `oid/too-many-subidentifiers` rather than expanding it. Each sub-identifier costs a bignum and a decimal string, so a 400 KB certificate carrying one such OID cost 55 MB of memory to parse and the ceiling scaled with the 16 MiB input bound. Every format parser reads OIDs through the bounded reader. Encoding is held to the same count, so a value the reader refuses cannot be produced. The generic `pki.asn1.decode` still returns such a value as an opaque primitive node, which allocates nothing beyond the input.
- `pki.cms.sign` and `pki.cms.countersign` over more than 16 MiB of buffered content threw an untyped `RangeError` instead of a `PkiError`.
- A CRL extension is decoded only in the scope RFC 5280 defines it for: `cRLNumber` at CRL scope (section 5.2.3), `reasonCode` and `invalidityDate` in a revoked-certificate entry (sections 5.3.1 and 5.3.2). An extension carried in the other scope is left with its raw value, as an unrecognized non-critical extension should be, instead of failing the whole CRL when its value does not parse as that type.
- The `pki.cms.verify` reference example built a signature with `detached` in the signer descriptor, where it is ignored; the example produced an attached SignedData while describing a detached one.

### Documentation

- SECURITY.md states that the toolkit matches no reference identity: name constraints reach the name forms a certificate carries plus the `emailAddress` synthesis RFC 5280 section 4.2.1.10 requires, and a `dNSName` constraint does not reach a subject `commonName`. RFC 9525 section 1.3 retires `commonName` as a source of hostname identity.

## v0.7.45 — 2026-09-13

A signature-protected CMP message fills an omitted sender with the signer's subject.

### Changed

- pki.cmp.build fills a header sender the caller omits under signature protection with the signer certificate's subject (RFC 9483 sec. 3.1), so a message built without one is receivable rather than refused. A sender the caller states is left as given, matching the signer or not. A signer certificate with an empty subject names itself by a subjectAltName entry, which the builder cannot choose for the caller, so an omitted sender there is still refused with cmp/bad-input. MAC-protected messages carry no signer and are unchanged.

## v0.7.44 — 2026-09-13

A JWS whose embedded jwk carries a private key is refused on verify.

### Changed

- pki.jose.verify refuses, with jose/private-key-material, a JWS whose protected header embeds a jwk carrying any private member (d, the RSA CRT parameters p, q, dp, dq, qi and the multi-prime oth, the symmetric k, or the AKP priv), before the signature is checked, so a malformed message carrying a private key in its header is never imported or returned in the verdict. A public-only embedded jwk is unaffected, and a message signed under the matching public key is refused the same way (the private embed is the fault).

## v0.7.43 — 2026-09-12

A CMP message carries the transaction fields a receiver requires.

### Changed

- pki.cmp.build fills a header transactionID or senderNonce the caller omits with 16 fresh random bytes, under every protection flavor, so a message built without them verifies on receipt; a KEM-protected message given no transactionID binds its derivation to the filled one. A senderNonce given under 128 bits is refused with cmp/bad-input. Explicit values travel verbatim, and a recipNonce is never filled.

### Fixed

- pki.scep.build refuses a transactionId carrying a character outside the PrintableString set (RFC 8894 sec. 3.2.1.1) with scep/bad-input naming the rule, where the codec's own asn1/bad-printable-string surfaced.

## v0.7.42 — 2026-09-12

An S/MIME message is signed only under a certificate a receiving agent accepts a mail signature from.

### Changed

- pki.smime.sign refuses, with smime/bad-signer-certificate, a signer certificate whose keyUsage extension asserts neither digitalSignature nor nonRepudiation (RFC 8550 sec. 4.4.2) or whose extendedKeyUsage extension carries neither emailProtection nor anyExtendedKeyUsage (sec. 4.4.4), naming the signer by position. A certificate that carries neither extension signs, as a reader presumes both key usage bits and any purpose for it. The rule is about the certificate the message carries: a key-only signer, which RFC 8550 sec. 3 permits where correspondents hold each other's certificates by other means, still signs, and the reader holds the certificate it supplies to the same rule. The signer descriptors are read once, so a certificate accessor answers the same certificate to this check and to the signing, and a sparse signer list is refused before it is traversed.

### Fixed

- The emailProtection requirement pki.smime.verify applies to a trusted signer is cited to RFC 8550 sec. 4.4.4, the certificate-handling specification, in the README and the reference; it was cited to RFC 8551.

## v0.7.41 — 2026-09-12

A delivered or archived private key is proven the private half of its certificate with the key itself.

### Fixed

- pki.est.serverkeygen proves a cleartext server-generated private key is the private half of the returned certificate it binds it to (a signature, an agreement, or an encapsulation the key completes), and refuses with est/key-cert-mismatch a key whose stated public half matches the certificate while its private material cannot use it. The selection of the certificate by public-key match and the ambiguity refusal are unchanged, and a finite-field Diffie-Hellman key, whose private structure carries the exponent alone, is still bound by the value it derives, at any width the runtime reads.
- pki.crmf.build proves the key enclosed in an encryptedKey proof of possession is the private half of certTemplate.publicKey with the key itself, and refuses with crmf/bad-popo an RSA, EC, EdDSA, ML-DSA, ML-KEM or composite key that only states the template's public components; a key the toolkit cannot read stays crmf/bad-input. A finite-field Diffie-Hellman key, whose private structure carries the exponent alone, keeps the derived comparison at any width, so the archival path still accepts a key wider than any group the toolkit agrees over.

## v0.7.40 — 2026-09-12

A PKCS#12 store links a certificate only to its own key.

### Changed

- pki.pkcs12.build refuses, with pkcs12/bad-input, a { key, cert } pair whose key is not the private half of the certificate's public key, and a key bag and a certificate bag sharing a localKeyId (PKCS #9 sec. 5.5.2) whose key is not that certificate's, across every safe and nested safe of the store. The pair is proven with the private material (a signature, an agreement, or for a composite ML-KEM key an encapsulation to the certificate's key decapsulated under the private key), never by the public components a private key carries about itself. A localKeyId names one private key, so a store carrying one localKeyId on two key bags is refused with pkcs12/bad-input; several certificate bags under one key's localKeyId are each held to that key, at most 1024 across the store. A store of more than 1024 safes is refused before any safe is built. Bags with different or absent localKeyIds are not a pair and are unaffected; a key-only or certificate-only store is unaffected.
- The toolkit proves a finite-field Diffie-Hellman key pair by the public value the exponent generates (a DH PrivateKeyInfo carries the exponent alone), in either encoding of the key: the PKCS #3 form a generated key is written in and the X9.42 form a certificate names it in (RFC 3279 sec. 2.3.3); a key in either encoding is held to the largest group the toolkit agrees over (4096 bits) and to a group whose modulus and stated order are prime, so a malformed DH certificate is refused rather than paired. Proving a DH pair costs those primality proofs, so a store links at most 8 finite-field DH pairs. So pki.pkcs12.build stores a DH key with its certificate and a CMP session confirms a centrally generated DH key the issued certificate certifies; a DH key under another key's certificate is refused as before.

## v0.7.39 — 2026-09-12

An OCSP CertID's issuer hashes are held to the length of the digest they name.

### Changed

- pki.schema.ocsp.parseRequest and parseResponse refuse a CertID whose issuerNameHash or issuerKeyHash is not the length of the digest its hashAlgorithm names (ocsp/bad-cert-id, RFC 6960 sec. 4.1.1), for SHA-1, SHA-256, SHA-384, SHA-512, SHA3-256 and SHA3-512; a CertID under a digest this build does not know is read as given. pki.ocsp.verify and pki.ocsp.verifyRequest, which parse first, report such a request or response as malformed instead of comparing a hash that could never match.
- The digest output lengths live in pki.constants.NAMES.DIGEST_OCTETS, read by the OCSP parser and by pki.tsp.sign and pki.tsp.verify for the messageImprint length check they already made.

## v0.7.38 — 2026-09-12

pki.lint.certificate grades six RFC 5280 rules its parser read past, and the parser surfaces the unique identifiers.

### Added

- pki.lint.certificate rules: lint/rfc5280/policy-mapping-any-policy (error, sec. 4.2.1.5: policies MUST NOT be mapped to or from anyPolicy), lint/rfc5280/unique-identifier-present (error, sec. 4.1.2.8: a conforming CA MUST NOT generate a certificate with a unique identifier), lint/rfc5280/san-critical-with-subject (warn, sec. 4.2.1.6: a subjectAltName beside a non-empty subject SHOULD be non-critical), lint/rfc5280/eku-critical-with-any-purpose (warn, sec. 4.2.1.12: an extKeyUsage naming anyExtendedKeyUsage SHOULD NOT be critical), lint/rfc5280/notice-ref-used (warn, sec. 4.2.1.4: noticeRef SHOULD NOT be used), and lint/rfc5280/recommended-criticality (warn, sec. 4.2.1.7 and 4.2.1.13: issuerAltName and cRLDistributionPoints SHOULD be non-critical), each naming its clause in the finding.
- pki.schema.x509.parse returns issuerUniqueID and subjectUniqueID, each { unusedBits, bytes } or null, so a relying party sees the fields RFC 5280 sec. 4.1.2.8 tells a CA not to generate.

### Changed

- pki.x509.sign refuses a pre-encoded policyMappings extension that maps to or from anyPolicy, the rule its object form and pki.path.validate already applied (RFC 5280 sec. 4.2.1.5).

## v0.7.37 — 2026-09-12

Every object a signer reads refuses a field it does not read, a signed OCSP request names its signer, and the requester nonce meets the RFC 9654 floor.

### Changed

- pki.ocsp.sign refuses an unknown field in responseData (accepted: responderID, responses, producedAt), in each response entry (cert, issuer, certID, hashAlgorithm, status, thisUpdate, nextUpdate, singleExtensions), in the responder ({ cert, key }) and in a revoked status ({ revoked, revocationReason }); pki.ocsp.buildRequest refuses one in a query entry ({ cert, issuer, singleRequestExtensions }) and in the signer ({ cert, key }). A signing option such as extendedRevoke given in responseData was silently ignored before; it is now refused with the message naming where it belongs.
- pki.attrcert.sign refuses an unknown option (accepted: pem, pss, digestAlgorithm) and an unknown field in a role, clearance, securityCategory, group / chargingIdentity (IetfAttrSyntax) and its values, authenticationInfo / accessIdentity, target, aaControls, objectDigestInfo or baseCertificateID object. The CRL distribution points extension key is spelled cRLDistributionPoints, as pki.x509.sign spells it; the spelling crlDistributionPoints is refused with a message naming the key.
- pki.csr.sign refuses an unknown field beside key in the { key } wrapper; pki.ct.signSct refuses an unknown option (accepted: timestamp, extensions, logId), and a log entry carrying a field its arm does not read (a leafCert on a precert_entry, an unknown name on either) is refused by signSct and verifySct alike; a CMS signed or unsigned attribute entry is { type, values } and nothing else.
- pki.ocsp.buildRequest with a signer takes the signer certificate's subject as the requestorName when none is stated (RFC 6960 sec. 4.1.2: a requestor that signs SHALL specify its name), and pki.ocsp.verifyRequest holds the requestorName of a signed request to the subject of a certificate that verified the signature, compared as a distinguished name; the verdict reports it as requestorNamed and valid requires it. A requestorName given as a GeneralName form other than directoryName is not compared and never passes. The parsed request carries the requestorName's decoded value.
- pki.ocsp.buildRequest refuses a caller nonce shorter than 32 octets (RFC 9654 sec. 2.1: an OCSP requester MUST use a minimum length of 32 octets); the responder side keeps the section's 1..128 octet bounds for the nonce it echoes.

## v0.7.36 — 2026-09-12

The attribute-certificate signer holds its extensions and attributes to the RFC 5755 issuer rules, and emits the authority key identifier by default.

### Added

- pki.attrcert.sign takes crlDistributionPoints and authorityInfoAccess in the list forms pki.x509.sign takes, each emitted non-critical as RFC 5755 sec. 4.3.4 and 4.3.5 require, so the sec. 6 "pointer in AC" revocation scheme needs no pre-encoded Extension DER.
- pki.schema.attrcert.parse decodes an attribute certificate's authorityKeyIdentifier, authorityInfoAccess and cRLDistributionPoints through the RFC 5280 readers, so a relying party running the "pointer in AC" scheme reads the distribution point and the OCSP location off the parse instead of off raw bytes.

### Changed

- pki.attrcert.sign emits the authorityKeyIdentifier without being named (RFC 5755 sec. 4.3.3: "this extension SHOULD be included in ACs"). Its keyIdentifier is the AA certificate's subjectKeyIdentifier when issuer.cert is given, else the RFC 5280 method (1) value of the AA key; a stated keyIdentifier that differs from the AA certificate's is refused; authorityKeyIdentifier: false omits it. The pre-encoded array form of extensions emits exactly what it is given.
- pki.attrcert.sign refuses, with attrcert/bad-input and on both extension forms, what RFC 5755 forbids an issuer to produce: an auditIdentity that is not 1 to 20 octets (sec. 4.3.1); a targetInformation carrying more than one Targets element or a targetCert (sec. 4.3.2); an id-ad-ocsp accessLocation that is not a URI carrying an HTTP URL (sec. 4.3.4); a crlDistributionPoints that does not name exactly one distribution point as a fullName holding a single directoryName or an HTTP or LDAP URL, or whose cRLIssuer does not name the CRL issuer as a directoryName (sec. 4.3.5, RFC 5280 sec. 4.2.1.13); and a role whose roleName is not a uniformResourceIdentifier (sec. 4.4.5), on the object form and the pre-encoded Attribute form alike.

## v0.7.35 — 2026-09-12

The timestamp signer refuses a TSA certificate its verifier would refuse, and keeps genTime's fraction of a second.

### Changed

- pki.tsp.sign refuses a TSA certificate that does not satisfy RFC 3161 sec. 2.3: exactly one extendedKeyUsage extension, marked critical, naming id-kp-timeStamping alone, and a keyUsage that, when present, allows signing. The refusal carries the code pki.tsp.verify answers for the same certificate (tsp/bad-eku, tsp/eku-not-critical, tsp/eku-not-exclusive, tsp/bad-key-usage), so a TSA operator learns at signing time what a relying party would say.
- genTime keeps the Date's milliseconds as the sec. 2.4.2 fraction of a second, in the X.690 DER form (a point followed by the digits, trailing zeros omitted, no fraction for a whole second), which pki.schema.tsp.parseToken already read; a whole-second genTime encodes as before. pki.asn1.build.generalizedTime takes { fractional: true } for this form.
- accuracy given as an object naming none of seconds, millis, micros is refused with tsp/bad-input: sec. 2.4.2 reads an absent field as zero, so an empty Accuracy asserted a zero deviation; omit the option to make no claim.

### Fixed

- pki.asn1.build.generalizedTime, pki.asn1.build.utcTime and pki.asn1.read.time read a Date's fields through the Date.prototype accessors captured when the module loaded. A getter overridden on the Date instance, or a Date.prototype method replaced after load, no longer changes the encoded time or the Date the reader returns.

## v0.7.34 — 2026-09-12

The OCSP signer refuses what its own lint grades an error.

### Changed

- A revoked response for a non-issued certificate (revocationReason certificateHold at 1970-01-01T00:00:00Z, RFC 6960 sec. 2.2, judged on the time as it is encoded, so any instant inside that first second counts) carries the extended revoked definition in responseExtensions whether or not opts.extendedRevoke names it, since sec. 4.4.8 requires it there; extendedRevoke: false on such a response is refused, and CRL references or any CRL entry extension on that SingleResponse, in the object form or pre-encoded, are refused as sec. 2.2 forbids them.
- A pre-encoded singleExtension is held to where RFC 6960 places it after its value is read: the request-only extensions (acceptable responses sec. 4.4.3, service locator sec. 4.4.6, preferred signature algorithms sec. 4.4.7) and the two a response carries in responseExtensions alone (the extended revoked definition sec. 4.4.8, the RFC 9654 nonce) are refused in singleExtensions, and a CRL entry extension carried there (sec. 4.4.5) must have the criticality RFC 5280 sec. 5.3 fixes for it (reasonCode and invalidityDate non-critical, certificateIssuer critical), must read with the CRL parser's own reader, and may not carry reasonCode removeFromCRL(8), which RFC 5280 sec. 5.3.1 keeps to a delta CRL. A private critical singleExtension is still signed; RFC 6960 places the rejection of an unrecognized critical extension on the relying party, and pki.lint.ocsp grades it.
- The CRL entry-extension criticality table and the OCSP extension-placement table now live in the CRL and OCSP schema modules, and the CRL signer, the OCSP signer and the linter read them from there.

## v0.7.33 — 2026-09-12

pki.x509.extension encodes one certificate extension from its plain value form.

### Added

- pki.x509.extension(name, value, opts) returns one Extension as DER. name is any extension name spec.extensions takes, or a dotted OID; value is the plain form spec.extensions takes for that name (the key-usage and purpose lists, the basicConstraints object, GeneralName lists, the policy, constraint, access and distribution-point objects, the qualified-certificate statements, the Active Directory enrollment values, true for the NULL-valued markers, and the explicit forms of the two key identifiers). For an unregistered dotted OID, value is the already-encoded extnValue as bytes and opts.critical is taken as stated. The criticality of a registered extension follows what pki.x509.sign emits for the object form: where RFC 5280 fixes it or the toolkit issues one form only, opts.critical may only restate that value and is otherwise refused with x509/bad-input; keyUsage, extendedKeyUsage, certificatePolicies, policyMappings, qcStatements and subjectAltName take opts.critical, and basicConstraints carries critical inside its value. subjectKeyIdentifier: true and authorityKeyIdentifier: true, which derive a value from the subject key or the issuer, are refused here and the message names spec.extensions; the authorityKeyIdentifier object form requires the key id bytes. Every registered extension encodes byte-identically through this verb and through spec.extensions. An unregistered OID's extnValue must hold exactly one DER-encoded value (RFC 5280 sec. 4.1), and a dotted string that is not a canonical OID (a first arc above 2, a second arc above 39 under arcs 0 and 1, a leading-zero arc) is refused as x509/bad-input rather than surfacing the OID codec's own error.

### Changed

- The pre-encoded array form of extensions on the certificate, CSR, CRL, attribute-certificate and timestamp signers holds an extension no decoder answers for to the same rule: its extnValue must be exactly one DER-encoded value, or the signer refuses it with its own bad-input code. An extension the signer decodes keeps the decoder's typed refusal.

## v0.7.32 — 2026-09-12

The certificate and CRL signers issue the RFC 5280 key identifiers without being asked.

### Changed

- pki.x509.sign emits a subjectKeyIdentifier derived from the subject key by method (1) unless the extensions object names one, and an authorityKeyIdentifier whose keyIdentifier is the issuer certificate's SKI (or, without one, the issuer key's method (1) value) unless the certificate is self-signed, which RFC 5280 sec. 4.2.1.1 defines by the signature: the subject's own key signs it and the names agree. A spec with no extensions, or with an empty extensions object, therefore issues a v3 certificate carrying those two extensions where it issued a v1 certificate before. subjectKeyIdentifier: false omits the SKI on an end entity (sec. 4.2.1.2 states a SHOULD there) and is refused with x509/bad-input on a CA (a MUST); authorityKeyIdentifier: false is honored on a self-signed certificate (the section's one exception) and refused with x509/bad-input on any other. The pre-encoded array form of extensions is unchanged and emits exactly what it is given, so extensions: [] remains the way to a v1 certificate.
- The authorityKeyIdentifier object form carries the keyIdentifier whether or not it is named: RFC 5280 sec. 4.2.1.1 requires the field, so { authorityCertIssuer, authorityCertSerialNumber } alone now yields all three fields, and keyIdentifier: false is honored on a self-signed certificate only. A keyIdentifier stated as bytes under an issuer given as a certificate that carries a subjectKeyIdentifier must equal that SKI (sec. 4.2.1.2) or the call is refused with x509/bad-input; under a name-and-key issuer it is taken as given.
- pki.crl.sign emits an authorityKeyIdentifier on every CRL unless the extensions object names one, with the same keyIdentifier derivation; RFC 5280 sec. 5.2.1 places it on every CRL a conforming issuer signs with no exception, so authorityKeyIdentifier: false is refused with crl/bad-input, and a keyIdentifier stated as bytes under an issuer certificate that carries a subjectKeyIdentifier must equal it. A CRL spec with no extensions object is v2 with that extension; the pre-encoded array form is unchanged.

### Fixed

- Comparing two public keys of different algorithms (an EC key against an Ed25519 or ML-DSA key) no longer leaves a fault queued in the crypto library. node:crypto's KeyObject equality answers false for such a pair but queues an OpenSSL error that made the next private-key import in the process fail with ERR_OSSL_EVP_DIFFERENT_KEY_TYPES. The key-equality helper behind the CMP session's key-rollover checks, the CRMF template check, the CMC challenge-opening check and the certificate signer's self-signed test now decides the different-type case by the key type and never asks the runtime to compare across types.

## v0.7.31 — 2026-09-12

HPKE encrypts to an ML-KEM public key.

### Added

- pki.hpke.suites.KEM.ML_KEM_512, ML_KEM_768 and ML_KEM_1024 (0x0040, 0x0041, 0x0042). The HPKE binding is FIPS 203 encapsulation and decapsulation with the 32-byte ML-KEM shared secret as the HPKE shared secret and the 64-byte seed as the private key. setupS and seal take the recipient's encapsulation key as raw bytes, as { pkm }, or as a node:crypto KeyObject; setupR and open take { skm } holding the seed, with pkm optional, or a KeyObject. The base and psk modes and every KDF and AEAD apply; the auth and auth-psk modes, which the ML-KEM KEMs do not define (draft-ietf-hpke-pq sec. 7.2), are refused with hpke/auth-unsupported at both ends. An encapsulation key that fails the FIPS 203 sec. 7.2 check (length or a coefficient at or above q) is refused at import, and an encapsulated key of the wrong length is refused before decapsulation, each as hpke/bad-key. The recipient side of the draft's A.1, A.2 and A.3 vectors (ML-KEM-512 and -768 with HKDF-SHA256, ML-KEM-1024 with HKDF-SHA384) is a full known answer: the vector's encapsulated key decapsulated under its seed, all ten decryptions and all five exports. The sender side is proven by round trip: node:crypto takes no encapsulation randomness, so a fixed ephemeral (opts.eph) is refused on an ML-KEM suite with hpke/bad-input rather than ignored.
- pki.hpke.suites.KDF.HKDF_SHA384 (0x0002), for any KEM. The draft-ietf-hpke-pq A.3 vector proves it in the key schedule. DHKEM(P-384) stays unoffered: no known-answer vector pairs it with an HKDF key schedule.

### Changed

- A serialized HPKE private key, { skm, pkm } on setupR, open, opts.eph and opts.senderKey, passes one door for every suite. skm must be a Buffer of exactly the suite's private-key length, an EC scalar additionally in [1, n-1]; the public key is derived from it; and pkm, now optional, must equal that derivation when given. Before this a DHKEM recipient given { skm } alone escaped as a raw TypeError, and a pkm of the wrong length or of another key was fed into the KEM context unchecked, producing a context that could open nothing. A node:crypto KeyObject is checked against the suite's curve or ML-KEM parameter set, and a public KeyObject where a private one is due is refused rather than tried; each refusal is hpke/bad-key naming what was checked.
- opts.senderKey on setupS and opts.senderPublicKey on setupR are refused with hpke/bad-input in the base and psk modes, where they were previously left unused: a caller who supplied one without mode auth or auth-psk was not authenticating the exchange. A value of undefined or null is still absent. opts.eph is { skm, pkm } or absent (undefined or null); another value, such as 0 or an empty string, is refused with hpke/bad-input instead of being read as absent.
- pki.hpke.setupS and setupR read each option once, at entry, into a snapshot. mode was read at the default and again at the use, so an option supplied through an accessor could answer auth to the first read and base to the second and get an unauthenticated setup; senderKey was read at its presence check and again at import.
- The HKDF expand step wipes every block it computed when a later block's HMAC fails, as it already did on the succeeding path. A private KeyObject handed where a public key is due (the recipient key on setupS and seal, senderPublicKey on setupR) now has its public half taken before export, so no JWK carrying the private scalar is produced.
- The fuzz target for the HPKE recipient path decapsulates under an ML-KEM-768 seed as well as an X25519 key, and selects HKDF-SHA384 among the KDFs.

## v0.7.30 — 2026-09-12

The Sigstore bundle verifier decides with operations bound when the module loads.

### Changed

- pki.sigstore.verifyBundle, parseBundle and pae read Buffer.prototype.equals, Buffer.prototype.toString, Buffer.prototype.subarray and Buffer.prototype.indexOf, Buffer.from and Buffer.concat, String.prototype.split, slice, indexOf, charAt and charCodeAt, Array.prototype.map, forEach, filter and slice, Object.assign, Object.create and Object.freeze, String, Number, BigInt, Date, Date.parse, Number.isNaN and JSON.stringify as bound at module load. Replacing Buffer.prototype.equals, String.prototype.split or indexOf, Array.prototype.filter or forEach, or Date.parse after load changes nothing this module decides: a valid bundle still verifies and a Rekor entry whose signature is not the bundle's is still refused. A replacement of Buffer.prototype.toString or subarray, Array.prototype.map, BigInt, Buffer.from or Buffer.concat still reaches the certificate parser and the chain builder's neighbors, which read those live; that residual is theirs and is stated here rather than implied covered.
- SECURITY.md names the key import and export verbs and the Sigstore bundle verifier among the surfaces that take what they decide with at load.

## v0.7.29 — 2026-09-12

Every decision pki.key makes is taken with operations bound when the module loads.

### Changed

- pki.key.import, pki.key.generate, pki.key.decrypt, pki.key.encrypt, pki.key.export, pki.key.publicFromPrivate and the key-pair correspondence check read Array.isArray, Math.floor, isFinite, String, String.prototype.toUpperCase and String.prototype.indexOf, Buffer.from, Object.assign, Object.create and Object.prototype.hasOwnProperty as bound at module load. Replacing any of them afterwards changes nothing the module decides: a non-array usages is still refused, a fractional or infinite maxIterations is still refused, an Ed25519 import still receives signing usages, and an unrestricted RSASSA-PSS pair still corresponds when an inherited saltLength is planted on Object.prototype. Behavior on an untouched runtime is unchanged.

## v0.7.28 — 2026-09-12

pki.cmp.verify names a MAC algorithm it does not compute as what it is, not as a failed signature.

### Added

- pki.oid resolves the RFC 9481 section 6.2 identifiers aes128-GMAC, aes192-GMAC, aes256-GMAC, kmacWithSHAKE128 and kmacWithSHAKE256.

### Changed

- pki.cmp.verify classifies the protectionAlg before checking it. A protectionAlg naming id-PasswordBasedMac, id-DHBasedMac, or one of the bare symmetric MACs of RFC 9481 section 6.2 (hmacWithSHA1 through hmacWithSHA512, aes128/192/256-GMAC, KMACWithSHAKE128/256) returns { valid: false, protectionType: "mac", code: "cmp/unsupported-algorithm" } with a reason naming the algorithm and its section, whatever credential the caller supplied. A symmetric MAC applied without a key derivation keys the shared secret directly, with no work factor, so one captured message is an offline oracle against a low-entropy secret; PBMAC1 exists to close that, and this verifier computes PBMAC1, KEM-based MAC and signature protection only. An identifier no table knows still reaches the signature path, which section 5.1.3.3 leaves open-ended, and still fails closed there.
- The message pki.cmp.build raises for a mac.algorithm other than pbmac1 states the profile decision (RFC 9481 section 6.1.2) rather than calling the alternative deferred.

## v0.7.27 — 2026-09-12

pki lint detects a certificate, a CRL or an OCSP response and runs the matching profile.

### Added

- pki lint <file> detects whether the file holds a certificate, a CRL or an OCSP response, as DER or PEM, and lints it with pki.lint.certificate, pki.lint.crl or pki.lint.ocsp. --profile, --severity and --json apply to whichever verb runs, and a profile that belongs to another structure is refused with the message naming the verb that takes it. For a file holding a structure no profile covers (a certificate request, a CMS message, a key), the command reports the name the detector gives it and exits instead of linting it as a certificate.
- lint/rfc5280/extension-criticality (error) reports a certificate extension marked critical that RFC 5280 requires a conforming CA to mark non-critical: authorityKeyIdentifier (section 4.2.1.1), subjectKeyIdentifier (4.2.1.2), subjectDirectoryAttributes (4.2.1.8), freshestCRL (4.2.1.15), authorityInfoAccess (4.2.2.1) and subjectInfoAccess (4.2.2.2). Each finding names the extension and its clause.

### Changed

- A certificate marking one of those six extensions critical was reported as lint/rfc5280/unknown-critical-extension, which told an operator the extension was not recognized when the defect was its criticality. The profile recognizes them, so that finding no longer fires for them; the new row does, at the same severity, so a report filtered at error is unchanged in its verdict and changed in its name.
- The table of extensions whose criticality RFC 5280 and RFC 6962 fix now lives once, in the shared PKIX schema module, and both pki.x509.sign (which refuses to write against it) and pki.lint.certificate (which reports against it) read it from there.

## v0.7.26 — 2026-09-12

An OCSP response can be linted against RFC 6960, and against the RFC 5019 lightweight profile on request.

### Added

- pki.lint.ocsp(pem | der | parsed, opts) lints an OCSP response under the rfc6960 profile: a SingleResponse whose thisUpdate follows its nextUpdate, a producedAt earlier than a thisUpdate it certifies, a certs field that is present and empty, the extended revoked definition (its criticality, its NULL value, and that it belongs in responseExtensions), the fixed shape RFC 6960 section 2.2 gives a revoked answer for a non-issued certificate (certificateHold at January 1, 1970) and what that answer must and must not carry, the list each extension belongs in (the nonce, the archive cutoff, the CRL references, and the three extensions defined for requests only), an archive cutoff later than producedAt, the criticality and value syntax of the CRL entry extensions section 4.4.5 lets a SingleResponse carry, an unrecognized critical extension on either list, the revocation reasons unspecified and removeFromCRL, and two SingleResponses answering for one CertID. Each finding names the SingleResponse it is about.
- The rfc5019 profile holds a response to the lightweight rules (nextUpdate always present, one SingleResponse, no responseExtensions, a byKey ResponderID). Nothing in a response says which profile its responder follows, so these rows run when profile: "rfc5019" is selected and count as not applicable in the default run.
- pki.schema.ocsp.parseResponse reports certsPresent beside certs, so a reader can tell a response that carried an empty certs field from one that omitted it.
- A lint-ocsp fuzz target drives the never-throw promise with hostile bytes on the ingestion boundary, spliced into a real response, and as the value of every extension the profile decodes.
- All three lint verbs report a signature field that carries no signature (lint/rfc5280/signature-empty, lint/rfc5280-crl/signature-empty, lint/rfc6960/signature-empty). An empty BIT STRING is well-formed DER, so the strict parsers admit it and the profiles grade it.

### Changed

- The three lint verbs refuse each other's profile names with a message naming the verb that takes the profile, and pki.lint.profiles() and pki.lint.rules() enumerate the two OCSP profiles beside the certificate and CRL ones.

## v0.7.25 — 2026-09-12

An OCSP response can say how far back its status reaches and which CRL it came from.

### Added

- A per-certificate response given to pki.ocsp.sign takes singleExtensions as an object beside the array of pre-encoded DER it already took: archiveCutoff is the Date before which the responder no longer holds status (RFC 6960 section 4.4.4), and crlReferences is { crlUrl, crlNum, crlTime }, any subset of the three, naming the CRL the status was drawn from (section 4.4.2). A fault in either is reported as ocsp/bad-input.
- pki.schema.ocsp.parseResponse decodes both onto the single response's extension entry, as archiveCutoff and crlReferences beside the raw value, the way it already decodes the nonce. A value that is not a GeneralizedTime, or a CrlID that is not the three-member SEQUENCE section 4.4.2 defines, is refused with ocsp/bad-archive-cutoff or ocsp/bad-crl-id rather than passed through.

### Changed

- The signing verb that would write a Date whose year lies outside 0000 to 9999 refuses it with that verb's own code (x509/bad-input, crl/bad-input, ocsp/bad-input and so on) instead of letting it reach the DER encoder and surface as asn1/bad-generalizedtime. A DER time carries a four-digit year, so such a Date never had an encoding; what changes is which error a caller receives. The check covers every Date a signing verb writes: a certificate's or a CRMF template's validity, a CRL's update, revocation and invalidity times, an OCSP response's producedAt, thisUpdate, revocation time, archive cutoff and CRL time, a CMS signingTime, a timestamp's genTime, an attribute certificate's validity, and a CMP message time. A Date a verifying verb only compares against, such as the time option of pki.path.validate, pki.crl.isRevoked or pki.cms.verify, is accepted as before.
- A pre-encoded singleExtensions entry given to pki.ocsp.sign is now read with the parser's own singleExtensions reader before it is emitted, so a malformed archive cutoff or CRL reference is refused at the signer with the parser's code instead of being written into a response the toolkit itself cannot read. A well-formed entry is unaffected. An object passed where the array was expected was previously dropped without a word; it is now either taken as the object form or refused.

### Fixed

- The pkijs.com sitemap dates every page on the UTC calendar the release is dated on. A page whose source was last committed in the evening west of Greenwich was dated a day earlier than the release that shipped it, and the following release could then move that date backward.

## v0.7.24 — 2026-09-12

A certificate request can name every extension its subject owns.

### Added

- The pki.csr.sign extensionRequest takes msCertificateTemplate, msEnrollCertType, msApplicationPolicies, subjectInfoAccess and subjectDirectoryAttributes, with the same spec shapes and the same rules pki.x509.sign applies, because one encoder serves both verbs: the same spec yields identical bytes and criticality in the request and in the certificate, and a fault is reported with a csr/* code.
- A request naming an extension the issuing CA assigns as a spec key is refused by name rather than encoded and ignored: authorityKeyIdentifier names the CA's key, precertificatePoison and signedCertificateTimestampList are the CA's exchange with a log, msCaVersion and msPreviousCertHash are the CA's own, and ocspNoCheck is the CA's decision about a responder. The pre-encoded Extension array is unchanged: it carries whatever well-formed extension a caller assembles, as it always has.

## v0.7.23 — 2026-09-12

Subject information access, subject directory attributes and the OCSP no-check marker are read and written.

### Added

- extensions.subjectInfoAccess takes the same { accessMethod, accessLocation } list authorityInfoAccess takes, because RFC 5280 section 4.2.2.2 gives it section 4.2.2.1's syntax. The two access methods that section defines are the registered names id-ad-caRepository and id-ad-timeStamping; the latter is a different object identifier from the extended-key-usage timeStamping, and naming the wrong one is the mistake this spelling avoids.
- extensions.subjectDirectoryAttributes takes a list of { type, values } with each value as pre-encoded DER, which is the form its reader surfaces (RFC 5280 section 4.2.1.8). Both extensions are emitted non-critical, which those sections require of a conforming CA, and a pre-encoded instance marked critical is refused on both signers.
- extensions.ocspNoCheck marks an OCSP responder certificate so a client does not ask about its own revocation (RFC 6960 section 4.2.2.2.1). The value is NULL, which that section requires, and the extension is emitted non-critical, which it says it should be.
- All three join the shared certificate-extension decoders. pki.lint.certificate reads them, so a malformed value is graded lint/rfc5280/extension-undecodable instead of passing unexamined, and pki.inspect prints the access method and location, the attribute and its value, and the no-check marker, where it previously printed a hex dump. That brings the set of certificate extensions the toolkit decodes and the set it can issue to the same twenty-six.

### Fixed

- pki.ocsp.verify authorized a delegated responder whose ocspNoCheck extension carried something other than NULL. The extension is what lets a client stop asking whether the responder itself is revoked, and the delegation gate tested only that it was present, so a responder certificate carrying a malformed marker was accepted and could return a good verdict. The gate now decodes the extension, as it already did for the key usage and extended key usage beside it, and a responder whose marker is not the NULL RFC 6960 section 4.2.2.2.1 requires is unauthorized. A conforming responder certificate is unaffected.

## v0.7.22 — 2026-09-11

A CRL can name its issuer's other identities.

### Added

- extensions.issuerAltName on pki.crl.sign takes the same GeneralName list the certificate extension takes, because RFC 5280 section 5.2.2 defines this extension's OID and syntax by reference to section 4.2.1.7. The same names given to pki.x509.sign and pki.crl.sign encode to identical bytes. Multiple name forms and multiple instances of one form are emitted as given, and the extension is non-critical, which section 5.2.2 says a conforming CRL issuer should do.

## v0.7.21 — 2026-09-11

Every certificate extension the toolkit reads, it can now also issue.

### Added

- extensions.msCertificateTemplate takes { templateID, templateMajorVersion, templateMinorVersion }, the versions optional and each a DWORD. A minor version requires a major one, because the reader takes the two positionally and a minor alone would be read as the major.
- extensions.msEnrollCertType takes the legacy v1 template name, written as the BMPString its reader requires. A character outside the basic multilingual plane has no BMPString encoding and is refused rather than written as something else.
- extensions.msCaVersion takes { caKeyIndex, certIndex }, each a WORD, or the composed DWORD directly, matching the two ways its reader surfaces the value.
- extensions.msPreviousCertHash takes the 20-octet SHA-1 thumbprint of the previous CA certificate, and extensions.msApplicationPolicies takes the certificatePolicies spec, qualifiers included, since that is the syntax its reader applies.
- All five are emitted non-critical. Neither pki.path.validate nor pki.lint.certificate processes them, so a critical instance is refused as an unrecognized critical extension by the toolkit's own readers; a caller who needs one supplies the Extension pre-encoded.

## v0.7.20 — 2026-09-11

Certificate policies carry their qualifiers, and the authority key identifier names the issuing certificate.

### Added

- extensions.certificatePolicies takes an entry object { oid, cps, userNotice } beside the registered name or dotted OID it already took. cps is the certification-practice-statement URI (RFC 5280 section 4.2.1.4 id-qt-cps), and userNotice takes { noticeRef, explicitText }, either or both, where noticeRef is { organization, noticeNumbers }. Every DisplayText is emitted as a UTF8String, the encoding section 4.2.1.4 names for a conforming CA and the one it forbids replacing with VisibleString or BMPString, and is held to its SIZE (1..200) bound counted the way the reader counts it. The qualifiers sequence is written only when a qualifier is named, since the reader refuses an empty one. It is accepted on the pki.csr.sign extensionRequest as well.
- extensions.authorityKeyIdentifier takes an object { keyIdentifier, authorityCertIssuer, authorityCertSerialNumber } beside true and a BufferSource key id, so a certificate can name its issuing certificate by name and serial (RFC 5280 section 4.2.1.1). The issuer name and the serial are both present or both absent, the rule the reader already applied. Each of the two also takes true, which reads it from the issuing certificate, so the pair identifies the certificate that actually signed this one.

### Fixed

- pki.asn1.build.utf8 refused nothing for an unpaired UTF-16 surrogate and converted it to U+FFFD, so a certificate carried text its issuer never wrote. A distinguished-name attribute was the reachable case: a common name holding a lone surrogate was signed with the replacement character in place of it. The encoder now refuses ill-formed UTF-16 with asn1/bad-utf8-string, the same verdict the decoder already returned for those bytes and the rule build.bmpString already applied. A well-formed surrogate pair is unaffected.

## v0.7.19 — 2026-09-11

Certificate transparency can be issued, not only verified.

### Added

- extensions.precertificatePoison: true issues a precertificate (RFC 6962 section 3.1). The poison is emitted critical with an ASN.1 NULL value, which is what stops a standard X.509v3 client from accepting a precertificate as an issued certificate: pki.path.validate refuses one, where the same leaf without the poison validates.
- extensions.signedCertificateTimestampList takes the SCTs a log returned, in the shape pki.ct.signSct and pki.ct.parseSctList already use, and embeds them in the certificate issued after the log answers (section 3.3, at least one). It is always emitted non-critical, since a client that does not recognize the OID refuses a certificate that marks it critical. A client recovers the signed entry from the issued certificate with pki.ct.x509CertEntry, and an SCT embedded this way verifies against that entry. A spec naming both the poison and an SCT list is refused, because they name opposite ends of one exchange and a certificate carrying both could never have its SCTs verified.

### Fixed

- A precertificate poison supplied through the pre-encoded Extension array was accepted without being critical, and RFC 6962 section 3.1 fixes it critical. A non-critical poison is ignored by a standard client, so the precertificate validated as an ordinary certificate rather than being refused, which is the single outcome the extension exists to prevent. Both pki.x509.sign and the pki.csr.sign extensionRequest now refuse it, alongside the nine certificate extensions whose criticality RFC 5280 section 4.2 fixes.

## v0.7.18 — 2026-09-11

Qualified-certificate statements can be issued from a plain spec.

### Added

- extensions.qcStatements takes a list of { statementId, info? } (RFC 3739 section 3.2.6). Each statement the toolkit knows encodes its own value syntax: qcCompliance and qcSSCD carry no info; qcType and qcIdentMethod take { types } or { methods } of object identifiers, including the registered ETSI names qctEsign, qctEseal and qctWeb; qcCClegislation and qcQSCDlegislation take { countries } of two-letter ISO 3166-1 codes; qcRetentionPeriod takes { years }; qcLimitValue takes { currency, amount, exponent } with an alphabetic three-letter or numeric 1 to 999 ISO 4217 currency; qcPDS takes { locations } of { url, language }; and qcsPkixQCSyntaxV1 and V2 take { semanticsIdentifier, nameRegistrationAuthorities }, at least one of the two. That is every statement type the parser reads back, so a certificate this builder emits decodes under the same syntax that reads it.
- A statement id the toolkit does not know carries its value as pre-encoded DER, or carries no value at all, since RFC 3739 makes statementInfo optional on every statement. It is refused a typed object, because nothing can infer the syntax of an unknown statement. Each typed info accepts only the fields its own statement defines, so a misspelled key is reported rather than dropped from the issued certificate. The extension is emitted non-critical unless qcStatementsCritical is set: pki.path.validate does not process qcStatements, so a critical one is rejected as an unrecognized critical extension and pki.lint.certificate grades it lint/rfc5280/unknown-critical-extension.
- qcStatements is accepted on the pki.csr.sign extensionRequest object as well, so a request and an issued certificate are written the same way.

## v0.7.17 — 2026-09-11

The certificate-policy machinery can be issued from a plain spec.

### Added

- extensions.policyConstraints takes { requireExplicitPolicy, inhibitPolicyMapping } skip counts and extensions.inhibitAnyPolicy takes a bare skip count. RFC 5280 section 4.2.1.11 forbids an empty policy-constraints sequence, so naming neither field is refused, and both extensions are emitted critical, which sections 4.2.1.11 and 4.2.1.14 require. A skip count is held to the same bound the decoders read it back through, so a negative or non-integer value is refused with x509/bad-input or csr/bad-input.
- extensions.policyMappings takes a list of { issuerDomainPolicy, subjectDomainPolicy }, each naming a registered policy or a dotted OID. A mapping to or from anyPolicy is refused, which RFC 5280 section 4.2.1.5 forbids and which pki.path.validate applies at section 6.1.4(a). That section states a SHOULD for marking the extension critical. The validator processes the extension on an intermediate certificate, applying the mappings to the policy tree, and treats a critical one on the target certificate as unprocessed and rejects it. A spec does not say where the certificate will sit in a chain, so it is emitted non-critical unless policyMappingsCritical is set, the same shape certificatePolicies already uses.
- extensions.issuerAltName takes the GeneralName forms subjectAltName takes, including a bare string classified into its form, and is emitted non-critical (RFC 5280 section 4.2.1.7).
- All four are accepted on the pki.csr.sign extensionRequest object as well, so a request and an issued certificate are written the same way. OpenSSL reads each of them back in the release gates, including both policy-constraint skip counts.

### Fixed

- A spec that names an object identifier with a non-string value now raises the signer's own x509/bad-input or csr/bad-input rather than the OID registry's oid/bad-input. This reached certificatePolicies and extendedKeyUsage as well as the new policy mappings, so a caller branching on the documented signer code missed a malformed policy or key-purpose entry. A registered name and a dotted-decimal OID are unaffected, and the already-correct treatment of a lexically-dotted but out-of-range OID is unchanged.

## v0.7.16 — 2026-09-11

Where to fetch the issuer and the CRL can be named in a plain spec.

### Added

- extensions.authorityInfoAccess takes a list of { accessMethod, accessLocation } (RFC 5280 section 4.2.2.1). The method is a registered name such as "ocsp" or "caIssuers", or a dotted OID; the location is a GeneralName in either form the builder accepts, including a bare string classified into its form. An empty list, an unregistered method name, and an entry with no accessLocation are refused with x509/bad-input or csr/bad-input. The extension is emitted non-critical, which that clause requires.
- extensions.cRLDistributionPoints takes a list of distribution points (RFC 5280 section 4.2.1.13), and extensions.freshestCRL takes the same shape, which section 4.2.1.15 gives it. An entry is either a GeneralName standing for a single fullName, so a list of URL strings is the short form, or { fullName, reasons, cRLIssuer } for the full one. reasons names ReasonFlags bits, whose numbering is not the CRLReason numbering pki.crl.sign takes: section 5.3.1 numbers privilegeWithdrawn 9 and aACompromise 10, while section 4.2.1.13 gives them bits 7 and 8. A distribution point naming only reasons is refused, which section 4.2.1.13 requires, as is an empty list or an unknown reason name. Both are emitted non-critical.
- All three are accepted on the pki.csr.sign extensionRequest object as well, so a request and an issued certificate are written the same way.

## v0.7.15 — 2026-09-11

Name constraints can be issued from a plain spec.

### Added

- extensions.nameConstraints takes { permitted, excluded }, with at least one side present, on pki.x509.sign and on the pki.csr.sign extensionRequest object. Each entry names one GeneralName form: { dNSName: ".example.com" }, { rfc822Name: "example.com" }, { uniformResourceIdentifier: ".example.com" }, { directoryName: [{ commonName: "Sub" }] }, or { iPAddress: buf } where buf is an address followed by its mask, 8 octets for IPv4 and 32 for IPv6. A base names a namespace rather than a subject, so it may carry a leading dot and is held to the rule for a constraint base, the same rule pki.trust.anchor applies to the subtrees it seeds. A bare string is refused, because the name form decides which namespace is constrained. The extension is emitted critical, which RFC 5280 section 4.2.1.10 requires. A subtree naming neither direction, an empty list, or a base that describes no namespace is refused with x509/bad-input or csr/bad-input. The constraint a certificate carries is enforced by OpenSSL as well as by pki.path.validate, checked in the release gates.

### Fixed

- pki.x509.sign refuses a nameConstraints extension on a certificate that is not a CA. RFC 5280 section 4.2.1.10 states the extension is used only in a CA certificate, and pki.lint.certificate already graded such a certificate error as lint/rfc5280/name-constraints-not-ca; the signer emitted it anyway when the extension arrived as pre-encoded DER in the extensions array. A name constraint on an end-entity certificate restricts nothing, so a caller reading it as a restriction was reading one that no validator would apply. Supply it on a certificate whose basicConstraints sets cA true.

## v0.7.14 — 2026-09-11

Every option a verify verb decides with is taken once.

### Changed

- The pki.smime verbs take their options once at entry. sign, verify, encrypt, decrypt, compress, decompress and buildCertsOnly share one option-checking helper, and it now returns the options it checked rather than leaving the caller's object to be read again by each later pass.

### Security

- pki.jose.verify took opts.key seven times: a type gate, the RFC 7638 thumbprint comparison against the jwk the JWS embeds, and the key the signature is verified under. An accessor-backed opts.key could satisfy the comparison with the embedded key and hand a different one to the verification, so a JWS naming a signer the caller did not pin returned a verdict of keySource "opts.key" instead of the jose/key-mismatch refusal a plain object gets. The option is taken once, so the key compared is the key used.
- pki.webauthn.parseClientData took each expected* option twice: once to decide whether to compare, once for the value compared, and the returned checked field then recorded that the comparison ran. An accessor-backed expectedChallenge could enter the branch on the challenge a ceremony issued and be compared against the challenge the response carried, returning checked.challenge true for a comparison that never ran against the caller's value. expectedType and expectedOrigin behaved the same way. Each option is taken once, so checked answers for the value the caller supplied. pki.webauthn.verify and pki.webauthn.verifyAssertion already copied their inputs and were not affected.

## v0.7.13 — 2026-09-11

The certificate signer refuses a pre-encoded extension whose criticality RFC 5280 fixes.

### Changed

- A call that supplied one of the nine fixed-criticality extensions with the wrong flag now throws where it previously produced a certificate or a request. Set the flag the clause requires: omit the critical BOOLEAN for the non-critical set, and encode it TRUE for the critical set. The extensions and extensionRequest object forms already emitted the correct flag and are unaffected, as are the CRL and attribute-certificate signers, which enforce the criticality their own profiles fix.

### Fixed

- pki.x509.sign and pki.csr.sign now refuse a pre-encoded extension carrying a criticality RFC 5280 does not permit, throwing x509/bad-input or csr/bad-input with the governing clause in the message. Nine extensions leave the issuer no choice. authorityKeyIdentifier (section 4.2.1.1), subjectKeyIdentifier (section 4.2.1.2), subjectDirectoryAttributes (section 4.2.1.8), freshestCRL (section 4.2.1.15), authorityInfoAccess (section 4.2.2.1) and subjectInfoAccess (section 4.2.2.2) must be non-critical. nameConstraints (section 4.2.1.10), policyConstraints (section 4.2.1.11) and inhibitAnyPolicy (section 4.2.1.14) must be critical. The check covers the pre-encoded array form on both verbs, which is the only way to supply these six extensions.

## v0.7.12 — 2026-09-11

An issuer can draw a certificate serial number before signing with it.

### Added

- pki.x509.randomSerial() returns a BigInt drawn from 20 octets of the platform CSPRNG, the same draw pki.x509.sign makes when spec.serialNumber is omitted. The value meets the RFC 5280 section 4.1.2.2 profile the signer enforces: positive, and at most 20 octets. Pass it back as spec.serialNumber and the certificate carries exactly that number, so a serial can be reserved or logged before issuance.

### Changed

- The serial draw redraws when the leading octet comes out zero, instead of replacing it with 1. Every value the leading octet can take is now equally likely; previously 1 occurred about twice as often as any other value, because it absorbed the zero case. Serials already issued are unaffected, and the draw carries the same 20 octets it always did.

## v0.7.11 — 2026-09-10

A name constraint whose base is a zero-length name is read rather than rejected.

### Changed

- pki.path.validate still refuses a caller-supplied subtree seed whose base is empty, through opts.initialPermittedSubtrees, opts.initialExcludedSubtrees or a trust anchor's nameConstraints. A seed that permits everything is a restriction the caller believes they applied, so that door is unchanged and is separate from decoding a certificate's own extension.

### Fixed

- A zero-length subtree base in a certificate's nameConstraints decodes. RFC 5280 section 4.2.1.6 states the prohibition for subjectAltName, that an extension with a dNSName of "" must not be used, and section 4.2.1.10 places no length floor on a constraint base. A zero-length dNSName in a subjectAltName is still refused, which is where the rule belongs.
- For dNSName the empty base names the root of the namespace, the same as the bare-dot base already did: section 4.2.1.10 satisfies the constraint by adding zero or more labels to its left-hand side, and every name is that base plus labels. An excluded empty base therefore excludes every dNSName, and a permitted one permits every dNSName. A leaf naming no dNSName is untouched by either.
- For rfc822Name and uniformResourceIdentifier the base decodes but the comparison reports path/name-constraint-unsupported and refuses. Section 4.2.1.10 gives a mail constraint a mailbox, a host or a leading-period domain, and a URI constraint a fully qualified domain name, so an empty base is none of the shapes either names and is not read as covering everything. The verdict now says the constraint could not be evaluated instead of saying the extension could not be parsed.

## v0.7.10 — 2026-09-10

An OCSP response is no longer signed with a status window that runs backwards.

### Fixed

- pki.ocsp.sign raises ocsp/bad-input for a response entry whose thisUpdate is after its nextUpdate, before anything is signed. RFC 6960 section 4.2.2.1 makes the pair a validity interval corresponding to the {thisUpdate, nextUpdate} interval in CRLs, and pki.crl.sign, pki.x509.sign, pki.attrcert.sign and pki.crmf.build each already refuse their own reversed window. Every entry in a batch is measured, not the first alone, and a nextUpdate that came from the default is held to the rule a supplied one is.
- An entry whose thisUpdate equals its nextUpdate still signs, as the equivalent CRL does: a zero-length window is not forbidden. A response entry that names no certificate still reports that first. The times are compared as given, so a thisUpdate later than its nextUpdate is refused even where the two fall inside one second and would encode to the same GeneralizedTime, which is how pki.x509.sign, pki.crl.sign and pki.crmf.build read their own.
- A pre-produced response still signs. RFC 6960 section 2.5 lets a responder sign a response ahead of the window it certifies, where producedAt is when the response was signed and thisUpdate is when the status was known correct, so a producedAt earlier than thisUpdate is conforming and is left alone.

## v0.7.9 — 2026-09-10

Certificate revocation lists can now be linted against the RFC 5280 profile.

### Added

- pki.lint.crl(pem | der | parsed, opts) lints a certificate revocation list against RFC 5280 section 5, under the new profile name rfc5280-crl. It returns the same LintReport as pki.lint.certificate, and hostile bytes produce a single fatal lint/unparseable finding carrying the strict parser's code rather than raising.
- On the CRL itself: nextUpdate presence (section 5.1.2.5) and thisUpdate not following it; cRLNumber presence, and the 20-octet ceiling section 5.2.3 places on a CRL number, measured on both the cRLNumber and the baseCRLNumber a delta CRL carries, which section 5.2.4 defines as a CRLNumber; both halves of the authorityKeyIdentifier requirement, that it is present and that it uses the key identifier method (section 5.2.1); the criticality every profiled extension must carry (section 5.2), with issuerAltName reported separately at warn because section 5.2.2 states its criticality as a recommendation; freshestCRL in a delta CRL, and one carrying the reasons or cRLIssuer fields section 5.2.6 requires a CRL to omit (section 5.2.6); a delta whose cRLNumber does not exceed its baseCRLNumber (section 5.2.4); an issuingDistributionPoint stating a scope the profile does not permit (section 5.2.5), which includes a reason scope naming no reason at all, as an empty mask or one setting only the unused bit does; a distribution point named relative to the CRL issuer, in either the issuingDistributionPoint or the freshestCRL, at warn, since section 4.2.1.13 states that as a recommendation; an authorityInfoAccess that names no caIssuers access method, or that carries one other than caIssuers (section 5.2.7), with a warning where no location it gives is an HTTP or LDAP URI; and an unrecognized critical extension.
- On each revoked entry: the entry-extension criticality section 5.3 requires; a certificate serial that is not positive or runs past 20 octets, the same section 4.1.2.2 profile a certificate's own serial carries; a removeFromCRL reason code outside a delta CRL (section 5.3.1); a certificateIssuer on a CRL that declares itself direct, or one that omits the issuer distinguished name the clause requires (section 5.3.3); a reasonCode encoding unspecified(0), which section 5.3.1 says should be absent instead, at warn, and as an error where the CRL is scoped to some reason codes, since section 5.2.5 makes a meaningful reason a requirement there; a reason the CRL's own issuingDistributionPoint does not declare, where that extension names at least one reason (section 5.2.5); a serial listed twice on a direct CRL, where a reader cannot tell which entry governs (section 5.1.2.6); and an unrecognized critical entry extension.
- An extension the section 5 profile names, whose value does not read as the structure that identifier names, is reported in either scope. Only extensions the CRL profile names are read this way: a certificate-only extension carried on a CRL is an unrecognized one, which the profile lets a consumer ignore when it is non-critical and which the unknown-critical row reports when it is not. Recognizing an extension by its identifier says only that the profile names it, so without this a critical issuingDistributionPoint carrying a NULL satisfied the criticality rule, was not an unrecognized extension, and reported clean although no consumer can process it. The issuingDistributionPoint is read through the same decoder certification-path validation uses, so the two verbs cannot disagree about what a well-formed one is. Where that extension does not read, the CRL states neither a scope nor a direction, so the rows that depend on it stand aside and the extension is reported once rather than every entry that would have followed from it.
- pki.lint.rules("rfc5280-crl") enumerates the CRL registry, and pki.lint.rules() with no argument now returns the certificate and CRL rules together. The two verbs do not accept each other's profile names: passing a CRL profile to pki.lint.certificate, or a certificate profile to pki.lint.crl, raises lint/unknown-profile naming the verb to use instead.

### Changed

- Six section 5 rules have no row, because the strict parser refuses them before a rule could run: a CRL whose version does not match the extensions it carries (section 5.1.2.1), a signatureAlgorithm that does not match the signature field inside tbsCertList (section 5.1.1.2), an empty issuer name (section 5.1.2.3), a present-but-empty revokedCertificates (section 5.1.2.6), a date carried in the wrong time type, which is UTCTime through 2049 and GeneralizedTime from 2050 (sections 5.1.2.4, 5.1.2.6 and 5.3.2), and an extension repeated on the CRL or on an entry (section 4.2). Each reaches a caller as the fatal lint/unparseable finding carrying the parser's own code.

## v0.7.8 — 2026-09-10

A pre-encoded CRL extension whose value does not decode is refused instead of signed.

### Fixed

- A pre-encoded extension supplied to pki.crl.sign is put through the extension decoder registered for its identifier, and a value that does not decode is refused with the decoder's own typed code, the same one pki.x509.sign raises for the same bytes. An issuerAltName whose value is a NULL now raises crl/bad-extension-value and a keyUsage whose value is a NULL raises crl/bad-key-usage, where both were previously signed. Revoked-entry extensions take the same check.
- The check runs after the per-extension profile rules this verb already applied, so a CRL whose only fault is one malformed extension keeps the code it has always raised: a pre-encoded cRLNumber that is not an INTEGER stays crl/bad-crl-number, and the criticality, delta-ordering and issuingDistributionPoint rules are unchanged. No CRL that was accepted before is refused for a different reason; the extensions newly refused are ones nothing was reading, and an extension with no registered decoder is unaffected.
- Where a CRL carries more than one fault, the reported code can differ from earlier releases. The rules that compare extensions to each other, such as freshestCRL not appearing in a delta CRL and a delta's cRLNumber exceeding its baseCRLNumber, run once every extension has been read, so a malformed value found first is now reported first. Such a CRL was refused before and is refused now; only which of its faults is named has changed. A caller matching on a specific code for a CRL with several faults should re-check the code it expects.

## v0.7.7 — 2026-09-10

The two GeneralName arms that were taken on their tag alone are now read.

### Changed

- Every verb that walks a name is affected, since they share one reader: certification-path validation, certificate and attribute-certificate parsing, CRL and CMP name handling, certificate linting, and the name-carrying extension decoders. A certificate carrying one of these two arms malformed was accepted before and is refused now, with the typed code that verb already raises for a bad name. A conforming arm is unaffected, and no error code was added or changed.

### Fixed

- GeneralName ediPartyName [5] is read as the EDIPartyName it holds: an optional nameAssigner [0] before a required partyName [1], each wrapping a DirectoryString of at least one character, in that order and with nothing else alongside. It was previously accepted as any non-empty constructed value, so a [5] over a NULL, over an INTEGER, or carrying nameAssigner without partyName all decoded as names while encoding none. GeneralName is defined in the RFC 5280 Appendix A.2 implicitly tagged module, so the [5] tag replaces the EDIPartyName SEQUENCE tag and its children are the two arms directly.
- GeneralName x400Address [3] must open with the BuiltInStandardAttributes SEQUENCE that begins an ORAddress, so an arm carrying that tag over some other value is refused. What the components hold is an X.400 structure this does not read, and every one of them is optional, so an address whose components are all empty is conforming and is not refused.

## v0.7.6 — 2026-09-10

A Sigstore bundle is held to the shape its specification states, in signature count and in entry count.

### Changed

- A bundle offering more than 32 transparency-log entries is refused with sigstore/bad-bundle, naming the count it offered. Each entry a bundle offers can cost a certification-path validation, since an entry is tried until one passes every check that depends on which entry it is, and the validation instant comes from the entry itself unless the caller pins opts.time. Nothing bounded the count: the 1 MiB bundle budget leaves room for roughly 155 entries, and every bundle in the Sigstore conformance suite carries exactly one. The ceiling is a local resource bound rather than a rule the specification states, and it is checked before any entry is read. It is available as pki.C.LIMITS.TLOG_MAX_COUNT.

### Fixed

- A DSSE envelope carrying more than one signature is refused with sigstore/bad-dsse, naming the count it carried. The Sigstore bundle specification states the rule on the producer, that an envelope in a bundle has exactly one signature, and again on the verifier, that a client rejects an envelope whose signature count is not one. Only the first signature was ever read, so an envelope with a second was accepted and returned a verdict that said nothing about it. An envelope with exactly one is unaffected, and an empty signature list keeps the refusal it already had.

## v0.7.5 — 2026-09-10

Certificate linting now covers the ML-DSA and SLH-DSA certificate profiles.

### Added

- pki.lint checks an ML-DSA certificate's key usage against RFC 9881 section 5, as the finding id lint/rfc9881/mldsa-key-usage under the new profile name rfc9881. Where the keyUsage extension is present, at least one of digitalSignature, nonRepudiation, keyCertSign or cRLSign must be set and none of keyEncipherment, dataEncipherment, keyAgreement, encipherOnly or decipherOnly may be. The finding carries which key-establishment bits were set and whether a signing bit was present. A certificate carrying no keyUsage extension is unconstrained. All three parameter sets are covered.
- pki.lint checks an SLH-DSA certificate's key usage against RFC 9909 section 6, as the finding id lint/rfc9909/slhdsa-key-usage under the new profile name rfc9909. The requirement is the same pair. RFC 9909 section 5 admits both the pure id-slh-dsa-* and the prehash id-hash-slh-dsa-*-with-* identifiers as a subject public key algorithm, and the rule reaches both, across all twelve parameter sets of each.
- pki.lint.profiles() returns rfc5280, rfc9881, rfc9909, rfc9935 and cabf-tls. Passing rfc9881 or rfc9909 as opts.profile runs that profile on its own, and pki.lint.rules(name) enumerates its rows.

## v0.7.4 — 2026-09-10

Two authoring inputs that were accepted quietly are now refused by name.

### Fixed

- pki.csr.sign refuses an option it does not recognize. It reads the same three options its sibling pki.x509.sign reads, and that verb already refused anything else, so a name outside the set was accepted and dropped here alone: a caller asking for PEM output and misspelling the option received DER and no error. A name every object inherits, such as toString, is not a recognized option either. The three it does read are unchanged.
- A distinguished name given as an array with a missing element is refused with this toolkit's own typed error. Encoding walks the array per element, and a gap left the element absent all the way to the encoder, which then failed on it as an untyped read of undefined rather than as a verdict naming the input. It affected the subject of a certificate and of a certification request, and an explicitly supplied issuer name. Every other authoring list already refused a gap, and this now answers the same way, in the domain of the verb that was called.

## v0.7.3 — 2026-09-10

A timestamp verification takes its options once, so every pass reads the same answer.

### Changed

- pki.tsp.verify reads each option once. A token is examined in more than one pass, and opts.certs was consulted again by each of them, so an option reached through an accessor could hand one set of certificates to the signature check and another to the chain the signer is traced through. No route was found that turns this into a wrong verdict, and a caller passing options as plain values was never affected; the change makes the verdict describe one set of inputs rather than relying on a caller object to answer consistently. It follows the same read-once shape applied to the PKCS#12 verbs in 0.7.2.

## v0.7.2 — 2026-09-10

A PKCS#12 iteration cap is read once, so it cannot be answered with a value that is not a number.

### Security

- pki.pkcs12.open and pki.pkcs12.verifyMac hold a store to opts.maxIterations however that option is supplied. The option is the caller's own bound on key-derivation work, and it was read once for each part of its shape check and again by every later pass that applies it: the message authentication code, and then each bag the store carries. An option reached through an accessor answers each read separately, so one that returned a valid integer to the shape check and a non-numeric value to a later comparison left the cap as NaN. No count is above NaN, so the comparison stopped refusing anything and a store demanding far more work than the caller allowed was accepted and its keys derived. A store carrying no message authentication code reaches this through its bags alone, so opening one with opts.allowUnauthenticated was affected even though the authenticated path was not consulted. Both verbs now take the whole options object once, at entry, and every pass reads that one copy, which closes the same door for every option either verb accepts rather than for this one alone. A caller passing options as plain values was never affected. The same read-once shape is applied to the PBES2 and CMP iteration caps, where no route was found that reaches them this way.

## v0.7.1 — 2026-09-10

A wildcard name in a certificate is held to an excluded name constraint it can reach.

### Security

- pki.path.validate refuses a certificate whose wildcard dNSName reaches a name an excluded subtree forbids. An excluded subtree states the names a certificate must not present, and a wildcard subject alternative name stands for every name one label below its parent rather than for the literal text. Comparing the text alone let a leaf carrying only *.example.com pass an exclusion of bar.example.com, which is a name that leaf presents. Any chain whose certificate authority relies on an excluded dNSName subtree to keep a subordinate away from a name was affected, in every version that shipped name-constraint validation. The permitted direction is unchanged and is not widened by this: a permitted subtree still has to cover the whole of a wildcard, so a base of foo.com admits *.foo.com while a base of bar.example.com does not admit *.example.com. A wildcard reaches exactly one label, so an excluded name deeper than that is out of its reach and is not refused: *.example.com is still admitted against an exclusion of deep.bar.example.com.

## v0.7.0 — 2026-09-10

A Sigstore bundle signed over an artifact's own bytes is verified, not just one wrapping an attestation.

### Added

- pki.sigstore.verifyBundle verifies a bundle carrying a message_signature. The caller passes the artifact as opts.artifact and the verifier hashes it: under the algorithm the Rekor entry names, compared against the hash that entry records, and then under the leaf certificate's key against the signature the bundle carries. The three happy-path bundles the Sigstore conformance suite publishes are covered, spanning all three bundle media types and both shapes of verification material.
- opts.artifact takes the artifact's bytes and nothing else. The digest a message_signature carries is covered by no signature, and the field's own definition states that a client must not use it to verify the signature, so there is no shape in which a caller hands over a digest instead: a hex string is refused at the door, and the digest bytes are refused where the artifact is compared against the log entry. A message_signature bundle with no opts.artifact is refused rather than verified from the digest it carries.
- The digest the bundle states is checked against the one computed, when it is there. It is optional, and a bundle omitting it verifies with messageDigestChecked reporting false. Where it disagrees with the artifact the request is refused, and the verdict reports the digest this computed rather than the one the bundle claimed. It may name any of the five HashAlgorithm members that name an algorithm, the two SHA-3 members included: those are marked deprecated, which says a producer should not choose one rather than that a bundle carrying one is malformed, and this digest names its own algorithm independently of the one the log entry records. It may name the member by its name or by its number, since the JSON mapping states that a parser accepts both; the unspecified member is refused under either spelling, as is a number outside the enum. The entry's algorithm is a different field under a different enum and stays the three the Rekor schema lists.
- The verdict has one shape for both content arms. contentType names the arm. The fields that carry a value report null where the arm has none: artifactDigest and digestAlgorithm on a DSSE bundle, and payload, statement, subjects, predicateType and predicate on a message signature. The fields that report whether a check ran stay boolean, so messageDigestChecked is false on a DSSE bundle exactly as predicateTypeChecked is false on a message signature. A caller reads the same fields either way rather than telling a missing key from a false one.
- opts.identity and opts.ctLogs run on this arm as they do on the other, so a caller pins who signed and whether the signing certificate was public when it was issued, whichever content the bundle carries.
- pki.tls offers RFC 8879 zstd certificate compression. All three registered algorithms are implemented, and each is offered only where the running Node decompresses it safely. At load each is handed a frame with its last two bytes cut: a decompressor that returns anything at all rather than faulting is dropped, because a runtime that accepts a cut frame lets a peer strip a frame's tail and have the receiver read a prefix as the whole message. Node 24.19 returned the prefix for a cut zstd frame, so zstd was dropped there. Node 24.21 faults on one, so all three are offered.

### Changed

- A hashedrekord entry may name the verifier as a public key rather than as a certificate. The Rekor field is documented as the public key that verifies the signature, which may also be an X509 code signing certificate carrying that key, so an entry writing the key itself is conforming; it binds when that key is the bundle leaf's, and an entry naming a different key does not bind. The armor says which form the entry wrote, so neither is guessed at from the bytes.
- Every option is read once, when verification starts, and the arm gates and the verdict read that one answer. An option is whatever its name resolves to on the options object, inherited or own, which is how an unrecognized option name is already detected and refused. Reading them as own properties made an inherited artifact look missing on the arm that requires it and let an inherited predicateType past the refusal that belongs to it. Reading one twice let it answer differently across the two reads: a predicateType that was absent where the arm gate ran and present where the verdict was built returned predicateTypeChecked true on a message signature, which carries no statement to check a predicate type against.
- A transparency-log entry is read by its own kind and version together. The entry registry holds one row per kind and apiVersion, and each row states which content arm it may accompany, so a hashedrekord v0.0.2 body is refused as an unsupported version rather than read for v0.0.1 field names it does not carry, and an entry describing a DSSE envelope beside a message signature is refused rather than bound to it.
- An option that belongs to the other arm is refused rather than ignored. Supplying opts.artifact on a dsse_envelope bundle, or opts.predicateType on a message_signature, is an error, since an option read and passed over reads as a check that ran. Either name set to null or undefined counts as not supplied.
- The supported Node floor moves to 24.21.0, the current long-term-support release, which carries OpenSSL 3.5.8. Nothing is transpiled, so the supported version is the version the source runs on. On a lower version npm reports the mismatch as EBADENGINE, and refuses the install where engine-strict is set. Nothing below the floor is tested, and zstd certificate compression is unavailable there.

### Fixed

- A bundle given as an object is copied into plain data before any rule runs on it, and every check then runs on that copy. Its fields were the caller's own to answer, and the transparency-log entry's canonicalizedBody was read separately by the three things that examine it: the binding that ties the entry to the signature and certificate, the inclusion proof folded to the signed tree root, and the signed entry timestamp. A field answering differently across those reads let one body be bound while a different one was proven, so a signature the log never recorded was attested by an authentic entry for something else and verifyBundle returned verified. The same shape reached the DSSE payload and signature, the inclusion proof's root and hashes, and the time the certificate is checked at. It affects both content arms and every version that shipped the DSSE one. A bundle given as JSON text was never affected, since parsing it yields fixed values.
- An object bundle is held to being data, which changes what is accepted. A field reached through an accessor is refused rather than called, since calling one runs code that can change the object while it is being read. So is a value JSON does not carry: a function, a symbol, a bigint or a non-finite number anywhere in the structure, because dropping one silently turned a bundle setting two content arms into a bundle setting one. A field set to undefined reads as absent, and one named `__proto__` stays a field of that name rather than becoming the copy's prototype. pki.sigstore.parseBundle takes the same copy and returns it, so what a caller reads back is what was checked and the two verbs cannot answer differently about one bundle.
- An object bundle is bounded by the same size and depth limits a text one is, and by the same numbers. The size is charged while the copy is built, so a bundle is refused before the memory is allocated rather than after: sharing one sub-object between two properties doubles the values a copy walks per level, so a few hundred bytes describes hundreds of millions of them, and a modest array of long strings is a handful of values holding tens of megabytes. Each value is charged what JSON writes it as, so the two representations of one document are judged alike: a string costs its escaped bytes, a number written in exponent form costs its digits, and the punctuation between values is counted once. A property whose value is not written costs nothing, since the document omits it. One nesting limit governs both routes.
- Nothing the copy relies on can be replaced after this package loads. The object path used to serialize the copy and read it back, so a serializer replaced afterwards could drop a field and hand the checks a bundle carrying one content arm where the copy held two; the copy is plain data already and is not converted to text at all. The operations the copy decides with are taken at load for the same reason, since replacing the one that lists what an object owns empties the bundle and replacing the one that counts a number's digits lets an oversized one through.
- The fields that name a registry row are read for being strings before they are used as keys. A messageDigest algorithm, a log entry's kind and apiVersion, and a log entry's hash algorithm reached a lookup as whatever the bundle carried, so a value whose own conversion throws escaped as an untyped error out of a verb whose contract is that malformed input is refused with a typed one.

### Security

- pki.webauthn.verifyMetadataBlob reads opts.previousNo once, so the rollback comparison and the result it reports run on the same baseline. The option was read separately by the comparison and by the result, and a baseline that answered absent at the comparison and present afterwards skipped the comparison entirely: a metadata BLOB whose sequence number did not advance past the held baseline was accepted, and the result stated rollbackChecked true and named the baseline it had not compared against. A caller that holds its baseline in a plain value was never affected. Every option the verb reads is now taken once at entry.

## v0.6.55 — 2026-09-08

A Diffie-Hellman key proves possession by agreeing a secret with the authority, without signing anything.

### Added

- pki.crmf.build assembles the agreeMAC proof of possession (RFC 4211 section 4.3, RFC 2875 section 3). The requester supplies pop.key, its own Diffie-Hellman private key, and pop.caCert, the authority's certificate it already holds; the two agree a secret, a key is derived from it, and that key MACs the request. The proof carries id-dhPop-static-HMAC-SHA1, and both that OID and id-alg-dhPOP are registered.
- The proof value is the DhPopStatic structure section 3 defines, not the bare MAC: the BIT STRING carries a SEQUENCE of the issuer name and serial number of the certificate the authority's key came from, and the HMAC result as an OCTET STRING. The section says so after the computation it describes, "i.e. encodes the above sequence instead of the raw output from 3d".
- The key is derived as RFC 2875 section 3 states it: SHA-1 over the authority certificate's subject name, the agreed secret, and its issuer name, in that order. Section 4.3 settles which certificate those names come from and what to do when one is absent, so an authority certificate whose subject name is empty contributes its subjectAltName instead, and one carrying neither is refused rather than keyed from nothing. That alternative name is read as the GeneralNames it is rather than measured, since the certificate parser surfaces an extension's bytes without running the decoder registered for it: an empty list carries a length while naming nobody, and so does one holding a value that no arm of the GeneralName choice covers. The three arms the shared name reader takes on their tag or admits empty are read further: an x400Address must open with the standard attributes an ORAddress begins with, rather than with whatever value carries that tag, and must name somebody in one of its components, every built-in field being optional so that naming data can sit in the domain-defined or extension attributes instead; a directoryName wrapping a Name of no relative names substitutes an empty name for the empty name that reached this fallback, and an ediPartyName is read down to the strings it names a party with, a DirectoryString being one of five string types carrying at least one character. A party named with an empty string, or with bytes that are not the encoding their tag claims, names nobody either. Section 4.3 states the same fallback for an empty issuer name, which a certificate reaching this proof cannot carry: RFC 5280 section 4.1.2.4 requires a non-empty issuer, and the certificate parser enforces it.
- The MAC covers the DER certReq, and the request must name both a subject and a public key. Section 4.3's own ASN.1 comment states both, where the prose states neither: the proof is "a MAC (over the DER-encoded value of the certReq parameter in CertReqMsg, which must include both subject and publicKey)". A template naming only one of them is refused.
- pop.key must be the private half of certTemplate.publicKey. The authority derives its side of the agreement from the requested key, so a private key from another pair agrees a secret with the authority and yields a MAC the authority cannot reproduce: a proof of possession of something the request never asked to have certified. The encryptedKey arm holds its enclosed key to the same rule.
- The proof is admitted only for a finite-field Diffie-Hellman pair, which is what section 3 defines it over, and only when the requester's key can actually reach the authority's, which section 4.3 requires. An elliptic-curve or montgomery pair agrees a secret through the same operation and is refused rather than emitted under an OID naming something the recipient is not performing; so is a key on another group, and a key this runtime cannot classify is named as unrecognized rather than reported as a key type. Every copy of private material the proof allocates is cleared before the call returns, on the refusing paths as well as the building one: the agreed secret, the key derived from it, and the copy taken of the caller's key. The caller's own key is left untouched.
- Every key this proof touches is read in both encodings a finite-field key is written in. An X.509 Diffie-Hellman certificate names dhpublicnumber and carries X9.42 DomainParameters (RFC 3279 section 2.3.3) where this runtime classifies only the PKCS#3 form, so such a key is rewritten into the form it reads: the authority's certificate, the requested key, the enclosed key an encryptedKey proof archives and the public half derived from it, and the requester's own private key, which is a PKCS#8 whose algorithm is rewritten while the private value it wraps is unchanged. That private key is taken in every form the verb accepts it in: DER, a PEM block, and a key object the runtime holds without classifying, since admitting one of the three is not supporting the encoding. The request still carries the encoding the caller asked to have certified.
- Whether a set of parameters describes the key it carries is asked of every key read, in either encoding, since a request carries those parameters on to whoever reads it next. The modulus must be prime, and the public value must lie inside the group. Where the certificate states a subgroup order, that order must divide p-1, must be an order the generator actually has, must be prime, and must hold the public value; where a cofactor is given with it, the two must account for p. The requester's own private key is read the same way, since the PKCS#3 form it is rewritten into carries neither the order nor the cofactor and that rewrite is the last place either is looked at. A PrivateKeyInfo states no public value, so the two tests that hold one to these parameters are the only ones it is not held to.
- These rules govern a key a secret is agreed with. The encryptedKey arm asks the one question RFC 4211 section 4.2 states, whether the enclosed key is the private half of the requested one, and agrees nothing, so it reads both encodings for their shape and does not measure the group: whether a key sits in a good group is the certifying authority's policy, and answering it here would mean proving a modulus prime, which costs seconds and bounds the size, so a key too large to prove would become a key too large to archive. A legacy group and a group larger than any agreement would accept both still enroll there. What a stated order and cofactor say about the p and g beside them is a separate question, and that arm answers it: the conversion drops both while the request goes out carrying the caller's own encoding of them, and the tests are a multiplication and a modulo rather than the exponentiation and the primality proofs the size bound exists for.
- Both exponents this agreement uses must be ones the other side cannot work out. RFC 2631 section 2.2 states X9.42's own requirement, that the private key lie in the interval [2, q-2], and the exponents outside it agree a secret that follows from values the request and the authority certificate already carry: zero agrees one, one agrees the other side's public value itself, and the order less one agrees the inverse of that value. A MAC keyed from any of them proves possession of nothing. The requester's key and the authority's are held to it alike, since an agreement has two exponents and either one outside the interval makes the secret public.
- The rule is read through the group's own order. A safe prime also has elements of order 2q, and the PKCS#3 form states no order, so a generator of that full order is admitted; its square root of one reflects each excluded value, so the exponent q+1 produces p minus g and q-1 produces p minus the inverse of g. Each agrees the negation of a secret that already follows, so the reflections are excluded with the values they reflect. An exponent that is merely small is a different matter and is not tested: it cannot be read off the public value without solving a discrete log, no specification states a floor for it, and the interval admits it. The test reads the public value each key states rather than the key itself, so it needs no copy of private material and no stated order.
- The floors are the strength the agreement itself needs. A stated order must be large enough to hide an exponent: a prime order makes the public value's order exactly that value rather than a divisor of it, which is worth nothing when the value is small, and three is prime. The floor is the smallest subgroup order NIST SP 800-56A states for a finite-field group. Where no order is stated, which the PKCS#3 form cannot, the modulus must be a safe prime, whose element orders are one, two, r and 2r alone, and that order carries the same floor, since seven is a safe prime whose own subgroup has order three. The modulus carries a floor of 2048 bits, the size the toolkit's linter already names for a public key: a large subgroup says a value is hard to take a discrete log of inside the group and says nothing about the group itself, and a 512-bit safe prime has a 255-bit subgroup while the secret agreed in it is recoverable. A 1024-bit legacy group no longer completes this proof. Proving a modulus prime costs from a fraction of a second over a 2048-bit group to a second or two at the 4096-bit bound, and the answers are remembered, so a run of requests against one authority proves its group once.
- The one optional field a PKCS#3 DHParameter carries is held to the modulus beside it. privateValueLength states how many bits the private value has, and a private value is less than the modulus, so a length above the modulus's own bit count describes no value the group holds; it was read for being a positive integer and carried through unchanged, so a request could travel stating one. The encoded size bounds the count before anything is shifted by it, since the field is an INTEGER the codec admits at any width.
- A refusal names the key it is about. The authority certificate, the requested key, the requester's private key and the enclosed private key each reported as the authority certificate's, whichever one the reader had been handed.
- The parameters are sized before anything is computed with them, since these tests raise one operand to the power of another and prove a modulus prime, while the DER reader admits an integer far wider than any Diffie-Hellman group. The bound is 4096 bits, which covers every MODP group a certificate carries. Every container is read for the tag it carries rather than for having children, so a context-specific constructed value in place of the DomainParameters SEQUENCE is refused rather than rewritten into a key that imports while the request carries the original. The cofactor and the validation parameters are each optional and independent, so a fourth field is read by its tag: the cofactor is held against p and q, and the validation parameters against the seed BIT STRING and pgenCounter INTEGER they consist of. The public key BIT STRING must hold whole octets, since the codec admits a nonzero unused-bit count whose padding bits are zero and that BIT STRING is carried through unchanged.
- The authority certificate's keyUsage is enforced. A keyUsage extension restricts the certified key to the purposes it asserts (RFC 5280 section 4.2.1.3), and this proof uses the authority's key to agree a secret, so a certificate that states its purposes without stating keyAgreement is refused rather than used for it. A certificate carrying no keyUsage states no restriction and is unaffected.

### Fixed

- A constructed DER value is written into one allocation. Building a SEQUENCE or a SET concatenated the children and then concatenated that with the header, leaving an intermediate holding everything the children carry; where a structure is built over private material, that intermediate was a copy of it no caller could reach to clear. pki.asn1.build.sequence, .set and .setOf now assemble directly, which covers every caller rather than the ones that remembered to. The encoding is unchanged, byte for byte. Clearing a key's copies is still best effort, since a runtime copies buffers where no code can reach them.
- pki.sigstore.verifyBundle refuses a bundle that carries both content arms. A bundle setting dsse_envelope and message_signature together was verified on the envelope alone, returning a verdict that said nothing about the other arm the bundle carried. The bundle's content is a oneof, so exactly one arm may be present and both is now sigstore/bad-bundle.
- The same rule holds for verificationMaterial, whose certificate, x509CertificateChain and publicKey are a oneof of the same kind. More than one arm set drew the leaf certificate from one and the chain it is validated through from another, so the certificate whose key was checked and the certificates it was checked through came from different statements about the same signer, and a publicKey beside either certificate arm named a second signer identity the verdict said nothing about.

## v0.6.54 — 2026-09-07

A holder of a composite ML-KEM key can enroll for it, proving possession by opening the challenge addressed to it.

### Fixed

- pki.cmc.build completes an unsigned enrollment for a composite ML-KEM key (RFC 5272 App. C.1). The rule is unchanged: the challenge must be addressed to the key the request asks for, and a challenge opened by any other key licenses nothing. What changed is how the two are compared. A key the runtime engine cannot read has no key object to compare, so the composite SubjectPublicKeyInfo answers instead, which is sound because that encoding is canonical: fixed component widths under a single OID, with no compressed-point spelling for two forms of one key to differ over. That ambiguity is the reason the classical comparison uses key objects, and it does not arise here. Every other key type is compared exactly as before.

## v0.6.53 — 2026-09-07

A composite ML-KEM private key states its public half, so a holder can name the recipient it is.

### Added

- pki.key.publicFromPrivate derives the public key of a composite ML-KEM private key (draft-ietf-lamps-pq-composite-kem section 4.1). A composite private key is the ML-KEM seed followed by the traditional private key, and the public key is the ML-KEM encapsulation key followed by the traditional public key under the same OID. Each half states its own: the seed generates the encapsulation key, and the traditional half is read in the encoding its algorithm uses, an RSAPrivateKey, a SEC1 ECPrivateKey, or the raw scalar of an OKP curve. All twelve registered families are covered, and each derives byte-for-byte the public key the draft's own Appendix G test vectors publish for that key. opts.pem returns it armored, as it does for every other family.
- The derived key is the one the rest of the toolkit takes. It is the recipient pki.cms.encrypt accepts for a composite KEM recipient, and pki.kem.encapsulate establishes a shared secret for it that the private key it came from decapsulates to the same value.

### Changed

- A composite ML-KEM private key reports why it cannot be read, rather than reporting that a public key could not be derived from it. An AlgorithmIdentifier carrying parameters is kem/bad-algorithm, since the draft requires them absent (section 5.3); a key no longer than the ML-KEM seed is kem/bad-key; and an OID no registered family claims is refused rather than guessed at. The private key material and the copy taken of it are wiped before the call returns, on the refusing paths as well as the deriving one.
- Each component is held to what the composite OID names it must be. The RSA component's modulus must be the size that family states, and the EC component must be on the curve it states: a SEC1 ECPrivateKey carries its own curve, and exporting the point of one curve under an OID naming another would state a public key nothing can use. Decapsulation reaches the same conclusion by failing the agreement, and a derivation performs no operation that would notice, so it is checked.
- Every copy of private key material the derivation allocates is cleared before it returns, on the refusing paths as well as the deriving one: the caller's snapshot, the key octets, and the ML-KEM seed re-encoded for the key engine to read. The EC component adds two more, the scalar taken out of the ECPrivateKey and the copy a width check rejects, and a decapsulation refused after its Diffie-Hellman has already run clears that agreement's secret before reporting the refusal.
- The EC component's point is generated from its scalar rather than read from the key, in the derivation and in pki.kem.decapsulate alike. RFC 5915 section 3 gives an ECPrivateKey an optional public point, which may be written compressed, and the composite public key carries the uncompressed point at a fixed width. The combiner hashes that public key, so the two sides read one function for it: a key storing a compressed point now establishes the same shared secret whichever spelling of it a sender encapsulated to. Generating the point also means the answer is the one the scalar produces, so a point stored beside a scalar that does not produce it does not reach the result.

### Fixed

- The X25519 and X448 component of a composite ML-KEM key is imported through an encoding that is now cleared once the import has read it. That encoding holds the component's private scalar and is allocated inside the helper both pki.kem.decapsulate and the new derivation reach the curve through, so a decapsulation left a copy of it behind.

## v0.6.52 — 2026-09-07

A CMP client can accept a key pair the CA generated for it, and refuses one from an authority that never claimed to be one.

### Added

- pki.cmp.openKeyPackage(container, opts) opens a centrally generated private key delivered in a CMP response (RFC 9483 section 4.1.6). The container is the CMS EnvelopedData a CA puts in the privateKey field of a granted CertifiedKeyPair, holding a CMS SignedData over an RFC 5958 AsymmetricKeyPackage. Both layers are undone in order and the authority is authorized before any key material is returned. The result is { keys, kga, trusted }: keys is every OneAsymmetricKey in the package, in order, each its own DER Buffer that is a PKCS#8 PrivateKeyInfo ready for pki.key.import; kga is the signer certificate whose signature was checked; trusted says whether that certificate chained. Each element is parsed as a OneAsymmetricKey before it is surfaced, so a SEQUENCE carrying neither an algorithm nor key octets is refused rather than handed back as a key.
- Both CMS layers are accepted in either form they are seen in. A ContentInfo opens with the OBJECT IDENTIFIER naming its content type; the bare structure opens with its own CMSVersion INTEGER. A CMP EncryptedKey carries the EnvelopedData itself (RFC 9810 section 5.2.2), and RFC 5652 section 6.1 encrypts the content rather than a ContentInfo around it, so the plaintext under an id-signedData encryptedContentInfo is a SignedData. The two forms are structurally exclusive, so neither is guessed at.
- Which key management technique opens the container follows the protection of the request that asked for the key. opts.key, with an optional opts.cert, opens the key transport and key agreement containers a signature-protected request selects (sections 4.1.6.1 and 4.1.6.2); opts.password opens the password container a MAC-protected request selects (section 4.1.6.3). The shared secret is copied at the door and wiped when the call returns, whether the container opened or not.
- The key generation authority is authorized on both of the section's terms. Its certificate must chain to an anchor in opts.trustAnchors, and it must assert the id-kp-cmKGA extended key usage (RFC 9480 section 2.2). A certificate carrying no extendedKeyUsage extension satisfies a requiredEku path constraint, since RFC 5280 section 4.2.1.12 constrains a certificate only when the extension is present, so the assertion is checked separately: an absent, empty, or differently-purposed extension does not authorize. The chain is still held to requiredEku as well, which reaches the intermediates an issuer's own extended key usage constrains.
- opts.trustAnchors and opts.intermediates each take one certificate or a list of them, as DER or PEM, the way pki.cmp.verify takes them. A pki.cmp.session forwards both of its own pools the same way, including an already-parsed certificate, which it hands on as the bytes it was parsed from, and the { name, publicKey, algorithm } anchor tuple, which it passes through; a certificate that went missing on the way would make the authority's chain unbuildable and read as an unauthorized authority.
- pki.cmp.session takes opts.authorizedBySharedSecret too, for the same exemption, and never infers it. A PBMAC1 session that accepts central key generation authorizes the key generation authority against opts.trustAnchors or by the secret that protects its requests, and must say which: a missing anchor list is a missing option, not a decision to drop the chain check and the id-kp-cmKGA assertion. A session with neither is refused when it is built, so the authority never generates a key for a session that cannot accept one.
- opts.authorizedBySharedSecret states the exemption section 4.1.6 gives an entity that protected its request with a shared secret, which MAY authorize the authority by that secret instead of validating its certificate. On pki.cmp.openKeyPackage it applies only to a container opened with opts.password, and it refuses opts.trustAnchors so that one call names one authorization rule; the result reports trusted: false. On pki.cmp.session it pairs with opts.trustAnchors, because there those anchors do a second job the exemption does not touch: they path-validate the issued certificate. A session accepting a generated key pair states one of the two rules, and one holding neither is refused when it is built rather than when the grant arrives, so the authority never generates a key for a session that cannot accept it.
- pki.cmp.session takes opts.acceptCentralKeyGeneration, which drives the whole exchange rather than only the response. The request goes out in the shape section 4.1.6 defines: a certTemplate with no publicKey, or one whose subjectPublicKey is a zero-length BIT STRING stating an algorithm preference, and no proof of possession, since this entity holds no key to prove possession of. It carries cmp2021 in its header, because the response will carry an EnvelopedData (RFC 9480 section 2.20), and so does every later leg of that transaction: a poll asking for the grant expects the EnvelopedData just as the request did, and a resumed poll in a later process is the same transaction. The session then opens the delivered container with the credential it enrolled under, authorizes the authority against the anchors it already holds at the instant opts.time names, and surfaces the result on the enrollment verdict as deliveredKey. A shared-secret session authorizes by its secret when it says so with opts.authorizedBySharedSecret, and never because it holds no anchors. An enrollment that delivered no key reports deliveredKey: null. Without the option, such a request is refused before it is sent and such a grant is refused with cmp/unexpected-arm.
- The issued certificate must certify the delivered private key, checked before the certConf that would accept the grant. The authority's signature says the key package is authentic and says nothing about which certificate the key belongs with, so a mismatched pair is cmp/bad-key-package: the certificate would be unusable with the key the enrollment received. A central key generation transaction submits no public key of its own, so the delivered key is the only thing its grant is bound to, and a granted CertResponse that carries no privateKey is cmp/bad-cert-response rather than a confirmed issuance.
- The pair is proven by USING it, not by deriving the public half and comparing. A key structure states its own public half and the key engine reads what it is told: RFC 5958 section 2 gives a OneAsymmetricKey an optional publicKey, an EC key's RFC 5915 ECPrivateKey carries its own public point, and an RSA private key carries the modulus and public exponent outright, so a structure whose private components were replaced while its stored public ones were left alone derives to exactly the public key planted in it. Instead the two halves perform whichever operation the key type can: a signature the public half verifies (RSA, ECDSA, EdDSA, ML-DSA, SLH-DSA), a Diffie-Hellman agreement reached from both sides (X25519, X448), or a key encapsulation the private half decapsulates (ML-KEM). What that establishes is that the delivered key works with the issued certificate; it is not a statement that every field of the private structure is internally consistent, which no operation reveals.
- One RSA key pair may be identified either by rsaEncryption or by id-RSASSA-PSS (RFC 4055 section 1.2), and the authority that encodes a delivered key need not have chosen what the CA put in the certificate. The key engine reports those two encodings as different key types, so the pair is recognized as one algorithm and exercised under PSS, the padding the restricted encoding permits. What each encoding pins is honored on the section 3.3 terms: the hash and the mask generator must match between the two, while the salt length is a floor rather than a fixed value, so two encodings naming different salt lengths are exercised at the larger and remain a pair. An encoding whose parameters are absent pins nothing, as an rsaEncryption encoding does. Two encodings that pin a different hash or mask generator name no shared operation and are reported as undecidable rather than as different keys, and a different modulus is still refused.
- A central key generation transaction that outlives its process resumes like any other. Its resumeToken records that the grant is bound to the delivered key rather than to a requested one, and carries no requestedSpki, since there is no requested key. A session resuming it must set opts.acceptCentralKeyGeneration too, and a token naming both bindings, or neither, is refused.
- pki.schema.cmp.parse surfaces the encoded EnvelopedData of a CertifiedKeyPair privateKey as certifiedKeyPair.privateKey.bytes, alongside the walked structure, so the container can be opened from the bytes the response carried rather than from a re-serialization.
- The RFC 5958 asymmetric key package content type is registered in the OID registry as aKeyPackage (2.16.840.1.101.2.1.2.78.5).

### Changed

- The container is held to RFC 9483 section 4.1.6 wherever its shape decides who can read the key or what the signature covers: it must be an EnvelopedData, it must name exactly one RecipientInfo, its encrypted content type must be id-signedData, and the signed content type must be id-ct-KP-aKeyPackage. A second recipient is a second party able to open a key generated for this entity, and the count is read past the RecipientInfo: one KeyAgreeRecipientInfo wraps the content-encryption key once per entry in its recipientEncryptedKeys, each for a different recipient (RFC 5652 section 6.2.2), so a container naming one of those with two entries is refused too. The signed content type is what binds the signature to a key package rather than to whatever else the authority has signed. The section's CMS version literals are not enforced, because RFC 5652 section 5.1 and section 6.1 derive those from the signer and recipient forms in use and a conforming sender reaches different values. Its choice-of-form rules, the signer identifier form and the ordering of the certificates the SignedData carries, are not judged either; both live inside the encrypted SignedData rather than in the container bytes, and the result carries only the signer certificate. The number of keys in the package is held where it decides something: section 4.1.6 gives the eContent a sequence of one, and a pki.cmp.session enrollment requires that, since one grant certifies one key and every further key is one the issued certificate says nothing about. pki.cmp.openKeyPackage on its own surfaces a whole RFC 5958 AsymmetricKeyPackage, which is SIZE (1..MAX), and leaves the binding to its caller.

### Fixed

- pki.path.validate refuses a name constraint whose base carries an empty label, instead of treating it as one that matches nothing. An rfc822Name, dNSName, or uniformResourceIdentifier base written with a doubled dot ("..example.com", "a..b.example.com"), a second trailing dot (".example.com.."), or dots alone ("..") now fails the certificate with path/name-constraint-unsupported, and so does an rfc822Name base left with no domain ("user@") or no local part ("@example.com"). No mailbox or host name carries an empty label, so such a base matched nothing, and an excluded subtree built on one excluded nothing: a sub-CA required to carry an exclusion could hold one that inspection renders in full and validation never applies. The permitted direction already failed closed. This is the comparison's own question, which is narrower than the well-formed host name a caller is held to at the door, so a base that is not a host name and still names a namespace the comparison can place, such as a label carrying an underscore, a label edged with a hyphen, or a label longer than 63 characters, keeps being enforced exactly as before. A dNSName base that is a bare dot still names the root of the namespace and still matches every name.
- The refusal of a uniformResourceIdentifier constraint base that is not a fully qualified domain name cites the rule it enforces. RFC 5280 section 4.2.1.10 requires a URI constraint to be specified as a fully qualified domain name and states no such requirement for a dNSName constraint, so .com is a valid dNSName base and is not a valid URI base.
- pki.cmp.build sets cmp2021 on an ir / cr / kur that asks for central key generation. RFC 9480 section 2.20 needs the version when a message exchange uses EnvelopedData, "for the request being sent or for the expected response", and RFC 9483 section 4.1.6 requires it in the header "already for the initial request message". A request carrying a real public key still goes out as cmp2000, unless its proof of possession is the agreeMAC or encryptedKey method, which has always carried the later version. Those three arms are the ones a keyless certTemplate speaks for: a krr names a key to recover and a ccr names one to cross-certify, so omitting the public key there says nothing about who generates a key and leaves the version alone.
- pki.cmp.session claims its one transaction slot before it reads the caller's request object. Reading such an object runs whatever accessors it carries, and an accessor can call back into the same session; a slot claimed only after validation let that re-entrant call open a transaction the outer call then wrote its own state over. The re-entrant call is now refused with cmp/bad-input, on enroll, revoke, info and resumePoll alike. A request refused during validation still leaves the session retryable, as before.
- pki.cmp.session.enroll sends the arm it checked. The request arm and its certTemplate are copied out of the caller's object once, so a field that answers differently on a second read cannot turn a validated client-generated enrollment into a central key generation request the session never admitted. enroll also names an unrecognized request field itself, with cmp/bad-input, rather than leaving it to be dropped.

## v0.6.51 — 2026-09-07

A CMP message that names one key and is protected by another no longer verifies.

### Added

- pki.cmp.verify checks the senderKID of a MAC-protected message against the commonName of its sender field, which is how such a message names the shared secret it was protected with (RFC 9483 section 4.1.5). A senderKID naming another entity, or a sender carrying no commonName to match, is refused with cmp/bad-sender-kid. An anonymous sender is the exception the base protocol names: where the sender is the NULL-DN a requester uses before it knows its own name, the senderKID is a reference number rather than a name (RFC 9810 section 5.1.1), so it is carried and not compared. A KEM-protected message's senderKID is likewise carried and not compared, since no specification says what it names there.
- pki.cmp.verify refuses an opts.time that is not a Date with cmp/bad-input, at the door, rather than letting whichever check reads it first throw a bare TypeError.
- pki.cmp.verify takes opts.messageTimeTolerance, the accepted messageTime skew in seconds. RFC 9483 section 3.5 requires a present messageTime to be close to the receiver's current time but leaves the threshold to the use case, so stating a window turns the check on and a message outside it in either direction is a cmp/bad-message-time verdict. A clock runs fast as readily as slow, so a message dated ahead of the receiver is judged the same as one dated behind it. Without the option the messageTime is not judged, and a message carrying none is unaffected.

### Changed

- A caller that passes opts.signerCert for a message whose senderKID does not match that certificate now receives a cmp/bad-sender-kid verdict where the message previously verified. A sender that sets senderKID to its certificate's subjectKeyIdentifier, which is what the field is for, is unaffected.

### Fixed

- pki.cmp.verify holds a declared senderKID to the key that actually verified the protection, on every path (RFC 9483 section 3.5). A certificate passed as opts.signerCert is now checked against the header's senderKID exactly as one selected from extraCerts already was, so a message naming one key identifier and signed under another is refused with cmp/bad-sender-kid instead of verifying. A signer certificate carrying no subjectKeyIdentifier offers nothing for a declared identifier to name and cannot satisfy one; a message declaring no senderKID is unaffected, since the rule is conditioned on the field being present.
- A message whose extraCerts carry no certificate matching the declared senderKID reports cmp/bad-sender-kid rather than cmp/signer-cert-not-found. The two failures are different: nothing was offered, versus nothing offered carries the identifier the message declares. Reporting them apart keeps one defect from carrying two codes depending on how the certificate would have arrived. pki.cmp.session treats the new code the same way it treated the old one, so a transaction whose response declares a signer the response did not carry still falls back to the certificate the session already holds, and that certificate is put through the same identifier check.

## v0.6.50 — 2026-09-07

A client whose key can only establish secrets can now authenticate its CMP messages with that key.

### Added

- pki.cmp.build(message, { kem }) protects a PKIMessage with a KEM-based message authentication code (RFC 9810 section 5.1.3.4). Pass the KEM private key as a CryptoKey, a PKCS#8 DER buffer or a PEM string, the ciphertext the peer encapsulated to it, and the ML-KEM parameter set it was produced under; the ciphertext is decapsulated, a key is derived from the resulting shared secret, and the message authentication code is computed over the exact ProtectedPart. The protectionAlg is id-KemBasedMac carrying the KemBMParameter that names the derivation, so a peer can reproduce the key from the message alone.
- The derived key is bound to the transaction it belongs to. RFC 9810 requires the derivation context to carry the identifier of the message that delivered the ciphertext, which keeps a key established in one PKI management operation from authenticating another. That is this message's own transaction identifier unless the ciphertext arrived under a different one, which spec kem transactionID names. A message carrying no transaction identifier and naming none is refused rather than bound to nothing, and an optional kemContext travels into the derivation as well as into the parameters.
- pki.cmp.verify(message, { kem: { sharedSecret, transactionID? } }) checks that protection, reporting protectionType kem. The shared secret is the one this side encapsulated to the sender's KEM public key. Verification recomputes over the raw header and body bytes the parser surfaced, never a re-serialization, and compares in constant time. A KemBMParameter that is absent or does not decode, and a key-derivation or message-authentication algorithm outside the accepted set, are failed verdicts rather than defaults; the receiving-side header rules apply as they do to the other two flavors.
- session.info({ kemCiphertext: true }) fetches the ciphertext, so a client can complete the exchange without assembling the general message itself. It sends the id-it-KemCiphertextInfo query with no value, as RFC 9810 section 5.3.19.18 requires, and returns the algorithm and the ciphertext the authority encapsulated. An answer naming a KEM this client cannot decapsulate under, or carrying an empty ciphertext, is refused rather than handed back as something to try; an answer carrying no value at all is the documented absence.
- pki.schema.cmp reads the two structures the exchange uses: KemBMParameter, the parameters of the protectionAlg, and KemCiphertextInfo, the algorithm and ciphertext a peer sends.
- A message carries one protection. Naming kem alongside a signature or a shared secret is refused rather than resolved by precedence, and opts.kem passed for a message protected another way is refused too. The KEM the client decapsulates under is ML-KEM-512, 768 or 1024, under HKDF-SHA256 and HMAC-SHA256; anything else is refused rather than defaulted. The sender chooses the derived key size, so a message naming one short enough to search is refused at both ends rather than protected or verified under it, and the shared secret is accepted in any of the byte shapes an encapsulation returns.

### Changed

- pki.cmp.verify no longer reports id-KemBasedMac as an unsupported protection algorithm, since it now verifies it. id-PasswordBasedMac and id-DHBasedMac are still refused as before.
- pki.cmp.verify takes a shared secret in any byte shape, not only a Buffer or a Uint8Array. An ArrayBuffer, which is what a KEM encapsulation returns, and a view over a larger allocation are both accepted and copied at the door as before. This applies to opts.sharedSecret as well as to the new opts.kem.sharedSecret.

## v0.6.49 — 2026-09-07

A key that cannot sign for itself can still prove possession, by answering the authority's encrypted challenge.

### Added

- pki.schema.cmc.parse decodes the two controls of the challenge-response proof of possession (RFC 5272 section 6.7). An Encrypted POP control surfaces as encryptedPOP with its request, its enveloped challenge, the two algorithm identifiers and the witness; a Decrypted POP control surfaces as decryptedPOP with its body part, algorithm and proof. The enveloped challenge is surfaced as the bytes it arrived as, since it is opened with key material the parser does not hold. Both algorithm identifiers keep their parameters, so a client answers with what it was sent. A control of either kind that is not the structure the section defines is refused with cmc/bad-pop-challenge. The two travel in opposite directions, so an Encrypted POP in a request or a Decrypted POP in a response is refused with cmc/control-misplaced. The challenge is held to what a client would have to answer: its envelope to the CMS EnvelopedData definition, and the request it quotes to the definition of the arm it uses, so a challenge nothing could answer is refused rather than surfaced as one.
- pki.cmc.build(spec, signer) takes spec popChallenge of { challenge, recipient } and answers it: the enveloped proof value is decrypted with the recipient key material, hashed under the witness algorithm the challenge names, and compared with the witness it carries. A value that does not decrypt, or that does not hash to that witness, is not the value the authority issued, so the request is refused with cmc/pop-failed and no proof is sent. The section makes that abort mandatory, and answering is not separable from checking: pki.cmc.build computes a proof only after the witness matches. A Decrypted POP control hand-encoded into spec.controls alongside spec popChallenge is refused, since a message carrying two leaves the authority no proof to check; the same now holds for the Identity Proof and POP Link controls that spec identityProof and spec popLink compute.
- The answer is a MAC over the request the challenge carried, keyed by the decrypted proof value, under the algorithm the challenge named rather than one the client picks. A proof value longer than 64 bytes is truncated to 64 for the key, which the section states and an implementation that skips it will compute a different proof than the authority expects. The control names the certification request in the new request, so a request carrying more than one is refused rather than guessed at. All three request arms bind: a PKCS#10 request by its own bytes, a CRMF one by its message, and an other-message one by its type together with its value. Every way a challenge can be malformed carries cmc/bad-pop-challenge, from bytes that are not DER through a field of the wrong type, so a caller classifies them the same way. RFC 5274 section 4.2 sets what a conforming client answers under: SHA-1 or SHA-256 for the witness, HMAC-SHA1 or HMAC-SHA256 for the proof. Anything else is refused rather than defaulted, as is an identifier carrying a parameter value, since those four algorithms are encoded with the parameters absent or NULL and the answer copies the identifier it was sent. The decrypted proof value and the key derived from it are wiped once the answer is built.
- A PKCS#10 request whose signature algorithm is id-alg-noSignature is accepted when this message answers a challenge for that same request. A decrypt-only key cannot sign the request that asks for its certificate, and RFC 5272 Appendix C.1 fills the signature field with an algorithm that proves nothing and sends the proof through the section 6.7 challenge instead; the request was previously refused with cmc/bad-popo, so the enrollment the challenge exists for could not be made. The challenge has to prove the key the request asks for: section 6.7 encrypts the proof value to that key, so the request is accepted only when the key that opened the challenge is the request's own subject public key. A challenge opened by any other key, or by a password or a key-encryption key, says nothing about the key being certified and does not stand in for the missing signature. The two keys are compared as keys rather than as encodings, so a curve point written in compressed form matches the same point written uncompressed. A key whose public half cannot be derived on the running Node, which today means the composite ML-KEM families, leaves the request refused rather than accepted. Without an answered challenge for that request it is still refused, since it carries no proof of possession at all, and its parameters must be present and encoded as NULL. A request that does carry a signature is still held to it.
- pki.schema.cmc.parse also reports how many recipients the challenge names, as encryptedPOP cmsRecipientCount, so a client answering one knows how many to try without walking the envelope itself.
- The client decrypts with the key alone, which is what the exchange requires: the key being certified has no certificate for a recipient identifier to name. Section 6.7 says the identifier should be the request's subject key identifier, and that form is answered. The alternative it allows, an issuer and serial number whose issuer is encoded as NULL, is not: that is not a Name, and the CMS EnvelopedData definition refuses it. A challenge addressed only that way is refused while parsing, with cmc/bad-pop-challenge naming the reason, and pki.cms.decrypt could not have opened it either. The proof also has to cover the request the message carries: a challenge quoting a different certification request is refused rather than answered for the wrong one.

### Fixed

- pki.schema.cmc.parse reports bytes that are not a CMS carrier as cmc/bad-der rather than cms/bad-der. Every other format parser answers for its own input in its own domain, and this one passed the carrier parser's code through, so a caller matching on the cmc prefix missed the most common malformed input there is.
- pki.cms.decrypt opens a key-agreement recipient whose encrypted key is not the first one listed. One KeyAgreeRecipientInfo can carry an encrypted key for several recipients under a single originator, and a caller decrypting with a private key alone has no recipient identifier to select its own entry by, so only the first entry was tried and a message addressed to that caller was refused as undecryptable. A caller with no certificate is now offered every entry, and AES key wrap's integrity check settles which one belongs to the key, so an entry opens only for the key it was encrypted to. A caller that supplies a certificate is unaffected: its entry is still chosen by recipient identifier. At most 64 entries are tried, since the list arrives with the message; a longer list still opens on an entry within that bound, and one whose only openable entry sits past it is refused with cms/too-many-recipient-keys.
- pki.schema.cms.parse refuses a key-agreement recipient that names no encrypted key. Such a recipient names nobody, so nothing could open the content it is attached to; it now fails with cms/bad-recipient-encrypted-keys while parsing rather than reaching a decrypt that has nothing to try.
- pki.cms.decrypt takes the curve of a key-agreement originator that omitted its parameters from the recipient's own private key. RFC 5753 section 7.1 allows the originator to omit them because the recipient knows the curve, but the curve was read only from a recipient certificate, so a caller decrypting with a key alone was refused with cms/unsupported-algorithm.

## v0.6.48 — 2026-09-06

A CMC enrollment request can be authenticated by a shared secret instead of a signature.

### Added

- pki.cmc.build(spec, { mac: { identifier, secret } }) carries a Full PKI Request in a CMS AuthenticatedData rather than a SignedData (RFC 5272 section 3.2). The recipient is the PasswordRecipientInfo that section requires, the MAC key is generated for the message, and the encapsulated content type stays id-cct-PKIData. The secret is taken as a string or as bytes. The request carries the identifier in an Identification control, since a PasswordRecipientInfo names no recipient and that control is what lets an authority holding secrets for many clients find the right one (RFC 5272 section 6.2.3). A name given anywhere else in the same request, through spec.identityProof.identity or an Identification control written into spec.controls, must be that same name: one request names one client, so a second name is refused rather than chosen between, and a control the caller already wrote is kept rather than duplicated. Because carrying that control also changes how an Identity Proof witness is derived, from the secret alone to the secret and the identity together (RFC 5272 section 6.2.3), a request that carries both derives the witness with the name it carries, whichever of the three places named it. A request also carries one secret: the one its MAC is keyed from and the one its Identity Proof is computed from are the same, and two different ones are refused. Naming a signature and a shared secret in the same call is refused: a request has one carrier, and a client with a key to sign with does not need the other. A renewal cannot use the shared-secret carrier at all, because a renewal carries no Identification or Identity Proof control and its identity comes instead from being signed with the certificate being renewed (RFC 5272 section 3.2(a)); a shared secret names no certificate, so that request would assert an identity by nothing. Every copy the toolkit makes of the secret and of the derived key is wiped once the message is built.
- The key is derived from the identifier encoded as UTF-8 followed by the shared secret (RFC 5272 section 3.2(c)), and pki.cmc.verify applies the same derivation to an opts.recipient given as { identifier, secret }. An implementation that derived the key itself still passes raw key material and is read as before. Deriving it in one place is what keeps a request this toolkit builds and a response it authenticates agreeing about the key: the secret alone is not it, and neither is the identifier alone.

### Changed

- An AuthenticatedData Full PKI Request this toolkit emits is read by OpenSSL: the interop suite checks that openssl cms accepts the message and that openssl names the AuthenticatedData carrier, the id-cct-PKIData content type and the password recipient's key-encryption algorithm from its own tables.

## v0.6.47 — 2026-09-06

A CMP enrollment left waiting can be picked up by a later process instead of started again.

### Added

- A pki.cmp.session poll-timeout verdict carries resumeToken, and session.resumePoll(token) continues that transaction in a later process. The authority's polling interval is waited out before the first resumed request, while the resuming session's own maxTotalWait still bounds it: a remainder longer than that budget times out immediately and hands the same due time on. A further timeout returns another token, so a wait can span any number of processes, and the polls each one spent accumulate across them. Each timeout reports the waiting status, whether or not that process read a certificate response of its own, so a wait split across processes reads the same as one held in a single process. The authority's own statusString and failInfo are not reproduced from the token, so a verdict never presents text as the authority's that it did not send in that exchange. A session that has already run a transaction refuses to resume one, since RFC 9810 section 5.1.1 gives each transaction its own identifier.
- The token is plain JSON, so it survives being written to disk and read back. It names the transaction identifier the exchange is keyed by, the nonce the next request must echo (RFC 9810 section 5.1.1), which request is being polled as a decimal string so an identifier of any width survives, the response arm its grant arrives on, whether the request that opened the transaction asked for implicit confirmation, the polls already spent, the public key the granted certificate must certify, the signer identity the first process authenticated and, separately, the certificate it currently verifies a response with when that response omits its extraCerts, the certificates the authority had already sent including any it delivered only on a waiting leg, and the instant its own checkAfter allows the next poll. It carries no secret, because the protection material, the trust anchors and the endpoint come from the session the caller builds to resume with.
- A resumed poll grants nothing the first process would not have accepted. It protection-verifies and nonce-binds every response before reading it, holds every response to the signer identity the token names, refuses a granted certificate that certifies a different key, and confirms the grant with the same certConf handshake. Those fields are what the resumed session holds the exchange to, so a token is integrity-sensitive and belongs in storage the caller controls: someone who can rewrite it can widen what the resumed session accepts, up to the limit of that session's own trust anchors and the authority's protection, which a rewritten token cannot reach past. Store it where the enrollment's own state is stored, and read it back into a session configured the way the first one was. The token carries what the transaction established and the session supplies the rest, so the protection material, the trust anchors, the endpoint and a sender pinned with opts.expectedSender are all given again: a resumed poll accepts and refuses exactly what an uninterrupted one in the same session would, which means it also refuses a response that session has no way to authenticate.
- A token that cannot carry the transaction is refused with cmp/bad-input before any request is sent, so stored state that cannot be used never spends the nonce the token is the only copy of. That covers a value that is not an object, one that names no transaction, a non-hex transaction identifier or non-canonical base64 nonce, a transaction identifier that is not the 128 bits a first message opens one with or a nonce shorter than the 128 bits a response must carry (RFC 9483 section 3.1 and 3.5), both of which name state no exchange could have produced, a request identifier written in any spelling other than the decimal one a session writes, a response arm that is not an enrollment arm, an unknown field, a key that is not a SubjectPublicKeyInfo, a field that names a certificate and is not one, and more certificates than the list it restores can hold. The two fields that bind the transaction are required rather than optional, since a token lives in storage where it can be edited and dropping a field must not drop the check it feeds: the key the grant is held to, and, for a signature session, the identity every response is held to. A shared-secret session pins no certificate, and its secret is the binding instead. The token also names how the transaction authenticates, and a session that authenticates the other way is refused: a signature transaction is bound by the pinned signer identity and a shared-secret one by the secret, so neither resumes the other's. A token trimmed of that field is read from its own contents, never from the session reading it, since a signature transaction pins the signer it authenticated and a shared-secret one has none to pin. How the grant is confirmed comes from the token rather than from the resuming session's own opts.implicitConfirm, since it was negotiated in the request the token continues: a session that never set the option still confirms an implicitly confirmed grant without a certConf, and one that sets it cannot make a transaction whose request never asked for it accept an implicit-confirm indication. A token that omits the field resumes as though the request asked for nothing. A token that asked for it is refused by a session carrying opts.acceptCert, the same pairing the constructor refuses, since an implicitly confirmed grant leaves no certConf leg for a policy to reject on.

### Changed

- pki.cmp.session refuses an opts.maxResponseBytes that is not a whole number of bytes between 1 and the 24 MiB transport ceiling, at construction, with cmp/bad-input. It is the bound the response reads and the restored certificate pools are measured against, and pki.acme.client already held the option to the same range. A session that passed a fractional or out-of-range value previously carried it as far as the first read.

## v0.6.46 — 2026-09-06

A Sigstore bundle can be checked against the certificate-transparency logs that recorded the signing certificate.

### Added

- pki.sigstore.verifyBundle accepts opts.ctLogs, an array of { keyId, spki, validFor? } in the same shape it takes for the Rekor keys, so an operator builds both from the one trusted-root document. When supplied, the certificate-transparency receipt Fulcio embeds in the signing certificate (RFC 6962 section 3.2) is verified: the logged entry is reconstructed from the certificate as it stood before the receipt was added, bound to the key hash of the certificate that issued it, and checked under the key of the pinned log the receipt names. At least one receipt must verify against a pinned log, which is the RFC 6962 section 3.3 floor. A log's validFor window is read at the instant the receipt was signed, since that is when the key was the log's rather than when the artifact was later logged, and a receipt dated after the instant being validated at is not counted (section 5.2); with no opts.time that instant is the end of the Rekor entry's own second, which allows for the entry recording whole seconds while a receipt carries milliseconds and accepts nothing beyond it. The verdict gains sctChecked, saying whether the check ran, and validScts, counting the receipts that verified. A receipt that verifies against no pinned log, and a certificate carrying no receipt at all, are each refused with sigstore/sct-unverified or sigstore/sct-missing rather than passing quietly. The check is opt-in: a caller that supplies no ctLogs verifies exactly as before and reads sctChecked false, while an empty array is refused with sigstore/bad-input rather than read as a policy that checks nothing.

## v0.6.45 — 2026-09-06

A certification path reaching a certificate signed with a stateful hash-based key validates without a separate call.

### Added

- pki.path.validate verifies a certificate signed with the RFC 9802 stateful hash-based algorithm id-alg-hss-lms-hashsig, and pki.crl.verify a certificate revocation list signed with it, which RFC 9802 section 6 provides for alongside the certificate. The signature covers the whole tbsCertificate rather than a digest of it (section 7.1); the RFC 8554 public key is read from the subjectPublicKey BIT STRING and the signature from signatureValue, neither carrying further ASN.1 wrapping (sections 5 and 6); the algorithm identifier carries no parameters; and the subject public key must name the algorithm the signature names, so a certificate advertising one algorithm over a key of another is refused. A key or signature the engine cannot read is a path/bad-signature verdict, never a pass. Every other section 6.1 check is unchanged, so an expired or otherwise faulty HSS certificate fails for the reason it should. A certificate carrying any of the three stateful hash-based keys, HSS, XMSS or XMSS^MT, is also held to the RFC 9802 section 6 key-usage rule whichever algorithm signed it, reported as the hbsKeyUsage check and refused with path/hbs-key-usage: a certification authority's certificate must assert at least one of digitalSignature, nonRepudiation, keyCertSign or cRLSign, an end entity's at least one of digitalSignature, nonRepudiation or cRLSign, and neither may assert any value outside its own list, so a key offered for encryption is refused. An absent keyUsage extension is unrestricted. The end-to-end proof is the self-signed certificate published in RFC 9802 Appendix A, which now validates through pki.path.validate. Signing remains out of scope: a stateful key's one-time state belongs in hardware that owns it.

## v0.6.44 — 2026-09-06

An enrollment client reaches a certification authority through a proxy that demands Digest authentication.

### Added

- opts.proxy.auth.scheme accepts "digest" alongside "basic", so a client reaches a certification authority through a forward proxy that authenticates with RFC 7616 Digest. The first CONNECT carries no credential; the proxy's 407 carries its Proxy-Authenticate challenge, and the client answers it once, hashing the method CONNECT over the authority-form target (RFC 9112 section 3.2.3) that the proxy checks it against. The challenge is read by the same parser the origin verbs use and held to the same policy, so MD5 and a challenge carrying no qop are refused unless opts.proxy.auth.allowMD5 or allowLegacyQop says otherwise, surfacing transport/proxy-digest-weak-algorithm, transport/proxy-digest-unsupported-algorithm, transport/proxy-digest-no-qop, and transport/proxy-digest-bad-challenge (each under the calling client's own prefix, so a SCEP call reports scep/...). A proxy that offers no Digest challenge while digest is configured is transport/proxy-auth-required naming what it offered, and a rejected credential is transport/proxy-auth-failed after that single answer. Digest requires an https proxy on the same terms as Basic: credentials ride the authenticated channel to the proxy, and a plaintext http proxy carrying auth stays refused with transport/proxy-auth-requires-tls.

### Removed

- transport/proxy-unsupported-scheme is no longer raised. It reported that Digest proxy authentication was unavailable, and Digest is now supported; an unknown scheme is transport/bad-proxy, as it was before.

## v0.6.43 — 2026-09-06

Path building finds a path through a mesh of cross-certified domains without walking the foreign branches first.

### Changed

- pki.path.build orders candidates in a mesh of cross-certified domains by the two RFC 4158 section 3.5 name heuristics it had not applied. A candidate is ranked by how many leading relative distinguished names it shares with the name the search is heading for: its issuer against a trust anchor's name when building forward (section 3.5.16), and its subject against the leaf's issuer when building in reverse (the section 3.5.16 and section 3.5.19 reverse methods). Names are compared under the RFC 5280 section 7.1 rule, so a certificate in the target's own domain is tried before one from a cross-certified domain. Forward building additionally sorts a candidate under every candidate that can complete the path when its basicConstraints path length constraint cannot cover the certificates already below it in the chain (section 3.5.7), counting the non-self-issued ones the way RFC 5280 section 6.1.4(m) does, so a key-rollover certificate below the candidate spends none of its budget. That ordering outranks the other hints rather than competing with them, so a key identifier match or an anchor-adjacent issuer does not raise a candidate whose constraint the path already exceeds. All of it is ordering alone: no candidate is removed, every assembled path is handed to pki.path.validate, and an unbounded search still reaches every path the pool holds. A build over a mesh reaches a path within a smaller opts.maxCandidatesConsidered budget and reports a lower candidatesConsidered. A bounded search spends that budget on whichever branches the order steers it to, so a build that reports path/build-limit where the earlier order succeeded wants a larger opts.maxCandidatesConsidered, and the same holds for the opts.maxAiaFetches allowance.

## v0.6.42 — 2026-09-06

A trust anchor carries the namespace a root program trusts a root for, so a certificate outside it is refused even where the root certificate states no such limit.

### Added

- pki.trust.anchor(entry, opts) accepts opts.nameConstraints, the namespace a root program applies to a root outside any extension the root certificate carries. It is a { permitted, excluded } pair of { tag, base } subtrees, the shape pki.path.validate takes for opts.initialPermittedSubtrees, and validate seeds it as the RFC 5280 section 6.1.1(h)(i) initial value: it intersects with each certificate's own nameConstraints so a leaf must satisfy both, and an excluded subtree rejects a name whatever the permitted set says. A verdict reports it at anchorConstraints.nameConstraintsApplied. The subtrees are copied out of the caller's object, so changing that object afterwards leaves the namespace an anchor already vouches for unchanged. An overlay naming no subtree, one carrying a malformed subtree, and one supplying its subtrees through an accessor are each refused with trust/bad-input, since carrying any of them would widen the namespace the operator asked to restrict.

### Changed

- pki.path.validate refuses a subtree in opts.initialPermittedSubtrees, opts.initialExcludedSubtrees, or a trust anchor's nameConstraints whose base is outside the form its tag names, with path/bad-input. Tag 1 is a mailbox, a host name, or a host name written as a subtree with a leading dot, and a mailbox may name its domain as a bracketed IPv4 or IPv6 address literal; tags 2 and 6 are host names, and a tag-6 base is the dotted host a certificate's URI is compared by rather than a URI; tag 4 names at least one relative name, with dotted-decimal object identifier types and string values; tag 7 is 8 bytes for IPv4 or 32 for IPv6. A host name is labels of letters, digits and hyphens, with a hyphen at neither edge of a label, 63 characters to a label and 253 to the name. An anchor whose nameConstraints names no subtree at all is refused the same way. Such a base previously reached the comparison, where it either matched every name, so a permitted subtree restricted nothing, or matched none, so an excluded subtree kept nothing out. A certificate's own nameConstraints extension is unaffected and is compared as the certificate wrote it.

## v0.6.41 — 2026-09-06

A lookup table answers from its own entries, so a name taken off the wire cannot resolve to something the table never registered.

### Changed

- Every object the toolkit publishes as a table carries no prototype: pki.C.TIME, pki.C.BYTES, pki.C.LIMITS, pki.C.NAMES and each name table under it, pki.asn1.TAGS, pki.asn1.build, pki.cbor.build, pki.ct.HASH_ALGORITHMS, pki.ct.SIGNATURE_ALGORITHMS, pki.hpke.suites, pki.path.PROCESSED_EXTENSIONS and pki.path.TARGET_UNPROCESSED_IF_CRITICAL. The same holds for a map a verb returns that is keyed by names it read, rather than by a fixed set of fields. Among them: the results of pki.oid.all, pki.est.paths and pki.scep.parseCapabilities, the headers on a pki.transport response and on each part pki.est.splitMultipartMixed returns, the counts map on a pki.lint.certificate result, the extensions map on a pki.sigstore.verifyBundle identity, and the purposes map on each anchor from pki.trust.parseCertdata, pki.trust.parseCcadbCsv and pki.trust.anchor. A result read by a fixed set of field names keeps the prototype it had, which covers parsed certificates, decoded WebAuthn authenticator data, CMS recipient identifiers and the other parse records. Reading an entry, calling a member, listing the keys, spreading, destructuring and serializing to JSON are unchanged; calling an Object.prototype method on the object itself, such as table.hasOwnProperty(name), no longer works, so use Object.hasOwn(table, name).

### Fixed

- A lookup table carries no prototype, so a name the wire supplies resolves to an entry the table registered or to nothing. Two verbs were reachable: pki.webauthn.verify selected a verifier the table never held for an attestation naming constructor, toString or another Object.prototype member as its format, and pki.schema.c509.parse accepted a certificate whose subject-public-key algorithm named such a member instead of throwing c509/unknown-algorithm. The same change applies to the signature-scheme, content-encryption, key-agreement, extension-decoder and status-name registries.
- Every verb that takes an injected opts.transport names the option when the value is not callable, refusing it with the module's own bad-input code at the door, before the message or the certificate is read. This covers all eighteen: the six pki.est verbs, the seven pki.scep verbs, pki.acme.client, pki.cmp.transfer, pki.cmp.session, pki.ct.fetchLogList and pki.path.build. Previously most of them let the value reach the call site, where the operator got a bare TypeError naming no option. A falsy transport and an explicit null are refused the same way. Either had been read as no transport supplied and replaced with the default network client, so a caller that believed it had injected a transport reached the CA instead, which is the case worth upgrading for. Only an option no lookup finds falls back, so a defaults object carrying the transport on its prototype still works. A value that passes for callable and then throws when it is called, which is what a class constructor does, is reported as that verb's typed error too, rather than escaping as a TypeError.

## v0.6.40 — 2026-09-05

Every verify verdict now reaches its caller as the object the verb built, and a list the toolkit assembles carries the entries the toolkit put in it.

### Fixed

- The verdicts of pki.attrcert.verify, pki.crl.verify, pki.csr.verify, pki.ct.verifySctWithLogList, pki.ct.verifySctList, pki.path.validate, pki.path.build, pki.pkcs12.verifyMac, pki.pkcs12.open, pki.webauthn.verify, pki.webauthn.verifyAssertion, pki.cmp.verify, pki.tsp.verify, pki.ocsp.verifyRequest and pki.sigstore.verifyBundle end the prototype lookup for then on themselves. An accessor installed there while a verification was pending could otherwise run with the verdict as its receiver and change what the caller reads.
- A list a verdict reports is appended to by defining the entry rather than assigning it, so a setter at the appended index cannot take the value and answer the read with something else. This covers the keys, certificates, CRLs and secrets pki.pkcs12.open returns from an authenticated store, the signers pki.cms.verify reports and the countersignatures under them, the per-SCT results and the distinct operators pki.ct.verifySctList counts its policy against, the per-certificate results pki.path.validate reports and the checks under each of them that name which gate refused, the elements of a compound attestation pki.webauthn.verify returns along with the routes its anchoredTo names, the certificate chain and transcript a pki.cmp session returns, and the signer identities pki.smime.verify reports. The arrays a pki.schema parser fills are not covered: a verify verb re-derives its answer from the bytes and refuses a parse result whose fields were rebuilt, so a substituted entry there decides nothing.
- pki.webauthn.verify builds the fields it learns after the attestation itself is checked into the verdict rather than writing them onto it. bindingChecked, which tells a relying party which of its expectations were enforced, could otherwise be taken by an inherited setter and reported by its getter as a check that never ran. anchoredTo, anchoredElements, metadata, clientData, safetyNet, tpm and compound are built the same way.
- The results of pki.path.crlChecker(...).check and pki.path.ocspChecker(...).check, the build-only result pki.path.build returns under validate:false, and the results of pki.ct.signSct and pki.ct.fetchLogList carry the same protection. Each is handed to a caller, so each ends the prototype lookup for then on itself.
- Path validation, CMS verification, timestamp verification, CMP protection verification and Sigstore bundle verification append to every one of their own lists the same way. Each reads its answer back out of those lists, so the name forms a name-constraint check compares, the CRLs a revocation decision scans, the policy-tree nodes that produce the user-constrained set, the candidate certificates a signer identifier selects, the chains a timestamp is trusted through and the path a Fulcio leaf is anchored by are all written where an indexed setter cannot reach them.

## v0.6.39 — 2026-09-05

A verdict field is created with the verdict rather than written onto it afterward, so a value left on Object.prototype can no longer answer a question the verification never asked.

### Added

- The pki.ocsp.verify and pki.path.verifyOcspResponse verdicts carry valid, true only when status is good. Every failure these verbs detect already reports status unknown, so the boolean covers exactly the checks they run.
- The pki.cmc.verify verdict carries valid, true only for the issued outcome. Every other outcome, pending included, reads as false, with outcome, failInfo and pendToken unchanged.

### Changed

- The pki.ocsp.verify and pki.path.verifyOcspResponse verdicts report thisUpdate and nextUpdate as null, not undefined, when no response covered the certificate. Every path now reports both, so a reader gets the same shape whatever the verdict. Code testing them with === undefined should test == null.
- The container release workflow takes docker/setup-qemu-action v4.3.0.

### Fixed

- A verdict field is now created with the verdict rather than assigned onto it afterward, so a value left on Object.prototype cannot answer a read the verification never wrote. Three verdicts could report an affirmative that had not been established: an OCSP response whose signature does not verify, or that no authorized responder signs, contributed a good status through pki.ocsp.verify, pki.path.verifyOcspResponse and pki.path.ocspChecker, so pki.path.validate accepted a certificate whose revocation status was never determined; pki.cms.verify reported a signer as trusted when it had been given no trust anchors to chain to; and pki.crmf.verifyPop reported verified for a request carrying no proof of possession at all. pki.cmc.verify, pki.smime.verify and the pki.cmp session results carried the same construction and are fixed with them.
- A verdict also ends the prototype lookup for then, so an accessor installed there cannot rewrite it between the verification and the caller.
- A pki.cmp session transcript no longer reports a protection result on a request leg. A request records no verdict, and the snapshot read the field without asking whether the entry carried one, so a value left on Object.prototype was copied in and read back as a result the session never reached.
- The tables that decide how a signature is verified answer from their own entries alone. pki.cms.verify looked its signature scheme up in a table that inherits from Object.prototype, so a message could name an algorithm the toolkit does not implement and have the verification proceed under a descriptor left there. The dispatch tables in pki.cms.verify, pki.path.validate and the composite-signature resolver are fixed.

### Internal

- The test covering the implicit-rejection substitute content key chooses a ciphertext whose outcome it can predict. OpenSSL answers an RSA PKCS#1 v1.5 decode fault with a synthetic key whose length is drawn from the private key and the ciphertext, and a draw equal to the content-key length took the substitute out of the path, so the test failed about once in every few hundred runs.

## v0.6.38 — 2026-09-05

A trust anchor can name the namespace its root is trusted for, and pki.path.validate enforces it as the initial name-constraint state.

### Added

- A trust anchor passed to pki.path.validate or pki.path.build may carry nameConstraints: { permitted, excluded }, each an array of { tag, base } subtrees. They seed the RFC 5280 section 6.1.1(h)(i) initial permitted and excluded state, so a root a program trusts only for a namespace enforces that namespace even though its certificate states no nameConstraints extension. The anchor's subtrees intersect with an intermediate's own extension and with opts.initialPermittedSubtrees; excluded subtrees are a union.
- The pki.path.validate verdict reports anchorConstraints.nameConstraintsApplied, so a stored verdict distinguishes an anchor that named a namespace from one that did not, alongside the distrustAfterApplied and purposeTrustApplied it already carries.

### Fixed

- pki.path.validate and pki.path.build copy a directoryName subtree base and snapshot an iPAddress one before validating, and read opts.initialPermittedSubtrees and opts.initialExcludedSubtrees once rather than again for each trust anchor or candidate path tried. A caller mutating its own subtree list, entry or base while the path is still verifying signatures no longer changes the namespace the validation enforces; the subtrees that bind are the ones read at entry.
- A directoryName subtree base whose rdns is an accessor rather than a data property is refused. Its value was read once to check its shape and again to copy it, so an accessor could answer with a restrictive name the first time and an empty one the second, and an empty directoryName constraint matches every name.
- A subtree list that is a Proxy, that is sparse, or whose entries are accessors is refused, on a trust anchor and on opts.initialPermittedSubtrees and opts.initialExcludedSubtrees alike. Such a list answers its own length and index reads, so it could hold a restrictive subtree while reporting none and leave the namespace unenforced. A directoryName subtree is built from property descriptors alone, so nothing the caller supplies runs while the namespace is read: an accessor, own or inherited, is refused rather than invoked. It is a relative-name sequence of attribute lists and nothing deeper, so an array nested past that, including one that refers to itself, is a typed refusal, as is a hole, an entry the array does not own, an attribute that is not an object, or one lacking its own type and value. Each of those refusals is a configuration error reported wherever the anchor sits among several, rather than a name mismatch a different anchor could mask.
- A parsed certificate passed as a trust anchor is refused if it carries nameConstraints, the way one carrying purposes or distrustAfter already is. A certificate anchor cannot hold that metadata, so accepting it would have dropped the namespace and validated a leaf the anchor was meant to exclude.
- An anchor whose nameConstraints names a field other than permitted or excluded is refused rather than read as unconstrained, and its subtrees are checked while the anchor is normalized, so a malformed one is refused wherever that anchor sits among several rather than only once it is reached.

## v0.6.37 — 2026-09-05

A natively signed C509 certificate is refused when it writes an extension in the generic OID form that the registry can carry as an identifier.

### Fixed

- pki.schema.c509.parse refuses a natively signed certificate (c509CertificateType 2) that writes an extension as an OID and a DER value when a C509 registry identifier carries the same extension value. Draft section 3.7 reserves the generic form for re-encoded certificates, where it stays accepted. An extension whose OID has no registry identifier, and one whose value the compact encodings cannot represent under that certificate type's rules, both keep the generic form.
- pki.schema.c509.encode chooses an extension's form under the rules of the certificate type it is writing. It previously judged whether a value could be written compactly using the rules for a re-encoded certificate, so a natively signed certificate could be given a compact form its own decoder does not accept.
- pki.schema.c509.encode resolves an extension named only by its OID to the matching registry identifier. It looked the identifier up by name alone, so an extension supplied without one was written in the generic OID form even where a compact encoding carried it.
- pki.schema.c509 decides what belongs to the C509 registries by dotted OID rather than by the name pki.oid.register maps that OID to. Registering a different name for a built-in OID previously changed what the toolkit emitted for an unrelated certificate, and for commonName, a curve, ecPublicKey or an ECDSA signature algorithm it stopped a certificate converting to C509 at all.

## v0.6.36 — 2026-09-05

A natively signed C509 certificate is refused when it spells an algorithm generically instead of using the registry entry that names the same value.

### Added

- The subjectPublicKeyAlgorithm on a pki.schema.c509.parse result carries a parameters buffer when the certificate used a C509 registry integer, holding the parameters that entry names: the DER NULL for rsaEncryption and the namedCurve OID for an EC key. The generic OID forms already surfaced this field, so it is now present for every form that has parameters.

### Fixed

- pki.schema.c509.parse refuses a natively signed certificate (c509CertificateType 2) that writes an algorithm as an OID, or as an OID with parameters, when a C509 registry entry names that same value. Draft section 3.7 reserves the generic form for re-encoded certificates, where it stays accepted. An algorithm with no registry entry, and a registry OID whose parameters differ from the entry's, both keep the generic form.
- pki.schema.c509.parse refuses an rsaEncryption subjectPublicKeyAlgorithm that does not carry the DER NULL parameters RFC 3279 section 2.3.1 requires, including the bare OID form that carries none. The type-3 reconstruction previously emitted the OID and a NULL regardless, so the rebuilt DER described an algorithm identifier the certificate had not encoded.
- pki.schema.c509.encode writes an algorithm back as the form naming the same value. It previously chose the form by algorithm name alone, so re-encoding a certificate field by field could move an algorithm that carried no parameters onto a registry entry that names some, and could miss the entry for a key identified by its namedCurve parameters rather than by a curve name.
- pki.schema.c509.encode refuses a serialNumber, RSA modulus or RSA exponent that is not a BigInt. A string was previously read as hexadecimal and truncated to an even number of digits, so a caller passing "abc" received a certificate carrying the serial 0xab, and a number was silently converted. A missing modulus or exponent reached the emitter as an untyped fault rather than a c509 error naming the field.
- pki.schema.c509.encode reports every field it reads off a result as a typed c509 error naming that field. An extensions entry or an RDN that is not an object, an extension carrying neither an OID nor a registry name, an extension OID that is not a dotted-decimal string, and a keyUsageBits that is not a safe integer previously surfaced as a TypeError, a RangeError, or a fault from the OID or CBOR builders. An RDN value that is not a string is refused rather than converted, so a caller passing a number no longer receives a certificate naming its decimal spelling.
- pki.schema.c509.encode refuses a sparse extensions or rdns array. Array iteration skips a hole, so such a list emitted fewer entries than it held without reporting anything.

## v0.6.35 — 2026-09-04

Three redundant C509 spellings are refused, so a certificate has one encoding and an issuer signature cannot cover two different C509 byte strings.

### Fixed

- pki.schema.c509.encode refuses a serialNumberHex that is not an even-length hexadecimal string instead of decoding a prefix of it. An odd-length or partly non-hexadecimal value previously became a different, valid serial number without any error, which the encoder's own re-parse could not catch. The check applies whether the result is re-emitted from its preserved field bytes or built field by field, so a caller is told the value is malformed rather than having it quietly ignored.

### Security

- pki.schema.c509.parse refuses an unsigned bignum whose first octet is 0x00, including a value that is only that octet, so every number has exactly one C509 encoding (draft sec. 3.1.2). A zero serial previously had two spellings that reconstructed byte-identical DER, letting one issuer signature cover two distinct C509 certificates. The rule covers the certificate serial number, the authorityKeyIdentifier serial, and the RSA modulus and exponent.
- pki.schema.c509.parse refuses an RSA subjectPublicKey written as [modulus, 65537] instead of the bare modulus. Draft sec. 3.1.9 omits the array and the exponent when the exponent is 65537, so the array spelling of such a key is a second encoding of one certificate, reconstructing byte-identical DER. An RSA key with any other exponent still uses the array form.
- pki.schema.c509.parse refuses a notAfter written as the epoch 253402300799 rather than the CBOR simple value null. Draft sec. 3.1.5 encodes the no-expiration time 99991231235959Z as null, so the explicit epoch is a second spelling that reconstructs the same GeneralizedTime and therefore the same DER. A notBefore is unaffected, and pki.schema.c509.encode refuses that Date on notAfter instead of emitting a form the decoder rejects.

## v0.6.34 — 2026-09-04

An injected transport that does not return a promise is reported as a typed option error naming the option, in every client that accepts one.

### Changed

- pki.est, pki.acme, pki.cmp, and pki.scep reject with est/bad-input, acme/bad-input, cmp/bad-input, or scep/bad-input when opts.transport returns something that is not a promise of the response, instead of failing with a raw TypeError or a misleading response verdict. A transport returning any thenable is still accepted, and the documented signature now states the promise contract.

## v0.6.33 — 2026-09-04

pki.transport reports the RFC 9266 tls-exporter channel binding of a TLS 1.3 connection, so every connection the toolkit opens now carries the binding its protocol version defines.

### Added

- pki.transport.https reports tls.tlsExporter, the RFC 9266 channel binding of a TLS 1.3 connection (32 bytes, label EXPORTER-Channel-Binding, zero-length context), on its response and on the argument passed to a request body given as a function. It is null below TLS 1.3, where RFC 9266 sec. 2 also requires the RFC 7627 extended master secret that Node cannot confirm, and null when the socket cannot export. tls.tlsUnique is unchanged, so a connection carries exactly the binding its version defines.

## v0.6.32 — 2026-09-04

pki.est.simpleenroll and pki.est.simplereenroll accept a CSR builder, so an enrollment is signed over the tls-unique of the connection it is sent on (RFC 7030 sec. 3.5 channel binding).

### Added

- pki.est.simpleenroll(baseUrl, builder, opts?) and pki.est.simplereenroll(baseUrl, builder, opts?) accept a CSR builder (tls) -> csr | Promise<csr> for RFC 7030 sec. 3.5 channel binding. The builder receives the connection's { protocol, cipher, peerCertificate, tlsUnique } after the handshake and returns the certification request to POST on it, so pki.csr.sign can embed the base64 tls-unique as spec.challengePassword. A redirect or authentication retry re-runs the builder against the new connection (RFC 7030 sec. 3.2.1). Existing calls that pass a DER or PEM request are unchanged.

### Changed

- pki.est.simplereenroll performs its RFC 7030 sec. 4.2.2 subject and SubjectAltName identity check on each CSR a builder produces, inside the post-handshake callback and before that CSR is written. With a pre-built request the check still runs before the transport is called at all.

### Security

- A CSR builder combined with Digest authentication is refused (est/channel-binding-digest-unsupported) instead of sending an enrollment whose binding and Authorization header cannot both be correct: the Digest header is computed over the request body before the handshake the body must be bound to. A builder that throws fails the request (est/csr-builder-failed, with the cause attached) and a typed toolkit error it raises, such as requesting a binding on a TLS 1.3 connection where none exists, keeps its own code. No certification request is written to the wire in any of these cases, so an unbound or mis-bound enrollment is never sent. When an enrollment is retried across connections, the reported failure is the one that actually ended the attempt: a builder error is surfaced only where it caused the failure, so a later connection that fails on its own, for instance during TLS setup before the builder runs, reports that fault rather than an earlier builder error. A response is only ever read as an enrollment outcome when that request's own certification request was actually built and sent. Each request the client makes gets its own builder callback and its own state, so a callback belonging to an earlier request, including one a transport retained and ran late, can never supply the key or the error used to judge the request in flight. If an injected transport absorbs a builder failure, answers without requesting the body, or answers before the builder has finished, neither a 202 queued verdict nor an issued certificate is returned; the call fails closed instead, so enrollment progress is never reported for a request that never went out, and an issued certificate is never accepted against a key the request in flight did not carry.

## v0.6.31 — 2026-09-04

pki.transport reports the RFC 5929 tls-unique of a TLS 1.2 connection and accepts a request body built from it, so a caller can bind an EST enrollment to the TLS session it posts over (RFC 7030 sec. 3.5).

### Added

- pki.transport.https reports tls.tlsUnique on its response (the RFC 5929 tls-unique of a TLS 1.2 connection; null on TLS 1.3, where RFC 5929 defines none) and accepts a request body given as a function (tls) -> bytes, invoked after the handshake and before the body is written. A caller builds a channel-bound CSR from tls.tlsUnique inside the callback and posts it on the same connection (RFC 7030 sec. 3.5), passing the binding to pki.csr.sign as spec.challengePassword. The callback may return a promise, so the CSR can be signed with pki.csr.sign while the connection is held open. A callback that throws, rejects, omits its return, or returns any value that is not bytes or a string fails the request closed rather than posting an empty enrollment; an explicit empty string remains an intentional empty body.

### Fixed

- pki.est.challengePasswordFromTlsUnique now carries reference documentation. It returns a pre-encoded DER challengePassword Attribute, which pki.csr.sign refuses in spec.attributes (csr/bad-input); to sign a channel-bound request, pass the base64 binding as spec.challengePassword, which produces the identical attribute. The two forms are alternatives, and the reference now says so rather than leaving the composition to be discovered.

## v0.6.30 — 2026-09-04

Reference documentation is corrected across the toolkit: a mistyped signed-attribute field name, and options and return fields the code accepts or returns that the reference had omitted.

### Changed

- Options and return fields the code accepts or returns are now documented across pki.cms, pki.tsp, pki.pkcs12, pki.hpke, pki.path, pki.ocsp, pki.asn1, pki.est, pki.acme, and pki.webauthn. In particular, pki.webauthn.verifyAssertion is documented as checking the challenge and origin when expectedChallenge / expectedOrigin / expectedTopOrigin are supplied with clientDataJSON.
- Reference prose across the toolkit and the documentation site is reworded for consistency.

### Fixed

- pki.smime.sign documented additionalSignedAttributes as [{ oid, values }]; the code reads the type field, so a caller following the reference would pass an unrecognized field. The correct form is [{ type, values }].
- pki.ocsp.buildErrorResponse is documented as returning a Buffer; the signature previously showed Buffer | string, but the verb has no PEM option and every path returns DER.
- The pki.schema.all example output lists csrattrs, which the enumeration returns between cmp and ocsp-request.
- The pki.acme.client method list documents updateAccount and listOrders, which the client returns.
- pki.tsp.verify now includes policyName on a failure verdict; it already returned the resolved policy name on a valid verdict and the raw policy on both, so a refused token carries the same descriptive fields as a valid one.

## v0.6.29 — 2026-09-03

The EST, ACME, and CMP clients reach a CA through a forward HTTP proxy, with the same opts.proxy the SCEP client already accepts.

### Added

- pki.est.cacerts / simpleenroll / simplereenroll / serverkeygen / csrattrs / fullcmc, pki.acme.client, pki.cmp.transfer, and the pki.cmp.session client (which forwards it to every enrollment, polling, revocation, and confirmation leg) accept opts.proxy = { url, auth?, tls? } to reach the CA through a forward HTTP proxy, the same option pki.scep accepts. The origin TLS is negotiated inside the proxy's CONNECT tunnel under the identical origin trust policy; Basic authentication (RFC 7617) is sent only over an https:// proxy verified against proxy.tls, and an http:// proxy is tunnel-only. The option is deep-copied at the call so a later change to the caller's object cannot alter the proxy in use.

## v0.6.28 — 2026-09-02

pki.path.build returns the best accepted path when the candidate cap is reached during policy ranking, instead of discarding it.

### Fixed

- pki.path.build with userInitialPolicySet no longer throws path/build-limit after it has already accepted a valid path. When the candidate cap is reached during policy ranking, it returns the best-policy accepted path (forward and reverse directions), and candidatesConsidered reflects the full expansion count. It throws path/build-limit only when no valid path was accepted before the cap. This restores the pre-0.6.26 behavior for a policy set that no single path fully covers under a bounded search.

## v0.6.27 — 2026-09-02

The SCEP client can reach a CA through a forward proxy, with Basic authentication over an https proxy.

### Added

- pki.transport gains forward-proxy support: request.proxy = { url, auth?, tls? } opens a CONNECT tunnel to the proxy and negotiates the origin's TLS inside the tunnel under the same trust anchors and rejectUnauthorized as a direct connection, so the proxy relays the encrypted origin session without being able to read it and cannot substitute the origin certificate.
- Basic proxy authentication (RFC 7617) is sent only over an https:// proxy, whose certificate is verified against proxy.tls (its own trust anchors, separate from the origin's), so credentials ride the authenticated TLS-to-proxy channel. A plaintext http:// proxy is tunnel-only; supplying auth for one is refused with transport/proxy-auth-requires-tls rather than exposing the credentials to the proxy hop. A proxy certificate that does not verify is transport/proxy-tls-failed.
- pki.scep threads opts.proxy through every network verb (getCACaps, getCACert, getNextCACert, enroll, renew, getCert, getCrl). A malformed proxy option (scep/bad-proxy), a non-2xx CONNECT (scep/proxy-connect-failed), a 407 with no or rejected credentials (scep/proxy-auth-required, scep/proxy-auth-failed), and a plaintext-http origin (scep/insecure-url) each fail closed. Digest proxy authentication is not included in this release.

## v0.6.26 — 2026-09-02

pki.path.build can prefer the certification path whose policies satisfy a caller-supplied policy set.

### Added

- pki.path.build now uses opts.userInitialPolicySet to select the best-policy valid path across trust anchors and intermediates (RFC 4158 sec. 4), not only to constrain pki.path.validate's policy processing. It returns the accepted path whose user-constrained policy set best satisfies the requested set, with the existing candidate ordering as a deterministic tie-break; validate still gates every path. An anyPolicy entry is unconstrained (first accepted path); the ranking is bounded by maxCandidatesConsidered; a malformed userInitialPolicySet is refused with path/bad-input.

## v0.6.25 — 2026-09-02

pki.path.build can search from a trust anchor toward the leaf, not only from the leaf toward an anchor.

### Added

- pki.path.build accepts opts.direction: "forward" (default), "reverse" (search from a trust anchor toward the leaf, RFC 4158 sec. 3.1), or "auto" (pick by first-hop fan-out). Every direction hands each assembled path to pki.path.validate, so the direction changes only search order, never which paths are accepted; the anchor is excluded from the returned path. Reverse building is pool-only and does not combine with fetchAia.

## v0.6.24 — 2026-09-02

A caller option of an unexpected type now yields the module's typed error, not a native TypeError.

### Fixed

- An option of an unexpected type passed to a build, sign, export, or enrollment verb (a BigInt, an object with no primitive form, or one whose Symbol.toPrimitive throws) is reported as the module's typed bad-input error instead of a native TypeError leaking from a diagnostic string, a lookup-table key, a Date conversion, or a numeric coercion. This covers the timestamp, OCSP, CMP, CMC, SCEP, EST, ACME, PKCS#12, S/MIME, attribute-certificate, HPKE, Certificate Transparency, and Sigstore APIs.

## v0.6.23 — 2026-09-02

The SCEP client can carry a PKIOperation over HTTP GET for a CA that does not support POST.

### Added

- pki.scep.enroll / renew / getCert / getCrl accept httpMethod: "POST" (default) or "GET". Under "GET" the client sends the PKIOperation message as GET SCEPPATH?operation=PKIOperation&message=<base64-CMS> with no body (RFC 8894 sec. 4.1), for a CA that does not advertise POST support. POST stays the default and the recommended transport; a bad httpMethod is refused with scep/bad-input.

## v0.6.22 — 2026-09-02

pki.acme.client gains scheduleRenewal, the RFC 9773 auto-sleeping certificate-renewal loop.

### Added

- pki.acme.client(...).scheduleRenewal(certDer, { random, shouldStop, renew, maxChecks, maxWait, longTermRetrySeconds, temporaryBaseSeconds }) is the RFC 9773 sec. 4.1 auto-sleeping renewal loop. It composes renewalWindow and the client's injectable sleep/clock: fetch the ARI decision, sleep until the sooner of the selected instant and the clamped Retry-After, refetch until renewNow, then resolve { reason: "renew-now", decision }. It resolves { reason: "expired" } once the certificate passes its notAfter (a client must not check RenewalInfo after expiry, RFC 9773 sec. 4.3), { reason: "stopped" } when shouldStop() signals the certificate is replaced, and { reason: "budget" } at the optional maxChecks or maxWait bound. An invalid suggested window or a transport error retries on the sec. 4.3.3 schedule, with a 5xx backing off exponentially from temporaryBaseSeconds and every other error waiting longTermRetrySeconds (default six hours). A supplied renew(decision) callback is awaited at renew-now instead of resolving, and returning a new certificate DER reschedules on it.

## v0.6.21 — 2026-09-01

WebCrypto and the signing and format builders refuse an algorithm, curve, digest, or profile name that collides with an inherited Object.prototype property name.

### Fixed

- pki.webcrypto (generateKey, sign, verify, encrypt, decrypt, deriveBits, deriveKey, wrapKey, unwrapKey, importKey, encapsulateBits, decapsulateBits) refuses an algorithm name or namedCurve that collides with an inherited Object.prototype property name, returning webcrypto/not-supported instead of a bare Node error. The digest, PBMAC1 PRF, CMP body, PKCS#12 MAC hash, PBES2 PRF, and lint-profile lookups behind pki.tsp.sign, pki.cmp.build, pki.pkcs12.build, pki.key.encrypt, pki.lint.certificate, and the RSA-PSS and C509 builders are hardened the same way, each returning the verb's typed error for such a name.

## v0.6.20 — 2026-09-01

A vulnerable build-time dependency is refreshed; it is not part of the published package.

### Security

- browserslist, a transitive development dependency of the fuzzing harness, is updated from 4.28.4 to 4.28.8, clearing GHSA-73wf-gq98-2v4g and GHSA-c83g-rgw3-j3cx (both High). browserslist is a build-time and test-time dependency and is not part of the published package.

## v0.6.19 — 2026-09-01

pki.scep.build issues the CA-side CertRep response, so the toolkit produces the SCEP certification-authority reply the client enrollment verbs consume.

### Added

- pki.scep.build({ messageType: "CertRep", pkiStatus, transactionId, recipientNonce, ... }) issues a SCEP CertRep response (RFC 8894 sec. 3.3.2). SUCCESS takes certificates (and/or crls, leaf identified by content since the CMS certificate SET is DER-ordered) plus the requester's recipient certificate and the CA signer; the issued certificates ride a certs-only SignedData inside a pkcsPKIEnvelope encrypted to the requester. FAILURE takes a failInfo (and optional failInfoText) and PENDING takes neither; both omit the pkcsPKIEnvelope and are signed detached. recipientNonce (echoing the request's senderNonce) and transactionId are required; a missing failInfo on a FAILURE, a SUCCESS without a certificate/CRL or recipient, an envelope field on a FAILURE or PENDING, or an unknown pkiStatus or failInfo is refused with a typed scep/bad-input.

### Fixed

- An enumerated option whose value collides with an inherited Object.prototype property name, such as a pkiStatus, contentEncryptionAlgorithm, macAlgorithm, digestAlgorithm, PKCS#8 cipher, or ACME resource kind of "toString" or "constructor", is now refused with the verb's own typed bad-input error across pki.scep.build, pki.cms.encrypt, pki.cms.authenticate, pki.key.encrypt, pki.acme.validate, and pki.acme.assertTransition. Such a value previously resolved to the inherited method and the call surfaced an unrelated error, and pki.acme.validate accepted the kind and returned without checking any field.
- pki.scep.build now requires a primitive-string pkiStatus. A boxed String or an object that coerces to a status name is refused with scep/bad-input, instead of building a CertRep whose signed pkiStatus attribute claims SUCCESS while the pkcsPKIEnvelope that a SUCCESS response requires is absent.

## v0.6.18 — 2026-09-01

pki.cms.encrypt and pki.cms.decrypt envelope to and open a composite ML-KEM recipient, extending the CMS KEMRecipientInfo arm to the hybrid post-quantum key-establishment algorithms.

### Added

- pki.cms.encrypt accepts a recipient certificate carrying a composite ML-KEM public key (id-MLKEM768-* or id-MLKEM1024-*, twelve algorithms pairing ML-KEM-768/1024 with RSA-OAEP 2048/3072/4096, ECDH over P-256/P-384/P-521 and brainpoolP256r1/P384r1, X25519, or X448) and emits an id-ori-kem KEMRecipientInfo: the composite as the kem algorithm with absent parameters, the composite ciphertext, HKDF-SHA256 as the kdf, and AES-256 key wrap with a 32-octet key-encryption key (RFC 9629 / RFC 9936). The recipient certificate must assert the keyEncipherment key usage.
- pki.cms.decrypt opens a composite ML-KEM KEMRecipientInfo with the recipient's composite PKCS#8 key, recovering the content-encryption key through the same KDF and unwrap. A wrong key, a tampered ciphertext, or a traditional-component decapsulation failure returns the uniform cms/decrypt-failed verdict, never a distinguishing error.

## v0.6.17 — 2026-09-01

pki.scep.getCert and pki.scep.getCrl retrieve an already-issued certificate or a CA's CRL over SCEP, sending RFC 8894 GetCert and GetCRL queries and reading the result out of the CA's signed CertRep.

### Added

- pki.scep.getCert(baseUrl, opts) retrieves an issued certificate over SCEP (RFC 8894 sec. 3.3.4). Name the target by { certificate } (the certificate whose issuer and serial identify it) or by { issuer, serialNumber } directly; the serial accepts a BigInt, a number, or a hex string. The CA response is verified against caCert (or responderCert), its recipientNonce must echo the query's fresh senderNonce, and the returned certificate is matched to the requested issuer and serial before it is handed back. A response carrying no matching certificate throws scep/cert-not-found; more than one throws scep/ambiguous-cert.
- pki.scep.getCrl(baseUrl, opts) retrieves a CA's CRL over SCEP (RFC 8894 sec. 3.3.4), named the same way as getCert. The response is authenticated identically, and the returned CRL is bound to the query: getCrl returns the CRL whose issuer matches the requested CA, throwing scep/no-crl when the response carries no such CRL and scep/ambiguous-crl when it carries more than one. Per RFC 8894 sec. 2.7 a client should compose a GetCRL only when the CA supports neither a CRL distribution point nor HTTP access; prefer those and pki.schema.crl.parse where available.
- pki.scep.build and pki.scep.parse now handle the GetCert (messageType 21) and GetCRL (messageType 22) queries: a pkcsPKIEnvelope carrying an IssuerAndSerialNumber (a CMS SEQUENCE of the issuer Name and the certificate serial), signed under the transaction attributes (RFC 8894 sec. 3.3.4).

### Changed

- pki.scep.parse accepts every messageType the RFC 8894 registry defines, including GetCert and GetCRL. A messageType outside the registry is still refused with scep/bad-message-type.

## v0.6.16 — 2026-09-01

pki.scep.enroll and pki.scep.renew poll a PENDING enrollment to its issued certificate, sending RFC 8894 CertPoll requests until the CA answers or a bounded poll-count and wait budget is exhausted.

### Added

- pki.scep.build and pki.scep.parse now handle the CertPoll (GetCertInitial, messageType 20) request: a pkcsPKIEnvelope carrying an IssuerAndSubject (the CA's subject name and the request's subject name), signed under the transaction attributes with the original transactionID (RFC 8894 sec. 3.3.3).
- pki.scep.enroll and pki.scep.renew poll a PENDING response to a terminal status. New options bound the loop: pollCount (maximum CertPoll requests, default 5), maxTotalWait (total Retry-After sleep budget in seconds), sleep (an injectable (ms) -> Promise sleeper), and onRetryAfter (an observer of each delay). A budget exhausted with no terminal response throws scep/poll-exhausted carrying the transactionId to resume with. For a deployment whose request is encrypted to a separate RA certificate, issuerCert names the issuing CA the CertPoll must carry (default caCert).

### Changed

- pki.scep.enroll and pki.scep.renew poll by default when the CA answers PENDING: a PENDING enrollment now resolves to the issued certificate or throws, rather than returning { status: "PENDING" } for the caller to retry. Pass pollCount: 0 to restore the single-shot PENDING return and drive the retry yourself.

## v0.6.15 — 2026-09-01

Verifying a Sigstore bundle no longer matches a pinned identity against a non-text subjectAltName value; the value is surfaced as null so the policy fails closed instead of comparing raw bytes. The library source also ships without its explanatory comments, keeping the JSDoc that generates the API reference and the SPDX license headers.

### Changed

- The library source no longer carries explanatory comments; the wiki-generating JSDoc blocks and SPDX license headers are unchanged, so the installed package is smaller.
- Internal cleanup with no observable difference: a dead helper was removed, and the android-safetynet certificate decoder now passes its message factory to the base64 input guard rather than an error class.

### Fixed

- Verifying a Sigstore bundle no longer reads a non-text subjectAltName otherName value as the certificate identity. The value is surfaced as null, and an identity policy that pins the san field fails closed against it. A value whose raw bytes matched a pinned identity could previously satisfy the policy.

## v0.6.14 — 2026-09-01

pki.scep.getNextCACert retrieves a SCEP CA's next (rollover) certificate and authenticates it against the current CA key, so a client can obtain and hold the CA certificate to install before the current one expires.

### Added

- pki.scep.getNextCACert(baseUrl, opts) performs the RFC 8894 sec. 4.7 GetNextCACert exchange for CA key rollover: a GET for the CA's next certificate whose SignedData response is verified and pinned to the current CA certificate (opts.caCertificate) before the next CA certificate(s) are returned in certificates. opts.caCertificate is required, and a response signed by any key other than the current CA is refused (scep/untrusted-signer), so a rollover certificate that would become a future trust anchor is never returned without authentication.

## v0.6.13 — 2026-09-01

pki.sigstore.verifyBundle now reports which transparency-log entry attested a bundle: the verdict carries `logIndex` and `logId` so a caller can locate the exact Rekor record it verified against.

### Added

- pki.sigstore.verifyBundle's verdict carries `logIndex` (the attested Rekor entry's global log index) and `logId` (the log's key id, hex) beside `integratedTime`, so a caller can fetch or audit the exact transparency-log record the bundle was verified against instead of re-deriving it.

## v0.6.12 — 2026-09-01

Three verify verbs that returned a bare boolean now return a verdict object naming the checks they had hidden, a canonical `valid` field is present on every object verify verdict so `if (res.valid)` reads the same everywhere, and revocation, digest, and provenance data omitted before is surfaced.

### Added

- Every object verify verdict now carries a canonical `valid` boolean beside its existing terminal, so `if (res.valid)` reads the verdict the same way across the toolkit: pki.sigstore.verifyBundle, pki.csr.verify, pki.crmf.verifyPop (top and per message), pki.attrcert.verify, pki.webauthn.verify, pki.webauthn.verifyAssertion, pki.ocsp.verifyRequest (`valid` is `signed && signatureValid`), pki.ct.verifySctList (`valid` is `policyOk`), and pki.pkcs12.open (`valid` is the store's verified integrity: the password MAC for a password store, or the CMS signature for a public-key store). The existing fields (`verified`, `policyOk`, `macVerified`, and the rest) are unchanged. The multi-state string verdicts (pki.ocsp.verify `status`, pki.scep `status`) keep their terminal and do not gain a `valid` alias, since a boolean cannot carry their states.
- pki.ocsp.verify and pki.path.verifyOcspResponse surface `revocationTime` (the revocation instant) beside `revocationReason` on a revoked verdict, for long-term validation.
- pki.cms.verify's primary signer node carries `digestAlgorithm`, the digest the countersignature node already reported, so a caller reading a signer's verdict learns it without a second parse.
- pki.sigstore.verifyBundle carries `predicateTypeChecked`, so a caller that did not pin `opts.predicateType` learns the in-toto predicate went unverified rather than assuming it was checked.

### Changed

- pki.crl.verify returns `{ valid, issuerMaySign, signatureValid, issuer, code?, reason? }` instead of a bare boolean. The boolean hid whether the signing certificate was this CRL's issuer and asserted `cRLSign` in its keyUsage, so a CRL minted under an end-entity certificate of the same CA verified as that CA's own. `valid` is `issuerMaySign && signatureValid`; the signature is now checked even when the issuer may not sign, so `signatureValid` is always reported. To upgrade, read `res.valid` where you read the boolean. This is a silent break for an unmigrated `if (await pki.crl.verify(...))`, which now reads an object as always-true.
- pki.pkcs12.verifyMac returns `{ valid, macAlgorithm, macAlgorithmName, iterationCount }` instead of a bare boolean, so a caller can reject a legacy SHA-1 integrity MAC by inspecting `macAlgorithmName` rather than accepting any authenticated store. To upgrade, read `res.valid`. Silent break for an unmigrated boolean test.
- pki.ct.verifySctWithLogList returns `{ valid, logId, logIdHex, operator, logState, timestamp }` instead of a bare boolean, surfacing which trusted log accepted the SCT and when for a policy decision. To upgrade, read `res.valid`. Silent break for an unmigrated boolean test.

## v0.6.11 — 2026-08-31

pki.path.validate and pki.tsp.verify now take the trust store under the option name trustAnchors, the spelling the other verify verbs already use, accepting one anchor or an array; and pki.acme.client gains opts.resignKeys, which re-signs a request under an alternative account-key algorithm when a CA reports badSignatureAlgorithm.

### Added

- pki.acme.client accepts opts.resignKeys, an ordered array of { alg, key } alternatives for the account key (RFC 8555 section 6.2). When a CA rejects the account's JWS algorithm with a badSignatureAlgorithm error and lists the algorithms it supports, the client re-signs the request once with the first listed alternative the CA supports and retries. Each key is a CryptoKey for the same account key material under that algorithm, so the account's registered public key still verifies the re-signed request. The caller's order selects the algorithm and the CA's list only filters it, so a spoofed badSignatureAlgorithm cannot force a weaker algorithm (RFC 8555 section 10). Without the option, or when no listed algorithm is a caller alternative, the error surfaces unchanged.

### Changed

- pki.path.validate and pki.tsp.verify read the trust anchor from opts.trustAnchors, the same option the other verify verbs take. It accepts a single anchor tuple or root certificate, or a non-empty array of them; when several are supplied, pki.path.validate selects the anchor that issued the path's top certificate, and a one-element array reproduces the previous single-anchor result exactly. The former singular opts.trustAnchor is removed and is refused as an unknown option rather than silently ignored, so a stale caller gets a named error instead of an unanchored valid result. To upgrade, pass { trustAnchors: anchor } (or { trustAnchors: [a, b] }) where you passed { trustAnchor: anchor }.

## v0.6.10 — 2026-08-31

pki.est computes a response body's length from a single read of res.body, shared by one helper across every verb, so a caller-supplied transport whose res.body getter changes between reads cannot desync the type check from the measured length.

### Fixed

- pki.est reads a response body's length from a single access of res.body rather than re-reading it across the null / string / BufferSource type-check arms. A caller-supplied transport whose res.body getter returns different values across reads could otherwise size the body from a different value than the one the type check classified. The five identical per-verb length computations are consolidated into one shared helper that reads res.body once.

## v0.6.9 — 2026-08-31

pki.cmp.verify holds a received PKIMessage to the RFC 9483 sec. 3.5 header rules, and pki.path.build and pki.est read a response body an injected transport returns as any BufferSource, not only a Node Buffer.

### Changed

- pki.cmp.verify holds a received PKIMessage to the RFC 9483 sec. 3.5 receiving-side header rules once its protection verifies: the pvno must be cmp2000(2) or cmp2021(3), the transactionID must be present, and the senderNonce must be present and carry at least 128 bits. A message failing any of these is a fail-closed verdict (cmp/unsupported-version, cmp/bad-transaction-id, cmp/bad-sender-nonce). A cmp1999(1) message stays valid RFC 9810 syntax that pki.cmp.parse still decodes; the profile refuses it only on receipt. pki.cmp.session applies these checks to every response and additionally refuses a waiting ip/cp/kup CertResponse that carries failInfo (RFC 9483 sec. 4.4). A conformant peer is unaffected; a peer that omits a required header field is now refused.

### Fixed

- pki.path.build's opt-in AIA caIssuers fetch reads a certificate an injected transport returns as any BufferSource (a Uint8Array, a raw ArrayBuffer, a DataView), not only a Node Buffer. A typed-array body was coerced to a comma-joined string and failed to parse, silently dropping an AIA-discoverable intermediate from the built path; it is now re-viewed through the byte guard and parsed from its bytes.
- Every pki.est network verb reads a response body an injected transport returns as any BufferSource, not only a Node Buffer or a string. The transfer decode, the multipart splitter, the response-size checks, and the error diagnostic take the raw bytes through the byte guard, so a cacerts, enrollment, or serverkeygen response delivered in a typed array is parsed and sized from its bytes.

## v0.6.8 — 2026-08-31

pki.cmp.build assembles the ccr cross-certification request body (RFC 9810 sec. 5.3.11), and the ACME client accepts a response body an injected transport returns as any BufferSource, not only a Node Buffer.

### Added

- pki.cmp.build assembles the ccr cross-certification request body (RFC 9810 sec. 5.3.11): a CertReqMessages under PKIBody [13], the request one certification authority sends to have another certify its existing public key. It carries the same certificate template and proof-of-possession as an ir or cr, and refuses the private-key-transport encryptedKey proof-of-possession, since a ccr does not send the requesting CA's private key. It is a CertReqMessages at the sec. 5.3.11 normative floor, so it requires no Appendix D.6 profile field and does not enforce the optional D.6 single-request cardinality: a multi-request ccr builds and round-trips, and a producer that follows App. D.6 sends one cross-certificate per message by its own policy.

### Changed

- pki.schema.cmp.parse and pki.cmp.build no longer enforce the App. D.6 single-CertResponse cardinality on a ccp cross-certification response. A CertRepMessage is SIZE (1..MAX) and App. D.6 is optional, so a multi-response ccp is parsed and built at the RFC 9810 sec. 5.3.12 normative floor, matching the new ccr treatment. The sec. 5.3.12 no-private-key restriction is unchanged.

### Fixed

- The ACME and SCEP clients read a response body an injected transport returns as any BufferSource (a Uint8Array, a raw ArrayBuffer, a DataView), not only a Node Buffer or a string. Both the response-size check and the body decode take its raw bytes through the byte guard, so a BufferSource response is measured and parsed from its bytes rather than from a comma-joined string, and the size check accepts the same forms the decode does.

## v0.6.7 — 2026-08-31

pki.scep gains the SCEP HTTP client verbs (RFC 8894): getCACaps, getCACert, enroll, and renew drive a certificate enrollment against a SCEP CA over the shipped pkiMessage codec.

### Added

- pki.scep.getCACaps(baseUrl, opts) queries a SCEP CA's advertised capabilities (RFC 8894 sec. 3.5) and returns the parsed keyword set; pki.scep.parseCapabilities(text) exposes the parser directly. The response is unauthenticated (sec. 7.5), so the verb reports what the CA claims without downgrading on it; pass expectSCEPStandard or requireStrongProfile to fail closed when the strong profile is absent.
- pki.scep.getCACert(baseUrl, opts) retrieves the CA certificate, either a single DER certificate or a certs-only chain (RFC 8894 sec. 4.2). Pass an out-of-band expectedFingerprint (a hex string or Buffer, SHA-256 by default) and a returned certificate must match it or the response is refused (RFC 8894 sec. 2.2); omitting it returns the certificates for the caller to authenticate.
- pki.scep.enroll(baseUrl, opts) and pki.scep.renew(baseUrl, opts) POST a PKCSReq or RenewalReq to ?operation=PKIOperation and read the CertRep. The response signature is authenticated against the CA certificate and its recipientNonce must echo the request's senderNonce; on SUCCESS the issued certificate is selected out of the response by SubjectPublicKeyInfo match, a FAILURE throws scep/enrollment-failed carrying the CA's failInfo, and a PENDING returns { status: "PENDING", transactionId } to retry. The content key is transported under RSAES-OAEP, so the CA certificate must be an RSA keyEncipherment certificate.

### Fixed

- The EST, SCEP, and ACME client verbs override the mTLS client certificate, key, and pinned servername on a cross-origin redirect rather than omitting them. A transport constructed with a default client credential (for example pki.transport.https({ tls: { cert, key } })) no longer presents that credential to the redirected origin.

## v0.6.6 — 2026-08-31

pki.kem establishes a shared secret with composite ML-KEM (draft-ietf-lamps-pq-composite-kem): a post-quantum ML-KEM hybridized with a traditional RSA-OAEP, ECDH, X25519, or X448, so the secret holds if either component is later broken.

### Added

- pki.kem.encapsulate(publicKey) establishes a 256-bit shared secret for a recipient's composite ML-KEM SubjectPublicKeyInfo, returning the secret and a ciphertext to send to the recipient (draft-ietf-lamps-pq-composite-kem).
- pki.kem.decapsulate(privateKey, ciphertext) recovers the shared secret from a composite ML-KEM ciphertext and the composite PKCS#8 private key. The twelve algorithms pair ML-KEM-768 and ML-KEM-1024 with RSA-OAEP 2048/3072/4096, ECDH over P-256/P-384/P-521 and brainpoolP256r1/P384r1, X25519, and X448.

## v0.6.5 — 2026-08-31

pki.cmp.build assembles a CMP key-recovery request (krr) body: a CertReqMessages under PKIBody tag [9], the key-recovery counterpart of an initialization request (RFC 9810 sec. 5.3.7).

### Added

- pki.cmp.build accepts a { krr } body arm (RFC 9810 sec. 5.3.7): a key-recovery request, a CertReqMessages built through pki.crmf.build under PKIBody tag [9], identical in syntax to an initialization request. The proof-of-possession key is a key field on the arm spec, and any proof-of-possession arm the CRMF builder produces is permitted, including a private-key-transport encryptedKey proof.

## v0.6.4 — 2026-08-31

The ACME client gains account update and order listing: pki.acme.client updates an account's contacts (RFC 8555 sec. 7.3.2) and fetches the account's orders list, following the paginated Link: rel="next" chain (sec. 7.1.2.1).

### Added

- pki.acme.client(...).updateAccount(opts) updates an ACME account (RFC 8555 sec. 7.3.2): a kid-signed POST to the account URL that sets opts.contact (RFC 6068 mailto hygiene applies; an empty array clears all contacts) and returns the resulting account object. The fields the server MUST ignore are refused at the door with a typed acme/bad-input naming the correct path: status (use deactivateAccount, sec. 7.3.6 is the only client-settable status), termsOfServiceAgreed (not client-updatable; a changed-terms CA answers a request with a userActionRequired problem, surfaced as acme/server-problem, rather than a client re-agreement), orders (server-assigned), and externalAccountBinding (not client-updatable). An unrecognized option is refused rather than sent.
- pki.acme.client(...).listOrders(ordersUrl, opts?) fetches an account's orders list (RFC 8555 sec. 7.1.2.1) via POST-as-GET and returns { orders, pages, truncated }: orders is the aggregated array of order URLs, pages is how many pages were read, and truncated is true when the list was cut short by the page cap. It follows the sec. 7.1.2.1 Link: rel="next" pagination, bounded by opts.maxPages (default 50) and a visited-page dedupe so a looping or over-long next chain stops rather than fetching without end. Because the next header is delivered over TLS but not signed, and it steers an account-key-signed POST-as-GET, each next is https-gated and confined to the orders URL's own origin; an off-origin or non-https next is refused with acme/bad-link, and two distinct next targets (a singleton relation, RFC 8288 sec. 3.3) are ambiguous and refused.
- pki.acme.updateAccount(opts) is a new message-layer builder (a top-level export alongside pki.acme.deactivate): it builds the kid-signed account-update JWS the client composes, so the message layer is usable without the stateful client.

### Changed

- The RFC 8288 Link response-header parser is now relation-parameterized: the sec. 7.4.2 alternate certificate-chain reader and the sec. 7.1.2.1 next orders-page reader share one hardened scan (byte cap, control-octet reject, relation-type well-formedness, anchor-context check, resolve, https gate, origin gate, and dedupe), so the same untrusted-header defenses cover both relations. Behavior for the alternate relation is unchanged.

## v0.6.3 — 2026-08-30

pki.cms builds and reads certs-only certificate-management messages: a degenerate CMS SignedData that conveys certificates and CRLs without signing anything (RFC 8551 sec. 3.8).

### Added

- pki.cms.certsOnly(certs, opts?) builds a certs-only certificate-management message (RFC 8551 sec. 3.8): a degenerate CMS SignedData with version 1, an empty digestAlgorithms and signerInfos, an id-data encapContentInfo with the eContent absent, and the caller's certificates in certificates [0] and any CRLs in crls [1], both DER-sorted and deduplicated. certs is a certificate or an array of them (DER Buffer or PEM string), and opts.crls carries CRLs the same way; at least one certificate or CRL is required. Each entry is parsed as a real X.509 certificate or CertificateList before it is embedded, so a non-certificate, a non-CRL, or a tagged CertificateChoices alternative is refused with a typed cms/bad-input rather than emitted. opts.pem returns a PEM string instead of DER.
- pki.cms.parseCertsOnly(input, opts?) reads a certs-only message (DER Buffer or PEM) and returns { certificates, crls } as raw DER. It requires a degenerate SignedData (id-data with no eContent, empty signerInfos) and refuses a non-degenerate structure with cms/not-certs-only. Unlike the RFC 5272 Simple PKI Response that pki.est.parseCertsOnly reads, a CRL-only message is accepted (RFC 8551 sec. 3.8 conveys certificates and/or CRLs); a message carrying neither is refused. opts.maxCerts caps the number of certificates and CRLs parsed and returned, a bound on an untrusted bundle.
- pki.cms.isCertsOnly(input) reports whether input is a certs-only message structurally, returning true for a SignedData with an id-data content, absent eContent, and empty signerInfos, and false for any other well-formed CMS. Undecodable bytes throw a typed CmsError.
- pki.smime.buildCertsOnly(certs, opts?) wraps a certs-only message in one application/pkcs7-mime; smime-type=certs-only; name=smime.p7c S/MIME entity (RFC 8551 sec. 3.2.1), with the certs-only DER as the base64 body. It accepts the same certificate and opts.crls inputs as pki.cms.certsOnly.

## v0.6.2 — 2026-08-30

pki.scep builds and reads SCEP (RFC 8894) enrollment messages: a client assembles a PKCSReq or RenewalReq request and parses a CA's response back to its verified transaction attributes and issued certificates.

### Added

- pki.scep.build(spec) assembles a SCEP request pkiMessage (RFC 8894 sec. 3). Given a PKCS#10 certification request as messageData, a recipient CA certificate, and a signer { cert, key }, it verifies the request's own proof-of-possession, encrypts it to the CA as the inner EnvelopedData (pkcsPKIEnvelope, AES-128-CBC with RSAES-OAEP key transport, not the legacy RSAES-PKCS1-v1_5, so the CA must support OAEP), and signs that under the transaction attributes: the messageType (PKCSReq or RenewalReq), a caller-unique transactionId, and a 16-byte senderNonce that is generated when omitted. The messageData must be a well-formed PKCS#10 whose self-signature verifies under its own subject public key, or it is refused (scep/bad-input or scep/bad-popo) rather than enveloped into a message the CA would reject. The recipient CA certificate must assert the keyEncipherment key usage, since the EnvelopedData uses RSA key transport. A malformed spec, an unknown messageType, an over-long transactionId, or a senderNonce that is not 16 bytes is refused with a typed ScepError.
- pki.scep.parse(bytes, opts?) disassembles a SCEP pkiMessage. It verifies the outer SignedData signature first and refuses a message whose signature does not verify (scep/bad-signature), so the transaction attributes it returns are read only from a verified signer and are never surfaced alongside a false verdict. A valid signature proves only that the message is self-consistent with the certificate it embeds; pass opts.signerCert to authenticate a CA response against the CA certificate you hold, which refuses a signer whose public key does not match (scep/untrusted-signer) and reports signerAuthenticated. The verdict carries the messageType, transactionId, senderNonce, and, for a CertRep, the pkiStatus, failInfo, failInfoText, and recipientNonce, each mapped from its RFC 8894 enumerant and refused when unknown. Passing recipientKey ({ cert, key }) decrypts the pkcsPKIEnvelope to recover the messageData; a SUCCESS CertRep's payload is validated as a degenerate certs-only CMS SignedData and its issued certificate(s) and any CRL are surfaced in certificates and crls (a GetCRL response carries a CRL and no certificate), raw for the caller to path-validate. Passing expectedSenderNonce refuses a response whose recipientNonce does not echo the nonce that was sent. A message carrying more than one signer, a duplicate or multi-valued transaction attribute, or a missing mandatory attribute is refused.
- pki.errors.ScepError is the typed error for the SCEP codec, alongside the other per-domain PkiError subclasses.

## v0.6.1 — 2026-08-30

pki.ct verifies all of a certificate's SCTs against a trusted log-list and renders a certificate-level Certificate Transparency verdict, and pki.path.validate gains an optional CT gate.

### Added

- pki.ct.verifySctList(entry, list, logList, opts?) renders a certificate-level Certificate Transparency verdict (RFC 6962 sec. 3.3) over the set of SCTs a certificate carries, rather than one SCT at a time. It verifies each SCT against the trusted log-list through the same signature check pki.ct.verifySct applies, and reports how many distinct trusted logs verified an SCT (validScts, so a duplicated SCT cannot inflate the count), from how many distinct trusted log operators (operatorCount and operators), and whether that meets the CT policy. The policy is minScts and minOperators, both defaulting to the RFC floor of one valid SCT; the distinct-operator requirement is a browser CT policy layered on top of the RFC, so the caller sets the threshold and the verdict surfaces the counts. A policy shortfall is a verdict (policyOk: false), not an error, and a per-SCT trust or signature failure is recorded in its result row rather than sinking the verdict; only a mis-shaped entry, list, logList, or opts throws a typed CtError.
- pki.ct.x509CertEntry(cert, issuer) reconstructs the precertificate log entry from a final certificate that carries embedded SCTs (RFC 6962 sec. 3.2), the piece the embedded-SCT verification path needs end to end. It removes the signedCertificateTimestampList extension from the certificate's TBSCertificate by byte surgery, leaving every other byte of the CA-signed structure intact, and computes issuerKeyHash as SHA-256 of the issuer's SubjectPublicKeyInfo. The returned entry feeds pki.ct.verifySctList or pki.ct.reconstructSignedData directly.
- pki.path.validate accepts an optional Certificate Transparency gate: pass ctLogList (a pki.ct.parseLogList result) and ctPolicy ({ minScts, minOperators }) to verify the target certificate's embedded SCTs against the trusted log-list as part of path validation. The verdict carries a ct check on the target certificate's checks; a target that declares a CT policy but carries no SCT-list extension fails closed, and an SCT dated after the validation time (opts.time) is rejected rather than counted (RFC 6962 sec. 5.2, a client rejects a future-dated SCT). Callers that pass neither option are unaffected.

## v0.6.0 — 2026-08-30

The toolkit's public APIs graduate to stable, and pki.ocsp.verifyRequest verifies a signed OCSP request for responder implementers.

### Added

- pki.ocsp.verifyRequest(request, opts?) verifies a signed OCSP request (RFC 6960 sec. 4.1.1) for a responder implementer. It checks the requestor's signature over the exact tbsRequest under the public key of the requestor certificate embedded in the request, or supplied through opts.certs when the request carries none, using the same signature engine pki.ocsp.verify applies to a response. The verdict is { signed, signatureValid, signerCert, signerCerts, certs, signerSubject, requestorName, requestList, requestExtensions, version, reason }. Signing a request is optional under RFC 6960, so an unsigned request reports signed:false rather than an error, and signatureValid speaks only to the cryptographic check. Because the request's certificate field is unordered and a key may appear under an expired certificate beside its renewal, signerCerts lists every certificate whose key verified the signature, while certs is every parseable certificate the request carried, including intermediates that do not sign; the responder passes certs as the candidate pool to pki.path.build to discover and validate a trusted path to a signerCerts entry, confirms that certificate asserts digitalSignature keyUsage (RFC 5280 sec. 4.2.1.3, since it signed the request), then binds its subject to the identity expected, rather than depending on the position of the single signerCert.

### Changed

- The public APIs that previously carried an experimental status are now stable and covered by the stable-upgrade policy: a deprecation warning ships at least one minor release before any removal, and a minor release makes no silent breaking change. The graduation bar is a settled governing standard, a wire format proven correct, and a frozen public surface. Where a surface graduates without fully meeting that bar, the relaxation is named here. On interop: pki.tls certificate compression (RFC 8879), pki.ocsp.verifyRequest, and the CMC surface (pki.cmc and pki.schema.cmc, RFC 5272) have no independent implementation in the interop harness, so their own decoders are their only cross-check. On the shared network transport: the EST (pki.est), ACME (pki.acme), and networked CMP (pki.cmp) clients and pki.path.build's opt-in AIA fetching compose the shared node:https transport (pki.transport), whose fail-closed contract still gains requirements as each client composes it. On the governing standard: pki.schema.c509 tracks the draft-ietf-cose-cbor-encoded-cert Internet-Draft, Certificate Transparency tracks the Experimental RFC 6962 (the standardized CT v2 is RFC 9162), composite post-quantum signature support tracks the LAMPS composite-signature drafts, and Sigstore bundle verification tracks the evolving Sigstore bundle spec rather than a settled RFC. On surface stability: pki.lint ships a representative CA/Browser Forum Baseline Requirements subset that will gain rules over time, pki.webauthn.verifyAssertion's verdict shape is frozen as of this release and grows only additively, and pki.hpke wires the classical DHKEM modes with the post-quantum modes added later. In each case the public surface is stable, and any change a later standard revision, an added rule, or continued transport hardening requires is handled under the deprecation policy.

## v0.5.39 — 2026-08-30

pki.cmc.build and pki.cmp.build verify an embedded PKCS#10 request's proof-of-possession and refuse one whose self-signature does not verify.

### Added

- pki.cmc.build (`tcr` arm) and pki.cmp.build (`p10cr` arm) verify the embedded PKCS#10 request's proof-of-possession before signing or protecting the enrollment message: the request's self-signature must verify under the subject public key it carries (RFC 5272 sec. 6.3 for CMC; RFC 9810 sec. 5.3.3 for CMP, over the PKCS#10 structure of RFC 2986). A request whose signature does not verify is refused with a typed `cmc/bad-popo` or `cmp/bad-popo` error naming the offending request; a request with a valid proof-of-possession builds unchanged.

## v0.5.38 — 2026-08-30

Builder verbs reject a sparse or nullish array argument with a typed error instead of a native one.

### Changed

- The array arguments of pki.pkcs12.build (SafeContents bags), pki.ocsp.buildRequest and pki.ocsp.sign (the query and response batches), pki.cmc.build (requests and the CMS and other-message sequences), pki.crl.sign (revoked entries and the issuing-distribution-point and freshest-CRL general names), pki.crmf.build (controls and registration info), and pki.cms.encrypt (authenticated attributes) are checked for holes and nullish entries up front. A sparse or nullish array is now the verb's own typed `<domain>/bad-input` error naming the index, in place of the native `TypeError` the hole previously produced at the encoder. A valid (dense) array is unaffected and its emitted structure is byte-for-byte identical.

## v0.5.37 — 2026-08-30

pki.cms.decrypt can return the recovered content as an async iterable of plaintext chunks.

### Added

- `pki.cms.decrypt(input, keyMaterial, opts)` accepts `opts.stream: true`, returning the verdict's `content` as an async iterable of plaintext `Buffer` chunks instead of a single `Buffer`. It applies to EnvelopedData, AuthEnvelopedData, EncryptedData (the `{ cek }` and `{ password }` forms), and AuthenticatedData. The integrity-checked modes verify before the first chunk is yielded, so a forged message fails before any plaintext is exposed; the unauthenticated CBC modes stream the plaintext as the cipher produces it, so a large payload is never buffered whole. Every failure remains the single uniform `cms/decrypt-failed` verdict, on the streamed path as on the buffered one.

## v0.5.36 — 2026-08-29

pki.cms.encrypt accepts an async-iterable plaintext, encrypting a large payload without holding it whole in memory.

### Added

- `pki.cms.encrypt(content, recipients, opts)` accepts an async iterable of byte chunks as `content` (detected by `Symbol.asyncIterator`), alongside the existing `Buffer`, typed-array, `DataView`, and `ArrayBuffer` forms. It streams the plaintext through the AES-GCM or AES-CBC content cipher chunk by chunk, assembling only the ciphertext, so a large plaintext is not held in memory whole. The output is a byte-for-byte match for the buffered form and decrypts with `pki.cms.decrypt` and OpenSSL. This covers the recipient-based EnvelopedData and AuthEnvelopedData as well as the `{ cek }` and `{ password }` EncryptedData forms.

## v0.5.35 — 2026-08-29

pki.cmp.build now assembles the nested PKIMessage body an RA uses to forward or batch complete messages.

### Added

- `pki.cmp.build` accepts a `nested` body: `{ body: { nested: [pkiMessageDer, ...] } }` wraps an array of complete PKIMessage DER as a `NestedMessageContent` under the explicit `[20]` PKIBody tag (RFC 9810 sec. 5.1.3.5). This is the body a registration authority sends when it forwards or batches messages that were protected by other parties. Each entry is checked to parse as a PKIMessage and is then wrapped byte-for-byte unchanged, so a byte string that is not a PKIMessage is refused. An empty array is refused, because the field is `SIZE (1..MAX)`. Both failures raise `cmp/bad-input`. Following the same section, the inner messages are forwarded unchanged: each keeps its own protection, which the recipient validates, and `pki.cmp.build` does not re-sign, re-verify, or deeply inspect their contents.

### Fixed

- A caller array passed where a list of complete items is required is now refused with a typed error unless every position from 0 to its length is the array's own element. `pki.cms.sign` (the signer list) and `pki.cmp.build` (the `nested` message list) previously reached a native `TypeError` for a sparse array such as `new Array(2)`, or one carrying an empty slot or a `null` entry. An entry resolvable only through the array's prototype, including a polluted `Array.prototype` index, was worse: the entry deep-copy materialized it and the value was signed as though the caller had supplied it. The deep-copy now copies own array indices only, so a hole stays a hole, and a dense-array check refuses the list with `cms/bad-input` or `cmp/bad-input`, failing fast so a large sparse length cannot force a long traversal.
- A PEM-armored byte source passed to `pki.cmp.build` where DER is required is now refused with a typed error up front. Every field that takes a DER value the builder both validates with a parser and embeds verbatim is covered: the `nested` message list, the `p10cr` request, the signer certificate (`opts.cert`) and `opts.extraCerts`, and an embedded certificate or CRL. Because the parsers accept a PEM string, a PEM-armored `Buffer` (a file read straight from disk) passed validation but was embedded as ASCII armor, so the message was rejected only later, by its own parse, as `cmp/bad-der`, and for the signer certificate only after the private-key operation. The builder now requires a DER SEQUENCE for these fields, so a PEM or non-DER input fails immediately with a clear code and the bytes checked are the bytes embedded.

## v0.5.34 — 2026-08-29

A subjectAltName URI shorthand whose authority is a bracketed IPv6 literal is now accepted rather than refused.

### Fixed

- A bare-string `subjectAltName` entry whose value is a URI with a bracketed IPv6 authority, such as `https://[2001:db8::1]:443/path`, now classifies as a `uniformResourceIdentifier`. The bracketed host is taken as one unit before an optional port, so a colon inside the IPv6 literal is no longer read as the port separator. An unterminated bracket, a bracket body that is not a valid IPv6 address, or bytes other than a port after the closing bracket stay refused, and the explicit `{ uniformResourceIdentifier: ... }` object form is unchanged.

## v0.5.33 — 2026-08-29

pki.cms.sign and pki.cms.verify accept the content as an async iterable of byte chunks, so a detached signature over a large payload is produced and verified without holding the payload in memory.

### Added

- `pki.cms.sign` accepts its `content` as an async iterable of byte chunks (for example an async generator), signing a large payload without holding it in memory. This form requires `detached: true` and signed attributes: the payload is hashed incrementally to build the message-digest attribute (RFC 5652 sec. 5.4), in a single pass that serves every signer, so signers may use different digest algorithms. The signature covers the same content as the buffered form.
- `pki.cms.verify` accepts its `content` option as an async iterable of byte chunks, verifying a detached signature over a streamed payload. The stream is hashed once for every signer, so every signer must carry signed attributes; a signer that signs the content directly is refused with a typed error and needs the content as a `Buffer`.

## v0.5.32 — 2026-08-28

The parse verbs accept their DER input as any BufferSource: an ArrayBuffer, a DataView, or any typed array, not only a Buffer or a Uint8Array.

### Added

- Every parse verb accepts any `BufferSource`, not only a `Buffer` or a `Uint8Array`: `pki.x509.parse`, `pki.crl.parse`, `pki.csr.parse`, `pki.pkcs8.parse`, `pki.cms.parse`, `pki.pkcs12.parse`, `pki.ocsp.parse` and `parseRequest`, `pki.cmp.parse`, and `pki.schema.cmc.parse` (including `parsePkiData` and `parsePkiResponse`, where a `DataView` is now read correctly rather than coerced to an empty buffer). An `ArrayBuffer` or a `DataView` produces the same result as the equivalent `Buffer`.
- Many byte-valued options on the signing, verifying, and enrollment verbs were widened to accept any `BufferSource` as well. These include the certificate and DER inputs to `pki.cms.sign` and `pki.cms.verify`; the `messageImprint` and TSA certificate in `pki.tsp.sign`; the `requestorName` and nonce in `pki.ocsp`, and the response in `pki.path.verifyOcspResponse`; the `expectedSender` and signer certificate in `pki.cmp`; a signer's `keyIdentifier` and `spki` in `pki.cmc`; the certificate and CSR inputs to `pki.acme` (`finalize`, `revokeCert`, `ariCertId`, `renewalInfo`); the CSR and `challengePasswordFromTlsUnique` channel binding in `pki.est`; the issuer SPKI in `pki.crl.verify` and the byte serial in `pki.crl.isRevoked`; a raw subject Name, a `subjectKeyIdentifier` or `authorityKeyIdentifier`, and a byte serial number in `pki.x509.sign` and `pki.csr.sign`; and an `iPAddress` name-constraint base in `pki.path.validate`.
- `pki.oid.isDottedDecimal(s)` reports whether a string is a dotted-decimal object identifier: two or more numeric arcs separated by dots.
- `pki.asn1.isPrintableString(s)` reports whether a string contains only the characters an ASN.1 PrintableString may carry: `A` to `Z`, `a` to `z`, `0` to `9`, space, and the symbols `'()+,-./:=?`.

### Changed

- A private key, a password, and other secret byte inputs keep their existing contract of a `Buffer`, a `Uint8Array`, a PEM string, or a `CryptoKey` where one is accepted. A secret input still refuses an `ArrayBuffer` with a typed error, because widening the secret ownership paths is a separate decision.

## v0.5.31 — 2026-08-25

The signing verbs accept a WebCrypto CryptoKey as the signing key, and a subjectAltName entry may be a bare string classified into its GeneralName form.

### Added

- Every signing verb -- `pki.x509.sign`, `pki.csr.sign`, `pki.crl.sign`, `pki.cms.sign`, `pki.ocsp.sign`, `pki.attrcert.sign`, `pki.tsp.sign`, and `pki.crmf.build` -- accepts a WebCrypto `CryptoKey` as its signing key, so a key from `pki.key.generate` (or `pki.webcrypto.subtle`) signs directly without first being exported to PKCS#8. A PKCS#8 private key as DER (a `Buffer` or `Uint8Array`) or PEM (a string) continues to work exactly as before, with the signature algorithm resolved from the key's own SubjectPublicKeyInfo. A `CryptoKey`'s own algorithm must be compatible with that resolved scheme, because WebCrypto binds a key to one algorithm: an RSASSA-PSS `CryptoKey` signs RSASSA-PSS, so pass `opts.pss` for it, whereas a raw PKCS#8 RSA key defaults to RSASSA-PKCS1-v1_5.
- A `subjectAltName` (and any other `GeneralName`) entry may be a bare string, classified fail-closed into its form: a hostname such as `"host.example.com"` (including a `*.` wildcard or an underscore label) becomes a `dNSName`, `"user@example.com"` an `rfc822Name`, an IPv4 or IPv6 literal such as `"192.0.2.1"` an `iPAddress`, and a `"https://host.example/path"` URL a `uniformResourceIdentifier`. Shorthand and object entries interoperate in the same array. A string that does not unambiguously match one form -- a host:port, an opaque `urn:`/`mailto:` URI, or an empty string -- is refused with a typed `bad-input` error rather than guessed; the explicit object form (`{ dNSName: "..." }`) still accepts any value, and a pre-encoded Extension DER passed through the escape-hatch array form is unaffected.

### Changed

- A signing key that is a public or secret `CryptoKey`, or a `node:crypto` KeyObject rather than a WebCrypto `CryptoKey`, is now refused with a message that names the specific problem -- the wrong key type, or a KeyObject that should be passed as a `CryptoKey` or exported to PKCS#8 -- instead of the generic "must be a CryptoKey or PKCS#8" message. The `"a signing key is required"` errors on `pki.x509.sign`, `pki.crl.sign`, and `pki.attrcert.sign` now name the `CryptoKey` option alongside PKCS#8.

## v0.5.30 — 2026-08-24

A CMP certReqTemplate keySpec that names an ISO/IEC 9796-2 RSA signature scheme is now refused, instead of being surfaced to the caller as a non-RSA algorithm requirement.

### Security

- `pki.cmp.session` refuses a certReqTemplate keySpec whose `id-regCtrl-algId` names an ISO/IEC 9796-2 RSA signature-with-message-recovery scheme, with `cmp/bad-info-value`, rather than surfacing it to the caller as a non-RSA algorithm requirement. RFC 9483 sec. 4.3.3 reserves an RSA key requirement for `id-regCtrl-rsaKeyLen`; the 9796-2 schemes are RSA, so a keySpec naming one is now rejected the same way an rsaEncryption or RSASSA-PSS algId already was. The classifier recognizes the deterministic (1.3.36.3.4.2) and randomized (1.3.36.3.4.3) sub-arcs by prefix -- so a hash variant registered later is caught without being listed -- and ISO/IEC 9796-1 (1.3.36.3.4.1) on the same registrant arc. The generic signatureScheme parent (1.3.36.3.4) and the sibling authentication arc (1.3.36.3.5, whose children include ECC) are not treated as RSA, so a non-RSA algorithm under either is still surfaced. Recognizing these OIDs classifies a keySpec requirement; it is not a claim that the toolkit produces or verifies a 9796-2 signature.

## v0.5.29 — 2026-08-24

pki.path.validate refuses a mis-shaped trust anchor instead of returning a verdict against it, and accepts a parsed certificate directly.

### Added

- `pki.path.anchorFromCert(cert)` turns a parsed certificate into the `{ name, publicKey, algorithm }` trust-anchor tuple `pki.path.validate` and `pki.path.build` consume, so a root can be pinned from its certificate without hand-building the tuple. `pki.path.validate` and `pki.path.build` also accept a parsed certificate passed directly as the anchor.

### Changed

- A normalized trust anchor -- what `pki.path.anchorFromCert` returns and what `pki.path.build` reports as `result.trustAnchor` -- carries a defined set of fields: `name`, `publicKey`, `algorithm`, and `parameters`, plus `purposes`, `distrustAfter`, `subjectDer`, `label`, and `mozillaCaPolicy` when the source supplies them (the fields `pki.trust.parseCertdata` emits). These survive normalization, so a pinned trust-store entry round-trips; a field attached outside that set is not carried onto the anchor.
- A trust anchor's `purposes` and `distrustAfter` constraint maps must be plain own data properties. A map -- or one of its entries -- supplied as a getter, or reached through the object's prototype rather than as an own property, is refused with `path/bad-input` rather than read, so a getter cannot answer a purpose or distrust check inconsistently and a restriction is never silently dropped. The `{ purposes, distrustAfter }` shape `pki.trust.parseCertdata` emits and a hand-built one are the normal, unaffected form.
- A trust-anchor tuple's nested `algorithm.oid` and `name.rdns` must likewise be plain own data properties. An accessor-backed or prototype-inherited definition is refused with `path/bad-input` rather than read, so a getter on one nested field cannot rewrite the other -- for example, rewriting a wrong declared algorithm OID to the key's real one -- before it is validated. A plain `{ oid }` algorithm and `{ rdns, bytes }` name, and a parsed certificate's own, are the normal, unaffected form.
- The ACME client's per-request methods refuse an unrecognized option instead of reading it as absent and applying a default: `finalize`, `pollOrder` / `pollAuthorization`, `downloadCertificate`, `revokeCert`, `keyChange`, and `renewalWindow`. A mistyped option name is now an `acme/bad-input` error rather than a silently ignored setting, matching the client constructor and the rest of the toolkit's option-taking verbs.

### Security

- `pki.path.validate` refuses a malformed `opts.trustAnchor` with `path/bad-input` at entry, rather than seeding the path from it and returning a soft verdict. An anchor tuple missing its `algorithm` could previously make the path validate and return `valid: true` -- a self-describing key algorithm filled the gap the absent field left -- so a caller who passed a mis-shaped anchor received a verdict that did not answer the question they asked. The anchor is now normalized and shape-checked at the door, the same way `pki.path.build` already treated its `trustAnchors`.
- `pki.path.validate` and `pki.path.build` also refuse a trust anchor whose declared `algorithm` does not match the algorithm of its `publicKey`, and take the key's algorithm parameters from that SubjectPublicKeyInfo rather than a declared `parameters` field. An anchor that named a different or unrelated algorithm than the key it carried was previously accepted and validated against the real key; a declared algorithm inconsistent with the key is now a `path/bad-input` refusal at entry. And because the key's own parameters are authoritative, a declared curve that disagreed with the key can no longer be promoted and inherited by an intermediate certificate that omits its own parameters -- which could otherwise validate a chain RFC 5280 parameter inheritance from the real key should reject.
- A trust anchor passed as a parsed certificate is recognized as a certificate before any tuple field is read, so a value reached through the object's prototype -- such as a polluted `Object.prototype` supplying `name`, `publicKey`, and `algorithm` -- cannot reclassify the certificate as a hand-built tuple and bind a substituted key. The certificate's own key is always the one used.
- A trust anchor supplied as a `Proxy`, or one whose `purposes` / `distrustAfter` constraint map is a `Proxy`, is refused with `path/bad-input`. A `Proxy`'s traps can answer a field read differently on successive lookups or report a field absent while forwarding the rest, so no field-by-field normalization can trust it to describe itself -- a `Proxy` could report `purposes` absent while carrying the other fields, or a `Proxy` distrust map could report no keys, dropping a `{ serverAuth: false }` restriction or an expired-cutoff date the caller attached and validating a path the anchor forbids. A normal anchor -- a plain tuple, a parsed certificate, or an object inheriting from one, with plain-object constraint maps -- is not a `Proxy` and is unaffected.

## v0.5.28 — 2026-08-23

Importing the toolkit is now silent, and a key's WebCrypto algorithm can no longer change under a signature once the key has been created.

### Changed

- `require("@blamejs/pki")` no longer prints Node experimental-feature warnings on load. The engine's typed-array scan reads each global's property descriptor and uses only a data value, so it never touches a host's lazy accessor (Node's `localStorage` and `WASI` emit an `ExperimentalWarning` the moment they are read). Recognition of the concrete typed-array kinds -- the reason for the scan -- is unchanged, since those are ordinary data properties.

### Security

- A `CryptoKey`'s `algorithm` is immutable once the key is created: the property is non-writable so it cannot be replaced, and its value is frozen so its fields cannot be changed. This engine reads `key.algorithm.hash` at sign time, and a mutable algorithm let the hash checked against a JWS `alg` (an `RS256` header signs under SHA-256) be rewritten between that check and the signature -- by swapping the whole object or a field of it, including from a microtask during the signing await -- producing a JWS whose signature does not match the algorithm its header advertises. The frozen value is a copy, so a caller's own `importKey` parameters object is left untouched. A key adopted from another WebCrypto implementation is re-imported from its own algorithm; the keys this engine mints, which is what the enrollment builders sign with, carry the immutable one.

## v0.5.27 — 2026-08-23

A mistyped option passed to pki.trust.anchor or the pki.acme.client constructor is now refused, naming it, instead of being read as absent and silently defaulted.

### Changed

- `pki.trust.anchor(entry, opts)` rejects an option it does not recognize, naming it, instead of ignoring it and applying the default. The one option it reads is `purpose`.
- The `pki.acme.client(directoryUrl, opts)` constructor rejects an unrecognized option the same way. The options it reads are `accountKey`, `accountJwk`, `alg`, `transport`, `tls`, `timeout`, `maxResponseBytes`, `maxRedirects`, `maxNonceRetries`, `maxPolls`, `maxTotalWait`, `sleep`, and `clock`; each method the client returns validates its own per-call options separately.

### Security

- An unrecognized option is a fail-closed error rather than a silent default. The prior behavior meant a security-relevant option -- a shorter redirect budget, a specific trust purpose -- that was misspelled took no effect and raised no error, so a caller could believe a stricter setting was in force when the default was.

## v0.5.26 — 2026-08-23

Revoking a certificate and asking a certification authority what it offers now go through the same verified CMP transaction as enrollment.

### Added

- `session.revoke(request)` drives the RFC 9483 sec. 4.2 revocation exchange. The request names the certificate as `{ certificate }` or as `{ certDetails: { issuer, serialNumber } }`, with an optional `reason` taking a CRLReason name. It returns a terminal verdict of `revoked`, `rejected`, or `poll-timeout`, carrying the responder's `PKIStatusInfo` and any `CertId` values and CRLs the response supplied.
- `session.info(request)` drives the four RFC 9483 sec. 4.3 support messages, one per call. `{ caCerts: true }` asks for the certification authority certificates available for chain construction. `{ rootCaCert: <cert> }` asks for a root key update and reads the `RootCaKeyUpdateContent` reply. `{ certReqTemplate: true }` asks for the requirements a future certificate request should meet, returning the template and any `keySpec` constraints. `{ crlUpdate: { issuer, dpn?, thisUpdate? } }` asks for a CRL from a named source. The verdict is `answered`, `rejected`, or `poll-timeout`; `answered` reports `present` alongside `value`, since an absent response value is how each of the four says nothing is available.
- `pki.cmp.build` accepts a structured `crlEntryDetails` on an `rr` body: `{ reason: 'keyCompromise' }` encodes the reasonCode extension the profile requires. A pre-encoded `Extensions` DER is still accepted for an entry extension outside that shape.
- The `id-regCtrl-algId`, `id-regCtrl-rsaKeyLen`, and `id-regCtrl-altCertTemplate` object identifiers RFC 9480 sec. 2.16 adds are in the registry, so a `keySpec` control resolves to a name.

### Changed

- Delayed delivery is recognized for a revocation and a support message. RFC 9483 sec. 4.4 puts the `waiting` status in an `ip`, `cp`, or `kup` when answering an enrollment and in an error message when answering anything else, so an error body carrying `waiting` now drives the bounded poll loop for those two operations, with the `pollReq` referring to the whole message. An error message that is not delayed delivery must state the rejection status: an error naming `accepted`, `grantedWithMods`, or another non-rejection status, or a `waiting` carrying `failInfo`, is refused as malformed rather than surfaced as a trusted rejection verdict its own `PKIStatusInfo` contradicts. An enrollment has no delayed-delivery error at all — the profile places enrollment `waiting` in an `ip`, `cp`, or `kup` — so a `waiting` error answering an enrollment is refused, not read as a rejection.
- One transaction per session covers all three verbs. A session that has revoked will not then enroll, because both would run under the same transactionID and nonce chain.

### Security

- A session revokes its own certificate. The signature over an `rr` is the proof of authorization to revoke (RFC 9483 sec. 4.2), so the certificate named in the request must be the one the session protects its messages with, compared by serial number and by the RFC 5280 sec. 7.1 canonical distinguished-name rule. A PBMAC1 session is refused outright: a shared secret establishes a bootstrapping relationship and says nothing about which certificate the holder may revoke. Revocation on behalf of another entity is a registration authority operation and is driven with `pki.cmp.build` and `pki.cmp.transfer`.
- The reasonCode is always sent, at `unspecified(0)` when no reason is given. RFC 9483 sec. 4.2 makes `crlEntryDetails` required and says the code must be 0 when the reason is unknown or is not to be published. This is the opposite of the RFC 5280 sec. 5.3.1 rule for a CRL entry, where absence and `unspecified` carry the same meaning and the extension is omitted, so the CRL behavior is not reused here.
- A response is held to the shape its operation defines before its content is read. An `rp` must carry exactly one status, and an accepted one must not also carry `failInfo`. When an accepted `rp` names the certificates it revoked in `revCerts`, that entry is bound to the certificate the request named: exactly one entry, its issuer and serialNumber equal to the revoked certificate's under the RFC 5280 sec. 7.1 canonical rule, so a verified `revoked` verdict cannot report an unrelated certificate as revoked. A `genp` must carry exactly one `InfoTypeAndValue`, whose infoType must be the one that operation is answered with: a root key update is requested with `id-it-rootCaCert` and answered with `id-it-rootCaKeyUpdate`, so a response echoing the request identifier is a different operation's answer and is refused.
- A `rootCaKeyUpdate` response must carry `newWithOld`, and the three certificates must stand in the relationships that make a root key update work. RFC 9480 marks the field OPTIONAL in the syntax and RFC 9483 sec. 4.3.2 requires it of a response, for the reason it states: an entity that trusts the old root needs that certificate to gain trust in the new one. That is a claim about signatures and keys, not about the fields being present, so `newWithOld` is checked to carry the new root key, to name the same subject as `newWithNew`, and to be issued and signed by the old root named in the request, and `oldWithNew`, when sent, to carry the old root key, name the old root, and be issued and signed by the new one. The names matter as much as the keys: a certification authority that has ever issued an ordinary certificate for the new key satisfies key equality and signature validity on its own, and its holder could pair it with a self-signed certificate of their choosing and have the result read as the authority's rollover. Names are compared under the RFC 5280 sec. 7.1 canonical rule, so a re-encoded but equal name still matches. Each of the three must also carry the authority the update transfers — `basicConstraints` with `cA` TRUE, and a `keyUsage`, where present, that allows `keyCertSign` — since an ordinary end-entity certificate for the same subject and key satisfies names, key and signature while being able to certify nothing. `newWithNew` is checked under its own key when it is self-issued, which is the case for a root; sec. 4.3.2 also extends this operation to a directly trusted non-root certificate, whose issuer the message does not carry, and there its own signature is left unchecked rather than the update refused for a check that cannot be run. Three certificates that merely parse establish neither direction of the transition. The signatures go through the same certification-path engine that verifies a message's protection, so the EdDSA low-order-point and algorithm-confusion gates apply here too, and a signature that is not octet-aligned (a BIT STRING declaring unused bits) is refused before the engine, the same shape the path verifier rejects. The certificate the request names as the current root is itself held to being a CA — refused at the door, before the one-shot transaction engages — since the response's rollover signatures are verified against its key, and an end-entity certificate there would let a cross-certificate read as a rollover.
- A `certReqTemplate` response must omit `publicKey`, `serialNumber`, `signingAlg`, `issuerUID`, and `subjectUID` from its template (RFC 9483 sec. 4.3.3). A template stating a public key would direct an entity to request a certificate over a key the responder chose, which is what the `keySpec` field exists to avoid. A `keySpec` control must be `id-regCtrl-algId` carrying a non-RSA AlgorithmIdentifier or `id-regCtrl-rsaKeyLen` carrying a positive integer. "Other than RSA" is decided by OID family. Every object identifier under an RSA-dedicated arc — the PKCS#1 arc, the TeleTrusT rsaSignature arc, or the BSI TR-03110 id-TA-RSA arc (EAC Terminal Authentication) — is an RSA algorithm, save the PKCS#1 arc's two auxiliary parameters (`mgf1` and `pSpecified`, the mask-generation and OAEP-label identifiers), which are not RSA algorithms and are surfaced, so a standardized member this toolkit has not separately named — `sha1WithRSAEncryption`, the `md*` and `sha224` variants — is refused with the ones it has; the RSA identifiers on arcs it shares with non-RSA algorithms — `id-rsa-kem`, `id-kem-rsa`, the RSASSA-PKCS1-v1_5-with-SHA-3 set, RSASSA-PSS-with-SHAKE, every legacy OIW RSA identifier on the arc it shares with DES, DSA, and the hashes, the X.500 directory RSA OIDs (the `rsa` public-key identifier and the signatureAlgorithm sub-arc's RSA members), and the Chinese GM/T `sm3WithRSAEncryption` — are named explicitly, since no prefix can isolate RSA there. Each arc is read from the registry rather than written as a literal, so `pki.oid.register` cannot move it underneath the check. The composite ML-DSA + RSA signatures are deliberately not refused: a hybrid is a composite key rather than an RSA one, and `rsaKeyLen` cannot state it either. That is the only rule the control is held to: an algorithm the registry cannot name is surfaced rather than refused, since a `keySpec` carries one entry per algorithm the CA supports and the entity picks one it can generate, so an unrecognized offer is passed to the caller with a null name rather than failing the whole exchange and dropping the algorithms alongside it.
- A CRL answer is bound to the query that asked for it. RFC 9483 sec. 4.3.4 does not say a responder returns a CRL; it returns the latest one from the referenced source, and only when it is more recent than a `thisUpdate` the request supplied, with the response value absent in every other case. A CRL from another issuer, or one no newer than the copy the caller already holds, answers a different question, so it is refused rather than reported as `answered`. The issuer is compared under the RFC 5280 sec. 7.1 canonical rule. A request that names a distribution point is bound to that point wherever the CRL states one: RFC 5280 sec. 5.2.5 puts the scope in `issuingDistributionPoint`, and sec. 4.3.4's own note names that extension as a place a distribution point name is obtained from. The test is the one RFC 5280 sec. 6.3.3 applies to a shard CRL, at least one name in common compared as identical encodings, which sec. 5.2.5 requires of the two fields; a canonical comparison would accept a CRL published for a point the request never named. A CRL stating no scope is claiming to be its issuer's complete list, which sec. 5.2.5 permits and which answers a request for any point of that issuer, so the distribution point cannot bind that case. The issuer is required for it: a request naming a `dpn` must name an `issuer` as well, and the two are used differently. `CRLSource` is a CHOICE, so the message carries the distribution point (sec. 4.3.4 sends that alternative when a distribution point name is available), while the issuer states which CA's CRL the caller will accept. A `dpn` alone is refused rather than answered with a CRL from a CA nobody named; sec. 4.3.4's own note says a distribution point name is obtained from a certificate's `cRLDistributionPoints` or from a held CRL's `issuingDistributionPoint`, and both carry the issuer alongside it. An unbound request is still available through `pki.cmp.build` and `pki.cmp.transfer`.

### Notes

- A `crlUpdate` request carries one `CRLSource` alternative, since it is a CHOICE: the `dpn` when a distribution point name is given, the `issuer` otherwise. The `dpn` arm takes `fullName`; the `nameRelativeToCRLIssuer` alternative is not built, which is the line `pki.crl.sign` already draws for `issuingDistributionPoint` and `freshestCRL`.
- The registration authority side of these operations is unchanged: `krr` and `ccr` requests and the `nested` body remain unbuildable, and a session has no way to resume a poll across a process restart.

## v0.5.25 — 2026-08-21

A key that cannot sign can now prove possession, so an ML-KEM certificate request has a proof to carry instead of no option at all.

### Added

- `pki.crmf.build` emits a `POPOPrivKey` proof of possession under `pop.type` of `keyEncipherment` or `keyAgreement`. `pop.method: 'subsequentMessage'` with `pop.subsequentMessage` of `encrCert` or `challengeResp` declares which follow-up exchange completes the proof (RFC 9810 sec. 5.2.8.3.2 and sec. 5.2.8.3.3); no key material leaves the requester and every key type can produce it. `pop.method: 'encryptedKey'` encloses the requester's private key for the certification authority in a CMS `EnvelopedData` whose content type is `id-ct-encKeyWithID`, taking `pop.privateKey`, `pop.recipients`, `pop.identifier`, and `pop.archive: true`. `pki.cmp.build` carries either in an `ir`, `cr`, or `kur` body.
- The requested version follows what the message carries: a CMP request whose proof of possession uses `encryptedKey` or `agreeMAC` announces cmp2021(3), which RFC 9810 sec. 5.2.8.3 requires. Before this a request carrying an `encryptedKey` proof went out announcing cmp2000(2), and this toolkit's own reader refused the message it had just built.

### Changed

- `spec.pop` refuses a field it does not recognize. Each rule below is one a caller turns off by naming it, so a misspelled key must not read as an omitted one: `archve: true` would have withheld the archival consent while appearing to give it.

### Security

- An `encryptedKey` proof must enclose the private half of `certTemplate.publicKey`. RFC 4211 sec. 4.2 defines the field as "the encrypted private key matching the public key for which the certificate is to be issued"; enclosing any other key proves possession of something the request never asked to have certified, while every structural check still passes. The public key is derived from the supplied private key through the toolkit's own key engine and compared before the message is built.
- An `encryptedKey` proof requires `pop.identifier`. The ASN.1 marks `EncKeyWithID.identifier` OPTIONAL and RFC 4211 sec. 4.2.1 then makes it mandatory whenever the purpose is proving possession, for a reason the section states: without it a decrypting agent holds a key it cannot attribute, so an intercepted key can be wrapped in someone else's request and recovered.
- The two alternatives RFC 4211 sec. 4.2 deprecates as it defines them, `thisMessage` and `dhMAC`, are refused with their successors named. `pki.schema.crmf.parse` still reads both, since a peer may send one.
- A `keyEncipherment` proof cannot carry a MAC alternative. RFC 4211 sec. 4.2 lists three methods for an encipherment key and introduces the MAC alternatives only in sec. 4.3, "for keyAgreement (only)". The parser already refused such a message; the builder no longer produces one.

### Notes

- The `agreeMAC` proof is not built. It requires the RFC 2875 static Diffie-Hellman shared secret between the requester's key and a certification authority certificate the requester already holds, a classical key-agreement primitive this toolkit does not otherwise carry. `pki.crmf.verifyPop` continues to report an inbound `agreeMAC` as unverified, naming the arm. Use `subsequentMessage`, which any key type can produce.

## v0.5.24 — 2026-08-21

An ACME certificate download is now bound to the order that asked for it, so a certificate for another key or another name is refused instead of returned as the issued one.

### Changed

- `downloadCertificate` requires the material that makes that check possible: `expectedSpki`, `identifiers`, or both. A call supplying neither is refused with `acme/binding-required` rather than returning a certificate nothing looked at. Passing `requireBinding: false` waives the requirement to supply material and never the check on material that is supplied, and the result reports `boundToKey` and `boundToIdentifiers` so an unchecked download cannot read as a checked one. See MIGRATING.md.

### Fixed

- `downloadCertificate` checks the end-entity certificate it received against the order: its subject public key must be the one this order's CSR asked to have certified (`acme/certificate-key-mismatch`), and its identifier set must equal the order's identifiers (`acme/certificate-identifier-mismatch`). A certificate's identifiers are its dNSName and iPAddress subject alternative names, with its subject common name read only where it asserts none: where an alternative name is present the common name is not an additional identity, and an address in a common name is never an IP identity, because name matching reads the alternative names and does not fall back. The binding runs on whichever chain is returned, including a chain chosen through `selectChain`.
- An identifier that maps to no certificate name is refused as `acme/unsupported-identifier-type` rather than dropped from the comparison, on both sides of it. The ACME identifier registry is open and only `dns` and `ip` name something a certificate carries, so an order identifier of another type, a certificate `subjectAltName` that is neither a dNSName nor an iPAddress, and a subject common name that is neither a dns name nor a canonical IP address, would otherwise be skipped while the comparison still reported the whole set as checked: an order for a name plus one other identifier was satisfied by a certificate covering only the name, a certificate additionally naming a mailbox compared equal to an order that covered no mailbox, and a certificate naming both the order's name and a trailing-dot spelling of another compared equal on the strength of the first. The outbound `finalize` check refuses the same names, and reads every common name the request carries whether or not it also asserts alternative names -- a request naming the order's identifier in an alternative name and an unauthorized one in its subject is a request for a name the order does not cover, since a CA may carry that common name through into the certificate it issues.
- Names fold with ASCII case rules rather than the Unicode mapping, so a character whose Unicode lowercase is ASCII -- U+212A KELVIN SIGN folds to `k` -- is not read as the ASCII name it would fold to. That fold now has one definition the host-part rule, the CMP domain comparison and this one share.
- The names a certificate carries survive a replaced built-in. Every operation the shared PKIX decoders traverse, convert, copy and compare names with is bound when the module loads, and the tables that decide which GeneralName alternative a tag selects, which string types are DisplayText, and which decoder an extension OID resolves to hold no prototype. Replacing `Array.prototype.map`, `Buffer.prototype.toString`, `Buffer.concat`, `Array.prototype.push` or `Array.prototype.forEach` after load, or planting a name on `Object.prototype`, could otherwise present a subject alternative name or a subject common name the encoded certificate does not carry -- which any comparison downstream, including the certificate binding above, would then answer about.
- Every guard module freezes its exports. A boundary reaches its fail-closed check as a property of the guard object at the moment of the call, and the module registry hands every caller the same object, so a single assignment would have replaced a check -- a constant-time comparison, a size cap, a secret wipe -- in every module at once.

## v0.5.23 — 2026-08-21

The tables that decide which status codes, revocation reasons, trust bits and extensions this toolkit recognizes now answer from an operation taken at load, and an unsupported HTTP Digest algorithm is refused rather than read off a prototype.

### Changed

- Eighteen further modules -- ACME, EST, CMC, WebAuthn and its metadata reader, Certificate Transparency, trust-store ingest, TLS certificate compression, HPKE, HTTP Digest, the CRL and attribute-certificate signers, and the OCSP, CRL, attribute-certificate and shared PKIX schemas -- are held to the live-read rule, each declaring the reads it still carries as a count that can only fall.

### Fixed

- An HTTP Digest challenge naming an unsupported algorithm is refused with a typed error. The algorithm registry was an ordinary object, so a name matching any member of `Object.prototype` answered the supported-algorithm gate with a value the registry never held: the refusal was skipped and the call ended in an untyped `TypeError`. The registry now carries a null prototype.
- OCSP response statuses and revocation reasons, CRL reason codes, CCADB trust bits, attribute-certificate critical extensions and object-digest types, Certificate Transparency log states, and the repeated-parameter check on an HTTP Digest challenge all decide membership through a captured operation, so replacing the runtime's own membership test after load cannot widen what they accept.

## v0.5.22 — 2026-08-21

The list tests that decide whether a caller's argument is refused, and which form an extension spec is in, now answer from operations taken at load rather than read when they run.

### Changed

- The DER codec, the PKIX builders and the certificate signer are held to the live-read rule, with the reads each still carries declared as a count that can only fall. Certification-path validation's declared count falls in this release as its list doors convert.

### Fixed

- The DER builder refuses a non-list of children under a captured test. Every structure this toolkit encodes passes through that door, and it was the busiest live read in the library by two orders of magnitude.
- `pki.x509.sign` picks which form the caller's extension spec is in with a captured test. That choice decides which fields are read and therefore what is signed into the certificate: answering wrongly sends an object down the list arm, and the extensions it carries are never seen. The same test decides whether the spec asserts keyCertSign, which is what makes the issued certificate a CA.
- The name, key-usage and GeneralNames builders refuse a non-list argument under a captured test, as do certification-path validation's own list doors.

## v0.5.21 — 2026-08-21

The check that holds the crypto engine to load-time captures could be satisfied around, so nine modules had taken the safe primitive at one site and were never held to it anywhere else.

### Added

- `guard.intrinsic.bufferEquals`, the captured byte-identity comparison the bindings above are decided with.

### Changed

- Taking the captures has one spelling again. The guard orchestrator no longer re-exports them, so a module reaches them by importing the module that holds them, and the check reads that off the loaded module graph rather than off the text. This is the enforcement change: a module that adopts one safe primitive is held to the rule everywhere, and no way of writing the import, or of writing something that resembles one, changes the answer.
- The seven modules the widened check surfaced carry a declared count of remaining reads rather than an exemption. A new live read pushes a module past its figure and fails, and converting reads without lowering the figure fails too, so the number keeps naming the real count. A module reaching zero leaves the list and is held to zero.

### Fixed

- An OCSP response is bound to the certificate it answers for with captured byte comparisons. The CertID says the response concerns this issuer and the responder ID says this key signed it, and both were decided by a `Buffer.prototype.equals` read at call time: replaced after load it reports every hash equal, and a `good` signed by a different CA answers for the certificate under check.
- The signer's key is matched to its certificate under a captured case fold. WebCrypto algorithm names compare case-insensitively, so the check folds both sides; dispatched live, a replaced fold reported two different algorithm names equal and a key whose algorithm does not match the certificate passed the gate that exists to catch exactly that.
- `pki.sign` decides the RSASSA-PSS parameters, the digest tables and its key-shape tests on captured operations, so a caller who replaces one after this package loads cannot change which algorithm a signature runs under.
- A signing key is classified by asking the value what it is rather than by `instanceof` against a global. The old test read the `Uint8Array` binding at call time, and it answered wrongly in both directions on its own terms: a real typed array from another realm was not one, and an object built on the prototype was.

## v0.5.20 — 2026-08-20

Five verdicts that turned on list membership now decide it by comparison, so replacing an array method after the module loads cannot change what a certificate, a responder or a signed message is found to be.

### Added

- `guard-list`, an internal choke point for membership decided by comparison. Four verbs cover the rule shapes a verdict needs: is this value present, are all of these present, does any member satisfy a test, do all of them. An absent list is honestly empty rather than a throw, because these run on verdict paths where a throw would read as a different verdict. It is not on the public namespace; the surface is the verdicts above.

### Changed

- Certification-path building asks its loop-detection and fetch-dedupe sets through captured methods. These bound the work a hostile chain can cause, and a replaced `Set.prototype.has` answering false let a cycle be walked and a URL be fetched repeatedly until a size cap stopped it.

### Fixed

- `pki.path.validate` holds its `requiredEku` gate against a replaced `Array.prototype.indexOf`. The purpose test IS the decision about what the certificate may be used for, and dispatched live it reported `serverAuth` present on a leaf whose extended key usage carried only `emailProtection`, returning a valid chain for a certificate the caller had asked it to refuse. Certificate-policy membership and the `anyPolicy` scans in the policy tree are decided the same way.
- `pki.cms.verify` keeps `trusted: false` for a message no supplied anchor covers. The message-level verdict combined the per-signer answers with `every`, and a replaced one reported the whole set trusted without consulting a single signer.
- An OCSP response is still refused when its delegate lacks id-kp-OCSPSigning. Responder authorization is the decision about who may answer for an issuer, and reading the purpose list through the prototype let a delegate the CA never authorized speak for the whole issuer.
- A TPM attestation certificate still needs the tcg-kp-AIKCertificate purpose. That test is what makes a leaf an attestation key, so reading it live let an ordinary certificate be accepted as one.
- A timestamp token carrying an unrecognized critical extension is still refused (RFC 3161 sec. 2.4.2). The scan for a critical extension IS the gate, and a replaced `Array.prototype.some` answering false made the extension invisible and the unusable token acceptable.
- `pki.cms.verify` keeps `valid: false` for a message whose signature does not verify. The soundness verdict combined the per-signer answers the same way the trust verdict did, so a corrupted signature reported `valid: true`.
- `pki.path.validate` asks its processed-extension tables for their OWN entries. Freezing a table prevents a write to it and does not prevent an inherited lookup, so a name planted on `Object.prototype` answered for every OID the table did not carry and an unrecognized critical extension counted as processed. On a CA certificate only one of the two tables is consulted, so the chain was accepted rather than refused (RFC 5280 sec. 6.1.4).
- A trust anchor's `purposes` map is read for its own entries too. The map is the operator's restriction, and an inherited `true` granted the purpose on every anchor whose map did not name it. `distrustAfter` is read the same way.
- The ML-DSA digest-strength rule (RFC 9882 sec. 3.3) is one question both the signer and the CMS verifier ask, and it answers from the policy table's own entries. A digest name planted on `Object.prototype` answered for every name a parameter set's row omits, so a below-strength digest passed the strength check: `sha256` was accepted for ML-DSA-87 on both paths. The check also had two copies, which is why it now lives in the module that owns the table.

## v0.5.19 — 2026-08-20

The crypto engine and the JWS signer decide with operations captured at load, so code that runs after the module does cannot change what a permission check concludes.

### Changed

- `pki.webcrypto` and `pki.jose` are held to the live-read rule the guard family already follows: the check over `lib/` that refuses reading an operation from the runtime at call time covers every module that takes the captures, and these two now do. Operator-visible behavior is unchanged except where a refusal that could be bypassed now holds.

### Fixed

- The WebCrypto key-usage check holds against a replaced `Array.prototype.indexOf`. The membership test IS the permission decision, so it runs through the captured method: dispatched live, a key permitted only for `verify` passed the engine's gate for `sign` and failed later, in the platform's own crypto, with an untyped fault instead of `webcrypto/invalid-access`.
- A CryptoKey's usage list is a copy this engine took with a captured `slice`, so a replaced one cannot leave the caller holding the array the key is checked against for the rest of its life. The same applies to the partition that decides which usages each half of a generated pair is minted with.
- `pki.key.import`, `pki.key.generate` and `subtle.generateKey` refuse a non-array `usages` by name. It reached the key constructor, where `Array.prototype.slice` on a number yields an empty list, so the call minted a key permitted for nothing rather than naming the argument that was wrong. The asymmetric branch of `generateKey` partitions the usages before either half is constructed and `filter` treats a non-list the same way, so it is checked at that verb's own door and the whole pair is refused.
- The AES-GCM ciphertext and authentication tag are split with a captured `subarray`, so a replaced one cannot hand the tag check a different range of the same buffer.
- A JWK thumbprint (RFC 7638 sec. 3.3) is built with a captured serializer. The canonical JSON is the thumbprint's preimage, so a replaced `JSON.stringify` changes the identity every consumer compares keys by.
- The JOSE header checks -- own-name reads, the `crit` list walk, the embedded-JWK shape test -- and the protected-header serialization all run on captured operations, so a caller who reaches the header through an accessor cannot decide which of them apply.
- A digest runs under the algorithm the caller named. The hash name is case-folded to pick its registry row, and dispatched live a replaced `String.prototype.toUpperCase` answering `SHA-1` gave a caller who asked for SHA-256 a 20-byte digest. The same fold picks the HMAC block size and decides whether a key's algorithm matches the operation's.
- `getRandomValues` settles the kinds WebCrypto refuses from the value's internal slot rather than by `instanceof` against a replaceable global, so a real `Float32Array` cannot pass a check written to reject it by swapping the constructor the comparison names.

## v0.5.18 — 2026-08-20

Every producing verb refuses an authoring field it does not read, so a misspelling is a refusal at the call instead of a signed artifact that quietly omits what was asked for.

### Changed

- The issuer argument of `pki.x509.sign` and `pki.crl.sign` accepts only the fields the form it selects reads: `{ key, cert }` when an issuing certificate is supplied, `{ key, name, publicKey }` otherwise. A caller who hands over a key-store object carrying anything else must narrow it to one of the two. The check is there because a misspelled `cert` is not otherwise refused on this argument -- the issuing certificate is optional, so a dropped one falls through to self-signing, and a cross-certificate issued that way names its own subject as its issuer. Supplying both forms is refused for the same reason: the certificate branch wins unconditionally and reads neither `name` nor `publicKey`, so a caller who named a different issuer got one issued under the certificate's own distinguished name.
- The TSA argument of `pki.tsp.sign` accepts only the certificate and key it reads. A misspelling of either was always refused for what it left missing, but that is half the class: a name belonging on the third argument, written on the TSA instead, is read by nothing there. `ordering` placed on the TSA emitted a token the same size as one that never asked for it, with the flag unset, while the caller believed they had set it. A caller passing a key-store object whole must narrow it to `{ cert, key }`.

### Fixed

- `pki.x509.sign` refuses an unrecognized field on the certificate spec, the issuer and the options. `extension` in place of `extensions` signed a certificate carrying NONE of them: a caller asking for `basicConstraints: { cA: true }` and `keyUsage` got neither, and nothing in the returned certificate said so. An `issuer` nested inside the spec is refused by name as well -- the issuer is the second argument, and nesting it produced a self-signed certificate with no error, which reads as a broken signer rather than a mis-shaped call.
- `pki.crl.sign` refuses an unrecognized field on the CRL spec, the issuer and the options. The revoked list is `revoked`; the parser reports the same list as `revokedCertificates`, so a caller round-tripping a parsed CRL back into signing reached for that name and got a correctly signed, structurally valid CRL asserting that nothing is revoked. The refusal names the field the producer reads.
- `pki.pkcs12.build` refuses an unrecognized field on the store spec and the options, and no longer accepts an option belonging to `open` or `verifyMac`. A store that silently omits a certificate or a key looks well formed and opens cleanly; the omission surfaces wherever it is later imported.
- `pki.pkcs12.build` refuses an unrecognized field on a `safeContents` entry, on a bag, and on the PBE descriptor either of them carries. The safeContents entry is where the privacy directive lives, so a misspelling there was the costliest in the format: `encrypt` misspelled is neither present nor falsy, so it passed the guard that rejects a present-but-falsy directive, no privacy was selected, and that safe went out as plaintext `id-data` -- an unshrouded private-key bag in the clear, inside a PFX whose MAC still verified and which opened without complaint. A misspelled PBE parameter, on either the safe or the bag, silently reverted to the built-in default, so a caller who asked for a stronger KDF or a different cipher got neither. `opts.mac` is checked the same way, and every one of its fields has a default too: a misspelled `iterations` produced a store MAC'd at the shipped iteration count while the caller believed they had raised it, and an array passed as `opts.mac` carried no field any table lists, so it reached the builder as an empty descriptor and produced the default MAC.
- `pki.pkcs12.build` refuses a spec that mixes its two forms. `safeContents` and the `{ key, cert, ca }` shorthand are alternatives, and the builder returns `safeContents` the moment it is present without ever reading the shorthand fields -- so `{ safeContents: [...], key }` produced a store with the key silently absent.
- A `safeContents` entry is checked against the privacy branch it selects. `contentEncryptionAlgorithm` is the public-key branch's content cipher and is read by nothing on a password safe, whose cipher comes from `encrypt.cipher`, so an explicit `aes-128-cbc` request on a password safe was accepted and the default used anyway.
- A certificate recipient of `pki.cms.encrypt` is checked against the fields ITS CERTIFICATE'S KEY ALGORITHM reads. The arms differ: RSA reads `oaepHash` and never `ukm`; ECDH, X25519, X448 and ML-KEM read `ukm` and never `oaepHash`. One table across all three accepted `oaepHash` on an elliptic-curve recipient, where nothing reads it, and wrapped the content key under the arm's own defaults.
- A bag is checked against the fields ITS OWN TYPE reads, rather than against one table covering every type. `encrypt` is real on a `shroudedKey` bag and read by nothing on a plaintext `key` bag, so a union accepted the combination and emitted the private key as an unencrypted key bag while the caller had supplied an explicit encryption directive and the surrounding MAC gave integrity only.
- `pki.crl.sign` refuses an unrecognized field on a revoked-certificate entry. Every optional field on an entry is encoded only when present, so `reson` in place of `reason` listed the certificate as revoked carrying no reason code at all -- in a CRL that verifies, where a relying party cannot distinguish it from a revocation published without a reason.
- `pki.cms.encrypt` refuses an unrecognized field on a recipient descriptor, against a table per recipient type. The fields beyond the selector all have defaults, so `oaepHsh` encrypted the content key under the default SHA-256 OAEP, and a misspelled `iterations`, `salt` or `prf` on a password recipient derived the key at the default work factor. The single descriptor an `EncryptedData` takes is checked per arm as well: its `cek` branch derives nothing, so a work factor handed to it was discarded exactly as a misspelling would be.
- `pki.tsp.sign` refuses an unrecognized field on the `accuracy` descriptor. Its three fields are all optional, so `milis` emitted an empty Accuracy that reads back as zero seconds, millis and micros, and the token understated the precision the caller asked it to claim.
- `pki.cms.encrypt` and `pki.cms.authenticate` each refuse an unrecognized option, against separate tables. The two verbs have different option sets, so a single shared table would have accepted either verb's keys and defeated the check.
- `pki.tsp.sign` refuses an unrecognized field on the message imprint and the options. A one-letter slip -- `odering` for `ordering` -- emitted a token with the ordering flag unset, which is a claim about the timestamp the caller believed they had made.
- `pki.pkcs12.open` and `pki.pkcs12.verifyMac` each refuse an unrecognized option, against their own tables. `maxIterations` caps the work a hostile store can demand of the key derivation, so a misspelling restored the built-in ceiling and the tighter bound the caller set was never applied.
- `pki.tsp.request` refuses an unrecognized field on the message imprint as well as on the options -- the same table `pki.tsp.sign` holds its imprint to. The request's own options are the second argument, so `nonce` written on the imprint reached nothing and the request went out with no nonce.
- `pki.tsp.request` refuses an unrecognized option. Every field it encodes is optional in the request structure, so a misspelled one was dropped without complaint: `nonce` misspelled put a request on the wire carrying no nonce at all, and the client then matched the reply against a replay defense it had never actually asked for.
- `pki.tsp.response` refuses an unrecognized option. `status` carries the verdict and defaults to granted, so a misspelling did not lose a detail -- it inverted the answer, and a TSA declining a request emitted a granted response over a token it meant to withhold.
- The options of `pki.pkcs12.build` are checked against the spec form in force. `recipientCerts` selects public-key privacy only where the builder assembles the safes itself, so under the `safeContents` form it selected nothing: a plaintext key bag went into an `id-data` safe with the private key in the clear, while the caller had asked for recipient-enveloped privacy. The full form carries privacy per entry, as `safeContents[i].recipients`.
- `pki.cms.encrypt` and `pki.cms.authenticate` refuse a per-recipient default that no recipient of the message reads. `oaepHash` is read only by an RSA certificate recipient, `ukm` only by a key-agreement or KEM certificate recipient, and `keyIdentifier` only by a certificate recipient -- so a password-only message given an `oaepHash` described a wrapping it never performed. A recipient that supplies its own value reads that one and never the default, so a default every recipient overrides is refused too; one recipient that does read it is enough. An `EncryptedData` carries no recipients at all and so reads none of the three.
- `pki.cmp.build` refuses a signature parameter under MAC protection. `pss` and `digestAlgorithm` are read only by the `{ key, cert }` form; supplied alongside `mac`, they emitted a message MAC'd under the default PRF, byte for byte identical to the call that never named them.
- `pki.jose.sign` refuses `opts.jwk` when the protected header already embeds one. The embedded key wins, so the option is read in kid mode only, and a caller who named a different public JWK beside an embedded one had it discarded.
- `pki.jose.sign` serializes the protected header once and every check reads back those exact bytes, through the same bounded reader a received JWS goes through. The header is a caller-owned object, so a `toJSON` or an accessor could answer the public-only check and the serializer differently: a JWK whose own members carried no private half but which serialized `d` passed the check and shipped the private key in the header. Serializing runs that code, so the option check now runs before it -- a `toJSON` reaching into the options bag could otherwise delete a misspelled option before the door looked at it. A header that cannot be serialized is refused rather than reaching the signer.
- `pki.pkcs12.build` decides which of the two spec forms it has once, and the field check and the builder both act on that decision. Held separately the two tests disagreed on a `safeContents` that is present but not a list, so the refusal named a field that was correct instead of the one that was wrong.
- `pki.cms.encrypt` refuses `authAttrs` under a CBC content algorithm. Authenticated attributes are carried by an `AuthEnvelopedData`, which an AEAD algorithm produces; a CBC algorithm selects `EnvelopedData`, which has no field for them, so the attributes a caller asked to authenticate were dropped and the message went out without them.
- `pki.cms.authenticate` refuses `digestAlgorithm` and `authAttrs` when `authenticatedAttributes` is `false`. That branch MACs the content octets directly and builds no attribute set, so the digest algorithm that names its hash, and any attribute added to it, are read by nothing.
- `pem` on an `EncryptedData` descriptor is refused; it is an option, the third argument, as on every other verb here. A second spelling on the descriptor was read by the password arm alone, so a `{ cek, pem: true }` descriptor was accepted and returned DER.
- `pki.crl.sign` refuses `spec.issuer` when the issuer argument already names one. The distinguished name has three possible sources and `issuer.cert` and `issuer.name` both win outright, so a spec naming one issuer beside a certificate for another produced a CRL, which verifies, issued under the certificate's name. `spec.issuer` is still the name when the issuer argument supplies none.
- `opts.mac.keyLength` on a `pki.pkcs12.build` store is refused under the classic `hmac` algorithm, which is the default. The Appendix B derivation produces a key at the hash's own output length and reads no key length, so an explicit one was discarded and the store carried the same MAC as a call that never named it. `keyLength` remains a PBMAC1 parameter (RFC 9579).
- A `pki.pkcs12.build` public-key integrity signer is checked against the form it selects, `{ cert, key }` or `{ spki, keyIdentifier, key }`, plus the signature parameters. Every field beyond the identity has a default, so a misspelled `pss` or `digestAlgorithm` signed the store under PKCS#1 with SHA-256 while the caller had asked for something else, and a store records only what was used. `opts.integrity.signer` and `opts.integrity.signers` are the same setting spelled two ways and the list won outright, so supplying both signed the store under the list alone; exactly one is now required.

## v0.5.17 — 2026-08-20

A failed integrity check destroys the plaintext it recovered, AuthEnvelopedData validates the attributes it is asked to authenticate, and every refusal names the rule it is actually applying.

### Changed

- The refusals for a content cipher in the wrong container now cite the rule they apply. RFC 5083 sec. 2 defines `AuthEnvelopedData` in terms of a content-authenticated-encryption algorithm; it never names GCM, and RFC 5084 sec. 3.1 defines AES-CCM for that content type. Saying the standard required GCM sent an operator to argue with a specification that says the opposite. The check now draws the line the RFC draws -- authenticated versus not -- and an AES-CCM message is declined separately, by name, as an algorithm this toolkit does not implement rather than as an unrecognized OID. Error codes are unchanged.
- Documentation pages at pkijs.com carry the expanded name of the format they describe in the title and heading, so a page about CMS says Cryptographic Message Syntax rather than three letters that mean a content management system elsewhere. The sitemap now reports each page's real last-changed date, taken from the commit that last touched the source it is generated from, and omits the date where it is unknown instead of reporting the build time. The home page gained runnable examples for the six things people most often arrive wanting to do.

### Fixed

- A failed AEAD integrity check now destroys the plaintext it recovered. `update` returns the complete recovered plaintext and `final` is what decides whether the message was authentic at all, so on a forged `AuthEnvelopedData` the plaintext existed in full before anything had judged it, and the buffer was then dropped unreferenced and unzeroed. RFC 5083 sec. 1 requires it to be destroyed. The success path leaked too, less visibly: joining the two halves copies them, leaving the first buffer as a second complete copy of the plaintext that the caller never receives and nothing else would ever clear. Both exits are now cleared, at every cipher the toolkit runs -- CMS content decryption and the RFC 3211 password-recipient unwrap, PBES2, HPKE `open`, PKCS#12 safe decryption, and the AES-GCM, AES-CBC, AES-CTR and AES-KW paths of the WebCrypto engine.
- `pki.cms.encrypt` validates the authenticated attributes a caller supplies for an `AuthEnvelopedData` before it MACs them. Each must be a well-formed `Attribute SEQUENCE { type, non-empty SET OF value }` with no repeated type (RFC 5652 sec. 5.3). `AuthenticatedData` had enforced this all along; `AuthEnvelopedData` accepted whatever it was handed and emitted it, so a malformed attribute reached the wire with the MAC already computed over it and the operator learned of it from a peer's parser rather than from the builder.
- `pki.cms.encrypt` refuses a `message-digest` attribute in an `AuthEnvelopedData`'s `authAttrs`. Its value is the unencrypted one-way hash of the plaintext, so disclosing it alongside the ciphertext enables content tracking and confirms a guessed plaintext against a message that was encrypted to prevent exactly that (RFC 5083 sec. 2.1 and sec. 5). Decryption still accepts one, since another implementation may legitimately emit it; the toolkit will not produce one. `AuthenticatedData` is unaffected -- it builds and MACs its own `message-digest` by design (RFC 5652 sec. 9.2).

## v0.5.16 — 2026-08-20

`pki.cmp.verify` reads every option at the call, and every guard takes what it needs from the runtime when it loads, so code a caller runs afterwards cannot change what a verification decides.

### Fixed

- `pki.cmp.verify` reduces every option to a value the caller no longer reaches, before verification begins. `transactionID` and `expectRecipNonce` are copied, so overwriting the buffer that was passed in changes nothing; `trustAnchors` and `intermediates` have their array copied, so appending an anchor mid-call cannot widen the set the chain is built against; and a `time` that is a real `Date` is re-made at the same instant, so calling `setTime` on it cannot move the point the signer's certificate is validated at. A `time` that is not a `Date` passes through untouched, so the input error it already earns is the one it still gets.
- `pki.cmp.verify` now takes its options through the same door every other verb uses, which it had been skipping in favor of two checks of its own. The door refuses an options object whose fields are accessors, and one that changes which fields it carries while they are read. An accessor is caller code running inside the call before the verb has done anything, and from there it can rewrite the very predicates and constructors the verb is about to use, so refusing it closes that whole class rather than one member at a time. What is asked of a caller is only that the options themselves be values.
- A `sharedSecret` given as a string is converted to bytes with a `Buffer.from` captured when the module loads. `Buffer.from` is a writable property of the `Buffer` global and the secret is the first option read, so the accessor supplying it could install a replacement, receive the plaintext as that replacement's argument, and have whatever it returned become the PBMAC1 key -- a message authenticated under a secret the caller never held would then verify. The captured reference is fixed before any caller code has run.
- A `sharedSecret` given as a string is converted to bytes once, at the door, and that copy is cleared with the rest. The conversion is what produces a wipeable plaintext copy, and it used to happen deeper inside the MAC path where nothing owned it, so the toolkit left a copy of the secret behind on every call. The string itself is immutable and cannot be cleared, which is still the reason to hand over bytes when the secret must not outlive the call.
- Whether a byte option is copied, and whether that copy is later wiped, is decided by asking the value's internal slot rather than its prototype. `Buffer.isBuffer` is a writable property and `instanceof Uint8Array` consults `Uint8Array[Symbol.hasInstance]`, and both are reachable from an option accessor that has already run by the time the test is evaluated. A caller who made those answer `false` for their own buffer got the copy skipped, which left their live buffer where the copy belongs -- and the wipe then cleared memory the toolkit does not own.
- The copies go through `guard.bytes.snapshot`, this toolkit's door for caller-supplied bytes, so a source the door refuses stays refused. A view backed by a `SharedArrayBuffer` is the case that matters: another thread can rewrite it at any moment, and copying it with `Buffer.from` would have turned it into an ordinary `Buffer` that every later check accepts without the door's rules ever having run.
- Each option is read from the caller's object exactly once and copied in the same step, before the next option is read. A property can be an accessor: asked twice it may answer differently, so the value that passed the check would not be the value that was used; and an accessor invoked for a later option can reach back and rewrite the buffer an earlier one handed over, so reading the whole bag before copying any of it would copy whatever the last accessor left behind.
- `opts.trustAnchors` and `opts.intermediates` must be a plain dense array (or a single certificate). A list with holes, or one whose elements are reachable only through its prototype, is refused with `cmp/bad-input` naming the problem. Reproducing what an ordinary array operation would consume means handling holes, inherited and non-enumerable elements, and the uint32 index boundary, and those pull against each other: reading every position covers inheritance but scans a sparse list's whole length, while enumerating present keys is bounded but silently drops an inherited anchor -- and dropping an anchor turns a trusted verification into an untrusted one. Refusing is the answer this verb gives instead of choosing quietly. No ceiling is imposed on how many anchors a dense list may hold.
- Two things stay caller-owned by design, and the release is explicit about them. A parsed certificate passed as an anchor or an intermediate is held by reference, because `pki.schema.x509.parse` results carry their provenance against the object's identity and a copy would stop being recognized as parser output; editing one mid-call changes nothing, since path validation re-derives a parsed certificate from the bytes it recorded. And a `revocationChecker` is the caller's own callback, so what it answers is theirs to decide whenever it is called.
- `pki.cmp.verify` refuses a Proxy-wrapped certificate list before it reads anything from it. A Proxy over an array reports itself as an array while answering from traps that need not agree with one another, so a count taken through them describes nothing that is still true by the time the anchors are used. Refusing a Proxy was already this toolkit's answer for a value it cannot ask honestly; that refusal now runs ahead of the length read and the descriptor walk, which would otherwise be the first thing to hand control back to the caller.
- Every guard that performs its work with a method captured at load now invokes that method without reading any property of it. Capturing freezes which function runs, so replacing the prototype method afterwards changes nothing; invoking it as `captured.call(receiver, ...)` then read `call` off the captured function, and that function is the one still sitting on the prototype. Assigning an own `call` to it shadows `Function.prototype.call` and hands the guard back to the caller with every check around it still passing: the secret wipe clears nothing, two unrelated distinguished names compare equal, a decode returns a string the caller chose, the instant a certificate is validated at becomes whatever the caller says, and a byte view reports a length and a backing store it does not have.
- Every operation the guard family performs while it is deciding something is taken from the runtime when the module loads, rather than at the moment it runs. That covers the reflective operations a guard inspects a value with, the predicates that answer what a value IS, the `Buffer` and `Promise` constructors, the numeric and array tests bounds are built on, and the string operations names and encodings are compared with. It also covers the array operations a guard walks its own lists with, where a `forEach` replaced by a no-op made a scan over real keys report nothing and every rule keyed on that scan then passed without examining anything. The whole family is driven with twenty-three of those globals replaced at once and each guard still reaches the answer it would have reached untouched. A check over the source refuses any guard that reads one of these at call time, so the rule holds for a guard written later without anyone remembering it.
- Canonicality takes both halves of its round trip at load, and the size cap measures a Buffer through the byte guard's captured length getter. Canonical form is decided by decoding the text and re-encoding the result, so either half settles the answer: a `toString` returning the input made `AB` compare equal to its own re-encoding and pass as canonical base64url, where its canonical form is `AA`. On a typed array `length` is a configurable accessor on the prototype, and the cap comparison read it, so one returning 0 admitted a buffer of any size and then decoded it in full.
- The `Buffer` constructor that turns a backing store into something the toolkit can read or clear is taken at load. It sits behind the re-view every byte boundary performs, so one returning a buffer over different memory handed each later step a decoy: a wipe zeroed the decoy and returned normally while the plaintext it was aimed at stayed readable, with every check in between satisfied. The same reference now backs the canonical-encoding round trip, where a replacement decides what that comparison sees, and the provenance copy, where it decides whether recorded bytes can still change afterwards.
- The index scan behind the accessor refusal asks the array question through a reference taken at load. A replacement answering `false` made the scan report no indices, so the refusal had nothing to refuse and an accessor under index 0 was reached by the copy that follows it -- the caller's own `Error` escaping a boundary whose contract is a typed refusal, with the accessor free to run before anything it returned was snapshotted. The same reference now backs the array questions in the byte-source, parsed-structure and identifier guards.
- The two predicates that decide whether a `pki.cmp.verify` option gets copied are taken at load rather than at the call. Both are ordinary writable properties, so reading either at call time handed that decision to the caller: an `isUint8Array` answering `false` left a live caller buffer where the copy belongs and left that buffer out of the list the call wipes, and an `Array.isArray` answering `false` routed a trust list past the list door and back out by reference, ready to be emptied or appended to while verification was suspended.
- The slot predicates a guard asks about a value are snapshotted at load, and so is the check that a `Date` holds a real instant. `util.types` is an ordinary object on an ordinary module export, so `util.types.isDate = () => true` reached every guard that asked it at call time; these predicates are how the toolkit decides what a value IS, in preference to a prototype test a caller can satisfy, so a caller answering them instead had replaced the one question a lookalike cannot lie about. The global `isNaN` was read the same way, and one returning `false` made `new Date(NaN)` pass the validity check that stands in front of every window comparison.
- The wipe that clears a secret the toolkit allocated writes its zeros through a fill captured when the module loads. `Buffer.prototype.fill` is a writable property, and it was the entire operation the wipe performed: replaced with a function that writes nothing, every wipe in the toolkit became a silent no-op. Nothing observable changed when that happened, since the wipe was still called and still returned, while a shared secret or a content-encryption key stayed readable in the heap after the call whose only purpose was to clear it.
- Distinguished-name comparison takes its string operations from the prototypes at load. Every value it compares is a plain string, so `value.toLowerCase()` reached whatever `String.prototype` carried at the moment it ran, and that is an ordinary writable property. One returning a constant made every DN compare equal to every other, which is the whole of what this comparison decides: chain building would accept an unrelated issuer, a revocation entry would match a certificate it was never written for, and a name constraint would stop excluding anything. The DN escaping used in reports and the control-byte reject that defends CVE-2009-2408 read characters and character codes the same way, and the same change covers them. `Array.isArray`, which decides whether a comparison can be performed at all, is captured alongside them; replacing that one points the other way, turning every name comparison the toolkit makes into a refusal.
- Decoding untrusted bytes to a string does its work through references captured at load, on both of its input arms and in both of its contracts. `Buffer.prototype.toString` produced the guard's entire output for a Buffer and `TextDecoder.prototype.decode` did the same under strict UTF-8, so replacing either handed the boundaries that compose it -- PEM headers, JOSE segments, DN attribute values -- a string the caller chose, with the size cap, the detached-view refusal and the strict-UTF-8 rule having all run and all passed. `Buffer.byteLength` measures a string against that cap, and one returning a small number admitted a string of any size, which is the allocation this guard exists to bound. `Number.isInteger` decides whether the cap is usable and `Buffer.isBuffer` decides which arm an input takes; both are captured for the same reason.

## v0.5.15 — 2026-08-19

`pki.attrcert.verify` checks an attribute certificate against the RFC 5755 validation rules, so a consumer reading its privilege attributes is reading ones an issuer actually granted.

### Added

- `pki.attrcert.verify(ac, issuer, opts)` performs the checks of RFC 5755 sec. 5 that an attribute certificate and a named issuer can settle between them: the signature over the exact `AttributeCertificateInfo` bytes, through the one path-validation signature engine with its algorithm-confusion and EdDSA low-order-point gates; the AC naming the issuer this verifier trusts; the evaluation instant lying within the validity, where equality with either bound succeeds as the section states; the sec. 4.3.2 targeting rule, where an AC that names targets is refused at a verifier it does not name; and rejection of any critical extension this verb does not process. Section 5 defines support as parsing the value AND rejecting where the value would reject, so an extension parsed but never evaluated is not supported: targeting is processed and an audit identity states no rule that rejects, while `aaControls` and `acProxying` carry constraints this verb does not evaluate, so a critical one is refused, and a critical extension defined in future defaults to refused. Targeting compares each GeneralName form under its own matching rule: a dNSName folds case across the whole name (RFC 5280 sec. 7.2), a mailbox matches its local-part exactly and its host-part case-insensitively (sec. 7.5), and a directoryName goes through the same distinguished-name comparison the rest of the toolkit uses. A URI compares as encoded, since sec. 7.4 makes URI equality a full RFC 3987 normalization that a partial version could get wrong in the accepting direction; a form with no comparison here reports the check as not performed rather than as a pass. The third Target alternative, targetCert, is one sec. 4.3.2 says "MUST NOT be used", so an attribute certificate carrying one is refused before any match is considered, and a matching entry beside it does not rescue it -- the issuer broke a MUST NOT of its own profile, and letting the verdict turn on which entries rode alongside the forbidden one would leave the outcome to whoever assembled the certificate.
- `issuer` is `{ name, publicKey }` and is required. Section 5 item 4 makes trusting an AC issuer the verifier's own configuration, so it is an argument this verb refuses to infer from the certificate that wants to be trusted. `issuer.name` takes every form `pki.attrcert.sign` accepts for the issuing authority -- a string, an array of RDNs, or raw `Name` DER -- and is compared as a distinguished name under RFC 5280 section 7.1, so an attribute certificate issued under a multi-RDN authority name is verifiable under that same name. An empty distinguished name names no issuer and is refused, whichever form it arrives in.
- Revocation is RFC 5755 section 6, outside section 5's seven rules, and it gates every pass. This verb implements the section's "never revoke" scheme, which AC users MUST support: it holds no revocation evidence and follows no pointer out of the certificate. The section states what that obliges -- "If only the 'never revoke' scheme is supported, then all ACs that do not contain a noRevAvail extension, MUST be rejected" -- because an issuer that omits `noRevAvail` is stating that revocation status checks are supported, so a verdict that skipped one would grant privileges the issuer expected to be able to withdraw. An attribute certificate carrying `noRevAvail` verifies and reports `revocationChecked: true` with `noRevAvail: true`. One without it is refused unless the caller passes `opts.revocationStatus` (`"notRevoked"` or `"revoked"`), the status they established through the section's "pointer in AC" scheme; the certificate's own `crlDistributionPoints` and `authorityInfoAccess` reach them on the verdict's `extensions` to follow. The two schemes are alternatives, and section 6 closes by saying so -- "An AC MUST NOT contain both a noRevAvail extension and a 'pointer in AC'" -- so an attribute certificate carrying both tells a verifier two different things about its own revocation and is refused however the caller answers.
- `opts.time` and `opts.target` are read at the call, before signature verification suspends it. A verdict assembled from options re-read after the signature settles would answer for whatever the caller last wrote, so a caller could hand in an expired instant, replace it while the signature was being checked, and be told the attribute certificate was timely. Mutating the `Date` in place has the same effect and is closed the same way: the instant is taken at the call, and the target is decoded there. `issuer.publicKey` is copied at the call for the same reason. The signature engine holds that buffer across the suspension, and a composite arm keeps slices of it and imports them only once its digest settles, so a caller could otherwise hand in an unrelated key of the same length, overwrite it with the real issuer's while the digest ran, and be told the certificate verified under the key they named.
- The verdict is `{ verified, signatureValid, validityChecked, targetingChecked, revocationChecked, noRevAvail, holderBindingChecked, issuerPathChecked, holder, issuer, attributes, extensions, notBefore, notAfter, serialNumberHex, reason }`, its fields re-derived from the signed bytes. The two checks needing certificates this verb is not given -- the holder's own certificate and chain, and the AC issuer's chain and sec. 4.5 profile -- report `false` in their own slots rather than leaving their absence to read as a pass; run those through `pki.path.validate` with the certificates you hold. Omitting `opts.time` leaves the validity question unasked, which reports `validityChecked: false` and never `verified: true`.

### Changed

- `pki.attrcert.sign` refuses to issue an attribute certificate carrying both a `noRevAvail` extension and a `crlDistributionPoints` or `authorityInfoAccess` revocation pointer, which RFC 5755 section 6 says an AC MUST NOT do. The rule is held over the extensions the certificate ends up with, so it binds the named-extension form and the pre-encoded DER form alike.
- `pki.schema.attrcert.parse` now records the bytes it read, joining the certificate, CRL, CMS, certification-request and certificate-request-message parsers, and `pki.attrcert.verify` accepts only a result carrying that record. A parsed attribute certificate presents the signed byte range and the attributes granting the privileges as separate properties, so a rebuilt one can hold a genuine issuer's signed bytes beside substituted attributes. As with those parsers, the raw byte views on a parsed result now read from the parser's own copy of the input; passing DER or PEM is unaffected.

## v0.5.14 — 2026-08-19

`pki.crmf.verifyPop` checks the proof of possession on an inbound certificate request message, the CRMF counterpart to the PKCS#10 check that shipped in 0.5.13.

### Added

- `pki.crmf.verifyPop(messages)` verifies the RFC 4211 proof of possession on each `CertReqMsg`, returning one verdict per message plus a top-level `verified` that is true only when every message carried a proof that held. For the `signature` proof the covered bytes are the ones the RFC names: the DER of `poposkInput` when that field is present, and the DER of `certReq` when it is absent (sec. 4.1, and the ASN.1 module, which is where the two readings of that sentence are settled). Verification composes the one path-validation signature engine, with the same algorithm-confusion (RFC 9814 sec. 4) and EdDSA low-order-point gates as the certificate and CRL paths.
- The proofs that cannot be checked from the message are reported as such. `raVerified` is an RA's assertion that it confirmed possession out of band, so it yields `verified: false` with `method: "raVerified"`, and a caller who trusts that RA opts in by reading `method`. `keyEncipherment` and `keyAgreement` complete over a later protocol exchange, or need the CA's decryption key, so they yield `verified: false` naming the arm. Each verdict carries only what its preimage covers, re-derived from the message's own bytes, so a CA issues from what was checked. `publicKey` is the key possession was proven for. `subject` is the requested name when the signature was over `certReq`, which covers the whole template; a `poposkInput` preimage covers the key and the sender alone, so a subject sitting beside it in the message is unsigned and is withheld with `subjectBound: false` rather than reported next to a passing verdict.

### Changed

- `pki.schema.crmf.parse` now records the bytes it read, joining the certificate, CRL, CMS and certification-request parsers, and `pki.crmf.verifyPop` accepts only a result carrying that record. A parsed message set presents the byte range a proof covers and the template a certificate would be issued from as separate properties, so a rebuilt one can hold a genuine requester's signed range beside a substituted subject. As with those parsers, the raw byte views on a parsed result now read from the parser's own copy of the input; passing DER is unaffected, and a parse result used as-is still works.

### Fixed

- The `pki.csr.sign` example passed `subject: "CN=device-42"`, which asks for a commonName whose value is the string `CN=device-42` and so issues `CN=CN=device-42`. A bare string is the commonName value throughout the toolkit; the example now says so and passes `"device-42"`.

## v0.5.13 — 2026-08-19

`pki.csr.verify` checks an inbound certification request's proof of possession, so a CA built on this toolkit can tell that the requester holds the key they are asking it to certify.

### Added

- `pki.csr.verify(request)` verifies a certification request's signature over its exact `certificationRequestInfo` bytes under the `subjectPKInfo` inside them (RFC 2986 sec. 4.2), the check `openssl req -verify` performs. `request` is DER, PEM, or a parsed request. It composes the one path-validation signature engine, with the same algorithm-confusion (RFC 9814 sec. 4) and EdDSA low-order-point gates, in place of the producing side's self-check, which waives that gate because it runs over a key the caller already controls. It fails closed on any import or verification fault, and malformed input throws a typed `CsrError`.
- The answer is `{ verified, subject, subjectPublicKeyInfo, attributes, certificationRequestInfoBytes }`, every field re-derived from the bytes the signature covers. Issue from those. A CA that normalizes a request before verifying it holds an object carrying its own edits, and a bare boolean would report on the signed bytes while the certificate got built from the edits; the fields travel with the verdict so the two cannot come apart.
- What `verified: true` establishes is stated in full, because the bound is the point: the producer held the private half of the key inside the request, and the subject and every requested extension are the ones covered by that signature. A CSR carries no issuer and its key is self-asserted, so a requester free to choose both can prove possession of a key they generated a moment ago under any name they like. Binding that name to an identity stays with the enrollment protocol.

### Changed

- `pki.schema.csr.parse` now records the bytes it read, the way the certificate, CRL and CMS parsers do, and `pki.csr.verify` accepts only a result carrying that record. A parsed request presents the signed byte range and the fields that range encodes as separate properties, so a rebuilt one (`Object.assign`, a spread, a JSON round-trip) can hold a genuine requester's signed bytes beside a substituted subject: the proof of possession verifies from the recorded range while the certificate a CA issues is for a name nobody signed. Passing DER or PEM is unaffected, and a parse result used as-is still works.
- One visible consequence of that record: the raw byte views on a parsed request (`certificationRequestInfoBytes`, `tbsBytes`, `subject.bytes` and the rest) now read from the parser's own copy of the input rather than from your buffer, matching what `pki.schema.x509.parse` has always done. Writing into the buffer you passed no longer changes what an already-parsed request reports, and writing through one of those views no longer reaches your buffer. Code that read the fields is unaffected; code that relied on either aliasing needs to re-parse instead.

## v0.5.12 — 2026-08-19

`pki.crl.isRevoked` can be asked at an instant, and a CRL that stopped speaking for that instant is refused rather than read as a clean bill of health.

### Added

- `pki.crl.isRevoked(crl, serialNumber, { time })` asks the question at an instant. A CRL whose `nextUpdate` has passed, whose `thisUpdate` is later, or which carries no `nextUpdate` at all is refused with `crl/not-current` instead of answered from: outside the window a CRL states, an absent serial says nothing about the certificate, and a list stating no window cannot be told from a replayed copy. This joins the refusals already made for a delta, indirect or narrowed-scope CRL, on the same reasoning -- a serial means something only within the set, and the span, a CRL speaks for. `pki.path.crlChecker` is unchanged and remains the verb that decides currency against material it fetched itself.
- `opts.historicalMode` reads a revocation entry against that same instant, the way `pki.path.crlChecker` reads one. By default a listed serial is revoked whatever its `revocationDate` says, since a date in the future is post-dating or clock skew and must not read good; set `historicalMode` -- validating as of a past instant, a timestamped signature say -- and an entry dated after that instant has not yet applied. Given without `time` it names no instant to read against and is refused.

### Changed

- `pki.crl.isRevoked` takes a third argument. It is optional and every existing call keeps its behavior and its answer: without `time` the verb is the structural lookup it has always been, and its documentation now names the question that then goes unasked, so `null` reads as "not listed on this CRL" rather than "not revoked". An option it does not read is refused rather than ignored.

## v0.5.11 — 2026-08-19

An option a verb never reads is now refused, so a misspelled `password` on key export can no longer leave a private key unprotected.

### Changed

- `pki.key` (encrypt, decrypt, export, import, generate, publicFromPrivate), `pki.path.validate`, `pki.path.build`, `pki.lint.certificate` and `pki.ocsp` (buildRequest, sign, verify) throw `<domain>/bad-input` on an option they do not read, instead of ignoring it. A call passing an option that did nothing before will now fail; the message names the unknown key, so the fix is to correct or drop it.
- The same verbs require an options object whose options are values. One supplied through a getter is refused, naming the option, because a getter is asked afresh on every read and the value the check saw is not necessarily the one the verb uses. A caller computing an option can read it into a plain object at the call: `pki.key.export(key, { format: computeFormat() })`. Methods are unaffected, so a class instance carrying them is still an options bag.
- Where two verbs spell the same idea differently, the refusal says so. `pki.path.validate` takes `trustAnchor` and `pki.path.build` takes `trustAnchors`, and each names the other, because carrying the wrong spelling between them previously bought no anchoring and no error. `pki.key.encrypt` chooses `iterations` while `pki.key.decrypt` caps `maxIterations`; `pki.ocsp` spells the nonce three ways across buildRequest, sign and verify because it means three different things.
- `pki.path.build` accepts every `pki.path.validate` option, since it forwards them to each internal validation. That union is derived from validate's own list rather than restated, so the two cannot drift apart.

### Fixed

- `pki.sigstore.verifyBundle(bundle, { time })` validates the Fulcio chain at the instant the Date holds. `time` is accepted by its internal slot, so a Date subclass reaches the check; the instant was then read back through `getTime`, which a subclass answers. A caller-supplied Date reporting the log time made the ephemeral signing certificate, whose validity is about ten minutes wide, look current whenever the caller chose to check it. Every read of a Date across the toolkit now goes through the intrinsic, so what a comparison uses is the instant a Date holds rather than the one it reports.
- `pki.path.validate` and `pki.ocsp.verify` decide the validity window on the instant a Date holds. Both compared the caller's `time` against a certificate's, CRL's or response's Date as objects, and comparing two Dates coerces each one through `Symbol.toPrimitive` or `valueOf`, which a caller's subclass answers. `path.validate` checked `opts.time` for a valid instant at entry and then compared it through a door that check never used, so a Date holding one moment and reporting an earlier one made an expired certificate validate. Both now narrow every operand to its held instant before comparing.
- A field inherited from an object a caller built over the prototype every typed array shares is reported as the unknown option it is. The kinds were recognized from that shared parent, so any level minted the same way read as one the language installs members on, and a bag inheriting `BYTES_PER_ELEMENT` from it reached `pki.key.generate` unremarked. The concrete kinds are now found by asking the runtime for them and matched by identity, which keeps a kind added later covered the day it lands without admitting anything that merely sits above the same prototype.
- A key from another WebCrypto implementation reaches the verb even where that implementation keeps its internals under a symbol. Every verb reads its options by name, so nothing under a symbol key can be read as one, and refusing a handle for carrying one protected nothing while turning a documented input away. The options door is unchanged and still reports a symbol on a bag passed as options, which is the case that matters: a caller who wrote one there meant it as an option and no verb will read it. A name a verb could read is still refused on a key handle.
- A `node:crypto` KeyObject reaches the verb it was passed to. `pki.hpke` documents one as a key input, and its material lives behind an internal slot, so what a copy holds is the shape of a key and none of the key: the object the verb received could not export, sign or derive. It is now recognized from that slot and handed on as itself, which is how a WebCrypto CryptoKey was already treated. A value wearing `KeyObject.prototype` with no key behind it is still copied, and the raw `TypeError` its `type` getter raises is now the calling module's typed refusal with that fault as its cause.
- The tables naming what each uncopyable kind carries no longer answer for names they inherit. Built as ordinary objects, a table answered `toString` with the function every object in the language inherits, so `toString`, `constructor`, `valueOf`, `hasOwnProperty` and `__proto__` read as published surface on a `RegExp`, an `Error`, a `CryptoKey` and a thenable alike. A caller could write any of the five onto such an object and have it handed to a verb by reference. Membership is now a fact about the table.
- A field written with `Object.defineProperty` is seen by the check that decides whether a handle may be passed on by reference. A `RegExp`, an `Error`, a key handle and a thenable cannot be copied, so a verb receives the caller's own object and a field on it stays the caller's to change after every check has read it. That check passed over non-enumerable properties on the reasoning that a caller adds fields by assignment; `Object.defineProperty` belongs to a caller as much as to a platform, so a `RegExp` from another realm carrying a hidden `detached` reached `pki.cms.sign`, and flipping it after the call moved the content out of the message the call had already been asked to sign. What is asked now is whether the property can still move: a field that is writable, configurable, or backed by a getter is refused, and so is one whose value can move even where the binding cannot, since a frozen slot holding a `Date` is an instant anybody can still change. A field that is settled through to its value rides along, which is how an implementation writes the internal state a key handle carries.
- A `Date` copy answers every `Date` method the way a `Date` holding that instant answers. A method written onto the value itself shadows the language's, and the copy carried it: an own `getUTCFullYear` that throws came across onto the copy and threw out of `pki.cms.sign` while the signing time was being encoded. The names the language supplies are read off `Date.prototype`, and what decides is the value rather than the name: a function under one of those names is behavior and is left behind, while a plain value under the same name is a field and comes across, so an option misspelled as `getTime` still reaches the check that refuses an option no verb reads.
- An element supplied through a getter is refused where a field already was. The deep copy taken at a verb's entry reads each element once and stores the result, so a getter that answers differently on the next read left the check nothing to find. The refusal covered an options bag's named fields and passed over its indices, which is where it pays most: a list element could answer as a trusted signer to the check and as something else afterwards.
- A signer, anchor or policy list whose elements come from its prototype reaches the verb intact. An array resolves a hole through its prototype chain, so `Object.setPrototypeOf(list, {0: signer})` reads as a one-element list to any consumer while reporting no element of its own. The copy taken at the door enumerated only the array's own keys, so the verb received a hole where the caller passed a value: `pki.cms.sign` refused a list it had been given a signer for. Elements at or past `length` are still left out, since no length-bounded read reaches them.
- `pki.key.export(key, { password })` no longer writes an unprotected private key. Export serializes; it has never encrypted. The option was ignored, so the file on disk was a plaintext PKCS#8 while the call site named a password. It now throws `key/bad-input` naming `pki.key.encrypt(key, password)`, whose result is what to export.
- An option carried on a prototype, defined non-enumerably, supplied through a getter, or planted on `Object.prototype` is seen by the check that refuses unknown options. It read own enumerable names only. Each of those four shapes answered `opts.password` while showing the check nothing, and `pki.key.export` returned the private key in the clear, which is the case the refusal exists to prevent. A polluted `Object.prototype` is covered too, where `{}` carries no option of its own; the check reports the planted name and stays silent on the built-ins it was seeded with at load. One thing is still skipped: a method, meaning a data property holding a function that the prototype chain also supplies under that name. So `constructor` and the methods a class defines stay machinery, and an instance of a caller's own class remains a valid options bag whether the verb inspects it directly or after copying it.
- A `Symbol`-named option is reported rather than passed over. The check enumerated with `Object.getOwnPropertyNames`, which never returns a Symbol key, so one was accepted in silence. Such a name answers no `opts.password` and reaches no verb, but it is still an option supplied and never read, which is what the refusal is for. The message names it as `Symbol(name)`.
- A name planted on `Object.prototype` before this package loads is reported, whether it holds a value or a function. The built-ins were recognized by reading `Object.prototype` at load, which is the polluted runtime answering the question, so a name already present was taken for one and `pki.key.export` returned a plaintext private key. They are now the twelve members the language specifies, each still required to have the shape a real one has. The level they are looked for at is the last one an object's chain reaches, which is where the root sits in every realm, so a bag built in a `vm` context or a worker thread is read the same way as a local one. A verb called with no options is unaffected, since it is handed a bag with no prototype and inherits nothing.
- `pki.path.build` forwards the caller's own value for a validation option even on a runtime whose `Object.prototype` carries the same name. It chose what to forward by reading a build-only table with the option name, which answers for any inherited name, so a caller's own `softFail` was taken for a build-only option and dropped; the set it was dropped from then re-read the inherited value. A caller who wrote `softFail: false` could receive a valid path built on an undetermined revocation result they had refused to waive.
- A number, a string or a boolean passed where an options bag belongs is refused by the verb rather than raising a `TypeError` about a reflection method the caller never used.
- A value that inherits from `Date`, `Map`, `Set` or `ArrayBuffer` while holding none of one raises the verb's own `<domain>/bad-input` instead of a raw `TypeError`. These kinds were recognized by their prototype. `Object.create(Date.prototype)` carries that whole prototype and holds no instant, so it passed as a Date and the read behind it threw the language's error out of `pki.cms.sign`, `pki.cms.verify`, `pki.x509.sign`, `pki.path.validate` and the argument copy every signing verb makes. The kind is now read off the value itself. That answers the other direction too, since a `Date` or a `Map` built in a `vm` context or a worker thread holds its kind while inheriting from that context's prototypes, and the prototype test called those neither.
- A `Date`'s instant is read from the Date itself, so a class of the caller's can no longer answer with a different one. `getTime` is an ordinary method on `Date.prototype`, and `class T extends Date { getTime() { ... } }` answers it: one that threw left the time guard through the caller's own exception, and one answering afresh on each call made the instant validated and the instant compared two different reads of the same argument. `pki.cms.verify` copies a caller's validation time and trust-anchor dates the same way, so the policy checked and the policy applied hold one value.
- A byte view's bytes are read from the view itself, so a class of the caller's can no longer say where they are. `buffer`, `byteOffset` and `byteLength` are accessors on the shared typed-array prototype, and `class Opts extends Uint8Array { get buffer() { return elsewhere; } }` answers all three. One that lied substituted the memory: an array holding `AA AA AA AA` whose `buffer` returned another store had every byte door hand back that other store's bytes, so what a verb went on to hash, sign or parse was not what was in the array it was given. One that threw left those doors through the caller's own exception. `pki.webcrypto.getRandomValues` measured its 64 KiB ceiling the same way, and a subclass reporting `byteLength` of 0 passed the ceiling while the fill wrote the array's real length. It also takes a view over a `SharedArrayBuffer` again, which is a supported W3C argument the platform fills: the refusal of shared memory answers a question about input, where bytes another thread can rewrite after they have been checked cannot be checked at all, and a write target the caller chose is the other direction.
- A copied argument comes back as a plain one of its kind, so a verb reading it can no longer be sent into the caller's own code. The copy used to be given the original's prototype back, which left it dispatching there: `list.map(...)` inside a signing verb reached an override on an `Array` subclass, `getUTCFullYear()` reached one on a `Date` subclass, and an override that threw surfaced the caller's exception from inside a verb whose contract is a typed error. A prototype can also be rewritten after the call has begun, so a copy taken to stop the argument changing under the verb changed with it. An instance of a caller's own class copies to a plain object the same way, and a dictionary with no prototype keeps having none, since a copy that gained `Object.prototype` would inherit whatever a runtime has planted there. The names the copy reports are unchanged, which is what restoring the prototype had been for.
- Copying a caller's argument keeps the kind and the instant the value itself holds. The copy chose a byte view's kind through `v.constructor` and its element width through `v.BYTES_PER_ELEMENT`, and took a `Date`'s instant through `getTime`: all three are ordinary properties a subclass answers, so one that threw took the copy out through the caller's own exception, and one that lied produced a copy of a different kind, a different element count, or a different instant than the value being copied. Which kinds those are is taken from the runtime rather than listed, so a typed array of a kind the language adds is copied as itself from the day the runtime names it, and a view of a kind the runtime does not name is refused rather than built through whatever the value says its constructor is. Reading an array element is typed the same way reading a named field already was, so an accessor at an index no longer leaves the copy through its author's own exception while the identical getter under a name raises `<domain>/bad-input`. A `Map` or a `Set` is walked with the language's own `forEach` for the same reason, and the refusal for a value whose state cannot be copied names the kind from what the value is, so a `constructor` that throws no longer replaces the message that says why it was refused.
- A response-body size cap counts the bytes the body holds. `pki.est` and `pki.acme` take an injectable transport, so the response is a value their caller supplies, and `length` on a Buffer is shadowable by an own property: one reporting 0 carried a body of any size past the cap that exists to bound it (RFC 7030 sec. 6). `pki.est.challengePasswordFromTlsUnique` read its emptiness test the same way, and refused channel-binding bytes that were there.
- Bytes built in a `vm` context or a worker thread are taken as bytes. The doors deciding whether an argument is a byte container measured it against this realm's `Uint8Array` and `ArrayBuffer`, so a buffer from another context was turned away; at a door that also accepts a parsed structure it was then refused as a rebuilt one, which reads as a complaint about the certificate rather than about the container it arrived in. The four accepted forms are decided in one place now, from what the value holds.
- An options bag built over a built-in's prototype, or built in another realm, reads the same before and after a verb copies it. Two walks answered the question of which names a caller supplied, and they disagreed. The copy stopped only at this realm's `Object.prototype`, so a foreign bag had its `__proto__` accessor copied down as a supplied name; and away from a collection it read every level of the chain, so `Object.create(Array.prototype)` gained `length` and `Symbol.unscopables`. Either way a bag the check had just accepted was refused once it had been copied. One walk now answers for both.
- The options a verb reads are the options that were checked. Every one is read once as the verb receives them, and the names are taken again afterwards. Checking the names and reading them were two separate moments before, and a caller's getter sits between: `{ get format() { this.password = "pw"; return "der"; } }` showed only `format` while the check ran and grew `password` when the verb read `format`, so `pki.key.export` accepted it and returned a private key in the clear. An option supplied through a getter is therefore refused, and the message names it. A getter answers afresh every time it is read, so the value the check saw says nothing about the one the verb goes on to use, and a name checked once can be joined by another the next time it runs. That covers the getter on the object, the one a caller's class supplies, and the one that waits several reads before adding anything. A class that defines methods is untouched: a method is machinery, not an option.
- A name planted on `Array.prototype`, `Map.prototype` or another collection's prototype is reported. Those levels were passed over whole as the kind's own, so on a runtime carrying `Array.prototype.password` an empty array answered `opts.password` while the check saw nothing, and `pki.key.export` returned an unprotected private key for it. `Object.prototype` was already read for this reason. Every level is read the same way now, and what a kind puts on its prototype is passed over by name, and only where the member is still shaped the way the language leaves it, so a planted value borrowing one of those names is reported too. The names passed over are the ones that kind defines and none a neighboring kind defines: a weak collection counts nothing and has no `size`, a `SharedArrayBuffer` is `growable` where an `ArrayBuffer` is `resizable` and `detached`, and a `DataView` reads its bytes through methods, so it has no elements and no `length` and both are ordinary options on one. Naming the members rather than holding this realm's prototypes also settles a bag built in a `vm` context or a worker thread, whose prototypes are that realm's.
- An option carried on the prototype of an array, a byte view, a Map, a Set or an ArrayBuffer is seen. The check read only such a bag's own names, on the grounds that these kinds carry `buffer`, `byteLength`, `size` and the like on their prototypes and reporting those would refuse a shape the toolkit supports. `Object.setPrototypeOf([], { password: "pw" })` has none of that in its chain and still answers `opts.password`, and `pki.key.export` returned a plaintext private key for it. The chain is now read, with only the levels that describe a kind passed over, so a subclass's own additions are reported too. Copying such a bag keeps its class, so a verb handed the copy reads the same names off it that the check read off the original.
- A byte view whose class overrides `length` reports it as the option it is. `length` is structure only where the language puts it on the value itself: an array carries an own one that no prototype can shadow, while a byte view carries a prototype accessor, so `class Opts extends Uint8Array { get length() {} }` answers `opts.length` with what its author wrote. The name was passed over for every indexed kind, which hid that one.
- A property on an array whose name merely looks numeric is treated as the ordinary named property it is. `"-1"`, `"1.5"`, `"NaN"`, `"Infinity"` and `"4294967295"` are not array indices, and taking them for structure meant an unknown option under one of those names was accepted in silence and dropped from the copy the verb was given.
- An array argument costs what it holds rather than what it measures. Copying walked from zero to `length`, so an array with a high index or an assigned `length` did that many iterations for however few elements it carried.
- Copying a caller's argument no longer drops a name they added to it. A `Date` came back as its instant alone, and an own `constructor` field was skipped along with the inherited one a class defines, so in both cases the copy held nothing while the original still answered and the checks read the copy.
- A `Proxy` whose `getPrototypeOf` trap throws is refused with the verb's own `<domain>/bad-input` rather than the trap's raw error. Reading a prototype chain runs that trap, and the chain was read before the value was recognized as a `Proxy` at all, so the caller's own exception escaped a boundary whose contract is a typed error. Every question about the chain now comes after that decision, at all three doors: the copy path, the one that reports an object's option names, and the one a verb normalizes its options argument at, where the chain was walked by the test for a Buffer in the options position. The kind of an options argument is read off the value itself and never off its type tag, so a caller's `Symbol.toStringTag` is not consulted: a getter under it cannot run, and writing `Arguments` there names nothing. Whether the argument is a `Buffer` is settled by walking its prototype chain rather than by `Buffer.isBuffer`, which is an `instanceof` against an ordinary extensible function: a `Symbol.hasInstance` planted on `Buffer` decided that question, and could answer it by throwing.
- A `Proxy` is refused as an options bag, and as any argument these verbs copy. It answers `ownKeys` and `get` from two independent traps, so one reporting no keys while returning a value for `password` presents an object every enumeration calls empty and every read calls populated. `pki.key.export` accepted such a bag and returned a plaintext private key, which is the case the refusal exists to prevent, and a copy of one silently held nothing the caller had supplied. No enumeration can close that, since it reads only what a trap chooses to say, so the shape is refused by identity rather than probed for a contradiction.
- An argument whose prototype chain is a cycle no longer hangs the call or raises an untyped `RangeError`. A Proxy may report itself as its own prototype while its target stays extensible. Reading an object's option names, copying it, and any `instanceof` inside the copy all walk that chain, and none of them terminated. Reading the names now stops once it revisits an object, which loses nothing since a second lap finds no new name, and copying refuses the shape with the caller's own `<domain>/bad-input` code.
- At every verb named here, an options argument of `false`, `0`, `""` or `NaN` is refused rather than read as no options at all. Verbs outside that list still read a falsy options argument as though none were given, and are a later cut. The check that an options argument is an object ran after the argument had already been replaced by an empty object, and that replacement treated any falsy value as absent, so those four reached the body as `{}`. `pki.cms.sign`, `pki.cms.countersign` and `pki.cms.verify` carried this before this release; the verbs gaining option checks here would have inherited it. `null` and `undefined` still mean no options, and a Buffer in the options position is now named as the argument-order mistake it is. The boxed forms go the same way: `new Number(0)` and `new Boolean(false)` carry no option of their own, so they passed as a bag holding nothing, which is the same silence one wrapper away. A boxed primitive, an `Error`, a `Promise` and an `arguments` object are each refused by name, so the message says which argument was passed rather than naming a field of the engine's own.

## v0.5.10 — 2026-08-18

The documentation and the package's own source comments settle on one spelling of the words they use in both, and a gate keeps them there.

### Added

- `npm run check:spelling` reports any word in the repository that has a second accepted spelling. It runs in `npm run gates`, on every pull request, and again before the published tarball is packed. The check is whole-word and case-insensitive, both to avoid a failure mode: a substring match reports `publicEncrypt` as a misspelling, and a case-sensitive one walks past the same word capitalized or upper-cased. It self-tests on planted forms before reporting, so a word list that has stopped matching cannot pass as a clean tree.

### Changed

- Documentation and source comments now use the US spellings behavior, recognize, unrecognized, labeled, honored, license, defense, neighbor, authorize, initialization, enrollment, signaled, modeled, favor and fulfill. 267 occurrences across 98 files no longer carry a second spelling: the README, the security policy, thirteen release notes and the changelog generated from them, the status-lifecycle record, the comments and error text in lib/, the test suite, and the release and wiki tooling. That figure counts words whose spelling changed, so a line edited for another reason that happened to contain one of these words is not counted twice. One word settles the other way: catalogue, which this repository already used by 185 uses to 32, so the checked form is that one and the US spelling is what now reports. Simple Certificate Enrolment Protocol is RFC 8894's title, quoted as published and allowed only on a line carrying that title in full, so the exception cannot spread to the word.

## v0.5.9 — 2026-08-18

A certificate can now carry an internationalized email address, which this toolkit could read and never write.

### Added

- pki.x509.sign accepts an otherName entry in a subjectAltName, given as { typeId, value } where typeId is an OID string and value is a Buffer holding one DER element. It encodes RFC 5280 section 4.2.1.6's otherName ::= SEQUENCE { type-id OBJECT IDENTIFIER, value [0] EXPLICIT ANY }, tagged [0] IMPLICIT. The value wrapper is EXPLICIT because ANY carries no tag of its own, which is what makes the encoding unambiguous and is the shape the decoder already required. This is what an SmtpUTF8Mailbox address needs, and it is equally the carrier for any other otherName a profile defines.
- The value is validated before it is wrapped and signed, because a signer that emits a malformed encoding under a real signature has produced something strict relying parties reject. It must be exactly one element with no trailing bytes, and its contents must satisfy the rules for its type: a BOOLEAN whose octet is not 0x00 or 0xFF is refused, as is a SET whose members sit in no canonical order, and so on recursively through a constructed value. The accepted universal types are BOOLEAN, INTEGER, ENUMERATED, BIT STRING, OCTET STRING, NULL, OBJECT IDENTIFIER, UTCTime, GeneralizedTime, NumericString, and the DirectoryString family (UTF8String, PrintableString, IA5String, TeletexString, VisibleString, BMPString, UniversalString), plus SEQUENCE and SET. A universal type outside that set, such as REAL or RELATIVE-OID, has no content validator here and is refused rather than accepted on its framing alone. A context- or application-tagged value passes on its framing, since no content rule is knowable for it, and its children are still walked. One known limitation: a GeneralizedTime carrying fractional seconds, such as 20260101000000.5Z, is refused here even though X.690 section 11.7 permits it. That relaxation is scoped to the codec and to RFC 3161 timestamping on purpose, and this validator does not widen it. A constructed wrapper does not evade the rule, since the walk recurses into its children; a profile needing a fractional time must carry it under an implicit primitive context tag, which passes on framing because no content rule is knowable for it.

### Fixed

- pki.smime.verify's sender binding is now exercised against certificates carrying an otherName. Two behaviors that previously had no conformance vector are pinned: a certificate whose subjectAltName carries an SmtpUTF8Mailbox does not let a legacy subject distinguished-name emailAddress speak for it, and an otherName unrelated to email, such as a Microsoft user principal name, neither erases a matching rfc822Name nor turns a definite non-match into an undecidable one.

## v0.5.8 — 2026-08-18

Four verdicts that answered a question nobody had asked now say what they checked, and an email domain comparison no longer folds two registrable domains into one identity.

### Added

- pki.cms.decrypt reports originAuthenticated, authenticatedBy and originatorInfo. authenticated is a claim about the content and the key that opened it; it never described who sent the message. originAuthenticated is false for every recipient type the toolkit supports: a ktri or ephemeral-static kari message is minted by anyone holding the recipient's public key, and a pwri or kekri message by any co-recipient sharing the secret. authenticatedBy names what the integrity rests on. originatorInfo is now surfaced rather than decoded and discarded, and is documented as unauthenticated: it sits outside the AEAD's authenticated data, so it is a hint the sender chose, and any certificate it carries must be validated before use. To bind a sender, verify a signature over the plaintext.
- pki.smime.verify accepts expectedSender and reports a sender block of { checked, expected, source, identities, match }. A signature proves a key signed; it does not prove the message came from the mailbox the reader sees. match is true only when the signer certificate asserts the address, compared under RFC 5280 section 7.5: the local-part exactly, the host-part case-insensitively. The address is read from the subjectAltName rfc822Name entries (RFC 8550 section 4.4.3), and where the extension carries none, from the subject distinguished name's PKCS #9 emailAddress attribute, which RFC 8550 section 3 requires a receiving agent to recognize. Where both are present the extension is authoritative, so a stale subject value cannot satisfy expectedSender while the extension names a different mailbox. It is three-valued: false when every identity was comparable and none matched, null when the question went unanswered, and null is not a pass, so a caller enforcing sender binding tests match === true. identities lists what the certificate actually asserts. With no expectedSender a single outer From is used and reported as source: "from", which is advisory, because on a message without header protection that header is attacker-controlled.

### Changed

- pki.smime.verify's headerProtection.fromMismatch is now null when there was no protected From to compare against, where it was previously false. It reported false on every message without RFC 9788 header protection, which is nearly all mail, so testing not fromMismatch read as a passed sender check on messages where no comparison had run. It is now true when the outer From differs from the protected one, false when they agree, and null when nothing was compared. null is falsy, so an existing not fromMismatch test keeps working and keeps accepting the unchecked case: compare against false explicitly. For a sender binding that does not depend on the composer having protected the headers, use expectedSender and test sender.match === true. See MIGRATING.md.

### Fixed

- An rfc822Name identity comparison no longer folds the host-part with a Unicode-aware lowercase. U+212A KELVIN SIGN lowercases to ASCII k, so ban<U+212A>.com and bank.com are different byte strings, separately registrable, that compared equal and read as one email identity. The host-part is now folded across A-Z only, which is the case-insensitive ASCII comparison RFC 5280 section 7.5 authorizes and no more. The local-part was already compared exactly and stays that way: RFC 8398 section 5 requires that it not be transformed in any way, including by case folding.
- pki.merkle.verifyConsistency refuses a proof whose older tree is empty and whose newer tree is not, as merkle/no-consistency-claim. RFC 6962 section 2.1.2 defines a consistency proof for 0 < oldSize < newSize. An empty tree is a prefix of every tree by definition, so there was no proof to check and nothing bound the newRoot that was passed: any value returned true, including a root from a different log. Two empty trees are unchanged and still check each root against the empty root hash.
- pki.cmc.verify refuses a Full PKI Response that carries nothing tying it to a request, as cmc/unbound-response. Every binding the module could check was previously conditional on the caller having supplied the matching value, and nothing in the verdict reported whether any of them ran, so a response captured from an earlier successful enrollment against the same CA verified identically. Pass what the request retained (transactionId, senderNonce, whose echo is the replay defense of RFC 5272 section 6.6, or dataReturn), or allowUnbound: true to interpret a response that could be a replay of any earlier exchange.

## v0.5.7 — 2026-08-16

A CMS signature made over signed attributes can no longer be re-presented as one made over content.

### Added

- The pki.cms.verify verdict carries eContentType, and each signers[i] carries signedAttributesPresent. Signing with attributes and signing the content directly are different claims: attributes bind a content type and a signing time alongside the digest, while content-only binds nothing but the bytes. One message may carry a signer of each. A caller whose profile is stricter than RFC 5652's, such as RFC 8551 S/MIME which requires signed attributes, can now enforce that from the verdict instead of parsing the message a second time. A check that needs a second parse is a check most callers will not write.

### Changed

- Because the producing verbs now copy their arguments at entry, each property of a spec or options object, own or inherited, is read exactly once, when the verb is called. A field defined as a getter is therefore evaluated at that point even if the verb has no use for it, and a getter that throws surfaces as that module's bad-input fault before its own validation of any other field. Reading each property once is deliberate: a getter consulted twice can answer differently the second time, which is the same problem the copy exists to remove. Plain data specs are unaffected.
- One argument shape cannot be copied and is refused: an object whose state this toolkit cannot read (a WeakMap or WeakSet, a promise, a CryptoKey) carrying its own named fields alongside. There is no safe handling for it, because it cannot be copied and passing it through would leave those fields changeable after the checks had read them, so it fails with the module's bad-input code and says to pass the fields as a plain object. The same objects are accepted as before when they carry only what their kind defines, which is what a real key, a real promise and a real WeakMap do.
- SECURITY.md previously said an attacker could "neither swap the content out from under a set of signed attributes, nor strip the attributes and present a signature made over them as one made over the content". The first half was true; the second was not, and had not been since the claim was written. The entry now describes what is actually defended and how, and names the case it costs: content which genuinely is an encoded SignedAttributes block must be signed with signed attributes. The v0.5.6 notes described the parsed-object re-derivation as closing this forgery; it closed the half reachable through a caller-assembled object, and this release closes the half reachable from bytes.

### Fixed

- pki.cms.verify refuses a SignerInfo with no signed attributes whose content is itself an encoded SignedAttributes block, as cms/ambiguous-content. This is Attack Type 1 of draft-vangeest-lamps-cms-euf-cma-signeddata: take a message signed with attributes present, drop the signedAttrs field, set the encapsulated content to the DER of those attributes, keep the signature. The signature genuinely verifies over those bytes. What is refused is the shape, which is why it has its own code and does not read as cms/bad-signature. The condition is necessary to the attack; it is no guess at anything SET OF shaped: RFC 5652 section 5.3 requires signed attributes to carry both a content-type and a message-digest attribute, so every message the attack produces has content carrying both, and content that is a set of attributes missing either one is not refused. Ordinary content (a certificate, a JSON payload, arbitrary bytes) does not have the shape at all. Verified against the shipped verb before and after, and the standards fixes for this are protocol changes (signing under a context string that names the mode) which no verifier can apply on its own.
- pki.cms.sign refuses to sign content that is itself an encoded SignedAttributes block when signedAttributes is false. That is the other direction of the same problem (Attack Type 2): such a signature can afterwards be promoted into an attributes-present message, because the signature does not commit to which mode was used: the attacker attaches the signed bytes as the SignedAttributes and swaps in whatever content their message-digest attribute names. Refusing to mint the ambiguous signature is the only point at which that direction can be stopped. Sign the same content with signed attributes and it is unambiguous again.
- A byte argument whose backing store has been transferred away is refused instead of read as empty. Transferring an ArrayBuffer (a structuredClone with transfer, a worker hand-off, a stream that adopts the buffer) leaves every view of it reading zero-length, with no throw, so a boundary that passed the caller's object straight on operated on nothing and succeeded: pki.cms.sign produced a sound, verifiable signature covering no content at all, pki.cms.compress the same, and pki.pkcs12.build derived its MAC and encryption keys from the empty password. Every boundary that takes caller bytes now re-views the input first and refuses a detached one with that module's own bad-input code. Where the empty read already failed further down, as when an empty certificate does not parse or an empty private key does not import, the refusal now carries the calling module's code and names the argument; it no longer surfaces whatever the later failure raised.
- A producing verb reads its arguments once, at entry. Every one of them does work across more than one promise turn, so a caller still holding a spec, an options object or a signer could change a field after the call returned and have a later turn read the new value: the checks ran against one input and the artifact was built from another. Every argument of pki.cms.sign, pki.cms.countersign, pki.x509.sign, pki.csr.sign, pki.crl.sign, pki.attrcert.sign, pki.crmf.build, pki.cmc.build, pki.cmp.build, pki.ocsp.buildRequest, pki.ocsp.sign, pki.tsp.sign and pki.pkcs12.build is now copied whole at entry, at every depth, and each copy is cleared when the call settles. Reachable cases included flipping signedAttributes from true to false to skip the content check the entry above describes, rewriting a certificate's key identifier or a CRL's authority key identifier between the check and the encoding, changing the encoding pki.x509.sign returns after the signature came back, rewriting the PKCS#12 password partway through so the file's MAC and its bag encryption were keyed to two different values, and rewriting the nested pki.cmp.build MAC secret so the message went out authenticated under a value the caller never supplied. Copying at one level does not cover the last of those and copying without clearing duplicates the secret, so both halves are the rule. A parsed structure passed inside a spec keeps its identity, so it still satisfies the verbs that require parser provenance, and a CryptoKey is used as it stands, never cloned.
- The verbs documented as returning a Promise now run their body at the call, and no longer a turn later. Ten of them deferred everything, including reading the caller's arguments, until after the call had already returned, which left the window above open even for a verb that copies its input on the first line. They still report a fault by rejecting and never by throwing; only the timing of the work changed.

## v0.5.6 — 2026-08-16

A CMS SignedData is verified over the bytes it was parsed from, an omitted PKCS#12 password is refused and never encoded as the empty one, and a verb documented as returning a Promise rejects instead of throwing past your .catch.

### Changed

- A verb documented as returning a Promise now rejects; it never throws synchronously. An operator reads -> Promise<...> in the reference and writes the documented shape, pki.cms.sign(content, signers).catch(handleIt). A validation that ran before the promise was created threw straight past that .catch, so a misspelled option or a malformed input became an uncaught exception in code that already handles errors. Nothing in the shape of the call said which verbs did it. Eleven did: pki.cms.verify, pki.cms.sign, pki.cms.countersign, pki.ocsp.sign, pki.tsp.sign, and six pki.acme verbs. Nine more on the client pki.acme.client(...) returns did the same, including getAuthorization, which an ordinary ACME loop reaches with a URL that came from the CA. The checks still run synchronously, so a caller's mutable options are read before any turn passes; only the way a fault leaves has changed. If you wrapped one of these verbs in a synchronous try/catch without awaiting it (the documented signature never supported that, though it worked by accident on exactly these verbs), that catch no longer fires and the rejection surfaces as an unhandled one instead. Await the call, or attach .catch to it.
- pki.tsp.verify reports whether revocation was established, as well as whether the timestamp authority chained. Revocation runs only when a revocationChecker is supplied, so a token whose TSA was never checked against a CRL or an OCSP responder read identically to one established un-revoked, and that is the default. The verdict now carries revocationChecked and anchorConstraints from the path validation, in pki.path.validate's own vocabulary, so a timestamp archived to be re-read years later can still answer what was actually checked. The trustAnchor documentation no longer lists revocation unconditionally.

### Fixed

- pki.cms.verify computes its verdict over the bytes the parser read. A SignedData's meaning is a signature over a byte range, but a parsed one presents that range, the signature, the algorithms and the certificates as separate properties, and the verb read them as though the parser had produced them together. The forgery that follows is concrete: take a message a trusted signer really signed, keep its SignerInfo and signature untouched, set signedAttrsBytes to null, and present the signature's own preimage (the signed attributes re-tagged as the SET OF the signature covers) as the encapsulated content under any content type you like. With no signed attributes the signature is checked against the content directly, which is exactly those bytes, and neither the content-type nor the message-digest check runs, so the message verifies as valid content the signer never signed. pki.schema.cms.parse now records what it read, pki.cms.verify re-derives from that record, and a SignedData the caller built by hand is refused with a typed error, where it used to be dereferenced into a raw TypeError. Passing DER, PEM, or the parser's own unmodified result is unaffected.
- pki.pkcs12.build refuses a store with no password; it no longer builds one under the empty password. An omitted password and the empty password are different credentials, and the difference was invisible at the call site: pki.pkcs12.build(spec) with no options at all, or with the option name misspelled, returned a well-formed store whose shrouded private key opened under "": a key protected by nothing, with no error anywhere. The empty password is still available; it has to be asked for, as "".
- pki.pkcs12.build validates opts.integrity.mode against the value it accepts. Compared against a single literal, any other spelling read as "not public-key" and silently selected password integrity, dropping the signer with it: a caller who wrote mode: 'publicKey' asked for a CMS signature over the AuthenticatedSafe and got a password MAC. Combined with the previous item, a caller who misspelled both options got a store MACed under the empty password while believing it was signature-protected. An unrecognized key on opts.integrity is refused too.
- pki.pkcs12.open bounds the key-derivation work of a modern store as well as a legacy one. The aggregate budget existed because a per-bag iteration cap resets on every bag, so a store that repeats a costly bag up to the parser's element limit multiplies the cap by that limit. It was charged only on the RFC 7292 Appendix C path, leaving the RFC 8018 PBES2 path free to do exactly that: the path OpenSSL and NSS emit, and the one this toolkit itself builds. Measured before the fix: ten bags at a million iterations each ran ten million aggregate rounds of blocking PBKDF2 with no refusal. Both schemes now charge one budget, and it is charged before the derivation runs, no longer after.
- A PKCS#12 password this toolkit encoded is cleared once the derivation has consumed it, on the PBES2 and PBMAC1 paths as well as the classic ones. The Appendix B.1 copy taken from a password argument was wiped while the UTF-8 copy taken from the same argument in the same call was not, leaving a plaintext password in the heap after every modern bag encryption, bag decryption and MAC. A Buffer you supply is never written to: it is yours, and clearing it would destroy your credential, which is the thing being protected.
- pki.tsp.request validates a pre-encoded extension instead of splicing it in. Elements of opts.extensions were checked only for being byte-like and then concatenated straight into the request, so a caller relaying an extension blob it did not author put fully chosen bytes into the structure. The encoder emitted requests its own pki.tsp.parseRequest refuses: an undecodable value, a repeated extension identifier, an explicit critical=FALSE that DER requires be omitted. Each element now goes through the same pre-encoded-Extension gate every other request builder in the toolkit applies, including the duplicate-identifier check.
- pki.tsp.sign, pki.tsp.request and pki.tsp.verify report a bad serial number or nonce as a typed tsp/bad-input. A value that is not an integer reached BigInt() directly and raised a raw SyntaxError or RangeError out of a public verb, an untyped fault a caller handling tsp/* codes cannot catch.
- pki.sigstore.verifyBundle refuses an option it does not recognize. The identity policy one level down already did, for the reason that applies just as much at the top: the signer pin goes by other names in other Sigstore tooling, and a spelling this verb swallowed checked nothing under a name the operator believed pinned the signer. At the top level the same slip also loses the SLSA predicate pin, and unlike the identity fields there is no report field to reveal it: the verdict said verified: true with the signer and the predicate entirely unpinned.

## v0.5.5 — 2026-08-15

A verdict is computed over the bytes the parser read, an identity is derived from the bytes that carry it, and the guards match no patterns.

### Changed

- The package resolves one entry point. require("@blamejs/pki") is unchanged; a path into the package, such as require("@blamejs/pki/lib/schema-x509"), no longer resolves. Every module under lib/ carries @internal in its own header and none has ever appeared in the API snapshot that freezes the public surface. They were reachable only because the package declared no exports map, and one of them mints the provenance record the integrity verbs above rely on. Everything the internals do is on pki.*: the decoders are pki.schema.<format>.parse, the codec is pki.asn1, the OID registry is pki.oid, the error classes are pki.errors. MIGRATING.md carries the recipe.
- pki.merkle, pki.jose, pki.hpke, pki.smime and pki.est refuse an option they do not recognize, which completes the toolkit: every module that takes options now does. A misspelled option is the one input that reads as an omission, so the caller who asked for something stricter gets the looser default and is told nothing: a misspelled psk leaves an HPKE psk-mode setup with no pre-shared key, a misspelled key leaves pki.jose.verify accepting whichever key the message names, a misspelled leafIndex leaves a Merkle inclusion proof about a leaf the caller never chose, a misspelled strictMicalg accepts the S/MIME digest mismatch it was set to reject, and a misspelled expectedRecipientKeyId drops the recipient pin on an EST server-generated private key. The accepted set is per verb, since the surfaces differ even inside one module: form means something to pki.smime.sign and nothing to pki.smime.encrypt, strict cannot run on pki.est.cacerts at all, and HPKE's two ends read the same object from opposite sides, so senderPublicKey does nothing at the sender and senderKey nothing at the recipient. A merged set would accept each verb's options everywhere and reproduce the silence in a wider form, which is why an option passed to the wrong end of an HPKE exchange is refused with a message saying where it belongs, because the caller who passed it usually believes they authenticated something. Four options pki.smime.sign has always forwarded (hcp, sid, signedAttributes, additionalSignedAttributes) and EST's auth object are now documented; they worked before and were absent from the reference.
- The guards match no patterns. A guard runs on the most hostile input the toolkit sees, and a pattern engine's cost on a rejecting string is a property of the pattern, which is the one thing a caller's size cap cannot bound. Nine patterns across five guards are now explicit character walks, each one pass. Three carried a second defect settled by the rewrite: the whitespace fold above, a JSON number grammar written twice (once as the scan and once as a pattern re-matching what the scan had just read), and an RFC 4514 escape that ran a pattern replace and then a second loop over the same attacker-supplied value.

### Fixed

- A claimed-parsed structure must carry every field the matching pki.schema parser produces. The rule already existed at one door: pki.path.build refused a partial claimed-parsed certificate. pki.path.validate, which build hands its result to, tested only for a truthy tbsBytes and passed the object into the RFC 5280 sec. 6.1 walk. Eleven doors now share it: pki.path.validate and build, pki.path.crlChecker, pki.crl.verify / isRevoked and the issuer of pki.crl.sign, the issuer of pki.x509.sign, pki.attrcert.sign, pki.ocsp's certificate argument, pki.lint, and the caller root certificates pki.webauthn takes for attestation and for android-safetynet. Completeness is measured against the parser. What any one verb happens to read is too narrow a bar, because a field absent from an object is not a field with a safe default: an extension entry with no critical property read as non-critical, so a certificate rejected for an unknown critical extension when passed as bytes validated when passed as an object; a missing serialNumber surfaced as an error from the ASN.1 layer; and a missing issuer.bytes produced an OCSP request whose issuerNameHash covered nothing. Passing bytes, PEM, or the parser's own unmodified output is unaffected.
- A certificate or CRL a verdict is taken from is re-derived from the bytes its parser read. pki.path.validate and build, pki.path.crlChecker, pki.crl.verify and pki.crl.isRevoked all reach a decision, and completeness alone cannot carry one: a certificate is one signature over one byte range, but a parsed certificate presents that range, the signature, and every field the range encodes as separate properties. Keep a real CA certificate's signed bytes and signature and replace only its subjectPublicKeyInfo, and every field is well-formed, the signature verifies over the original range, and the substituted key is then what verifies the next certificate in the chain: a forged chain built out of a genuine certificate. Emptying extensions is the same move against basicConstraints, keyUsage, name constraints and the unknown-critical rule; emptying a CRL's revokedCertificates leaves a correctly signed CRL reporting a revoked certificate as good. pki.schema.x509.parse and pki.schema.crl.parse now record what they read, these verbs parse it again from that record, and a certificate or CRL the caller assembled, with no parse behind it, is refused. Passing bytes, PEM, or the parser's own unmodified output is unaffected.
- pki.ocsp.verify, pki.path.verifyOcspResponse, pki.pkcs12.verifyMac and pki.pkcs12.open compute their verdict over the bytes the parser read. A signature check has three parts: the signature, the algorithm that verifies it, and the byte range it covers. On a parsed response all three are separate properties: pair a real CA's signature over a certificate that CA issued with that certificate's own signed bytes and algorithm, relabel the three, and every part of the check passes for a response the responder never produced. A PKCS#12 store has the same shape with two parts, the range the MAC covers and the bags handed back as verified, so one object could say verify this and return that. The parsers now record what they parsed and these verbs re-derive from that record, so an object edited or rebuilt after parsing is not what the verdict describes. Passing the parser's own result still works and is unchanged.
- pki.attrcert.sign derives both halves of a Holder's identity from the signed bytes. Issuer and serial together are the identity being bound; the issuer was decoded from tbsBytes while the serial was read off the object, so a parsed certificate with one field replaced produced a Holder naming a real issuer with a serial nobody issued.
- pki.trust.anchor answers from what the store read. A root program's metadata (these purposes, until this date) is a statement about a key, so an entry rebuilt with a substituted publicKey carried the program's word onto a key it never saw; the (name, key) pair is now re-derived from the certificate the store parsed, and an entry carrying store metadata without that provenance is refused. The purposes and distrust dates come from the same place and are copied on the way out, so neither editing a store entry nor writing through a returned anchor changes what that anchor authorizes: pki.trust.anchor(entry).purposes.serverAuth = true no longer opens a gate the store never opened, and an anchor reports the store's bits and dates however the caller has since handled the entry. A caller asserting their own bare (name, key) anchor carries no metadata and is unaffected.
- A private key decoded for the crypto engine is wiped once the engine has imported it. A signer or recipient key may be given as a Buffer, a Uint8Array, or a PEM string; the first is the caller's own memory and is used in place, while the other two are decoded into a new buffer inside the toolkit. That is a second copy of a private key, and until now it stayed readable in the heap until the garbage collector happened to reuse the page. It is cleared on the failure path too, so a malformed key or a tampered message is not a way to leave one behind. A Buffer you supply is never written to: it is yours, you still hold it, and clearing it would destroy the key we are supposed to be protecting.
- pki.webauthn.verifyAssertion holds both accepted forms of a stored credential key to the same rules. The COSE bytes went through the curve and length rules, the 2048-bit RSA modulus floor and the exponent checks; the object form went through none, so one key was refused in one form and imported for signature verification in the other. Which form a relying party stores is a question about what their datastore round-trips, and no longer a question about how carefully their credential is checked.
- A certificate's keyUsage is read the same way at every boundary that asks what the certificate may do. keyUsage is a NamedBitList, so DER drops its trailing zero bits (X.690 sec. 11.2.2) and RFC 5280 sec. 4.2.1.3 requires at least one bit set. Four boundaries read the bits themselves and applied neither rule, so one certificate could be authorized in one place and called malformed everywhere else: pki.crl.verify accepting a CRL signer, pki.tsp.verify accepting a timestamp authority, pki.cms.encrypt accepting a recipient, and the FIDO metadata reader accepting the leaf that signs a catalogue.
- The distinguished-name comparison that decides name chaining, revocation-issuer matching and name constraints folds the four ASCII whitespace characters X.520's caseIgnoreMatch names, and no others. It had been collapsing whitespace with a pattern, which also folds vertical tab, form feed, no-break space and every Unicode space separator, equating names X.520 keeps distinct.

## v0.5.4 — 2026-08-15

A path verdict says whether revocation was ever established, a trust anchor's own distrust metadata can no longer sit inert, and a CRL is asked what only a certificate can answer.

### Added

- pki.path.validate reports revocationChecked, taking the weakest outcome on the path: false when no revocationChecker was supplied, "determined" when every certificate got an explicit good or revoked answer, "waived" when softFail turned an undetermined one into a pass, and "undetermined" when one could not be answered at all. The per-certificate revocation check carries the status it was decided on and marks a waiver, so "checked, and it said good" is distinguishable from "could not check, and you waived it". Those were the same object before, which is why a stored verdict could not answer whether revocation was ever established.
- pki.path.validate reports anchorConstraints: the checkedPurpose the anchor's trust metadata was judged under, and whether the distrustAfter date and the purposes delegator map each applied. A bare anchor says it carried nothing to apply; it no longer says nothing at all.
- pki.tsp.verify returns trusted alongside valid. The entire out-of-path TSA certificate validation runs only when a trustAnchor is supplied, so one boolean collapsed "the token's signature and structural bindings hold" with "the timestamp authority is one you accept". A timestamp is archived precisely to be re-read years later, when that distinction is the whole question. Without an anchor trusted is false: a definite answer, on the refusal branch as well as the accepting one.

### Fixed

- A trust anchor carrying purpose-scoped metadata is no longer validated as though it carried none. distrustAfter and purposes are indexed by key purpose, so neither could apply unless the caller passed opts.checkPurpose, an option absent from the verb's own documentation while SECURITY.md described the enforcement as unconditional. An anchor carrying that metadata with no purpose to select by is now a configuration fault (path/bad-input), and no longer a constraint that silently does nothing.
- pki.tsp.verify names the timeStamping purpose when it validates the TSA chain, so an anchor's trust metadata reaches the decision. It already required that key purpose of the TSA certificate; asking the certificate without asking the anchor checked one end of the chain and left the other: a root explicitly distrusted for timestamping still answered trusted.
- A revocation checker that throws, or whose promise rejects, fails the path with path/revocation-checker-error carrying the fault, including under softFail. It was laundered into an unknown status and then waived, so a broken checker and a working one that could not reach the responder produced the same verdict, and a certificate could pass with no revocation result at all. softFail is the caller opting into an undetermined answer, which the built-in CRL and OCSP checkers report as status "unknown" for every unreachable or unverifiable condition; neither throws, so a throw is a fault in the caller's own code, and it surfaces.
- pki.crl.verify asks what only a certificate can answer. Given one, it now also checks that the certificate is the issuer the CRL names and that its keyUsage, when it carries one, asserts cRLSign, the rule this module's signing side already enforced. A signature verifying says only that some key signed these bytes, so a CRL minted under an end-entity certificate of the same CA verified as that CA's CRL. Both answers are false, and neither throws, so trying each candidate issuer in turn still works; handed a bare key there is no certificate to carry either restriction and the signature remains all that is checked.
- pki.crl.isRevoked checks the CRL's scope before looking for the serial. A serial means something only inside the set of certificates a CRL speaks for, and this verb is handed a serial and nothing else. A CRL speaking for part of its issuer's certificates is now refused; it is no longer answered from. A delta CRL lists changes since a base, so an entry recording that a certificate was released reads as a revocation when the delta is read alone (crl/delta-not-authoritative); an indirect CRL carries other issuers' entries, whose serial numbers are unrelated to yours (crl/indirect-not-supported), as does any CRL carrying certificateIssuer on an entry while declaring itself direct, since the contradiction belongs to the list as a whole. Every other issuingDistributionPoint narrows the CRL to one distribution point, one kind of certificate, or a subset of revocation reasons (crl/scope-not-authoritative): which part applies is decided against fields of the certificate, so a serial absent from such a CRL is not a certificate that is unrevoked. Each of these previously answered, and the answer could be the opposite of the truth. pki.path.crlChecker is the verb for all of them: it is handed the certificate, merges a delta with its base, and performs the RFC 5280 sec. 6.3.3 scope correspondence.
- A certificate's keyUsage is read the same way at every boundary that asks what the certificate may do. keyUsage is a NamedBitList, so DER drops its trailing zero bits (X.690 sec. 11.2.2) and RFC 5280 sec. 4.2.1.3 requires at least one bit set. The shared extension decoder enforces both rules, which is why the issuing side and pki.path.validate already applied them. Four boundaries read the bits themselves and applied neither, so one certificate could be authorized here and called malformed everywhere else: pki.crl.verify accepting a CRL signer, pki.tsp.verify accepting a timestamp authority, pki.cms.encrypt accepting a recipient, and the FIDO metadata reader accepting the leaf that signs a catalogue. All four now route through the decoder, so a certificate this toolkit refuses to issue is a certificate it refuses to trust.
- An issuingDistributionPoint scope flag is read under the encoding rules that define it, in both the CRL verbs and the path validator, and no longer by inspecting a content byte. Each flag is an IMPLICIT BOOLEAN, so DER admits exactly one content octet of 0x00 or 0xFF; a byte test read an empty flag as absent and a multi-octet one by whichever byte it indexed, and absent is the reading that lets a CRL whose scope cannot be established answer a serial anyway. Signing rejects a pre-encoded issuingDistributionPoint on the same terms, so this toolkit cannot emit a CRL whose scope a relying party would read differently.

## v0.5.3 — 2026-08-15

pki.webauthn checks the ceremony at registration, withholds a revoked model's anchors, and refuses a name comparison it cannot perform.

### Added

- pki.webauthn.verify accepts opts.clientDataJSON as an alternative to the clientDataHash argument. Supply exactly one of the two; neither is inferred from the other's absence. Given the JSON it reads it: the ceremony type is checked unconditionally, because which ceremony a response belongs to is fixed by the specification and is not the caller's to choose, and a login response replayed into a registration is what that check stops. The challenge, origin and top-level origin are checked against what you issued, and the verdict carries clientData, whose checked field says which comparisons ran. From the digest form clientData is null: nothing read it, and reporting anything else would claim a check that never happened. An expectation supplied without the JSON is refused; it is never left silently uncompared.
- parseClientData, verify and verifyAssertion take expectedTopOrigin: an origin, a list of them, or null to require an unframed ceremony, which a list cannot express. In a cross-origin ceremony origin is the framed document's and topOrigin names the page that framed it, so the framing policy is about the second: a value a relying party could read and nothing could compare. It is compared whole and case-sensitively, as origin already was, and reported in checked alongside it. Whether a ceremony was framed is stated by both crossOrigin and topOrigin and is only usable when they agree, so a response cannot answer the policy with the field it left out.
- A verified FIDO metadata result reports rollbackChecked and the previousNo it was compared against, as it already reported what the freshness rule found. The rollback rule runs only when a caller supplies the sequence number it holds, so a result that recorded nothing could not be told from one where the check was skipped. Being able to show the catalogue never went backwards is the whole point of the rule.
- pki.jose.sigAlgs() lists the JWS signature algorithms this toolkit verifies, one row per alg with the JWK key type, curve, hash and PSS salt length it requires. Each call returns fresh rows; the registry that drives verification is never handed out, so nothing a caller does to the result can widen what a signature check accepts. MAC algorithms are absent by construction: HS256 is not a signature algorithm, and listing it beside RS256 is how the key-confusion class starts.

### Changed

- Every byte argument in pki.webauthn accepts the same forms: a Buffer, any typed-array view, a DataView, or an ArrayBuffer. They differed per argument before, so verify took an attestation object as an ArrayBuffer while refusing a clientDataHash in the form crypto.subtle.digest returns, which is the natural output of the API these verbs exist to serve. The same held for a metadata BLOB, which is retrieved over the network and so most naturally arrives as the ArrayBuffer a fetched body gives you, and for the certificates supplied as trust anchors, where identical DER is the identical certificate whichever container it was read into. Anything that is not bytes is refused by name, where before it was described by whichever parser reached it first.
- opts.requireCtsProfileMatch is refused when the attestation cannot satisfy it. It is a demand about an android-safetynet device-integrity signal and was checked only inside that format's arm, so a relying party that demanded a CTS-matching device got a pass from a packed or none attestation that was never asked the question. That is the same shape the TPM key policy beside it already guarded against. A mistyped requireCtsProfileMatch or verifySafetyNetJws is now a configuration fault whatever the format, and no longer a truthy string that demands nothing.
- pki.webauthn.metadataAnchors takes a second, optional argument carrying the metadata result, the instant to judge at, and the attestation certificate presented.
- The documented signatures of pki.webauthn.verify, verifyAssertion and parseAuthenticatorData list every field their verdicts carry, and verify documents its options.

### Fixed

- The canonical RFC 5280 sec. 7.1 distinguished-name comparison refuses input it cannot compare, instead of answering that it matched. It takes the RDN sequence on both sides; handed a parsed Name object it compared two absent lengths, ran its loop zero times, and returned true for two unrelated names. One caller passed that shape: the FIDO metadata anchor test, whose name half was therefore inert, leaving the public-key comparison to carry a decision RFC 5280 sec. 6.1.1 defines as name and key together. It passes the RDN sequence now, and every other call site in the toolkit already did.
- A metadata entry whose status reports disqualify the model registers no attestation anchors. The route an operator follows to anchor an attestation themselves (metadataFor, then metadataAnchors, then pki.path.validate) never consulted those reports, so a revoked authenticator's registered roots were handed back and the path validated against them. metadataAnchors now refuses with webauthn/metadata-status. An entry may only be judged against the catalogue it was read out of: a process holding two would otherwise be able to pair an entry from one with the other, and the second's status policy and freshness would decide about the first's reports. A by-date reading would hand back anchors the entry's own catalogue records as revoked. It reads the status reports exactly as the attestation path does when given the same three things: the verified catalogue and its status policy, the instant to judge at, and the attestation certificate presented. Where you leave one out it applies the strictest reading: any disqualifying report denies, judged now, with no report treated as concerning some other certificate. Supplying all three, which is what the attestation path does, is what makes the two verdicts identical; supplying none can only refuse more.
- An attestation certificate's public key must match the credential public key in kind as well as in bytes. An X25519 key-agreement key and an Ed25519 signing key are both 32 raw bytes, so on the Edwards curves the material alone cannot separate them. For the apple format, which carries no attestation signature, this comparison is the entire binding. The certificate's declared key algorithm is now part of it for every key type; before, only the EC branch asked, as a curve check.
- An RSA credential key's public exponent must be minimally encoded, as its modulus already had to be. Padded, 00 01 reads as a two-byte exponent and skips the value check that refuses 1, the identity exponent, under which every signature verifies. A padded short modulus could clear the modulus floor the same way.
- An Edwards certificate key is validated on-curve and full-order before a metadata BLOB signature is verified under it. The identity point and the other low-order points are imported by the platform without complaint and verify a trivial signature over any message, so a leaf carrying one authenticates whatever payload it is shown. Chaining to the pinned FIDO root does not help: the certificate is properly issued and still malformed. Every other Edwards key in the toolkit already passed this gate; the metadata reader takes the same one now, through the shared entry, with no second copy of it.
- An id-RSASSA-PSS key's parameters restrict it even where they look empty, in both directions: signing and verification read the restriction through one reader. RFC 4055 sec. 3.1 makes the presence of the parameters the line: absent, they restrict nothing; present, the certificate user must use the hash they identify. hashAlgorithm is [0] ... DEFAULT sha1Identifier, so a parameters SEQUENCE that omits it names SHA-1; it does not decline to name anything. Reading that omission as no restriction let a key its own certificate confines to SHA-1 sign and verify under SHA-256 or SHA-512. Parameters that are present but unreadable are likewise a restriction that cannot be honored, which is not the same as no restriction, so they are refused.
- The RSA credential-key floor is measured in bits. A byte count is not a bit count: a minimally encoded 256-byte modulus whose leading byte is 01 is 2041 bits, seven short of the 2048-bit floor, and cleared a test written in bytes.
- A metadata BLOB signed with RSASSA-PSS, EdDSA or ML-DSA verifies. The reader carried its own six-row JWS algorithm table beside the toolkit's registry, and only the registry had been extended, so PS256 was accepted as an ACME signature and refused as a metadata signature. The table is derived from pki.jose's registry now and is total over it, so an algorithm the toolkit verifies cannot be one this reader rejects: an X.509 SubjectPublicKeyInfo carries an Edwards key (RFC 8410) and an ML-DSA key (RFC 9881) as readily as an EC one. EdDSA names a scheme without fixing a curve, so the certificate decides whether it is Ed25519 or Ed448. An x5c leaf carrying an id-RSASSA-PSS key verifies a PS256/384/512 BLOB, and the restriction that certificate places on the key is enforced in both directions: the key may not verify an RSASSA-PKCS1-v1_5 signature, and where the certificate pins a single hash, a signature under any other is refused.
- The android-safetynet service chain reaches its anchors through the same walk as every other certificate chain in the namespace, and no longer through a second copy of it. An x5c ending in a cross-signed form of the pinned root (the ordinary shape during a CA rotation) now chains, where the local copy left it in the path to fail against a root that never issued it.

## v0.5.2 — 2026-08-13

pki.cms.verify gains a trust seam: name the roots you accept, and the verdict says whether the signer chained to one.

### Added

- pki.cms.verify(input, opts) accepts opts.trustAnchors (the roots the caller accepts, as certificate DER or anchor tuples) and returns trusted alongside valid, with a per-signer trusted on each entry of signers. The signer certificate is chained through the same path engine pki.path.validate uses, with the SignedData's own certificates offered as intermediates and never as anchors. Every signer must chain for the message to be trusted, the same rule the per-signer signature check already followed: reporting the whole as trusted because one signer anchored would let an unanchored signer ride out on another's chain. opts.time picks the instant the chain is judged at. Without anchors there is nothing to chain to and trusted is false: a definite answer, and the same shape pki.cmp.verify returns. Anchors that cannot be read are a caller's configuration mistake and throw; absorbing them into trusted: false would report a verdict about the message for a check that never ran.
- pki.smime.verify forwards opts.trustAnchors and opts.time to the CMS verification beneath it and surfaces trusted in its own verdict. It documents itself as that verdict plus the MIME surface, so the seam had to reach it: building the options from scratch and passing only certs would leave a caller naming anchors with no way to have them applied. It asks for the emailProtection key purpose when anchoring, at both ends of the chain: requiredEku constrains the signer certificate, because a certificate restricted to serverAuth chains to its root perfectly well and is still the wrong key to have signed a message (RFC 8551 sec. 4.4.4), and checkPurpose selects the anchor's own trust metadata, because a root distributed with NSS trust bits can be marked untrusted for email while remaining a good TLS root. Those bits, along with distrustAfter, are consulted only when a purpose is named. Asking one without the other checks one end of the chain and not the other. Pass requiredEku or checkPurpose to ask for something else.
- pki.webauthn accepts RSASSA-PSS credential keys at all three strengths: PS384 (-38) and PS512 (-39) join PS256, in registration and in assertion verification. They were previously refused at parse time on keys that are perfectly well-formed, the same bytes being accepted under -37, so a relying party holding credential rows written by another implementation had some it simply could not check, and could not tell which without scanning its own table. An algorithm this verifier does not implement now reports webauthn/unsupported-algorithm, where it used to report webauthn/bad-cose-key: the key is not malformed, and only one of those two facts tells an operator that re-registering the credential cannot help.
- pki.webauthn.parseCoseKey(bytes) decodes a stored credential public key on its own, and pki.webauthn.verifyAssertion accepts credentialPublicKey as either that parsed object or its COSE bytes. The registration-to-login round trip had a gap in the middle: verify returns the key as an object, but the durable form is bytes. The object carries Buffers, so a JSON round trip through a datastore returns {"type":"Buffer","data":[...]}, and existing credential rows already hold COSE bytes whoever wrote them. The only routes into the decoder parsed a containing structure, so recovering a stored key meant fabricating an authenticatorData that never existed. A registration verdict now also carries credentialPublicKeyBytes, the form to persist.

### Changed

- A signer certificate whose keyUsage forbids signing is not trusted, however well it chains. RFC 5280 sec. 4.2.1.3 makes the extension binding when present, so a leaf asserting keyEncipherment alone must not verify a signature. Path validation checks the CA's keyCertSign, leaving the target's own usage unexamined. The verb that knows a signature was made asks the question, the same format-local gate pki.cmp.verify applies, reading the value through the one strict decoder so a malformed keyUsage fails the gate; a hand-rolled bit test could have authorized it. contentCommitment counts alongside digitalSignature. The signature is still reported sound; what changes is whether the certificate was permitted to have made it.
- pki.cms.verify and pki.tsp.verify refuse an unrecognized option instead of ignoring it. This is what kept the missing trust seam silent: a caller writing trustAnchors before it existed, or trustAnchor now, got a verdict that looked anchored and was not. It matters most between these two verbs, because they spell the anchor option differently: pki.tsp.verify takes trustAnchor, singular, an anchor tuple, while pki.cms.verify and pki.cmp.verify take trustAnchors, plural, accepting certificate DER. Carrying the plural spelling to pki.tsp.verify previously meant no anchoring and no error, leaving an unchained TSA certificate under valid: true. The refusal names the difference.

### Security

- Build and analysis pins move up: github/codeql-action to v4.37.6 across all six references, ossf/scorecard-action to v2.4.4, actions/setup-python to v7.0.0, the ClusterFuzzLite base-builder-javascript image to its current digest, and eslint to 10.8.1. Nothing here reaches the published tarball; the package still declares no runtime dependencies, and every action stays pinned by commit SHA with its version in a trailing comment.

## v0.5.1 — 2026-08-12

Four verify and export paths stop answering a question other than the one they were asked: the key you supply governs, and a private key exports as one.

### Changed

- pki.sigstore.verifyBundle reports identityChecked alongside verified: a boolean per identity field showing which were actually compared. verified: true says the artifact was signed and logged, not that a party you trust signed it. Fulcio issues a certificate to anyone who completes an OIDC flow, so who signed is decided only by opts.identity, and the two claims were previously indistinguishable in the verdict. An opts.identity naming none of san, issuer or sourceRepositoryURI is now refused, since every comparison inside it was falsy: it accepted every signer while reading as a policy in force. An unrecognized field name is refused for the same reason: cosign spells this certificateIdentity, and swallowed it pinned nothing under a name the operator believed constrained the signer.

### Fixed

- pki.webcrypto.subtle.exportKey("raw", privateKey) is refused with webcrypto/not-supported; it no longer answers with the public key. The W3C definition of raw covers public and secret keys; there is no raw private-key serialization for EC or OKP, and Node's own WebCrypto refuses it too. The consequence ran through wrapKey, which forwards the caller's format straight to exportKey: a private key wrapped as raw escrowed the public key, and unwrapping it returned a handle announcing usages ["sign"] that cannot sign, with the private key gone and no error at any step. Use pkcs8 or jwk to serialize a private key; the public half still exports as raw.
- A post-quantum private key exported to a JWK re-imports as a private key. ML-DSA, ML-KEM and SLH-DSA JWKs are kty: "AKP" and carry the private half in priv, while the import tested only for the d an EC or OKP key uses. Every PQC private JWK therefore read as public. The re-imported key was type public yet still announced usages ["sign"], and extractable was forced true even where the caller asked for false: a key that could not sign, said it could, and ignored the extractability it was given. Round-tripping now preserves the half that signs.
- pki.jose.verify treats opts.key as the key the message must be signed under. Where the profile also permits an embedded header jwk, as acme-outer does, the embedded key was preferred and the two were never compared, so the sender chose which key verified its own message and a caller supplying the account key it expected got no benefit from doing so. The two must now be the same key, compared as RFC 7638 thumbprints so member order cannot make equal keys differ, and a disagreement is refused with jose/key-mismatch. The verdict carries keySource, naming which key answered, because a signature checked against a key the caller named is a different claim from one checked against the key the message brought with it.

## v0.5.0 — 2026-08-12

CMC (Certificate Management over CMS) ships end to end: build a Full PKI Request, carry it to a CA over EST, and read the response into one terminal outcome.

### Added

- pki.cmc.verify(response, sent) interprets a Full PKI Response into one terminal outcome: issued, pending, confirm-required, pop-required or rejected. The response is bound to the request that provoked it first. The Transaction Identifier, the Sender/Recipient Nonce echo and the Data Return echo each apply only if the client sent that half, and each becomes a refusal once it did. The nonce is compared in constant time and by full value, so a truncated echo cannot match on a prefix. `bodyPartIDs` binds what the response is about: pass the identifiers the request carried, and a status reporting on a body part that was never sent is refused with cmc/body-part-unknown. The transaction and nonce cannot catch that, because a server can echo both correctly while answering about a different message. Several status controls are permitted and the worst governs, so a failure cannot hide behind an earlier success, and no status control at all is success. Both carriers RFC 5272 accepts work, including the certificate bag an AuthenticatedData keeps under originatorInfo.
- pki.cmc.build(spec, signer, opts) assembles and signs a Full PKI Request across all three request forms: PKCS#10, CRMF, and the other-message arm. Body-part identifiers are allocated unique across the whole message, skipping the reserved 0, and a caller-supplied identifier that clashes is refused and never renumbered, because a control may already reference it. An Identity Proof V2 witness is computed over the reqSequence bytes exactly as emitted, and where an Identification control accompanies it, the key derivation includes that identity as RFC 5272 sec. 6.2.3 specifies. A POP Link Witness is emitted only together with the POP Link Random control sec. 6.3.1.1 requires alongside it, and a renewal carries neither Identification nor Identity Proof, in either version.
- pki.schema.cmc.parse / parsePkiData / parsePkiResponse read the CMC message layer, reached by content type through the CMS carrier. Controls keep their wire order and their raw values, so an unrecognized control is data, and does not read as a fault. Body-part identity is enforced unique across the whole message, covering controls, requests, content infos and other messages alike; uniqueness per list is not enough. The reqSequence bytes are surfaced exactly as they arrived, so an Identity Proof witness is computed over the wire bytes themselves, with no re-encoding in between.
- pki.est.fullcmc(baseUrl, request, opts) POSTs a Full PKI Request and returns the pki.cmc.verify verdict. It accepts either response arm RFC 7030 sec. 4.3.2 names, certs-only or CMC-response, and requires the declared label to agree with the bytes, on the error path as well as the success one. It retains the identifiers its own request carried, so a status reporting on a body part that was never sent is refused; it does not become the answer. A 404 or a 501 is reported as the distinct "not implemented" answer, and no longer as a generic failure, and a rejection surfaces the CMC verdict as a typed est/cmc-failed without letting an unreadable error body mask the HTTP fault it arrived with.
- pki.cms.sign accepts a key-only signer: { key, spki, keyIdentifier } with no certificate. RFC 5272 sec. 3.2 requires exactly this when a Full PKI Request is signed with the key of a certification request it carries. There is no certificate yet, so the signer identifier takes the subjectKeyIdentifier form and carries the identifier the request itself declares, the signature scheme resolves from the request's own public key, and no certificate is embedded.
- pki.webauthn now verifies the authentication half of WebAuthn as well as registration. verifyAssertion(input) checks an assertion signature over authenticatorData || SHA-256(clientDataJSON): raw bytes with no COSE_Sign1 wrapper, and an ES256 signature carried as ASN.1 DER, read with the same order-aware reader the attestation path uses, so an r or s outside [1, n-1] is refused, with no normalization. Give it the previousSignCount you stored and the sec. 7.2 step 21 counter rule applies: a counter that fails to advance is refused as the cloned authenticator it signals, while the 0/0 case an authenticator without a counter reports is accepted. parseAuthenticatorData(bytes) reads the bare authenticatorData an assertion carries, through the same fail-closed parser registration uses. parseClientData(bytes, opts) decodes the clientDataJSON that no signature check ever looks inside, through the shared JSON guard since these are bytes an attacker chose, and returns the challenge decoded, so the caller's comparison is a byte comparison.
- pki.webauthn.verify accepts opts.rootCertificates, the attestation trust anchors a caller pins. Anchoring previously ran only through a FIDO Metadata Service entry, which reaches only the models that catalogue lists: Apple does not publish its authenticators to it, and the Google hardware-attestation roots are distributed by Google, so for those formats there was no parameter to carry a root and a trust path came back unchecked. The precedence is stated explicitly. opts.metadata governs when supplied, because a model's own registered roots are a stronger claim than a static pin and its status reports can disqualify a model those roots would still accept; opts.rootCertificates is the route for everything the catalogue does not cover. Supplying both is the ordinary configuration for a relying party that accepts MDS-listed authenticators as well as Apple. Every verdict now reports anchoredTo, naming every route that anchored the path and joining them with + when more than one did: "metadata", "rootCertificates", and "safetyNetRoots" for the android-safetynet chain, which anchors through the roots that format requires whether or not either other route was asked for. It is null only when nothing anchored the path. A compound holding one element the catalogue lists beside one it does not is anchored element by element, each against the roots its own route supplies, where before it would have been refused, and the verdict keeps the entries that governed so the metadata-backed half of the decision stays auditable.

### Changed

- 79 APIs graduate from experimental to stable, taking the stable surface from 78 to 157: the whole of pki.cms, pki.tsp, pki.ocsp, pki.smime, pki.cbor, pki.merkle, pki.crmf, pki.inspect, pki.trust, pki.tls and pki.shbs; the offline CMP message layer (build and verify); pki.path.validate / crlChecker / ocspChecker / verifyOcspResponse; the pki.webauthn attestation surface and its FIDO metadata reader, the assertion verbs being new here and shipping experimental; and the pki.schema decoders for S/MIME attributes, TSP requests and the engine itself. Each rests on a final standard and is proven either against an independent implementation in the interop harness or, for the formats no mainstream tool implements, by conformance-vector round-trip plus coverage-guided fuzzing. Stable means the deprecation policy now applies: no silent breaking changes, and a deprecation warning at least one minor before any removal.
- 68 APIs stay experimental, each with a written reason and a re-review date attached. Four things hold one back. The standard is not final: pki.schema.c509 tracks an IETF draft, the Sigstore bundle format is still moving, and all of pki.ct rests on RFC 6962, which is published Category: Experimental however widely CT v1 is deployed. The surface is knowingly incomplete, so its own output will change: pki.hpke has no post-quantum KEMs yet, and pki.lint's rule set is still growing. It first ships in this release with no soak behind it: pki.cmc, pki.schema.cmc, pki.est.fullcmc, and the pki.webauthn assertion verbs. Or it is a network client waiting on the shared pki.transport (pki.est, pki.acme, the driven half of pki.cmp, and the opt-in AIA fetching in pki.path.build), which is still absorbing a fail-closed requirement from each new protocol that composes it, and this release added another.
- An AuthenticatedData response is authenticated by its MAC. Pass pki.cmc.verify a recipient with the key material, the shape pki.cms.decrypt takes, and the MAC is checked, so a caller holding the key gets an authenticated verdict instead of the unauthenticated opt-out. The content the MAC covers is bound to the content the verdict was read from, so a MAC over other bytes cannot stand in for it, and a wrong key is reported in this layer's own terms, with no CMS code leaking through.
- A Full PKI Response must be authenticated before it is interpreted (RFC 5272 sec. 3.2.1.3.4). A conforming response carries its own signer certificate, so the ordinary flow needs nothing extra and the verdict reports signatureVerified: true. Where the signer is found nowhere, neither in the message nor supplied, the opt-out has to be named: pass certs with the responder's certificate, or allowUnverified: true, in which case the verdict reports signatureVerified: false. Doing neither is refused, so no caller ends up assuming a check that did not happen, and the opt-out never excuses a signature that is present and wrong. A carrier with no signer at all is refused outright. pki.est.fullcmc threads this through as responderCerts and allowUnverifiedResponse, on both the success and the rejection paths.
- A pki.webauthn verdict can no longer be mistaken for a ceremony verdict. The field is attestationVerified, and signatureVerified for an assertion, where it used to be verified. An attestation statement being sound is a different claim from a registration being acceptable: a statement naming another relying party, with user presence clear, is perfectly sound and must not be registered. The bindings this layer can check are now offered by name: expectedRpId, requireUserPresence, requireUserVerification, allowedAlgorithms, and for clientDataJSON the ceremony type, challenge and origin. Every verdict reports which of them actually ran in bindingChecked, so a check that passed is distinguishable from one that never happened. The ceremony type is checked unconditionally when verifyAssertion is given the JSON, since the specification fixes which ceremony a response belongs to and the caller does not choose it, and a registration response replayed as a login is what that check stops. A registration verdict also now carries the credentialId, credentialPublicKey and initial signCount a later login needs, which previously required parsing the attestation object a second time.

### Fixed

- A CMC status control carrying the OPTIONAL statusString (the human-readable explanation a CA sends with a rejection, so it is on the common path) crashed the decoder with a raw TypeError instead of returning the string, because the reader it named does not exist. pki.schema.cmc.parse, pki.cmc.verify and pki.est.fullcmc were all affected. Every CMC decode now goes through a reader the codec actually exports, and a gate checks that across the whole tree by reading the codec's own export list, so a mistyped reader cannot ship again.
- pki.cmc.build takes transactionId, senderNonce and dataReturn as named spec fields, the same three pki.cmc.verify checks a response against. They previously had to be hand-encoded into spec.controls, and an unrecognized spec field was accepted in silence, so a request written the obvious way built, signed and sent with no exchange binding at all. Neither end could detect that, because the verifier only enforces the halves the client says it sent. An unknown spec field is now refused.
- pki.cms.verify and pki.cmc.verify parse a private copy of a Buffer input, so the value they report and the bytes the signature was checked against are provably the same. Both decode the message synchronously and check signatures in a later turn, which left every byte range the parse surfaced, the signed content above all, a view into the caller's memory across that gap. A buffer rewritten in between could yield a result describing one message while the signature covered another, and the everyday way to hit it is a pooled read buffer recycled across concurrent verifies. A PEM string or an already-parsed object is untouched, so neither call accepts less than it did.
- Every example in the API documentation runs against the shipped package as part of the test suite, with no fixtures supplied to it, and the ones that did not run have been corrected. An example that quietly depended on a value the surrounding text never defined would previously have failed only for the operator who pasted it.
- pki.webauthn.verify copies opts.rootCertificates synchronously, so pinned attestation roots cannot be swapped out from under the check. The roots are not read until the attestation verifier resolves, a later promise turn, and both the array and each DER buffer stayed caller-owned across it: a caller recycling the array or overwriting a certificate's bytes in that gap had the attestation anchored against the replacement roots while the verdict still reported anchoredTo: "rootCertificates". Both are now copied at the entry point, the same defense the assertion input already had, and the documented parsed-certificate form is deep-copied and never passed by reference, since the anchor comparison reads its nested subject and subjectPublicKeyInfo buffers. opts.safetyNetRoots carried the identical window one level down, since a format verifier reads it a microtask after the call returns, and is snapshotted at the same boundary.
- In a compound attestation, an element the metadata catalogue does not list can no longer launder a revoked sibling. The two governance failures are not equal: metadata-not-found is the one outcome a caller may fall back to opts.rootCertificates on, and that fallback covers the whole statement. Governance stopped at the first failing element, and a compound's element order is not signed, so placing an unlisted element first raised the fallback error before a listed-but-revoked sibling was ever consulted, and the statement anchored against the pinned roots instead. Every element is now governed, and every listed element chain-validated against the roots its own entry registers, before any outcome is chosen. So a disqualifying status report outranks an unlisted sibling from either position, and a listed element whose path reaches the caller's pinned roots but not its own registered roots can no longer ride out on that sibling's fallback either. A compound in which no element is listed still falls back as documented.
- pki.ocsp.sign copies the responder key synchronously. The key is not read until several promise turns after the call, and capturing only the reference stopped responder.key being reassigned but not the PKCS#8 bytes, or a composite key's components, being rewritten in place. Either produced a response carrying this responder's identifier and embedded certificate over a signature made by different key material, which no relying party can verify. A CryptoKey is opaque and a PEM string immutable, so both were already safe. A composite descriptor is cloned whether or not its components currently hold bytes: one carrying two PEM strings has nothing mutable inside it, but the object is still the caller's, and reassigning a component reaches the deferred sign just as rewriting a buffer would.
- An Apple attestation certificate whose anonymous-attestation extension carries more than the nonce is refused. AppleAnonymousAttestation is a SEQUENCE of exactly one field wrapping exactly one value, but the decoder read the first child and ignored the rest, so a certificate with a trailing field beside the nonce, a second value inside the EXPLICIT [1] wrapper, or a non-SEQUENCE outer value was accepted. That extension exists to carry the value the attestation binds to, so an ambiguous encoding of it is not a shape the verifier gets to pick a reading from; arity is now enforced as part of the declared type.
- A key identifier and a pre-encoded CertID must now be bytes, and are no longer coerced. pki.cms.sign takes a key-only signer's keyIdentifier and pki.ocsp.sign takes a response entry's certID straight into the encoding, and both previously ran the value through Buffer.from, which accepts far more than it should mean: Buffer.from(20) allocates twenty zero octets and Buffer.from("a1b2") takes the ASCII of the text where a reader means two octets. Either produced a structurally valid but wrong SignerIdentifier or CertID, inside a message that then gets signed and that no verifier can match back. A Buffer or Uint8Array is accepted as before; anything else is now cms/bad-input or ocsp/bad-input.
- An OCSP response whose nonce does not echo the request no longer downgrades a revoked verdict to unknown. pki.ocsp.verify applies that downgrade to good only. Revocation does not go stale the way non-revocation does, so discarding a signed, current, authorized revoked response because it was replayed would hand a soft-failing caller the certificate the responder just refused: the anti-replay defense would become the thing that accepts it. The verdict was also self-contradictory, reporting status unknown while carrying revocationReason keyCompromise. nonceMatched: false still reports that the response was not bound to this request, and the field is now three-state and always present (true bound, false not bound, null when the client sent no nonce), so a caller can tell a check that ran from one that was never asked for.
- Corrections to the repository documentation, each of which would have misled a reader who acted on it. SUPPORT.md described additive APIs as minor releases; pre-1.0 they ship as patches, and a minor is an explicit decision recorded in the release notes. ARCHITECTURE.md, CONTRIBUTING.md and the interop test guide showed pki.x509.parse, which does not exist: parsing is pki.schema.x509.parse, and pki.<format> is the issuing half throughout. ARCHITECTURE.md listed namespaces as future that have shipped, and omitted the schema, guard and validator families entirely. THREAT-MODEL.md marked path validation, signature verification, CMS decryption and ML-KEM decapsulation as targeted when all four have shipped, and linked to a section that no longer exists. ROADMAP.md reported CRL reason-shard accumulation and delta-CRL merge as planned in one entry while describing them as shipped in another. ML-KEM encapsulation and decapsulation were described as roadmap items; both ship and are what the CMS KEMRecipientInfo arm rides on. The interoperability acceptance gate was described as running against NSS, Windows CAPI and macOS Keychain alongside OpenSSL; only the OpenSSL cross-checks are wired, and the others are roadmap. The format detectors behind pki.schema.parse were described as mutually exclusive regardless of registration order; order is load-bearing where two overlap, so a CMP PKIMessage sits ahead of the OCSP-request probe and a v1 attribute certificate ahead of X.509. The fuzzing guide listed 27 of the 55 harnesses. The README carried two separate entries for pki.tls. The published pki.transport response contract named three fields where the transport returns four: the omitted tls field is what pki.est.serverkeygen reads to assert the channel can protect a server-generated private key, and a transport that reports no cipher is trusted, so an operator injecting a substitute built to the documented contract silently skipped that check. EST channel binding was described as shipped; the challengePassword builder and the server-instruction flag ship, but nothing produces the RFC 5929 tls-unique value and the shared transport does not expose it, so the attribute cannot be driven end to end.

## v0.4.15 — 2026-08-11

A CA that partitions revocations by reason code, or publishes a delta CRL alongside its base, now gets a real answer instead of "undetermined".

### Added

- Reason coverage accumulates. Each CRL that corresponds to one of the certificate's distribution points contributes its interim reason mask (RFC 5280 sec. 6.3.3(d)(1)-(4)), and the certificate reads good once the CRLs together cover all eight revocation reasons (sec. 6.3.3(l)). Previously only a CRL that covered every reason by itself could establish good, so a reason-partitioned CA could never be satisfied. Partial coverage still fails closed, and a shard that does not correspond to the certificate contributes nothing while still being consulted for revocation.
- Delta CRLs are merged onto a complete CRL they may be combined with (sec. 5.2.4(a)-(d), sec. 6.3.3(c)): same issuer, byte-identical issuing distribution point and authority key identifier, and a base number the complete CRL's own number covers. The delta is searched first, the complete CRL only if the delta left the certificate unrevoked, and a removeFromCRL entry then releases it, so a certificate placed on hold and later released now reaches good, where it used to stay rejected. A delta is merged only when the certificate or the complete CRL carries a freshestCRL locator (sec. 6.3.3(a)(2)).
- pki.path.crlChecker(crls, opts) takes opts.useDeltas (sec. 6.3.1(b)), default true. With it false a delta is never merged; it is still consulted for revocation.
- A revoked verdict carries reasonCode, the CRLReason integer (0 for unspecified when the entry has no reasonCode extension), and a reason naming it, so an operator learns that a certificate was revoked for keyCompromise, where before the verdict said only that it was revoked.

### Changed

- Merging can only ever turn an undetermined verdict into good or revoked. A delta that combines with no complete CRL held locally is still consulted for the revocations it lists, and still withholds good, so an unmergeable delta can never erase a revocation, including one that names a base the verifier does not have. Where several current deltas exist for one scope, which RFC 5280 sec. 5.2.4 permits, the one with the latest thisUpdate is selected, and the set is not treated as a fault.
- The reasons field of a certificate's cRLDistributionPoints is now rejected unless minimally encoded, matching the rule already applied to keyUsage (X.690 sec. 11.2.2 named bit lists). Two encodings of one reason set previously both parsed, which would leave the reason intersection computed over an encoding the rules forbid.
- A delta CRL indicator that is not marked critical, which RFC 5280 sec. 5.2.4 requires it to be, does not make the CRL mergeable. Such a CRL is still treated as a delta and still consulted for the revocations it lists, as before, but it cannot release a certificate its base revoked. Releasing rests on a conforming indicator.
- An issuing distribution point that is not marked critical cannot contribute reason coverage at all. Such an extension is one a relying party may ignore entirely, so building a good verdict on the scope it declares would rest on something another verifier would not see; that is the same fail-closed reasoning already applied to distribution-point correspondence. It still restricts nothing and withholds good, exactly as before this release.
- A CRL number past the RFC 5280 sec. 5.2.3 twenty-octet ceiling does not make a CRL mergeable, matching the bound pki.crl.sign already enforces when emitting one. Such a CRL is still consulted for the revocations it lists; only the ability to release a certificate is withheld.

### Fixed

- Holding a delta CRL alongside its base is no longer worse than holding the base alone. Any authoritative delta previously forced the whole verdict to undetermined.
- A CRL that covers no revocation reasons for the certificate, such as a shard whose distribution point does not correspond to it, is now checked for currency and signature before it is consulted at all. Such a CRL is still read for revocations, so without those checks an expired or forged one could have revoked a certificate it never legitimately covered, or, as a delta, released a certificate its base genuinely revoked.
- A delta CRL superseded by a more recent one for the same scope no longer affects the verdict in either direction. It could previously contribute a revocation that the selected, later delta had released; that resurrected a revocation the CA withdrew, while its own release was correctly ignored.

## v0.4.14 — 2026-08-10

Every key-establishment secret this library allocates is now wiped when it stops being needed, classical as well as post-quantum.

### Changed

- Raw secret key material is now reached through a single path that clears the copy it hands out, so a new operation cannot obtain that material without the wipe. Behavior of the public API is unchanged.

### Fixed

- The raw shared secret of an ECDH / X25519 / X448 key agreement is cleared once the derived bits have been produced, including on the exit where the caller asks for the whole secret and on the error when more bits are requested than the curve provides. It was previously left readable for the process lifetime after every key-agreement operation.
- The AES content-encryption key is cleared after every encrypt and decrypt. The key material was exported into a fresh buffer on each call, with no clear afterwards, so an application that encrypted or decrypted repeatedly accumulated a readable copy of each content key. This covers GCM, CBC and CTR in both directions.
- A key-derivation function now clears the copy it makes of its input keying material. This was already done for HKDF; the X9.63 and PBKDF2 derivations on the same dispatch did not, and the X9.63 one holds the ECDH shared secret of an RFC 5753 key-agreement recipient.
- The content-encryption key of an enveloped message is cleared once the message is built, and the recovered one is cleared once the content is open. Because that key is wrapped for every recipient, the clear happens once at the end and never per recipient, so a message with several recipients still opens correctly for each of them.
- The password-derived key-encryption key of a password recipient, and the password-derived content key of a password-protected EncryptedData, are cleared on both the producing and consuming sides. That includes a wrong password, which is the path an attacker repeats.
- When a PKCS#1 v1.5 key-transport unwrap hits a decode fault, the decryptor continues with a fresh random substitute content key so the failure stays indistinguishable from any other bad-key path (RFC 3218). That substitute is now cleared too, since it is allocated only on the failing path, which is the one an attacker drives repeatedly.
- The message-authentication key of an AuthenticatedData is cleared on both sides: the producer generates it, wraps it for every recipient and clears it once; the consumer clears it after the MAC and message-digest checks, including when a tampered message fails them.
- Password-based private-key protection clears the key it derives. pki.key.encrypt / pki.key.decrypt and the shared PBES2 encrypt / decrypt used by PKCS#12 each left the password-derived key readable after use. That key guards a private key, which is the most sensitive thing this library encrypts.
- PKCS#12 integrity clears the password-derived MAC key on both sides (when a store is built, and when its MAC is recomputed to verify it), and the legacy-PBE decryption arm clears its derived key, which its PBES2 sibling on the same dispatch already did. The PBMAC1 key, shared by both, is cleared as well.
- HPKE clears the raw Diffie-Hellman output on every DHKEM arm: base and authenticated, sealing and opening, including the concatenated form the authenticated modes build from two agreements.
- A key-derivation function returns an exact-sized buffer the caller wholly owns, and no longer a window onto a larger accumulator. Where the requested key size is not a multiple of the digest length, as for an RC2 key from a SHA-1 block or an X9.63 or HPKE derivation of an odd length, clearing the returned key previously left the unused tail of the final derived block readable behind it.
- Key-derivation intermediates are cleared as they are superseded: the HPKE extract and key-schedule pseudorandom keys, and each digest round and input block of the PKCS#12 derivation, whose accumulator is now allocated once at its final size; regrowing it each round abandoned an unreachable password-derived copy per iteration.
- An HPKE recipient clears the shared secret it derives once the key schedule has consumed it, and the single-shot seal / open clear the encryption context they build and discard: its AEAD key, base nonce and exporter secret. A context obtained from setupS / setupR belongs to the caller and is untouched, so a multi-message exchange is unaffected.
- A derivation or decryption result is cleared once it has been copied out to the caller. The PBKDF2 and X9.63 outputs, and the RSA-OAEP decryption output, which for a key-transport recipient is the recovered content key, were each copied into the returned buffer and then abandoned, leaving key material readable that no caller could reach to clear.
- A password supplied as a string or Uint8Array is encoded into a buffer this library allocates, and that credential encoding is now cleared once the derivation has consumed it. Previously only a caller-supplied Buffer was handled, and it was handled by leaving it alone, so the common case left the encoded password readable. A caller-supplied Buffer is still borrowed and never written to.
- The RFC 3211 password key-wrap clears its plaintext intermediates. Both the formatting block built around the content key when wrapping, and the recovered block when unwrapping, held a complete copy of that key and were abandoned; on the unwrap side that includes the two validation rejects, which are the paths an attacker induces by tampering with the wrapped key.
- Wrapping a key clears the plaintext serialization it makes of that key (the very material the wrap protects) on the delegated RSA-OAEP / AES-GCM branch as well as AES-KW. HPKE clears the labeled input copy its extract step builds around a shared secret or PSK, and clears the sender secret when setup itself rejects.
- A password is encoded only after its options validate, so a rejected iteration count or salt cannot abandon a credential copy; the PKCS#12 derivation clears the block-repeated salt and password fills it builds; and the HPKE expand clears each round feedback input, which carries the previous output block.
- Every site that builds a PKCS#12 password encoding clears it: store integrity on both sides, and legacy-PBE decryption. The clear happens only when this library allocated the encoding. A password supplied as a Buffer is passed through that encoder unchanged, so it stays borrowed and is never written to, exactly as on the CMS paths.
- Deriving a key clears the transient bits it derives once they have been imported into the key object, including when the import itself rejects.

## v0.4.13 — 2026-08-10

A KEM shared secret and the key it derives are now wiped as soon as they stop being needed. The failing path clears them too, and that is the path an attacker chooses.

### Added

- A KEM shared secret and the key-encryption key derived from it are wiped as soon as they stop being needed, satisfying NIST SP 800-227 RS5 / sec. 4.2 and RFC 9629 sec. 7. The wipe runs in a finally, so a decryption that fails clears the same buffers a successful one does. A wipe on the success path alone would preserve the secret in exactly the case an attacker can force. Only buffers this library allocated are cleared; a caller's key material, certificate, and the returned plaintext are never written to, and the plaintext remains usable after the wipe.
- This is best-effort, and is documented as such. The runtime copies a shared secret into places no code can reach (the decapsulation result on its way out, and again when it is imported as key material) and may relocate a buffer's backing store. Wiping the copies the library holds shortens the window in which a secret is readable; it does not mean a secret never persists in memory.
- pki.oid.kemParams resolves an ML-KEM parameter set to its FIPS 203 Table 3 sizes, by dotted OID or by registered name.

### Changed

- The ML-KEM ciphertext-length check FIPS 203 sec. 7.3 requires of a decapsulating party is now performed by the crypto engine, so a caller reaching decapsulateBits directly is covered too; before, only the CMS path that happens to call it today was. It reports webcrypto/bad-kem-ciphertext, naming the parameter set and both lengths, where the failure was previously indistinguishable from any other decapsulation fault; a ciphertext whose length is valid for some other parameter set is refused on those terms, and no longer reads as merely short. The check is on length alone: a correct-length ciphertext that has been tampered with still resolves to a pseudo-random shared secret, because turning that into an error would give an attacker a decryption oracle. No engine detail reaches a CMS caller: a structurally valid message whose decryption fails for any secret-dependent reason still reports the single uniform cms/decrypt-failed verdict. A message whose ML-KEM ciphertext length does not match the parameter set the message itself declares is a separate case and always was (including a length that would be valid for a different set), because the strict parser rejects it up front and names it: the mismatch is a structural fault, decidable from the message alone, with nothing about it depending on a key.
- The ML-KEM parameter sizes resolve from one registry instead of three separate tables in three modules. The encapsulation-key lengths were already duplicated verbatim in two of them, and each new consumer meant another copy that could drift; a parameter set is a property of the algorithm identifier, so it now lives beside the registry that resolves one. Behavior is unchanged.

### Fixed

- The roadmap attributed two rules to NIST SP 800-227 that it does not state: implicit rejection and re-encapsulation are FIPS 203's, reached through SP 800-227's requirement to comply with the KEM's own standard, and SP 800-227 sec. 4.3 explicitly permits a shared secret to be used directly, truncated, or split into segments. The unconditional key-derivation requirement comes from RFC 9629 sec. 5. The entry now states what each document requires.

## v0.4.12 — 2026-08-10

A CMS message can no longer declare one content cipher and be opened with another: the declared algorithm's mode is now bound to the container that carries it, so an EnvelopedData naming an authenticated cipher is refused. It used to be opened, unauthenticated, under a result that reported it as authenticated.

### Added

- pki.lint reports the RFC 5280 sec. 4.2.1.4 rules for a certificate policy's user notice, at the strength the specification states each one: encoding a notice as VisibleString or BMPString is an error, since conforming CAs must not; a notice past 200 characters, an empty one, and one containing control characters are warnings; a UTF8String notice that is not in Unicode normalization form C is a notice. The length is measured in characters, so a conforming notice whose accented or emoji characters occupy more storage than 200 units is not reported, and a value whose contents do not decode under its own declared string type is not measured at all. The encoding rule, which the ASN.1 tag alone answers, still reports it. The rules live in the linter and not the decoder deliberately: the same section directs certificate users to handle an over-long notice gracefully, so a verifier that refused one would reject certificates that exist and are otherwise valid.
- The two ends of the SIZE (1..200) bound report separately, because the section treats them differently: it directs certificate users to handle a notice above 200 characters gracefully and says nothing of the sort about an empty one, so suppressing the first must not silently suppress the second. Both cover a notice reference's organization as well as the explicit text, since the bound belongs to the DisplayText type itself, which both fields use.

### Changed

- pki.inspect renders a certificate policy's user notice as text. A user notice is a constructed value, so it previously fell to the hexadecimal fallback and an operator could not read the notice the qualifier exists to display; its explicit text and its notice reference now render, the reference carrying its organization together with the notice numbers that identify which notice is meant.
- pki.inspect renders an authority-information-access location given as a directory name. It previously printed a bare form tag, hiding the responder or issuer identity the entry exists to convey, while the same name form already printed as a distinguished name elsewhere in the report.
- The producing entry points state their error contract completely. pki.x509.sign, pki.csr.sign, pki.crl.sign and pki.attrcert.sign accept raw DER for a name, a pre-encoded extension, or a public key; a structural fault in those bytes raises the format's own error, while a malformed leaf inside them raises the codec's, which the parsing entry points already documented and these did not.

### Fixed

- A CMS content cipher is now bound to the container that declares it. An EnvelopedData must name a CBC cipher and an AuthEnvelopedData an AEAD one, checked before the content-encryption key is used; a mismatch is refused as an unsupported algorithm naming both the cipher and the container. Previously only the cipher's key length was resolved, and because AES-CBC and AES-GCM share key lengths, an EnvelopedData whose algorithm identifier had been changed to the same-size AES-GCM identifier decrypted successfully as unauthenticated CBC while reporting the AEAD algorithm in its result. A caller inspecting contentEncryptionAlgorithm to establish that the content was authenticated was answered from a field the decryption had not honored. The reverse pairing was refused only incidentally, by a later dereference of parameters the AEAD path expects, and no stated rule refused it.
- The password-recipient inner cipher is resolved through the same identifier-keyed table. It previously required a CBC mode by matching the algorithm identifier's display name, which pki.oid.register can rebind, so a caller that had registered a name over a built-in one could change which ciphers that check admitted.

## v0.4.11 — 2026-08-09

A WebAuthn attestation can now be bound to the roots the authenticator's own model actually registered, by reading a FIDO Metadata Service BLOB that is verified and chained to a root you supply before any of its contents are parsed.

### Added

- pki.webauthn.verifyMetadataBlob reads a FIDO Metadata Service (MDS v3) BLOB and returns its entries indexed by aaguid. The BLOB is a JWS: its signature is checked under the certificate in its own header, that chain is validated to one of the roots the caller pins, and only then is the payload parsed. The ordering is the point, because a reader that parses first hands an attacker every structure behind the signature.
- The catalogue's freshness is enforced, and enforced again wherever it is used. A BLOB whose sequence number does not exceed the one the caller already holds is refused as a rollback, and requireRollbackCheck makes supplying that number mandatory so the check cannot be skipped by forgetting the option. A BLOB past its nextUpdate is refused as stale. A verified result is an ordinary object a relying party may cache, so its expiry is re-checked each time it is passed to verify, and a catalogue fetched while current cannot keep authorizing an authenticator whose status reports have since revoked it. Both checks fail closed, and the caller's own allowStale decision rides on the result, so it need not be repeated.
- Passing the verified result to pki.webauthn.verify as opts.metadata binds the attestation to its own model: the authenticator's registered attestation roots are resolved from its identifier, and its trust path must fully validate to one of them. That means signature chaining, validity and constraints: the same path validation any certificate chain gets. An authenticator whose model the catalogue does not list, whose entry registers no attestation root, or whose status reports disqualify it, is refused. Which reports disqualify is selectable: any report ever filed, only the most recent one so that a later remediation clears an earlier revocation, or a predicate of the caller's own. An unrecognized status is ignored by default, as the specification requires, or treated as disqualifying on request.
- Authenticators that carry no aaguid are covered too. A U2F authenticator declares no model identity, and the catalogue keys it by the key identifiers of its attestation certificates instead; both key spaces are indexed and looked up, so a U2F registration binds to its registered roots and is not refused as unlisted. The identifier is read from the entry and from its metadata statement, since live entries populate both, and is computed as RFC 5280 sec. 4.2.1.2 method 1 defines it. A compound attestation is covered as well: its elements carry independent trust paths, and every one of them must reach a registered root.
- Which identifier is allowed to select an entry depends on what the attestation signature covers. The fido-u2f signature is computed over named fields and does not include the aaguid, so for that format the certificate decides and the declared aaguid is ignored; otherwise setting it to a listed model that shares the vendor's registered root would resolve to that model's entry and skip the real one's status reports. For relying parties: res.aaguid reports what the authenticator presented and is not signature-bound for that format; res.metadata.aaguid names the entry that actually matched.
- Only a catalogue this library verified can decide anything. A metadata result is recognized by its provenance. Shape is not enough on its own, so an object restored from a cache is refused as a catalogue. It has been through none of the signature and chain checks, and its contents are whatever an attacker able to write that cache chose. Re-verify the BLOB instead, which the freshness rule asks for anyway. The verified result is also frozen. The catalogue that decides a later verification is therefore the one the signature covered, unedited.
- A stored attestation is anchored at the instant its own format judged it. An android-safetynet response carries its signing time and its service chain has usually expired since, so the metadata anchor check reuses that instant in place of the current clock; otherwise it would refuse the very registration the format verifier had just accepted. An explicit opts.time still takes precedence.
- Status reports are read against the instant being judged: a report dated in the future has not taken effect, so it cannot displace a revocation that is in force now, and a deliberately historical verification does not see reports filed after the time it asks about.
- A status report that names a single certificate is judged against the certificate actually presented. A whole batch of authenticators is commonly listed under one entry, so a key-compromise report naming one attestation certificate denies only that one, whatever else the entry covers; a report that names nothing, or names something that does not decode, still applies to the entry as a whole. Trust anchors are recognized by name and public key. Self-issuedness is not the test, so a chain terminating in a cross-signed form of a root you supplied still anchors to it.
- The BLOB's own signing certificate must be permitted to sign. A certificate that carries a key-usage extension omitting digitalSignature is refused before its key is used to check the BLOB signature, so a certificate restricted to some other purpose cannot confer metadata-signing authority just because it chains to the root you supplied.
- pki.webauthn.metadataFor looks an entry up against a verified result only, never raw bytes, so a lookup cannot be answered out of a catalogue nobody verified. It takes either identifier (an aaguid or an attestation-certificate key identifier), dispatching on the form, which are disjoint by shape. pki.webauthn.metadataAnchors decodes an entry's registered attestation roots one entry at a time, since a handful of certificates in the live metadata do not parse under a strict decoder and decoding everything up front would let one vendor's malformed root refuse the entire catalogue for every other authenticator in it.

### Changed

- pki.webauthn.verify now rejects an unrecognized option key, where it used to ignore one, and validates opts.time at the boundary. Every option it takes either gates the verdict or supplies the trust material a gate needs, so a misspelled key was not harmless: asking for metadata enforcement and mistyping the key left the gate switched off and returned a pass the caller believed had been checked against the catalogue. An invalid time is now reported as the configuration fault it is, and no longer surfaces later as an authenticator trust failure.
- Configuration objects across certificate, CRL, attribute-certificate, CSR, and CMP issuance now reject an unrecognized option key through one shared check, replacing a dozen separate ones. Two cases that a hand-written check gets wrong are fixed everywhere at once: a key that every JavaScript object inherits, such as constructor or toString, is no longer accepted as a recognized option name, and an option object built by parsing JSON that carries its own __proto__ key is now inspected; it used to be skipped. The wording of every rejection is unchanged. This matters because the failure is silent in the quietest possible way: a misspelled option key leaves the default in force, so a caller who asked for a stricter check gets the looser behavior and no error anywhere.

### Fixed

- An invalid opts.time passed to pki.webauthn.verify for an android-safetynet attestation raised an untyped internal error instead of the typed webauthn/bad-input verdict. Every failure from the verifier is a typed error again, so a caller catching pki.errors.PkiError no longer has a hole on that path.

## v0.4.10 — 2026-08-09

A TPM attestation now reports the credential key's own object attributes and access policy, so a relying party can require the properties it cares about (a key bound to one TPM, generated by that TPM, not duplicable) instead of taking the attestation on trust.

### Added

- A verified TPM attestation reports the credential key's object attributes as named flags, the raw attribute word, and the key's access-policy digest. Previously both fields were read past and discarded, so a relying party that wanted to know whether the key was bound to its TPM had to re-parse the public area itself.
- opts.tpmPolicy requires any of those attributes by name, in either direction, since an attribute may be required set or required clear; it refuses the attestation naming which one disagreed. A single profile, hardware-bound, is the shorthand for the six attributes every genuine attestation examined agrees on: the key is bound to one TPM and one parent, the TPM generated it, it can sign, and it is neither a restricted key nor an X.509 signing key. An explicit attribute layers over the profile, so one flag can be overridden without losing the rest. Three attributes are deliberately absent from it, because they differ across genuine authenticators and requiring any of them would reject working hardware.
- The policy also covers the key's access policy: requiring that one is present at all and is not the empty policy, and requiring it to be one of an allow-list of digests, compared in constant time. Two structural opt-ins are available for callers who want them: rejecting an attribute word that sets a bit the specification reserves, and rejecting attribute combinations the specification does not define.
- A mistyped policy is refused when it is set: an unknown key at any level, an unknown profile, an unknown attribute name, a non-boolean value, or an allow-list entry that is not a certificate digest all fail immediately, so a typo can never silently disable the check a caller believes they enabled. Names that every JavaScript object inherits are not accepted as policy names either, since a lookup would otherwise report them as recognized and leave the policy applying nothing. Requiring that the TPM generated the key without also requiring the key be non-duplicable is refused for the same reason. On its own it establishes nothing, because the key could have been imported.
- Requesting a TPM policy also requires an attestation that can satisfy it. The policy is evaluated against the TPM public area, which only a TPM attestation carries, so an attestation in any other format would never reach it, and a relying party that demanded a TPM-bound key would have accepted a credential with no attestation at all. Such a request is now refused up front, naming the format, and a compound attestation qualifies only when it actually contains a TPM statement.

### Fixed

- Calendar dates without a time are now read through the same strict reader as full timestamps. A date that does not exist is rejected: the thirtieth of February is refused, where the language would have rolled it into the following month and silently made it the second of March. A parsed date is anchored to UTC, so a freshness or expiry comparison does not shift with the host's time zone.

## v0.4.9 — 2026-08-09

A WebAuthn compound attestation now verifies, and every nested statement must pass, so a wrapper cannot launder a failed attestation behind one that succeeds. The certificate chains an attestation carries are also bounded by count, not only by size.

### Added

- pki.webauthn.verify verifies the compound attestation format, which it previously refused as unsupported. Every nested statement must verify for the attestation to verify. The specification leaves the threshold to relying-party policy, and this is the fail-closed reading of it. The result reports attestation type Compound and carries each element's own verdict, attestation type and certificate chain in order, so a caller applies its own policy to the parts. A merged verdict could overstate or understate any of them. The combined trust path is empty by construction: several elements produce several independent chains, and presenting them as one ordered path would misrepresent what was validated.
- The nested statements are held to the format's own syntax: at least two of them, each exactly a format identifier and a statement, each identifier matched case-sensitively against the supported set, and none of them compound; the specification spells that exclusion out, so nesting is impossible by construction and needs no depth counter. Each format now declares which CBOR shape its statement takes, where the shape used to be fixed for all of them, so accommodating the array-shaped compound statement leaves every other format's contract unchanged, and a compound presented in the older map shape is refused.

### Fixed

- The number of certificates an attestation may carry is now bounded. Both the attestation statement's certificate array and a JSON Web Signature certificate header capped the size of each certificate but not how many there were, so a statement could present thousands of small certificates and each one cost a parse and, downstream, a full path validation, work far out of proportion to the bytes on the wire. A single bound now covers every place a chain arrives, set well above any real attestation chain.

## v0.4.8 — 2026-08-08

A stored android-safetynet WebAuthn attestation can be re-verified in full (the signature, the registration binding, and the certificate chain) behind an opt-in and against a root the caller supplies.

### Added

- pki.webauthn.verify verifies the android-safetynet attestation format, which it previously refused as unsupported. Enable it with opts.verifySafetyNetJws and supply the Google root(s) to anchor the chain to as opts.safetyNetRoots. Both are required; with either missing the call is refused and never falls back to a weaker check. The format is off by default and this library bundles no root, because the service that produced these statements is retired and choosing a trust anchor on a caller's behalf is not this library's decision to make. A caller who does not enable it sees the same result as before.
- Every binding the specification states is checked, and each failure names which one: the response must be a three-part JWS whose algorithm is RS256, its signature must verify under the certificate in its own header, its nonce must match this registration's authenticator data and client data, the certificate must be issued to attest.android.com, and the chain must validate to one of the supplied roots. The algorithm is pinned and never read from the token, so a statement cannot select its own verification algorithm. The hostname is matched exactly against the certificate's subject alternative name, falling back to its common name only when it carries no alternative name at all; a name merely ending in attest.android.com does not pass. The chain goes through full path validation, so an expired or otherwise non-conforming certificate cannot pass on a signature alone. On success the result reports attestation type Basic with the embedded chain as its trust path.
- Device-integrity signals in the response (whether the device passed the compatibility test suite, the reported timestamp, the requesting package) are deliberately not gated on, because the specification does not make them part of attestation verification. They remain relying-party policy.

## v0.4.7 — 2026-08-08

One certificate now renders one distinguished-name string whichever parser read it. A C509 certificate's subject and issuer strings used to join their components without the separating space every other parser in the toolkit uses.

### Fixed

- A C509 certificate's subject and issuer strings now use the same spelling as every other parser in the toolkit: components separated by a comma and a space, which is what openssl prints for the same certificate. Previously the C509 parser omitted the space, so one certificate had two different rendered names depending on the parser that read it, and code comparing or logging them saw a difference where there was none. The two renderers are now checked against each other on a multi-component name, so the divergence cannot return.
- Where these strings are built now states what they are: a display form listing the components in the certificate's own order, escaped so that a comma inside a value cannot read as a separator between components. It is not an LDAP distinguished name, which reverses the component order and omits the space, and must not be passed to a directory client as one. Name comparison never used these strings and still does not.

## v0.4.6 — 2026-08-08

A C509 certificate now has one encoding where the specification defines one: nine alternative spellings that rebuilt a byte-identical X.509 certificate, so that a single signature covered all of them, are refused, and the encoder emits the spelling it accepts.

### Fixed

- An attribute value now carries the one spelling the specification assigns it. A text value of even length drawn only from the characters 0-9 and a-f is a byte string; a value in EUI-64 form is a tagged MAC address, 48-bit when it matches the FF-FE marker pattern and 64-bit otherwise; anything else is text. Each alternative spelling rebuilt the identical certificate, so the one signature over it covered them all. An empty value spelled as an empty byte string is refused for the same reason: it renders as the empty text, which already has a spelling.
- A name holding a single common name is the bare value, an extensions field holding only a key usage is the single integer, and an alternative name holding exactly one DNS name is the bare text. Each of these compact forms is the encoding the specification defines for that case, so the long form of the same value (an array of one pair, an array of two, an array of one entry) is now refused. The long form remains the encoding for every case that is not the single one: a name with two attributes, a key usage beside another extension, an alternative name with two entries.
- A certificate whose issuer is identical to its subject encodes that issuer as the CBOR simple value null, which the specification requires and which this toolkit previously wrote out in full. Both directions changed: the encoder emits the null, and a certificate that spells the issuer out instead is refused. The comparison is made on the certificate's own bytes, because the reconstruction rebuilds a null issuer from the subject: two names that merely compare equal would rebuild different bytes and break the signature over them.
- Algorithm parameters must be a complete element. An empty byte string is none, and it rebuilt the same algorithm identifier as the form that omits parameters entirely, giving one algorithm two encodings.
- The encoder walks the same rules it enforces on the way in, so a certificate it emits is one it reads back. Previously it wrote an even-length-hex attribute value as text (a spelling its own parser now refuses) and wrote a self-signed certificate's issuer out in full.

### Known limitations

- One redundancy remains because the specification permits it: a registered algorithm may be encoded either as its registry integer or as its object identifier, and both are accepted, so a certificate using one is byte-different from the same certificate using the other. Identify a certificate by the X.509 bytes it reconstructs; its C509 bytes are not a stable identity.

## v0.4.5 — 2026-08-08

A private key created outside this toolkit's own WebCrypto now signs and exports across the toolkit. A key from the platform's WebCrypto, or from a separately-installed copy of this toolkit, used to reach the crypto library as a key it could not read and fail with a type error that gave no reason.

### Fixed

- Certificate, CRL, CSR, CMS, attribute-certificate, OCSP, CMP and CRMF signing, together with pki.key.export and pki.jose.sign, accept a private key created by the platform's WebCrypto or by a separately-installed copy of this toolkit. Previously only a key this toolkit's own engine created would work; any other reached the crypto library as a key with no material behind it and failed with a type error naming an internal property, giving a caller who had followed the documented contract nothing to act on. Signing, private-key export, public-key export and secret-key signing are all covered, across EC, Edwards and RSA keys.
- A key that cannot be reached is refused with the reason and the ways forward (import it through this toolkit's WebCrypto, or pass it as DER); it is no longer reported as an argument of the wrong type. That covers a key created non-extractable, which no implementation can export, and one belonging to an implementation that keeps its material behind its own interface. The non-extractable refusal holds on every path, including for a key whose handle this process could otherwise read directly, so the flag means the same thing wherever the key came from. A non-extractable key this toolkit created is unaffected and still signs, since it is used in place and never exported.
- A key's permitted usages travel with it. Re-importing is the one moment that restriction could be widened, since the new key is created with the usages the operation needs. Every needed usage must therefore be present on the original first. Asking a verify-only key to sign is refused, which is what this toolkit's own engine already did for its own keys; the two now agree.
- pki.webcrypto.subtle refuses a key created by a different WebCrypto implementation with a typed fault that names where it came from, and distinguishes it from an argument that is no key at all. The specification leaves cross-implementation use undefined; every operation that reads key material (sign, verify, encrypt, decrypt, key derivation and encapsulation, wrapping, and export) previously let a bare type error escape from inside the crypto library instead. The counterpart public key of a key-agreement operation, which travels in the algorithm rather than as the key argument, is checked on the same footing.

## v0.4.4 — 2026-08-08

pki.schema.c509 encodes and decodes the RFC 3779 resource-delegation extensions: a C509 certificate carrying IP address blocks or AS identifiers now parses at all, where before it was refused outright, and its addresses ride the compact form the specification defines.

### Added

- pki.schema.c509 encodes and decodes the RFC 3779 IPAddrBlocks and ASIdentifiers extensions in their compact value form, together with their RFC 8360 v2 twins, which the specification encodes identically. Previously these extensions had no registry entry, so a conformant C509 certificate carrying one was refused with no fallback, and a C509 resource certificate could not be read at all. An address family carries its address-family identifier and optional sub-identifier, and its addresses as either the delta-coded integer form or the byte-string form; the prefix length rides the unused-bit count, so a prefix ending in zero bits survives exactly. Both directions reproduce the worked example published in the specification's own appendix, byte for byte.
- The specification fixes which address form applies, and the sender has no say: the byte-string form applies to a whole address family as soon as any one of its addresses exceeds eight octets, and the integer form applies otherwise. The decoder enforces that, so a family that used the wrong form, or mixed the two, is refused, which keeps a certificate from having two valid encodings.
- The compact form is used only for a certificate already in the canonical order RFC 3779 requires, at both levels. Within an address family the entries must be sorted, non-overlapping, and with any two contiguous entries already combined into one; the same three rules apply to AS identifiers. Across families, each address family may appear only once and they must ascend by their identifying octets, with a family carrying no sub-identifier preceding the one sharing its identifier. A certificate breaking any of these keeps its original bytes, because compacting it would give one resource set a second encoding when it already has a canonical one, and because such a certificate is one an independent validator rejects, so re-encoding it would quietly turn a refused certificate into an accepted one. An address wider than its family allows, or one whose declared unused bits are not zero, is likewise refused. Every rule is enforced in both directions, so the two halves of the codec accept exactly the same certificates.

### Fixed

- A certificate whose version is not v3 is now refused, with the reason given. Both C509 certificate types are defined over X.509 v3 and the encoding carries no version field, so a v1 or v2 certificate is outside the format; it previously fell through to the byte-exactness self-check, whose verdict reads as a defect in the encoder and says nothing about a certificate the format does not cover. A v3 certificate whose extensions field is omitted was and remains fully supported; the specification encodes that as an empty array.

## v0.4.3 — 2026-08-08

pki.tls encodes and decodes RFC 8879 compressed certificate messages (the largest payload a TLS handshake carries, and the one post-quantum chains grow by kilobytes) with the two-sided decompression bound the specification requires. Alongside it, SHAKE128 and SHAKE256 join the digest surface, which brings the Ed448 composite signature arm into service.

### Added

- pki.tls.decompressCertificate and pki.tls.compressCertificate encode and decode an RFC 8879 CompressedCertificate. Decompression applies the bound RFC 8879 sec. 5 requires on both sides: the decompressor is capped at the message's own declared uncompressed length, so a decompression bomb is refused as its output would exceed that declaration, before the memory is committed, and the recovered length must then equal the declaration exactly. A caller's own cap applies independently and can only tighten the limit, never raise it. An algorithm outside the registry, one this runtime cannot decompress, or one absent from the caller's advertised set is refused before any decompressor runs; an empty compressed body is a framing violation; and trailing bytes are refused, so one chain has exactly one encoding. compressCertificate decodes its own output before returning it, so a message this toolkit produces cannot be one this toolkit refuses, and refuses to emit one whose own framing would exceed the handshake limit.
- All three registered compression algorithms are implemented, and each is offered only where the running Node can decompress it safely. A decompressor is required to fault on a frame it could not finish; where one instead returns a short result and reports the whole input as consumed, a peer could cut a frame's tail and have the receiver process a prefix as if it were the entire message. Any algorithm whose decompressor behaves that way is dropped at startup and is then neither advertised nor accepted; it is never offered with a truncation it cannot detect. On the current long-term-support Node this drops zstd, leaving zlib and brotli; it returns by itself on a runtime that reports the fault.
- pki.tls.parseCertificateMessage decodes the RFC 8446 sec. 4.4.2 Certificate message itself, surfacing each entry's certificate DER exactly as it arrived, ready for pki.schema.x509.parse and never re-serialized, alongside its raw extensions and the certificate request context. The certificate type is negotiated by a separate extension and is not present in the message, so it is declared through an option and never inferred from the bytes. The number of entries is bounded: a message's byte ceiling does not limit how many it declares, since the smallest legal entry is six bytes, so a message well inside the framing limit could otherwise declare hundreds of thousands and exhaust memory. The cap matches the longest chain the path validator will accept, and is exactly one under a negotiated RawPublicKey type, which RFC 8446 sec. 4.4.2 requires.
- pki.webcrypto.subtle.digest computes SHAKE128 and SHAKE256, at the 32- and 64-byte lengths RFC 8702 sec. 4 fixes for message-digest use. The length follows from the name and is not the caller's to choose, so a digest cannot be squeezed to a non-conforming width. The extendable-output functions are a digest route only: the signature, MAC and key-derivation operations continue to refuse them with the same typed error as before.
- The composite signature arm id-MLDSA87-Ed448-SHAKE256 now verifies and signs. It was registered and parameter-guarded but failed closed as unsupported because its SHAKE256 pre-hash was unavailable; it is now checked byte-for-byte against the composite specification's own known-answer certificate, and both components must pass for the signature to be accepted. Sixteen of the eighteen arms now verify; the two remaining are the brainpool-curve arms.

### Changed

- pki.cms.sign and pki.cms.verify compute their message digests through the crypto engine; neither holds a private digest table any more. Behavior is unchanged; the digest algorithms a signer and a verifier accept are now defined in one place.

### Fixed

- pki.cms.decompress refuses a stream carrying bytes after the end of the compressed data. A decompressor stops at the end of the first complete frame and ignores whatever follows, so arbitrary bytes, or a second entire frame, could be appended and the same content still recovered. That gave one content unboundedly many encodings, so a digest over the compressed object no longer identified what it decompressed to. The whole octet string must now be exactly one frame, which is what the DER layer already required of its own encodings.
- An unusable hash is now refused when a key is created, and no longer at its first use. pki.webcrypto.subtle.importKey and generateKey recorded the requested hash without resolving it, so a name this engine cannot use produced a CryptoKey that failed only at its first sign, verify or wrap, after the caller had already paid for the key generation. The name is now resolved at the entry point, through the same table the operations use, so what a key can be created with and what it can be used with cannot diverge.

## v0.4.2 — 2026-08-08

A NumericString attribute value no longer shares distinguished-name identity with a printable or UTF-8 value of the same characters. That comparison decides name chaining, revocation-issuer matching and name constraints. Alongside it, several C509 name-encoding conformance fixes and a move to Node 24.19.0.

### Changed

- The supported Node floor moves to 24.19.0, the current long-term-support release. Nothing is transpiled, so the supported version is the version the source runs on; the release is verified against that runtime.
- pki.asn1.read.numericString reads a NumericString value, validated strictly to the digits and space the type permits. The shared string reader no longer accepts the type, so a caller that wants it asks for it by name.

### Fixed

- A NumericString attribute value no longer compares equal to a PrintableString or UTF8String attribute value carrying the same characters. RFC 5280 sec. 7.1 name comparison folds the directory-string types into one identity class, and NumericString is not one of them; because the previous release read it through the shared string reader, it entered that class and was treated as the same name by the comparison that decides certificate chaining, revocation-issuer matching and name-constraint evaluation. It now reads through its own reader and, as before, renders in the RFC 4514 hexadecimal form.
- A natively signed C509 certificate is no longer accepted with, or built carrying, a negative attribute-type integer. The sign of that integer exists only to reproduce the string type of an original X.509 encoding, which a natively signed certificate does not have, so all of its integers are non-negative (draft-ietf-cose-cbor-encoded-cert-20 sec. 3.1.4); the toolkit previously read such a certificate and could also emit one that a conformant implementation must reject.
- A country name or serial number attribute now keeps the string type its attribute integer's sign declares, and its restriction to the printable-string character subset is enforced on the characters instead. Both signs previously rebuilt the same certificate bytes, so two distinct compact encodings of one value produced one identical certificate under a single signature.
- The rendered distinguished-name string now escapes its values (RFC 4514 sec. 2.4), so an attribute value containing a comma can no longer read as though the name held several attributes, and a control byte can no longer reach a log line unescaped.
- An empty issuer name is now refused (RFC 5280 sec. 4.1.2.4 requires a non-empty issuer). It previously parsed and rebuilt a certificate that this toolkit's own certificate parser declines to load. An empty subject is still accepted; the profile pairs that with a subject alternative name, which this codec does not yet require.

## v0.4.1 — 2026-08-08

pki.schema.c509 encodes and decodes the compact subjectDirectoryAttributes value form: a C509 certificate's subject directory attributes ride their draft-20 registry integers (or unwrapped OIDs) with their directory-string values, so a conformant C509 implementation reads them.

### Added

- pki.schema.c509 encodes and decodes the compact value form for the subjectDirectoryAttributes extension (draft-ietf-cose-cbor-encoded-cert-20 sec. 3.3): a flat array of (attribute type, attribute values) pairs where each type is a sec. 8.6 registry integer (the sign selecting the directory-string type) or an unwrapped OID, and each values slot is a non-empty array holding the attribute's SET of one or more values: the string values for a registry-integer type, or the raw DER attribute values for an unwrapped-OID type. Both directions invert to the DER extnValue byte-for-byte, so a certificate carrying the extension is the specific compact shape a conformant C509 implementation reads, and an opaque DER byte string is no longer emitted for it. The encoder is guarded per attribute and per extension: an attribute whose value is not a directory string, or whose value SET mixes string types, uses the unwrapped-OID form for that attribute (keeping the rest of the extension compact); a value the compact form cannot represent falls the whole extension back to the unwrapped-OID byte-string form, where it survives byte-for-byte; and a malformed compact value fails closed with a typed C509Error.
- The OID registry gains the subjectDirectoryAttributes certificate-extension identifier (2.5.29.9), resolvable through pki.oid.byName / pki.oid.name.
- The ASN.1 codec reads NumericString (pki.asn1.TAGS.NUMERIC_STRING), the X.520 syntax of the x121Address and internationalISDNNumber directory attributes. It is validated strictly like every other string type: a value outside the digits-and-space set the type permits is rejected as malformed.

### Changed

- A C509 certificate carrying subjectDirectoryAttributes now encodes to, and decodes from, its compact CBOR value shape; an earlier release emitted the unwrapped-OID byte-string form. Both reconstruct the same DER, but the CBOR bytes differ, so re-encode any C509 produced by an earlier release.

## v0.4.0 — 2026-08-06

CRL issuance and verification, PKCS#12 build and open, attribute-certificate issuance, and the key-material lifecycle graduate to stable, and pki.schema.c509 adds the compact policyMappings and policyConstraints value forms. A C509 certificate's policy mappings and policy constraints now ride their specific draft-20 CBOR shape, readable by any conformant C509 implementation.

### Added

- pki.schema.c509 encodes and decodes the compact value forms for the policyMappings and policyConstraints extensions (draft-ietf-cose-cbor-encoded-cert-20 sec. 3.3): a policy mapping's issuerDomainPolicy and subjectDomainPolicy each ride the sec. 8.9 registry-integer / unwrapped-OID policy space the certificatePolicies extension uses (a policy mapping to or from the special anyPolicy is preserved, matching what a certificate's own decoder accepts), and a policy constraints value rides the fixed two-element [requireExplicitPolicy, inhibitPolicyMapping] array, each field a non-negative skip count or absent. Both directions invert to the DER extnValue byte-for-byte, so a certificate carrying either extension is the specific compact shape a conformant C509 implementation reads; an opaque DER byte string is no longer emitted. The encoder is guarded: a value the compact form cannot hold falls the whole extension back to the unwrapped-OID byte-string form, preserving it exactly; a malformed compact value (an empty or both-absent policy constraints, an odd-length or empty policy-mappings array, a policy mapping member that is not a two-policy pair, or an unregistered policy integer) fails closed with a typed C509Error.

### Changed

- pki.crl.sign / verify / isRevoked (RFC 5280 sec. 5), pki.pkcs12.build / open / verifyMac (RFC 7292 / RFC 9579), pki.attrcert.sign (RFC 5755), and the key-material lifecycle pki.key.encrypt / decrypt / export / import / generate / publicFromPrivate (PKCS#8 / RFC 5958, RFC 8018) graduate from experimental to stable. Their governing standards are settled and each is proven against an independent implementation in the integration harness (OpenSSL), or for the attribute-certificate format through the toolkit's own conformance-vector round-trip plus coverage-guided fuzzing. They are now covered by the stability contract: a breaking change to any of them ships only after a prior deprecation cycle, never silently in a minor (the published LTS support window itself takes effect at v1.0).
- A C509 certificate carrying policyMappings or policyConstraints now encodes to and decodes from its compact CBOR value shape; an earlier release emitted the unwrapped-OID byte-string form. Both reconstruct the same DER, but the CBOR bytes differ, so re-encode any C509 produced by an earlier release.

### Fixed

- The C509 encoder now bounds the basicConstraints path length and the inhibitAnyPolicy and policyConstraints skip counts to the same non-negative 31-bit range the toolkit's own certificate decoders enforce. A native C509 carrying one of these counts past that range now fails closed with a typed C509Error and no longer reconstructs a DER that an X.509 decoder, this toolkit's included, would then reject.

## v0.3.33 — 2026-08-06

pki.schema.c509 encodes and decodes the compact certificatePolicies value form. A C509 certificate's policy identifiers ride their draft-20 registry integers (or unwrapped OIDs) and their CPS-URI and UserNotice qualifiers ride the specific compact CBOR shape, so the certificate interoperates with a conformant C509 implementation.

### Added

- pki.schema.c509 encodes and decodes the compact value form for the certificatePolicies extension (draft-ietf-cose-cbor-encoded-cert-20 sec. 3.3): each policy identifier is a sec. 8.9 registry integer (the CA/Browser Forum validation levels, the RFC 3779 id-cp-ipAddr resource-certificate policies, and the GSMA SGP.22 id-rspRole roles), or an unwrapped OID for a policy outside the registry; each policy qualifier is a sec. 8.10 integer (id-qt-cps / id-qt-unotice) with its text, reconstructing the CPS pointer as a URI IA5String and the UserNotice as an explicit-text UTF8String. Both directions invert to the DER extnValue byte-for-byte, so a certificate carrying policies is the specific compact shape a conformant C509 implementation reads. The encoder is guarded: a UserNotice with a noticeRef, a non-UTF8String explicit text, or a policy-qualifier identifier outside the sec. 8.10 registry is not compact-representable and falls the whole extension back to the unwrapped-OID byte-string form with no lossy encoding; a malformed compact value (an empty UserNotice text, a control-byte CPS URI, an unregistered policy or qualifier integer) fails closed with a typed C509Error.
- The OID registry gains the CA/Browser Forum certificate-policy identifiers (domain-, organization-, individual-validated, ev-guidelines), the RFC 3779 id-cp-ipAddr-asNumber policies, the GSMA SGP.22 id-rspRole roles, and the RFC 5280 id-qt policy qualifiers (cps, unotice), resolvable through pki.oid.byName / pki.oid.name.

### Changed

- A C509 certificate carrying certificatePolicies now encodes to its compact CBOR value shape and decodes from it, where an earlier release emitted the unwrapped-OID byte-string form; both reconstruct the same DER, but the CBOR bytes differ, so re-encode any C509 produced by an earlier release.

## v0.3.32 — 2026-08-05

pki.schema.c509 encodes and decodes the compact general-name extension values: a C509 certificate's subjectAltName, issuer alternative name, name constraints, CRL distribution points, authority/subject information access, and the authority key identifier issuer form now ride their specific draft-20 CBOR shape, so a conformant C509 implementation can read them.

### Added

- pki.schema.c509 encodes and decodes the compact value forms for the general-name-bearing extensions defined by draft-ietf-cose-cbor-encoded-cert-20 sec. 3.3: subjectAltName and issuerAltName (a flat array of (general-name type, value) pairs, or the bare dNSName as CBOR text for a single host name), nameConstraints (the permitted and excluded GeneralSubtrees, with the RFC 9549 sec. 2.2 address-plus-prefix-length form for an iPAddress constraint), cRLDistributionPoints and freshestCRL (each distribution point's URI full name with optional reasons and CRL issuer, or a single bare URI as CBOR text), authorityInfoAccess and subjectInfoAccess (each access description as a sec. 8.11 registry integer or unwrapped OID plus its URI), and the authorityKeyIdentifier key-identifier / authority-cert-issuer / serial form. One shared GeneralNames codec drives them all, covering every general-name form the sec. 8.13 registry defines: rfc822Name, dNSName, directoryName, URI, iPAddress, registeredID, and otherName including the hardware-module-name (RFC 4108), SMTP UTF-8 mailbox (RFC 9598), and MAC-address specials. Each form inverts to the DER extnValue byte-for-byte in both directions, so a certificate carrying these extensions is the specific compact shape a conformant C509 implementation reads; it is no longer an opaque DER byte string. The encoder is guarded: it emits the compact form only when it decodes back to the exact DER value, so a general name outside the registry (an X.400 address, an EDI party name), a non-canonical value, or a value the compact form cannot hold falls the whole extension back to the unwrapped-OID byte-string form, so no partial or lossy value is encoded; a malformed compact value fails closed with a typed C509Error.
- The OID registry gains the id-on other-name type identifiers (hardware module name, SMTP UTF-8 mailbox, MAC address), the id-ad access-description methods (time stamping, CA repository, RPKI manifest / signed object / notify), and subject information access, resolvable through pki.oid.byName / pki.oid.name.

### Changed

- A C509 certificate carrying a general-name-bearing extension (subjectAltName, issuerAltName, nameConstraints, cRLDistributionPoints, freshestCRL, authorityInfoAccess, subjectInfoAccess, or the authorityKeyIdentifier issuer form) now encodes to, and decodes from, its compact CBOR value shape where an earlier release emitted the unwrapped-OID byte-string form; both reconstruct the same DER, but the CBOR bytes differ, so re-encode any C509 produced by an earlier release. A native C509 that carried a subjectAltName as a raw byte string under its extension integer, which is not a value form the draft defines, is now rejected.

## v0.3.31 — 2026-08-03

pki.schema.c509 encodes and decodes the compact per-extension value forms. A C509 certificate's keyUsage, basicConstraints, extended key usage, key identifiers, and other scalar extensions now ride in their specific draft-20 CBOR shape, which any conformant C509 implementation can read.

### Added

- pki.schema.c509 encodes and decodes the compact per-extension value forms defined by draft-ietf-cose-cbor-encoded-cert-20 sec. 3.3 for the common scalar extensions: subjectKeyIdentifier (the bare key id), keyUsage (a network-byte-order integer), basicConstraints (an integer: -2 for cA=false, -1 for cA=true with no path length, N for the path length), authorityKeyIdentifier (the bare key id, keyId-only form), extendedKeyUsage (an array of registry integers or unwrapped OIDs, the array omitted for a single purpose), inhibitAnyPolicy (an integer), OCSP No Check (CBOR null), and TLS Feature (an array of integers). Each form inverts to the DER extnValue byte-for-byte in both directions, so a C509 certificate carrying these extensions is the specific compact shape a conformant C509 implementation expects (draft-20 sec. 3.7 requires the specific form where one is defined), and no longer an opaque DER byte string. The encoder is guarded: it emits the compact form only when it decodes back to the exact DER value, so a non-canonical or unrepresentable extension value falls back to the unwrapped-OID byte-string form with no lossy encoding; a malformed compact value fails closed with a typed C509Error. The extended-key-usage integer shorthands cover the full draft-20 sec. 8.12 registry: the RFC 5280 purposes (serverAuth, clientAuth, codeSigning, emailProtection, timeStamping, ocspSigning, anyExtendedKeyUsage) plus the SSH (RFC 6187), Kerberos PKINIT (RFC 4556), CMC (RFC 6402), and Wi-SUN purposes; any purpose outside the registry encodes as an unwrapped OID.

### Changed

- pki.schema.c509's RDN-attribute (sec. 8.6) and extension (sec. 8.8) integer registries now match draft-ietf-cose-cbor-encoded-cert-20: authorityKeyIdentifier is extension integer 7, extendedKeyUsage 8, inhibitAnyPolicy 30, OCSP No Check 36, and TLS Feature 38; and the RDN attributes localityName, stateOrProvinceName, and streetAddress are integers 5, 6, and 7. A certificate carrying any of these attributes or extensions now encodes to, and decodes from, the draft-20 integers and compact value shapes, so it interoperates with a conformant C509 implementation; re-encode any C509 produced by an earlier release, whose integers and per-extension value shapes differ.

## v0.3.29 — 2026-08-02

The pki.acme client rounds out its RFC 8555 / RFC 9773 surface: pre-authorize an identifier with client.newAuthz, choose among alternate issuance chains in client.downloadCertificate, and schedule renewal from the CA's ARI window with client.renewalWindow.

### Added

- client.newAuthz(identifier) pre-authorizes a single identifier ahead of placing an order (RFC 8555 sec. 7.4.1): it POSTs the identifier to the directory's newAuthz resource (kid-signed) and returns { authorization, url } for the created authorization, normally pending, or an already-valid one when the CA has out-of-band authorization for the identifier. A wildcard identifier is refused before any request (pre-authorization of a wildcard is not defined), an unadvertised newAuthz resource fails closed, a 201 without a Location fails closed, and the returned authorization is validated to name exactly the identifier requested and to be a non-wildcard authorization the flow can proceed with. One that names a different identifier, is marked as a broader wildcard grant, or is in a terminal failed state is rejected.
- client.downloadCertificate(url, opts?) gains selectChain + maxAlternates to choose among the alternate issuance chains a CA offers (RFC 8555 sec. 7.4.2, RFC 8288 Link). Without a selector it returns the primary chain and now also alternates, the resolved URLs of every Link rel="alternate" the certificate response advertised. With selectChain, it evaluates the primary chain first, then each alternate in header order, and resolves to the first chain the predicate accepts (selectChain receives { certificate, chain, certificates }); none accepted fails closed. The alternate Link header is untrusted: it is parsed strictly against RFC 8288 (rel matched as a whole token, case-insensitively; a malformed header or a non-https target fails closed), the extra signed fetches are bounded by maxAlternates (default 8, over-budget fails closed), duplicate resolved URLs are de-duplicated, and an alternate whose end-entity certificate differs from the primary's is rejected; it does not substitute for the primary. Each alternate is fetched by the same POST-as-GET path, inheriting the media-type, size, and strict-chain-parse gates.
- client.renewalWindow(certDer, opts?) turns the CA's ARI renewal window into a scheduling decision (RFC 9773 sec. 4.2 / 4.3). It composes the unauthenticated renewalInfo GET and selects a uniform-random instant within the CA's suggested window, bounded by the certificate's own expiry so the chosen time is never after notAfter, so many clients do not renew at the same edge. An injectable random and clock make the decision deterministic and let a caller ask 'renew as of time T?'. It returns { suggestedWindow, selectedTime, renewNow, retryAfterSeconds, explanationURL }, where renewNow is set when the selected instant is already in the past. It refuses before any request for a certificate already past its notAfter (nothing to renew) or one the caller marks with replaced: true (already superseded), and retryAfterSeconds always carries a poll delay: the CA's Retry-After clamped to [60s, 24h], or a sensible default when the CA omits it. Pass a prior result back as opts.previous to reuse its selectedTime while the CA's window is unchanged (RFC 9773 sec. 4.2), so a client that refreshes ARI on each poll keeps one stable renewal instant and does not re-randomize it. The helper returns the decision as data; it never sleeps or schedules on a background timer.

### Changed

- client.renewalInfo(certDer) now refuses an already-expired certificate before issuing the unauthenticated RenewalInfo GET (RFC 9773 sec. 4.3: a client MUST NOT check a certificate's RenewalInfo after it has expired), throwing acme/certificate-expired, the same pre-fetch expiry gate renewalWindow applies.
- pki.acme.client now rejects a server-provided URL whose host is an IPv4-address form (hex, octal, decimal, or shorthand) that the WHATWG URL parser would coerce to a different, often loopback or internal, address. The account-signed JWS url (RFC 8555 sec. 6.4) must name the exact authority the request connects to, so such a URL can no longer steer an authenticated request to an unintended host (SSRF hardening).

## v0.3.28 — 2026-08-01

pki.est gains its remaining RFC 7030 network verbs: request a server-generated key pair with pki.est.serverkeygen, fetch the CA's CSR-attributes policy with pki.est.csrattrs, and authenticate with HTTP Digest as an alternative to HTTP Basic.

### Added

- pki.est.serverkeygen(baseUrl, csr, opts?) requests a server-generated key pair + certificate (RFC 7030 sec. 4.4): it POSTs the CSR (application/pkcs10, identical encoding to simpleenroll) to /.well-known/est/serverkeygen and returns { certificates, privateKey } for a cleartext PKCS#8 PrivateKeyInfo key part, { certificates, encryptedKey } for a CMS EnvelopedData key part (surfaced structurally, never decrypted), or { retry, retryAfterSeconds, retryAfterDate } on a 202. The certificates are returned raw (no leaf is selected: the CA generated the key, so the issued certificate carries the generated public key; the CSR key was a throwaway). A cleartext key is bound to its certificate before it is surfaced: the delivered private key's public half must match exactly one returned certificate, so a key mis-associated with the returned certificate set is refused. Whether the key part must be encrypted, and to which recipient, is derived from the CSR's DecryptKeyIdentifier (a symmetric key-encryption key) or AsymmetricDecryptKeyIdentifier (an asymmetric key) attribute; the recipient mechanism is preserved and required to match the RecipientInfo arm, so a symmetric KEK recipient never satisfies an advertised asymmetric key (or vice versa) merely because the identifier bytes coincide. An opts value that contradicts the CSR is refused, and a cleartext key delivered where the CSR requested encryption is refused, as is an encrypted key delivered where the CSR advertised no decryption key to open it (an unusable, unsolicited credential). The delivered key's channel must negotiate a confidentiality-bearing cipher (a NULL / anonymous / EXPORT suite is refused). https-only, explicit-anchor, and the full redirect / auth / budget machinery of the enrollment verbs apply.
- pki.est.csrattrs(baseUrl, opts?) fetches the CA's CSR-attributes policy (RFC 7030 sec. 4.5, RFC 9908): a 200 application/csrattrs body is parsed and returned as { available: true, attrs, plan }, where plan is the enroll-attribute plan the caller builds its next CSR from; a 204 or 404 is { available: false } (a valid "CSR Attributes Response not available"); an empty CsrAttrs is a complete empty policy. The verb never applies attributes to a CSR itself. Server authentication is not required for this policy GET, but a 401 is honored so the auth path stays available.
- HTTP Digest access authentication (RFC 7616) is available on every EST verb as an alternative to HTTP Basic via opts.auth = { scheme: "digest", username, password }. SHA-256 and SHA-512-256 are supported; MD5 and MD5-sess are refused unless opts.auth.allowMD5 is set, and a legacy no-qop (RFC 2069) challenge is refused unless opts.auth.allowLegacyQop is set. Among multiple offered challenges the most secure usable one is chosen. A challenge this client cannot answer under its policy (an unsupported or disallowed algorithm, or a qop mode it does not accept) never shadows a lower-ranked usable one, so the client authenticates on an offer it can actually answer; when no offer is usable it fails closed and names the specific reason, with no downgrade to a weaker scheme. A server stale=true re-challenge is answered under a bounded opts.auth.maxStaleRetries budget. The response is scoped to the challenge's protection space: within an origin it is preemptively reused only for the URIs the challenge's domain directive covers (a quoted directive; a malformed unquoted one is refused and never silently widened; an absolute-URI entry is matched by its authority as well as its path, so an entry naming a different host never widens the scope to this one; the query is kept in the comparison, so an entry scoped to one query does not cover a different one). A same-origin resource outside that scope is authenticated afresh under a bound, classified by whether the request actually carried a credential, so a resource sent unauthenticated is never mistaken for a rejected one; it is neither sent the first realm's response nor blocked outright. A non-ASCII username under a charset=UTF-8 challenge is carried in the RFC 5987 username* extended form (percent-encoded UTF-8). The legacy quoted field would reach a server as ISO-8859-1. A non-ASCII realm's incoming header octets are hashed unchanged, so a UTF-8 realm contributes the same octets the server used and the response matches an RFC 7616 server. As with Basic, the credential is answered only on the origin the caller authenticated to: never sent to a redirected origin, and never as a silent Basic downgrade of a Digest challenge.

## v0.3.27 — 2026-07-31

pki.cmp.session drives a full CMP certificate enrollment end to end: build, transfer, and verify every leg of an ir/cr/kur/p10cr exchange, with every response protection-checked before its body is read.

### Added

- pki.cmp.session(opts) returns a stateful CMP enrollment session; session.enroll(request) drives an RFC 9810 ir / cr / kur / p10cr exchange to completion and returns a terminal verdict { outcome, certificate, chain, status, trusted, confirmed, implicitConfirm, transactionID, polls, transcript }. Every response is protection-verified (pki.cmp.verify) and bound to the transaction by transactionID and a fresh-senderNonce / echoed-recipNonce chain before its body is read, and is advanced only if its signer is trusted (chains to a supplied anchor, or the shared secret matches); a waiting status is polled under a bounded pollReq/pollRep loop; a grant is confirmed by a certConf/pkiConf handshake unless implicit confirmation was granted. opts.key + opts.cert select signature protection (with opts.trustAnchors REQUIRED to authenticate the CA's response signer, opts.intermediates an extra chain pool) or opts.mac selects PBMAC1; opts.transport / opts.tls / opts.timeout / opts.maxResponseBytes configure transfer; opts.maxPolls / opts.maxTotalWait / opts.sleep bound the poll loop; opts.sender / opts.recipient / opts.implicitConfirm / opts.extraCerts tune the request. opts.acceptCert is an acceptance policy consulted before confirmation: it can inspect and veto a grantedWithMods certificate the CA changed, sending a rejecting certConf and returning outcome rejected with the certificate still surfaced. A MAC-protected ir / cr / kur must carry the requested key's private half as the request-arm key so the CRMF proof of possession is signed (a signature session reuses its protection key). A verified rejection or error, or an exhausted poll budget, is a terminal outcome; a tampered, unverifiable, untrusted, or nonce-mismatched response is a typed CmpError throw. RFC 9810 sec. 5.1.1 / 5.2.3 / 5.3.4 / 5.3.18 / 5.3.22, RFC 9811, RFC 9483.

## v0.3.26 — 2026-07-30

pki.cmp.verify checks the protection on an incoming CMP PKIMessage. It verifies a signature or PBMAC1 MAC over the exact protected bytes, and can chain the signer certificate to a trust anchor.

### Added

- pki.cmp.verify(message, opts) verifies the protection on an incoming CMP PKIMessage: a DER Buffer, a PEM CMP block, or an already-parsed pki.schema.cmp.parse result (the protection is always recomputed from the parser's raw header/body wire slices, so a mutated display field on a parsed object cannot desync the crypto). Signature protection is verified through the shared certification-path signature engine; PBMAC1 protection is recomputed from opts.sharedSecret and compared in constant time. opts.signerCert / opts.trustAnchors / opts.intermediates / opts.time drive signer-certificate resolution (opts.signerCert, the message senderKID, or RFC 9483 extraCerts[0]) and full out-of-path certificate validation; opts.transactionID / opts.expectRecipNonce add opt-in response-echo checks; opts.maxIterations bounds the PBKDF2 work. The result is a fail-closed verdict { valid, trusted, protectionType, protectionAlg, signer, transactionID, senderNonce, recipNonce, header, body } carrying a typed cmp/* code on rejection; a malformed message or a flavor/credential mismatch throws a typed CmpError. RFC 9810 sec. 5.1.3, RFC 9481 sec. 3 / 6.1.2, RFC 9579, RFC 9483 sec. 3.1 / 3.2 / 3.3.

### Fixed

- The EST and ACME clients (pki.est / pki.acme) now reset the origin-specific tls.servername (SNI) on a cross-origin redirect / request even when no mTLS client certificate is set, so the trusted host's SNI is never sent to a different origin. A caller's tls.checkServerIdentity pin is retained across the origin boundary and re-evaluated against the redirected host, so a certificate / SPKI pin keeps applying and is never silently bypassed by dropping the callback.

## v0.3.25 — 2026-07-27

pki.path.build can now fetch a missing intermediate over the network: opt in with `fetchAia` and it discovers the issuer from a certificate's AIA caIssuers URL, so a chain with a gap in the supplied pool still builds.

### Added

- pki.path.build accepts opts.fetchAia: true to discover a missing intermediate from a certificate's AIA caIssuers URL (RFC 5280 sec. 4.2.2.1) over pki.transport, explored as a lazy fallback only after the local candidate pool is exhausted (RFC 4158 sec. 7.2 local-before-remote), so a build the pool can complete never touches the network. The result gains aiaFetches (the count of network GETs). opts.transport injects the transport for offline use; opts.tls carries the TLS trust for the AIA host (distinct from opts.trustAnchors); opts.maxAiaFetches / opts.maxAiaPerCert / opts.aiaTimeout / opts.maxResponseBytes bound the fetch. Off by default; the default build is byte-identical offline. RFC 5280 sec. 4.2.2.1, RFC 4158 sec. 6.3 / sec. 8.1.
- pki.inspect renders the authorityInfoAccess extension (RFC 5280 sec. 4.2.2.1) as the CA Issuers and OCSP access descriptions with their URLs, where it used to print a hex dump.

### Changed

- The AIA fetch is fail-closed and SSRF-bounded: an http/ldap/ftp/file/mailto or non-URI caIssuers accessLocation, or an id-ad-ocsp access method, is never fetched (no socket); a destination that is, or that resolves to, a private, loopback, or link-local address is refused (the checked address pinned for the connection), so an untrusted certificate cannot drive an authenticated GET to an internal service by IP literal or by hostname; a total fetch budget silently caps fetching (never a throw that denies a buildable path); a per-certificate URL cap (maxAiaPerCert:0 disables per-certificate fetching outright), a build-wide URL dedupe on the normalized URL, a response-size cap, and a per-response certificate-count cap bound the work; no redirect is followed. Every fetch fault (a transport error, a non-200, an oversize or non-certificate body) is a silent skip, so an unreachable or hostile AIA endpoint never fails a build that the pool could still complete.

## v0.3.24 — 2026-07-26

pki.smime.verify / decrypt can now recognize a legacy (RFC 8551) header-protected message. Opt in with `legacyHeaderProtection` and the real headers of an older `message/rfc822`-wrapped message are surfaced, safely separated from the authenticated header set.

### Added

- pki.smime.verify and pki.smime.decrypt accept opts.legacyHeaderProtection: true to detect a legacy RFC 8551 header-protected message (RFC 9788 sec. 4.10): a signed or encrypted payload that is a bare message/rfc822 wrap with no hp= parameter. On a precise match the inner message's Non-Structural headers are surfaced under headerProtection.legacy = { headers, mode, fromMismatch, confidential }, with the mode inferred from the envelope and an encrypted message's confidential set computed against the visible outer headers. RFC 9788 sec. 4.10.1 / sec. 4.10.2, RFC 8551.
- headerProtection.legacy is null on every verify / decrypt result unless a legacy message was detected via legacyHeaderProtection. The inferred set is intentionally kept separate from the authenticated protectedHeaders (which stays null) and from present (which stays false), since a legacy message is indistinguishable from a forwarded message/rfc822. A consumer keying trust off present/protectedHeaders is therefore never misled by the opt-in heuristic, and consuming headerProtection.legacy.headers is an explicit, fromMismatch-checkable choice.

### Changed

- Detection is opt-in and safe-by-default. Without legacyHeaderProtection the behavior is unchanged: a legacy-form message reads as protectedHeaders: null, present: false, legacy: null (never mis-authenticated). With the option set, a message that is not precisely identified (an ordinary forwarded message/rfc822 that fails a condition, an inner part that is itself a signed/encrypted layer, an inner part declaring hp=, or a part with a duplicate Content-Type) reports legacy: null. The signed-and-encrypted legacy form (RFC 9788 Appendix C.3.17) surfaces as clear via the caller's re-verify step, a documented limitation of the non-recursive layered API.

## v0.3.23 — 2026-07-26

pki.pkcs12.open now reads legacy PKCS#12 stores: it decrypts the RFC 7292 Appendix C 3DES and RC2 bags an `openssl pkcs12 -legacy` (and NSS) store uses, so an older .p12/.pfx now opens.

### Added

- pki.pkcs12.open decrypts the RFC 7292 Appendix C legacy PBE bags: pbeWithSHAAnd3-KeyTripleDES-CBC, pbeWithSHAAnd2-KeyTripleDES-CBC, pbeWithSHAAnd128BitRC2-CBC, and pbeWithSHAAnd40BitRC2-CBC. An `openssl pkcs12 -legacy` or NSS store, whose default key bag is 3DES and default cert bag is 40-bit RC2, now opens where before it was refused. The cipher key and IV are derived with the PKCS#12 Appendix B method over the BMPString+NULL password; a wrong password is caught by the MAC gate (or, for a MAC-less store, is the uniform pkcs12/decrypt-failed). The iteration count is DoS-capped before the key derivation runs. RFC 7292, RFC 2268, RFC 8018.
- An in-tree RFC 2268 RC2-CBC (lib/rc2.js) fills the gap left by OpenSSL 3.x moving RC2 to its legacy provider (Node's crypto can no longer decrypt it). It is own code with no new runtime dependency, pinned to the RFC 2268 Section 5 known-answer vectors and cross-checked against real OpenSSL -legacy RC2-40 and RC2-128 stores.

### Changed

- The legacy RC4 PBE schemes (pbeWithSHAAnd128BitRC4 / pbeWithSHAAnd40BitRC4) remain refused, now with a message that names the scheme and the remediation (re-export the store under AES-256-CBC or PBE-SHA1-3DES).

## v0.3.22 — 2026-07-26

S/MIME header protection ships: cover the message headers (Subject, From, To, ...) under the CMS signature or encryption with pki.smime, RFC 9788.

### Added

- pki.smime.sign / pki.smime.encrypt gain opts.protectHeaders (RFC 9788 header protection) with opts.headers, an object { Name: value } or an array [{ name, value }] of the Non-Structural fields to protect (Subject / From / To / Date / ...). Signed protection marks the payload hp="clear" and copies the fields to the outer display headers; encrypted protection marks it hp="cipher", inlines the real values inside the ciphertext, emits the Header-Confidentiality-Policy-processed outer copies, and embeds the authenticated HP-Outer records that document which fields were left visible outside. opts.hcp selects "hcp_baseline" (default: obscure Subject to [...], remove Comments/Keywords) or "hcp_no_confidentiality". The CMS crypto is unchanged, so any signer / recipient algorithm carries through. RFC 9788.
- pki.smime.verify / pki.smime.decrypt return protectedHeaders (the authenticated inner header set, or null when the message is not header-protected) and headerProtection { present, mode, fromMismatch, confidential }. The inner protected headers are surfaced distinctly from the untrusted outer headers, fromMismatch flags an outer From that disagrees with the protected one, and confidential lists the fields the composer kept end-to-end confidential, computed from the authenticated HP-Outer records so a caller can reply or forward without leaking them. A payload declaring hp that is malformed, carries an invalid value, or contradicts the cryptographic envelope (a signed message claiming hp="cipher") fails closed with smime/bad-header-protection; there is no silent downgrade. RFC 9788.
- Every MIME header field pki.smime emits routes through a fail-closed header-field guard: a CR / LF / NUL in a field value, or a field name outside RFC 5322 ftext, is rejected with smime/bad-header, so a caller-supplied Subject can never inject a Bcc header or split the message (CWE-93).

## v0.3.21 — 2026-07-26

A Certificate Transparency log-list live-fetch client ships. pki.ct.fetchLogList fetches and verifies the CT log list over HTTPS before trusting a single log.

### Added

- pki.ct.fetchLogList(opts) fetches the Certificate Transparency log list live and returns the trusted-log set only after the detached signature verifies against the caller-pinned distributor key. It GETs opts.url (the log_list.json) and the detached opts.sigUrl (the log_list.sig, by default opts.url with a .json path suffix rewritten to .sig) over the shared pki.transport (or an injected opts.transport), verifies pki.ct.verifyLogListSignature over the raw JSON bytes against opts.signerKey, and only on a valid signature ingests the same bytes through pki.ct.parseLogList, returning { logs, byLogId, version, timestamp, raw, status, contentType, tls }. No baked-in vendor URL and no baked-in key (both are caller-pinned); explicit TLS trust (an anchor set or an opts.tls.useSystemStore opt-in, rejectUnauthorized always on); each GET is size-capped before verify/parse; every fetch / verify / parse failure is a typed CtError. RFC 6962.
- pki.ct.parseLogList now also returns the document's version (a string or null) and timestamp (the parsed log_list_timestamp as a Date, or null when absent/unparseable). That is the freshness surface a caller polices, read leniently from the same document. Existing callers of the { logs, byLogId } shape are unaffected.

## v0.3.20 — 2026-07-26

PKCS#12 public-key privacy ships: encrypt a store's contents to a recipient public key with pki.pkcs12.build/open, plus a webcrypto RSA algorithm-name fix.

### Added

- PKCS#12 public-key privacy (RFC 7292 sec. 3.1): pki.pkcs12.build encrypts a SafeContents to recipient public keys, either through per-safeContents recipients: [{ cert }, ...] (certificate recipients only) with an optional contentEncryptionAlgorithm (aes-128|192|256-cbc, default 256; GCM/AEAD rejected), or through the opts.recipientCerts convenience that envelopes the cert + key. It emits an id-envelopedData ContentInfo via pki.cms.encrypt, so every certificate recipient type (RSA-OAEP, ECDH P-256/384/521, X25519, X448, ML-KEM) and multiple recipients per safe carry through. Privacy is independent of the integrity mode; combining a password (encrypt) and recipients on one safe is rejected, as is a non-certificate (password/KEK) recipient.
- pki.pkcs12.open gains opts.recipientKey (+ opts.recipientCert or recipientIndex) to decrypt an id-envelopedData safe via pki.cms.decrypt, after the MAC / SignedData integrity gate. The recipient key is a privacy credential only; it is never a MAC key, signer, or bag password. A wrong key, a tampered envelope, or a decrypt that yields non-SafeContents bytes all collapse to a uniform pkcs12/decrypt-failed (oracle-free); an enveloped safe with no recipientKey is pkcs12/no-recipient-key.

### Fixed

- pki.webcrypto now emits the WebCrypto-registered casing on a CryptoKey's algorithm.name for RSASSA-PKCS1-v1_5 (lowercase v), matching the standard and the mixed-case Ed25519 / Ed448 it already emitted, so the toolkit's own RSASSA-PKCS1-v1_5 CryptoKey can be passed as an x509 signer key. The x509 signer's algorithm-name match is now ASCII-case-folded as well (WebCrypto algorithm names are case-insensitive), so a CryptoKey from any source with equivalent casing is accepted.

## v0.3.19 — 2026-07-26

The CMP HTTP transfer client ships: pki.cmp.transfer carries a protected PKIMessage to a CMP endpoint over the shared node:https transport (RFC 9811).

### Added

- pki.cmp.transfer(url, message, opts) is the RFC 9811 HTTP transfer verb: POST a DER (or PEM) PKIMessage over the shared pki.transport and return the parsed response { response, responseBytes, status, contentType, tls }. The message is sent verbatim so its message-layer protection is preserved, and the response is classified fail-closed: HTTP 200 with an application/pkixcmp body is parsed; another 2xx is rejected (RFC 9811 requires 200); a 3xx is not followed; a 4xx/5xx carrying a CMP error PKIMessage forwards that integrity-protected verdict with the HTTP status surfaced as data, while a 4xx/5xx with no CMP body is an error. Protection is surfaced but not verified; the caller checks it. Composes pki.cmp.build + pki.schema.cmp.parse over pki.transport; the default transport is https-only and refuses an unpinned server.
- Use pki.cmp.wellKnownUrl(base, opts) to build an RFC 9811 sec. 3.4 /.well-known/cmp request-URI, optionally with a { label, operation } path. Each label/operation is a single safe path segment; a base carrying a query or fragment, or a segment containing a separator or dot-segment, is refused so the resource cannot be silently retargeted.

### Changed

- pki.x509.sign now accepts a subjectAltName iPAddress entry as a dotted-quad IPv4 or colon-hex IPv6 string (packed to its 4- or 16-octet network form internally) in addition to a pre-packed Buffer, matching the string ergonomics of dNSName and uniformResourceIdentifier. This applies to every GeneralName consumer (certificates, CRLs, CMP, attribute certificates).
- extendedKeyUsage and certificatePolicies now accept a raw dotted-decimal OID string directly, in addition to a registered purpose/policy name, so an unregistered KeyPurposeId or private policy OID (a BIMI VMC purpose, a document-signing EKU, a vendor-specific purpose) can be supplied inline without first calling pki.oid.register or hand-encoding the extension. A token that is neither a registered name nor a well-formed dotted OID still fails closed.

## v0.3.18 — 2026-07-25

The ACME client ships. pki.acme drives the full RFC 8555 certificate-issuance flow over the shared node:https transport.

### Added

- pki.acme.client(directoryUrl, opts) is a stateful RFC 8555 ACME client over the shared pki.transport. newAccount / newOrder / getOrder / getAuthorization / getChallenge / respondToChallenge / finalize / pollOrder / pollAuthorization / downloadCertificate drive the issuance flow; revokeCert (RFC 8555 sec. 7.6, account-key or certificate-key signed), keyChange (sec. 7.3.5 account key rotation), deactivateAccount / deactivateAuthorization, and renewalInfo (ARI, RFC 9773) round out the account and certificate lifecycle. Signs every request with the account key (opts.accountKey / accountJwk / alg); reads are POST-as-GET; a problem+json response surfaces as a typed acme/server-problem.
- Fail-closed transport defaults for the client: HTTPS is required for the directory URL and every server-returned URL (acme/insecure-url), the default transport rejects a connection with no explicit trust anchor unless tls.useSystemStore is set (acme/no-trust-anchors), each JWS carries a fresh single-use nonce with a bounded badNonce retry, the poll loop is bounded by maxPolls and a total-wait budget and sleeps on a Retry-After via an injectable sleeper, and every response body is capped (acme/response-too-large).

### Fixed

- The EST enrollment client now measures a string response body as UTF-8 (the width it is decoded at), so a non-ASCII body cannot undercount its byte length and slip past the response-size cap. The built-in node:https transport was unaffected (it returns raw bytes); this hardens a custom injected transport that returns string bodies.

## v0.3.17 — 2026-07-25

Refresh two development-only tooling dependencies to clear newly-disclosed advisories.

### Security

- Refresh the development/fuzzing-only brace-expansion (5.0.7 -> 5.0.8, GHSA-mh99-v99m-4gvg) and tar (7.5.19 -> 7.5.22, GHSA-r292-9mhp-454m) tooling dependencies to versions clear of two newly-disclosed advisories. Both are used only by the development and fuzzing harnesses and are never part of the published package (the toolkit has zero runtime dependencies), so installed contents are unchanged.

## v0.3.16 — 2026-07-25

The EST enrollment client ships: pki.est fetches CA certificates and enrolls certificates over the wire (RFC 7030), on a new shared node:https transport.

### Added

- pki.est.cacerts(baseUrl, opts?), pki.est.simpleenroll(baseUrl, csr, opts?), and pki.est.simplereenroll(baseUrl, csr, opts?) are the thin RFC 7030 client verbs over pki.transport. cacerts returns the raw, unordered CA certificate set; simpleenroll submits a PKCS#10 CSR (a DER Buffer or PEM) and returns the issued certificate (chosen by public-key match) with its chain; simplereenroll additionally requires opts.oldCert and enforces the byte-identical Subject + SubjectAltName re-enroll check before the request. A 202 is surfaced as { retry, retryAfterSeconds } and never slept on. Trust is fail-closed: an https URL and an explicit opts.tls.anchors (or opts.tls.useSystemStore) are required, HTTP Basic credentials are answered only after the server is authenticated and are dropped on a cross-origin redirect, and a downgrade, redirect loop, oversized response, or unmatched issued certificate each fails closed with a typed est/* error.
- The enrollment clients drive pki.transport, a shared, fail-closed node:https transport. pki.transport.https(defaults) returns a transport(request) -> Promise<{ status, headers, body }>. It is the toolkit's only socket: rejectUnauthorized is always on with no disable path, an explicit trust anchor or a system-store opt-in is required, TLS is floored at 1.2, the response body is bounded while it streams (LIMITS.HTTP_MAX_RESPONSE_BYTES, tightenable downward), and connect/read is bounded by a timeout. It carries no HTTP or protocol semantics (status, content-type, redirect, and authentication decisions live in the message layer), so it is reused verbatim across enrollment protocols. errors.TransportError is its default fault type; a protocol client may parameterize it to surface domain codes.

## v0.3.15 — 2026-07-24

PKCS#12 public-key integrity is produced and verified (RFC 7292 sec. 4).

### Added

- pki.pkcs12.build produces a public-key-integrity PKCS#12 store (RFC 7292 sec. 4). With opts.integrity { mode: 'public-key', signer: { cert, key, digestAlgorithm?, pss? } | signers: [ ... ], sid?, signingTime?, certificates? } it wraps the AuthenticatedSafe in a CMS SignedData whose id-data eContent is the byte-exact AuthenticatedSafe, signed by any pki.cms.sign signer algorithm (RSA PKCS#1 v1.5 / RSASSA-PSS, ECDSA, EdDSA, ML-DSA, SLH-DSA, composite ML-DSA), with no MacData. Combining opts.mac with public-key integrity, or building with no signer, is a config-time pkcs12/bad-integrity-mode or pkcs12/bad-input. Privacy is unchanged: opts.password still PBES2-encrypts the bags.
- pki.pkcs12.open verifies a public-key-integrity store. It runs pki.cms.verify over the store's CMS SignedData authSafe before decrypting any bag, the same integrity gate the MAC provides for password mode, and returns nothing on a failure (pkcs12/signature-invalid). The result bundle gains signers, the per-signer verdict [{ ok, sid, cert }] (null in password / MAC-less mode); the signer certificate is surfaced but never chained to a trust anchor; anchoring it is the caller's pki.path.validate step. opts.signerCerts supplies the signer certificate for a store built with certificates: false. The bags then decrypt under the caller password exactly as in password mode (privacy is independent of integrity); a wrong bag password is the uniform pkcs12/decrypt-failed.

## v0.3.14 — 2026-07-24

CMS AuthenticatedData is produced and verified (RFC 5652 sec. 9).

### Added

- pki.cms.authenticate(content, recipients, opts) produces a CMS AuthenticatedData (RFC 5652 sec. 9): cleartext content authenticated by an HMAC-SHA-256/384/512 MAC, with the fresh MAC key wrapped for each recipient through the same RecipientInfo model pki.cms.encrypt uses (key-transport RSAES-OAEP, key-agreement ECDH/X25519/X448, ML-KEM ori, password pwri, key-wrap kekri). By default it MACs the authenticated attributes (content-type + message-digest of the content) re-tagged to the EXPLICIT SET OF (sec. 9.2); opts.authenticatedAttributes false MACs the content octets directly (id-data only). opts.macAlgorithm selects the HMAC hash and opts.digestAlgorithm the message-digest hash. Returns a DER Buffer or, with opts.pem, a PEM string. RFC 5652 sec. 9, RFC 2104, RFC 4231.
- pki.cms.decrypt verifies a CMS AuthenticatedData. It recovers the MAC key through the matching RecipientInfo, recomputes the HMAC over the exact RFC 5652 section 9.2 preimage, and, when authenticated attributes are present, independently recomputes digest(content) and confirms it equals the message-digest attribute (section 9.3, do not trust the originator's digest), before releasing the content with authenticated true and macAlgorithm / digestAlgorithm in place of contentEncryptionAlgorithm. Every secret-dependent failure (a wrong recipient key, a forged or tampered MAC, a message-digest mismatch) collapses to the one uniform cms/decrypt-failed verdict, so a MAC failure is indistinguishable from a key-unwrap failure and leaks no unwrap-success bit. A weak or unknown macAlgorithm (HMAC-SHA-1) is refused with a distinct cms/unsupported-algorithm before any key step.

## v0.3.13 — 2026-07-24

CMS gains countersignatures and unsigned attributes (RFC 5652 sec. 11.4).

### Added

- pki.cms.countersign(cms, signers, opts) adds RFC 5652 section 11.4 countersignatures to a CMS SignedData. Each countersigner is the same { cert, key, digestAlgorithm?, pss? } descriptor pki.cms.sign takes (RSA, RSASSA-PSS, ECDSA, EdDSA, ML-DSA, SLH-DSA, or composite ML-DSA), and its signature covers the countersigned SignerInfo's signature octets under the section 11.4 preimage: message-digest bound to those octets, content-type omitted. opts.signerIndex selects which primary signer(s) to countersign (an index, an array, or 'all'), opts.countersignatureOf countersigns an existing countersignature (a nested countersignature), opts.signedAttributes false signs the target signature directly, and the countersigner certificate is embedded by default. The countersigned SignedData's original bytes are preserved so the primary signature verifies unchanged, and multiple countersignatures on one signer land as multiple values of the one id-countersignature attribute. Returns a DER Buffer or, with opts.pem, a PEM string. RFC 5652 sec. 11.4.
- pki.cms.verify surfaces countersignatures and unsigned attributes per signer. Each res.signers[i] carries countersignatures and unsignedAttrs. countersignatures is an array of per-countersignature verdicts { ok, sid, cert, digestAlgorithm, ... }, each verified over the exact RFC 5652 section 11.4 preimage and nested for a countersignature of a countersignature; unsignedAttrs holds the decoded unsigned attributes with their type names. Both are unauthenticated by definition (an unsigned attribute is outside the signature) and never change signers[i].ok or res.valid; a present-but-invalid countersignature is surfaced ok:false, never silently dropped.
- pki.cms.sign gains an unsignedAttributes option: an array of { type, values } unsigned attributes placed in each SignerInfo, outside the signature. It is the vehicle for attaching an RFC 3161 timestamp token (id-aa-timeStampToken) or another unsigned attribute at signing time. content-type, message-digest, and signing-time are rejected as unsigned attributes (RFC 5652 sec. 11), as is a duplicate attribute type.

## v0.3.12 — 2026-07-24

pki.pkcs12.open reads and decrypts a password-integrity PKCS#12 store (RFC 7292, RFC 9579).

### Added

- pki.pkcs12.open(pfx, password, opts) reads a password-integrity PKCS#12 store. It verifies the classic Appendix B HMAC or RFC 9579 PBMAC1 MAC before decrypting (RFC 7292 sec. 5.1), then PBES2-decrypts every privacy safe and pkcs8ShroudedKeyBag, returning { integrityMode, macVerified, keys, certs, crls, secrets }, with private keys as re-validated PKCS#8 DER, certificates/CRLs/secrets as raw DER, all carrying friendlyName and localKeyId, and nested safeContents recursively. A wrong password fails at the MAC gate (pkcs12/mac-mismatch); a MAC-less store is refused (pkcs12/no-integrity) unless opts.allowUnauthenticated is set; a public-key-integrity store and a legacy (non-PBES2) bag are refused; a post-MAC decrypt failure is the uniform pkcs12/decrypt-failed. opts.maxIterations lowers the KDF/MAC iteration cap for the call, and opts.keys 'crypto' imports each private key to a WebCrypto CryptoKey (opts.importAlgorithm for the ambiguous RSA/EC arms). pfx accepts a DER Buffer, PEM string, or a pki.schema.pkcs12.parse result. RFC 7292 sec. 5.1, RFC 9579, RFC 8018.

## v0.3.11 — 2026-07-23

pki.pkcs12 builds and MAC-verifies password-integrity PKCS#12 (.p12/.pfx) stores (RFC 7292, RFC 9579).

### Added

- pki.pkcs12.build(spec, opts) builds a password-integrity PKCS#12 store. spec is { key, cert, ca?, friendlyName?, localKeyId? } (one PBES2-encrypted cert safe plus one shrouded-key safe) or { safeContents: [...] }, where each element is a plaintext or PBES2-encrypted SafeContents of key / shroudedKey / cert / crl / secret / nested safeContents bags; keys and certs are validated before wrapping. opts.mac selects a classic Appendix B HMAC (default) or an RFC 9579 PBMAC1 over SHA-256/384/512, or false for a MAC-less store; opts.password is the shared privacy + integrity password; opts.pem returns a PEM PKCS12 string. Shrouded keys and cert safes are encrypted under RFC 8018 PBES2 (AES-128/192/256-CBC). Passwords are BMPString+NULL encoded for the classic MAC and UTF-8 for the PBES2 bags and PBMAC1, so the output opens in OpenSSL and NSS; the MAC covers the exact AuthenticatedSafe byte range, a DEFAULT-1 MacData iterations and a <=160-bit PBMAC1 digest are rejected, and the store is re-parsed before return. RFC 7292, RFC 9579, RFC 8018.
- pki.pkcs12.verifyMac(pfx, password, opts) verifies a password-integrity store's MAC. pfx is a pki.schema.pkcs12.parse result, a DER Buffer, or a PEM string; the password is BMPString+NULL (classic) or UTF-8 (PBMAC1) encoded, the MAC is recomputed over the store's exact AuthenticatedSafe byte range with the store's own MAC parameters, and constant-time-compared to the stored value. Returns true / false for the password match; throws Pkcs12Error on a MAC-less or public-key-integrity store, or an unsupported MAC algorithm. RFC 7292 sec. 5.1, RFC 9579.
- pki.asn1.build.bmpString(str) encodes a JS string as a universal BMPString (UTF-16BE) TLV, rejecting an unpaired surrogate code point.

## v0.3.10 — 2026-07-23

pki.key exports, imports, and PBES2-encrypts private keys (RFC 5958, RFC 8018) over every WebCrypto algorithm.

### Added

- pki.key.encrypt(privateKey, password, opts) encrypts a PKCS#8 private key (DER, PEM, or an extractable CryptoKey) into an RFC 5958 EncryptedPrivateKeyInfo under RFC 8018 PBES2. opts selects the cipher (aes-256-cbc default, aes-192-cbc, aes-128-cbc), the pseudorandom function (hmacWithSHA256 default, SHA-384, SHA-512, SHA-1), the iteration count (default 600000, bounded by the decryptor's cap), and the salt (16 random octets by default); opts.pem returns an ENCRYPTED PRIVATE KEY string. The plaintext is validated as a well-formed PKCS#8 structure before encryption and the output is re-parsed before return. A default pseudorandom function is omitted and keyLength is omitted, so the parameters are byte-exact with OpenSSL. RFC 5958 sec. 3, RFC 8018.
- pki.key.decrypt(encrypted, password, opts) decrypts an RFC 5958 EncryptedPrivateKeyInfo (DER or ENCRYPTED PRIVATE KEY PEM), returning the inner PrivateKeyInfo re-validated through pki.schema.pkcs8.parse. Only PBES2 with a PBKDF2 key-derivation function and an AES-CBC scheme is accepted. PBES1, PBMAC1, scrypt, and any other algorithm are refused. The salt and iteration count are bounded before any key derivation (opts.maxIterations lowers the cap for this call, never raises it), a malformed parameter set or wrong-length IV is a distinct typed error, and, because a MAC-less PBES2-CBC decrypt must not be a padding oracle (RFC 8018 sec. 8), a wrong password and a valid pad that is not a private key both surface the single uniform decrypt-failed. RFC 5958 sec. 3, RFC 8018.
- pki.key.export(key, opts) exports an extractable CryptoKey to DER or PEM: a private key as PKCS#8, a public key as SubjectPublicKeyInfo. The encoding is delegated to WebCrypto, so the algorithm-specific parameters are correct: RSA an explicit NULL, EC a named curve, Ed25519/Ed448/X25519/X448 parameters absent (RFC 8410 sec. 3). RFC 5958, RFC 5280 sec. 4.1.2.7.
- pki.key.import(input, opts) imports a DER or PEM PKCS#8 private key, SPKI public key, or (with opts.password) an ENCRYPTED PRIVATE KEY into a CryptoKey, auto-detecting the structure. The WebCrypto algorithm is inferred for the algorithms that name exactly one (Ed25519, Ed448, X25519, X448, ML-DSA, ML-KEM, SLH-DSA); RSA and EC are ambiguous between signing and key agreement, so opts.algorithm is required for them; import fails closed and never guesses a use.
- pki.key.generate(algorithm, opts) generates a key pair over the WebCrypto engine (RSA, ECDSA/ECDH, Ed25519/Ed448, X25519/X448, and the FIPS post-quantum ML-DSA and ML-KEM), with usages defaulting to the algorithm's natural set. pki.key.publicFromPrivate(privateKey) derives the SubjectPublicKeyInfo public key from a private key.
- pki.errors.KeyError is the typed error for the pki.key domain (key/bad-input, key/unsupported-algorithm, key/bad-algorithm-parameters, key/iteration-limit, key/bad-version, key/decrypt-failed).

## v0.3.9 — 2026-07-23

pki.crl builds, signs, and verifies X.509 certificate revocation lists (RFC 5280 sec. 5) over any registry algorithm.

### Added

- pki.crl.sign(spec, issuer, opts) builds and signs an X.509 CRL (RFC 5280 sec. 5): thisUpdate/nextUpdate, an optional crlNumber, a revoked list (each entry a serialNumber and revocationDate with an optional reason or invalidityDate), and an extensions object (authorityKeyIdentifier, issuingDistributionPoint, deltaCRLIndicator, freshestCRL, authorityInfoAccess) or an array of pre-encoded Extension DER. The issuer is a CA certificate + key, or an explicit name + public key + key. The signature algorithm is resolved from the issuer key, so RSA (PKCS#1 v1.5 / PSS via opts.pss), ECDSA, EdDSA, ML-DSA, SLH-DSA, and the composite arms all sign without a per-algorithm branch. The version is derived from the extension set (v2 when any CRL or entry extension is present, else v1), an empty revocation list omits the field, the reason code is an ENUMERATED, an invalidity date is always a GeneralizedTime, and per-extension criticality is fixed by the profile. Returns DER or a PEM X509 CRL; the produced signature is verified under the issuer key before return, and every emitted CRL round-trips through pki.schema.crl.parse and is accepted by OpenSSL across the classical and post-quantum arms. RFC 5280 sec. 5.
- pki.crl.verify(crl, issuer) verifies a CRL's signature over its exact tbsCertList bytes against the issuer public key (a { cert }, a { publicKey } SPKI DER, or a raw SPKI Buffer), composing the one path-validation signature engine pki.path.crlChecker uses (RFC 9814 algorithm-confusion and Edwards low-order-point gates included) and failing closed to false. It checks the signature only; issuer authorization, currency, and distribution-point scope remain pki.path.crlChecker.
- pki.crl.isRevoked(crl, serialNumber) returns the revoked-certificate entry a CRL lists for a serial number, or null when the serial is not listed.

## v0.3.8 — 2026-07-18

Human-readable inspection extends to CRLs, CSRs, and CMS messages, with a pki.schema.detectFormat companion.

### Added

- pki.inspect.crl / .csr / .cms render a certificate revocation list, a PKCS#10 certification request, and a CMS message as OpenSSL-familiar text reports (openssl crl -text / req -text / cms -cmsout -print), and pki.inspect.any detects the format of a DER/PEM input and routes it to the matching report. Each composes the certificate inspector's shipped field renderers, resolves every extension/attribute/algorithm/content-type OID through the registry (unknown -> dotted, undecodable value -> raw octets), and is best-effort: a malformed part hex-dumps without failing the report, and only entry-point coercion throws (inspect/bad-crl / inspect/bad-csr / inspect/bad-cms / inspect/unsupported-format). Cross-checked field-for-field against OpenSSL. RFC 5280 / RFC 2986 / RFC 5652.
- pki.schema.detectFormat(input) returns the registered PKI format name a DER Buffer or PEM string encodes (one of pki.schema.all()) without parsing it, or null when it matches no registered format. It is the detection half of pki.schema.parse, over the same authoritative format ordering.

## v0.3.7 — 2026-07-18

Certification path building arrives as pki.path.build, and pki.lint gains seven RFC 5280 extension-criticality and CA-scope lints.

### Added

- pki.path.build(leaf, opts) is the discovering complement of pki.path.validate: it finds the ordered certification path from a leaf up to a trust anchor over an untrusted pool of candidate CA certificates, then validates it. Candidates are matched by RFC 5280 name chaining, prioritized by the RFC 4158 heuristics (subjectKeyIdentifier/authorityKeyIdentifier match, anchor-adjacent issuer, CA and keyCertSign, validity), and searched depth-first with backtracking; every accept flows through pki.path.validate, so a name or key-identifier match is only an ordering hint and building never weakens a path-validation check. The search is bounded (a chain-length cap, a total-work cap on candidate expansions, and an identity-tuple visited-set) so a cross-certificate cycle or Bridge-CA fan-out terminates deterministically; the trust store accepts anchor tuples or self-signed root certificates, opts.validate:false returns the ordered path unvalidated, and the verdict is cross-checked against openssl verify. AIA caIssuers fetching is offline-only (supply fetched issuers in opts.candidates). RFC 4158 / RFC 5280.
- pki.lint.certificate flags seven RFC 5280 extension-criticality and CA-scope violations that parse but breach the certificate profile: basicConstraints (on a certificate-signing CA), nameConstraints, policyConstraints, and inhibitAnyPolicy must be marked critical (error); keyUsage should be critical (warn); nameConstraints must appear only in a CA certificate (error); and an end-entity certificate should carry a subjectKeyIdentifier (notice). The basicConstraints criticality rule applies only when the CA key validates certificate signatures (RFC 5280 4.2.1.9), so a CRL-signing-only CA carrying a non-critical basicConstraints is not falsely flagged.

## v0.3.6 — 2026-07-17

pki.cmp.build gains the CA/responder side. Certificate, revocation, key-recovery, general, error, poll, and confirmation responses complete the RFC 9810 message surface.

### Added

- pki.cmp.build message.body now accepts the CA/responder-side arms: ip / cp / kup / ccp (a CertRepMessage, holding caPubs plus a response of CertResponse entries, each carrying a PKIStatusInfo and, under a granting status, a certifiedKeyPair), rp (a RevRepContent), krp (a KeyRecRepContent), genp (a general response), error (an ErrorMsgContent), pollRep (a poll response), and pkiconf (the final confirmation). They reuse the request-side header, envelope, ProtectedPart, and signature / PBMAC1 protection, and round-trip through pki.schema.cmp.parse. The RFC 9810 section 5.3.4 rules are enforced (a certifiedKeyPair only under a granting status and never with a failInfo, a validated certificate CHOICE, a single-response ccp). The private-key-transport / KEM encrypted forms ride a pre-encoded escape hatch.

## v0.3.5 — 2026-07-17

pki.cmp.build assembles protected RFC 9810 CMP PKIMessages: certificate requests, confirmations, revocations, and general messages, protected by a sender-key signature or a PBMAC1 shared secret.

### Added

- pki.cmp.build(message, opts) assembles a protected RFC 9810 CMP PKIMessage. message.header carries the sender / recipient GeneralNames (including the anonymous NULL-DN) and optional transaction metadata; message.body is a single-key arm: ir / cr / kur (a CertReqMessages via pki.crmf.build), p10cr (a PKCS#10 CertificationRequest), certConf, pollReq, genm, or rr. Protection is exactly one of opts.{ key, cert } (a signature under the sender key, algorithm resolved from the certificate: RSA / ECDSA / EdDSA / ML-DSA / SLH-DSA / composite) or opts.mac (a PBMAC1 shared-secret HMAC, RFC 9481 / RFC 9579), computed over the exact ProtectedPart DER and self-verified before return. Returns DER, or a PEM CMP block with opts.pem; malformed input throws a typed CmpError. Message parsing remains pki.schema.cmp.parse.
- pki.crmf.buildCertTemplate(template) encodes a bare RFC 4211 CertTemplate (subject, public key, validity, requested extensions, version) to canonical DER. This is the certTemplate interior of pki.crmf.build, exposed for the CMP rr revocation body whose certDetails names the certificate to revoke.

## v0.3.4 — 2026-07-17

pki.schema.c509.encode produces C509 CBOR certificates. A DER X.509 certificate compresses to a compact, byte-exact-invertible type-3 C509, and a deterministic-CBOR encoder joins pki.cbor.

### Added

- pki.schema.c509.encode(input) encodes a C509 certificate to deterministic-CBOR bytes: a DER X.509 v3 certificate to a compact type-3 C509 (byte-exact-invertible, so the original signature verifies), or a pki.schema.c509.parse result re-emitted to its native array. Canonical deterministic CBOR with the registry integer shorthands and the C509 compressions; a certificate outside the invertible covered set throws a typed C509Error. Certificate parsing remains pki.schema.c509.parse.
- pki.cbor.build is a deterministic-CBOR encoder (RFC 8949 section 4.2), the byte-exact inverse of pki.cbor.decode: shortest-form heads, definite lengths, sorted and unique map keys, over unsigned and negative integers, byte and text strings, arrays, maps, tags, and the tagged bignum / epoch-time / object-identifier leaves. Encoded output always re-decodes through the strict decoder.

## v0.3.3 — 2026-07-17

pki.crmf.build issues RFC 4211 certificate request messages: a CertReqMessages with a signature proof of possession, over every algorithm the toolkit supports.

### Added

- pki.crmf.build(spec, key, opts) builds and DER-encodes an RFC 4211 CertReqMessages and returns DER, or a PEM block with opts.pem. The message carries a CertTemplate of the requested certificate fields plus a POPOSigningKey proof of possession signed with the requester's key, or a raVerified proof, opted into without a key. The signing algorithm is resolved from the requested public key: RSA PKCS#1 v1.5 / PSS, ECDSA, EdDSA, ML-DSA, SLH-DSA, or a composite arm. Requested v3 extensions, registration controls, and regInfo ride in the message; pass an array of specs for a batch. Certificate-request-message parsing remains pki.schema.crmf.parse.
- pki.asn1.build.implicit(tag, tlv) retags an encoded universal TLV as a context-class IMPLICIT [tag], preserving the source's primitive/constructed bit and content, so the CertTemplate and other context-tagged structures all compose one home for IMPLICIT tag replacement.

## v0.3.2 — 2026-07-17

pki.attrcert.sign issues RFC 5755 attribute certificates: an Attribute Authority binds a holder to privilege attributes and signs with any algorithm the toolkit supports.

### Added

- pki.attrcert.sign(spec, issuer, opts) builds and signs an RFC 5755 attribute certificate as an Attribute Authority, and returns DER, or a PEM ATTRIBUTE CERTIFICATE with opts.pem. The holder is exactly one of an entity name, a baseCertificateID, a fromCertificate binding derived from a public-key certificate, or an object digest; the attributes are the sec. 4.4 privilege syntaxes (role, clearance, group, chargingIdentity, accessIdentity, authenticationInfo) or pre-encoded Attribute DER; the extensions are auditIdentity, targetInformation, noRevAvail, aaControls, acProxying, and authorityKeyIdentifier or pre-encoded Extension DER, each carried with its RFC 5755 criticality. The AA signs with RSA PKCS#1 v1.5 / PSS, ECDSA, EdDSA, ML-DSA, SLH-DSA, or a composite arm, and the signature is verified under the AA public key before the certificate is returned. Attribute-certificate parsing remains pki.schema.attrcert.parse.

## v0.3.1 — 2026-07-17

pki.csr.sign issues PKCS#10 certification requests, self-signed by the subject key for proof of possession, over every signature algorithm the toolkit supports.

### Added

- pki.csr.sign(spec, key, opts) builds a PKCS#10 certification request and self-signs it with the subject's own key as proof of possession, returning DER, or a PEM CERTIFICATE REQUEST with opts.pem. The subject may be empty; requested v3 extensions (subject alternative names, key usage, extended key usage, basic constraints, certificate policies, subject key identifier, or pre-encoded Extension DER) ride in a PKCS#9 extensionRequest attribute a CA copies into the issued certificate, and an optional challengePassword is carried too. The signing algorithm is resolved from the subject key: RSA PKCS#1 v1.5 / PSS, ECDSA, EdDSA, ML-DSA, SLH-DSA, or a composite arm. Request parsing remains pki.schema.csr.parse.

### Fixed

- The certificate and certification-request distinguished-name and extension encoders now reject an unrecognized attribute, extended-key-usage purpose, or certificate-policy name at build time with a typed error, so no malformed object identifier is emitted. An unknown name in a pki.x509.sign or pki.csr.sign spec fails closed and produces no unparseable structure.

## v0.3.0 — 2026-07-17

pki.x509.sign issues self-signed and CA-signed X.509 certificates over every signature algorithm the toolkit supports, from RSA and ECDSA through EdDSA, ML-DSA, and SLH-DSA.

### Added

- pki.x509.sign(spec, issuer, opts) builds and signs an X.509 certificate and returns DER, or a PEM CERTIFICATE string with opts.pem. Pass a signing key alone for a self-signed certificate; pass an explicit issuer name and public key, or an issuing certificate, for a CA-signed one. The signing algorithm is resolved from the key: RSA PKCS#1 v1.5, RSASSA-PSS (opts.pss), ECDSA P-256/P-384/P-521, Ed25519, Ed448, ML-DSA-44/65/87, the twelve SLH-DSA parameter sets, and the composite arms. The version, serial-number bounds, UTCTime/GeneralizedTime cutover, DER default omissions, and the CA cross-field rules are enforced; malformed input throws a typed CertificateError. Certificate parsing remains pki.schema.x509.parse.
- pki.asn1.build.namedBitString(positions) encodes a minimal DER NamedBitList (X.690 section 11.2.2): the asserted bit positions with every trailing zero bit removed. The keyUsage and PKIFailureInfo encoders now compose it as their single canonical encoder.

### Security

- The Sigstore bundle verifier now routes an Ed25519 or Ed448 key through the shared full-order, on-curve Edwards-point gate at the raw signature-verification sink as well as at key parsing. A low-order or off-curve key that would verify a forged EdDSA signature is refused wherever a verify path handles one. This completes the defense across every EdDSA verification sink in the toolkit.

## v0.2.33 — 2026-07-17

Attribute certificates now decode their RFC 5755 attribute values and attribute-certificate extensions alongside the certificate structure.

### Added

- pki.schema.attrcert.parse decodes the RFC 5755 section 4.4 attribute values: role (RoleSyntax), clearance (Clearance, including the classification bit list and security categories), authenticationInfo and accessIdentity (SvceAuthInfo), and group and chargingIdentity (IetfAttrSyntax). It also decodes the section 4.3 attribute-certificate extensions: auditIdentity, targetInformation and proxyInfo (Targets, with targetCert reusing the IssuerSerial decoder), noRevAvail, and aaControls. Each is surfaced additively (a decoded field alongside the raw value) through the parse consumer path; an unrecognized attribute type or extension id is preserved opaque, and a malformed recognized value fails closed with a typed error. Every GeneralName inside these structures is validated through the shared name decoder.

## v0.2.32 — 2026-07-16

X.509 certificates now decode the Microsoft Active Directory Certificate Services enrollment extensions, and pki.lint's critical-extension check is aligned with certification-path validation.

### Added

- pki.schema.x509.parse decodes the Microsoft Active Directory Certificate Services enrollment extensions ([MS-WCCE] / [MS-CRTD]): the v2 certificate template (szOID-CERTIFICATE_TEMPLATE: template OID plus major/minor version), the legacy v1 template name (szOID-ENROLL_CERTTYPE_EXTENSION), the CA version (szOID-CERTSRV_CA_VERSION, surfaced as the raw value and as the CA key index / certificate index split), the previous-CA-certificate hash (szOID-CERTSRV_PREVIOUS_CERT_HASH), and the application policies (szOID-APPLICATION_CERT_POLICIES, decoded as RFC 5280 certificate policies). Each is rendered by pki.inspect and fails closed with a typed error on a malformed shape. The version fields accept the full 32-bit range Active Directory uses; a signed subset would be too narrow.

### Changed

- pki.lint's unrecognized-critical-extension check now mirrors certification-path validation: it reports a critical extension whose semantics the path validator does not process (an authority/subject key identifier, a freshest-CRL pointer, an SCT list, a qualified-certificate statement, or a Microsoft enterprise-CA extension). An extension it can merely decode no longer counts as processed. precertificatePoison, which RFC 6962 requires to be critical, is not reported. A conforming relying party must reject a critical extension it cannot process, so a certificate whose critical enterprise or qualified-certificate constraints this toolkit does not enforce is now surfaced by the linter.

## v0.2.31 — 2026-07-16

X.509 certificates now decode the RFC 3739 / ETSI EN 319 412-5 qualified-certificate qcStatements extension, and the sigstore verifier gains a low-order EdDSA public-key gate.

### Added

- The toolkit decodes the RFC 3739 sec. 3.2.6 / ETSI EN 319 412-5 qualified-certificate qcStatements extension (id-pe-qcStatements). The decoded statements are validated by the toolkit's certificate-extension decoders and rendered by pki.inspect: QcCompliance (EU-qualified), QcLimitValue (reliance limit), QcSSCD (key in a QSCD), QcType (certificate purpose: esign / eseal / web), QcRetentionPeriod, QcPDS (disclosure-statement URLs), QcCClegislation (country of qualification), QcIdentMethod, QcQSCDlegislation, and the PKIX SemanticsInformation. An unknown statementId is preserved opaque (its raw bytes, semantics not executed); a malformed shape fails closed with a typed error.

### Security

- The sigstore keyless-bundle verifier now routes an Ed25519/Ed448 Fulcio leaf or Rekor log public key through the shared full-order Edwards-point gate before verification. A low-order OKP key (which node imports without complaint and which can verify a forged EdDSA signature) is rejected at key-parse (sigstore/bad-key) instead of being handed to verify, the same defense the webauthn and certification-path EdDSA verifiers already apply.
- Certification-path validation rejects a critical qcStatements extension (RFC 5280 sec. 6.1.4). A critical qualified-certificate statement asserts semantics a relying party must enforce (a QcLimitValue reliance limit, a QcType certificate purpose), and the validator does not enforce them, so treating the extension as processed would let a caller rely on a certificate outside its asserted qualified-certificate constraints; it is left unprocessed and fails as an unrecognized critical extension. A non-critical qcStatements is informational and does not affect the verdict; the extension is still decoded for pki.inspect and lint, and the linter flags a critical qcStatements as an unrecognized critical extension for the same reason: structural decodability is not validation processing.

## v0.2.30 — 2026-07-16

C509 CBOR-encoded certificates arrive as pki.schema.c509.parse: decode the compact CBOR profile of X.509 in both its natively-signed and X.509-re-encoded forms, reconstructing the original DER byte-for-byte so the original signature still verifies.

### Added

- pki.schema.c509.parse(bytes) decodes a C509 CBOR-encoded certificate (draft-ietf-cose-cbor-encoded-cert) into structured, validated fields. It reads both certificate forms, natively-signed (c509CertificateType 2) and the CBOR re-encoding of a DER X.509 v3 certificate (type 3), over the strict deterministic-CBOR codec, fail-closed. For a type-3 certificate it reconstructs the original DER byte-for-byte (de-compressing the EC point, re-emitting each field as canonical DER, re-wrapping the ECDSA signature) so the original signature verifies and the certificate round-trips through pki.schema.x509.parse; a field it cannot invert byte-exactly fails closed. It decodes CBOR instead of DER, so it is an explicit-call surface and is not auto-routed by pki.schema.parse.

### Fixed

- Certification-path validation now rejects a trust anchor whose per-purpose distrust-after date is an invalid Date at input (path/bad-input). Previously a malformed date value passed the Date type check but compared as not-a-number, silently disabling the distrust-after restriction it was meant to enforce.

## v0.2.29 — 2026-07-16

Certificate Transparency log-list signature verification arrives as pki.ct.verifyLogListSignature: verify the detached signature published alongside the CT log list against a caller-pinned signer key, completing the offline log-list trust chain.

### Added

- pki.ct.verifyLogListSignature(json, signature, publicKey) verifies the detached signature over the Certificate Transparency log list (log_list.sig over log_list.json). The message is the raw json bytes, verified byte-for-byte with no canonicalization; publicKey is the caller-pinned signer SubjectPublicKeyInfo (there is no embedded key). The scheme is RSASSA-PKCS1-v1.5 with SHA-256 (an EC P-256 arm is accepted for future-proofing). It resolves true or false (a cryptographic verdict) and throws a typed CtError on a forgeable or unsupported key (an RSA exponent below 3, a sub-2048-bit RSA key, an unsupported key type or curve, a non-conformant ECDSA DER signature). Its verdict is cross-checked against OpenSSL's dgst -verify. Paired with pki.ct.parseLogList, this completes the offline CT log-list trust chain.

## v0.2.28 — 2026-07-16

The Certificate Transparency log-list trust surface arrives as pki.ct.parseLogList and pki.ct.verifySctWithLogList: resolve a trusted CT log's key from an SCT's log id and verify it in one step, ingesting the CT log-list JSON into state- and temporal-interval-constrained trusted logs.

### Added

- pki.ct.parseLogList(json, opts) ingests a Certificate Transparency log-list JSON document into { logs, byLogId }, constraint-carrying trusted logs keyed by log id. Each log's key is base64-decoded to its DER SubjectPublicKeyInfo and validated as on-profile; the log id is recomputed as SHA-256 of the key and must equal the stated log_id (RFC 6962 section 3.2, ct/log-id-mismatch), so a swapped key or a flipped id is refused. The state (exactly one of pending/qualified/usable/readonly/retired/rejected) and temporal_interval decode into trust constraints, and two entries for one recomputed id must agree or the list is rejected (ct/duplicate-log). Parsing is offline and routes through the bounded, duplicate-member-rejecting JSON reader; every malformed input is a typed CtError.
- pki.ct.verifySctWithLogList(entry, sct, logList, opts) resolves the trusted CT log for an SCT by its log id and verifies it in one step. The log's state gates trust (usable/qualified/readonly proceed; a retired log only for an SCT timestamped before its retirement; pending/rejected are ct/log-untrusted) and its temporal_interval gates the covered certificate (the cert's notAfter, from the leaf certificate for a precert-free entry or opts.certNotAfter, must fall in the log's window, and a windowed log with no resolvable notAfter is ct/temporal-interval, never silently skipped). The signature check is delegated to the shipped pki.ct.verifySct. Resolves true for a valid signature from a trusted, in-window log, false on a cryptographic mismatch, and throws a typed CtError on any structural or trust failure.

## v0.2.27 — 2026-07-16

CMS CompressedData arrives as pki.cms.compress / pki.cms.decompress, with the matching S/MIME pki.smime.compress / pki.smime.decompress: compress and recover RFC 3274 messages, with a bounded inflate that defends the decompression-bomb class.

### Added

- pki.cms.compress(content, opts) and pki.cms.decompress(input, opts) produce and consume a CMS CompressedData (RFC 3274): ZLIB (RFC 1950 / RFC 1951) compression, version 0, id-alg-zlibCompress with omitted parameters. opts.contentType sets the inner content type (default id-data), opts.level the DEFLATE level, opts.pem returns PEM. Decompress requires version 0, id-alg-zlibCompress with absent-or-NULL parameters, and a present encapsulated content; it bounds the decompressed output at C.LIMITS.COMPRESS_MAX_BYTES (16 MiB, tightened downward via opts.maxOutputBytes) and stops before the output is materialized, so a decompression bomb throws cms/decompress-too-large before memory is exhausted. Every malformed or truncated stream fails closed as cms/decompress-failed. Fail-closed with typed CmsError.
- pki.smime.compress(content, opts) and pki.smime.decompress(message, opts) assemble and open a compressed S/MIME message (RFC 8551 section 3.6): an opaque application/pkcs7-mime; smime-type=compressed-data; name=smime.p7z entity whose base64 body is a CMS CompressedData. The MIME entity is canonicalized before compression and recovered exactly; the recovered content (which may itself be a signed or enveloped S/MIME message) is returned for the caller to feed back to pki.smime.verify / pki.smime.decrypt. Compression is a size transform with no integrity, confidentiality, or authentication (RFC 8551 section 2.4.5). Receive-tolerant: OpenSSL's legacy application/x-pkcs7-mime and a missing smime-type are both accepted.

## v0.2.26 — 2026-07-16

S/MIME encryption arrives as pki.smime.encrypt / pki.smime.decrypt: envelope and open RFC 8551 encrypted messages over the CMS layer, with AES-GCM authenticated enveloping as the default and bidirectional OpenSSL interoperability.

### Added

- pki.smime.encrypt(content, recipients, opts) envelopes a MIME entity as an encrypted RFC 8551 S/MIME message (opaque application/pkcs7-mime). The default AES-256-GCM content encryption yields an authEnveloped-data message (confidentiality and integrity, RFC 8551 sec. 3.4); opts.contentEncryptionAlgorithm can select AES-CBC for an enveloped-data message (confidentiality only, RFC 8551 sec. 3.3). recipients is the pki.cms.encrypt recipient array (RSA-OAEP key transport, ECDH / X25519 / X448 key agreement, AES key wrap, PBKDF2 password, and post-quantum ML-KEM), and a single descriptor is accepted and normalized to a one-element array. content is wrapped as a text/plain entity by default or taken verbatim with opts.entity. Fail-closed with typed SmimeError.
- pki.smime.decrypt(message, keyMaterial, opts) opens an encrypted S/MIME message, returning the recovered inner MIME entity plus smimeType, authenticated (true only for an AuthEnvelopedData; a CBC enveloped-data message reports false, the RFC 8551 sec. 3.3 no-integrity caveat), recipientType, recipientIndex, and contentEncryptionAlgorithm. The smime-type is derived from the CMS body and never from the header; opts.strictSmimeType additionally rejects a header smime-type that disagrees with the body. keyMaterial is the pki.cms.decrypt key material ({ key, cert }, { password }, or { kek, kekId }). Decryption is fail-closed and oracle-free. OpenSSL's legacy application/x-pkcs7-mime and a missing smime-type are both accepted. A recovered content that is itself a signed S/MIME message is returned for the caller to feed back to pki.smime.verify. Bidirectionally interoperable with openssl cms -encrypt / -decrypt.

## v0.2.25 — 2026-07-16

S/MIME signed-message assembly and verification arrive as pki.smime: sign and verify RFC 8551 multipart/signed and application/pkcs7-mime messages over the CMS layer, bidirectionally interoperable with OpenSSL.

### Added

- pki.smime.sign(content, signers, opts) assembles a signed RFC 8551 S/MIME message. opts.form selects multipart/signed (default, clear-signed: the canonical entity in the first part and a detached CMS SignedData over its canonical form as an application/pkcs7-signature second part, with protocol="application/pkcs7-signature" and a matching micalg) or pkcs7-mime (opaque: one application/pkcs7-mime; smime-type=signed-data entity whose base64 body is an attached CMS SignedData). content is wrapped as a text/plain entity by default or taken verbatim with opts.entity. Any pki.cms.sign signer (RSA / RSASSA-PSS / ECDSA / EdDSA / ML-DSA / SLH-DSA) carries through. Fail-closed with typed SmimeError.
- pki.smime.verify(message, opts) unwraps and verifies a signed S/MIME message in both forms, recomputing the detached signature over the first part's RFC 8551 sec. 3.1.1 canonical form (the same canonicalizer the signer used, so a line-ending-mangling transport still verifies and a tampered part fails). It returns pki.cms.verify's { valid, signers } verdict plus the form, the recovered content, and the micalg; a micalg disagreeing with the actual digest is advisory unless opts.strictMicalg. Chaining a signer to a trust anchor is the caller's pki.path.validate step. Bidirectionally interoperable with openssl smime and openssl cms.

## v0.2.24 — 2026-07-16

SCT-list encoding and log signing join pki.ct: encodeSctList builds an RFC 6962 SignedCertificateTimestampList byte-for-byte, and signSct performs a Certificate Transparency log's signing step, completing the parse/verify/encode/sign symmetry.

### Added

- pki.ct.encodeSctList(scts) builds an RFC 6962 SCT-list extension value from an array of SCTs, the exact inverse of pki.ct.parseSctList (byte-identical round-trip). A decoded v1 SCT is rebuilt from its fields in the RFC 6962 sec. 3.2 order; an opaque non-v1 entry re-emits its rawSct verbatim (forward compatibility). The list must be non-empty and stays within the parser's SCT_MAX_COUNT / SCT_MAX_BYTES caps. Fail-closed with a typed CtError.
- pki.ct.signSct(entry, logKey, opts) performs a Certificate Transparency log's signing step (RFC 6962 sec. 3.2): it rebuilds the digitally-signed preimage via the same reconstructSignedData builder the verifier hashes, signs it with the log's private key (ECDSA NIST P-256 or RSA >= 2048, SHA-256 per sec. 2.1.4), and returns a fully-formed v1 SCT that pki.ct.verifySct accepts. The LogID is derived as SHA-256 of the log's SubjectPublicKeyInfo (sec. 3.4); a supplied opts.logId must match. Composes with encodeSctList to assemble a signed SCT-list extension.

## v0.2.23 — 2026-07-16

CMS content encryption arrives as pki.cms.encrypt and pki.cms.decrypt: EnvelopedData, AuthEnvelopedData, and EncryptedData with every RFC 5652 recipient type (RSA-OAEP, ephemeral-static ECDH, X25519/X448, symmetric key-wrap, password, and post-quantum ML-KEM per RFC 9629/9936), and a single, oracle-free decryption verdict.

### Added

- pki.cms.encrypt(content, recipients, opts) produces a CMS EnvelopedData, AuthEnvelopedData (AES-GCM, the default), or EncryptedData. Recipients auto-dispatch off the certificate key: RSA -> ktri RSAES-OAEP-SHA256/384/512 (v1.5 never emitted); EC P-256/384/521 -> kari ephemeral-static ECDH with the ANSI-X9.63 KDF; X25519/X448 -> kari with HKDF (RFC 8418); ML-KEM-512/768/1024 -> ori/KEMRecipientInfo (RFC 9629/9936). A { password } recipient uses PBKDF2 + the RFC 3211 PWRI-KEK; a { kek, kekId } recipient uses AES key wrap. EncryptedData takes a raw { cek } or a PBES2 { password }. Content is AES-128/192/256-GCM or -CBC; the same fresh content-encryption key is wrapped for every recipient. Fail-closed with typed CmsError.
- pki.cms.decrypt(input, keyMaterial, opts) decrypts an EnvelopedData / AuthEnvelopedData / EncryptedData (DER or PEM). It selects the recipient the key material { key, cert } / { password } / { kek } / { cek } targets, acquires the content-encryption key through the matching arm (RSA-OAEP or PKCS#1 v1.5 decrypt-only under the RFC 3218 implicit-rejection countermeasure, ECDH / X25519 / X448, AES key-unwrap, PBKDF2, ML-KEM decapsulation), and decrypts + authenticates the content, returning { content, contentType, contentTypeName, recipientType, recipientIndex, contentEncryptionAlgorithm, authenticated }. Every secret-dependent failure collapses to one uniform cms/decrypt-failed verdict (Bleichenbacher / EFAIL / password-oracle freedom); a PBKDF2 iteration cap bounds password-based decryption work.
- The WebCrypto engine gains ML-KEM key encapsulation (SubtleCrypto.encapsulateBits / decapsulateBits over FIPS 203) and the ANSI-X9.63 single-step key-derivation function (the X963KDF derive algorithm), the two primitives the post-quantum and elliptic-curve CMS recipient arms compose.

## v0.2.22 — 2026-07-16

The RFC 6960 OCSP producer and relying-party surface arrives as pki.ocsp: build and sign OCSP requests and responses, mint unsigned error responses, and verify a response end to end against the same hardened responder-authorization, signature, CertID, currency, and nonce gates the certification-path revocation checker runs.

### Added

- pki.ocsp is the RFC 6960 OCSP request/response surface. pki.ocsp.buildRequest(query, opts) builds an OCSPRequest (one or many { cert, issuer } queries; CertID under SHA-1 or SHA-2; optional RFC 9654 nonce; optional requestor signature; the RFC 5019 lightweight profile). pki.ocsp.sign(responseData, responder, opts) signs a BasicOCSPResponse over the exact ResponseData DER for the issuing CA or a delegated responder, with good / revoked / unknown per-certificate status and any pki.cms.sign signature algorithm (RSA, RSASSA-PSS, ECDSA, EdDSA, the post-quantum ML-DSA and SLH-DSA sets). pki.ocsp.buildErrorResponse(status) mints the unsigned section 2.3 error response. Transport-free; malformed input throws a typed OcspError.
- pki.ocsp.verify(response, opts) verifies an OCSP response as a relying party, fail-closed: it binds the supplied issuer certificate to the target certificate (the target's issuer name must equal the issuer's subject and the target's signature must verify under the issuer's key), recomputes the CertID under its own hash algorithm to bind the checked certificate to its issuer, requires an authorized responder (the issuing CA, or a CA-issued delegate bearing id-kp-OCSPSigning and id-pkix-ocsp-nocheck that passes the full out-of-path certificate gates), verifies the signature over tbsResponseDataBytes, enforces thisUpdate / nextUpdate currency, and binds the request nonce (RFC 9654) under a constant-time comparison. A revoked status shadows good within a response; an unbound issuer, an unauthorized responder, a mismatched CertID, or a stale or unverifiable response returns { status: "unknown" } with granular responderAuthorized / signatureValid / matched flags, never a silent accept. It runs the exact responder-authorization, signature, and currency gates pki.path.ocspChecker runs, through one shared core.
- pki.path.verifyOcspResponse(parsedResponse, cert, issuerCert, time, opts) is the lower-level primitive pki.ocsp.verify composes: it verifies a single already-parsed OCSP response for one certificate against its already-parsed issuer, returning the same fail-closed granular verdict, for callers that have already decoded their inputs.

## v0.2.21 — 2026-07-16

ML-KEM public keys in X.509 certificates and PKCS#8 private keys (RFC 9935 / FIPS 203) become a first-class, fail-closed surface: certification-path validation enforces the keyEncipherment-only key usage, key import validates the private-key CHOICE and rejects an inconsistent key with a typed error, and pki.lint gains the RFC 9935 certificate rows.

### Added

- pki.path.validate enforces the RFC 9935 section 5 key-usage rule for ML-KEM (id-ml-kem-512/768/1024) certificates: a present keyUsage MUST assert keyEncipherment as the only bit. A leaf with digitalSignature, an ML-KEM key with keyCertSign / cRLSign / keyAgreement / dataEncipherment / nonRepudiation, or keyEncipherment set alongside any reserved bit, fails closed with the frozen code path/kem-key-usage, for the target and every intermediate whose own subject key is ML-KEM. An absent keyUsage places no restriction (RFC 5280 section 4.2.1.3).
- pki.lint gains an rfc9935 profile: lint/rfc9935/kem-key-usage (the section 5 keyEncipherment-only rule) and lint/rfc9935/kem-key-length (the SPKI encapsulation key must be exactly 800 / 1184 / 1568 octets for the id-ml-kem-512 / 768 / 1024 OID; the OID is the sole authority for the parameter set, so an OCTET-STRING-wrapped or wrong-set key is flagged). Both run by default and are silent on a non-ML-KEM certificate.

### Fixed

- pki.webcrypto.subtle.importKey no longer surfaces a raw engine exception when handed a malformed or inconsistent key: a bad SPKI, a bad JWK, or an ML-KEM private key whose seed and expanded halves are inconsistent (FIPS 203 section 7.3) now fails closed with a typed webcrypto/data error. For an ML-KEM PKCS#8, the RFC 9935 section 6 private-key CHOICE is validated by its DER tag and exact size for the algorithm OID before the engine imports it. The OpenSSL-legacy bare-seed, bare-expanded-key, and concatenated layouts the engine would otherwise accept are rejected, so a non-conformant private key cannot be imported under an ML-KEM name.
- pki.webcrypto.subtle.importKey now fails closed on two further malformed-input classes: a JWK import whose key data is not an object (null, a primitive, or an array, including the JSON null an unwrap over non-authenticating ciphertext can yield) returns a typed webcrypto/data error instead of a raw TypeError; and an SPKI or PKCS#8 import requested under a secret-key or key-derivation algorithm name (AES-GCM/CBC/CTR/KW, HMAC, HKDF, PBKDF2) is rejected as webcrypto/not-supported, and no mislabeled key handle is imported.

## v0.2.20 — 2026-07-15

A WebAuthn attestation object whose attestation statement is not a CBOR map is now rejected with a typed webauthn/bad-attestation-object at parse instead of surfacing an untyped error from a format verifier, and the strict CBOR codec gains pki.cbor.read.mapGet: a keyed map lookup that asserts the map's major type inside the accessor.

### Added

- pki.cbor.read.mapGet(node, key) -- the keyed lookup over a decoded CBOR map (RFC 8949 major type 5). A text-string key matches text-string map keys; an integer key (a safe-integer number or a BigInt, as COSE labels are) matches integer map keys; matching never coerces across the two. It returns the value node, or null when the map has no such entry. Decode already enforced key uniqueness, so at most one entry can match. A non-map node throws cbor/unexpected-major, and a key that is neither a text string nor an integer throws a TypeError.

### Changed

- A WebAuthn attestation object whose attStmt is not a CBOR map is now classified as a malformed attestation object (webauthn/bad-attestation-object, thrown at parse for every attestation format), where it previously surfaced as a per-format webauthn/bad-att-stmt or an untyped error depending on the CBOR type carried.

### Fixed

- pki.webauthn.parseAttestationObject and pki.webauthn.verify no longer throw a raw TypeError when the attestation object's attStmt is a CBOR array: the attestation-statement field walk read the array's children as key/value pairs and dereferenced undefined. The attestation object's attStmt shape is now validated at parse (WebAuthn sec. 6.5.4), and the statement walk reads its pairs through pki.cbor.read.map, which asserts the major type: malformed hostile bytes are a typed webauthn/* verdict, never an untyped crash.

## v0.2.19 — 2026-07-14

The RFC 3161 Time-Stamp Protocol surface is complete: pki.tsp.request and pki.tsp.response build and parse the protocol's request and response messages, and pki.tsp.verify verifies a timestamp token end to end: the CMS signature, the message imprint, the ESSCertID(V2) certificate binding, the critical timeStamping-only extendedKeyUsage, and full validation of the TSA certificate at the token's own genTime.

### Added

- pki.tsp.request builds an RFC 3161 TimeStampReq around a message imprint, with the optional nonce, requested TSA policy, certReq, and extensions (canonical DER, the DEFAULT-FALSE certReq omitted unless true), and pki.tsp.parseRequest parses one (a new TimeStampReq decoder, also exposed as pki.schema.tsp.parseRequest). pki.tsp.response builds a TimeStampResp: a granted status wrapping the token pki.tsp.sign produces, or a rejection carrying a PKIStatus, status text, and PKIFailureInfo names. pki.tsp.parseResponse parses one, and the section 2.4.2 status-to-token coupling (a granted response carries a token, any other status must not) is enforced on build and parse alike. These are the byte payloads an RFC 3161 transport carries, completing the protocol message surface around pki.tsp.sign and pki.schema.tsp.parseToken.
- pki.tsp.verify(token, data, opts) verifies a timestamp token and returns a verdict carrying the verified genTime, serial number, and TSTInfo fields. It checks the CMS signature over the exact signed bytes, recomputes the message imprint from the supplied data (or compares a precomputed imprint), requires the encapsulated content be a TSTInfo, binds the token to the TSA certificate by recomputing the ESSCertID(V2) certificate hash (RFC 5816) and matching its issuerSerial when present, and enforces RFC 3161 section 2.3 on the TSA certificate: its extendedKeyUsage must be present, critical, and contain exactly id-kp-timeStamping, and, when the certificate asserts a keyUsage, that keyUsage must permit signing, so a certificate not issued for timestamping cannot mint a trusted token. When a trust anchor is supplied, the TSA certificate chain (ordered from the token's embedded certificates, so a TSA under an intermediate CA validates) receives full certification-path validation at the token's genTime, including optional revocation; when the request carried a nonce, the token must echo it. Every checked field is read from the verified encapsulated content, never a caller-supplied parsed object; a well-formed token failing any check is a fail-closed { valid: false } verdict with a stable reason code.

### Fixed

- CMS signer-certificate lookup now matches the certificate's issuer name in addition to its serial number when a signer is identified by issuerAndSerialNumber (RFC 5652); the issuer comparison was previously inert, so a signer was located by serial number alone. The verification verdict is unchanged (the signature check remains the authority), but the correct signer certificate is now selected precisely.
- Malformed input to several verifiers now fails closed with a typed pki.errors.PkiError instead of a raw TypeError: a signer or issuer distinguished name carrying an embedded control byte (the RFC 5280 section 7.1 name comparison, CVE-2009-2408) and oversized or malformed JSON are rejected with a domain error code across pki.cms.verify, pki.tsp.verify, pki.jose, pki.sigstore, and pki.webcrypto key import.

## v0.2.18 — 2026-07-14

Composite ML-DSA signatures join CMS SignedData: pki.cms.sign and pki.cms.verify now produce and verify a composite SignerInfo (a post-quantum ML-DSA paired with a traditional RSA, ECDSA, or EdDSA), accepted only when both components verify.

### Added

- pki.cms.verify verifies, and pki.cms.sign produces, a composite ML-DSA CMS SignerInfo (draft-ietf-lamps-cms-composite-sigs) pairing ML-DSA-44/65/87 with a traditional RSA (PKCS#1 v1.5 or PSS), ECDSA (P-256/384/521), or EdDSA (Ed25519) component. The signature is accepted only when both the post-quantum and traditional components verify over the domain-separated message representative; the digestAlgorithm is the parameter set's paired pre-hash, and the composite public-key OID must match the signatureAlgorithm. Fifteen algorithm arms verify and sign today; the two brainpool-curve arms and the one SHAKE256-pre-hash arm are recognized but fail closed to a typed error (their curve / digest is outside the WebCrypto surface).
- pki.cms.sign accepts a composite signer as { cert, key: { mldsa, trad } } (the two component private keys as PKCS#8), since a composite private key has no single native representation; it signs both components over the RFC 5652 section 5.4 preimage and emits the fixed-order composite signature the verifier consumes.

### Fixed

- EdDSA (Ed25519 / Ed448) public keys are validated as a canonical, on-curve, full-order Edwards point before any signature is verified with them, across certification-path validation (pki.path.validate, whether a certificate signature or a CRL / OCSP-response signature checked during revocation), composite CMS SignerInfo components, and JWS verification (pki.jose.verify). A low-order key (for example the identity point, which the underlying platform imports without complaint and which verifies a forged signature for every message) is rejected up front, so it can no longer certify a forged certificate chain, forge a CRL or OCSP response, satisfy the traditional half of a composite signature, or make a forged JWS verify. Certificate and revocation verification share one key-import routine, so the check cannot be applied to one surface and skipped on another.

## v0.2.17 — 2026-07-14

Post-quantum SLH-DSA joins CMS SignedData: pki.cms.sign and pki.cms.verify now sign and verify with all twelve FIPS 205 SLH-DSA parameter sets (RFC 9814), freely mixed with the classical and ML-DSA signers in one message.

### Added

- pki.cms.sign and pki.cms.verify sign and verify a CMS SignedData with the twelve pure FIPS 205 SLH-DSA parameter sets (id-slh-dsa-sha2-128s/f, -192s/f, -256s/f and the SHAKE equivalents), RFC 9814: pure mode, empty context, AlgorithmIdentifier parameters absent, over attached or detached content and single or multiple signers. An SLH-DSA signer mixes freely with RSA, RSASSA-PSS, ECDSA, EdDSA, and ML-DSA signers in one message. The signer identifier is issuerAndSerialNumber or subjectKeyIdentifier, and the output is a DER Buffer or PEM.
- The CMS message-digest algorithm for an SLH-DSA signer is fixed to the parameter set's paired digest (RFC 9814 section 4); signing emits it automatically and rejects a caller digest that contradicts the set, so the SignedData carries the conformant digest for the chosen parameter set.

### Changed

- CMS signature verification's one-shot signer-key agreement check (a single algorithm identifier naming both the key and the signature) now covers SLH-DSA alongside EdDSA and ML-DSA: an SLH-DSA SignerInfo whose signer certificate public-key parameter set disagrees with the signatureAlgorithm fails closed with a typed error.

## v0.2.16 — 2026-07-14

Post-quantum ML-DSA joins CMS SignedData: pki.cms.sign and pki.cms.verify now sign and verify with ML-DSA-44/65/87 (RFC 9882), freely mixed with the classical signers in one message.

### Added

- pki.cms.sign and pki.cms.verify sign and verify a CMS SignedData with the post-quantum ML-DSA-44, ML-DSA-65, and ML-DSA-87 (RFC 9882): pure mode, empty context, AlgorithmIdentifier parameters absent, over attached or detached content and single or multiple signers, freely mixed with RSA, RSASSA-PSS, ECDSA, and EdDSA signers in one message. The signer identifier is issuerAndSerialNumber or subjectKeyIdentifier, and the output is a DER Buffer or PEM.
- The CMS message-digest algorithm for an ML-DSA signer is held to the parameter set's security strength (RFC 9882 section 3.3): SHA-512 by default and SHAKE256 optional, with SHA-256 accepted only for ML-DSA-44. A below-strength digest is refused fail-closed on both signing and verification, so a weak message digest cannot cap the collision resistance of a strong ML-DSA signature.

### Changed

- CMS signature verification now requires a one-shot signer (EdDSA or ML-DSA, where a single algorithm identifier names both the key and the signature) to present a signer certificate whose public-key algorithm matches the SignerInfo signatureAlgorithm; a disagreement fails closed with a typed error and no longer surfaces as an opaque import failure.

## v0.2.15 — 2026-07-14

CMS SignedData signing arrives as pki.cms.sign, and RFC 3161 timestamp token creation as pki.tsp.sign: the producing sides of the CMS and timestamp verifiers, emitting exactly what pki.cms.verify and OpenSSL cms -verify accept.

### Added

- pki.cms.sign(content, signers, opts) produces a CMS SignedData (RFC 5652 section 5): attached or detached content, one or many signers, RSA (PKCS#1 v1.5 and, with opts.pss, RSASSA-PSS), ECDSA (P-256/384/521), Ed25519, and Ed448. Each signer is { cert, key } (a PEM/DER certificate and a WebCrypto CryptoKey or PKCS#8 key); the signed attributes (content-type, message-digest, signing-time, plus opts.additionalSignedAttributes) are canonical DER and the signature covers the exact section 5.4 preimage. opts selects detached content, the encapsulated content type, the signer identifier (issuerAndSerialNumber or subjectKeyIdentifier), certificate embedding, and DER or PEM output.
- pki.tsp.sign(messageImprint, tsa, opts) creates an RFC 3161 TimeStampToken over a message imprint ({ hashAlgorithm, hashedMessage }): a CMS SignedData whose content is a TSTInfo carrying the imprint, the TSA policy, a serial number, and genTime, with optional accuracy, nonce, and ordering, and the RFC 3161 section 2.4.2 / RFC 5816 signing-certificate (ESSCertIDv2) attribute binding the token to the TSA certificate. It composes pki.cms.sign, so any supported TSA key algorithm works.

## v0.2.14 — 2026-07-13

CMS SignedData signature verification arrives as pki.cms.verify, verifying a signed message (S/MIME, timestamps, code signing) over the exact RFC 5652 preimage, for attached and detached content, one or many signers, across RSA, RSASSA-PSS, ECDSA, and EdDSA.

### Added

- pki.cms.verify(input, opts) verifies a CMS SignedData signature (RFC 5652 section 5) for attached and detached content, single and multiple signers, across RSA, RSASSA-PSS, ECDSA, and EdDSA. It accepts a PEM string, a DER Buffer, or a parsed pki.schema.cms object; opts.content supplies the external content for a detached signature and opts.certs supplies additional signer certificates. Each signer is located by its issuerAndSerialNumber or subjectKeyIdentifier and its signature checked over the exact RFC 5652 section 5.4 preimage: the message-digest attribute bound to the content digest and the signature verified over the SignedAttributes re-encoding when signed attributes are present, otherwise over the content directly. Returns { valid, signers } with a per-signer verdict and the matched certificate; a false verdict or a structural fault is a fail-closed cms/* outcome.

## v0.2.13 — 2026-07-13

The certificate linter validates IP-literal common names with a strict in-house checker instead of node:net, so the toolkit pulls in no networking module.

### Changed

- pki.lint validates an IP-literal common name with a strict in-house IPv4/IPv6 checker (lib/ip-utils) instead of node:net, so the toolkit needs no networking module; the validator now also recognizes IPv4-mapped and dual-stack IPv6 literals.

## v0.2.12 — 2026-07-13

pki.ct.verifySct verifies a Signed Certificate Timestamp's signature against a Certificate Transparency log's public key.

### Added

- pki.ct.verifySct(entry, sct, logPublicKey) verifies a Signed Certificate Timestamp's signature against a CT log's public key (RFC 6962 section 3.2), composing pki.ct.reconstructSignedData, the shared ECDSA-Sig-Value conformance gate, and pki.webcrypto. Resolves true/false on the cryptographic verdict; throws a typed CtError on a structural fault.

## v0.2.11 — 2026-07-13

The pki command-line tool gains inspect, lint, convert, and verify: front-ends over the certificate inspector, the linter, the PEM codecs, and RFC 5280 path validation.

### Added

- pki inspect <cert> renders a certificate as an openssl x509 -text style report (composes pki.inspect.certificate).
- pki lint <cert> [--profile <name>] [--severity <floor>] [--json] lints a certificate against the RFC 5280 and CABF TLS profiles, exiting non-zero when an error or fatal finding is present (composes pki.lint.certificate).
- pki convert <file> --to der|pem [--label <label>] transcodes between DER and PEM with auto-detected input encoding and byte-exact round-tripping.
- pki verify <cert>... --anchor <cert> [--time <ISO>] validates an ordered certification path against a trust anchor per RFC 5280 section 6.1 (composes pki.path.validate), exiting non-zero and naming the failing check on rejection.

## v0.2.10 — 2026-07-13

Certificate linting arrives as pki.lint: graded, advisory conformance findings against the RFC 5280 profile and a representative CA/Browser Forum TLS Baseline Requirements subset.

### Added

- pki.lint.certificate(input, opts) lints a certificate against the RFC 5280 profile and a representative CA/Browser Forum TLS BR subset, returning a report of graded advisory findings (id, severity, source, spec citation, message). The data path never throws: malformed input becomes a fatal lint/unparseable finding instead of an exception, so a corpus lints without per-file error handling; only config-time misuse raises a typed LintError.
- pki.lint.rules(profile) and pki.lint.profiles() enumerate the rule registry and the available profiles (rfc5280, cabf-tls) so findings are traceable to a stable id and a spec clause.

## v0.2.9 — 2026-07-13

Certification-path validation verifies composite ML-DSA signatures, accepting a certificate only when both its post-quantum and traditional components verify.

### Added

- pki.path.validate verifies composite ML-DSA certificate signatures (draft-ietf-lamps-pq-composite-sigs): a post-quantum ML-DSA paired with a traditional RSA / ECDSA / EdDSA, accepted only when both components verify over the domain-separated message representative, an all-components-must-verify rule (an OR would be a downgrade). The same combinator verifies a composite-signed CRL or OCSP response. Proven against the draft's official known-answer test vectors.
- The 18 composite algorithm identifiers (1.3.6.1.5.5.7.6.37-54) are registered in the OID registry and their AlgorithmIdentifier parameters-absent requirement is enforced across every format that carries a signature algorithm.

## v0.2.8 — 2026-07-13

pki.webcrypto rejects an AES key of invalid length at import instead of deferring the failure to first use.

### Fixed

- pki.webcrypto.subtle.importKey now rejects a raw or JWK AES key whose length is not 128, 192, or 256 bits as a webcrypto/data DataError at import, where it previously returned a CryptoKey that only failed at first use. Covers AES-GCM, AES-CBC, AES-CTR, and AES-KW; HMAC, HKDF, and PBKDF2 keys are unaffected.
- The README capability table and the documentation site render the certificate-inspection entry correctly: an inline code span containing pipe characters no longer breaks the surrounding table into misaligned columns.

## v0.2.7 — 2026-07-13

pki.webcrypto rejects an imported key whose type disagrees with its algorithm, and reports every cipher fault as a typed error.

### Changed

- The pkijs.com documentation site is regenerated with content-hashed CSS/JS under a strict Content-Security-Policy, an in-memory search endpoint, a browsable reference of every error class and code, symbol autocomplete, and concept guides. Documentation only; the published package is unchanged.

### Fixed

- pki.webcrypto.subtle.importKey now validates that an imported asymmetric key's actual type matches the requested algorithm (an RSA key imported under an Ed25519, ECDSA, or RSA-PSS name is rejected as webcrypto/data), closing an algorithm-confusion path where a mislabeled CryptoKey could later be used under the wrong signature scheme. The EC import path already derived and checked the curve; this extends the same key-is-authority rule to RSA and the Edwards/Montgomery curves.
- pki.webcrypto AES cipher faults now fail closed with a typed webcrypto/operation error instead of a raw Node exception: a decrypt of a tampered AES-GCM ciphertext (failed authentication tag), bad AES-CBC padding, a non-8-byte-multiple AES-KW wrap/unwrap length, and a malformed cipher parameter all surface as a WebCryptoError, so a caller catching pki.errors.PkiError sees a typed verdict and no bare Node error crosses the API boundary.

## v0.2.6 — 2026-07-13

WebAuthn attestation verification covers Ed448 and the RFC 9864 fully-specified COSE algorithms, and hardens credential-key conformance.

### Added

- pki.webauthn now verifies the RFC 9864 fully-specified COSE algorithm identifiers a WebAuthn relying party may receive: ESP256 (-9), ESP384 (-51), ESP512 (-52), Ed25519 (-19), and Ed448 (-53). Ed448 (-53) is the only WebAuthn path to Ed448, so an Ed448 credential now verifies where it previously errored as an unsupported algorithm.
- The verifier now runs against the official W3C WebAuthn Level 3 test-vector suite as an independent cross-implementation oracle: every published vector across ES256 / ES384 / ES512 / RS256 / Ed25519 / Ed448 and the packed / self / tpm / apple / fido-u2f / none formats.

### Changed

- A WebAuthn credential key with COSE alg -8 (EdDSA) now requires curve Ed25519 (crv 6), matching the WebAuthn algorithm-identifier profile; an -8 key claiming Ed448 (crv 7) is rejected; Ed448 is carried under the fully-specified identifier -53 instead.
- An EC2 credential key must use the uncompressed point form; the compressed sign-bit y encoding is rejected for WebAuthn credential keys.

### Fixed

- The WebAuthn credential public-key point is now validated on its curve, so an off-curve or identity EC/Edwards point fails closed at decode and is never carried into a later verify step.
- An ECDSA attestation signature is now enforced as a minimally-encoded DER ECDSA-Sig-Value (X.690): a non-minimal, negative, zero, or over-size r/s coordinate is rejected as malformed instead of being stripped and accepted.

## v0.2.5 — 2026-07-13

WebAuthn / passkey attestation verification joins the toolkit as pki.webauthn.

### Added

- pki.webauthn.parseAttestationObject(bytes) structurally decodes a WebAuthn attestation object (the CBOR { fmt, attStmt, authData }) and its authenticatorData over the strict pki.cbor codec, returning the format id, the decoded rpIdHash / flags / signCount, the attested credential data (aaguid, credentialId, and the decoded COSE credentialPublicKey), and the raw authenticatorData bytes a signature covers. Malformed input throws webauthn/bad-attestation-object. W3C WebAuthn Level 3.
- pki.webauthn.verify(attestationObject, clientDataHash, opts) verifies a WebAuthn attestation statement (packed, tpm, android-key, apple, fido-u2f, or none), checking the attestation signature over authenticatorData || clientDataHash and each format's structural bindings (the x5c leaf key, the apple nonce, the tpm certInfo Name / extraData over the pubArea, the android KeyDescription, the fido-u2f verificationData), binding each attestation certificate key to the credential public key, and enforcing each format's certificate requirements (a packed leaf's Authenticator Attestation subject and non-CA basic constraints, a tpm AIK's empty subject and non-CA constraints). It resolves the attestation type and trust path or throws a typed webauthn/* error; a signature that does not verify is a webauthn/verify-failed verdict, never a silent pass. The error taxonomy gains WebauthnError (webauthn/*). W3C WebAuthn Level 3, RFC 9052.
- The OID registry gains the FIDO id-fido-gen-ce-aaguid, Android key-attestation, Apple anonymous-attestation, and TCG TPM (AIK key purpose, tpmManufacturer / tpmModel / tpmVersion) arcs that WebAuthn attestation certificates carry.

### Changed

- The bounded big-endian byte cursor under pki.ct's TLS-vector decoding is extracted to a shared engine primitive so the packed big-endian TPM structures in pki.webauthn read through the same bounds-before-slice cursor: one definition of the length-checked read, carrying each caller's typed error domain.

## v0.2.4 — 2026-07-12

Human-readable certificate inspection joins the toolkit as pki.inspect.

### Added

- pki.inspect.certificate(input) renders a certificate (a PEM string, a DER Buffer, or a pki.schema.x509.parse result) as a human-readable OpenSSL-x509-text-style report, composed over the strict X.509 parser, the shared RFC 5280 extension decoders, and the two-way OID registry. Standard extensions (basic constraints, key usage, extended key usage with purpose names, subject/issuer alternative names, subject and authority key identifiers, and more) decode to their content; an extension with no decoder is named from the registry and shown as its string or a hex dump. A value that is not a certificate, DER, or PEM throws inspect/bad-input; malformed certificate bytes throw inspect/bad-certificate; a malformed single extension never sinks the report. The error taxonomy gains InspectError (inspect/*). RFC 5280.

## v0.2.3 — 2026-07-12

Offline Sigstore bundle verification joins the toolkit as pki.sigstore.

### Added

- pki.sigstore.verifyBundle(bundle, opts) verifies a Sigstore bundle offline against caller-supplied trust material (opts.fulcioRoots, the Fulcio CA certificates; opts.rekorKeys, the Rekor log public keys, each with an optional validFor window honored against the log time so a rotated-out key is not used; optional opts.identity policy and opts.time). It returns { verified: true, payload, statement, subjects, predicateType, predicate, identity, integratedTime } on success (payload being the raw verified envelope bytes, never a re-serialization), and throws a typed sigstore/* error on any leg's failure. The transparency-log entry is bound to both the bundle signature and its leaf certificate, and only the v0.1-v0.3 bundle versions this release verifies are accepted (a newer version is recognized and deferred). pki.sigstore.parseBundle(input) decodes and structurally validates a bundle (object, JSON string, or Buffer) fail-closed. pki.sigstore.pae(payloadType, payloadBytes) builds the DSSE Pre-Authentication Encoding a signature covers. DSSE / Sigstore bundle v0.3 / RFC 9162 / SLSA provenance v1.
- The OID registry gains the Fulcio (Sigstore) certificate-extension arc 1.3.6.1.4.1.57264.1.* (the OIDC issuer, build-signer and source-repository identity claims), honoring the raw-string-vs-DER-UTF8String encoding split by member. The error taxonomy gains SigstoreError (sigstore/*): a malformed or oversize bundle (sigstore/bad-bundle), an unknown media type (sigstore/bad-bundle-version), an unsupported content arm (sigstore/unsupported-content), a DSSE signature that does not verify under the Fulcio leaf key (sigstore/dsse-verify-failed), an inclusion proof that does not reconstruct the tree root (sigstore/inclusion-proof-mismatch) or is malformed (sigstore/bad-inclusion-proof), a tree root not attested by the Rekor key (sigstore/unsigned-root), a log time not attested by the Rekor SET that signs it (sigstore/unattested-time, so an attacker cannot backdate the ephemeral Fulcio certificate into validity), a Fulcio chain that does not terminate at a caller-supplied trust anchor (sigstore/chain-incomplete) or fails validation as of the log time (sigstore/chain-invalid), an undecodable certificate identity (sigstore/bad-certificate), a malformed transparency-log entry (sigstore/bad-tlog-entry), a log entry that does not bind this signature (sigstore/entry-mismatch), an identity that fails the caller policy (sigstore/identity-mismatch), a payload that is not the expected in-toto statement (sigstore/bad-statement), and a predicateType that does not match a caller-pinned one (sigstore/predicate-mismatch). Fulcio CA anchors and Rekor keys honor their trusted-root validity windows so a rotated-out key or CA is not used, and every anchor sharing a subject DN is tried.

## v0.2.2 — 2026-07-12

Hybrid Public Key Encryption (RFC 9180) joins the toolkit as pki.hpke.

### Added

- pki.hpke.setupS(suiteIds, recipientPublicKey, opts) / pki.hpke.setupR(suiteIds, enc, recipientPrivateKey, opts) establish a sender / recipient HPKE context (RFC 9180 sec. 5.1); the returned context exposes seal(aad, pt) / open(aad, ct) with the sequence-counter nonce and a message-limit guard, and export(exporterContext, L) for the secret-export interface. pki.hpke.seal / pki.hpke.open are the single-shot wrappers (sec. 6). pki.hpke.suites carries the RFC 9180 sec. 7 KEM / KDF / AEAD / MODE code points. Keys are node KeyObjects or serialized bytes; the offered suites are DHKEM P-256, P-521, X25519, and X448, HKDF-SHA256 / HKDF-SHA512, the three AEADs plus export-only, and all four modes. RFC 9180.
- The error taxonomy gains HpkeError (hpke/*): a malformed, low-order, or otherwise invalid encapsulated or KEM key (hpke/bad-key, so a Diffie-Hellman that fails during derivation surfaces as a typed error, never a raw fault), an unknown or unsupported ciphersuite code point (hpke/unknown-suite, never a silent default), an unsupported mode (hpke/unknown-mode, so an out-of-registry mode is rejected before the key schedule), an authenticated mode invoked without the sender's key (hpke/auth-key-required), inconsistent PSK inputs (hpke/inconsistent-psk), an AEAD tag that does not verify (hpke/open-failed, returning no plaintext), a sequence-number overflow (hpke/message-limit, before any nonce reuse), a seal/open against an export-only suite (hpke/export-only), and a wrong-direction context call (hpke/wrong-role, so a recipient context cannot seal nor a sender context open; they share a key and base nonce).

## v0.2.1 — 2026-07-12

Stateful hash-based signature verification (HSS/LMS) joins the toolkit as pki.shbs.

### Added

- pki.shbs.verify(publicKey, message, signature) verifies an HSS (Hierarchical Signature System) signature, the wire form RFC 9802 (X.509) and RFC 9708 (CMS) carry for id-alg-hss-lms-hashsig, returning true only if every level of the hierarchy verifies. pki.shbs.verifyLms(publicKey, message, signature) verifies a single-tree LMS signature (the component HSS composes, and a standalone algorithm). Both take the raw octet blobs the parsers surface (a certificate's subjectPublicKeyInfo.publicKey.bytes, tbsBytes, and signatureValue.bytes); a malformed blob (bad length, an unknown or unapproved typecode, truncation, a typecode the public key does not commit to) throws a typed ShbsError, and a well-formed but wrong signature returns false. RFC 8554 / RFC 9802 / RFC 9708 / NIST SP 800-208.
- The OID registry gains id-alg-hss-lms-hashsig (1.2.840.113549.1.9.16.3.17), id-alg-xmss-hashsig, and id-alg-xmssmt-hashsig, all with parameters MUST be absent (RFC 9802 sec. 4). A stateful-hash-signature AlgorithmIdentifier carrying any parameters now fails closed at the shared algorithm-identifier gate, inherited by every format the toolkit parses. The error taxonomy gains ShbsError (shbs/*).

### Changed

- XMSS / XMSS^MT verification and automatic HSS/LMS verification inside pki.path.validate are not in this release; see the roadmap. The former awaits an authoritative interoperability test vector (RFC 8391 ships none and NIST ACVP does not yet cover XMSS); the latter awaits a real HSS-signed certificate to prove the certification-path wiring end to end. Operators verify today by handing the raw certificate / CMS blobs to pki.shbs.verify directly.

## v0.2.0 — 2026-07-11

Trust-store ingestion, sharded-CRL revocation, and a hardened input-guard layer.

### Added

- pki.trust.parseCertdata(text) and pki.trust.parseCcadbCsv(text) parse the Mozilla/NSS certdata.txt object stream and the CCADB All Certificate Records CSV into one Anchor shape carrying the exact { name, publicKey, algorithm, parameters } fields pki.path.validate consumes, plus per-purpose distrustAfter dates, delegator purposes (only CKT_NSS_TRUSTED_DELEGATOR grants a purpose), subjectDer, label, and mozillaCaPolicy. Certificate and trust objects are paired by byte-exact issuer + serial (never adjacency) and cross-checked against the parsed DER, so metadata can never attach to the wrong root. pki.trust.anchor(entry, { purpose }) hands the anchor to validate, failing fast when the entry does not delegate the purpose. Malformed octal, an oversized block or file, an unrecognized trust value, an undecodable distrust date, or a mispaired object throws a typed trust/* error. Offline and pure: the caller supplies the text; nothing fetches.
- pki.path.validate gains opts.checkPurpose plus trust-anchor constraint enforcement: with an anchor carrying distrustAfter / purposes metadata, a leaf whose notBefore is strictly after the anchor's distrust date for the checked purpose fails with path/distrusted-after (the boundary instant stays trusted, matching Mozilla's enforcement), and a purpose the anchor does not delegate fails with path/purpose-not-trusted. Anchors without the metadata validate exactly as before.
- cRLDistributionPoints and freshestCRL certificate-extension decoders, and RFC 5280 sec. 6.3.3 distribution-point correspondence in pki.path.crlChecker: a partitioned CRL whose critical issuing-distribution-point shares an identically-encoded name with one of the certificate's distribution points, carries no reason restriction on either side, is current, and verifies now establishes a good status for its shard. A non-corresponding, reason-restricted, non-critical-IDP, delta, or unverifiable shard keeps failing closed to undetermined. A listed serial still reports revoked regardless of correspondence.
- pki.asn1.decode and pki.cbor.decode accept a maxItems option (default C.LIMITS.DER_MAX_ITEMS) capping the total decoded elements, so a small dense input cannot fan out into an unbounded node tree (asn1/too-many-items). OCSP responses are capped at C.LIMITS.OCSP_MAX_CERTS embedded certificates (ocsp/too-many-certs), bounding the pre-authentication signature work an attacker-supplied response can demand.
- Strict JWK oct key-material decoding: importKey / unwrapKey reject a JWK oct key whose k member is missing, empty, padded, or non-canonical base64url instead of importing a wrong or empty key, and JSON key unwrap rejects duplicate members at every depth so a smuggled second parameter can no longer resolve last-wins. Unwrapped JWK text is bounded (size, nesting depth, strict UTF-8).

### Changed

- OID string handling is canonical everywhere: pki.asn1.build.oid and pki.oid.toDER reject a leading-zero arc (previously accepted and silently encoded as a different OID: "2.05.29.15" emitted the DER of 2.5.29.15), and pki.oid.toArcs / register now enforce the X.660 arc bounds on the string form (a root above 2, or a second arc above 39 under roots 0 and 1, can never be DER-encoded and now throws oid/bad-arc). Error codes consolidated with the canonical form: a one-arc or non-numeric-arc string throws oid/bad-input (previously oid/too-short and oid/bad-arc; oid/too-short is removed).
- pki.jose and WebCrypto JWK unwrap JSON limits are enforced through one shared bounded reader; an unwrapped-key JSON parse failure reports the typed webcrypto/data error without a nested SyntaxError cause.

### Fixed

- Signing via pki.jose over a payload backed by a detached ArrayBuffer now fails closed with a typed error instead of silently signing an empty payload; PEM encoding and EST transfer encoding reject a detached or non-buffer input the same way.
- EST server-side key generation rejects an EnvelopedData whose encryptedContent is present but zero-length (est/bad-key-part); previously only the fully absent form was rejected, so an empty ciphertext could reach the caller's decrypt step. The same shared check now backs the CRMF encrypted-key proof-of-possession and PKCS#12 paths.
- An attribute certificate whose objectDigestInfo digest is not octet-aligned is rejected at parse (attrcert/bad-object-digest-info) instead of surfacing a bit-truncated digest.
- A malformed content-type attribute value inside AuthEnvelopedData authenticated attributes surfaces cms/bad-content-type-attr instead of a raw asn1/* codec error.
- pki.path.validate rejects fractional maxPathCerts / maxPolicyNodes values (path/bad-input) instead of silently tolerating them, and validates requiredEku / userInitialPolicySet entries as canonical OID strings at the entry point; a leading-zero or out-of-bounds key could never match decoder output and now fails at boot instead of silently never matching.

## v0.1.32 — 2026-07-11

OCSP-backed revocation checking joins certification-path validation.

### Added

- pki.path.ocspChecker(responses) -> RevocationChecker builds an OCSP-backed revocation checker for pki.path.validate's revocationChecker option from a set of pre-fetched OCSP responses (DER Buffer, PEM string, or already-parsed). It matches the full CertID triple against the certificate under whichever hash algorithm the CertID declares, authorizes the responder (issuing CA or a CA-issued id-kp-OCSPSigning delegate), verifies the response signature over tbsResponseData, and enforces thisUpdate/nextUpdate currency, reporting good, revoked (with revocationReason), or unknown. A wrong-issuer CertID, an unauthorized responder, a stale, not-yet-valid, or nextUpdate-less response, a non-successful responseStatus, or any verification failure yields unknown, which the validator fails closed unless softFail is set. RFC 6960.

## v0.1.31 — 2026-07-11

The DER format cohort and the JOSE surface graduate to stable.

### Changed

- pki.schema.pkcs12 / attrcert / crmf / cmp / csrattrs (parse, pemDecode, pemEncode), pki.schema.all and pki.schema.parse, and the pki.jose signing / verification / thumbprint / base64url / JSON surface graduate from experimental to stable.
- The LTS-CALENDAR graduation criterion now states that a settled, well-tested format no mainstream tool implements graduates on the toolkit's own conformance-vector round-trip plus coverage-guided fuzzing, since no harness oracle exists for it.

## v0.1.30 — 2026-07-11

Fail-closed hardening of the byte-input and text-decode boundaries.

### Changed

- A detached-backed BufferSource, a transferred or structuredClone'd view, no longer decodes as an empty buffer in a DER format parser: pki.schema.x509 / crl / csr / pkcs8 / cms / pkcs12 all fail closed with the format's typed bad-input error at the shared parse-input boundary.

### Fixed

- The EST transfer and multipart-mixed decoders enforce their size cap on the raw byte length before decoding the payload to a string, and an HTTP error response body is decoded only up to the prefix shown in the message, closing a single-input string-allocation amplification where an oversized body was materialized in full before the cap rejected it.
- pki.oid.fromDER rejects a non-Buffer or detached-backed input with a typed oid/bad-input error instead of a raw TypeError.

## v0.1.29 — 2026-07-11

A detached-backed BufferSource now fails closed with a typed error at every byte-input boundary.

### Fixed

- pki.webcrypto digest / sign / verify no longer silently process a detached-backed Buffer as empty input (a fail-open where a transferred backing ArrayBuffer left the view zero-length); a detached BufferSource is now rejected with a typed webcrypto/data error, as is getRandomValues.
- pki.asn1.decode, pki.cbor.decode, and pki.ct.parseSctList reject a detached-backed Buffer or view with a typed error (asn1/not-buffer, cbor/not-buffer, ct/bad-input) instead of a raw TypeError or a misleading truncated-input verdict. The underlying byte-view failure is threaded as the error cause.

## v0.1.28 — 2026-07-11

Merkle transparency proof verification joins the toolkit as pki.merkle.

### Added

- pki.merkle.leafHash / nodeHash / emptyRootHash -- the RFC 6962 / RFC 9162 tree hashes: a leaf is SHA-256(0x00 || entry), an interior node is SHA-256(0x01 || left || right), the empty tree is SHA-256(""). The domain-separation prefixes are applied unconditionally.
- pki.merkle.verifyInclusion({ leafIndex, treeSize, leafHash, proof, rootHash }) -- verify an RFC 6962 / RFC 9162 audit proof by folding the leaf up the audit path and constant-time-comparing the reconstructed root to a trusted root. Returns true only when the proof binds the leaf to the root.
- pki.merkle.verifyConsistency({ oldSize, newSize, oldRoot, newRoot, proof }) -- verify an append-only consistency proof by reconstructing both the old and the new root and constant-time-comparing each; the append-only guarantee lives in the old-root leg.
- The error taxonomy gains MerkleError (merkle/*). A node-count ceiling (C.LIMITS.MERKLE_MAX_PROOF_NODES) rejects a pathologically long proof before any hashing; the precise per-proof guard is the geometry check in each verifier.
- Fuzz target merkle-verify (both fold algorithms and the hash producers over adversarial coordinates, hashes, and proofs) joins the per-PR and nightly fuzz matrices with a seed corpus.

## v0.1.27 — 2026-07-11

A strict deterministic-CBOR codec joins the toolkit as pki.cbor.

### Added

- pki.cbor.decode -- the RFC 8949 core-deterministic CBOR decoder. It returns a node carrying the major type, the argument (a lossless BigInt), zero-copy content / bytes views (the raw ranges an external verifier hashes), and children (array elements, ordered map key/value pairs, or a tag's one inner item). Every non-canonical shape fails closed with a stable cbor/* code; maxBytes, maxDepth, maxItems, and a per-bignum byte cap bound the work before allocation (so a container declaring millions of tiny elements fails closed before memory is exhausted); allowTrailing decodes the first item of a CBOR Sequence.
- The read.* leaf readers over a decoded node: read.uint / read.nint / read.int (uniform BigInt), read.byteString (zero-copy Buffer), read.textString (strict UTF-8), read.array, read.map (ordered key/value node pairs), read.boolean / read.nullValue / read.undefinedValue, read.float (half / single / double), and the tagged forms read.biguint (RFC 8949 tag 2 unsigned bignum, minimality and byte cap enforced), read.time (RFC 8949 tag 1 epoch time, bounded to the valid Date range), and read.oid (RFC 9090 tag 111, decoded through the shared OID-content codec so a malformed body surfaces the existing oid/* codes).
- The error taxonomy gains CborError (cbor/*). The decoder is profile-parameterized, so a future CTAP2 canonical profile arrives as data and needs no new code path.
- Fuzz target cbor-det-parse (the decode head-well-formedness and minimal-argument checks, the map ordering / uniqueness verify, the shortest-float rule, the strict-UTF-8 gate, and the size / depth / bignum caps, in both whole-buffer and CBOR-Sequence modes) joins the per-PR and nightly fuzz matrices with a seed corpus.

### Changed

- pki.oid.paramsMustBeAbsent graduated from experimental to stable: its dotted-OID-to-boolean surface has been unchanged since 0.1.21 and is exercised end-to-end by the algorithm-identifier decoder every format shares.

## v0.1.26 — 2026-07-10

Test-coverage measurement and the OpenSSF Best Practices badge.

### Added

- npm run coverage measures statement and branch coverage over the full test suite with c8. It is a development dependency only; the published package still declares zero runtime dependencies.

### Changed

- The README badge row now includes the OpenSSF Best Practices badge.

## v0.1.25 — 2026-07-10

ACME joins the toolkit: an RFC 8555 message layer over a new RFC 7515 JOSE surface.

### Added

- pki.jose — the RFC 7515 Flattened JWS and RFC 7638 JWK-thumbprint layer. jose.sign / jose.verify produce and check a Flattened JWS against one of three declarative profiles (ACME outer, EAB inner, keyChange inner) that carry the required and forbidden header rules as data, so sign and verify cannot diverge; jose.base64url is the strict RFC 4648 section 5 codec (a trailing '=', a '+' or '/', whitespace, or non-canonical trailing bits are rejected); jose.parseJson is a bounded recursive-descent reader that throws on a duplicate member at any nesting depth, on invalid UTF-8, and past the size or depth caps; jose.thumbprint is the RFC 7638 canonical SHA-256 thumbprint (RSA, EC, oct, OKP, and AKP member templates, optional members excluded).
- pki.acme — the RFC 8555 / 8737 / 8738 / 9773 ACME message layer over pki.jose. acme.validate checks a directory, account, order, authorization, challenge, or renewalInfo object against its spec (closed status enums, conditional-required fields such as a pending order's expires, URL and RFC 3339 shapes, non-empty arrays; unknown fields are ignored, never reflected); acme.validateProblem checks an RFC 7807 problem document and its subproblems (a top-level identifier is rejected); acme.assertTransition enforces the three section 7.1.6 state machines; acme.identify classifies an object into exactly one kind.
- ACME request builders: acme.newAccount (with a fail-closed mailto contact check) and acme.externalAccountBinding (an HMAC-only inner JWS over the account key); acme.newOrder (identifier validation with one leading wildcard label permitted for dns, and the RFC 9773 replaces field); acme.finalize, which parses the CSR with pki.schema.csr, requires its requested identifier set (SAN plus CN) to equal the order identifiers, and rejects a CSR whose public key is the account key (RFC 8555 section 11.1); acme.challengeResponse, acme.deactivate, acme.revokeCert (account-key or certificate-key signed, CRLReason range-checked), acme.keyChange (the section 7.3.5 nested JWS), and acme.postAsGet (an empty payload, distinct from an empty object).
- ACME challenge computations: acme.keyAuthorization (token plus the account-key thumbprint), acme.http01, acme.dns01 (the _acme-challenge record with one wildcard label stripped), and the tls-alpn-01 pair acme.tlsAlpn01Extension / acme.verifyTlsAlpn01, which build and check the critical id-pe-acmeIdentifier extension (a 32-octet Authorization equal to the SHA-256 of the key authorization) together with a single-entry SubjectAltName.
- ARI (RFC 9773): acme.ariCertId builds a certificate's renewal identifier from its authorityKeyIdentifier and serial content octets, preserving the serial's leading sign-padding byte so the identifier matches what a CA computes; acme.parseAriCertId decodes one back to its two halves; acme.validateRenewalInfo checks a suggestedWindow and rejects an inverted or zero-width window.
- The error taxonomy gains JoseError (jose/*) and AcmeError (acme/*); the OID registry gains id-pe-acmeIdentifier (RFC 8737).
- pkix.pemDecodeAll decodes an RFC 7468 multi-block PEM chain (CERTIFICATE label, no explanatory text between blocks, at least one block) beside the existing single-block pemDecode.
- Fuzz targets jose-parse (the base64url and JSON codecs and the JWS profile walk) and acme-object (the resource validators, identify, and the ARI certID parser) join the per-PR and nightly fuzz matrices with seed corpora.

## v0.1.24 — 2026-07-10

EST enrollment joins the toolkit: the RFC 8951 CSR-attributes parser and an RFC 7030 client-codec surface.

### Added

- pki.schema.csrattrs.parse(der) — decode EST CSR Attributes (CsrAttrs ::= SEQUENCE OF AttrOrOID, RFC 8951 section 3.5) into { items }. Each item is { kind, oid, name }, with kind 'oid' for a bare OBJECT IDENTIFIER or 'attribute' for an Attribute, which adds raw values plus, for the RFC 9908 meaningful types, a decoded view: extensions (id-ExtensionReq), curve / keySize (the EC / RSA key-type conventions), or template (the CertificationRequestInfoTemplate). An empty SEQUENCE is a complete valid document. Unknown OIDs / attribute types are tolerated (surfaced raw); the RFC 9908 semantic MUSTs fail closed with a typed CsrattrsError (at most one id-ExtensionReq whose value is a single Extensions, template version v1(0), a template carrying at most one id-aa-extensionReqTemplate and never both extension-request kinds). Registered in the format orchestrator (pki.schema.parse routes a CsrAttrs, including the empty SEQUENCE, to csrattrs).
- pki.est — the transport-agnostic RFC 7030 / 8951 / 9908 EST client surface. transferDecode / transferEncode are the RFC 8951 base64 transfer codec (RFC 4648, blind to any Content-Transfer-Encoding header, bounded before and after decoding). splitMultipartMixed splits the /serverkeygen multipart/mixed body (terminal boundary required, nested/extra parts rejected). parseCertsOnly validates a certs-only Simple PKI Response (RFC 5272 section 4.1) over cms.parse output (empty signerInfos, no eContent, plain X.509 certificates only), surfacing certificates raw and in as-received order. findIssuedCert picks the issued certificate by a public-key match (never a positional guess). parseServerKeygenResponse dispatches the two-part key + certificate response and enforces the request-to-response recipient-arm coherence. classifyResponse is the HTTP status / content-type / Retry-After state machine (a 202 surfaces retryAfterSeconds, never slept on; 204/404 on /csrattrs is a 'none available' verdict). paths builds the RFC 7030 operation URLs with the optional CA-label guard. The builders assemble the CSR attributes EST adds: challengePasswordFromTlsUnique (channel binding, 255-octet cap), decryptKeyIdentifierAttr / asymmetricDecryptKeyIdentifierAttr, smimeCapabilitiesAttr, buildEnrollAttributes (the RFC 9908 template-priority enroll plan), and reenrollGuard.
- The error taxonomy gains CsrattrsError (csrattrs/*) and EstError (est/*).
- The OID registry gains the RFC 4108 / RFC 7030 / RFC 9908 attribute identifiers: id-aa-decryptKeyID, id-aa-asymmDecryptKeyID, id-aa-certificationRequestInfoTemplate, and id-aa-extensionReqTemplate.
- Fuzz targets csrattrs-parse and est-transfer (the base64 + multipart codecs) join the per-PR and nightly fuzz matrices with seed corpora.

## v0.1.23 — 2026-07-10

CMS grows authenticated content: RFC 5652 AuthenticatedData, RFC 5083 AuthEnvelopedData, and RFC 9629 KEM recipients (ML-KEM ready), plus a toolkit-wide hardening pass.

### Added

- pki.schema.cms.parse decodes id-ct-authData (RFC 5652 section 9 AuthenticatedData): { version, originatorInfo, recipientInfos, macAlgorithm, digestAlgorithm, encapContentInfo, authAttrs, authAttrsBytes, mac, unauthAttrs }. The section 9.1 version rule is computed from originatorInfo contents (recipient kinds never influence it); digestAlgorithm and authAttrs are enforced as a biconditional; authAttrs are required for a non-id-data content type and must carry content-type (matching the eContentType) and message-digest; authAttrsBytes is the raw on-wire [2] TLV for the section 9.2 MAC re-tag.
- pki.schema.cms.parse decodes id-ct-authEnvelopedData (RFC 5083): { version, originatorInfo, recipientInfos, encryptedContentInfo, aead, authAttrs, authAttrsBytes, mac, unauthAttrs }. A recognized AES-GCM/CCM content-encryption algorithm gets its RFC 5084 parameters validated (present, nonce bounds of CCM 7..13 octets, ICV length from the allowed set and equal to the mac length, DEFAULT-omitted per X.690 11.5) and surfaced as aead: { kind, nonce, icvLen }; an unrecognized algorithm surfaces raw parameters with aead null.
- KEMRecipientInfo (RFC 9629) parsed under the OtherRecipientInfo id-ori-kem arm: { version, rid, ridType, kem, kemct, kdf, kekLength, ukm, wrap, encryptedKey } as kemri alongside the raw oriValue. version must be 0; kekLength must be 1..65535 and match a recognized AES key-wrap's KEK size; a recognized ML-KEM kem pins the exact FIPS 203 ciphertext length. An unrecognized oriType still surfaces raw (the ORI extension point); a recognized one is validated by content, never accepted on the type OID alone.
- The parameters-absent registry (pki.oid.paramsMustBeAbsent) gains ML-KEM-512/768/1024 (RFC 9936) and the three HKDF identifiers (RFC 8619), enforced once in the shared AlgorithmIdentifier schema so certificates, CMS, and every other format inherit the rule.
- RFC 5652 section 11 attribute placement rules, enforced everywhere attribute sets are parsed: content-type / message-digest / signing-time must not appear in unsigned, unauthenticated, or unprotected attribute sets; countersignature only in unsigned attributes. signing-time values are validated as single-valued Time; every countersignature value is validated as a SignerInfo whose signedAttrs carry message-digest and no content-type (RFC 5652 section 11.4), recursively.
- Every pki.schema.cms.parse result carries contentType (the dotted OID) and contentTypeName, naming which of the five content types was dispatched, so a consumer no longer duck-types the result shape.
- pemEncode lands on every remaining format: pki.schema.ocsp.pemEncode (default label OCSP RESPONSE), pki.schema.attrcert.pemEncode (ATTRIBUTE CERTIFICATE), pki.schema.crl.pemEncode (X509 CRL), and label-required pki.schema.tsp.pemEncode / pki.schema.crmf.pemEncode (no standard PEM label exists for those formats, so the operator names the envelope explicitly).
- New fuzz targets with seed corpora: crl-parse, csr-parse, pkcs8-parse, and schema-all-parse (the orchestrator front door), plus authenticated-content and KEM-recipient seeds for cms-parse; all wired into the per-PR and nightly fuzz matrices.
- pki.oid.register / registerFamily validate X.660 encodability at registration: root arc 0..2, second arc 0..39 under roots 0 and 1, at least two arcs, and no leading-zero components. A typo fails at config time instead of minting an unmatchable registry key.

### Changed

- SignedData and OriginatorInfo certificates/crls buckets validate the closed CertificateChoices / RevocationInfoChoice tag sets (RFC 5652 sections 10.2.1-10.2.2): elements are still surfaced raw, but a tag outside the CHOICE, or a primitive encoding, is rejected instead of silently feeding the version computation.
- Signed and authenticated attribute sets must be DER encoded even when the enclosing structure is BER (RFC 5652 sections 5.3/9.1, RFC 5083 section 2.1). An indefinite-length attribute set reaching the PKCS#12 public-key-integrity path is now rejected instead of surfacing re-tag bytes a verifier would hash incorrectly.
- Validity, TBSCertificate, and AttributeTypeAndValue assert their SEQUENCE tag (RFC 5280 section 4.1): a SET-tagged body no longer parses through pki.schema.x509.parse while the format orchestrator rejects the same bytes.
- issuerUniqueID / subjectUniqueID are decoded as the [n] IMPLICIT BIT STRING RFC 5280 section 4.1.2.8 defines; an EXPLICIT-wrapped or malformed unique identifier is rejected.
- The IssuingDistributionPoint decoder enforces the DER field grammar (strictly ascending tags, each at most once, DER BOOLEAN values, no encoded DEFAULT FALSE); a CRL whose IDP violates it has unknown scope and is unusable for revocation decisions.
- An empty embedded SEQUENCE is rejected wherever a Certificate / CertificateList is expected (OCSP certs lists, CMP, CRMF); CRMF encryptedKey proof-of-possession and PKCS#12 encrypted safes reject a zero-length ciphertext the same way as a missing one.
- RecipientEncryptedKey surfaces ridType (rKeyId | issuerAndSerialNumber) so a key-agreement consumer no longer duck-types the recipient-matching form.
- The pki.schema.cms.parse reference documentation describes all five decoded content types and their return shapes.

### Fixed

- Crypto engine: deriveKey implements the W3C get-key-length rules (AES lengths limited to 128/192/256, HMAC defaulting to the hash block size); deriveBits rejects over-length, zero, and non-multiple-of-8 requests against the actual shared-secret size; unwrapKey surfaces a typed error instead of a bare JSON.parse SyntaxError; an RSA publicExponent is bounds-checked before numeric narrowing.
- Path validation: an unreadable basicConstraints or issuer key-usage extension now fails the check that consulted it instead of being silently swallowed; name-constraint inputs are validated at the entry point; the policy-tree node budget is a documented constant (LIMITS.PATH_MAX_POLICY_NODES).
- ASN.1 codec: the constructed encodings of GeneralString, NumericString, and ObjectDescriptor are rejected in DER mode; the byte caps passed to asn1.decode are validated as finite non-negative integers.
- Publish pipeline: the npm tarball and SBOM are re-hashed against the attested SLSA subjects and verified with slsa-verifier before anything is signed, released, or published; eslint is lockfile-pinned and runs isolated from the pack workspace; gitleaks is version+checksum pinned; the OpenSSF Scorecard threshold gate fails closed when a score cannot be extracted.
- Every behavioral fix above ships with a conformance vector driving the public parse/validate surface on the malformed input.

## v0.1.22 — 2026-07-09

An RFC 5035 / RFC 8551 S/MIME ESS signed-attribute decoder joins the schema family.

### Added

- pki.schema.smime.parseSigningCertificate(der) / parseSigningCertificateV2(der) — decode the RFC 5035 ESS SigningCertificate (v1) and SigningCertificateV2 (v2) signed-attribute values into { certs, policies }. Each certs entry is { certHash, hashAlgorithm, issuerSerial } in wire order: certHash raw; hashAlgorithm the implied SHA-1 for v1, or the decoded algorithm (or the id-sha256 default, flagged defaulted) for v2; issuerSerial (or null) the issuer GeneralNames (each CHOICE arm validated, surfaced raw) plus serialNumber as a BigInt and serialNumberHex. certs is non-empty and order-preserving; a v2 hashAlgorithm encoded byte-equal to its DEFAULT is rejected smime/non-canonical-default (X.690 §11.5). Malformed input fails closed with a typed smime/* (or leaf asn1/*) code.
- pki.schema.smime.parseSmimeCapabilities(der) — decode an RFC 8551 SMIMECapabilities value into { capabilities }, an ordered list (preference order preserved, never sorted) of { capabilityID, name, parameters } with parameters the raw ANY-DEFINED-BY bytes.
- pki.schema.smime.decodeAttribute(attr) — an OID-dispatch convenience over the three decoders for a CMS-shaped { type, values } attribute (the shape cms.parse surfaces on signerInfos[i].signedAttrs). It enforces the single-AttributeValue rule (a values length other than one is rejected smime/multi-valued-attribute), routes on the attribute OID, and recognize-and-defers an unknown type as smime/unsupported-attribute carrying the type, registry name, and raw values.
- The error taxonomy gains SmimeError, carrying a stable smime/* code.
- The OID registry gains the smimeCapabilities attribute identifier and the RFC 2634 ESS attribute names (receiptRequest through contentReference), so a CMS signed attribute of one of those types resolves to a name in an unsupported-attribute diagnostic.

## v0.1.21 — 2026-07-09

SLH-DSA signatures verify in certification-path validation, and the post-quantum / EdDSA parameters-absent rule is enforced across every format.

### Added

- SLH-DSA signature verification in pki.path.validate — all twelve FIPS 205 parameter sets (id-slh-dsa-sha2-{128,192,256}{s,f} and the SHAKE sets). A certificate or CRL signed with SLH-DSA now verifies by importing the issuer's SLH-DSA public key and checking the one-shot signature over the raw signed region; the same rows serve both the certificate signature check and the CRL revocation checker. ML-DSA and the classical RSA / ECDSA / EdDSA set were already wired.
- pki.oid.paramsMustBeAbsent(oid) — a predicate that reports whether an AlgorithmIdentifier bearing the given OID must encode its parameters field as absent (the ML-DSA and SLH-DSA families and the RFC 8410 Edwards / Montgomery curves). It is the single source the shared AlgorithmIdentifier decoder consults.
- The OID registry now names the twelve pre-hash HashSLH-DSA identifiers (id-hash-slh-dsa-*, RFC 9909 §3), so a certificate or CRL that carries a HashSLH-DSA algorithm resolves to a name and is covered by the parameters-absent rule.

### Fixed

- The shared AlgorithmIdentifier decoder now rejects a present parameters field on the algorithms whose parameters MUST be absent, namely ML-DSA, SLH-DSA, Ed25519, Ed448, X25519, and X448 (RFC 9909 §3, RFC 9814 §4, RFC 9881 §2, RFC 8410 §3), failing closed with a <format>/bad-algorithm-parameters code. Previously a stray explicit NULL or arbitrary bytes in that field were surfaced raw. The rule is enforced once in the shared decoder, so every format that names an algorithm inherits it; a conforming identifier, which omits the field, is unaffected.
- Certification-path validation now enforces issuer-key / signature-algorithm consistency for the one-shot families whose public-key OID equals the signature OID (EdDSA, ML-DSA, and SLH-DSA), rejecting a mismatch with a path/algorithm-mismatch reason (RFC 9814 §4). Because the underlying WebCrypto import binds a public key of a different type to the requested algorithm name and verifies with the real key, a certificate or CRL signed by one key type but labeling its signatureAlgorithm as another one-shot type could otherwise validate; the check closes that algorithm-confusion path for both the certificate signature and the CRL revocation checker.

## v0.1.20 — 2026-07-09

An RFC 6962 Certificate Transparency SCT-list parser joins the toolkit.

### Added

- pki.ct.parseSctList(extValue) — RFC 6962 SCT-list parsing. It decodes the SignedCertificateTimestampList extension value (the raw extnValue content an x509 or OCSP extension surfaces) into { scts, unknownScts }. Each scts entry is a fully decoded v1 SCT: version (0), logId (32-byte Buffer) plus logIdHex, timestamp (exact BigInt) plus timestampMs (a Number, or null above 2^53) plus timestampDate, extensions (raw Buffer), the hashAlg / sigAlg code points plus a named signatureAlgorithm, the raw signature, and rawSct (the full SerializedSCT body). A SerializedSCT whose version is not v1 is preserved opaque in unknownScts as { version, rawSct } without failing the list (RFC 6962 §3.3 gives each SerializedSCT its own length so unknown versions are skippable). The extension value is the §3.3 double DER OCTET STRING wrap over a TLS-encoded list, decoded with a bounded reader that validates the list and per-SCT framing and every internal length, and asserts a per-list byte and count cap before it iterates. The signature is never verified and the log id never recomputed. Malformed input fails closed with a typed ct/* (or leaf asn1/*) code.
- pki.ct.reconstructSignedData(entry, sct) — rebuilds the exact digitally-signed preimage a verifier hashes to check an SCT's signature (RFC 6962 §3.2), for a decoded v1 SCT. entry selects the log-entry form: { entryType: 0, leafCert } for an SCT delivered over TLS or OCSP (signed over the leaf certificate), or { entryType: 1, tbsCertificate, issuerKeyHash } for an SCT embedded in a certificate (signed over the issuer key hash and the precertificate TBS). The preimage reuses the parsed SCT's raw extensions byte-for-byte; a verifier hashes the returned bytes and checks the signature with the log's public key.
- The certificate-extension value registry gains the SCT-list decoder and the precertificate-poison decoder (the poison value is tag-checked and its content validated as ASN.1 NULL).
- The OID registry gains the Certificate Transparency arc, covering the SCT-list, precertificate-poison, precertificate-signing-certificate, and OCSP SCT-list identifiers, so those extension OIDs resolve to names.
- The error taxonomy gains CtError, carrying a stable ct/* code.

## v0.1.19 — 2026-07-09

An RFC 9810 Certificate Management Protocol message parser joins the pki.schema family.

### Added

- pki.schema.cmp.parse(input) — RFC 9810 PKIMessage parsing. It decodes a DER Buffer or PEM into { header, headerBytes, body, bodyBytes, protection, extraCerts }. The header carries pvno (1..3), validated sender / recipient GeneralNames (the anonymous NULL-DN accepted), and the optional messageTime (GeneralizedTime only), protectionAlg, senderKID / recipKID, transactionID, senderNonce / recipNonce, freeText, and generalInfo (recognized id-it values are syntax-checked). The body is { arm, tag, bytes, decoded? }: ir / cr / kur / krr / ccr decode through the CRMF parser; ip / cp / kup / ccp decode to a certificate-response structure (an encrypted certificate's EnvelopedData decodes through the CMS parser; the deprecated EncryptedValue arm and caPubs surface raw, conferring no trust); krp decodes to a key-recovery structure ({ status, newSigCert, caCerts, keyPairHist }); rr / rp, genm / genp, error, certConf (an empty confirmation is the legal reject-all), and pollReq / pollRep decode structurally; pkiconf decodes to null; every other defined arm surfaces raw: p10cr, the challenge-response and announcement arms, and nested (never auto-recursed). certReqId values are big integers and accept the protocol's -1 sentinel. The two cross-field coherence rules (protection bits and protectionAlg present together or absent together; a certConf hashAlg requires version cmp2021) are enforced. Protection is surfaced; verifying it is the caller's job. headerBytes and bodyBytes are the exact wire slices, so a verifier reconstructs the protected part as a DER SEQUENCE wrapping them and checks the MAC or signature. Malformed input fails closed with a typed cmp/* or asn1/* code.
- pki.schema.cmp.pemDecode(text, label?) / pemEncode(der, label?) — PEM handling for messages that transit text channels (default label CMP).
- pki.schema.crmf.parse now surfaces every CertTemplate field, including serialNumber and the issuer/subject unique identifiers. RFC 4211's rule that a certificate request must omit the CA-assigned fields (serialNumber, signingAlg) and the deprecated unique identifiers moved from the shared CertTemplate structure to the request layer, so a request that sets them still fails closed while the same structure can identify an existing certificate, with serialNumber and issuer present, inside a CMP revocation.
- The OID registry gains the CMP id-it information types and the message-protection MAC algorithm identifiers (passwordBasedMac, dhBasedMac, kemBasedMac), so a parsed message's info types and protection algorithm resolve to names.
- The error taxonomy gains CmpError, carrying a stable cmp/* code.

### Changed

- pki.schema.tsp parsing (parse, parseTstInfo, parseToken, pemDecode) is now stable.
- The npm-publish vulnerability scan reads the committed lockfiles (the dev and build toolchain that runs during a publish) instead of the runtime SBOM, which is empty by construction because the package ships zero runtime dependencies.

### Fixed

- Certification-path validation bounds the BasicConstraints pathLenConstraint and the PolicyConstraints / InhibitAnyPolicy skip counts before narrowing them to a number, so a certificate carrying a value past the safe-integer range is rejected and the counter cannot round silently to the wrong value (the same exact-or-rejected rule the RSASSA-PSS salt length and PKCS#12 iteration count follow).
- Certification-path validation rejects a non-empty DER NULL in an RSASSA-PSS hash AlgorithmIdentifier's parameters. A NULL must carry empty content (X.690 8.8.2), so the previous tag-only check accepted a malformed encoding it now fails closed.

## v0.1.18 — 2026-07-09

An RFC 7292 PKCS#12 (PFX) store parser joins the pki.schema family.

### Added

- pki.schema.pkcs12.parse(input) — RFC 7292 PFX parsing. It decodes a DER / BER Buffer or PEM into { version, integrityMode, mac, macedBytes, authSafeSigned, safeBags, encryptedSafes }. Password-integrity stores surface { kind, hashOid, hashName, hashParameters, pbmac1, macValue, macSalt, iterations } plus macedBytes: the exact value octets the HMAC covers, excluding the octet-string header, so an external verifier hashes the correct region. The RFC 9579 PBMAC1 arm is validated as well as recognized: its parameters must be present, the key-derivation function must be PBKDF2 with a keyLength, and the decoded KDF (salt, iteration count, key length, PRF) and MAC scheme surface on pbmac1. The X.690 DEFAULT rule is enforced (an explicitly encoded iterations = 1 is non-canonical and rejects). Public-key-integrity stores surface the CMS SignedData and must carry at least one signer (the signature itself is verified externally). Each safeBag carries its type, friendlyName / localKeyId (decoded, single-value and single-instance rules enforced), and all attributes: keyBags delegate to pki.schema.pkcs8.parse, shrouded key bags to parseEncrypted (algorithm surfaced, ciphertext opaque), cert / CRL / secret values stay raw and byte-exact, and safeContentsBags recurse under a depth ceiling. Encrypted and enveloped safes are validated structurally by the CMS module with ciphertext kept raw, and must declare id-data (a SafeContents) as their encrypted content type. The version-3 rule, the contradictory MacData-alongside-SignedData combination, the closed bag-type and cert/CRL-type sets, and per-list element caps all fail closed with typed pkcs12/* codes; a MacData-less id-data store is legal syntax and parses as integrityMode "none".
- pki.schema.pkcs12.pemDecode(text, label?) / pemEncode(der, label?) — PEM handling for stores that transit text channels (default label PKCS12).
- pki.asn1.decode gains an opt-in ber option for formats whose content is normatively BER, accepting exactly two shapes: an indefinite length on a constructed value, and a constructed OCTET STRING whose segments reassemble into one primitive content. The default remains strict DER; minimal-length, minimal-integer, trailing-byte, and size / depth verdicts are unchanged in both modes, an indefinite length on a primitive value still rejects, a foreign-type segment inside a constructed string rejects, and constructed-string nesting is capped (each level re-copies its payload, so deep nesting amplifies memory without adding data).
- pki.schema.engine.embeddedDer(schema, bytes, ctx, opts) — the named form of the re-decode idiom: decode a fresh DER / BER blob carried inside an already-decoded value and walk it against a schema, wrapping codec failures in the caller's typed code. A shared budget option bounds how many nested blobs one parse may unwrap, so a container that chains encodings across octet-string boundaries cannot restart the depth caps from zero. The timestamp, OCSP, and certificate-request parsers now route their embedded-structure decodes through it.
- SEQUENCE OF / SET OF schemas can declare an element-count ceiling (max), so a container listing a great many tiny elements fails typed instead of amplifying memory through per-element parse products; a single attribute's value list is now capped this way across every format.
- The OID registry gains the PKCS#12 bag types, the PKCS#12 password-based encryption identifiers, the PKCS#9 certTypes / crlTypes / friendlyName / localKeyId entries, PKCS#5 PBKDF2 / PBES2 / PBMAC1, the NIST AES content-encryption arc, and the HMAC-with-SHA identifiers, so a store's algorithms resolve to names.
- The error taxonomy gains Pkcs12Error, carrying a stable pkcs12/* code.

### Changed

- The npm-publish workflow's vulnerability scan now scans the SBOM unconditionally and the vendored-bundle directory only when it holds a real bundle, so the scan is exact in both states instead of warning on the empty directory.

### Fixed

- Certification-path validation bounds the RSASSA-PSS saltLength and trailerField before numeric conversion, so an oversized value rejects with path/unsupported-algorithm instead of rounding silently on its way to the verifier, the same exact-or-rejected rule the PKCS#12 MAC parameters follow.

## v0.1.17 — 2026-07-07

An RFC 4211 certificate-request-message parser joins the pki.schema family.

### Added

- pki.schema.crmf.parse(input) — RFC 4211 CertReqMessages parsing. It decodes a DER Buffer or PEM into { messages: [ { certReq, popo, regInfo } ] }, where each certReq is { certReqId, certReqIdHex, certTemplate, controls, certReqBytes } and certTemplate carries the requestable fields version, issuer, validity, subject, publicKey, and extensions (each null when absent). RFC 4211 §5 requires a request to omit serialNumber and signingAlg (assigned by the CA) and issuerUID and subjectUID (deprecated); those fields are rejected and do not come back in the result, so a requester cannot dictate a CA-assigned value. issuer and subject Names are accepted in both the EXPLICIT and the IMPLICIT wire encodings; the OptionalValidity times are EXPLICIT UTCTime or GeneralizedTime; a supplied CertTemplate version must be 2; certReqId is an unbounded signed integer. popo is null, a raVerified marker, a decoded signature proof (with its poposkInput and signature surfaced raw), or a raw key-encipherment / key-agreement arm; for a signature proof, poposkInput's presence is checked against the template per §4.1. certReqBytes is the exact CertRequest byte range a proof-of-possession verifier hashes. Malformed input fails closed with a typed crmf/* or asn1/* code.
- pki.schema.crmf.pemDecode(text, label?) — extract the DER bytes from a PEM block (the first block unless a label is given).
- The OID registry gains the RFC 4211 registration-control (id-regCtrl) and registration-info (id-regInfo) identifiers on the id-pkip arc, so a parsed control or info entry resolves to its name (oldCertID, pkiArchiveOptions, utf8Pairs, and the rest).
- The error taxonomy gains CrmfError, carrying a stable crmf/* code.
- pki.schema.engine.encode(schema, value) — the constructor direction of the schema engine. A single declarative schema now drives both decode (walk, bytes to value) and encode (canonical DER, value to bytes): every leaf carries a read and a write, and EXPLICIT wrapping and IMPLICIT context-tag retagging are applied in one place, so an encoder can no longer emit a different tag than the decoder reads. A round-trip test proves walk(decode(encode(value))) recovers the value across universal, IMPLICIT, EXPLICIT, and SET-OF-ordered shapes, and the CRMF request format is proven to round-trip end to end.

### Changed

- pki.schema.ocsp request and response parsing (parseRequest, parseResponse, pemDecode) is now stable.
- An experimental primitive is surfaced for a graduation review once it has been experimental for a fixed number of releases; the review is recorded as a graduation to stable or a dated keep-experimental decision, so the experimental-to-stable-to-deprecated transition is driven on a schedule (see LTS-CALENDAR.md).

## v0.1.16 — 2026-07-06

Certification path validation joins the toolkit — RFC 5280 section 6, as a pure re-entrant algorithm.

### Added

- pki.path.validate(path, opts) — RFC 5280 section 6 certification-path validation. It validates an ordered array of pki.schema.x509.parse certificates (or DER/PEM it parses) against a trust anchor, running the section 6.1 state machine: section 6.1.3(a)(1) signature chaining, the always-on section 6.1.3(a)(2) validity window with the check date an explicit input, section 6.1.3(a)(4) name chaining, section 6.1.3(b,c)/6.1.4(g) name constraints (directoryName, dNSName, rfc822Name including an emailAddress carried in the subject DN, uniformResourceIdentifier, and iPAddress with the address-and-mask subtree form), section 6.1.4(k) basic constraints as the single authoritative CA gate, section 6.1.4(l,m) path length, section 6.1.4(n) keyUsage keyCertSign, and the section 6.1.3(d)/6.1.4(a,b,i,j)/6.1.5 certificate-policy tree with its explicit-policy, policy-mapping, and inhibit-any-policy counters. It returns { valid, path, results, workingPublicKey, workingPublicKeyAlgorithm, workingPublicKeyParameters, validPolicyTree } where results[i].checks carries a stable path/* reason code per check. Validation is pure and re-entrant. An unrecognized critical extension, an undetermined revocation status, or a structural fault fails the path with a typed code.
- pki.path.crlChecker(crls) — a CRL-backed revocation checker for the validate revocationChecker option, composing pki.schema.crl.parse. For each certificate it consults every CRL issued by the certificate's issuer (so a clean CRL cannot shadow a revoking one), verifies each CRL signature over its tbsBytes, honors the issuing-distribution-point scope and reason coverage, checks thisUpdate/nextUpdate currency, and requires the CRL signer to assert keyUsage cRLSign; a certificate listed in any authoritative in-scope CRL is revoked, and an issuer with no authoritative in-scope CRL yields an undetermined status, which the validator fails closed unless softFail is set. An OCSP checker satisfies the same interface.
- pki.schema.pkix gains the ns-parameterized RFC 5280 section 4.2.1 extension-value decoders (pkix.certExtensionDecoders) for basicConstraints, keyUsage, nameConstraints, certificatePolicies, policyMappings, policyConstraints, inhibitAnyPolicy, subjectAltName / issuerAltName, extKeyUsage, and authorityKeyIdentifier / subjectKeyIdentifier. Each turns a raw extension value into a validated structure or a typed error, fail-closed. The shared GeneralName validator gains a decoded-value mode (surfacing the IA5 text, the IP octets, or the directoryName as a structured name alongside the raw bytes) and an address-and-mask subtree-base mode for name-constraint bases; both are opt-in, so the existing callers are byte-identical.
- The OID registry gains the RFC 5280 policy and wildcard extension identifiers used by path validation: policyMappings, policyConstraints, and inhibitAnyPolicy, plus the anyPolicy and anyExtendedKeyUsage special-OID leaves.
- The error taxonomy gains PathError, carrying the per-check reason in its stable path/* code.

## v0.1.15 — 2026-07-06

CMS EnvelopedData and EncryptedData join the parser, and every documentation example is now executed as a test.

### Added

- pki.schema.cms.parse now decodes CMS EnvelopedData (RFC 5652 §6) and EncryptedData (§8). An EnvelopedData returns { version, originatorInfo, recipientInfos, encryptedContentInfo, unprotectedAttrs } with all five RecipientInfo kinds decoded: KeyTransRecipientInfo (§6.2.1, with the issuerAndSerialNumber/subjectKeyIdentifier version coupling enforced), KeyAgreeRecipientInfo (§6.2.2 + RFC 5753 §3.1), KEKRecipientInfo (§6.2.3), PasswordRecipientInfo (§6.2.4), and OtherRecipientInfo (§6.2.5). An EncryptedData returns { version, encryptedContentInfo, unprotectedAttrs }. The wrapped keys, the ciphertext, and all AlgorithmIdentifier parameters are surfaced raw: every recipient carries the keyEncryptionAlgorithm its encryptedKey must be unwrapped with, and a kekid / rKeyId OtherKeyAttribute is surfaced as raw DER. Decryption and key-unwrap are a separate layer. The CMSVersion is recomputed and enforced per structure and per recipient, recipientInfos is required non-empty, and the encryptedContent [0] IMPLICIT OCTET STRING is read as the ciphertext directly.
- The schema engine gains an implicitTag option on pki.schema.engine.seq() and on pki.schema.pkix.algorithmIdentifier(ns, { implicitTag }), a [tag] IMPLICIT SEQUENCE / AlgorithmIdentifier reader (used by the PasswordRecipientInfo keyDerivationAlgorithm [0]). A call with no option is byte-identical to before.
- Every @example in the documentation comment blocks is now executed end-to-end as a test (test/layer-0-primitives/doc-examples.test.js, in the smoke gate) instead of only being parse-checked: an example must run to completion or throw a typed PkiError, and every documented @primitive path must resolve to a real export, so a documented example can no longer drift from the shipped API. A new @originated comment tag records a callable's original availability version when its documented path is later corrected, enforced alongside the @since version gate.

### Changed

- The W3C WebCrypto constructor classes (CryptoKey, Crypto, SubtleCrypto, WebCryptoError) are now reachable under pki.webcrypto (e.g. pki.webcrypto.CryptoKey) alongside the ready Crypto instance, matching their documented path; the previously-separate pki.WebCrypto holder is removed.
- Repository tooling now installs npm packages exclusively through integrity-verified lockfiles: the fuzz build installs the jazzer.js engine via npm ci against the committed fuzz/package-lock.json, and the vendoring script resolves a package to an integrity-pinned lockfile in an isolated staging workspace (no install script runs, and the repo's own node_modules is never touched) before bundling. Tooling child processes that need a shell (the Windows npm shim) now receive one explicitly-quoted command string instead of an unescaped argument array.

### Fixed

- Two documented API paths that did not resolve at runtime are corrected: pki.webcrypto.CryptoKey (previously reachable only via pki.WebCrypto.CryptoKey) and pki.asn1.read.oid (its comment block labeled the path pki.asn1.readOid, which never existed). Both are now reachable at the documented path.
- A documentation example for pki.webcrypto.subtle.exportKey referenced an undefined variable; it now generates the key pair it exports.

## v0.1.14 — 2026-07-06

An RFC 5755 attribute-certificate parser joins the pki.schema family.

### Added

- pki.schema.attrcert — an RFC 5755 attribute-certificate parser. pki.schema.attrcert.parse turns a DER Buffer or PEM into a structured v2 attribute certificate ({ version, holder, issuer, signatureAlgorithm, serialNumber, serialNumberHex, validity, attributes, issuerUniqueID, extensions, tbsBytes, signatureValue }). The holder (baseCertificateID / entityName / objectDigestInfo) and issuer (v1Form / v2Form) identities come back as validated GeneralNames; the validity window is real Dates; the privilege attributes (id-at-role, id-aca-group, id-at-clearance, and any others) resolve by name where the registry knows them. The outer-equals-inner signatureAlgorithm agreement (RFC 5755 4.2.4), the positive-and-at-most-20-octet serialNumber (4.2.5), the GeneralizedTime-only validity (4.2.6), the non-empty unique-typed attribute list (4.2.7), and the digestedObjectType enumeration are all enforced fail-closed. pki.schema.parse detect-and-routes an attribute certificate; the obsolete v1 form is recognized and deferred with a precise attrcert/legacy-v1-not-supported.
- pki.schema.pkix gains a shared GeneralNames validator that the attribute-certificate parser composes for its four GeneralNames-bearing fields, validating every element as a well-formed GeneralName (rejecting a bad tag, a wrong primitive/constructed form, a non-IA5 string, or a mis-sized iPAddress). The sequence no longer surfaces as opaque bytes. It handles both a bare universal SEQUENCE OF GeneralName and a context-tagged IMPLICIT GeneralNames.
- The OID registry gains the RFC 5755 attribute-certificate object identifiers: the id-aca attribute-type family (authenticationInfo / accessIdentity / chargingIdentity / group), id-at-role and id-at-clearance, the id-ce-targetInformation and id-ce-noRevAvail extensions, and the id-pe-ac-auditIdentity / id-pe-aaControls / id-pe-ac-proxying private extensions, so a parsed attribute certificate's attributes and extensions resolve by name.

## v0.1.13 — 2026-07-06

An RFC 3161 timestamp parser joins the pki.schema family.

### Added

- pki.schema.tsp — an RFC 3161 timestamp parser. pki.schema.tsp.parse turns a DER Buffer or PEM into a TimeStampResp ({ status, statusString, failInfo, timeStampToken }) with the granted-carries-token / rejected-carries-none coupling enforced (tsp/missing-token, tsp/unexpected-token) and the PKIFailureInfo named bits decoded. pki.schema.tsp.parseToken parses a TimeStampToken by composing pki.schema.cms.parse and asserting the id-ct-TSTInfo content type (tsp/wrong-econtent-type), attached content (tsp/detached-token), and the single (TSA) signer (tsp/multi-signer), returning the decoded TSTInfo plus the signer material. pki.schema.tsp.parseTstInfo decodes a bare TSTInfo. The TSTInfo mandatory version-1, the GeneralizedTime-only genTime, the accuracy 1..999 range, the ordering DEFAULT FALSE omission, and the PKIStatus 0..5 range are all enforced fail-closed. pki.schema.parse detect-and-routes a TimeStampResp.
- The codec and schema engine gain three composable primitives that TSP required: pki.asn1.read.integerImplicit / pki.schema.engine.implicitInteger(tag) (a context-tagged IMPLICIT INTEGER, for the Accuracy millis / micros fields); pki.schema.engine.implicitSeqOf(tag, item) (an order-preserving IMPLICIT SEQUENCE OF, the sibling of implicitSetOf without the SET ordering rule, for the extensions field); and RFC 3161 / X.690 §11.7 fractional-seconds GeneralizedTime support in pki.asn1.read.time (a '.'-separated, trailing-zero-free, Z-terminated fraction, surfaced at millisecond precision).
- The OID registry gains the id-kp extended-key-purpose family (including id-kp-timeStamping) and the id-aa S/MIME authenticated-attribute family (signingCertificate / signingCertificateV2 / timeStampToken), so a parsed TSA certificate's key purpose and a signer's ESS binding attribute resolve by name.

## v0.1.12 — 2026-07-05

SLH-DSA object identifiers corrected and completed to all twelve FIPS 205 parameter sets.

### Fixed

- SLH-DSA OID resolution — id-slh-dsa-shake-128s and id-slh-dsa-shake-256s were mapped to the arcs of id-slh-dsa-sha2-256s (.24) and id-slh-dsa-shake-128f (.27), so pki.oid.name / pki.oid.byName resolved them incorrectly. All twelve Pure SLH-DSA parameter sets (sha2-128s/128f/192s/192f/256s/256f and shake-128s/128f/192s/192f/256s/256f) are now registered at their correct arcs .20 through .31 per RFC 9909 §3; the previously-absent nine sets now resolve as well. WebCrypto SLH-DSA sign/verify was unaffected, since it selects by algorithm name and does not go through the OID registry.

## v0.1.11 — 2026-07-05

An OCSP request and response parser joins the pki.schema family.

### Added

- pki.schema.ocsp.parseRequest and pki.schema.ocsp.parseResponse — an OCSP request / response parser per RFC 6960 (§4.1 OCSPRequest, §4.2 OCSPResponse). parseRequest turns a DER Buffer or an 'OCSP REQUEST' PEM string into { tbsRequestBytes, version, requestorName, requestList, requestExtensions, optionalSignature }, each requestList entry carrying its CertID with the two issuer hashes raw. parseResponse turns a DER Buffer or an 'OCSP RESPONSE' PEM string into { responseStatus, responseBytes, basicResponse }; for a successful basic response, basicResponse carries { tbsResponseDataBytes, responderID, producedAt, responses, signatureAlgorithm, signature, certs } and each responses[i].certStatus is { type: 'good' | 'revoked' | 'unknown' } (a revoked entry adds its revocationTime and revocationReason). A malformed structure throws a typed OcspError (ocsp/*), an unsupported responseType throws ocsp/unsupported-response-type, and a leaf-level codec fault surfaces as asn1/*. pki.schema.ocsp.pemDecode handles the PEM envelope, and pki.schema.parse detect-and-routes both OCSP shapes.
- pki.asn1.read.nullImplicit and the pki.schema.engine.implicitNull(tag) leaf — read a context-tagged IMPLICIT NULL (the shape the OCSP CertStatus good [0] and unknown [2] arms take), the primitive-leaf sibling of implicitBitString and implicitOctetString. It rejects a constructed or non-empty context node fail-closed.
- An ocsp-parse coverage-guided fuzz harness joins the CI fuzzing matrix (jazzer.js + libFuzzer, per pull request and nightly), driving both OCSP entry points on mutated input.

## v0.1.10 — 2026-07-05

A CMS SignedData parser joins the pki.schema family; coverage-guided fuzzing now runs in CI.

### Added

- pki.schema.cms.parse — a CMS SignedData parser per RFC 5652 (§3 ContentInfo + §5 SignedData). It turns a DER Buffer or a 'CMS' PEM string into { version, digestAlgorithms, encapContentInfo, certificates, crls, signerInfos }. encapContentInfo.eContent is the raw content (or null when the signature is detached); each SignerInfo carries its raw signature and, when signed attributes are present, the on-wire signedAttrsBytes for external verification. The signer identifier is an issuerAndSerialNumber or a subjectKeyIdentifier, with the version-to-identifier rule (RFC 5652 §5.3) enforced, and a degenerate certificates-only SignedData (empty digest algorithms and signer infos) is accepted. A ContentInfo whose content type is not id-signedData throws cms/unsupported-content-type (a recognized PKCS#7 type) or cms/unknown-content-type; a malformed structure throws a typed CmsError (cms/*) and a leaf-level codec fault surfaces as asn1/*. pki.schema.cms.pemDecode / pemEncode handle the PEM envelope, and pki.schema.parse detect-and-routes a CMS message.
- pki.asn1.read.octetStringImplicit and the pki.schema.engine.implicitOctetString(tag) leaf — read a context-tagged IMPLICIT OCTET STRING (the shape the CMS SignerIdentifier subjectKeyIdentifier [0] takes), the primitive-leaf sibling of implicitBitString.
- Coverage-guided fuzzing in CI — a ClusterFuzzLite integration (.clusterfuzzlite/) plus pull-request and nightly-batch workflows run jazzer.js + libFuzzer against the pki.asn1.decode, pki.schema.x509.parse and pki.schema.cms.parse harnesses. A finding fails the run with the reproducer inline and uploads the crash input as an artifact. The integration is detectable by OpenSSF Scorecard's Fuzzing check.

### Fixed

- The OpenSSL interop runner counted a cross-check the oracle could not perform (for example, an OpenSSL predating ML-DSA) as a pass. Such a cross-check is now recorded as a skip and reported separately, so the interop pass count is not inflated by checks that never executed.

## v0.1.9 — 2026-07-05

A PKCS#8 private-key parser joins the pki.schema family.

### Added

- pki.schema.pkcs8.parse — a PKCS#8 PrivateKeyInfo / OneAsymmetricKey parser per RFC 5208 §5 and RFC 5958 §2. It turns a DER Buffer or a 'PRIVATE KEY' PEM string into { version, privateKeyAlgorithm, privateKey, attributes, publicKey }, where privateKey is the raw OCTET STRING content (the inner RSA/EC/curve key, decoded by the caller via privateKeyAlgorithm.oid) and publicKey is present only for a v2 key. The version must be v1 (0) or v2 (1), and a [1] public key is permitted only in a v2 key (both directions enforced). A malformed key throws a typed Pkcs8Error (pkcs8/*); a leaf-level codec fault surfaces as asn1/*. pki.schema.pkcs8.pemDecode / pemEncode handle the PEM envelope.
- pki.schema.pkcs8.parseEncrypted — recognizes an EncryptedPrivateKeyInfo ('ENCRYPTED PRIVATE KEY') and surfaces its encryptionAlgorithm and raw encryptedData. Decryption (PBES2/PBKDF2 + a passphrase) is a separate concern and is not performed here. This is an explicit call because an EncryptedPrivateKeyInfo shares its SEQUENCE{SEQUENCE, OCTET STRING} shape with a PKCS#1 DigestInfo, so pki.schema.parse does not auto-route it (structure alone cannot classify it without a validated encryption-algorithm discriminator).
- pki.asn1.read.enumerated's sibling pki.asn1.read.bitStringImplicit and the pki.schema.engine.implicitBitString(tag) leaf — read a context-tagged IMPLICIT BIT STRING (the shape a PKCS#8 OneAsymmetricKey public key [1] takes).

## v0.1.8 — 2026-07-05

A PKCS#10 certification-request parser joins the pki.schema family.

### Added

- pki.schema.csr.parse — a PKCS#10 CertificationRequest parser per RFC 2986. It turns a DER Buffer or a 'CERTIFICATE REQUEST' PEM string into a structured object: version, subject distinguished name, subjectPublicKeyInfo, the requested attributes (each with its type OID, resolved name, and raw-DER values), and the signatureAlgorithm / signatureValue over the CertificationRequestInfo, with the raw certificationRequestInfoBytes returned for signature verification. It composes the shared schema engine and PKIX sub-schemas (AlgorithmIdentifier, Name, SubjectPublicKeyInfo), so a certification request inherits the identical fail-closed structural rules and a malformed request throws a typed CsrError (csr/*); a leaf-level codec fault surfaces as asn1/*. The version must be v1 (INTEGER 0), the [0] IMPLICIT attributes element is mandatory, and each attribute's values SET must be non-empty. pki.schema.parse now detects and routes certification requests, and pki.schema.all() lists it alongside crl and x509. pki.schema.csr.pemDecode / pemEncode handle the PEM envelope.
- pki.asn1.read.enumerated — reads an ENUMERATED value from a decoded node (the same content rules as an INTEGER), the counterpart to the now-strict pki.asn1.read.integer.

### Changed

- C.TIME.ms is renamed to C.TIME.milliseconds, so every C.TIME duration helper now reads as a full word (milliseconds, seconds, minutes, hours, days, weeks). The behavior is unchanged: it still returns an integer millisecond count.

### Security

- pki.asn1.read.integer now rejects an ENUMERATED-tagged node. INTEGER and ENUMERATED share DER content encoding, so an INTEGER-pinned field (a certificate or certification-request version, a serial number, or a cRLNumber) mis-encoded as ENUMERATED was previously decoded as though it were the INTEGER, a type confusion that let malformed DER parse where a conformant reader rejects it. read.integer is now strict on the tag, and ENUMERATED values are read with the new pki.asn1.read.enumerated. Certificate, CRL, and certification-request parsing reject these inputs fail-closed.
- SubjectPublicKeyInfo is now required to be a universal SEQUENCE across the certificate and certification-request parsers. A context-tagged or SET-tagged constructed node carrying a well-formed algorithm and key is no longer accepted as an SPKI.
- SET OF components are now required to be in ascending DER order (X.690 §11.6) wherever the schema declares a SET OF: a relative distinguished name, and a certification request's attributes and attribute values. A non-canonical, unsorted encoding is rejected fail-closed.

### Migration

- Replace C.TIME.ms(n) with C.TIME.milliseconds(n). The other C.TIME and C.BYTES helpers are unchanged.

## v0.1.7 — 2026-07-05

A unified pki.schema family: the structure-schema engine, the X.509 parser, a new CRL parser, and a detect-and-route orchestrator.

### Added

- pki.schema.crl.parse — an X.509 CRL (CertificateList) parser per RFC 5280 §5. It turns a DER Buffer or an 'X509 CRL' PEM string into a structured object: version, issuer distinguished name, thisUpdate / nextUpdate as real Dates, the ordered list of revoked certificates (serial number + hex + revocation date + entry extensions), and the CRL extensions, with the cRLNumber, reasonCode, and invalidityDate values decoded and the raw tbsCertList bytes returned for signature verification. It composes the same schema engine and shared PKIX sub-schemas (AlgorithmIdentifier, Name, Extension) as the certificate parser, so the CertificateList inherits the identical fail-closed structural rules (bounds-checked positional reads, the signature-algorithm agreement, non-empty issuer, extension uniqueness, the v2-only version rule).
- pki.schema.parse — a detect-and-route entry point: hand it DER or PEM and it identifies which registered PKI format the bytes encode (certificate vs CRL) and routes to that member's parser. pki.schema.all() enumerates the registered formats.

### Changed

- The schema engine and the per-format parsers are reorganized under one pki.schema namespace. pki.x509.parse is now pki.schema.x509.parse (and .pemDecode / .pemEncode likewise), and the structure-schema engine pki.asn1.schema is now pki.schema.engine. pki.asn1 remains the strict DER codec (decode / encode / build / read / TAGS). This is a breaking rename with no compatibility shim; see MIGRATING. The schema engine also gained a universal-tag optional-field recognizer, which the CRL's bare version / nextUpdate / revokedCertificates fields require.

### Migration

- Replace pki.x509.parse(...) with pki.schema.x509.parse(...); pki.x509.pemDecode / pemEncode become pki.schema.x509.pemDecode / pemEncode.
- Replace pki.asn1.schema (the structure-schema engine) with pki.schema.engine. pki.asn1 is unchanged for the DER codec (pki.asn1.decode / encode / build / read / TAGS).

## v0.1.6 — 2026-07-04

A declarative ASN.1 structure-schema engine; the X.509 parser is rebuilt on it.

### Added

- pki.asn1.schema — a declarative ASN.1 structure-schema engine. A schema is plain data built from combinators (seq / field / optional / explicit / trailing / seqOf / setOf / setOfUnique / choice, plus the value leaves oidLeaf / integerLeaf / boolean / octetString / bitString / any / decode / time); pki.asn1.schema.walk(schema, node, ctx) interprets it against a decoded DER node under an error namespace, enforcing the structural rules in one place: shape assertion, bounds-checked positional reads, optional / context-tagged fields in strictly increasing tag order, SET-OF uniqueness, and fail-closed typed errors. This is the shared base the certificate parser is built on and the forthcoming CRL / CMS parsers compose, so declaring a new format is a data exercise and no format needs a hand-written decoder.

### Changed

- pki.x509.parse is now built on the schema engine: the Certificate, tbsCertificate, and every sub-structure (AlgorithmIdentifier, Name, Validity, SubjectPublicKeyInfo, Extensions) are declared as schemas and walked. Every valid certificate parses to the same result as before, and every malformed certificate is still rejected. The full existing test suite passes unchanged. The certificate's structural rules (positional bounds, the trailing-field grammar, extension uniqueness, the signature-algorithm agreement) now live in one auditable place instead of a hand-written decoder, and the format is structurally incapable of the positional-read and duplicate-field bug classes. The parser now validates the full certificate structure before applying cross-field checks, so a certificate carrying more than one defect at once may be rejected with a different (still fail-closed) error than a prior release reported.

## v0.1.5 — 2026-07-04

Container healthcheck honors WIKI_PORT; release-tooling supply-chain hardening.

### Fixed

- The example wiki container's HEALTHCHECK now probes the port from WIKI_PORT (defaulting to 3009) where it previously hardcoded 3009, so overriding WIKI_PORT at runtime no longer leaves the container reporting unhealthy while the server is serving on the configured port.

### Security

- The CI secret-scan gate now fetches the gitleaks binary over authenticated requests and verifies it against the checksums file published in the same release before executing it, so a corrupted or tampered download fails closed instead of running as the gate. Tracking the latest release keeps detection rules current.
- The release-container workflow validates that the base image resolved to a well-formed sha256 digest before building against it, so a failed resolution can no longer silently produce an unpinned base. The scanned image is always the published one.
- The workflow-security audit re-runs when its own configuration file changes, so an edit that would suppress a finding is itself audited.

## v0.1.4 — 2026-07-04

The ASN.1 codec's universal-type metadata moves to a single descriptor registry.

### Changed

- The ASN.1/DER codec's universal-type metadata is now defined once in a descriptor registry (each entry carries the type's tag and its required DER encoding form). pki.asn1.TAGS, the primitive-only set (a type DER requires primitive, encoded constructed, is rejected) and the constructed-only set (a SEQUENCE/SET encoded primitive is rejected) are all derived from it, so registering a universal type is a single data entry. This is an internal refactor: the public surface and every decode/encode result are unchanged, and it lays the groundwork for schema-driven format parsers.

## v0.1.3 — 2026-07-04

WebCrypto EC key import validates the curve against the key material.

### Security

- pki.webcrypto.subtle.importKey now derives an imported EC key's named curve from the key material and enforces it across the spki, pkcs8 and jwk formats. Previously it trusted the caller-supplied namedCurve without checking it against the key, so a key on an unsupported curve (for example secp256k1) imported as an approved curve, and a key on one curve could be labeled as another, an algorithm-confusion vector in which the CryptoKey's algorithm disagreed with its key material. A curve the framework does not support is now rejected (NotSupportedError) and a namedCurve that does not match the key is rejected (DataError); generateKey already enforced this, and import now matches it. The raw-key format was already validated against its declared curve and is unchanged.

## v0.1.2 — 2026-07-04

Fail-closed hardening across the DER codec, WebCrypto engine, and X.509 parser.

### Changed

- pki.oid gains registerFamily(base, members): register a whole OID arc family in one call by its shared base arc and each member's trailing leaf. The built-in registry is now declared this way, so a new object identifier is a data entry under its family instead of a re-spelled full path.
- Every primitive now declares the normative reference it is derived from (@spec) and, where it guards a known attack, the class it defends (@defends). The generated reference documentation links each citation to its source (RFC section anchors, NIST FIPS, ITU-T, W3C, CVE and CWE), so the surface is traceable to the standards it implements.

### Fixed

- pki.asn1.read.time rejects semantically invalid UTCTime/GeneralizedTime values (Feb 30, month 13, hour 25, second 60, day 00) instead of silently normalizing them, and preserves a four-digit GeneralizedTime year below 100 instead of remapping it a century, so a malformed or edge-case certificate validity window no longer parses to a shifted instant that disagrees with a strict verifier.
- The DER encoder is now symmetric with the decoder, so no builder can emit DER the decoder would reject: build.utcTime rejects a year outside RFC 5280's 1950-2049 window with no silent century wrap, build.generalizedTime zero-pads the year to four digits, build.set orders its components as DER requires, build.integer/enumerated reject an empty or non-minimal content buffer, build.oid caps each sub-identifier, and build.ia5 rejects non-ASCII bytes.
- String decoding validates each restricted type: IA5String and VisibleString reject bytes outside their permitted range, PrintableString rejects characters outside its restricted set, and UTF8String rejects malformed UTF-8 instead of substituting the Unicode replacement character, closing a parser-differential on certificate name fields.
- BIT STRING decoding enforces DER's requirement that unused trailing bits be zero and rejects an empty BIT STRING that declares unused bits; UniversalString and BMPString decoding reject out-of-range and lone-surrogate code points with a typed Asn1Error instead of a bare RangeError.
- HMAC verify resolves false for a wrong-length signature instead of throwing, per the Web Cryptography API. AES-CTR encrypt/decrypt no longer ignore the counter length parameter: a value other than 128 is rejected.
- pki.x509.parse raises a typed CertificateError (not a generic TypeError) for a truncated tbsCertificate, rejects a certificate carrying duplicate extensions (RFC 5280 §4.2), rejects a tbsCertificate with a repeated or out-of-order trailing field, whether that is a second extensions [3] wrapper (which would otherwise hide the first extension block and split duplicate extension OIDs across two wrappers past the per-extension check) or an out-of-order or unknown context field (RFC 5280 §4.1), rejects an empty issuer distinguished name (RFC 5280 §4.1.2.4) while still permitting an empty subject for the subjectAltName case, rejects an empty or non-SEQUENCE extensions field (RFC 5280 §4.1.2.9) with a typed error, validates the certificate version against the RFC 5280 set, and fails closed on a malformed string in a distinguished name (an invalid-UTF8 or out-of-range name value) instead of hex-escaping the invalid bytes away, so the decoder's strict string validation is enforced on the name path; a genuinely non-string attribute value (a primitive ANY-typed value, or a constructed non-string type such as a SEQUENCE) still renders as its RFC 4514 hex-encoded DER so the name stays representable.
- pki.oid.fromArcs rejects a negative or unsafe-integer arc instead of emitting a malformed OID string; the OID sub-identifier ceiling admits a 128-bit UUID-based arc; and the INTEGER ceiling admits a key at the magnitude cap with its DER sign octet.
- pki.version, pki.C.version, and the CLI now report the installed package version. The value is single-sourced from the package manifest and can no longer drift from the published release.

### Security

- The DER decoder now builds every INTEGER and OID sub-identifier in a single linear pass and refuses any that exceed a per-value byte ceiling (C.LIMITS.DER_MAX_INTEGER_BYTES / OID sub-identifier limit), before reading them. Previously these values were accumulated a byte at a time, which is quadratic in their length: a certificate carrying an oversized serial number or OID arc, well within the overall size cap, could pin a CPU for minutes. This closes a remotely-triggerable decode denial-of-service reachable through pki.x509.parse and pki.asn1.read.*.
- A primitive-encoded SEQUENCE or SET no longer decodes to a leaf node. X.690 §8.9.1/§8.11.1 require these to be constructed, so the DER decoder rejects one. Previously such input decoded to a leaf that pki.x509.parse dereferenced as a structured node, crashing with an uncaught TypeError on attacker-controlled bytes; it now fails closed with a typed error.
- The DER decoder also rejects the mirror violation: a constructed encoding of a universal primitive-only type (INTEGER, OBJECT IDENTIFIER, BOOLEAN, the restricted strings, UTCTime/GeneralizedTime, BIT/OCTET STRING), which is valid BER but not valid DER (X.690 §10.2). Previously a constructed string tag decoded to a childless node that a certificate distinguished name would hex-render, letting an invalid BER/DER name value parse despite the restricted-string content checks; it now fails closed at decode.
- pki.webcrypto.subtle.unwrapKey now enforces the 'unwrapKey' key usage on every unwrap path, including the RSA-OAEP and AES-GCM delegate paths that previously skipped it. An unwrapping key without the 'unwrapKey' usage is now rejected. deriveKey now enforces the distinct 'deriveKey' usage; inheriting 'deriveBits' is no longer enough. Both close cases where an operator-set key-usage restriction could be bypassed.
- pki.x509.parse now rejects a certificate whose outer signatureAlgorithm does not match the signature algorithm inside the signed tbsCertificate (RFC 5280 §4.1.1.2). Surfacing the two AlgorithmIdentifiers without enforcing their equality let a certificate claim one algorithm in the signed body and another in the outer wrapper, a signature-algorithm-substitution vector; the two fields must now be identical.

## v0.1.1 — 2026-07-04

First published release of the 0.1.x foundation.

### Changed

- First release published to npm. The toolkit surface is the 0.1.x foundation: pki.asn1 (strict DER codec), pki.oid (OID ↔ name registry), pki.x509.parse (DER/PEM certificate parsing), and pki.webcrypto (a W3C SubtleCrypto engine over node:crypto with ML-DSA/SLH-DSA signatures alongside the full classical set). It is now available on npm with a SLSA provenance attestation, and served as the pkijs.com documentation container.

## v0.1.0 — 2026-07-04

Initial foundation — a PQC-first WebCrypto engine, a strict DER codec, an OID registry, and X.509 certificate parsing.

### Added

- pki.webcrypto — a zero-dependency W3C Web Cryptography API (Crypto / SubtleCrypto / CryptoKey) built on Node's native node:crypto. PQC-first without being PQC-only: ML-DSA-44/65/87 and SLH-DSA signatures sit alongside the full classical set of RSASSA-PKCS1-v1_5, RSA-PSS, RSA-OAEP, ECDSA, ECDH, Ed25519/Ed448, AES-GCM/CBC/CTR/KW, HMAC, HKDF, PBKDF2, and the SHA family (including legacy SHA-1), plus ML-KEM key generation. Every key and signature it emits is OpenSSL/NSS-interoperable.
- pki.asn1 — a strict, fail-closed DER decoder and canonical encoder with a navigable node tree, typed readers (integer, boolean, OID, bit string, octet string, time, string), and value builders. Rejects indefinite length, non-minimal encodings, and trailing bytes, and enforces size and depth caps (X.690).
- pki.oid — a two-way OID ↔ name registry with dotted/arc conversion, seeded with RFC 5280 attribute types and extensions, the classical signature/public-key/digest algorithms, and the NIST post-quantum arcs (ML-DSA, ML-KEM, SLH-DSA).
- pki.x509.parse — parse DER or PEM X.509 certificates into structured, validated fields: version, serial, signature algorithm, issuer/subject distinguished names, validity window as Date values, subject public-key info, and the extension list, with the exact tbsCertificate bytes exposed for downstream verification.
- pki.C — functional scale constants (C.TIME.*, C.BYTES.*) and shared codec limits.
- pki.errors — a PkiError taxonomy with a defineClass factory and stable domain/reason codes.
- pki command-line front-end (version, oid, parse).

### Security

- The DER decoder is fail-closed: non-DER shapes are rejected and size/depth caps are enforced before the parser walks the input, so a hostile length prefix cannot become a decoder denial-of-service.
- The crypto engine is fail-closed: an unknown algorithm, curve, or format is rejected. There is no silent downgrade, and every sign/verify path returns a real verdict or throws.
