@blamejs/pki 0.5.7 → 0.5.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/CHANGELOG.md +405 -386
  2. package/MIGRATING.md +43 -0
  3. package/README.md +12 -12
  4. package/lib/acme.js +31 -31
  5. package/lib/asn1-der.js +10 -10
  6. package/lib/attrcert-sign.js +19 -19
  7. package/lib/byte-reader.js +6 -6
  8. package/lib/byte-writer.js +5 -5
  9. package/lib/cbor-det.js +27 -24
  10. package/lib/cmc-build.js +97 -92
  11. package/lib/cmc-verify.js +106 -30
  12. package/lib/cmp-build.js +25 -25
  13. package/lib/cmp-session.js +70 -70
  14. package/lib/cmp-verify.js +71 -57
  15. package/lib/cms-compress.js +7 -7
  16. package/lib/cms-decrypt.js +90 -72
  17. package/lib/cms-encrypt.js +32 -32
  18. package/lib/cms-sign.js +74 -55
  19. package/lib/cms-verify.js +97 -75
  20. package/lib/composite-sig.js +13 -13
  21. package/lib/constants.js +4 -4
  22. package/lib/crl-sign.js +22 -22
  23. package/lib/crl-verify.js +7 -6
  24. package/lib/crmf-sign.js +14 -14
  25. package/lib/csr-sign.js +8 -8
  26. package/lib/ct.js +37 -37
  27. package/lib/edwards-point.js +7 -7
  28. package/lib/est.js +98 -55
  29. package/lib/framework-error.js +5 -5
  30. package/lib/guard-all.js +3 -3
  31. package/lib/guard-async.js +4 -4
  32. package/lib/guard-bytes.js +79 -79
  33. package/lib/guard-compress.js +17 -17
  34. package/lib/guard-crypto.js +1 -1
  35. package/lib/guard-encoding.js +15 -15
  36. package/lib/guard-header.js +3 -3
  37. package/lib/guard-identifier.js +16 -16
  38. package/lib/guard-json.js +15 -15
  39. package/lib/guard-limits.js +7 -7
  40. package/lib/guard-name.js +81 -16
  41. package/lib/guard-parsed.js +80 -80
  42. package/lib/guard-range.js +19 -19
  43. package/lib/guard-secret.js +11 -10
  44. package/lib/guard-text.js +6 -6
  45. package/lib/guard-time.js +10 -10
  46. package/lib/hpke.js +18 -17
  47. package/lib/http-digest.js +35 -35
  48. package/lib/http-retry-after.js +13 -13
  49. package/lib/http-transport.js +20 -19
  50. package/lib/inspect.js +53 -53
  51. package/lib/ip-utils.js +2 -2
  52. package/lib/jose.js +13 -13
  53. package/lib/key.js +16 -16
  54. package/lib/lint.js +51 -51
  55. package/lib/merkle.js +51 -36
  56. package/lib/mime.js +18 -18
  57. package/lib/ocsp-verify.js +10 -10
  58. package/lib/ocsp.js +13 -13
  59. package/lib/oid.js +29 -29
  60. package/lib/path-validate.js +114 -113
  61. package/lib/pbes2.js +16 -16
  62. package/lib/pkcs12-build.js +53 -53
  63. package/lib/pki-build.js +21 -19
  64. package/lib/rc2.js +1 -1
  65. package/lib/rfc3339.js +5 -5
  66. package/lib/schema-all.js +31 -31
  67. package/lib/schema-attrcert.js +12 -12
  68. package/lib/schema-c509.js +144 -142
  69. package/lib/schema-cmc.js +58 -58
  70. package/lib/schema-cmp.js +43 -43
  71. package/lib/schema-cms.js +45 -45
  72. package/lib/schema-crl.js +7 -7
  73. package/lib/schema-crmf.js +28 -28
  74. package/lib/schema-csr.js +12 -12
  75. package/lib/schema-csrattrs.js +16 -16
  76. package/lib/schema-engine.js +18 -18
  77. package/lib/schema-ocsp.js +15 -15
  78. package/lib/schema-pkcs12.js +20 -20
  79. package/lib/schema-pkcs8.js +2 -2
  80. package/lib/schema-pkix.js +131 -126
  81. package/lib/schema-smime.js +19 -19
  82. package/lib/schema-tsp.js +12 -12
  83. package/lib/schema-x509.js +3 -3
  84. package/lib/shbs.js +18 -18
  85. package/lib/sign-scheme.js +13 -13
  86. package/lib/sigstore.js +10 -11
  87. package/lib/sleep.js +1 -1
  88. package/lib/smime.js +308 -96
  89. package/lib/tls-cert-compress.js +18 -18
  90. package/lib/trust.js +27 -27
  91. package/lib/tsp-sign.js +17 -17
  92. package/lib/validator-all.js +1 -1
  93. package/lib/validator-attcert.js +1 -1
  94. package/lib/validator-cose.js +43 -44
  95. package/lib/validator-keydesc.js +3 -3
  96. package/lib/validator-sig.js +13 -13
  97. package/lib/validator-tls.js +11 -11
  98. package/lib/validator-tpm.js +20 -19
  99. package/lib/webauthn-mds.js +66 -66
  100. package/lib/webauthn.js +33 -33
  101. package/lib/webcrypto.js +15 -15
  102. package/lib/x509-sign.js +13 -13
  103. package/package.json +3 -2
  104. package/sbom.cdx.json +6 -6
package/CHANGELOG.md CHANGED
@@ -4,47 +4,66 @@ All notable changes to `@blamejs/pki` are documented here. The format
4
4
  follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this
5
5
  project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## v0.5.8 — 2026-08-18
8
+
9
+ 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.
10
+
11
+ ### Added
12
+
13
+ - 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.
14
+ - 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 recognise. 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.
15
+
16
+ ### Changed
17
+
18
+ - 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.
19
+
20
+ ### Fixed
21
+
22
+ - 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.
23
+ - 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.
24
+ - 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 defence of RFC 5272 section 6.6, or dataReturn), or allowUnbound: true to interpret a response that could be a replay of any earlier exchange.
25
+
7
26
  ## v0.5.7 — 2026-08-16
8
27
 
9
28
  A CMS signature made over signed attributes can no longer be re-presented as one made over content.
10
29
 
11
30
  ### Added
12
31
 
13
- - 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, content-only binds nothing but the bytes -- and 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.
32
+ - 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.
14
33
 
15
34
  ### Changed
16
35
 
17
- - 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 the point rather than a side effect: 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.
18
- - One argument shape is refused rather than copied: 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.
19
- - 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.
36
+ - 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.
37
+ - 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.
38
+ - 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.
20
39
 
21
40
  ### Fixed
22
41
 
23
- - 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 -- the refusal is the shape, not a failed signature check, which is why it has its own code rather than reading as cms/bad-signature. The condition is necessary to the attack rather than a 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.
24
- - 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.
25
- - 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 rather than throwing, 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 -- an empty certificate does not parse, an empty private key does not import -- the refusal now carries the calling module's code and names the argument, rather than surfacing whatever the later failure raised.
26
- - 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 rather than being copied, so it still satisfies the verbs that require parser provenance, and a CryptoKey is used rather than cloned.
27
- - The verbs documented as returning a Promise now run their body at the call, not 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 rather than throwing; only the timing of the work changed.
42
+ - 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.
43
+ - 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.
44
+ - 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.
45
+ - 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.
46
+ - 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.
28
47
 
29
48
  ## v0.5.6 — 2026-08-15
30
49
 
31
- A CMS SignedData is verified over the bytes it was parsed from, an omitted PKCS#12 password is refused rather than encoded as the empty one, and a verb documented as returning a Promise rejects instead of throwing past your .catch.
50
+ 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.
32
51
 
33
52
  ### Changed
34
53
 
35
- - 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) -- and 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 rather than from the caller. 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 -- which the documented signature never supported, but which 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.
36
- - pki.tsp.verify reports whether revocation was established, not only 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.
54
+ - 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.
55
+ - 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.
37
56
 
38
57
  ### Fixed
39
58
 
40
- - 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 a caller assembled rather than parsed is refused with a typed error instead of dereferenced into a raw TypeError. Passing DER, PEM, or the parser's own unmodified result is unaffected.
41
- - pki.pkcs12.build refuses a store with no password instead of building 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 "".
59
+ - 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.
60
+ - 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 "".
42
61
  - 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.
43
- - pki.pkcs12.open bounds the key-derivation work of a modern store, not only 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 -- but it was charged only on the RFC 7292 Appendix C path, leaving the RFC 8018 PBES2 path -- the one OpenSSL and NSS emit, and the one this toolkit itself builds -- free to do exactly that. 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 rather than after.
44
- - 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 rather than protect it.
45
- - 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 -- and 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.
46
- - 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.
47
- - 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.
62
+ - 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.
63
+ - 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.
64
+ - 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.
65
+ - 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.
66
+ - 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.
48
67
 
49
68
  ## v0.5.5 — 2026-08-15
50
69
 
@@ -52,21 +71,21 @@ A verdict is computed over the bytes the parser read, an identity is derived fro
52
71
 
53
72
  ### Changed
54
73
 
55
- - 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 because the package declared no exports map, not because they were offered, 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.
56
- - 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 rather than as a value, 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 rather than per module, because the surfaces differ -- 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 rather than ignored -- 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.
57
- - 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 rather than of the length -- 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.
74
+ - 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.
75
+ - 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.
76
+ - 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.
58
77
 
59
78
  ### Fixed
60
79
 
61
- - 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 -- while 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 rather than against what any one verb reads, 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.
62
- - 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 a caller assembled rather than parsed is refused. Passing bytes, PEM, or the parser's own unmodified output is unaffected.
63
- - 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 -- and 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.
64
- - 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.
65
- - 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.
66
- - 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 -- a second copy of a private key, which until now 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 rather than protect it.
67
- - 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, not about how carefully their credential is checked.
80
+ - 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.
81
+ - 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.
82
+ - 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.
83
+ - 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.
84
+ - 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.
85
+ - 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.
86
+ - 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.
68
87
  - 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.
69
- - 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.
88
+ - 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.
70
89
 
71
90
  ## v0.5.4 — 2026-08-15
72
91
 
@@ -75,18 +94,18 @@ A path verdict says whether revocation was ever established, a trust anchor's ow
75
94
  ### Added
76
95
 
77
96
  - 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.
78
- - 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 rather than saying nothing at all.
79
- - 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" -- and 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.
97
+ - 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.
98
+ - 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.
80
99
 
81
100
  ### Fixed
82
101
 
83
- - 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) rather than a constraint that silently does nothing.
84
- - 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.
85
- - 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 the caller's own fault to see rather than waive.
86
- - 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 rather than a throw, 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.
87
- - 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 -- so a CRL speaking for part of its issuer's certificates is now refused rather than 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 -- that contradiction belongs to the list, not to whichever entry the serial matched. 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.
88
- - 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 -- rules the shared extension decoder enforces, and which the issuing side and pki.path.validate therefore applied. 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.
89
- - An issuingDistributionPoint scope flag is read under the encoding rules that define it, in both the CRL verbs and the path validator, rather than 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.
102
+ - 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.
103
+ - 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.
104
+ - 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.
105
+ - 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.
106
+ - 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.
107
+ - 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.
108
+ - 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.
90
109
 
91
110
  ## v0.5.3 — 2026-08-14
92
111
 
@@ -94,29 +113,29 @@ pki.webauthn checks the ceremony at registration, withholds a revoked model's an
94
113
 
95
114
  ### Added
96
115
 
97
- - pki.webauthn.verify accepts opts.clientDataJSON as an alternative to the clientDataHash argument -- exactly one of the two, neither 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 rather than chosen by a caller, 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 rather than left silently uncompared.
98
- - 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.
99
- - 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 -- and being able to show the catalogue never went backwards is the whole point of the rule.
100
- - 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.
116
+ - 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.
117
+ - 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.
118
+ - 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.
119
+ - 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.
101
120
 
102
121
  ### Changed
103
122
 
104
- - 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 -- 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 rather than described by whichever parser reaches it first.
105
- - 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 -- 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, rather than a truthy string that demands nothing.
123
+ - 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.
124
+ - 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.
106
125
  - pki.webauthn.metadataAnchors takes a second, optional argument carrying the metadata result, the instant to judge at, and the attestation certificate presented.
107
126
  - The documented signatures of pki.webauthn.verify, verifyAssertion and parseAuthenticatorData list every field their verdicts carry, and verify documents its options.
108
127
 
109
128
  ### Fixed
110
129
 
111
- - 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. It passes the RDN sequence now, and every other call site in the toolkit already did.
112
- - 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 handing 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 -- and applies the strictest reading of whichever you leave out: 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.
113
- - An attestation certificate's public key must be the same KIND of key as the credential public key, not only the same 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 -- and 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.
114
- - 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.
115
- - 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 -- and chaining to the pinned FIDO root does not help, because such a certificate is malformed rather than unissued. Every other Edwards key in the toolkit already passed this gate; the metadata reader takes the same one now, through the shared entry rather than a second copy of it.
116
- - 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 rather than declining to name anything -- and 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 rather than fallen through.
117
- - The RSA credential-key floor is measured in bits, not bytes. 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.
130
+ - 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.
131
+ - 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.
132
+ - 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.
133
+ - 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.
134
+ - 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.
135
+ - 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.
136
+ - 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.
118
137
  - 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.
119
- - The android-safetynet service chain reaches its anchors through the same walk as every other certificate chain in the namespace, rather than 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.
138
+ - 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.
120
139
 
121
140
  ## v0.5.2 — 2026-08-12
122
141
 
@@ -124,19 +143,19 @@ pki.cms.verify gains a trust seam: name the roots you accept, and the verdict sa
124
143
 
125
144
  ### Added
126
145
 
127
- - 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 rather than a missing one, and the same shape pki.cmp.verify returns. Anchors that cannot be read are a caller's configuration mistake and throw, rather than being absorbed into trusted: false, which would report a verdict about the message for a check that never ran.
128
- - 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 -- and 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.
129
- - 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 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 rather than 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.
130
- - 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":[...]} rather than what went in, 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.
146
+ - 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.
147
+ - 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.
148
+ - 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.
149
+ - 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.
131
150
 
132
151
  ### Changed
133
152
 
134
- - 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 -- and path validation checks the CA's keyCertSign, not the target's own usage. 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 rather than a hand-rolled bit test authorizing it. contentCommitment counts alongside digitalSignature. The signature is still reported sound; what changes is whether the certificate was permitted to have made it.
135
- - 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 -- an unchained TSA certificate under valid: true. The refusal names the difference.
153
+ - 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.
154
+ - 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.
136
155
 
137
156
  ### Security
138
157
 
139
- - 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.
158
+ - 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.
140
159
 
141
160
  ## v0.5.1 — 2026-08-12
142
161
 
@@ -144,49 +163,49 @@ Four verify and export paths stop answering a question other than the one they w
144
163
 
145
164
  ### Changed
146
165
 
147
- - 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 rather than satisfied: every comparison inside it was falsy, so 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.
166
+ - 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.
148
167
 
149
168
  ### Fixed
150
169
 
151
- - pki.webcrypto.subtle.exportKey("raw", privateKey) is refused with webcrypto/not-supported rather than answered 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.
152
- - 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 -- so every PQC private JWK 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.
153
- - pki.jose.verify treats opts.key as the key the message must be signed under. Where the profile also permits an embedded header jwk -- 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.
170
+ - 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.
171
+ - 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.
172
+ - 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.
154
173
 
155
174
  ## v0.5.0 — 2026-08-12
156
175
 
157
- 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.
176
+ 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.
158
177
 
159
178
  ### Added
160
179
 
161
180
  - 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.
162
- - 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 and never the reserved 0, and a caller-supplied identifier that clashes is refused rather than renumbered, because a control may already reference it. An Identity Proof V2 witness is computed over the reqSequence bytes exactly as emitted rather than over a re-serialization, 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.
163
- - 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 rather than a fault. Body-part identity is enforced unique across the whole message -- controls, requests, content infos and other messages alike -- rather than per list. The reqSequence bytes are surfaced exactly as they arrived, so an Identity Proof witness is computed over the wire bytes rather than a re-encoding of them.
164
- - 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 rather than read as the answer. A 404 or a 501 is reported as the distinct "not implemented" answer rather than 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.
181
+ - 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.
182
+ - 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.
183
+ - 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.
165
184
  - 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.
166
- - pki.webauthn verifies the authentication half of WebAuthn, not only 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 rather than normalized. 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 a caller compares bytes rather than two spellings of one value.
167
- - 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 rather than implied. 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 rather than refused, each against the roots its own route supplies, and the verdict keeps the entries that governed so the metadata-backed half of the decision stays auditable.
185
+ - 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.
186
+ - 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.
168
187
 
169
188
  ### Changed
170
189
 
171
190
  - 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.
172
- - 68 APIs stay experimental, each with a written reason and a re-review date rather than an open-ended maybe. 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.
173
- - 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 rather than leaking the CMS code.
174
- - 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 -- not in the message, not supplied -- the opt-out is named rather than silent: 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.
175
- - A pki.webauthn verdict can no longer be mistaken for a ceremony verdict. The field is attestationVerified, and signatureVerified for an assertion, rather than verified, because 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 -- and 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 which ceremony a response belongs to is fixed by the specification rather than chosen by the caller, 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.
191
+ - 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.
192
+ - 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.
193
+ - 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.
194
+ - 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.
176
195
 
177
196
  ### Fixed
178
197
 
179
- - 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.
198
+ - 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.
180
199
  - 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.
181
- - 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.
200
+ - 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.
182
201
  - 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.
183
- - 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 defence the assertion input already had, and the documented parsed-certificate form is deep-copied rather than 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.
202
+ - 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 defence 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.
184
203
  - 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.
185
204
  - 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.
186
205
  - 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.
187
- - A key identifier and a pre-encoded CertID are now required to be bytes rather than 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 rather than the two octets a reader means by it. 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.
188
- - 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 defence becoming 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.
189
- - 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 rather than 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 rather than refused, 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.
206
+ - 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.
207
+ - 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 defence 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.
208
+ - 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.
190
209
 
191
210
  ## v0.4.15 — 2026-08-10
192
211
 
@@ -194,28 +213,28 @@ A CA that partitions revocations by reason code, or publishes a delta CRL alongs
194
213
 
195
214
  ### Added
196
215
 
197
- - 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.
198
- - 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 reaches good rather than staying rejected. A delta is merged only when the certificate or the complete CRL carries a freshestCRL locator (sec. 6.3.3(a)(2)).
216
+ - 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.
217
+ - 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)).
199
218
  - 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.
200
- - 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 rather than only that it was revoked.
219
+ - 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.
201
220
 
202
221
  ### Changed
203
222
 
204
- - 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 -- including one naming a base the verifier does not have -- can never erase a revocation. Where several current deltas exist for one scope, which RFC 5280 sec. 5.2.4 permits, the one with the latest thisUpdate is selected rather than the set being treated as a fault.
223
+ - 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.
205
224
  - 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.
206
- - 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.
207
- - 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 -- the same fail-closed reasoning already applied to distribution-point correspondence. It still restricts nothing and withholds good, exactly as before this release.
225
+ - 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.
226
+ - 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.
208
227
  - 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.
209
228
 
210
229
  ### Fixed
211
230
 
212
231
  - 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.
213
- - A CRL that covers no revocation reasons for the certificate -- 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.
214
- - 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 -- resurrecting a revocation the CA withdrew -- while its own release was correctly ignored.
232
+ - 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.
233
+ - 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.
215
234
 
216
235
  ## v0.4.14 — 2026-08-10
217
236
 
218
- Every key-establishment secret this library allocates is now wiped when it stops being needed -- the classical ones too, not only the post-quantum ones.
237
+ Every key-establishment secret this library allocates is now wiped when it stops being needed, classical as well as post-quantum.
219
238
 
220
239
  ### Changed
221
240
 
@@ -224,53 +243,53 @@ Every key-establishment secret this library allocates is now wiped when it stops
224
243
  ### Fixed
225
244
 
226
245
  - 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.
227
- - The AES content-encryption key is cleared after every encrypt and decrypt. The key material was exported into a fresh buffer on each call and never cleared, 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.
246
+ - 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.
228
247
  - 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.
229
- - 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, it is cleared exactly once at the end rather than per recipient -- a message with several recipients still opens correctly for each of them.
230
- - 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 -- including when the password is wrong, which is the path an attacker repeats.
231
- - 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 -- it is allocated only on the failing path, which is the one an attacker drives repeatedly.
232
- - The message-authentication key of an AuthenticatedData is cleared on both sides -- generated, wrapped for every recipient and then cleared once by the producer, and cleared by the consumer after the MAC and message-digest checks, including when a tampered message fails them.
233
- - 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 -- the key guarding a private key, which is the most sensitive thing this library encrypts.
234
- - 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.
235
- - 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.
236
- - A key-derivation function returns an exact-sized buffer the caller wholly owns rather than a window onto a larger accumulator. Where the requested key size is not a multiple of the digest length -- an RC2 key from a SHA-1 block, 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.
237
- - 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 rather than regrown each round (which abandoned an unreachable password-derived copy per iteration).
238
- - 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.
239
- - 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.
240
- - 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.
241
- - 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 including the two validation rejects, which are the paths an attacker induces by tampering with the wrapped key.
242
- - 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.
248
+ - 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.
249
+ - 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.
250
+ - 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.
251
+ - 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.
252
+ - 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.
253
+ - 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.
254
+ - 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.
255
+ - 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.
256
+ - 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.
257
+ - 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.
258
+ - 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.
259
+ - 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.
260
+ - 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.
261
+ - 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.
243
262
  - 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.
244
- - The PKCS#12 password encoding is cleared at every site that builds one -- store integrity on both sides and legacy-PBE decryption -- but only when this library allocated it. 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.
263
+ - 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.
245
264
  - Deriving a key clears the transient bits it derives once they have been imported into the key object, including when the import itself rejects.
246
265
 
247
266
  ## v0.4.13 — 2026-08-09
248
267
 
249
- A KEM shared secret and the key it derives are now wiped as soon as they stop being needed -- on the failing path as well as the succeeding one, which is the path an attacker chooses.
268
+ 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.
250
269
 
251
270
  ### Added
252
271
 
253
- - 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.
254
- - This is best-effort and is documented as such rather than overstated. 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.
272
+ - 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.
273
+ - 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.
255
274
  - pki.oid.kemParams resolves an ML-KEM parameter set to its FIPS 203 Table 3 sizes, by dotted OID or by registered name.
256
275
 
257
276
  ### Changed
258
277
 
259
- - 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 rather than only the CMS path that happens to call it today. 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 a DIFFERENT parameter set is refused as such rather than treated as merely short. The check is on length ONLY: 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.
278
+ - 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.
260
279
  - 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. Behaviour is unchanged.
261
280
 
262
281
  ### Fixed
263
282
 
264
- - 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.
283
+ - 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.
265
284
 
266
285
  ## v0.4.12 — 2026-08-09
267
286
 
268
- 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 rather than opened, unauthenticated, under a result that reported it as authenticated.
287
+ 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.
269
288
 
270
289
  ### Added
271
290
 
272
- - 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 -- though 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.
273
- - The two ends of the SIZE (1..200) bound are separate findings rather than one, 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 rather than to one of the two fields that use it.
291
+ - 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.
292
+ - 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.
274
293
 
275
294
  ### Changed
276
295
 
@@ -280,7 +299,7 @@ A CMS message can no longer declare one content cipher and be opened with anothe
280
299
 
281
300
  ### Fixed
282
301
 
283
- - 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 -- so a caller inspecting contentEncryptionAlgorithm to establish that the content was authenticated was answered from a field the decryption had not honoured. The reverse pairing was refused only incidentally, by a later dereference of parameters the AEAD path expects, rather than by a stated rule.
302
+ - 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 honoured. The reverse pairing was refused only incidentally, by a later dereference of parameters the AEAD path expects, and no stated rule refused it.
284
303
  - 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.
285
304
 
286
305
  ## v0.4.11 — 2026-08-09
@@ -289,22 +308,22 @@ A WebAuthn attestation can now be bound to the roots the authenticator's own mod
289
308
 
290
309
  ### Added
291
310
 
292
- - 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.
293
- - The catalogue's freshness is enforced rather than reported, 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 -- and because a verified result is an ordinary object a relying party may cache, its expiry is re-checked each time it is passed to verify, so 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 rather than needing to be repeated.
294
- - 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 -- signature chaining, validity, and constraints, the same path validation any certificate chain gets, not a name comparison against the top of the path. 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 -- and an unrecognised status is ignored by default, as the specification requires, or treated as disqualifying on request.
295
- - 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 rather than being 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.
296
- - 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.
297
- - Only a catalogue this library verified can decide anything. A metadata result is recognised by provenance rather than by its shape, so an object restored from a cache -- which has been through none of the signature and chain checks, and whose contents an attacker able to write that cache would choose -- is refused rather than accepted as a catalogue. Re-verify the BLOB instead, which the freshness rule asks for anyway. The verified result is also frozen, so the catalogue that decides a later verification is the one the signature covered rather than a copy something has since edited.
298
- - 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 rather than the current clock -- otherwise it would refuse the very registration the format verifier had just accepted. An explicit opts.time still takes precedence.
311
+ - 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.
312
+ - 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.
313
+ - 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 unrecognised status is ignored by default, as the specification requires, or treated as disqualifying on request.
314
+ - 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.
315
+ - 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.
316
+ - Only a catalogue this library verified can decide anything. A metadata result is recognised 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.
317
+ - 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.
299
318
  - 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.
300
- - 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 that one rather than every device 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 recognised by name and public key rather than by self-issuedness, so a chain terminating in a cross-signed form of a root you supplied still anchors to it.
319
+ - 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 recognised 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.
301
320
  - 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.
302
- - 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, per entry rather than for the whole catalogue: 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.
321
+ - 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.
303
322
 
304
323
  ### Changed
305
324
 
306
- - pki.webauthn.verify now rejects an unrecognised option key instead of ignoring it, 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 a configuration fault reported as such, rather than surfacing later as an authenticator trust failure.
307
- - Configuration objects across certificate, CRL, attribute-certificate, CSR, and CMP issuance now reject an unrecognised option key through one shared check instead of 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 recognised option name, and an option object built by parsing JSON that carries its own __proto__ key is now inspected rather than 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 behaviour and no error anywhere.
325
+ - pki.webauthn.verify now rejects an unrecognised 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.
326
+ - Configuration objects across certificate, CRL, attribute-certificate, CSR, and CMP issuance now reject an unrecognised 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 recognised 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 behaviour and no error anywhere.
308
327
 
309
328
  ### Fixed
310
329
 
@@ -312,117 +331,117 @@ A WebAuthn attestation can now be bound to the roots the authenticator's own mod
312
331
 
313
332
  ## v0.4.10 — 2026-08-08
314
333
 
315
- 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.
334
+ 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.
316
335
 
317
336
  ### Added
318
337
 
319
338
  - 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.
320
- - opts.tpmPolicy requires any of those attributes by name, in either direction -- an attribute may be required set or required clear -- and 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.
321
- - The policy also covers the key's access policy: requiring that one is present at all rather than 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.
322
- - A mistyped policy is refused when it is set, not ignored: 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 recognised 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.
323
- - 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 -- 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.
339
+ - 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.
340
+ - 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.
341
+ - 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 recognised 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.
342
+ - 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.
324
343
 
325
344
  ### Fixed
326
345
 
327
- - Calendar dates without a time are now read through the same strict reader as full timestamps, rejecting a date that does not exist rather than rolling it into the following month the way the language would. A day such as the thirtieth of February is refused instead of silently becoming the second of March, and a parsed date is anchored to UTC so a freshness or expiry comparison does not shift with the host's time zone.
346
+ - 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.
328
347
 
329
348
  ## v0.4.9 — 2026-08-08
330
349
 
331
- A WebAuthn compound attestation now verifies -- every nested statement must pass, so a wrapper cannot launder a failed attestation behind one that succeeds -- and the certificate chains an attestation carries are bounded by count, not only by size.
350
+ 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.
332
351
 
333
352
  ### Added
334
353
 
335
- - 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 rather than to a merged verdict that 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.
336
- - 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 rather than by a depth counter. Which CBOR shape a statement takes is now a property of the format rather than a fixed rule, 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.
354
+ - 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.
355
+ - 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.
337
356
 
338
357
  ### Fixed
339
358
 
340
- - 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.
359
+ - 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.
341
360
 
342
361
  ## v0.4.8 — 2026-08-08
343
362
 
344
- 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.
363
+ 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.
345
364
 
346
365
  ### Added
347
366
 
348
- - 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, and with either missing the call is refused rather than falling 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.
349
- - 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 rather than 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.
350
- - 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.
367
+ - 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.
368
+ - 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.
369
+ - 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.
351
370
 
352
371
  ## v0.4.7 — 2026-08-08
353
372
 
354
- One certificate now renders one distinguished-name string whichever parser read it -- a C509 certificate's subject and issuer strings joined their components without the separating space every other parser in the toolkit uses.
373
+ 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.
355
374
 
356
375
  ### Fixed
357
376
 
358
- - 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.
377
+ - 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.
359
378
  - 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.
360
379
 
361
380
  ## v0.4.6 — 2026-08-08
362
381
 
363
- 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.
382
+ 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.
364
383
 
365
384
  ### Fixed
366
385
 
367
- - 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.
368
- - A name holding a single common name is the bare value, not an array of one pair; an extensions field holding only a key usage is the single integer, not an array of two; and an alternative name holding exactly one DNS name is the bare text, not an array. Each of these compact forms is the encoding the specification defines for that case, and the long form of the same value is now refused rather than accepted alongside it. 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.
369
- - 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 rather than on a normalized name, 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.
386
+ - 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.
387
+ - 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.
388
+ - 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.
370
389
  - 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.
371
- - 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.
390
+ - 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.
372
391
 
373
392
  ### Known limitations
374
393
 
375
- - 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 rather than by its C509 bytes.
394
+ - 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.
376
395
 
377
396
  ## v0.4.5 — 2026-08-08
378
397
 
379
- 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, previously reached the crypto library as a key it could not read and failed with a type error instead of a reason.
398
+ 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.
380
399
 
381
400
  ### Fixed
382
401
 
383
402
  - 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.
384
- - 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 -- rather than being 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 rather than exported.
385
- - A key's permitted usages travel with it. Re-importing is the one moment that restriction could be widened -- the new key is created with the usages the operation needs, not the ones the key was created with -- so every needed usage must be present on the original first. A key marked verify-only is refused rather than signing, which is what this toolkit's own engine already did for its own keys; the two now agree.
386
- - 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.
403
+ - 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.
404
+ - 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.
405
+ - 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.
387
406
 
388
407
  ## v0.4.4 — 2026-08-08
389
408
 
390
- 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.
409
+ 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.
391
410
 
392
411
  ### Added
393
412
 
394
- - 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 rather than falling back -- 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.
395
- - Which address form applies is fixed by the specification, not chosen by the sender: 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 instead of giving one certificate two valid encodings.
396
- - 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.
413
+ - 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.
414
+ - 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.
415
+ - 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.
397
416
 
398
417
  ### Fixed
399
418
 
400
- - A certificate whose version is not v3 is now refused with the reason, rather than reported as one this encoder could not reconstruct. 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 rather than 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.
419
+ - 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.
401
420
 
402
421
  ## v0.4.3 — 2026-08-08
403
422
 
404
- 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.
423
+ 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.
405
424
 
406
425
  ### Added
407
426
 
408
- - 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 rather than after 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.
409
- - 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, rather than being 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.
410
- - 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 rather than 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.
411
- - 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 rather than being chosen by the caller, 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.
427
+ - 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.
428
+ - 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.
429
+ - 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.
430
+ - 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.
412
431
  - 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.
413
432
 
414
433
  ### Changed
415
434
 
416
- - pki.cms.sign and pki.cms.verify compute their message digests through the crypto engine rather than each holding a private digest table. Behaviour is unchanged; the digest algorithms a signer and a verifier accept are now defined in one place.
435
+ - pki.cms.sign and pki.cms.verify compute their message digests through the crypto engine; neither holds a private digest table any more. Behaviour is unchanged; the digest algorithms a signer and a verifier accept are now defined in one place.
417
436
 
418
437
  ### Fixed
419
438
 
420
- - 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.
421
- - An unusable hash is now refused when a key is created rather than when it is first used. 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.
439
+ - 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.
440
+ - 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.
422
441
 
423
442
  ## v0.4.2 — 2026-08-07
424
443
 
425
- A NumericString attribute value no longer shares distinguished-name identity with a printable or UTF-8 value of the same characters -- the comparison that 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.
444
+ 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.
426
445
 
427
446
  ### Changed
428
447
 
@@ -431,7 +450,7 @@ A NumericString attribute value no longer shares distinguished-name identity wit
431
450
 
432
451
  ### Fixed
433
452
 
434
- - 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 rather than as a plain string.
453
+ - 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.
435
454
  - 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.
436
455
  - 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.
437
456
  - 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.
@@ -439,101 +458,101 @@ A NumericString attribute value no longer shares distinguished-name identity wit
439
458
 
440
459
  ## v0.4.1 — 2026-08-07
441
460
 
442
- 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, interoperating with a conformant C509 implementation rather than only this decoder.
461
+ 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.
443
462
 
444
463
  ### Added
445
464
 
446
- - 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 rather than an opaque DER byte string. 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 instead of encoding lossily; and a malformed compact value fails closed with a typed C509Error.
465
+ - 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.
447
466
  - The OID registry gains the subjectDirectoryAttributes certificate-extension identifier (2.5.29.9), resolvable through pki.oid.byName / pki.oid.name.
448
- - 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 rather than decoded.
467
+ - 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.
449
468
 
450
469
  ### Changed
451
470
 
452
- - A C509 certificate carrying subjectDirectoryAttributes now encodes to -- and decodes from -- its compact CBOR value shape rather than the unwrapped-OID byte-string form an earlier release emitted; both reconstruct the same DER, but the CBOR bytes differ, so re-encode any C509 produced by an earlier release.
471
+ - 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.
453
472
 
454
473
  ## v0.4.0 — 2026-08-06
455
474
 
456
- 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 and interoperate with a conformant C509 implementation rather than only this decoder.
475
+ 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.
457
476
 
458
477
  ### Added
459
478
 
460
- - 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 rather than an opaque DER byte string. The encoder is guarded: a value the compact form cannot hold falls the whole extension back to the unwrapped-OID byte-string form instead of encoding lossily; 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.
479
+ - 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.
461
480
 
462
481
  ### Changed
463
482
 
464
483
  - 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).
465
- - A C509 certificate carrying policyMappings or policyConstraints now encodes to -- and decodes from -- its compact CBOR value shape rather than the unwrapped-OID byte-string form an earlier release emitted; both reconstruct the same DER, but the CBOR bytes differ, so re-encode any C509 produced by an earlier release.
484
+ - 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.
466
485
 
467
486
  ### Fixed
468
487
 
469
- - 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 rather than reconstructing a DER an X.509 decoder -- this toolkit's included -- would then reject.
488
+ - 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.
470
489
 
471
490
  ## v0.3.33 — 2026-08-05
472
491
 
473
- 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, interoperating with a conformant C509 implementation rather than only this decoder.
492
+ 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.
474
493
 
475
494
  ### Added
476
495
 
477
- - 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 rather than an opaque DER byte string. 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 instead of encoding lossily; 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.
496
+ - 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.
478
497
  - 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.
479
498
 
480
499
  ### Changed
481
500
 
482
- - A C509 certificate carrying certificatePolicies now encodes to -- and decodes from -- its compact CBOR value shape rather than the unwrapped-OID byte-string form an earlier release emitted; both reconstruct the same DER, but the CBOR bytes differ, so re-encode any C509 produced by an earlier release.
501
+ - 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.
483
502
 
484
503
  ## v0.3.32 — 2026-08-05
485
504
 
486
- 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 and interoperate with a conformant C509 implementation, not only this decoder.
505
+ 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.
487
506
 
488
507
  ### Added
489
508
 
490
- - 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 rather than 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 instead of encoding a partial or lossy value; a malformed compact value fails closed with a typed C509Error.
509
+ - 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.
491
510
  - 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.
492
511
 
493
512
  ### Changed
494
513
 
495
- - 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 rather than the unwrapped-OID byte-string form an earlier release emitted; 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.
514
+ - 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.
496
515
 
497
516
  ## v0.3.31 — 2026-08-03
498
517
 
499
- 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 and interoperate with a conformant C509 implementation, not only this decoder.
518
+ 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.
500
519
 
501
520
  ### Added
502
521
 
503
- - 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) rather than 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 instead of encoding lossily; 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.
522
+ - 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.
504
523
 
505
524
  ### Changed
506
525
 
507
- - 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.
526
+ - 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.
508
527
 
509
528
  ## v0.3.29 — 2026-08-02
510
529
 
511
- 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.
530
+ 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.
512
531
 
513
532
  ### Added
514
533
 
515
- - 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 rather than acted on.
516
- - 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 rather than substituted. Each alternate is fetched by the same POST-as-GET path, inheriting the media-type, size, and strict-chain-parse gates.
517
- - 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, 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 -- (an injectable random and clock make the decision deterministic and let a caller ask 'renew as of time T?') so many clients do not renew at the same edge, and returns { suggestedWindow, selectedTime, renewNow, retryAfterSeconds, explanationURL } -- 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 instead of re-randomizing it. The helper returns the decision as data; it never sleeps or schedules on a background timer.
534
+ - 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.
535
+ - 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.
536
+ - 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.
518
537
 
519
538
  ### Changed
520
539
 
521
- - 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.
522
- - 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).
540
+ - 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.
541
+ - 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).
523
542
 
524
543
  ## v0.3.28 — 2026-08-01
525
544
 
526
- 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.
545
+ 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.
527
546
 
528
547
  ### Added
529
548
 
530
- - 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, not the throwaway CSR key). 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 rather than handed back unusable. 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.
549
+ - 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.
531
550
  - 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.
532
- - 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 a usable offer instead of picking the strongest and failing; when no offer is usable it fails closed with the specific reason rather than downgrading 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, not 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), and a same-origin resource outside that scope is authenticated afresh (bounded) -- classified by whether the request actually carried a credential, so a resource sent unauthenticated is never mistaken for a rejected one -- rather than sent the first realm's response or 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) rather than the legacy quoted field a server would read as ISO-8859-1, and 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.
551
+ - 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.
533
552
 
534
553
  ## v0.3.27 — 2026-07-31
535
554
 
536
- 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.
555
+ 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.
537
556
 
538
557
  ### Added
539
558
 
@@ -541,49 +560,49 @@ pki.cmp.session drives a full CMP certificate enrollment end to end -- build, tr
541
560
 
542
561
  ## v0.3.26 — 2026-07-30
543
562
 
544
- pki.cmp.verify checks the protection on an incoming CMP PKIMessage -- verify a signature or PBMAC1 MAC over the exact protected bytes, and optionally chain the signer certificate to a trust anchor.
563
+ 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.
545
564
 
546
565
  ### Added
547
566
 
548
- - 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.
567
+ - 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.
549
568
 
550
569
  ### Fixed
551
570
 
552
- - 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 rather than being silently bypassed by dropping the callback.
571
+ - 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.
553
572
 
554
573
  ## v0.3.25 — 2026-07-26
555
574
 
556
- 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.
575
+ 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.
557
576
 
558
577
  ### Added
559
578
 
560
- - 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.
561
- - pki.inspect renders the authorityInfoAccess extension (RFC 5280 sec. 4.2.2.1) -- the CA Issuers and OCSP access descriptions with their URLs -- instead of a hex dump.
579
+ - 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.
580
+ - 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.
562
581
 
563
582
  ### Changed
564
583
 
565
- - 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.
584
+ - 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.
566
585
 
567
586
  ## v0.3.24 — 2026-07-26
568
587
 
569
- 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.
588
+ 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.
570
589
 
571
590
  ### Added
572
591
 
573
- - 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.
574
- - 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 -- so a consumer keying trust off present/protectedHeaders is never misled by the opt-in heuristic, and consuming headerProtection.legacy.headers is an explicit, fromMismatch-checkable choice.
592
+ - 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.
593
+ - 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.
575
594
 
576
595
  ### Changed
577
596
 
578
- - 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.
597
+ - 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.
579
598
 
580
599
  ## v0.3.23 — 2026-07-26
581
600
 
582
- pki.pkcs12.open now reads legacy PKCS#12 stores -- decrypt the RFC 7292 Appendix C 3DES and RC2 bags an `openssl pkcs12 -legacy` (and NSS) store uses, so an older .p12/.pfx opens instead of being refused.
601
+ 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.
583
602
 
584
603
  ### Added
585
604
 
586
- - pki.pkcs12.open decrypts the RFC 7292 Appendix C legacy PBE bags: pbeWithSHAAnd3-KeyTripleDES-CBC, pbeWithSHAAnd2-KeyTripleDES-CBC, pbeWithSHAAnd128BitRC2-CBC, and pbeWithSHAAnd40BitRC2-CBC. This lets an `openssl pkcs12 -legacy` or NSS store -- whose default key bag is 3DES and default cert bag is 40-bit RC2 -- open, 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.
605
+ - 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.
587
606
  - 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.
588
607
 
589
608
  ### Changed
@@ -592,62 +611,62 @@ pki.pkcs12.open now reads legacy PKCS#12 stores -- decrypt the RFC 7292 Appendix
592
611
 
593
612
  ## v0.3.22 — 2026-07-26
594
613
 
595
- S/MIME header protection ships -- cover the message headers (Subject, From, To, ...) under the CMS signature or encryption with pki.smime, RFC 9788.
614
+ S/MIME header protection ships: cover the message headers (Subject, From, To, ...) under the CMS signature or encryption with pki.smime, RFC 9788.
596
615
 
597
616
  ### Added
598
617
 
599
- - 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.
600
- - 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 -- never a silent downgrade. RFC 9788.
618
+ - 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.
619
+ - 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.
601
620
  - 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).
602
621
 
603
622
  ## v0.3.21 — 2026-07-26
604
623
 
605
- 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.
624
+ 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.
606
625
 
607
626
  ### Added
608
627
 
609
- - pki.ct.fetchLogList(opts) -- fetch the Certificate Transparency log list live and return 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.
610
- - 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) -- the freshness surface a caller polices, read leniently from the same document. Existing callers of the { logs, byLogId } shape are unaffected.
628
+ - 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.
629
+ - 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.
611
630
 
612
631
  ## v0.3.20 — 2026-07-25
613
632
 
614
- 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.
633
+ 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.
615
634
 
616
635
  ### Added
617
636
 
618
- - PKCS#12 public-key privacy (RFC 7292 sec. 3.1): pki.pkcs12.build encrypts a SafeContents to recipient public keys -- per-safeContents recipients: [{ cert }, ...] (certificate recipients only) with an optional contentEncryptionAlgorithm (aes-128|192|256-cbc, default 256; GCM/AEAD rejected), or 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.
619
- - 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 -- 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.
637
+ - 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.
638
+ - 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.
620
639
 
621
640
  ### Fixed
622
641
 
623
- - 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.
642
+ - 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.
624
643
 
625
644
  ## v0.3.19 — 2026-07-25
626
645
 
627
- 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).
646
+ 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).
628
647
 
629
648
  ### Added
630
649
 
631
- - pki.cmp.transfer(url, message, opts) -- 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, 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.
632
- - pki.cmp.wellKnownUrl(base, opts) -- 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.
650
+ - 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.
651
+ - 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.
633
652
 
634
653
  ### Changed
635
654
 
636
- - 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).
637
- - 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.
655
+ - 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).
656
+ - 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.
638
657
 
639
658
  ## v0.3.18 — 2026-07-25
640
659
 
641
- The ACME client ships -- pki.acme drives the full RFC 8555 certificate-issuance flow over the shared node:https transport.
660
+ The ACME client ships. pki.acme drives the full RFC 8555 certificate-issuance flow over the shared node:https transport.
642
661
 
643
662
  ### Added
644
663
 
645
- - pki.acme.client(directoryUrl, opts) -- 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.
664
+ - 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.
646
665
  - 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).
647
666
 
648
667
  ### Fixed
649
668
 
650
- - 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.
669
+ - 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.
651
670
 
652
671
  ## v0.3.17 — 2026-07-24
653
672
 
@@ -659,12 +678,12 @@ Refresh two development-only tooling dependencies to clear newly-disclosed advis
659
678
 
660
679
  ## v0.3.16 — 2026-07-24
661
680
 
662
- 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.
681
+ 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.
663
682
 
664
683
  ### Added
665
684
 
666
- - pki.est.cacerts(baseUrl, opts?), pki.est.simpleenroll(baseUrl, csr, opts?), and pki.est.simplereenroll(baseUrl, csr, opts?) -- 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.
667
- - pki.transport -- the shared, fail-closed node:https transport the enrollment clients drive. 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.
685
+ - 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.
686
+ - 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.
668
687
 
669
688
  ## v0.3.15 — 2026-07-24
670
689
 
@@ -673,7 +692,7 @@ PKCS#12 public-key integrity is produced and verified (RFC 7292 sec. 4).
673
692
  ### Added
674
693
 
675
694
  - 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.
676
- - pki.pkcs12.open verifies a public-key-integrity store. It runs pki.cms.verify over the store's CMS SignedData authSafe FIRST -- the integrity gate, exactly as the MAC is for password mode -- and returns nothing on a failure (pkcs12/signature-invalid), before decrypting any bag. 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.
695
+ - 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.
677
696
 
678
697
  ## v0.3.14 — 2026-07-23
679
698
 
@@ -682,7 +701,7 @@ CMS AuthenticatedData is produced and verified (RFC 5652 sec. 9).
682
701
  ### Added
683
702
 
684
703
  - 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.
685
- - 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.
704
+ - 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.
686
705
 
687
706
  ## v0.3.13 — 2026-07-23
688
707
 
@@ -690,8 +709,8 @@ CMS gains countersignatures and unsigned attributes (RFC 5652 sec. 11.4).
690
709
 
691
710
  ### Added
692
711
 
693
- - 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.
694
- - pki.cms.verify surfaces countersignatures and unsigned attributes per signer. Each res.signers[i] carries countersignatures -- an array of per-countersignature verdicts { ok, sid, cert, digestAlgorithm, ... }, each verified over the exact RFC 5652 section 11.4 preimage, nested for a countersignature of a countersignature -- and unsignedAttrs, 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.
712
+ - 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.
713
+ - 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.
695
714
  - 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.
696
715
 
697
716
  ## v0.3.12 — 2026-07-23
@@ -700,7 +719,7 @@ pki.pkcs12.open reads and decrypts a password-integrity PKCS#12 store (RFC 7292,
700
719
 
701
720
  ### Added
702
721
 
703
- - 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 } -- 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.
722
+ - 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.
704
723
 
705
724
  ## v0.3.11 — 2026-07-23
706
725
 
@@ -719,10 +738,10 @@ pki.key exports, imports, and PBES2-encrypts private keys (RFC 5958, RFC 8018) o
719
738
  ### Added
720
739
 
721
740
  - 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.
722
- - 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.
723
- - 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.
724
- - 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 rather than guess a use.
725
- - 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.
741
+ - 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.
742
+ - 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.
743
+ - 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.
744
+ - 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.
726
745
  - 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).
727
746
 
728
747
  ## v0.3.9 — 2026-07-23
@@ -732,7 +751,7 @@ pki.crl builds, signs, and verifies X.509 certificate revocation lists (RFC 5280
732
751
  ### Added
733
752
 
734
753
  - 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.
735
- - 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 -- the 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.
754
+ - 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.
736
755
  - 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.
737
756
 
738
757
  ## v0.3.8 — 2026-07-18
@@ -741,8 +760,8 @@ Human-readable inspection extends to CRLs, CSRs, and CMS messages, with a pki.sc
741
760
 
742
761
  ### Added
743
762
 
744
- - 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 rather than 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.
745
- - 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. The detection half of pki.schema.parse, over the same authoritative format ordering.
763
+ - 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.
764
+ - 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.
746
765
 
747
766
  ## v0.3.7 — 2026-07-17
748
767
 
@@ -750,84 +769,84 @@ Certification path building arrives as pki.path.build, and pki.lint gains seven
750
769
 
751
770
  ### Added
752
771
 
753
- - pki.path.build(leaf, opts) discovers the ordered certification path from a leaf up to a trust anchor over an untrusted pool of candidate CA certificates, then validates it -- the discovering complement of pki.path.validate. 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.
772
+ - 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.
754
773
  - 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.
755
774
 
756
775
  ## v0.3.6 — 2026-07-17
757
776
 
758
- pki.cmp.build gains the CA/responder side -- certificate, revocation, key-recovery, general, error, poll, and confirmation responses complete the RFC 9810 message surface.
777
+ pki.cmp.build gains the CA/responder side. Certificate, revocation, key-recovery, general, error, poll, and confirmation responses complete the RFC 9810 message surface.
759
778
 
760
779
  ### Added
761
780
 
762
- - pki.cmp.build message.body now accepts the CA/responder-side arms: ip / cp / kup / ccp (a CertRepMessage -- 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.
781
+ - 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.
763
782
 
764
783
  ## v0.3.5 — 2026-07-17
765
784
 
766
- 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.
785
+ 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.
767
786
 
768
787
  ### Added
769
788
 
770
- - 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.
771
- - pki.crmf.buildCertTemplate(template) encodes a bare RFC 4211 CertTemplate (subject, public key, validity, requested extensions, version) to canonical DER -- the certTemplate interior of pki.crmf.build, exposed for the CMP rr revocation body whose certDetails names the certificate to revoke.
789
+ - 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.
790
+ - 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.
772
791
 
773
792
  ## v0.3.4 — 2026-07-17
774
793
 
775
- 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.
794
+ 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.
776
795
 
777
796
  ### Added
778
797
 
779
- - 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.
780
- - 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.
798
+ - 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.
799
+ - 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.
781
800
 
782
801
  ## v0.3.3 — 2026-07-17
783
802
 
784
- pki.crmf.build issues RFC 4211 certificate request messages -- a CertReqMessages with a signature proof of possession, over every algorithm the toolkit supports.
803
+ pki.crmf.build issues RFC 4211 certificate request messages: a CertReqMessages with a signature proof of possession, over every algorithm the toolkit supports.
785
804
 
786
805
  ### Added
787
806
 
788
- - pki.crmf.build(spec, key, opts) builds and DER-encodes an RFC 4211 CertReqMessages -- 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) -- and returns DER, or a PEM block with opts.pem. 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.
789
- - 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 -- the single home for the IMPLICIT tag replacement the CertTemplate and other context-tagged structures compose.
807
+ - 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.
808
+ - 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.
790
809
 
791
810
  ## v0.3.2 — 2026-07-17
792
811
 
793
- 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.
812
+ 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.
794
813
 
795
814
  ### Added
796
815
 
797
- - 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.
816
+ - 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.
798
817
 
799
818
  ## v0.3.1 — 2026-07-17
800
819
 
801
- 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.
820
+ 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.
802
821
 
803
822
  ### Added
804
823
 
805
- - pki.csr.sign(spec, key, opts) builds and signs a PKCS#10 certification request -- self-signed by the subject's own key for proof of possession -- and returns 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.
824
+ - 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.
806
825
 
807
826
  ### Fixed
808
827
 
809
- - 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, rather than emitting a malformed object identifier -- an unknown name in a pki.x509.sign or pki.csr.sign spec fails closed instead of producing an unparseable structure.
828
+ - 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.
810
829
 
811
830
  ## v0.3.0 — 2026-07-16
812
831
 
813
- pki.x509.sign issues X.509 certificates -- self-signed or CA-signed, over every signature algorithm the toolkit supports, from RSA and ECDSA through EdDSA, ML-DSA, and SLH-DSA.
832
+ 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.
814
833
 
815
834
  ### Added
816
835
 
817
- - pki.x509.sign(spec, issuer, opts) builds and signs an X.509 certificate -- self-signed (a signing key alone) or CA-signed (an explicit issuer name and public key, or an issuing certificate) -- and returns DER, or a PEM CERTIFICATE string with opts.pem. 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.
818
- - 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 single canonical home the keyUsage and PKIFailureInfo encoders now compose.
836
+ - 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.
837
+ - 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.
819
838
 
820
839
  ### Security
821
840
 
822
- - 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, not only 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.
841
+ - 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.
823
842
 
824
843
  ## v0.2.33 — 2026-07-16
825
844
 
826
- Attribute certificates now decode their RFC 5755 attribute values and attribute-certificate extensions, not just the certificate structure.
845
+ Attribute certificates now decode their RFC 5755 attribute values and attribute-certificate extensions alongside the certificate structure.
827
846
 
828
847
  ### Added
829
848
 
830
- - 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) -- and 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.
849
+ - 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.
831
850
 
832
851
  ## v0.2.32 — 2026-07-16
833
852
 
@@ -835,11 +854,11 @@ X.509 certificates now decode the Microsoft Active Directory Certificate Service
835
854
 
836
855
  ### Added
837
856
 
838
- - 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, not a signed subset.
857
+ - 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.
839
858
 
840
859
  ### Changed
841
860
 
842
- - 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 -- rather than treating any extension it can merely decode 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.
861
+ - 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.
843
862
 
844
863
  ## v0.2.31 — 2026-07-16
845
864
 
@@ -847,12 +866,12 @@ X.509 certificates now decode the RFC 3739 / ETSI EN 319 412-5 qualified-certifi
847
866
 
848
867
  ### Added
849
868
 
850
- - 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 -- 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 -- are decoded and validated by the toolkit's certificate-extension decoders and rendered by pki.inspect. An unknown statementId is preserved opaque (its raw bytes, semantics not executed); a malformed shape fails closed with a typed error.
869
+ - 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.
851
870
 
852
871
  ### Security
853
872
 
854
- - 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.
855
- - 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.
873
+ - 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.
874
+ - 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.
856
875
 
857
876
  ## v0.2.30 — 2026-07-16
858
877
 
@@ -860,7 +879,7 @@ C509 CBOR-encoded certificates arrive as pki.schema.c509.parse: decode the compa
860
879
 
861
880
  ### Added
862
881
 
863
- - 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, not DER, so it is an explicit-call surface and is not auto-routed by pki.schema.parse.
882
+ - 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.
864
883
 
865
884
  ### Fixed
866
885
 
@@ -880,8 +899,8 @@ The Certificate Transparency log-list trust surface arrives as pki.ct.parseLogLi
880
899
 
881
900
  ### Added
882
901
 
883
- - 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.
884
- - 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.
902
+ - 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.
903
+ - 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.
885
904
 
886
905
  ## v0.2.27 — 2026-07-16
887
906
 
@@ -889,8 +908,8 @@ CMS CompressedData arrives as pki.cms.compress / pki.cms.decompress, with the ma
889
908
 
890
909
  ### Added
891
910
 
892
- - 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 rather than exhausting memory. Every malformed or truncated stream fails closed as cms/decompress-failed. Fail-closed with typed CmsError.
893
- - 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.
911
+ - 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.
912
+ - 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.
894
913
 
895
914
  ## v0.2.26 — 2026-07-16
896
915
 
@@ -899,7 +918,7 @@ S/MIME encryption arrives as pki.smime.encrypt / pki.smime.decrypt: envelope and
899
918
  ### Added
900
919
 
901
920
  - 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.
902
- - 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, not 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.
921
+ - 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.
903
922
 
904
923
  ## v0.2.25 — 2026-07-16
905
924
 
@@ -916,17 +935,17 @@ SCT-list encoding and log signing join pki.ct: encodeSctList builds an RFC 6962
916
935
 
917
936
  ### Added
918
937
 
919
- - 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.
938
+ - 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.
920
939
  - 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.
921
940
 
922
941
  ## v0.2.23 — 2026-07-16
923
942
 
924
- 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 (RFC 9629/9936) -- and a single, oracle-free decryption verdict.
943
+ 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.
925
944
 
926
945
  ### Added
927
946
 
928
947
  - 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.
929
- - 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.
948
+ - 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.
930
949
  - 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.
931
950
 
932
951
  ## v0.2.22 — 2026-07-15
@@ -936,7 +955,7 @@ The RFC 6960 OCSP producer and relying-party surface arrives as pki.ocsp: build
936
955
  ### Added
937
956
 
938
957
  - 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.
939
- - 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.
958
+ - 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.
940
959
  - 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.
941
960
 
942
961
  ## v0.2.21 — 2026-07-15
@@ -945,56 +964,56 @@ ML-KEM public keys in X.509 certificates and PKCS#8 private keys (RFC 9935 / FIP
945
964
 
946
965
  ### Added
947
966
 
948
- - 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).
949
- - 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.
967
+ - 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).
968
+ - 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.
950
969
 
951
970
  ### Fixed
952
971
 
953
- - 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.
954
- - 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 rather than importing a mislabeled key handle.
972
+ - 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.
973
+ - 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.
955
974
 
956
975
  ## v0.2.20 — 2026-07-15
957
976
 
958
- 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.
977
+ 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.
959
978
 
960
979
  ### Added
961
980
 
962
- - 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.
981
+ - 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.
963
982
 
964
983
  ### Changed
965
984
 
966
- - 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.
985
+ - 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.
967
986
 
968
987
  ### Fixed
969
988
 
970
- - 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.
989
+ - 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.
971
990
 
972
991
  ## v0.2.19 — 2026-07-14
973
992
 
974
- 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.
993
+ 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.
975
994
 
976
995
  ### Added
977
996
 
978
- - 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 -- and pki.tsp.parseResponse parses one; 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.
979
- - 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.
997
+ - 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.
998
+ - 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.
980
999
 
981
1000
  ### Fixed
982
1001
 
983
- - 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.
1002
+ - 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.
984
1003
  - 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.
985
1004
 
986
1005
  ## v0.2.18 — 2026-07-14
987
1006
 
988
- 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.
1007
+ 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.
989
1008
 
990
1009
  ### Added
991
1010
 
992
- - 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).
993
- - 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.
1011
+ - 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).
1012
+ - 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.
994
1013
 
995
1014
  ### Fixed
996
1015
 
997
- - 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) -- 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.
1016
+ - 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.
998
1017
 
999
1018
  ## v0.2.17 — 2026-07-13
1000
1019
 
@@ -1002,7 +1021,7 @@ Post-quantum SLH-DSA joins CMS SignedData: pki.cms.sign and pki.cms.verify now s
1002
1021
 
1003
1022
  ### Added
1004
1023
 
1005
- - 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 -- freely mixed 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.
1024
+ - 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.
1006
1025
  - 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.
1007
1026
 
1008
1027
  ### Changed
@@ -1015,16 +1034,16 @@ Post-quantum ML-DSA joins CMS SignedData: pki.cms.sign and pki.cms.verify now si
1015
1034
 
1016
1035
  ### Added
1017
1036
 
1018
- - 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.
1037
+ - 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.
1019
1038
  - 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.
1020
1039
 
1021
1040
  ### Changed
1022
1041
 
1023
- - 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 rather than surfacing an opaque import failure.
1042
+ - 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.
1024
1043
 
1025
1044
  ## v0.2.15 — 2026-07-13
1026
1045
 
1027
- 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.
1046
+ 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.
1028
1047
 
1029
1048
  ### Added
1030
1049
 
@@ -1033,11 +1052,11 @@ CMS SignedData signing arrives as pki.cms.sign, and RFC 3161 timestamp token cre
1033
1052
 
1034
1053
  ## v0.2.14 — 2026-07-13
1035
1054
 
1036
- 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.
1055
+ 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.
1037
1056
 
1038
1057
  ### Added
1039
1058
 
1040
- - 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.
1059
+ - 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.
1041
1060
 
1042
1061
  ## v0.2.13 — 2026-07-13
1043
1062
 
@@ -1057,7 +1076,7 @@ pki.ct.verifySct verifies a Signed Certificate Timestamp's signature against a C
1057
1076
 
1058
1077
  ## v0.2.11 — 2026-07-13
1059
1078
 
1060
- 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.
1079
+ 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.
1061
1080
 
1062
1081
  ### Added
1063
1082
 
@@ -1068,11 +1087,11 @@ The pki command-line tool gains inspect, lint, convert, and verify -- front-ends
1068
1087
 
1069
1088
  ## v0.2.10 — 2026-07-13
1070
1089
 
1071
- 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.
1090
+ 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.
1072
1091
 
1073
1092
  ### Added
1074
1093
 
1075
- - 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 rather than an exception -- so a corpus lints without per-file error handling; only config-time misuse raises a typed LintError.
1094
+ - 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.
1076
1095
  - 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.
1077
1096
 
1078
1097
  ## v0.2.9 — 2026-07-13
@@ -1081,7 +1100,7 @@ Certification-path validation verifies composite ML-DSA signatures, accepting a
1081
1100
 
1082
1101
  ### Added
1083
1102
 
1084
- - 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.
1103
+ - 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.
1085
1104
  - 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.
1086
1105
 
1087
1106
  ## v0.2.8 — 2026-07-13
@@ -1090,7 +1109,7 @@ pki.webcrypto rejects an AES key of invalid length at import instead of deferrin
1090
1109
 
1091
1110
  ### Fixed
1092
1111
 
1093
- - 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, rather than returning a CryptoKey that only fails at first use. Covers AES-GCM, AES-CBC, AES-CTR, and AES-KW; HMAC, HKDF, and PBKDF2 keys are unaffected.
1112
+ - 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.
1094
1113
  - 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.
1095
1114
 
1096
1115
  ## v0.2.7 — 2026-07-13
@@ -1099,12 +1118,12 @@ pki.webcrypto rejects an imported key whose type disagrees with its algorithm, a
1099
1118
 
1100
1119
  ### Changed
1101
1120
 
1102
- - 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.
1121
+ - 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.
1103
1122
 
1104
1123
  ### Fixed
1105
1124
 
1106
1125
  - 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.
1107
- - 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 rather than a bare Node error crossing the API boundary.
1126
+ - 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.
1108
1127
 
1109
1128
  ## v0.2.6 — 2026-07-12
1110
1129
 
@@ -1112,17 +1131,17 @@ WebAuthn attestation verification covers Ed448 and the RFC 9864 fully-specified
1112
1131
 
1113
1132
  ### Added
1114
1133
 
1115
- - 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 rather than erroring as an unsupported algorithm.
1116
- - The verifier is now checked against the official W3C WebAuthn Level 3 test-vector suite -- every published vector across ES256 / ES384 / ES512 / RS256 / Ed25519 / Ed448 and the packed / self / tpm / apple / fido-u2f / none formats -- as an independent cross-implementation oracle.
1134
+ - 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.
1135
+ - 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.
1117
1136
 
1118
1137
  ### Changed
1119
1138
 
1120
- - 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.
1139
+ - 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.
1121
1140
  - An EC2 credential key must use the uncompressed point form; the compressed sign-bit y encoding is rejected for WebAuthn credential keys.
1122
1141
 
1123
1142
  ### Fixed
1124
1143
 
1125
- - 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 rather than being carried into a later verify step.
1144
+ - 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.
1126
1145
  - 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.
1127
1146
 
1128
1147
  ## v0.2.5 — 2026-07-12
@@ -1132,12 +1151,12 @@ WebAuthn / passkey attestation verification joins the toolkit as pki.webauthn.
1132
1151
  ### Added
1133
1152
 
1134
1153
  - 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.
1135
- - 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.
1154
+ - 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.
1136
1155
  - 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.
1137
1156
 
1138
1157
  ### Changed
1139
1158
 
1140
- - 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.
1159
+ - 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.
1141
1160
 
1142
1161
  ## v0.2.4 — 2026-07-12
1143
1162
 
@@ -1153,7 +1172,7 @@ Offline Sigstore bundle verification joins the toolkit as pki.sigstore.
1153
1172
 
1154
1173
  ### Added
1155
1174
 
1156
- - 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.
1175
+ - 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.
1157
1176
  - 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.
1158
1177
 
1159
1178
  ## v0.2.2 — 2026-07-11
@@ -1163,7 +1182,7 @@ Hybrid Public Key Encryption (RFC 9180) joins the toolkit as pki.hpke.
1163
1182
  ### Added
1164
1183
 
1165
1184
  - 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.
1166
- - 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 rather than key-scheduled), 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).
1185
+ - 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).
1167
1186
 
1168
1187
  ## v0.2.1 — 2026-07-11
1169
1188
 
@@ -1171,12 +1190,12 @@ Stateful hash-based signature verification (HSS/LMS) joins the toolkit as pki.sh
1171
1190
 
1172
1191
  ### Added
1173
1192
 
1174
- - 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.
1175
- - 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/*).
1193
+ - 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.
1194
+ - 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/*).
1176
1195
 
1177
1196
  ### Changed
1178
1197
 
1179
- - 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.
1198
+ - 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.
1180
1199
 
1181
1200
  ## v0.2.0 — 2026-07-11
1182
1201
 
@@ -1186,13 +1205,13 @@ Trust-store ingestion, sharded-CRL revocation, and a hardened input-guard layer.
1186
1205
 
1187
1206
  - 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.
1188
1207
  - 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.
1189
- - 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 -- and a listed serial still reports revoked regardless of correspondence.
1208
+ - 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.
1190
1209
  - 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.
1191
1210
  - 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).
1192
1211
 
1193
1212
  ### Changed
1194
1213
 
1195
- - 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).
1214
+ - 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).
1196
1215
  - 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.
1197
1216
 
1198
1217
  ### Fixed
@@ -1201,7 +1220,7 @@ Trust-store ingestion, sharded-CRL revocation, and a hardened input-guard layer.
1201
1220
  - 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.
1202
1221
  - 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.
1203
1222
  - A malformed content-type attribute value inside AuthEnvelopedData authenticated attributes surfaces cms/bad-content-type-attr instead of a raw asn1/* codec error.
1204
- - 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.
1223
+ - 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.
1205
1224
 
1206
1225
  ## v0.1.32 — 2026-07-11
1207
1226
 
@@ -1218,7 +1237,7 @@ The DER format cohort and the JOSE surface graduate to stable.
1218
1237
  ### Changed
1219
1238
 
1220
1239
  - 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.
1221
- - 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, rather than on a harness oracle that does not exist.
1240
+ - 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.
1222
1241
 
1223
1242
  ## v0.1.30 — 2026-07-11
1224
1243
 
@@ -1226,11 +1245,11 @@ Fail-closed hardening of the byte-input and text-decode boundaries.
1226
1245
 
1227
1246
  ### Changed
1228
1247
 
1229
- - A detached-backed BufferSource (a transferred / structuredClone'd view) passed to any DER format parser -- pki.schema.x509 / crl / csr / pkcs8 / cms / pkcs12 -- fails closed with the format's typed bad-input error at the shared parse-input boundary rather than being decoded as an empty buffer.
1248
+ - 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.
1230
1249
 
1231
1250
  ### Fixed
1232
1251
 
1233
- - 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.
1252
+ - 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.
1234
1253
  - pki.oid.fromDER rejects a non-Buffer or detached-backed input with a typed oid/bad-input error instead of a raw TypeError.
1235
1254
 
1236
1255
  ## v0.1.29 — 2026-07-10
@@ -1239,7 +1258,7 @@ A detached-backed BufferSource now fails closed with a typed error at every byte
1239
1258
 
1240
1259
  ### Fixed
1241
1260
 
1242
- - 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.
1261
+ - 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.
1243
1262
  - 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.
1244
1263
 
1245
1264
  ## v0.1.28 — 2026-07-10
@@ -1250,7 +1269,7 @@ Merkle transparency proof verification joins the toolkit as pki.merkle.
1250
1269
 
1251
1270
  - 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.
1252
1271
  - 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.
1253
- - 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.
1272
+ - 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.
1254
1273
  - 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.
1255
1274
  - 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.
1256
1275
 
@@ -1260,9 +1279,9 @@ A strict deterministic-CBOR codec joins the toolkit as pki.cbor.
1260
1279
 
1261
1280
  ### Added
1262
1281
 
1263
- - 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 rather than exhausting memory); allowTrailing decodes the first item of a CBOR Sequence.
1282
+ - 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.
1264
1283
  - 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).
1265
- - The error taxonomy gains CborError (cbor/*). The decoder is profile-parameterized, so a future CTAP2 canonical profile is a data addition rather than a new code path.
1284
+ - 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.
1266
1285
  - 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.
1267
1286
 
1268
1287
  ### Changed
@@ -1275,7 +1294,7 @@ Test-coverage measurement and the OpenSSF Best Practices badge.
1275
1294
 
1276
1295
  ### Added
1277
1296
 
1278
- - 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.
1297
+ - 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.
1279
1298
 
1280
1299
  ### Changed
1281
1300
 
@@ -1291,7 +1310,7 @@ ACME joins the toolkit: an RFC 8555 message layer over a new RFC 7515 JOSE surfa
1291
1310
  - 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.
1292
1311
  - 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).
1293
1312
  - 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.
1294
- - ARI (RFC 9773): acme.ariCertId builds a certificate's renewal identifier from its authorityKeyIdentifier and serial content octets the serial's leading sign-padding byte is preserved, 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.
1313
+ - 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.
1295
1314
  - The error taxonomy gains JoseError (jose/*) and AcmeError (acme/*); the OID registry gains id-pe-acmeIdentifier (RFC 8737).
1296
1315
  - 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.
1297
1316
  - 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.
@@ -1302,34 +1321,34 @@ EST enrollment joins the toolkit: the RFC 8951 CSR-attributes parser and an RFC
1302
1321
 
1303
1322
  ### Added
1304
1323
 
1305
- - 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 } 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).
1306
- - 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.
1324
+ - 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).
1325
+ - 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.
1307
1326
  - The error taxonomy gains CsrattrsError (csrattrs/*) and EstError (est/*).
1308
1327
  - 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.
1309
1328
  - Fuzz targets csrattrs-parse and est-transfer (the base64 + multipart codecs) join the per-PR and nightly fuzz matrices with seed corpora.
1310
1329
 
1311
1330
  ## v0.1.23 — 2026-07-09
1312
1331
 
1313
- CMS grows authenticated content: RFC 5652 AuthenticatedData, RFC 5083 AuthEnvelopedData, and RFC 9629 KEM recipients (ML-KEM ready) plus a toolkit-wide hardening pass.
1332
+ CMS grows authenticated content: RFC 5652 AuthenticatedData, RFC 5083 AuthEnvelopedData, and RFC 9629 KEM recipients (ML-KEM ready), plus a toolkit-wide hardening pass.
1314
1333
 
1315
1334
  ### Added
1316
1335
 
1317
1336
  - 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.
1318
- - 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 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.
1337
+ - 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.
1319
1338
  - 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.
1320
1339
  - 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.
1321
1340
  - 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.
1322
- - Every pki.schema.cms.parse result carries contentType (the dotted OID) and contentTypeName, naming which of the five content types was dispatched no more duck-typing the result shape.
1341
+ - 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.
1323
1342
  - 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).
1324
1343
  - 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.
1325
- - 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.
1344
+ - 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.
1326
1345
 
1327
1346
  ### Changed
1328
1347
 
1329
- - 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.
1330
- - 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.
1348
+ - 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.
1349
+ - 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.
1331
1350
  - 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.
1332
- - 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.
1351
+ - 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.
1333
1352
  - 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.
1334
1353
  - 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.
1335
1354
  - RecipientEncryptedKey surfaces ridType (rKeyId | issuerAndSerialNumber) so a key-agreement consumer no longer duck-types the recipient-matching form.
@@ -1367,8 +1386,8 @@ SLH-DSA signatures verify in certification-path validation, and the post-quantum
1367
1386
 
1368
1387
  ### Fixed
1369
1388
 
1370
- - The shared AlgorithmIdentifier decoder now rejects a present parameters field on the algorithms whose parameters MUST be absent 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.
1371
- - 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 labelling 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.
1389
+ - 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.
1390
+ - 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 labelling 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.
1372
1391
 
1373
1392
  ## v0.1.20 — 2026-07-09
1374
1393
 
@@ -1376,10 +1395,10 @@ An RFC 6962 Certificate Transparency SCT-list parser joins the toolkit.
1376
1395
 
1377
1396
  ### Added
1378
1397
 
1379
- - 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 } rather than 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.
1398
+ - 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.
1380
1399
  - 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.
1381
- - The certificate-extension value registry gains the SCT-list decoder and the precertificate-poison decoder (the poison value is content-validated as ASN.1 NULL, not merely tag-checked).
1382
- - The OID registry gains the Certificate Transparency arc the SCT-list, precertificate-poison, precertificate-signing-certificate, and OCSP SCT-list identifiers so those extension OIDs resolve to names.
1400
+ - 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).
1401
+ - 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.
1383
1402
  - The error taxonomy gains CtError, carrying a stable ct/* code.
1384
1403
 
1385
1404
  ## v0.1.19 — 2026-07-09
@@ -1388,9 +1407,9 @@ An RFC 9810 Certificate Management Protocol message parser joins the pki.schema
1388
1407
 
1389
1408
  ### Added
1390
1409
 
1391
- - 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 p10cr, the challenge-response and announcement arms, and nested (never auto-recursed) — surfaces raw. 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, not verified: 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.
1410
+ - 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.
1392
1411
  - pki.schema.cmp.pemDecode(text, label?) / pemEncode(der, label?) — PEM handling for messages that transit text channels (default label CMP).
1393
- - 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 serialNumber and issuer present inside a CMP revocation.
1412
+ - 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.
1394
1413
  - 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.
1395
1414
  - The error taxonomy gains CmpError, carrying a stable cmp/* code.
1396
1415
 
@@ -1401,8 +1420,8 @@ An RFC 9810 Certificate Management Protocol message parser joins the pki.schema
1401
1420
 
1402
1421
  ### Fixed
1403
1422
 
1404
- - 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 rather than having the counter round silently to the wrong value (the same exact-or-rejected rule the RSASSA-PSS salt length and PKCS#12 iteration count follow).
1405
- - 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.
1423
+ - 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).
1424
+ - 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.
1406
1425
 
1407
1426
  ## v0.1.18 — 2026-07-08
1408
1427
 
@@ -1410,9 +1429,9 @@ An RFC 7292 PKCS#12 (PFX) store parser joins the pki.schema family.
1410
1429
 
1411
1430
  ### Added
1412
1431
 
1413
- - 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, not just 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".
1432
+ - 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".
1414
1433
  - pki.schema.pkcs12.pemDecode(text, label?) / pemEncode(der, label?) — PEM handling for stores that transit text channels (default label PKCS12).
1415
- - pki.asn1.decode gains an opt-in ber option accepting exactly two shapes an indefinite length on a constructed value and a constructed OCTET STRING, whose segments reassemble into one primitive content — for formats whose content is normatively BER. 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 is memory amplification, not data).
1434
+ - 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).
1416
1435
  - 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.
1417
1436
  - 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.
1418
1437
  - 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.
@@ -1424,7 +1443,7 @@ An RFC 7292 PKCS#12 (PFX) store parser joins the pki.schema family.
1424
1443
 
1425
1444
  ### Fixed
1426
1445
 
1427
- - 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.
1446
+ - 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.
1428
1447
 
1429
1448
  ## v0.1.17 — 2026-07-06
1430
1449
 
@@ -1432,7 +1451,7 @@ An RFC 4211 certificate-request-message parser joins the pki.schema family.
1432
1451
 
1433
1452
  ### Added
1434
1453
 
1435
- - 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). The fields RFC 4211 §5 requires a request to omit serialNumber and signingAlg (assigned by the CA) and issuerUID and subjectUID (deprecated) are rejected, not returned, 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.
1454
+ - 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.
1436
1455
  - pki.schema.crmf.pemDecode(text, label?) — extract the DER bytes from a PEM block (the first block unless a label is given).
1437
1456
  - 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).
1438
1457
  - The error taxonomy gains CrmfError, carrying a stable crmf/* code.
@@ -1451,7 +1470,7 @@ Certification path validation joins the toolkit — RFC 5280 section 6, as a pur
1451
1470
 
1452
1471
  - 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.
1453
1472
  - 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.
1454
- - pki.schema.pkix gains the ns-parameterized RFC 5280 section 4.2.1 extension-value decoders (pkix.certExtensionDecoders) basicConstraints, keyUsage, nameConstraints, certificatePolicies, policyMappings, policyConstraints, inhibitAnyPolicy, subjectAltName / issuerAltName, extKeyUsage, and authorityKeyIdentifier / subjectKeyIdentifier each turning 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.
1473
+ - 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.
1455
1474
  - 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.
1456
1475
  - The error taxonomy gains PathError, carrying the per-check reason in its stable path/* code.
1457
1476
 
@@ -1461,14 +1480,14 @@ CMS EnvelopedData and EncryptedData join the parser, and every documentation exa
1461
1480
 
1462
1481
  ### Added
1463
1482
 
1464
- - 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.
1465
- - 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.
1466
- - 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), not merely 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.
1483
+ - 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.
1484
+ - 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.
1485
+ - 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.
1467
1486
 
1468
1487
  ### Changed
1469
1488
 
1470
1489
  - 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.
1471
- - 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.
1490
+ - 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.
1472
1491
 
1473
1492
  ### Fixed
1474
1493
 
@@ -1482,8 +1501,8 @@ An RFC 5755 attribute-certificate parser joins the pki.schema family.
1482
1501
  ### Added
1483
1502
 
1484
1503
  - 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.
1485
- - 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) rather than surfacing the sequence as opaque bytes. It handles both a bare universal SEQUENCE OF GeneralName and a context-tagged IMPLICIT GeneralNames.
1486
- - 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.
1504
+ - 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.
1505
+ - 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.
1487
1506
 
1488
1507
  ## v0.1.13 — 2026-07-05
1489
1508
 
@@ -1501,7 +1520,7 @@ SLH-DSA object identifiers corrected and completed to all twelve FIPS 205 parame
1501
1520
 
1502
1521
  ### Fixed
1503
1522
 
1504
- - 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 it selects by algorithm name, not through the OID registry.
1523
+ - 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.
1505
1524
 
1506
1525
  ## v0.1.11 — 2026-07-05
1507
1526
 
@@ -1534,7 +1553,7 @@ A PKCS#8 private-key parser joins the pki.schema family.
1534
1553
  ### Added
1535
1554
 
1536
1555
  - 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.
1537
- - 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 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).
1556
+ - 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).
1538
1557
  - 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).
1539
1558
 
1540
1559
  ## v0.1.8 — 2026-07-04
@@ -1543,18 +1562,18 @@ A PKCS#10 certification-request parser joins the pki.schema family.
1543
1562
 
1544
1563
  ### Added
1545
1564
 
1546
- - 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.
1565
+ - 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.
1547
1566
  - 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.
1548
1567
 
1549
1568
  ### Changed
1550
1569
 
1551
- - 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 behaviour is unchanged it still returns an integer millisecond count.
1570
+ - 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 behaviour is unchanged: it still returns an integer millisecond count.
1552
1571
 
1553
1572
  ### Security
1554
1573
 
1555
- - 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.
1556
- - 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.
1557
- - 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.
1574
+ - 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.
1575
+ - 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.
1576
+ - 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.
1558
1577
 
1559
1578
  ### Migration
1560
1579
 
@@ -1566,7 +1585,7 @@ A unified pki.schema family: the structure-schema engine, the X.509 parser, a ne
1566
1585
 
1567
1586
  ### Added
1568
1587
 
1569
- - 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).
1588
+ - 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).
1570
1589
  - 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.
1571
1590
 
1572
1591
  ### Changed
@@ -1584,11 +1603,11 @@ A declarative ASN.1 structure-schema engine; the X.509 parser is rebuilt on it.
1584
1603
 
1585
1604
  ### Added
1586
1605
 
1587
- - 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 shape assertion, bounds-checked positional reads, optional / context-tagged fields in strictly increasing tag order, SET-OF uniqueness, and fail-closed typed errors — in one place. This is the shared base the certificate parser is built on and the forthcoming CRL / CMS parsers compose, so a new format is declared as data rather than hand-written.
1606
+ - 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.
1588
1607
 
1589
1608
  ### Changed
1590
1609
 
1591
- - 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.
1610
+ - 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.
1592
1611
 
1593
1612
  ## v0.1.5 — 2026-07-04
1594
1613
 
@@ -1596,12 +1615,12 @@ Container healthcheck honors WIKI_PORT; release-tooling supply-chain hardening.
1596
1615
 
1597
1616
  ### Fixed
1598
1617
 
1599
- - The example wiki container's HEALTHCHECK now probes the port from WIKI_PORT (defaulting to 3009) rather than a hardcoded 3009, so overriding WIKI_PORT at runtime no longer leaves the container reporting unhealthy while the server is serving on the configured port.
1618
+ - 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.
1600
1619
 
1601
1620
  ### Security
1602
1621
 
1603
1622
  - 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.
1604
- - 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.
1623
+ - 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.
1605
1624
  - The workflow-security audit re-runs when its own configuration file changes, so an edit that would suppress a finding is itself audited.
1606
1625
 
1607
1626
  ## v0.1.4 — 2026-07-04
@@ -1618,7 +1637,7 @@ WebCrypto EC key import validates the curve against the key material.
1618
1637
 
1619
1638
  ### Security
1620
1639
 
1621
- - 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 labelled 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.
1640
+ - 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 labelled 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.
1622
1641
 
1623
1642
  ## v0.1.2 — 2026-07-04
1624
1643
 
@@ -1626,27 +1645,27 @@ Fail-closed hardening across the DER codec, WebCrypto engine, and X.509 parser.
1626
1645
 
1627
1646
  ### Changed
1628
1647
 
1629
- - 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 rather than a re-spelled full path.
1630
- - 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.
1648
+ - 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.
1649
+ - 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.
1631
1650
 
1632
1651
  ### Fixed
1633
1652
 
1634
1653
  - 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.
1635
- - The DER encoder is now symmetric with the decoder no builder can emit DER the decoder would reject: build.utcTime rejects a year outside RFC 5280's 1950-2049 window rather than wrapping it a century, 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.
1636
- - 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.
1654
+ - 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.
1655
+ - 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.
1637
1656
  - 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.
1638
- - HMAC verify resolves false for a wrong-length signature instead of throwing, per the Web Cryptography API. AES-CTR encrypt/decrypt reject a counter length other than 128 rather than silently ignoring the parameter.
1639
- - 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 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 rather than a raw TypeError, 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.
1657
+ - 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.
1658
+ - 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.
1640
1659
  - 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.
1641
- - 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.
1660
+ - 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.
1642
1661
 
1643
1662
  ### Security
1644
1663
 
1645
- - 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.*.
1646
- - The DER decoder rejects a primitive-encoded SEQUENCE or SET (X.690 §8.9.1/§8.11.1 require these to be constructed) rather than producing a leaf node. 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.
1647
- - 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.
1648
- - 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 rather than inheriting 'deriveBits'. Both close cases where an operator-set key-usage restriction could be bypassed.
1649
- - 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.
1664
+ - 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.*.
1665
+ - 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.
1666
+ - 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.
1667
+ - 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.
1668
+ - 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.
1650
1669
 
1651
1670
  ## v0.1.1 — 2026-07-04
1652
1671
 
@@ -1654,7 +1673,7 @@ First published release of the 0.1.x foundation.
1654
1673
 
1655
1674
  ### Changed
1656
1675
 
1657
- - 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) now available on npm with a SLSA provenance attestation, and served as the pkijs.com documentation container.
1676
+ - 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.
1658
1677
 
1659
1678
  ## v0.1.0 — 2026-07-04
1660
1679
 
@@ -1662,7 +1681,7 @@ Initial foundation — a PQC-first WebCrypto engine, a strict DER codec, an OID
1662
1681
 
1663
1682
  ### Added
1664
1683
 
1665
- - 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 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.
1684
+ - 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.
1666
1685
  - 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).
1667
1686
  - 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).
1668
1687
  - 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.
@@ -1673,4 +1692,4 @@ Initial foundation — a PQC-first WebCrypto engine, a strict DER codec, an OID
1673
1692
  ### Security
1674
1693
 
1675
1694
  - 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.
1676
- - The crypto engine is fail-closed: an unknown algorithm, curve, or format is rejected rather than silently downgraded, and every sign/verify path returns a real verdict or throws.
1695
+ - 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.