@blamejs/pki 0.5.7 → 0.5.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +418 -386
- package/MIGRATING.md +43 -0
- package/README.md +12 -12
- package/lib/acme.js +31 -31
- package/lib/asn1-der.js +10 -10
- package/lib/attrcert-sign.js +19 -19
- package/lib/byte-reader.js +6 -6
- package/lib/byte-writer.js +5 -5
- package/lib/cbor-det.js +27 -24
- package/lib/cmc-build.js +97 -92
- package/lib/cmc-verify.js +106 -30
- package/lib/cmp-build.js +25 -25
- package/lib/cmp-session.js +70 -70
- package/lib/cmp-verify.js +71 -57
- package/lib/cms-compress.js +7 -7
- package/lib/cms-decrypt.js +90 -72
- package/lib/cms-encrypt.js +32 -32
- package/lib/cms-sign.js +74 -55
- package/lib/cms-verify.js +97 -75
- package/lib/composite-sig.js +13 -13
- package/lib/constants.js +4 -4
- package/lib/crl-sign.js +22 -22
- package/lib/crl-verify.js +7 -6
- package/lib/crmf-sign.js +14 -14
- package/lib/csr-sign.js +8 -8
- package/lib/ct.js +37 -37
- package/lib/edwards-point.js +7 -7
- package/lib/est.js +98 -55
- package/lib/framework-error.js +5 -5
- package/lib/guard-all.js +5 -3
- package/lib/guard-async.js +4 -4
- package/lib/guard-bytes.js +79 -79
- package/lib/guard-compress.js +17 -17
- package/lib/guard-crypto.js +1 -1
- package/lib/guard-der.js +152 -0
- package/lib/guard-encoding.js +15 -15
- package/lib/guard-header.js +3 -3
- package/lib/guard-identifier.js +16 -16
- package/lib/guard-json.js +15 -15
- package/lib/guard-limits.js +7 -7
- package/lib/guard-name.js +81 -16
- package/lib/guard-parsed.js +80 -80
- package/lib/guard-range.js +19 -19
- package/lib/guard-secret.js +11 -10
- package/lib/guard-text.js +6 -6
- package/lib/guard-time.js +10 -10
- package/lib/hpke.js +18 -17
- package/lib/http-digest.js +35 -35
- package/lib/http-retry-after.js +13 -13
- package/lib/http-transport.js +20 -19
- package/lib/inspect.js +53 -53
- package/lib/ip-utils.js +2 -2
- package/lib/jose.js +13 -13
- package/lib/key.js +16 -16
- package/lib/lint.js +51 -51
- package/lib/merkle.js +51 -36
- package/lib/mime.js +18 -18
- package/lib/ocsp-verify.js +10 -10
- package/lib/ocsp.js +13 -13
- package/lib/oid.js +29 -29
- package/lib/path-validate.js +114 -113
- package/lib/pbes2.js +16 -16
- package/lib/pkcs12-build.js +53 -53
- package/lib/pki-build.js +48 -20
- package/lib/rc2.js +1 -1
- package/lib/rfc3339.js +5 -5
- package/lib/schema-all.js +31 -31
- package/lib/schema-attrcert.js +12 -12
- package/lib/schema-c509.js +149 -197
- package/lib/schema-cmc.js +58 -58
- package/lib/schema-cmp.js +43 -43
- package/lib/schema-cms.js +45 -45
- package/lib/schema-crl.js +7 -7
- package/lib/schema-crmf.js +28 -28
- package/lib/schema-csr.js +12 -12
- package/lib/schema-csrattrs.js +16 -16
- package/lib/schema-engine.js +18 -18
- package/lib/schema-ocsp.js +15 -15
- package/lib/schema-pkcs12.js +20 -20
- package/lib/schema-pkcs8.js +2 -2
- package/lib/schema-pkix.js +131 -126
- package/lib/schema-smime.js +19 -19
- package/lib/schema-tsp.js +12 -12
- package/lib/schema-x509.js +3 -3
- package/lib/shbs.js +18 -18
- package/lib/sign-scheme.js +13 -13
- package/lib/sigstore.js +10 -11
- package/lib/sleep.js +1 -1
- package/lib/smime.js +308 -96
- package/lib/tls-cert-compress.js +18 -18
- package/lib/trust.js +27 -27
- package/lib/tsp-sign.js +17 -17
- package/lib/validator-all.js +1 -1
- package/lib/validator-attcert.js +1 -1
- package/lib/validator-cose.js +43 -44
- package/lib/validator-keydesc.js +3 -3
- package/lib/validator-sig.js +13 -13
- package/lib/validator-tls.js +11 -11
- package/lib/validator-tpm.js +20 -19
- package/lib/webauthn-mds.js +66 -66
- package/lib/webauthn.js +33 -33
- package/lib/webcrypto.js +15 -15
- package/lib/x509-sign.js +13 -13
- package/package.json +3 -2
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -4,47 +4,79 @@ 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.9 — 2026-08-18
|
|
8
|
+
|
|
9
|
+
A certificate can now carry an internationalized email address, which this toolkit could read and never write.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- pki.x509.sign accepts an otherName entry in a subjectAltName, given as { typeId, value } where typeId is an OID string and value is a Buffer holding one DER element. It encodes RFC 5280 section 4.2.1.6's otherName ::= SEQUENCE { type-id OBJECT IDENTIFIER, value [0] EXPLICIT ANY }, tagged [0] IMPLICIT. The value wrapper is EXPLICIT because ANY carries no tag of its own, which is what makes the encoding unambiguous and is the shape the decoder already required. This is what an SmtpUTF8Mailbox address needs, and it is equally the carrier for any other otherName a profile defines.
|
|
14
|
+
- The value is validated before it is wrapped and signed, because a signer that emits a malformed encoding under a real signature has produced something strict relying parties reject. It must be exactly one element with no trailing bytes, and its contents must satisfy the rules for its type: a BOOLEAN whose octet is not 0x00 or 0xFF is refused, as is a SET whose members sit in no canonical order, and so on recursively through a constructed value. The accepted universal types are BOOLEAN, INTEGER, ENUMERATED, BIT STRING, OCTET STRING, NULL, OBJECT IDENTIFIER, UTCTime, GeneralizedTime, NumericString, and the DirectoryString family (UTF8String, PrintableString, IA5String, TeletexString, VisibleString, BMPString, UniversalString), plus SEQUENCE and SET. A universal type outside that set, such as REAL or RELATIVE-OID, has no content validator here and is refused rather than accepted on its framing alone. A context- or application-tagged value passes on its framing, since no content rule is knowable for it, and its children are still walked. One known limitation: a GeneralizedTime carrying fractional seconds, such as 20260101000000.5Z, is refused here even though X.690 section 11.7 permits it. That relaxation is scoped to the codec and to RFC 3161 timestamping on purpose, and this validator does not widen it. A constructed wrapper does not evade the rule, since the walk recurses into its children; a profile needing a fractional time must carry it under an implicit primitive context tag, which passes on framing because no content rule is knowable for it.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- pki.smime.verify's sender binding is now exercised against certificates carrying an otherName. Two behaviours that previously had no conformance vector are pinned: a certificate whose subjectAltName carries an SmtpUTF8Mailbox does not let a legacy subject distinguished-name emailAddress speak for it, and an otherName unrelated to email, such as a Microsoft user principal name, neither erases a matching rfc822Name nor turns a definite non-match into an undecidable one.
|
|
19
|
+
|
|
20
|
+
## v0.5.8 — 2026-08-17
|
|
21
|
+
|
|
22
|
+
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.
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- 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.
|
|
27
|
+
- 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.
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
|
|
31
|
+
- 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.
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
- 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.
|
|
36
|
+
- 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.
|
|
37
|
+
- 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.
|
|
38
|
+
|
|
7
39
|
## v0.5.7 — 2026-08-16
|
|
8
40
|
|
|
9
41
|
A CMS signature made over signed attributes can no longer be re-presented as one made over content.
|
|
10
42
|
|
|
11
43
|
### Added
|
|
12
44
|
|
|
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
|
|
45
|
+
- 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
46
|
|
|
15
47
|
### Changed
|
|
16
48
|
|
|
17
|
-
- Because the producing verbs now copy their arguments at entry, each property of a spec or options object
|
|
18
|
-
- One argument shape
|
|
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
|
|
49
|
+
- 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.
|
|
50
|
+
- 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.
|
|
51
|
+
- 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
52
|
|
|
21
53
|
### Fixed
|
|
22
54
|
|
|
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
|
|
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
|
|
25
|
-
- A byte argument whose backing store has been transferred away is refused instead of read as empty. Transferring an ArrayBuffer
|
|
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
|
|
27
|
-
- The verbs documented as returning a Promise now run their body at the call,
|
|
55
|
+
- 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.
|
|
56
|
+
- 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.
|
|
57
|
+
- 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.
|
|
58
|
+
- 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.
|
|
59
|
+
- 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
60
|
|
|
29
61
|
## v0.5.6 — 2026-08-15
|
|
30
62
|
|
|
31
|
-
A CMS SignedData is verified over the bytes it was parsed from, an omitted PKCS#12 password is refused
|
|
63
|
+
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
64
|
|
|
33
65
|
### Changed
|
|
34
66
|
|
|
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)
|
|
36
|
-
- pki.tsp.verify reports whether revocation was established,
|
|
67
|
+
- 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.
|
|
68
|
+
- 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
69
|
|
|
38
70
|
### Fixed
|
|
39
71
|
|
|
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
|
|
41
|
-
- pki.pkcs12.build refuses a store with no password
|
|
72
|
+
- 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.
|
|
73
|
+
- 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
74
|
- 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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
75
|
+
- 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.
|
|
76
|
+
- 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.
|
|
77
|
+
- 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.
|
|
78
|
+
- 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.
|
|
79
|
+
- 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
80
|
|
|
49
81
|
## v0.5.5 — 2026-08-15
|
|
50
82
|
|
|
@@ -52,21 +84,21 @@ A verdict is computed over the bytes the parser read, an identity is derived fro
|
|
|
52
84
|
|
|
53
85
|
### Changed
|
|
54
86
|
|
|
55
|
-
- The package resolves one entry point. require("@blamejs/pki") is unchanged; a path
|
|
56
|
-
- pki.merkle, pki.jose, pki.hpke, pki.smime and pki.est refuse an option they do not recognize, which completes the toolkit
|
|
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
|
|
87
|
+
- 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.
|
|
88
|
+
- 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.
|
|
89
|
+
- 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
90
|
|
|
59
91
|
### Fixed
|
|
60
92
|
|
|
61
|
-
- A claimed-parsed structure must carry every field the matching pki.schema parser produces. The rule already existed at one door
|
|
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
|
|
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
|
|
64
|
-
- pki.attrcert.sign derives both halves of a Holder's identity from the signed bytes. Issuer and serial together
|
|
65
|
-
- pki.trust.anchor answers from what the store read. A root program's metadata
|
|
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
|
|
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,
|
|
93
|
+
- 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.
|
|
94
|
+
- 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.
|
|
95
|
+
- 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.
|
|
96
|
+
- 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.
|
|
97
|
+
- 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.
|
|
98
|
+
- 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.
|
|
99
|
+
- 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
100
|
- 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
|
|
101
|
+
- 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
102
|
|
|
71
103
|
## v0.5.4 — 2026-08-15
|
|
72
104
|
|
|
@@ -75,18 +107,18 @@ A path verdict says whether revocation was ever established, a trust anchor's ow
|
|
|
75
107
|
### Added
|
|
76
108
|
|
|
77
109
|
- 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
|
|
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"
|
|
110
|
+
- 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.
|
|
111
|
+
- 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
112
|
|
|
81
113
|
### Fixed
|
|
82
114
|
|
|
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
|
|
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
|
|
85
|
-
- A revocation checker that throws, or whose promise rejects, fails the path with path/revocation-checker-error carrying the fault
|
|
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
|
|
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
|
|
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
|
|
89
|
-
- An issuingDistributionPoint scope flag is read under the encoding rules that define it, in both the CRL verbs and the path validator,
|
|
115
|
+
- 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.
|
|
116
|
+
- 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.
|
|
117
|
+
- 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.
|
|
118
|
+
- 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.
|
|
119
|
+
- 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.
|
|
120
|
+
- 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.
|
|
121
|
+
- 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
122
|
|
|
91
123
|
## v0.5.3 — 2026-08-14
|
|
92
124
|
|
|
@@ -94,29 +126,29 @@ pki.webauthn checks the ceremony at registration, withholds a revoked model's an
|
|
|
94
126
|
|
|
95
127
|
### Added
|
|
96
128
|
|
|
97
|
-
- pki.webauthn.verify accepts opts.clientDataJSON as an alternative to the clientDataHash argument
|
|
98
|
-
- parseClientData, verify and verifyAssertion take expectedTopOrigin
|
|
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
|
|
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
|
|
129
|
+
- 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.
|
|
130
|
+
- 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.
|
|
131
|
+
- 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.
|
|
132
|
+
- 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
133
|
|
|
102
134
|
### Changed
|
|
103
135
|
|
|
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
|
|
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
|
|
136
|
+
- 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.
|
|
137
|
+
- 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
138
|
- pki.webauthn.metadataAnchors takes a second, optional argument carrying the metadata result, the instant to judge at, and the attestation certificate presented.
|
|
107
139
|
- The documented signatures of pki.webauthn.verify, verifyAssertion and parseAuthenticatorData list every field their verdicts carry, and verify documents its options.
|
|
108
140
|
|
|
109
141
|
### Fixed
|
|
110
142
|
|
|
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
|
|
112
|
-
- A metadata entry whose status reports disqualify the model registers no attestation anchors. The route an operator follows to anchor an attestation themselves
|
|
113
|
-
- An attestation certificate's public key must
|
|
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
|
|
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
|
|
116
|
-
- An id-RSASSA-PSS key's parameters restrict it even where they look empty, in both directions
|
|
117
|
-
- The RSA credential-key floor is measured in bits
|
|
143
|
+
- 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.
|
|
144
|
+
- 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.
|
|
145
|
+
- 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.
|
|
146
|
+
- 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.
|
|
147
|
+
- 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.
|
|
148
|
+
- 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.
|
|
149
|
+
- 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
150
|
- 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,
|
|
151
|
+
- 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
152
|
|
|
121
153
|
## v0.5.2 — 2026-08-12
|
|
122
154
|
|
|
@@ -124,19 +156,19 @@ pki.cms.verify gains a trust seam: name the roots you accept, and the verdict sa
|
|
|
124
156
|
|
|
125
157
|
### Added
|
|
126
158
|
|
|
127
|
-
- pki.cms.verify(input, opts) accepts opts.trustAnchors
|
|
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
|
|
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
|
|
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
|
|
159
|
+
- 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.
|
|
160
|
+
- 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.
|
|
161
|
+
- 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.
|
|
162
|
+
- 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
163
|
|
|
132
164
|
### Changed
|
|
133
165
|
|
|
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
|
|
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
|
|
166
|
+
- 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.
|
|
167
|
+
- 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
168
|
|
|
137
169
|
### Security
|
|
138
170
|
|
|
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
|
|
171
|
+
- 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
172
|
|
|
141
173
|
## v0.5.1 — 2026-08-12
|
|
142
174
|
|
|
@@ -144,49 +176,49 @@ Four verify and export paths stop answering a question other than the one they w
|
|
|
144
176
|
|
|
145
177
|
### Changed
|
|
146
178
|
|
|
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
|
|
179
|
+
- 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
180
|
|
|
149
181
|
### Fixed
|
|
150
182
|
|
|
151
|
-
- pki.webcrypto.subtle.exportKey("raw", privateKey) is refused with webcrypto/not-supported
|
|
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
|
|
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
|
|
183
|
+
- 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.
|
|
184
|
+
- 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.
|
|
185
|
+
- 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
186
|
|
|
155
187
|
## v0.5.0 — 2026-08-12
|
|
156
188
|
|
|
157
|
-
CMC
|
|
189
|
+
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
190
|
|
|
159
191
|
### Added
|
|
160
192
|
|
|
161
193
|
- 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
|
|
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
|
|
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
|
|
194
|
+
- 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.
|
|
195
|
+
- 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.
|
|
196
|
+
- 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
197
|
- 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
|
|
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
|
|
198
|
+
- 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.
|
|
199
|
+
- 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
200
|
|
|
169
201
|
### Changed
|
|
170
202
|
|
|
171
203
|
- 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
|
|
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
|
|
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
|
|
175
|
-
- A pki.webauthn verdict can no longer be mistaken for a ceremony verdict. The field is attestationVerified, and signatureVerified for an assertion,
|
|
204
|
+
- 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.
|
|
205
|
+
- 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.
|
|
206
|
+
- 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.
|
|
207
|
+
- 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
208
|
|
|
177
209
|
### Fixed
|
|
178
210
|
|
|
179
|
-
- A CMC status control carrying the OPTIONAL statusString
|
|
211
|
+
- 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
212
|
- 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
|
|
213
|
+
- 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
214
|
- 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
|
|
215
|
+
- 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
216
|
- 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
217
|
- 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
218
|
- 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
|
|
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
|
|
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
|
|
219
|
+
- 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.
|
|
220
|
+
- 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.
|
|
221
|
+
- 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
222
|
|
|
191
223
|
## v0.4.15 — 2026-08-10
|
|
192
224
|
|
|
@@ -194,28 +226,28 @@ A CA that partitions revocations by reason code, or publishes a delta CRL alongs
|
|
|
194
226
|
|
|
195
227
|
### Added
|
|
196
228
|
|
|
197
|
-
- Reason coverage
|
|
198
|
-
- Delta CRLs are
|
|
229
|
+
- 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.
|
|
230
|
+
- 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
231
|
- 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
|
|
232
|
+
- 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
233
|
|
|
202
234
|
### Changed
|
|
203
235
|
|
|
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
|
|
236
|
+
- 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
237
|
- 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
|
|
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
|
|
238
|
+
- 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.
|
|
239
|
+
- 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
240
|
- 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
241
|
|
|
210
242
|
### Fixed
|
|
211
243
|
|
|
212
244
|
- 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
|
|
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
|
|
245
|
+
- 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.
|
|
246
|
+
- 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
247
|
|
|
216
248
|
## v0.4.14 — 2026-08-10
|
|
217
249
|
|
|
218
|
-
Every key-establishment secret this library allocates is now wiped when it stops being needed
|
|
250
|
+
Every key-establishment secret this library allocates is now wiped when it stops being needed, classical as well as post-quantum.
|
|
219
251
|
|
|
220
252
|
### Changed
|
|
221
253
|
|
|
@@ -224,53 +256,53 @@ Every key-establishment secret this library allocates is now wiped when it stops
|
|
|
224
256
|
### Fixed
|
|
225
257
|
|
|
226
258
|
- 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
|
|
259
|
+
- 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
260
|
- 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,
|
|
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
|
|
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
|
|
232
|
-
- The message-authentication key of an AuthenticatedData is cleared on both sides
|
|
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
|
|
234
|
-
- PKCS#12 integrity clears the password-derived MAC key on both sides
|
|
235
|
-
- HPKE clears the raw Diffie-Hellman output on every DHKEM arm
|
|
236
|
-
- A key-derivation function returns an exact-sized buffer the caller wholly owns
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
242
|
-
- Wrapping a key clears the plaintext serialization it makes of that key
|
|
261
|
+
- 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.
|
|
262
|
+
- 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.
|
|
263
|
+
- 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.
|
|
264
|
+
- 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.
|
|
265
|
+
- 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.
|
|
266
|
+
- 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.
|
|
267
|
+
- 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.
|
|
268
|
+
- 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.
|
|
269
|
+
- 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.
|
|
270
|
+
- 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.
|
|
271
|
+
- 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.
|
|
272
|
+
- 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.
|
|
273
|
+
- 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.
|
|
274
|
+
- 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
275
|
- 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
|
-
-
|
|
276
|
+
- 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
277
|
- 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
278
|
|
|
247
279
|
## v0.4.13 — 2026-08-09
|
|
248
280
|
|
|
249
|
-
A KEM shared secret and the key it derives are now wiped as soon as they stop being needed
|
|
281
|
+
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
282
|
|
|
251
283
|
### Added
|
|
252
284
|
|
|
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
|
|
254
|
-
- This is best-effort and is documented as such
|
|
285
|
+
- 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.
|
|
286
|
+
- 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
287
|
- pki.oid.kemParams resolves an ML-KEM parameter set to its FIPS 203 Table 3 sizes, by dotted OID or by registered name.
|
|
256
288
|
|
|
257
289
|
### Changed
|
|
258
290
|
|
|
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
|
|
291
|
+
- 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
292
|
- 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
293
|
|
|
262
294
|
### Fixed
|
|
263
295
|
|
|
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
|
|
296
|
+
- 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
297
|
|
|
266
298
|
## v0.4.12 — 2026-08-09
|
|
267
299
|
|
|
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
|
|
300
|
+
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
301
|
|
|
270
302
|
### Added
|
|
271
303
|
|
|
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
|
|
273
|
-
- The two ends of the SIZE (1..200) bound
|
|
304
|
+
- 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.
|
|
305
|
+
- 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
306
|
|
|
275
307
|
### Changed
|
|
276
308
|
|
|
@@ -280,7 +312,7 @@ A CMS message can no longer declare one content cipher and be opened with anothe
|
|
|
280
312
|
|
|
281
313
|
### Fixed
|
|
282
314
|
|
|
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
|
|
315
|
+
- 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
316
|
- 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
317
|
|
|
286
318
|
## v0.4.11 — 2026-08-09
|
|
@@ -289,22 +321,22 @@ A WebAuthn attestation can now be bound to the roots the authenticator's own mod
|
|
|
289
321
|
|
|
290
322
|
### Added
|
|
291
323
|
|
|
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
|
|
293
|
-
- The catalogue's freshness is enforced
|
|
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
|
|
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
|
|
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
|
|
297
|
-
- Only a catalogue this library verified can decide anything. A metadata result is recognised by provenance
|
|
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
|
|
324
|
+
- 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.
|
|
325
|
+
- 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.
|
|
326
|
+
- 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.
|
|
327
|
+
- 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.
|
|
328
|
+
- 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.
|
|
329
|
+
- 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.
|
|
330
|
+
- 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
331
|
- 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
|
|
332
|
+
- 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
333
|
- 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
|
|
334
|
+
- 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
335
|
|
|
304
336
|
### Changed
|
|
305
337
|
|
|
306
|
-
- pki.webauthn.verify now rejects an unrecognised option key
|
|
307
|
-
- Configuration objects across certificate, CRL, attribute-certificate, CSR, and CMP issuance now reject an unrecognised option key through one shared check
|
|
338
|
+
- 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.
|
|
339
|
+
- 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
340
|
|
|
309
341
|
### Fixed
|
|
310
342
|
|
|
@@ -312,117 +344,117 @@ A WebAuthn attestation can now be bound to the roots the authenticator's own mod
|
|
|
312
344
|
|
|
313
345
|
## v0.4.10 — 2026-08-08
|
|
314
346
|
|
|
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
|
|
347
|
+
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
348
|
|
|
317
349
|
### Added
|
|
318
350
|
|
|
319
351
|
- 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
|
|
321
|
-
- The policy also covers the key's access policy: requiring that one is present at all
|
|
322
|
-
- A mistyped policy is refused when it is set
|
|
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
|
|
352
|
+
- 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.
|
|
353
|
+
- 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.
|
|
354
|
+
- 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.
|
|
355
|
+
- 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
356
|
|
|
325
357
|
### Fixed
|
|
326
358
|
|
|
327
|
-
- Calendar dates without a time are now read through the same strict reader as full timestamps
|
|
359
|
+
- 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
360
|
|
|
329
361
|
## v0.4.9 — 2026-08-08
|
|
330
362
|
|
|
331
|
-
A WebAuthn compound attestation now verifies
|
|
363
|
+
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
364
|
|
|
333
365
|
### Added
|
|
334
366
|
|
|
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
|
|
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
|
|
367
|
+
- 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.
|
|
368
|
+
- 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
369
|
|
|
338
370
|
### Fixed
|
|
339
371
|
|
|
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
|
|
372
|
+
- 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
373
|
|
|
342
374
|
## v0.4.8 — 2026-08-08
|
|
343
375
|
|
|
344
|
-
A stored android-safetynet WebAuthn attestation can be re-verified in full
|
|
376
|
+
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
377
|
|
|
346
378
|
### Added
|
|
347
379
|
|
|
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
|
|
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
|
|
350
|
-
- Device-integrity signals in the response
|
|
380
|
+
- 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.
|
|
381
|
+
- 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.
|
|
382
|
+
- 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
383
|
|
|
352
384
|
## v0.4.7 — 2026-08-08
|
|
353
385
|
|
|
354
|
-
One certificate now renders one distinguished-name string whichever parser read it
|
|
386
|
+
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
387
|
|
|
356
388
|
### Fixed
|
|
357
389
|
|
|
358
|
-
- A C509 certificate's subject and issuer strings now use the same spelling as every other parser in the toolkit
|
|
390
|
+
- 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
391
|
- 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
392
|
|
|
361
393
|
## v0.4.6 — 2026-08-08
|
|
362
394
|
|
|
363
|
-
A C509 certificate now has one encoding where the specification defines one
|
|
395
|
+
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
396
|
|
|
365
397
|
### Fixed
|
|
366
398
|
|
|
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
|
|
368
|
-
- A name holding a single common name is the bare value,
|
|
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
|
|
399
|
+
- 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.
|
|
400
|
+
- 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.
|
|
401
|
+
- 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
402
|
- 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
|
|
403
|
+
- 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
404
|
|
|
373
405
|
### Known limitations
|
|
374
406
|
|
|
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
|
|
407
|
+
- 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
408
|
|
|
377
409
|
## v0.4.5 — 2026-08-08
|
|
378
410
|
|
|
379
|
-
A private key created outside this toolkit's own WebCrypto now signs and exports across the toolkit
|
|
411
|
+
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
412
|
|
|
381
413
|
### Fixed
|
|
382
414
|
|
|
383
415
|
- 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
|
|
385
|
-
- A key's permitted usages travel with it. Re-importing is the one moment that restriction could be widened
|
|
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
|
|
416
|
+
- 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.
|
|
417
|
+
- 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.
|
|
418
|
+
- 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
419
|
|
|
388
420
|
## v0.4.4 — 2026-08-08
|
|
389
421
|
|
|
390
|
-
pki.schema.c509 encodes and decodes the RFC 3779 resource-delegation extensions
|
|
422
|
+
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
423
|
|
|
392
424
|
### Added
|
|
393
425
|
|
|
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
|
|
395
|
-
-
|
|
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
|
|
426
|
+
- 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.
|
|
427
|
+
- 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.
|
|
428
|
+
- 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
429
|
|
|
398
430
|
### Fixed
|
|
399
431
|
|
|
400
|
-
- A certificate whose version is not v3 is now refused with the reason
|
|
432
|
+
- 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
433
|
|
|
402
434
|
## v0.4.3 — 2026-08-08
|
|
403
435
|
|
|
404
|
-
pki.tls encodes and decodes RFC 8879 compressed certificate messages
|
|
436
|
+
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
437
|
|
|
406
438
|
### Added
|
|
407
439
|
|
|
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
|
|
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
|
|
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
|
|
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
|
|
440
|
+
- 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.
|
|
441
|
+
- 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.
|
|
442
|
+
- 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.
|
|
443
|
+
- 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
444
|
- 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
445
|
|
|
414
446
|
### Changed
|
|
415
447
|
|
|
416
|
-
- pki.cms.sign and pki.cms.verify compute their message digests through the crypto engine
|
|
448
|
+
- 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
449
|
|
|
418
450
|
### Fixed
|
|
419
451
|
|
|
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
|
|
421
|
-
- An unusable hash is now refused when a key is created
|
|
452
|
+
- 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.
|
|
453
|
+
- 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
454
|
|
|
423
455
|
## v0.4.2 — 2026-08-07
|
|
424
456
|
|
|
425
|
-
A NumericString attribute value no longer shares distinguished-name identity with a printable or UTF-8 value of the same characters
|
|
457
|
+
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
458
|
|
|
427
459
|
### Changed
|
|
428
460
|
|
|
@@ -431,7 +463,7 @@ A NumericString attribute value no longer shares distinguished-name identity wit
|
|
|
431
463
|
|
|
432
464
|
### Fixed
|
|
433
465
|
|
|
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
|
|
466
|
+
- 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
467
|
- 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
468
|
- 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
469
|
- 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 +471,101 @@ A NumericString attribute value no longer shares distinguished-name identity wit
|
|
|
439
471
|
|
|
440
472
|
## v0.4.1 — 2026-08-07
|
|
441
473
|
|
|
442
|
-
pki.schema.c509 encodes and decodes the compact subjectDirectoryAttributes value form
|
|
474
|
+
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
475
|
|
|
444
476
|
### Added
|
|
445
477
|
|
|
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
|
|
478
|
+
- 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
479
|
- 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
|
|
480
|
+
- 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
481
|
|
|
450
482
|
### Changed
|
|
451
483
|
|
|
452
|
-
- A C509 certificate carrying subjectDirectoryAttributes now encodes to
|
|
484
|
+
- 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
485
|
|
|
454
486
|
## v0.4.0 — 2026-08-06
|
|
455
487
|
|
|
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
|
|
488
|
+
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
489
|
|
|
458
490
|
### Added
|
|
459
491
|
|
|
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
|
|
492
|
+
- 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
493
|
|
|
462
494
|
### Changed
|
|
463
495
|
|
|
464
496
|
- 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
|
|
497
|
+
- 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
498
|
|
|
467
499
|
### Fixed
|
|
468
500
|
|
|
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
|
|
501
|
+
- 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
502
|
|
|
471
503
|
## v0.3.33 — 2026-08-05
|
|
472
504
|
|
|
473
|
-
pki.schema.c509 encodes and decodes the compact certificatePolicies value form
|
|
505
|
+
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
506
|
|
|
475
507
|
### Added
|
|
476
508
|
|
|
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
|
|
509
|
+
- 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
510
|
- 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
511
|
|
|
480
512
|
### Changed
|
|
481
513
|
|
|
482
|
-
- A C509 certificate carrying certificatePolicies now encodes to
|
|
514
|
+
- 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
515
|
|
|
484
516
|
## v0.3.32 — 2026-08-05
|
|
485
517
|
|
|
486
|
-
pki.schema.c509 encodes and decodes the compact general-name extension values
|
|
518
|
+
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
519
|
|
|
488
520
|
### Added
|
|
489
521
|
|
|
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
|
|
522
|
+
- 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
523
|
- 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
524
|
|
|
493
525
|
### Changed
|
|
494
526
|
|
|
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
|
|
527
|
+
- 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
528
|
|
|
497
529
|
## v0.3.31 — 2026-08-03
|
|
498
530
|
|
|
499
|
-
pki.schema.c509 encodes and decodes the compact per-extension value forms
|
|
531
|
+
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
532
|
|
|
501
533
|
### Added
|
|
502
534
|
|
|
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
|
|
535
|
+
- 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
536
|
|
|
505
537
|
### Changed
|
|
506
538
|
|
|
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
|
|
539
|
+
- 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
540
|
|
|
509
541
|
## v0.3.29 — 2026-08-02
|
|
510
542
|
|
|
511
|
-
The pki.acme client rounds out its RFC 8555 / RFC 9773 surface
|
|
543
|
+
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
544
|
|
|
513
545
|
### Added
|
|
514
546
|
|
|
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
|
|
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
|
|
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
|
|
547
|
+
- 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.
|
|
548
|
+
- 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.
|
|
549
|
+
- 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
550
|
|
|
519
551
|
### Changed
|
|
520
552
|
|
|
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
|
|
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
|
|
553
|
+
- 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.
|
|
554
|
+
- 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
555
|
|
|
524
556
|
## v0.3.28 — 2026-08-01
|
|
525
557
|
|
|
526
|
-
pki.est gains its remaining RFC 7030 network verbs
|
|
558
|
+
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
559
|
|
|
528
560
|
### Added
|
|
529
561
|
|
|
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
|
|
562
|
+
- 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
563
|
- 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
|
|
564
|
+
- 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
565
|
|
|
534
566
|
## v0.3.27 — 2026-07-31
|
|
535
567
|
|
|
536
|
-
pki.cmp.session drives a full CMP certificate enrollment end to end
|
|
568
|
+
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
569
|
|
|
538
570
|
### Added
|
|
539
571
|
|
|
@@ -541,49 +573,49 @@ pki.cmp.session drives a full CMP certificate enrollment end to end -- build, tr
|
|
|
541
573
|
|
|
542
574
|
## v0.3.26 — 2026-07-30
|
|
543
575
|
|
|
544
|
-
pki.cmp.verify checks the protection on an incoming CMP PKIMessage
|
|
576
|
+
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
577
|
|
|
546
578
|
### Added
|
|
547
579
|
|
|
548
|
-
- pki.cmp.verify(message, opts) verifies the protection on an incoming CMP PKIMessage
|
|
580
|
+
- 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
581
|
|
|
550
582
|
### Fixed
|
|
551
583
|
|
|
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
|
|
584
|
+
- 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
585
|
|
|
554
586
|
## v0.3.25 — 2026-07-26
|
|
555
587
|
|
|
556
|
-
pki.path.build can now fetch a missing intermediate over the network
|
|
588
|
+
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
589
|
|
|
558
590
|
### Added
|
|
559
591
|
|
|
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
|
|
561
|
-
- pki.inspect renders the authorityInfoAccess extension (RFC 5280 sec. 4.2.2.1)
|
|
592
|
+
- 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.
|
|
593
|
+
- 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
594
|
|
|
563
595
|
### Changed
|
|
564
596
|
|
|
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
|
|
597
|
+
- 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
598
|
|
|
567
599
|
## v0.3.24 — 2026-07-26
|
|
568
600
|
|
|
569
|
-
pki.smime.verify / decrypt can now recognize a legacy (RFC 8551) header-protected message
|
|
601
|
+
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
602
|
|
|
571
603
|
### Added
|
|
572
604
|
|
|
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)
|
|
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
|
|
605
|
+
- 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.
|
|
606
|
+
- 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
607
|
|
|
576
608
|
### Changed
|
|
577
609
|
|
|
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
|
|
610
|
+
- 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
611
|
|
|
580
612
|
## v0.3.23 — 2026-07-26
|
|
581
613
|
|
|
582
|
-
pki.pkcs12.open now reads legacy PKCS#12 stores
|
|
614
|
+
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
615
|
|
|
584
616
|
### Added
|
|
585
617
|
|
|
586
|
-
- pki.pkcs12.open decrypts the RFC 7292 Appendix C legacy PBE bags: pbeWithSHAAnd3-KeyTripleDES-CBC, pbeWithSHAAnd2-KeyTripleDES-CBC, pbeWithSHAAnd128BitRC2-CBC, and pbeWithSHAAnd40BitRC2-CBC.
|
|
618
|
+
- 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
619
|
- 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
620
|
|
|
589
621
|
### Changed
|
|
@@ -592,62 +624,62 @@ pki.pkcs12.open now reads legacy PKCS#12 stores -- decrypt the RFC 7292 Appendix
|
|
|
592
624
|
|
|
593
625
|
## v0.3.22 — 2026-07-26
|
|
594
626
|
|
|
595
|
-
S/MIME header protection ships
|
|
627
|
+
S/MIME header protection ships: cover the message headers (Subject, From, To, ...) under the CMS signature or encryption with pki.smime, RFC 9788.
|
|
596
628
|
|
|
597
629
|
### Added
|
|
598
630
|
|
|
599
|
-
- pki.smime.sign / pki.smime.encrypt gain opts.protectHeaders (RFC 9788 header protection) with opts.headers
|
|
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
|
|
631
|
+
- 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.
|
|
632
|
+
- 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
633
|
- 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
634
|
|
|
603
635
|
## v0.3.21 — 2026-07-26
|
|
604
636
|
|
|
605
|
-
A Certificate Transparency log-list live-fetch client ships
|
|
637
|
+
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
638
|
|
|
607
639
|
### Added
|
|
608
640
|
|
|
609
|
-
- pki.ct.fetchLogList(opts)
|
|
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)
|
|
641
|
+
- 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.
|
|
642
|
+
- 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
643
|
|
|
612
644
|
## v0.3.20 — 2026-07-25
|
|
613
645
|
|
|
614
|
-
PKCS#12 public-key privacy ships
|
|
646
|
+
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
647
|
|
|
616
648
|
### Added
|
|
617
649
|
|
|
618
|
-
- PKCS#12 public-key privacy (RFC 7292 sec. 3.1): pki.pkcs12.build encrypts a SafeContents to recipient public keys
|
|
619
|
-
- pki.pkcs12.open gains opts.recipientKey (+ opts.recipientCert or recipientIndex) to decrypt an id-envelopedData safe via pki.cms.decrypt,
|
|
650
|
+
- 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.
|
|
651
|
+
- 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
652
|
|
|
621
653
|
### Fixed
|
|
622
654
|
|
|
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
|
|
655
|
+
- 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
656
|
|
|
625
657
|
## v0.3.19 — 2026-07-25
|
|
626
658
|
|
|
627
|
-
The CMP HTTP transfer client ships
|
|
659
|
+
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
660
|
|
|
629
661
|
### Added
|
|
630
662
|
|
|
631
|
-
- pki.cmp.transfer(url, message, opts)
|
|
632
|
-
- pki.cmp.wellKnownUrl(base, opts)
|
|
663
|
+
- 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.
|
|
664
|
+
- 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
665
|
|
|
634
666
|
### Changed
|
|
635
667
|
|
|
636
|
-
- pki.x509.sign now accepts a subjectAltName iPAddress entry as a dotted-quad IPv4 or colon-hex IPv6 string
|
|
637
|
-
- extendedKeyUsage and certificatePolicies now accept a raw dotted-decimal OID string directly, in addition to a registered purpose/policy name
|
|
668
|
+
- 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).
|
|
669
|
+
- 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
670
|
|
|
639
671
|
## v0.3.18 — 2026-07-25
|
|
640
672
|
|
|
641
|
-
The ACME client ships
|
|
673
|
+
The ACME client ships. pki.acme drives the full RFC 8555 certificate-issuance flow over the shared node:https transport.
|
|
642
674
|
|
|
643
675
|
### Added
|
|
644
676
|
|
|
645
|
-
- pki.acme.client(directoryUrl, opts)
|
|
677
|
+
- 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
678
|
- 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
679
|
|
|
648
680
|
### Fixed
|
|
649
681
|
|
|
650
|
-
- The EST enrollment client now measures a string response body as UTF-8
|
|
682
|
+
- 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
683
|
|
|
652
684
|
## v0.3.17 — 2026-07-24
|
|
653
685
|
|
|
@@ -659,12 +691,12 @@ Refresh two development-only tooling dependencies to clear newly-disclosed advis
|
|
|
659
691
|
|
|
660
692
|
## v0.3.16 — 2026-07-24
|
|
661
693
|
|
|
662
|
-
The EST enrollment client ships
|
|
694
|
+
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
695
|
|
|
664
696
|
### Added
|
|
665
697
|
|
|
666
|
-
- pki.est.cacerts(baseUrl, opts?), pki.est.simpleenroll(baseUrl, csr, opts?), and pki.est.simplereenroll(baseUrl, csr, opts?)
|
|
667
|
-
- pki.transport
|
|
698
|
+
- 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.
|
|
699
|
+
- 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
700
|
|
|
669
701
|
## v0.3.15 — 2026-07-24
|
|
670
702
|
|
|
@@ -673,7 +705,7 @@ PKCS#12 public-key integrity is produced and verified (RFC 7292 sec. 4).
|
|
|
673
705
|
### Added
|
|
674
706
|
|
|
675
707
|
- 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
|
|
708
|
+
- 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
709
|
|
|
678
710
|
## v0.3.14 — 2026-07-23
|
|
679
711
|
|
|
@@ -682,7 +714,7 @@ CMS AuthenticatedData is produced and verified (RFC 5652 sec. 9).
|
|
|
682
714
|
### Added
|
|
683
715
|
|
|
684
716
|
- 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
|
|
717
|
+
- 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
718
|
|
|
687
719
|
## v0.3.13 — 2026-07-23
|
|
688
720
|
|
|
@@ -690,8 +722,8 @@ CMS gains countersignatures and unsigned attributes (RFC 5652 sec. 11.4).
|
|
|
690
722
|
|
|
691
723
|
### Added
|
|
692
724
|
|
|
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
|
|
694
|
-
- pki.cms.verify surfaces countersignatures and unsigned attributes per signer. Each res.signers[i] carries countersignatures
|
|
725
|
+
- 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.
|
|
726
|
+
- 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
727
|
- 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
728
|
|
|
697
729
|
## v0.3.12 — 2026-07-23
|
|
@@ -700,7 +732,7 @@ pki.pkcs12.open reads and decrypts a password-integrity PKCS#12 store (RFC 7292,
|
|
|
700
732
|
|
|
701
733
|
### Added
|
|
702
734
|
|
|
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 }
|
|
735
|
+
- 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
736
|
|
|
705
737
|
## v0.3.11 — 2026-07-23
|
|
706
738
|
|
|
@@ -719,10 +751,10 @@ pki.key exports, imports, and PBES2-encrypts private keys (RFC 5958, RFC 8018) o
|
|
|
719
751
|
### Added
|
|
720
752
|
|
|
721
753
|
- 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
|
|
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
|
|
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
|
|
725
|
-
- pki.key.generate(algorithm, opts) generates a key pair over the WebCrypto engine
|
|
754
|
+
- 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.
|
|
755
|
+
- 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.
|
|
756
|
+
- 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.
|
|
757
|
+
- 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
758
|
- 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
759
|
|
|
728
760
|
## v0.3.9 — 2026-07-23
|
|
@@ -732,7 +764,7 @@ pki.crl builds, signs, and verifies X.509 certificate revocation lists (RFC 5280
|
|
|
732
764
|
### Added
|
|
733
765
|
|
|
734
766
|
- 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
|
|
767
|
+
- 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
768
|
- 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
769
|
|
|
738
770
|
## v0.3.8 — 2026-07-18
|
|
@@ -741,8 +773,8 @@ Human-readable inspection extends to CRLs, CSRs, and CMS messages, with a pki.sc
|
|
|
741
773
|
|
|
742
774
|
### Added
|
|
743
775
|
|
|
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
|
|
745
|
-
- pki.schema.detectFormat(input) returns the registered PKI format name a DER Buffer or PEM string encodes
|
|
776
|
+
- 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.
|
|
777
|
+
- 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
778
|
|
|
747
779
|
## v0.3.7 — 2026-07-17
|
|
748
780
|
|
|
@@ -750,84 +782,84 @@ Certification path building arrives as pki.path.build, and pki.lint gains seven
|
|
|
750
782
|
|
|
751
783
|
### Added
|
|
752
784
|
|
|
753
|
-
- pki.path.build(leaf, opts)
|
|
785
|
+
- 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
786
|
- 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
787
|
|
|
756
788
|
## v0.3.6 — 2026-07-17
|
|
757
789
|
|
|
758
|
-
pki.cmp.build gains the CA/responder side
|
|
790
|
+
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
791
|
|
|
760
792
|
### Added
|
|
761
793
|
|
|
762
|
-
- pki.cmp.build message.body now accepts the CA/responder-side arms: ip / cp / kup / ccp (a CertRepMessage
|
|
794
|
+
- 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
795
|
|
|
764
796
|
## v0.3.5 — 2026-07-17
|
|
765
797
|
|
|
766
|
-
pki.cmp.build assembles protected RFC 9810 CMP PKIMessages
|
|
798
|
+
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
799
|
|
|
768
800
|
### Added
|
|
769
801
|
|
|
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
|
|
771
|
-
- pki.crmf.buildCertTemplate(template) encodes a bare RFC 4211 CertTemplate (subject, public key, validity, requested extensions, version) to canonical DER
|
|
802
|
+
- 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.
|
|
803
|
+
- 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
804
|
|
|
773
805
|
## v0.3.4 — 2026-07-17
|
|
774
806
|
|
|
775
|
-
pki.schema.c509.encode produces C509 CBOR certificates
|
|
807
|
+
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
808
|
|
|
777
809
|
### Added
|
|
778
810
|
|
|
779
|
-
- pki.schema.c509.encode(input) encodes a C509 certificate to deterministic-CBOR bytes
|
|
780
|
-
- pki.cbor.build is a deterministic-CBOR encoder (RFC 8949 section 4.2)
|
|
811
|
+
- 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.
|
|
812
|
+
- 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
813
|
|
|
782
814
|
## v0.3.3 — 2026-07-17
|
|
783
815
|
|
|
784
|
-
pki.crmf.build issues RFC 4211 certificate request messages
|
|
816
|
+
pki.crmf.build issues RFC 4211 certificate request messages: a CertReqMessages with a signature proof of possession, over every algorithm the toolkit supports.
|
|
785
817
|
|
|
786
818
|
### Added
|
|
787
819
|
|
|
788
|
-
- pki.crmf.build(spec, key, opts) builds and DER-encodes an RFC 4211 CertReqMessages
|
|
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
|
|
820
|
+
- 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.
|
|
821
|
+
- 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
822
|
|
|
791
823
|
## v0.3.2 — 2026-07-17
|
|
792
824
|
|
|
793
|
-
pki.attrcert.sign issues RFC 5755 attribute certificates
|
|
825
|
+
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
826
|
|
|
795
827
|
### Added
|
|
796
828
|
|
|
797
|
-
- pki.attrcert.sign(spec, issuer, opts) builds and signs an RFC 5755 attribute certificate as an Attribute Authority
|
|
829
|
+
- 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
830
|
|
|
799
831
|
## v0.3.1 — 2026-07-17
|
|
800
832
|
|
|
801
|
-
pki.csr.sign issues PKCS#10 certification requests
|
|
833
|
+
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
834
|
|
|
803
835
|
### Added
|
|
804
836
|
|
|
805
|
-
- pki.csr.sign(spec, key, opts) builds
|
|
837
|
+
- 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
838
|
|
|
807
839
|
### Fixed
|
|
808
840
|
|
|
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,
|
|
841
|
+
- 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
842
|
|
|
811
843
|
## v0.3.0 — 2026-07-16
|
|
812
844
|
|
|
813
|
-
pki.x509.sign issues
|
|
845
|
+
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
846
|
|
|
815
847
|
### Added
|
|
816
848
|
|
|
817
|
-
- pki.x509.sign(spec, issuer, opts) builds and signs an X.509 certificate
|
|
818
|
-
- pki.asn1.build.namedBitString(positions) encodes a minimal DER NamedBitList (X.690 section 11.2.2)
|
|
849
|
+
- 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.
|
|
850
|
+
- 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
851
|
|
|
820
852
|
### Security
|
|
821
853
|
|
|
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
|
|
854
|
+
- 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
855
|
|
|
824
856
|
## v0.2.33 — 2026-07-16
|
|
825
857
|
|
|
826
|
-
Attribute certificates now decode their RFC 5755 attribute values and attribute-certificate extensions
|
|
858
|
+
Attribute certificates now decode their RFC 5755 attribute values and attribute-certificate extensions alongside the certificate structure.
|
|
827
859
|
|
|
828
860
|
### Added
|
|
829
861
|
|
|
830
|
-
- pki.schema.attrcert.parse decodes the RFC 5755 section 4.4 attribute values
|
|
862
|
+
- 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
863
|
|
|
832
864
|
## v0.2.32 — 2026-07-16
|
|
833
865
|
|
|
@@ -835,11 +867,11 @@ X.509 certificates now decode the Microsoft Active Directory Certificate Service
|
|
|
835
867
|
|
|
836
868
|
### Added
|
|
837
869
|
|
|
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
|
|
870
|
+
- 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
871
|
|
|
840
872
|
### Changed
|
|
841
873
|
|
|
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
|
|
874
|
+
- 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
875
|
|
|
844
876
|
## v0.2.31 — 2026-07-16
|
|
845
877
|
|
|
@@ -847,12 +879,12 @@ X.509 certificates now decode the RFC 3739 / ETSI EN 319 412-5 qualified-certifi
|
|
|
847
879
|
|
|
848
880
|
### Added
|
|
849
881
|
|
|
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
|
|
882
|
+
- 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
883
|
|
|
852
884
|
### Security
|
|
853
885
|
|
|
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
|
|
855
|
-
- Certification-path validation rejects a
|
|
886
|
+
- 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.
|
|
887
|
+
- 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
888
|
|
|
857
889
|
## v0.2.30 — 2026-07-16
|
|
858
890
|
|
|
@@ -860,7 +892,7 @@ C509 CBOR-encoded certificates arrive as pki.schema.c509.parse: decode the compa
|
|
|
860
892
|
|
|
861
893
|
### Added
|
|
862
894
|
|
|
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
|
|
895
|
+
- 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
896
|
|
|
865
897
|
### Fixed
|
|
866
898
|
|
|
@@ -880,8 +912,8 @@ The Certificate Transparency log-list trust surface arrives as pki.ct.parseLogLi
|
|
|
880
912
|
|
|
881
913
|
### Added
|
|
882
914
|
|
|
883
|
-
- pki.ct.parseLogList(json, opts) ingests a Certificate Transparency log-list JSON document into { logs, byLogId }
|
|
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
|
|
915
|
+
- 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.
|
|
916
|
+
- 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
917
|
|
|
886
918
|
## v0.2.27 — 2026-07-16
|
|
887
919
|
|
|
@@ -889,8 +921,8 @@ CMS CompressedData arrives as pki.cms.compress / pki.cms.decompress, with the ma
|
|
|
889
921
|
|
|
890
922
|
### Added
|
|
891
923
|
|
|
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
|
|
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
|
|
924
|
+
- 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.
|
|
925
|
+
- 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
926
|
|
|
895
927
|
## v0.2.26 — 2026-07-16
|
|
896
928
|
|
|
@@ -899,7 +931,7 @@ S/MIME encryption arrives as pki.smime.encrypt / pki.smime.decrypt: envelope and
|
|
|
899
931
|
### Added
|
|
900
932
|
|
|
901
933
|
- 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
|
|
934
|
+
- 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
935
|
|
|
904
936
|
## v0.2.25 — 2026-07-16
|
|
905
937
|
|
|
@@ -916,17 +948,17 @@ SCT-list encoding and log signing join pki.ct: encodeSctList builds an RFC 6962
|
|
|
916
948
|
|
|
917
949
|
### Added
|
|
918
950
|
|
|
919
|
-
- pki.ct.encodeSctList(scts) builds an RFC 6962 SCT-list extension value from an array of SCTs
|
|
951
|
+
- 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
952
|
- 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
953
|
|
|
922
954
|
## v0.2.23 — 2026-07-16
|
|
923
955
|
|
|
924
|
-
CMS content encryption arrives as pki.cms.encrypt and pki.cms.decrypt: EnvelopedData, AuthEnvelopedData, and EncryptedData with every RFC 5652 recipient type
|
|
956
|
+
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
957
|
|
|
926
958
|
### Added
|
|
927
959
|
|
|
928
960
|
- 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
|
|
961
|
+
- 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
962
|
- 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
963
|
|
|
932
964
|
## v0.2.22 — 2026-07-15
|
|
@@ -936,7 +968,7 @@ The RFC 6960 OCSP producer and relying-party surface arrives as pki.ocsp: build
|
|
|
936
968
|
### Added
|
|
937
969
|
|
|
938
970
|
- 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
|
|
971
|
+
- 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
972
|
- 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
973
|
|
|
942
974
|
## v0.2.21 — 2026-07-15
|
|
@@ -945,56 +977,56 @@ ML-KEM public keys in X.509 certificates and PKCS#8 private keys (RFC 9935 / FIP
|
|
|
945
977
|
|
|
946
978
|
### Added
|
|
947
979
|
|
|
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
|
|
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
|
|
980
|
+
- 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).
|
|
981
|
+
- 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
982
|
|
|
951
983
|
### Fixed
|
|
952
984
|
|
|
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
|
|
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
|
|
985
|
+
- 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.
|
|
986
|
+
- 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
987
|
|
|
956
988
|
## v0.2.20 — 2026-07-15
|
|
957
989
|
|
|
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
|
|
990
|
+
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
991
|
|
|
960
992
|
### Added
|
|
961
993
|
|
|
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
|
|
994
|
+
- 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
995
|
|
|
964
996
|
### Changed
|
|
965
997
|
|
|
966
|
-
- A WebAuthn attestation object whose attStmt is not a CBOR map is now classified as a malformed attestation object
|
|
998
|
+
- 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
999
|
|
|
968
1000
|
### Fixed
|
|
969
1001
|
|
|
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
|
|
1002
|
+
- 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
1003
|
|
|
972
1004
|
## v0.2.19 — 2026-07-14
|
|
973
1005
|
|
|
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
|
|
1006
|
+
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
1007
|
|
|
976
1008
|
### Added
|
|
977
1009
|
|
|
978
|
-
- pki.tsp.request builds an RFC 3161 TimeStampReq around a message imprint, with the optional nonce, requested TSA policy, certReq, and extensions
|
|
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
|
|
1010
|
+
- 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.
|
|
1011
|
+
- 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
1012
|
|
|
981
1013
|
### Fixed
|
|
982
1014
|
|
|
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
|
|
1015
|
+
- 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
1016
|
- 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
1017
|
|
|
986
1018
|
## v0.2.18 — 2026-07-14
|
|
987
1019
|
|
|
988
|
-
Composite ML-DSA signatures join CMS SignedData: pki.cms.sign and pki.cms.verify now produce and verify a composite SignerInfo
|
|
1020
|
+
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
1021
|
|
|
990
1022
|
### Added
|
|
991
1023
|
|
|
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
|
|
993
|
-
- pki.cms.sign accepts a composite signer as { cert, key: { mldsa, trad } }
|
|
1024
|
+
- 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).
|
|
1025
|
+
- 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
1026
|
|
|
995
1027
|
### Fixed
|
|
996
1028
|
|
|
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
|
|
1029
|
+
- 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
1030
|
|
|
999
1031
|
## v0.2.17 — 2026-07-13
|
|
1000
1032
|
|
|
@@ -1002,7 +1034,7 @@ Post-quantum SLH-DSA joins CMS SignedData: pki.cms.sign and pki.cms.verify now s
|
|
|
1002
1034
|
|
|
1003
1035
|
### Added
|
|
1004
1036
|
|
|
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
|
|
1037
|
+
- 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
1038
|
- 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
1039
|
|
|
1008
1040
|
### Changed
|
|
@@ -1015,16 +1047,16 @@ Post-quantum ML-DSA joins CMS SignedData: pki.cms.sign and pki.cms.verify now si
|
|
|
1015
1047
|
|
|
1016
1048
|
### Added
|
|
1017
1049
|
|
|
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
|
|
1050
|
+
- 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
1051
|
- 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
1052
|
|
|
1021
1053
|
### Changed
|
|
1022
1054
|
|
|
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
|
|
1055
|
+
- 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
1056
|
|
|
1025
1057
|
## v0.2.15 — 2026-07-13
|
|
1026
1058
|
|
|
1027
|
-
CMS SignedData signing arrives as pki.cms.sign, and RFC 3161 timestamp token creation as pki.tsp.sign
|
|
1059
|
+
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
1060
|
|
|
1029
1061
|
### Added
|
|
1030
1062
|
|
|
@@ -1033,11 +1065,11 @@ CMS SignedData signing arrives as pki.cms.sign, and RFC 3161 timestamp token cre
|
|
|
1033
1065
|
|
|
1034
1066
|
## v0.2.14 — 2026-07-13
|
|
1035
1067
|
|
|
1036
|
-
CMS SignedData signature verification arrives as pki.cms.verify
|
|
1068
|
+
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
1069
|
|
|
1038
1070
|
### Added
|
|
1039
1071
|
|
|
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
|
|
1072
|
+
- 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
1073
|
|
|
1042
1074
|
## v0.2.13 — 2026-07-13
|
|
1043
1075
|
|
|
@@ -1057,7 +1089,7 @@ pki.ct.verifySct verifies a Signed Certificate Timestamp's signature against a C
|
|
|
1057
1089
|
|
|
1058
1090
|
## v0.2.11 — 2026-07-13
|
|
1059
1091
|
|
|
1060
|
-
The pki command-line tool gains inspect, lint, convert, and verify
|
|
1092
|
+
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
1093
|
|
|
1062
1094
|
### Added
|
|
1063
1095
|
|
|
@@ -1068,11 +1100,11 @@ The pki command-line tool gains inspect, lint, convert, and verify -- front-ends
|
|
|
1068
1100
|
|
|
1069
1101
|
## v0.2.10 — 2026-07-13
|
|
1070
1102
|
|
|
1071
|
-
Certificate linting arrives as pki.lint
|
|
1103
|
+
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
1104
|
|
|
1073
1105
|
### Added
|
|
1074
1106
|
|
|
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
|
|
1107
|
+
- 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
1108
|
- 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
1109
|
|
|
1078
1110
|
## v0.2.9 — 2026-07-13
|
|
@@ -1081,7 +1113,7 @@ Certification-path validation verifies composite ML-DSA signatures, accepting a
|
|
|
1081
1113
|
|
|
1082
1114
|
### Added
|
|
1083
1115
|
|
|
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
|
|
1116
|
+
- 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
1117
|
- 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
1118
|
|
|
1087
1119
|
## v0.2.8 — 2026-07-13
|
|
@@ -1090,7 +1122,7 @@ pki.webcrypto rejects an AES key of invalid length at import instead of deferrin
|
|
|
1090
1122
|
|
|
1091
1123
|
### Fixed
|
|
1092
1124
|
|
|
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,
|
|
1125
|
+
- 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
1126
|
- 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
1127
|
|
|
1096
1128
|
## v0.2.7 — 2026-07-13
|
|
@@ -1099,12 +1131,12 @@ pki.webcrypto rejects an imported key whose type disagrees with its algorithm, a
|
|
|
1099
1131
|
|
|
1100
1132
|
### Changed
|
|
1101
1133
|
|
|
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
|
|
1134
|
+
- 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
1135
|
|
|
1104
1136
|
### Fixed
|
|
1105
1137
|
|
|
1106
1138
|
- 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
|
|
1139
|
+
- 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
1140
|
|
|
1109
1141
|
## v0.2.6 — 2026-07-12
|
|
1110
1142
|
|
|
@@ -1112,17 +1144,17 @@ WebAuthn attestation verification covers Ed448 and the RFC 9864 fully-specified
|
|
|
1112
1144
|
|
|
1113
1145
|
### Added
|
|
1114
1146
|
|
|
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
|
|
1116
|
-
- The verifier
|
|
1147
|
+
- 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.
|
|
1148
|
+
- 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
1149
|
|
|
1118
1150
|
### Changed
|
|
1119
1151
|
|
|
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
|
|
1152
|
+
- 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
1153
|
- An EC2 credential key must use the uncompressed point form; the compressed sign-bit y encoding is rejected for WebAuthn credential keys.
|
|
1122
1154
|
|
|
1123
1155
|
### Fixed
|
|
1124
1156
|
|
|
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
|
|
1157
|
+
- 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
1158
|
- 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
1159
|
|
|
1128
1160
|
## v0.2.5 — 2026-07-12
|
|
@@ -1132,12 +1164,12 @@ WebAuthn / passkey attestation verification joins the toolkit as pki.webauthn.
|
|
|
1132
1164
|
### Added
|
|
1133
1165
|
|
|
1134
1166
|
- 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
|
|
1167
|
+
- 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
1168
|
- 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
1169
|
|
|
1138
1170
|
### Changed
|
|
1139
1171
|
|
|
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
|
|
1172
|
+
- 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
1173
|
|
|
1142
1174
|
## v0.2.4 — 2026-07-12
|
|
1143
1175
|
|
|
@@ -1153,7 +1185,7 @@ Offline Sigstore bundle verification joins the toolkit as pki.sigstore.
|
|
|
1153
1185
|
|
|
1154
1186
|
### Added
|
|
1155
1187
|
|
|
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
|
|
1188
|
+
- 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
1189
|
- 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
1190
|
|
|
1159
1191
|
## v0.2.2 — 2026-07-11
|
|
@@ -1163,7 +1195,7 @@ Hybrid Public Key Encryption (RFC 9180) joins the toolkit as pki.hpke.
|
|
|
1163
1195
|
### Added
|
|
1164
1196
|
|
|
1165
1197
|
- 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
|
|
1198
|
+
- 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
1199
|
|
|
1168
1200
|
## v0.2.1 — 2026-07-11
|
|
1169
1201
|
|
|
@@ -1171,12 +1203,12 @@ Stateful hash-based signature verification (HSS/LMS) joins the toolkit as pki.sh
|
|
|
1171
1203
|
|
|
1172
1204
|
### Added
|
|
1173
1205
|
|
|
1174
|
-
- pki.shbs.verify(publicKey, message, signature) verifies an HSS (Hierarchical Signature System) signature
|
|
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)
|
|
1206
|
+
- 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.
|
|
1207
|
+
- 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
1208
|
|
|
1177
1209
|
### Changed
|
|
1178
1210
|
|
|
1179
|
-
- XMSS / XMSS^MT verification and automatic HSS/LMS verification inside pki.path.validate are not in this release
|
|
1211
|
+
- 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
1212
|
|
|
1181
1213
|
## v0.2.0 — 2026-07-11
|
|
1182
1214
|
|
|
@@ -1186,13 +1218,13 @@ Trust-store ingestion, sharded-CRL revocation, and a hardened input-guard layer.
|
|
|
1186
1218
|
|
|
1187
1219
|
- 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
1220
|
- 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
|
|
1221
|
+
- 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
1222
|
- 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
1223
|
- 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
1224
|
|
|
1193
1225
|
### Changed
|
|
1194
1226
|
|
|
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
|
|
1227
|
+
- 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
1228
|
- 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
1229
|
|
|
1198
1230
|
### Fixed
|
|
@@ -1201,7 +1233,7 @@ Trust-store ingestion, sharded-CRL revocation, and a hardened input-guard layer.
|
|
|
1201
1233
|
- 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
1234
|
- 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
1235
|
- 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
|
|
1236
|
+
- 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
1237
|
|
|
1206
1238
|
## v0.1.32 — 2026-07-11
|
|
1207
1239
|
|
|
@@ -1218,7 +1250,7 @@ The DER format cohort and the JOSE surface graduate to stable.
|
|
|
1218
1250
|
### Changed
|
|
1219
1251
|
|
|
1220
1252
|
- 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,
|
|
1253
|
+
- 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
1254
|
|
|
1223
1255
|
## v0.1.30 — 2026-07-11
|
|
1224
1256
|
|
|
@@ -1226,11 +1258,11 @@ Fail-closed hardening of the byte-input and text-decode boundaries.
|
|
|
1226
1258
|
|
|
1227
1259
|
### Changed
|
|
1228
1260
|
|
|
1229
|
-
- A detached-backed BufferSource
|
|
1261
|
+
- 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
1262
|
|
|
1231
1263
|
### Fixed
|
|
1232
1264
|
|
|
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
|
|
1265
|
+
- 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
1266
|
- pki.oid.fromDER rejects a non-Buffer or detached-backed input with a typed oid/bad-input error instead of a raw TypeError.
|
|
1235
1267
|
|
|
1236
1268
|
## v0.1.29 — 2026-07-10
|
|
@@ -1239,7 +1271,7 @@ A detached-backed BufferSource now fails closed with a typed error at every byte
|
|
|
1239
1271
|
|
|
1240
1272
|
### Fixed
|
|
1241
1273
|
|
|
1242
|
-
- pki.webcrypto digest / sign / verify no longer silently process a detached-backed Buffer as
|
|
1274
|
+
- 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
1275
|
- 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
1276
|
|
|
1245
1277
|
## v0.1.28 — 2026-07-10
|
|
@@ -1250,7 +1282,7 @@ Merkle transparency proof verification joins the toolkit as pki.merkle.
|
|
|
1250
1282
|
|
|
1251
1283
|
- 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
1284
|
- 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
|
|
1285
|
+
- 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
1286
|
- 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
1287
|
- 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
1288
|
|
|
@@ -1260,9 +1292,9 @@ A strict deterministic-CBOR codec joins the toolkit as pki.cbor.
|
|
|
1260
1292
|
|
|
1261
1293
|
### Added
|
|
1262
1294
|
|
|
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
|
|
1295
|
+
- 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
1296
|
- 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
|
|
1297
|
+
- 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
1298
|
- 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
1299
|
|
|
1268
1300
|
### Changed
|
|
@@ -1275,7 +1307,7 @@ Test-coverage measurement and the OpenSSF Best Practices badge.
|
|
|
1275
1307
|
|
|
1276
1308
|
### Added
|
|
1277
1309
|
|
|
1278
|
-
- npm run coverage measures statement and branch coverage over the full test suite with c8. It is a development dependency only
|
|
1310
|
+
- 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
1311
|
|
|
1280
1312
|
### Changed
|
|
1281
1313
|
|
|
@@ -1291,7 +1323,7 @@ ACME joins the toolkit: an RFC 8555 message layer over a new RFC 7515 JOSE surfa
|
|
|
1291
1323
|
- 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
1324
|
- 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
1325
|
- 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
|
|
1326
|
+
- 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
1327
|
- The error taxonomy gains JoseError (jose/*) and AcmeError (acme/*); the OID registry gains id-pe-acmeIdentifier (RFC 8737).
|
|
1296
1328
|
- 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
1329
|
- 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 +1334,34 @@ EST enrollment joins the toolkit: the RFC 8951 CSR-attributes parser and an RFC
|
|
|
1302
1334
|
|
|
1303
1335
|
### Added
|
|
1304
1336
|
|
|
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 }
|
|
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
|
|
1337
|
+
- 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).
|
|
1338
|
+
- 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
1339
|
- The error taxonomy gains CsrattrsError (csrattrs/*) and EstError (est/*).
|
|
1308
1340
|
- 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
1341
|
- Fuzz targets csrattrs-parse and est-transfer (the base64 + multipart codecs) join the per-PR and nightly fuzz matrices with seed corpora.
|
|
1310
1342
|
|
|
1311
1343
|
## v0.1.23 — 2026-07-09
|
|
1312
1344
|
|
|
1313
|
-
CMS grows authenticated content: RFC 5652 AuthenticatedData, RFC 5083 AuthEnvelopedData, and RFC 9629 KEM recipients (ML-KEM ready)
|
|
1345
|
+
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
1346
|
|
|
1315
1347
|
### Added
|
|
1316
1348
|
|
|
1317
1349
|
- 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
|
|
1350
|
+
- 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
1351
|
- 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
1352
|
- 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
1353
|
- 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
|
|
1354
|
+
- 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
1355
|
- 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
1356
|
- 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
|
|
1357
|
+
- 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
1358
|
|
|
1327
1359
|
### Changed
|
|
1328
1360
|
|
|
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
|
|
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)
|
|
1361
|
+
- 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.
|
|
1362
|
+
- 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
1363
|
- 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
|
|
1364
|
+
- 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
1365
|
- 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
1366
|
- 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
1367
|
- RecipientEncryptedKey surfaces ridType (rKeyId | issuerAndSerialNumber) so a key-agreement consumer no longer duck-types the recipient-matching form.
|
|
@@ -1367,8 +1399,8 @@ SLH-DSA signatures verify in certification-path validation, and the post-quantum
|
|
|
1367
1399
|
|
|
1368
1400
|
### Fixed
|
|
1369
1401
|
|
|
1370
|
-
- The shared AlgorithmIdentifier decoder now rejects a present parameters field on the algorithms whose parameters MUST be absent
|
|
1371
|
-
- Certification-path validation now enforces issuer-key / signature-algorithm consistency for the one-shot families whose public-key OID equals the signature OID
|
|
1402
|
+
- 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.
|
|
1403
|
+
- 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
1404
|
|
|
1373
1405
|
## v0.1.20 — 2026-07-09
|
|
1374
1406
|
|
|
@@ -1376,10 +1408,10 @@ An RFC 6962 Certificate Transparency SCT-list parser joins the toolkit.
|
|
|
1376
1408
|
|
|
1377
1409
|
### Added
|
|
1378
1410
|
|
|
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 }
|
|
1411
|
+
- 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
1412
|
- 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
|
|
1382
|
-
- The OID registry gains the Certificate Transparency arc
|
|
1413
|
+
- 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).
|
|
1414
|
+
- 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
1415
|
- The error taxonomy gains CtError, carrying a stable ct/* code.
|
|
1384
1416
|
|
|
1385
1417
|
## v0.1.19 — 2026-07-09
|
|
@@ -1388,9 +1420,9 @@ An RFC 9810 Certificate Management Protocol message parser joins the pki.schema
|
|
|
1388
1420
|
|
|
1389
1421
|
### Added
|
|
1390
1422
|
|
|
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
|
|
1423
|
+
- 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
1424
|
- 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
|
|
1425
|
+
- 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
1426
|
- 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
1427
|
- The error taxonomy gains CmpError, carrying a stable cmp/* code.
|
|
1396
1428
|
|
|
@@ -1401,8 +1433,8 @@ An RFC 9810 Certificate Management Protocol message parser joins the pki.schema
|
|
|
1401
1433
|
|
|
1402
1434
|
### Fixed
|
|
1403
1435
|
|
|
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
|
|
1405
|
-
- Certification-path validation rejects a non-empty DER NULL in an RSASSA-PSS hash AlgorithmIdentifier's parameters
|
|
1436
|
+
- 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).
|
|
1437
|
+
- 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
1438
|
|
|
1407
1439
|
## v0.1.18 — 2026-07-08
|
|
1408
1440
|
|
|
@@ -1410,9 +1442,9 @@ An RFC 7292 PKCS#12 (PFX) store parser joins the pki.schema family.
|
|
|
1410
1442
|
|
|
1411
1443
|
### Added
|
|
1412
1444
|
|
|
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
|
|
1445
|
+
- 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
1446
|
- 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
|
|
1447
|
+
- 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
1448
|
- 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
1449
|
- 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
1450
|
- 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 +1456,7 @@ An RFC 7292 PKCS#12 (PFX) store parser joins the pki.schema family.
|
|
|
1424
1456
|
|
|
1425
1457
|
### Fixed
|
|
1426
1458
|
|
|
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
|
|
1459
|
+
- 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
1460
|
|
|
1429
1461
|
## v0.1.17 — 2026-07-06
|
|
1430
1462
|
|
|
@@ -1432,7 +1464,7 @@ An RFC 4211 certificate-request-message parser joins the pki.schema family.
|
|
|
1432
1464
|
|
|
1433
1465
|
### Added
|
|
1434
1466
|
|
|
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).
|
|
1467
|
+
- 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
1468
|
- pki.schema.crmf.pemDecode(text, label?) — extract the DER bytes from a PEM block (the first block unless a label is given).
|
|
1437
1469
|
- 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
1470
|
- The error taxonomy gains CrmfError, carrying a stable crmf/* code.
|
|
@@ -1451,7 +1483,7 @@ Certification path validation joins the toolkit — RFC 5280 section 6, as a pur
|
|
|
1451
1483
|
|
|
1452
1484
|
- 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
1485
|
- 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)
|
|
1486
|
+
- 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
1487
|
- 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
1488
|
- The error taxonomy gains PathError, carrying the per-check reason in its stable path/* code.
|
|
1457
1489
|
|
|
@@ -1461,14 +1493,14 @@ CMS EnvelopedData and EncryptedData join the parser, and every documentation exa
|
|
|
1461
1493
|
|
|
1462
1494
|
### Added
|
|
1463
1495
|
|
|
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
|
|
1465
|
-
- The schema engine gains an implicitTag option on pki.schema.engine.seq() and on pki.schema.pkix.algorithmIdentifier(ns, { implicitTag })
|
|
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)
|
|
1496
|
+
- 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.
|
|
1497
|
+
- 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.
|
|
1498
|
+
- 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
1499
|
|
|
1468
1500
|
### Changed
|
|
1469
1501
|
|
|
1470
1502
|
- 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
|
|
1503
|
+
- 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
1504
|
|
|
1473
1505
|
### Fixed
|
|
1474
1506
|
|
|
@@ -1482,8 +1514,8 @@ An RFC 5755 attribute-certificate parser joins the pki.schema family.
|
|
|
1482
1514
|
### Added
|
|
1483
1515
|
|
|
1484
1516
|
- 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
|
|
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
|
|
1517
|
+
- 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.
|
|
1518
|
+
- 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
1519
|
|
|
1488
1520
|
## v0.1.13 — 2026-07-05
|
|
1489
1521
|
|
|
@@ -1501,7 +1533,7 @@ SLH-DSA object identifiers corrected and completed to all twelve FIPS 205 parame
|
|
|
1501
1533
|
|
|
1502
1534
|
### Fixed
|
|
1503
1535
|
|
|
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
|
|
1536
|
+
- 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
1537
|
|
|
1506
1538
|
## v0.1.11 — 2026-07-05
|
|
1507
1539
|
|
|
@@ -1534,7 +1566,7 @@ A PKCS#8 private-key parser joins the pki.schema family.
|
|
|
1534
1566
|
### Added
|
|
1535
1567
|
|
|
1536
1568
|
- 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
|
|
1569
|
+
- 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
1570
|
- 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
1571
|
|
|
1540
1572
|
## v0.1.8 — 2026-07-04
|
|
@@ -1543,18 +1575,18 @@ A PKCS#10 certification-request parser joins the pki.schema family.
|
|
|
1543
1575
|
|
|
1544
1576
|
### Added
|
|
1545
1577
|
|
|
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
|
|
1578
|
+
- 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
1579
|
- 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
1580
|
|
|
1549
1581
|
### Changed
|
|
1550
1582
|
|
|
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
|
|
1583
|
+
- 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
1584
|
|
|
1553
1585
|
### Security
|
|
1554
1586
|
|
|
1555
|
-
- pki.asn1.read.integer now rejects an ENUMERATED-tagged node. INTEGER and ENUMERATED share DER content encoding, so an INTEGER-pinned field
|
|
1556
|
-
- SubjectPublicKeyInfo is now required to be a universal SEQUENCE across the certificate and certification-request parsers
|
|
1557
|
-
- SET OF components are now required to be in ascending DER order (X.690 §11.6) wherever the schema declares a SET OF
|
|
1587
|
+
- 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.
|
|
1588
|
+
- 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.
|
|
1589
|
+
- 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
1590
|
|
|
1559
1591
|
### Migration
|
|
1560
1592
|
|
|
@@ -1566,7 +1598,7 @@ A unified pki.schema family: the structure-schema engine, the X.509 parser, a ne
|
|
|
1566
1598
|
|
|
1567
1599
|
### Added
|
|
1568
1600
|
|
|
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
|
|
1601
|
+
- 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
1602
|
- 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
1603
|
|
|
1572
1604
|
### Changed
|
|
@@ -1584,11 +1616,11 @@ A declarative ASN.1 structure-schema engine; the X.509 parser is rebuilt on it.
|
|
|
1584
1616
|
|
|
1585
1617
|
### Added
|
|
1586
1618
|
|
|
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
|
|
1619
|
+
- 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
1620
|
|
|
1589
1621
|
### Changed
|
|
1590
1622
|
|
|
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
|
|
1623
|
+
- 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
1624
|
|
|
1593
1625
|
## v0.1.5 — 2026-07-04
|
|
1594
1626
|
|
|
@@ -1596,12 +1628,12 @@ Container healthcheck honors WIKI_PORT; release-tooling supply-chain hardening.
|
|
|
1596
1628
|
|
|
1597
1629
|
### Fixed
|
|
1598
1630
|
|
|
1599
|
-
- The example wiki container's HEALTHCHECK now probes the port from WIKI_PORT (defaulting to 3009)
|
|
1631
|
+
- 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
1632
|
|
|
1601
1633
|
### Security
|
|
1602
1634
|
|
|
1603
1635
|
- 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
|
|
1636
|
+
- 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
1637
|
- The workflow-security audit re-runs when its own configuration file changes, so an edit that would suppress a finding is itself audited.
|
|
1606
1638
|
|
|
1607
1639
|
## v0.1.4 — 2026-07-04
|
|
@@ -1618,7 +1650,7 @@ WebCrypto EC key import validates the curve against the key material.
|
|
|
1618
1650
|
|
|
1619
1651
|
### Security
|
|
1620
1652
|
|
|
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
|
|
1653
|
+
- 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
1654
|
|
|
1623
1655
|
## v0.1.2 — 2026-07-04
|
|
1624
1656
|
|
|
@@ -1626,27 +1658,27 @@ Fail-closed hardening across the DER codec, WebCrypto engine, and X.509 parser.
|
|
|
1626
1658
|
|
|
1627
1659
|
### Changed
|
|
1628
1660
|
|
|
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
|
|
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
|
|
1661
|
+
- 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.
|
|
1662
|
+
- 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
1663
|
|
|
1632
1664
|
### Fixed
|
|
1633
1665
|
|
|
1634
1666
|
- 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
|
|
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
|
|
1667
|
+
- 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.
|
|
1668
|
+
- 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
1669
|
- 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
|
|
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
|
|
1670
|
+
- 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.
|
|
1671
|
+
- 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
1672
|
- 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
|
|
1673
|
+
- 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
1674
|
|
|
1643
1675
|
### Security
|
|
1644
1676
|
|
|
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
|
|
1646
|
-
-
|
|
1647
|
-
- The DER decoder also rejects the mirror violation
|
|
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
|
|
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
|
|
1677
|
+
- 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.*.
|
|
1678
|
+
- 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.
|
|
1679
|
+
- 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.
|
|
1680
|
+
- 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.
|
|
1681
|
+
- 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
1682
|
|
|
1651
1683
|
## v0.1.1 — 2026-07-04
|
|
1652
1684
|
|
|
@@ -1654,7 +1686,7 @@ First published release of the 0.1.x foundation.
|
|
|
1654
1686
|
|
|
1655
1687
|
### Changed
|
|
1656
1688
|
|
|
1657
|
-
- First release published to npm. The toolkit surface is the 0.1.x foundation
|
|
1689
|
+
- 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
1690
|
|
|
1659
1691
|
## v0.1.0 — 2026-07-04
|
|
1660
1692
|
|
|
@@ -1662,7 +1694,7 @@ Initial foundation — a PQC-first WebCrypto engine, a strict DER codec, an OID
|
|
|
1662
1694
|
|
|
1663
1695
|
### Added
|
|
1664
1696
|
|
|
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
|
|
1697
|
+
- 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
1698
|
- 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
1699
|
- 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
1700
|
- 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 +1705,4 @@ Initial foundation — a PQC-first WebCrypto engine, a strict DER codec, an OID
|
|
|
1673
1705
|
### Security
|
|
1674
1706
|
|
|
1675
1707
|
- 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
|
|
1708
|
+
- 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.
|