@blamejs/pki 0.5.8 → 0.5.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,14 +4,39 @@ All notable changes to `@blamejs/pki` are documented here. The format
4
4
  follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this
5
5
  project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
- ## v0.5.8 — 2026-08-18
7
+ ## v0.5.10 — 2026-08-18
8
+
9
+ The documentation and the package's own source comments settle on one spelling of the words they use in both, and a gate keeps them there.
10
+
11
+ ### Added
12
+
13
+ - `npm run check:spelling` reports any word in the repository that has a second accepted spelling. It runs in `npm run gates`, on every pull request, and again before the published tarball is packed. The check is whole-word and case-insensitive, both to avoid a failure mode: a substring match reports `publicEncrypt` as a misspelling, and a case-sensitive one walks past the same word capitalized or upper-cased. It self-tests on planted forms before reporting, so a word list that has stopped matching cannot pass as a clean tree.
14
+
15
+ ### Changed
16
+
17
+ - Documentation and source comments now use the US spellings behavior, recognize, unrecognized, labeled, honored, license, defense, neighbor, authorize, initialization, enrollment, signaled, modeled, favor and fulfill. 267 occurrences across 98 files no longer carry a second spelling: the README, the security policy, thirteen release notes and the changelog generated from them, the status-lifecycle record, the comments and error text in lib/, the test suite, and the release and wiki tooling. That figure counts words whose spelling changed, so a line edited for another reason that happened to contain one of these words is not counted twice. One word settles the other way: catalogue, which this repository already used by 185 uses to 32, so the checked form is that one and the US spelling is what now reports. Simple Certificate Enrolment Protocol is RFC 8894's title, quoted as published and allowed only on a line carrying that title in full, so the exception cannot spread to the word.
18
+
19
+ ## v0.5.9 — 2026-08-17
20
+
21
+ A certificate can now carry an internationalized email address, which this toolkit could read and never write.
22
+
23
+ ### Added
24
+
25
+ - 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.
26
+ - 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.
27
+
28
+ ### Fixed
29
+
30
+ - pki.smime.verify's sender binding is now exercised against certificates carrying an otherName. Two behaviors that previously had no conformance vector are pinned: a certificate whose subjectAltName carries an SmtpUTF8Mailbox does not let a legacy subject distinguished-name emailAddress speak for it, and an otherName unrelated to email, such as a Microsoft user principal name, neither erases a matching rfc822Name nor turns a definite non-match into an undecidable one.
31
+
32
+ ## v0.5.8 — 2026-08-17
8
33
 
9
34
  Four verdicts that answered a question nobody had asked now say what they checked, and an email domain comparison no longer folds two registrable domains into one identity.
10
35
 
11
36
  ### Added
12
37
 
13
38
  - pki.cms.decrypt reports originAuthenticated, authenticatedBy and originatorInfo. authenticated is a claim about the content and the key that opened it; it never described who sent the message. originAuthenticated is false for every recipient type the toolkit supports: a ktri or ephemeral-static kari message is minted by anyone holding the recipient's public key, and a pwri or kekri message by any co-recipient sharing the secret. authenticatedBy names what the integrity rests on. originatorInfo is now surfaced rather than decoded and discarded, and is documented as unauthenticated: it sits outside the AEAD's authenticated data, so it is a hint the sender chose, and any certificate it carries must be validated before use. To bind a sender, verify a signature over the plaintext.
14
- - pki.smime.verify accepts expectedSender and reports a sender block of { checked, expected, source, identities, match }. A signature proves a key signed; it does not prove the message came from the mailbox the reader sees. match is true only when the signer certificate asserts the address, compared under RFC 5280 section 7.5: the local-part exactly, the host-part case-insensitively. The address is read from the subjectAltName rfc822Name entries (RFC 8550 section 4.4.3), and where the extension carries none, from the subject distinguished name's PKCS #9 emailAddress attribute, which RFC 8550 section 3 requires a receiving agent to recognise. Where both are present the extension is authoritative, so a stale subject value cannot satisfy expectedSender while the extension names a different mailbox. It is three-valued: false when every identity was comparable and none matched, null when the question went unanswered, and null is not a pass, so a caller enforcing sender binding tests match === true. identities lists what the certificate actually asserts. With no expectedSender a single outer From is used and reported as source: "from", which is advisory, because on a message without header protection that header is attacker-controlled.
39
+ - pki.smime.verify accepts expectedSender and reports a sender block of { checked, expected, source, identities, match }. A signature proves a key signed; it does not prove the message came from the mailbox the reader sees. match is true only when the signer certificate asserts the address, compared under RFC 5280 section 7.5: the local-part exactly, the host-part case-insensitively. The address is read from the subjectAltName rfc822Name entries (RFC 8550 section 4.4.3), and where the extension carries none, from the subject distinguished name's PKCS #9 emailAddress attribute, which RFC 8550 section 3 requires a receiving agent to recognize. Where both are present the extension is authoritative, so a stale subject value cannot satisfy expectedSender while the extension names a different mailbox. It is three-valued: false when every identity was comparable and none matched, null when the question went unanswered, and null is not a pass, so a caller enforcing sender binding tests match === true. identities lists what the certificate actually asserts. With no expectedSender a single outer From is used and reported as source: "from", which is advisory, because on a message without header protection that header is attacker-controlled.
15
40
 
16
41
  ### Changed
17
42
 
@@ -21,7 +46,7 @@ Four verdicts that answered a question nobody had asked now say what they checke
21
46
 
22
47
  - An rfc822Name identity comparison no longer folds the host-part with a Unicode-aware lowercase. U+212A KELVIN SIGN lowercases to ASCII k, so ban<U+212A>.com and bank.com are different byte strings, separately registrable, that compared equal and read as one email identity. The host-part is now folded across A-Z only, which is the case-insensitive ASCII comparison RFC 5280 section 7.5 authorizes and no more. The local-part was already compared exactly and stays that way: RFC 8398 section 5 requires that it not be transformed in any way, including by case folding.
23
48
  - pki.merkle.verifyConsistency refuses a proof whose older tree is empty and whose newer tree is not, as merkle/no-consistency-claim. RFC 6962 section 2.1.2 defines a consistency proof for 0 < oldSize < newSize. An empty tree is a prefix of every tree by definition, so there was no proof to check and nothing bound the newRoot that was passed: any value returned true, including a root from a different log. Two empty trees are unchanged and still check each root against the empty root hash.
24
- - pki.cmc.verify refuses a Full PKI Response that carries nothing tying it to a request, as cmc/unbound-response. Every binding the module could check was previously conditional on the caller having supplied the matching value, and nothing in the verdict reported whether any of them ran, so a response captured from an earlier successful enrollment against the same CA verified identically. Pass what the request retained (transactionId, senderNonce, whose echo is the replay defence of RFC 5272 section 6.6, or dataReturn), or allowUnbound: true to interpret a response that could be a replay of any earlier exchange.
49
+ - pki.cmc.verify refuses a Full PKI Response that carries nothing tying it to a request, as cmc/unbound-response. Every binding the module could check was previously conditional on the caller having supplied the matching value, and nothing in the verdict reported whether any of them ran, so a response captured from an earlier successful enrollment against the same CA verified identically. Pass what the request retained (transactionId, senderNonce, whose echo is the replay defense of RFC 5272 section 6.6, or dataReturn), or allowUnbound: true to interpret a response that could be a replay of any earlier exchange.
25
50
 
26
51
  ## v0.5.7 — 2026-08-16
27
52
 
@@ -199,12 +224,12 @@ CMC (Certificate Management over CMS) ships end to end: build a Full PKI Request
199
224
  - 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.
200
225
  - 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.
201
226
  - 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.
202
- - pki.webauthn.verify copies opts.rootCertificates synchronously, so pinned attestation roots cannot be swapped out from under the check. The roots are not read until the attestation verifier resolves, a later promise turn, and both the array and each DER buffer stayed caller-owned across it: a caller recycling the array or overwriting a certificate's bytes in that gap had the attestation anchored against the replacement roots while the verdict still reported anchoredTo: "rootCertificates". Both are now copied at the entry point, the same defence the assertion input already had, and the documented parsed-certificate form is deep-copied and never passed by reference, since the anchor comparison reads its nested subject and subjectPublicKeyInfo buffers. opts.safetyNetRoots carried the identical window one level down, since a format verifier reads it a microtask after the call returns, and is snapshotted at the same boundary.
227
+ - pki.webauthn.verify copies opts.rootCertificates synchronously, so pinned attestation roots cannot be swapped out from under the check. The roots are not read until the attestation verifier resolves, a later promise turn, and both the array and each DER buffer stayed caller-owned across it: a caller recycling the array or overwriting a certificate's bytes in that gap had the attestation anchored against the replacement roots while the verdict still reported anchoredTo: "rootCertificates". Both are now copied at the entry point, the same defense the assertion input already had, and the documented parsed-certificate form is deep-copied and never passed by reference, since the anchor comparison reads its nested subject and subjectPublicKeyInfo buffers. opts.safetyNetRoots carried the identical window one level down, since a format verifier reads it a microtask after the call returns, and is snapshotted at the same boundary.
203
228
  - 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.
204
229
  - 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.
205
230
  - 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.
206
231
  - A key identifier and a pre-encoded CertID must now be bytes, and are no longer coerced. pki.cms.sign takes a key-only signer's keyIdentifier and pki.ocsp.sign takes a response entry's certID straight into the encoding, and both previously ran the value through Buffer.from, which accepts far more than it should mean: Buffer.from(20) allocates twenty zero octets and Buffer.from("a1b2") takes the ASCII of the text where a reader means two octets. Either produced a structurally valid but wrong SignerIdentifier or CertID, inside a message that then gets signed and that no verifier can match back. A Buffer or Uint8Array is accepted as before; anything else is now cms/bad-input or ocsp/bad-input.
207
- - An OCSP response whose nonce does not echo the request no longer downgrades a revoked verdict to unknown. pki.ocsp.verify applies that downgrade to good only. Revocation does not go stale the way non-revocation does, so discarding a signed, current, authorized revoked response because it was replayed would hand a soft-failing caller the certificate the responder just refused: the anti-replay defence would become the thing that accepts it. The verdict was also self-contradictory, reporting status unknown while carrying revocationReason keyCompromise. nonceMatched: false still reports that the response was not bound to this request, and the field is now three-state and always present (true bound, false not bound, null when the client sent no nonce), so a caller can tell a check that ran from one that was never asked for.
232
+ - An OCSP response whose nonce does not echo the request no longer downgrades a revoked verdict to unknown. pki.ocsp.verify applies that downgrade to good only. Revocation does not go stale the way non-revocation does, so discarding a signed, current, authorized revoked response because it was replayed would hand a soft-failing caller the certificate the responder just refused: the anti-replay defense would become the thing that accepts it. The verdict was also self-contradictory, reporting status unknown while carrying revocationReason keyCompromise. nonceMatched: false still reports that the response was not bound to this request, and the field is now three-state and always present (true bound, false not bound, null when the client sent no nonce), so a caller can tell a check that ran from one that was never asked for.
208
233
  - 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.
209
234
 
210
235
  ## v0.4.15 — 2026-08-10
@@ -238,7 +263,7 @@ Every key-establishment secret this library allocates is now wiped when it stops
238
263
 
239
264
  ### Changed
240
265
 
241
- - Raw secret key material is now reached through a single path that clears the copy it hands out, so a new operation cannot obtain that material without the wipe. Behaviour of the public API is unchanged.
266
+ - Raw secret key material is now reached through a single path that clears the copy it hands out, so a new operation cannot obtain that material without the wipe. Behavior of the public API is unchanged.
242
267
 
243
268
  ### Fixed
244
269
 
@@ -276,7 +301,7 @@ A KEM shared secret and the key it derives are now wiped as soon as they stop be
276
301
  ### Changed
277
302
 
278
303
  - 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.
279
- - The ML-KEM parameter sizes resolve from one registry instead of three separate tables in three modules. The encapsulation-key lengths were already duplicated verbatim in two of them, and each new consumer meant another copy that could drift; a parameter set is a property of the algorithm identifier, so it now lives beside the registry that resolves one. Behaviour is unchanged.
304
+ - The ML-KEM parameter sizes resolve from one registry instead of three separate tables in three modules. The encapsulation-key lengths were already duplicated verbatim in two of them, and each new consumer meant another copy that could drift; a parameter set is a property of the algorithm identifier, so it now lives beside the registry that resolves one. Behavior is unchanged.
280
305
 
281
306
  ### Fixed
282
307
 
@@ -299,7 +324,7 @@ A CMS message can no longer declare one content cipher and be opened with anothe
299
324
 
300
325
  ### Fixed
301
326
 
302
- - A CMS content cipher is now bound to the container that declares it. An EnvelopedData must name a CBC cipher and an AuthEnvelopedData an AEAD one, checked before the content-encryption key is used; a mismatch is refused as an unsupported algorithm naming both the cipher and the container. Previously only the cipher's key length was resolved, and because AES-CBC and AES-GCM share key lengths, an EnvelopedData whose algorithm identifier had been changed to the same-size AES-GCM identifier decrypted successfully as unauthenticated CBC while reporting the AEAD algorithm in its result. A caller inspecting contentEncryptionAlgorithm to establish that the content was authenticated was answered from a field the decryption had not honoured. The reverse pairing was refused only incidentally, by a later dereference of parameters the AEAD path expects, and no stated rule refused it.
327
+ - A CMS content cipher is now bound to the container that declares it. An EnvelopedData must name a CBC cipher and an AuthEnvelopedData an AEAD one, checked before the content-encryption key is used; a mismatch is refused as an unsupported algorithm naming both the cipher and the container. Previously only the cipher's key length was resolved, and because AES-CBC and AES-GCM share key lengths, an EnvelopedData whose algorithm identifier had been changed to the same-size AES-GCM identifier decrypted successfully as unauthenticated CBC while reporting the AEAD algorithm in its result. A caller inspecting contentEncryptionAlgorithm to establish that the content was authenticated was answered from a field the decryption had not honored. The reverse pairing was refused only incidentally, by a later dereference of parameters the AEAD path expects, and no stated rule refused it.
303
328
  - 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.
304
329
 
305
330
  ## v0.4.11 — 2026-08-09
@@ -310,20 +335,20 @@ A WebAuthn attestation can now be bound to the roots the authenticator's own mod
310
335
 
311
336
  - pki.webauthn.verifyMetadataBlob reads a FIDO Metadata Service (MDS v3) BLOB and returns its entries indexed by aaguid. The BLOB is a JWS: its signature is checked under the certificate in its own header, that chain is validated to one of the roots the caller pins, and only then is the payload parsed. The ordering is the point, because a reader that parses first hands an attacker every structure behind the signature.
312
337
  - The catalogue's freshness is enforced, and enforced again wherever it is used. A BLOB whose sequence number does not exceed the one the caller already holds is refused as a rollback, and requireRollbackCheck makes supplying that number mandatory so the check cannot be skipped by forgetting the option. A BLOB past its nextUpdate is refused as stale. A verified result is an ordinary object a relying party may cache, so its expiry is re-checked each time it is passed to verify, and a catalogue fetched while current cannot keep authorizing an authenticator whose status reports have since revoked it. Both checks fail closed, and the caller's own allowStale decision rides on the result, so it need not be repeated.
313
- - Passing the verified result to pki.webauthn.verify as opts.metadata binds the attestation to its own model: the authenticator's registered attestation roots are resolved from its identifier, and its trust path must fully validate to one of them. That means signature chaining, validity and constraints: the same path validation any certificate chain gets. An authenticator whose model the catalogue does not list, whose entry registers no attestation root, or whose status reports disqualify it, is refused. Which reports disqualify is selectable: any report ever filed, only the most recent one so that a later remediation clears an earlier revocation, or a predicate of the caller's own. An unrecognised status is ignored by default, as the specification requires, or treated as disqualifying on request.
338
+ - Passing the verified result to pki.webauthn.verify as opts.metadata binds the attestation to its own model: the authenticator's registered attestation roots are resolved from its identifier, and its trust path must fully validate to one of them. That means signature chaining, validity and constraints: the same path validation any certificate chain gets. An authenticator whose model the catalogue does not list, whose entry registers no attestation root, or whose status reports disqualify it, is refused. Which reports disqualify is selectable: any report ever filed, only the most recent one so that a later remediation clears an earlier revocation, or a predicate of the caller's own. An unrecognized status is ignored by default, as the specification requires, or treated as disqualifying on request.
314
339
  - Authenticators that carry no aaguid are covered too. A U2F authenticator declares no model identity, and the catalogue keys it by the key identifiers of its attestation certificates instead; both key spaces are indexed and looked up, so a U2F registration binds to its registered roots and is not refused as unlisted. The identifier is read from the entry and from its metadata statement, since live entries populate both, and is computed as RFC 5280 sec. 4.2.1.2 method 1 defines it. A compound attestation is covered as well: its elements carry independent trust paths, and every one of them must reach a registered root.
315
340
  - Which identifier is allowed to select an entry depends on what the attestation signature covers. The fido-u2f signature is computed over named fields and does not include the aaguid, so for that format the certificate decides and the declared aaguid is ignored; otherwise setting it to a listed model that shares the vendor's registered root would resolve to that model's entry and skip the real one's status reports. For relying parties: res.aaguid reports what the authenticator presented and is not signature-bound for that format; res.metadata.aaguid names the entry that actually matched.
316
- - Only a catalogue this library verified can decide anything. A metadata result is recognised by its provenance. Shape is not enough on its own, so an object restored from a cache is refused as a catalogue. It has been through none of the signature and chain checks, and its contents are whatever an attacker able to write that cache chose. Re-verify the BLOB instead, which the freshness rule asks for anyway. The verified result is also frozen. The catalogue that decides a later verification is therefore the one the signature covered, unedited.
341
+ - Only a catalogue this library verified can decide anything. A metadata result is recognized by its provenance. Shape is not enough on its own, so an object restored from a cache is refused as a catalogue. It has been through none of the signature and chain checks, and its contents are whatever an attacker able to write that cache chose. Re-verify the BLOB instead, which the freshness rule asks for anyway. The verified result is also frozen. The catalogue that decides a later verification is therefore the one the signature covered, unedited.
317
342
  - 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.
318
343
  - 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.
319
- - A status report that names a single certificate is judged against the certificate actually presented. A whole batch of authenticators is commonly listed under one entry, so a key-compromise report naming one attestation certificate denies only that one, whatever else the entry covers; a report that names nothing, or names something that does not decode, still applies to the entry as a whole. Trust anchors are recognised by name and public key. Self-issuedness is not the test, so a chain terminating in a cross-signed form of a root you supplied still anchors to it.
344
+ - A status report that names a single certificate is judged against the certificate actually presented. A whole batch of authenticators is commonly listed under one entry, so a key-compromise report naming one attestation certificate denies only that one, whatever else the entry covers; a report that names nothing, or names something that does not decode, still applies to the entry as a whole. Trust anchors are recognized by name and public key. Self-issuedness is not the test, so a chain terminating in a cross-signed form of a root you supplied still anchors to it.
320
345
  - 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.
321
346
  - 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.
322
347
 
323
348
  ### Changed
324
349
 
325
- - pki.webauthn.verify now rejects an unrecognised option key, where it used to ignore one, and validates opts.time at the boundary. Every option it takes either gates the verdict or supplies the trust material a gate needs, so a misspelled key was not harmless: asking for metadata enforcement and mistyping the key left the gate switched off and returned a pass the caller believed had been checked against the catalogue. An invalid time is now reported as the configuration fault it is, and no longer surfaces later as an authenticator trust failure.
326
- - Configuration objects across certificate, CRL, attribute-certificate, CSR, and CMP issuance now reject an unrecognised option key through one shared check, replacing a dozen separate ones. Two cases that a hand-written check gets wrong are fixed everywhere at once: a key that every JavaScript object inherits, such as constructor or toString, is no longer accepted as a recognised option name, and an option object built by parsing JSON that carries its own __proto__ key is now inspected; it used to be skipped. The wording of every rejection is unchanged. This matters because the failure is silent in the quietest possible way: a misspelled option key leaves the default in force, so a caller who asked for a stricter check gets the looser behaviour and no error anywhere.
350
+ - pki.webauthn.verify now rejects an unrecognized option key, where it used to ignore one, and validates opts.time at the boundary. Every option it takes either gates the verdict or supplies the trust material a gate needs, so a misspelled key was not harmless: asking for metadata enforcement and mistyping the key left the gate switched off and returned a pass the caller believed had been checked against the catalogue. An invalid time is now reported as the configuration fault it is, and no longer surfaces later as an authenticator trust failure.
351
+ - Configuration objects across certificate, CRL, attribute-certificate, CSR, and CMP issuance now reject an unrecognized option key through one shared check, replacing a dozen separate ones. Two cases that a hand-written check gets wrong are fixed everywhere at once: a key that every JavaScript object inherits, such as constructor or toString, is no longer accepted as a recognized option name, and an option object built by parsing JSON that carries its own __proto__ key is now inspected; it used to be skipped. The wording of every rejection is unchanged. This matters because the failure is silent in the quietest possible way: a misspelled option key leaves the default in force, so a caller who asked for a stricter check gets the looser behavior and no error anywhere.
327
352
 
328
353
  ### Fixed
329
354
 
@@ -338,7 +363,7 @@ A TPM attestation now reports the credential key's own object attributes and acc
338
363
  - 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.
339
364
  - opts.tpmPolicy requires any of those attributes by name, in either direction, since an attribute may be required set or required clear; it refuses the attestation naming which one disagreed. A single profile, hardware-bound, is the shorthand for the six attributes every genuine attestation examined agrees on: the key is bound to one TPM and one parent, the TPM generated it, it can sign, and it is neither a restricted key nor an X.509 signing key. An explicit attribute layers over the profile, so one flag can be overridden without losing the rest. Three attributes are deliberately absent from it, because they differ across genuine authenticators and requiring any of them would reject working hardware.
340
365
  - The policy also covers the key's access policy: requiring that one is present at all and is not the empty policy, and requiring it to be one of an allow-list of digests, compared in constant time. Two structural opt-ins are available for callers who want them: rejecting an attribute word that sets a bit the specification reserves, and rejecting attribute combinations the specification does not define.
341
- - A mistyped policy is refused when it is set: an unknown key at any level, an unknown profile, an unknown attribute name, a non-boolean value, or an allow-list entry that is not a certificate digest all fail immediately, so a typo can never silently disable the check a caller believes they enabled. Names that every JavaScript object inherits are not accepted as policy names either, since a lookup would otherwise report them as recognised and leave the policy applying nothing. Requiring that the TPM generated the key without also requiring the key be non-duplicable is refused for the same reason. On its own it establishes nothing, because the key could have been imported.
366
+ - A mistyped policy is refused when it is set: an unknown key at any level, an unknown profile, an unknown attribute name, a non-boolean value, or an allow-list entry that is not a certificate digest all fail immediately, so a typo can never silently disable the check a caller believes they enabled. Names that every JavaScript object inherits are not accepted as policy names either, since a lookup would otherwise report them as recognized and leave the policy applying nothing. Requiring that the TPM generated the key without also requiring the key be non-duplicable is refused for the same reason. On its own it establishes nothing, because the key could have been imported.
342
367
  - 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.
343
368
 
344
369
  ### Fixed
@@ -432,7 +457,7 @@ pki.tls encodes and decodes RFC 8879 compressed certificate messages (the larges
432
457
 
433
458
  ### Changed
434
459
 
435
- - pki.cms.sign and pki.cms.verify compute their message digests through the crypto engine; neither holds a private digest table any more. Behaviour is unchanged; the digest algorithms a signer and a verifier accept are now defined in one place.
460
+ - pki.cms.sign and pki.cms.verify compute their message digests through the crypto engine; neither holds a private digest table any more. Behavior is unchanged; the digest algorithms a signer and a verifier accept are now defined in one place.
436
461
 
437
462
  ### Fixed
438
463
 
@@ -1387,7 +1412,7 @@ SLH-DSA signatures verify in certification-path validation, and the post-quantum
1387
1412
  ### Fixed
1388
1413
 
1389
1414
  - The shared AlgorithmIdentifier decoder now rejects a present parameters field on the algorithms whose parameters MUST be absent, namely ML-DSA, SLH-DSA, Ed25519, Ed448, X25519, and X448 (RFC 9909 §3, RFC 9814 §4, RFC 9881 §2, RFC 8410 §3), failing closed with a <format>/bad-algorithm-parameters code. Previously a stray explicit NULL or arbitrary bytes in that field were surfaced raw. The rule is enforced once in the shared decoder, so every format that names an algorithm inherits it; a conforming identifier, which omits the field, is unaffected.
1390
- - Certification-path validation now enforces issuer-key / signature-algorithm consistency for the one-shot families whose public-key OID equals the signature OID (EdDSA, ML-DSA, and SLH-DSA), rejecting a mismatch with a path/algorithm-mismatch reason (RFC 9814 §4). Because the underlying WebCrypto import binds a public key of a different type to the requested algorithm name and verifies with the real key, a certificate or CRL signed by one key type but labelling its signatureAlgorithm as another one-shot type could otherwise validate; the check closes that algorithm-confusion path for both the certificate signature and the CRL revocation checker.
1415
+ - Certification-path validation now enforces issuer-key / signature-algorithm consistency for the one-shot families whose public-key OID equals the signature OID (EdDSA, ML-DSA, and SLH-DSA), rejecting a mismatch with a path/algorithm-mismatch reason (RFC 9814 §4). Because the underlying WebCrypto import binds a public key of a different type to the requested algorithm name and verifies with the real key, a certificate or CRL signed by one key type but labeling its signatureAlgorithm as another one-shot type could otherwise validate; the check closes that algorithm-confusion path for both the certificate signature and the CRL revocation checker.
1391
1416
 
1392
1417
  ## v0.1.20 — 2026-07-09
1393
1418
 
@@ -1567,7 +1592,7 @@ A PKCS#10 certification-request parser joins the pki.schema family.
1567
1592
 
1568
1593
  ### Changed
1569
1594
 
1570
- - C.TIME.ms is renamed to C.TIME.milliseconds, so every C.TIME duration helper now reads as a full word (milliseconds, seconds, minutes, hours, days, weeks). The behaviour is unchanged: it still returns an integer millisecond count.
1595
+ - C.TIME.ms is renamed to C.TIME.milliseconds, so every C.TIME duration helper now reads as a full word (milliseconds, seconds, minutes, hours, days, weeks). The behavior is unchanged: it still returns an integer millisecond count.
1571
1596
 
1572
1597
  ### Security
1573
1598
 
@@ -1637,7 +1662,7 @@ WebCrypto EC key import validates the curve against the key material.
1637
1662
 
1638
1663
  ### Security
1639
1664
 
1640
- - pki.webcrypto.subtle.importKey now derives an imported EC key's named curve from the key material and enforces it across the spki, pkcs8 and jwk formats. Previously it trusted the caller-supplied namedCurve without checking it against the key, so a key on an unsupported curve (for example secp256k1) imported as an approved curve, and a key on one curve could be labelled as another, an algorithm-confusion vector in which the CryptoKey's algorithm disagreed with its key material. A curve the framework does not support is now rejected (NotSupportedError) and a namedCurve that does not match the key is rejected (DataError); generateKey already enforced this, and import now matches it. The raw-key format was already validated against its declared curve and is unchanged.
1665
+ - pki.webcrypto.subtle.importKey now derives an imported EC key's named curve from the key material and enforces it across the spki, pkcs8 and jwk formats. Previously it trusted the caller-supplied namedCurve without checking it against the key, so a key on an unsupported curve (for example secp256k1) imported as an approved curve, and a key on one curve could be labeled as another, an algorithm-confusion vector in which the CryptoKey's algorithm disagreed with its key material. A curve the framework does not support is now rejected (NotSupportedError) and a namedCurve that does not match the key is rejected (DataError); generateKey already enforced this, and import now matches it. The raw-key format was already validated against its declared curve and is unchanged.
1641
1666
 
1642
1667
  ## v0.1.2 — 2026-07-04
1643
1668
 
package/README.md CHANGED
@@ -225,7 +225,7 @@ comment blocks, is at [pkijs.com](https://pkijs.com).
225
225
  | `pki.jose` | Flattened JWS (RFC 7515) and JWK thumbprints (RFC 7638). `sign` and `verify` run a Flattened JWS against declarative profiles (ACME outer, EAB inner, keyChange inner) that carry the required and forbidden header rules as data. `base64url` is the strict RFC 4648 §5 codec, rejecting padding, non-alphabet characters, and non-canonical trailing bits. `parseJson` is a bounded reader that refuses duplicate members at any depth. `thumbprint` is the RFC 7638 / 8037 / 9964 canonical digest. The algorithm registry binds each `alg` to its key type (ES/RS/PS/EdDSA/ML-DSA), leaving no code path for `alg:none`, an RS256→HS256 key confusion, or an all-zero ECDSA signature; `assertPublicJwk` refuses a JWK carrying private material, so an exported private key is never published. `opts.key` names the key a message must be signed under and governs: where the profile also permits an embedded header `jwk`, the two must be the same key — compared as RFC 7638 thumbprints, so member order cannot make equal keys differ — and a disagreement is refused rather than resolved in the message's favor. `keySource` reports which key answered, since a signature checked against a key you named is a different claim from one checked against the key the message carried — `sign`, `verify`, `base64url`, `parseJson`, `thumbprint`, `assertPublicJwk` |
226
226
  | `pki.acme` | ACME (RFC 8555 / 8737 / 8738 / 9773). `client(directoryUrl, opts)` is a stateful client driving a live CA directory over `pki.transport`: `newAccount`, `newOrder`, `newAuthz`, `getOrder`, `getAuthorization`, `getChallenge`, `respondToChallenge`, `finalize`, `pollOrder`, `pollAuthorization`, and `downloadCertificate` walk the issuance flow, with `newAuthz` pre-authorizing a single identifier (§7.4.1) and `downloadCertificate` choosing among alternate chains (`Link rel="alternate"`, §7.4.2, via a `selectChain` predicate bounded by `maxAlternates`). `revokeCert` (account-key or certificate-key signed), `keyChange`, `deactivateAccount`, `deactivateAuthorization`, `renewalInfo` (ARI), and `renewalWindow` (the RFC 9773 §4.2/4.3 renewal decision) complete the lifecycle. Every URL is HTTPS only, an explicit trust anchor is required, each request carries a fresh single-use nonce with a bounded badNonce retry, reads are POST-as-GET, polling is bounded and sleeps on a Retry-After via an injectable sleeper capped by a poll count and a total-wait budget, and every response body is size-capped. The transport is injectable via `opts.transport`. Over the message layer it composes resource-object validators (closed status enums, conditional-required fields, unknown fields ignored), the three §7.1.6 state machines, the request builders (newAccount with External Account Binding, newOrder with `replaces`, finalize with a CSR identifier-set match and account-key-reuse rejection, challenge responses, deactivation, revokeCert in both key modes, the keyChange nested JWS, POST-as-GET), the http-01 / dns-01 / tls-alpn-01 challenge computations, the dns and ip identifier validators, and the ARI certID with serial sign-padding preserved — `client`, `validate`, `identify`, `assertTransition`, the builders, `keyAuthorization`, `http01`, `dns01`, `tlsAlpn01Extension`, `verifyTlsAlpn01`, `ariCertId` |
227
227
  | `pki.schema.smime` | S/MIME ESS signed-attribute values (RFC 5035 / RFC 8551). `parseSigningCertificate` and `parseSigningCertificateV2` bind a signature to its signing certificate (cert hash, hash algorithm, issuer `GeneralNames` and serial); `parseSmimeCapabilities` decodes the ordered capability list; `decodeAttribute` dispatches a CMS attribute by OID, enforcing the single-value rule and deferring on unknown types. A companion decoder for CMS signed attributes rather than an auto-routed format — `parseSigningCertificate`, `parseSigningCertificateV2`, `parseSmimeCapabilities`, `decodeAttribute` |
228
- | `pki.cmc` | Build and interpret CMC messages (RFC 5272). `build(spec, signer)` assembles a Full PKI Request across all three request arms (PKCS#10, CRMF, other) and signs it through `pki.cms.sign` under `id-cct-PKIData`. Body-part identifiers are unique across the whole message and never the reserved 0; a caller's clash is refused rather than renumbered, since a control may already reference it. An Identity Proof V2 witness is computed over the `reqSequence` bytes exactly as emitted (§6.2.1 step 1) rather than a re-serialization, a POP Link Witness is emitted only alongside the POP Link Random control §6.3.1.1 requires beside it, and a renewal carries neither Identification nor Identity Proof in either version. `verify(response, sent)` takes what the CA returned plus the state the client retained and reduces it to one terminal outcome: `issued`, `pending`, `confirm-required`, `pop-required`, or `rejected`. It binds the exchange first — Transaction Identifier, the Sender and Recipient Nonce echo compared in constant time and by full value so a truncation cannot match, and the Data Return echo — with each check applying only if the client sent that half, and, once sent, an absent or differing echo being a refusal, which is the replay defence. `bodyPartIDs` extends the same rule to what the response is about: a status reporting on a body part the request never sent is refused, which the transaction and nonce cannot catch, 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; the absence of any status control is success, per §6.1.2. The carrier's signature must verify (§3.2.1.3.4): a conforming SignedData carries its own signer certificate, so the ordinary build-then-verify flow needs nothing extra and the verdict reports `signatureVerified: true`. Where the signer is found nowhere the posture is fail-closed with a named opt-out — supply `certs` with the responder's certificate, or `allowUnverified: true`, in which case the verdict reports `signatureVerified: false`. Doing neither is refused, the opt-out never excuses a signature that is present and wrong, and a carrier with no signer at all is refused outright. The response's own `cmsSequence` and `otherMsgs` come back raw, since a request whose only arm was the other-message form has no certificate to return and §4.1 puts its answer there. Nothing is trusted: issued certificates are read from the CMS certificate bag (§4.2) and surfaced raw for `pki.path.validate`, and a Publish Trust Anchors control is surfaced with `trusted: false` rather than added to a store — `build`, `verify` |
228
+ | `pki.cmc` | Build and interpret CMC messages (RFC 5272). `build(spec, signer)` assembles a Full PKI Request across all three request arms (PKCS#10, CRMF, other) and signs it through `pki.cms.sign` under `id-cct-PKIData`. Body-part identifiers are unique across the whole message and never the reserved 0; a caller's clash is refused rather than renumbered, since a control may already reference it. An Identity Proof V2 witness is computed over the `reqSequence` bytes exactly as emitted (§6.2.1 step 1) rather than a re-serialization, a POP Link Witness is emitted only alongside the POP Link Random control §6.3.1.1 requires beside it, and a renewal carries neither Identification nor Identity Proof in either version. `verify(response, sent)` takes what the CA returned plus the state the client retained and reduces it to one terminal outcome: `issued`, `pending`, `confirm-required`, `pop-required`, or `rejected`. It binds the exchange first — Transaction Identifier, the Sender and Recipient Nonce echo compared in constant time and by full value so a truncation cannot match, and the Data Return echo — with each check applying only if the client sent that half, and, once sent, an absent or differing echo being a refusal, which is the replay defense. `bodyPartIDs` extends the same rule to what the response is about: a status reporting on a body part the request never sent is refused, which the transaction and nonce cannot catch, 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; the absence of any status control is success, per §6.1.2. The carrier's signature must verify (§3.2.1.3.4): a conforming SignedData carries its own signer certificate, so the ordinary build-then-verify flow needs nothing extra and the verdict reports `signatureVerified: true`. Where the signer is found nowhere the posture is fail-closed with a named opt-out — supply `certs` with the responder's certificate, or `allowUnverified: true`, in which case the verdict reports `signatureVerified: false`. Doing neither is refused, the opt-out never excuses a signature that is present and wrong, and a carrier with no signer at all is refused outright. The response's own `cmsSequence` and `otherMsgs` come back raw, since a request whose only arm was the other-message form has no certificate to return and §4.1 puts its answer there. Nothing is trusted: issued certificates are read from the CMS certificate bag (§4.2) and surfaced raw for `pki.path.validate`, and a Publish Trust Anchors control is surfaced with `trusted: false` rather than added to a store — `build`, `verify` |
229
229
  | `pki.schema.cmc` | Decode CMC messages (RFC 5272 as updated by RFC 6402): a Full PKI Request (`PKIData`) or Full PKI Response (`PKIResponse`) riding inside a CMS SignedData, reached by the encapsulated content type (`id-cct-PKIData` / `id-cct-PKIResponse`). Controls are surfaced in wire order with their values raw, so an unrecognized one is data rather than a fault. Tagged requests decode across all three arms. The status verdicts (`CMCStatusInfo` v1 and `CMCStatusInfoV2`) are collected as an ordered list, and the RFC 6402 two-module `OtherStatusInfo` ambiguity — `pendInfo` and `extendedFailInfo` are both untagged SEQUENCEs in the 1988 module, told apart only by their first element — is resolved by inspection and refused when it cannot be told apart. Body-part identity is unique across the whole message rather than per sequence, 0 is reserved as the reference to the enclosing PKIData, and the `reqSequence` bytes are surfaced exactly as they appeared so an Identity Proof witness is computed over the wire bytes. A companion decoder for CMS content rather than an auto-routed format — `parse`, `parsePkiData`, `parsePkiResponse` |
230
230
  | `pki.schema.engine` | The declarative ASN.1 structure-schema engine every format parser composes — `walk`, `encode`, `embeddedDer`, and the schema combinators |
231
231
  | `pki.path` | Certification-path validation (RFC 5280 §6). `validate` runs the §6.1 state machine over an ordered path and a trust anchor: signature chaining across RSA, ECDSA, EdDSA, ML-DSA, SLH-DSA and hybrid composite ML-DSA (a composite is accepted only when both its post-quantum and traditional components verify), validity windows, name chaining, basic constraints and path length, key usage, name constraints, and the certificate-policy tree. It returns a structured verdict with per-check reason codes and enforces a `pki.trust` anchor's per-purpose distrust-after dates and delegator purposes through `checkPurpose` — an anchor that carries either one with no purpose named to select by is refused as a configuration fault rather than validated as though it carried none. The verdict says what was established. `revocationChecked` takes the weakest outcome on the path: `false` with no checker 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 — which includes a checker that throws, a fault `softFail` does not waive because `softFail` opts into an undetermined answer, not into a broken checker. `anchorConstraints` names the purpose the anchor was judged under and whether each of its two constraints applied. `crlChecker` supplies CRL-based revocation, covering partitioned and sharded CRLs, whose §6.3.3 Distribution Point ↔ IDP correspondence lets corresponding shards accumulate reason coverage until all eight revocation reasons are covered, and delta CRLs, merged onto the complete CRL they may be combined with (§5.2.4) so a held certificate its delta releases reads good, while a delta that merges with nothing still reports what it lists and still withholds good. `ocspChecker` supplies OCSP-based revocation (RFC 6960: CertID binding, responder authorization, signature, currency) over the same pluggable hook. `build(leaf, opts)` is the discovering complement (RFC 4158): from a leaf, an untrusted pool of candidate CA certificates, and a trust store, it finds the ordered leaf-to-anchor path `validate` accepts, using name chaining plus the RFC 4158 §3.5 sort hints (an AKI/SKI match, an anchor-adjacent issuer, CA plus keyCertSign, validity at the check time — ordering hints, never filters), a depth-first search with backtracking so the first accepted path wins, and a bounded search (chain-length cap, candidate-expansion cap, identity-tuple visited set) so a cross-certificate cycle or Bridge-CA fan-out terminates deterministically. Every accept flows through `validate`, and its verdict is cross-checked against `openssl verify`. Opt-in AIA `caIssuers` fetching (`opts.fetchAia: true`) discovers a missing intermediate from a certificate's Authority Information Access URL (§4.2.2.1) over `pki.transport`, triggered only on a pool miss and bounded against SSRF and amplification: HTTPS only, a total fetch budget that caps fetching silently rather than throwing, a per-cert URL cap, a build-wide URL dedupe, a response-size and certificate-count cap, no redirect following, and every fault a silent skip. The TLS trust (`opts.tls`) stays distinct from the PKI `trustAnchors`, and every fetched certificate remains untrusted pool material that still flows through `validate` (never a trust anchor). It is off by default, so the default build is byte-identical offline. Pure and re-entrant — `validate`, `build`, `crlChecker`, `ocspChecker` |
package/lib/acme.js CHANGED
@@ -2024,7 +2024,7 @@ function client(directoryUrl, opts) {
2024
2024
  if (!authz.identifier || authz.identifier.type !== canon.type || authz.identifier.value !== canon.value || authz.wildcard === true) throw E("acme/identifier-mismatch", "the returned authorization does not match the requested identifier (RFC 8555 sec. 7.4.1)");
2025
2025
  // A pre-authorization's status "MUST be 'pending' unless the server has out-of-band information about the
2026
2026
  // client's authorization status" (sec. 7.4.1): the normal case is a fresh "pending" authz the caller then
2027
- // fulfils, but the CA MAY instead return an already-"valid" authz (the identifier is already authorized for
2027
+ // fulfills, but the CA MAY instead return an already-"valid" authz (the identifier is already authorized for
2028
2028
  // this account -- no challenge to answer). Both are usable; only a terminal failed state ("invalid"/
2029
2029
  // "deactivated"/"expired"/"revoked") is not the authorization the pre-auth flow can proceed with.
2030
2030
  if (authz.status !== "pending" && authz.status !== "valid") throw E("acme/unexpected-authorization-status", "newAuthz returned an authorization that is neither pending nor already valid (status " + JSON.stringify(authz.status) + "); RFC 8555 sec. 7.4.1");
package/lib/cmc-build.js CHANGED
@@ -83,7 +83,7 @@ var KNOWN_SPEC_KEYS = {
83
83
  // The exchange binding (RFC 5272 sec. 6.6 / 6.4). First-class here because
84
84
  // pki.cmc.verify names these same three when checking the response: a builder
85
85
  // that made the caller hand-encode them while the verifier took them by name is
86
- // the asymmetry that lets a request ship with no replay defence.
86
+ // the asymmetry that lets a request ship with no replay defense.
87
87
  transactionId: 1, senderNonce: 1, dataReturn: 1,
88
88
  };
89
89
 
@@ -208,7 +208,7 @@ function encodeRequest(req, ids, index) {
208
208
  // A tcr arm is a PKCS#10 CertificationRequest, so it is parsed and never
209
209
  // taken on the tag: the readback of the assembled message checks the CMC
210
210
  // structure around it, and would pass an empty SEQUENCE here as happily as a
211
- // real request. Signing a request body that is not one produces an enrolment
211
+ // real request. Signing a request body that is not one produces an enrollment
212
212
  // no CA can act on, and the producer is where that is cheap to catch.
213
213
  try { csr.parse(_der(req.tcr, "a tcr certificationRequest")); }
214
214
  catch (e) {
@@ -503,7 +503,7 @@ function _build(spec, signer, opts) {
503
503
  // A misspelled or unsupported spec field fails open in the quietest way there is:
504
504
  // the message builds, is signed, and simply does not carry what was asked for.
505
505
  // That is worst for the exchange-binding fields below: a request built without
506
- // them has no replay defence, and pki.cmc.verify cannot enforce a binding the
506
+ // them has no replay defense, and pki.cmc.verify cannot enforce a binding the
507
507
  // client never sent, so the omission is invisible from both ends.
508
508
  guard.identifier.assertKnownKeys(spec, KNOWN_SPEC_KEYS, E, "cmc/bad-input", "unknown spec field ");
509
509
  var requests = spec.requests || [];
@@ -588,7 +588,7 @@ function _build(spec, signer, opts) {
588
588
 
589
589
  // The exchange binding (RFC 5272 sec. 6.6 Transaction Identifier / Sender Nonce,
590
590
  // sec. 6.4 Data Return). These are what pki.cmc.verify checks the response
591
- // against, and a request that omits them has no replay defence, so they are
591
+ // against, and a request that omits them has no replay defense, so they are
592
592
  // named fields here and not something the caller hand-encodes into
593
593
  // spec.controls and can silently get wrong.
594
594
  // A named field and a hand-encoded control of the same type would emit the
@@ -797,7 +797,7 @@ function _assertKeyOnlySignerBinding(so, requests) {
797
797
  var req = requests[i];
798
798
  // Both key-bearing arms: sec. 3.2 says the signing key may belong to a request
799
799
  // "included in the TaggedRequest tcr or crm fields", so reading only PKCS#10
800
- // would reject every conforming CRMF enrolment of a brand-new key.
800
+ // would reject every conforming CRMF enrollment of a brand-new key.
801
801
  var declaredBy;
802
802
  try {
803
803
  declaredBy = req && req.tcr != null ? _csrKeyIdentity(req.tcr)
package/lib/cmc-verify.js CHANGED
@@ -139,7 +139,7 @@ function _assertBound(body, sent) {
139
139
  var got = asn1.read.integer(asn1.decode(_singleValue(txControl, "Transaction Identifier")));
140
140
  // Compared against the value the authoring guard validated, never a fresh
141
141
  // conversion of the caller's input -- a number too large to hold an integer
142
- // exactly has already lost the digits that distinguish it from its neighbour.
142
+ // exactly has already lost the digits that distinguish it from its neighbor.
143
143
  if (got !== sent.transactionIdValue) {
144
144
  throw E("cmc/transaction-mismatch",
145
145
  "the response Transaction Identifier " + got + " does not match the request's " + sent.transactionId);
@@ -226,7 +226,7 @@ function _assertBound(body, sent) {
226
226
  // caller who retained nothing runs none of them and still gets a full verdict
227
227
  // (`issued`, signature verified, certificates surfaced) off a Full PKI Response
228
228
  // captured from any earlier exchange with the same CA. That is the CWE-294 this
229
- // module names as its defence, and leaving it opt-in leaves the defence off for
229
+ // module names as its defense, and leaving it opt-in leaves the defense off for
230
230
  // whoever did not know to ask.
231
231
  //
232
232
  // The posture here is the one _assertAuthentic already takes for the carrier
@@ -244,7 +244,7 @@ function _assertBound(body, sent) {
244
244
  if (!bound.boundToRequest && sent.allowUnbound !== true) {
245
245
  throw E("cmc/unbound-response",
246
246
  "nothing ties this response to a request. Pass what the request retained (`transactionId`, " +
247
- "`senderNonce`, whose echo is the replay defence of RFC 5272 sec. 6.6, or `dataReturn`) so the " +
247
+ "`senderNonce`, whose echo is the replay defense of RFC 5272 sec. 6.6, or `dataReturn`) so the " +
248
248
  "echo can be checked, or `allowUnbound: true` to interpret a response that could be a replay of " +
249
249
  "any earlier exchange with this CA");
250
250
  }
@@ -288,7 +288,7 @@ function _governingStatus(statuses, outcome) {
288
288
  * none. `sent` is what the client retained from its request --
289
289
  * `transactionId`, `senderNonce`, `dataReturn`, and `bodyPartIDs` (every identifier the request
290
290
  * carried). Each of those is checked only if it was sent, and once sent an absent or differing
291
- * echo is a refusal: that asymmetry is the replay defence. `bodyPartIDs` is the same rule applied
291
+ * echo is a refusal: that asymmetry is the replay defense. `bodyPartIDs` is the same rule applied
292
292
  * to what the response is ABOUT -- a status naming a body part the request never sent is refused
293
293
  * with `cmc/body-part-unknown`, which the transaction and nonce cannot catch, since a server can
294
294
  * echo both correctly while reporting on something else.
@@ -429,7 +429,7 @@ function _snapshotSent(sent) {
429
429
  // integer through. A Transaction Identifier is an unbounded INTEGER on the wire,
430
430
  // so one above Number.MAX_SAFE_INTEGER has already been rounded by the time it
431
431
  // arrives as a `number` -- 9007199254740993 is 9007199254740992 before this code
432
- // sees it, and a response echoing that NEIGHBOURING identifier would compare
432
+ // sees it, and a response echoing that NEIGHBORING identifier would compare
433
433
  // equal. Held here rather than converted, so a value too large to be a number is
434
434
  // refused with the shape to use instead (a bigint) rather than silently bound to
435
435
  // something adjacent.
@@ -599,7 +599,7 @@ function _assertAuthentic(body, sent, responseBytes) {
599
599
  .then(function (res) {
600
600
  if (res.valid) return true;
601
601
  // EVERY failing signer, not just the first. A response may carry several,
602
- // and the opt-out may only be honoured when none of them was actually
602
+ // and the opt-out may only be honored when none of them was actually
603
603
  // checked and rejected: one signer whose certificate is missing must not
604
604
  // let a DIFFERENT signer's failed signature through beside it.
605
605
  var failed = (res.signers || []).filter(function (s) { return !s.ok; });
@@ -710,7 +710,7 @@ function _shape(body, sent, signatureVerified, bound) {
710
710
  * `spec.transactionId` (number|bigint), `spec.senderNonce` and `spec.dataReturn` (bytes) attach the
711
711
  * exchange-binding controls (RFC 5272 sec. 6.6 / 6.4), the same three `pki.cmc.verify` checks the
712
712
  * response against. They are named fields, not something to hand-encode into
713
- * `spec.controls`, because a request that quietly omits them has no replay defence and neither end
713
+ * `spec.controls`, because a request that quietly omits them has no replay defense and neither end
714
714
  * can tell: the verifier only enforces the halves the client says it sent. An unrecognized spec
715
715
  * field is refused for the same reason: a misspelling would otherwise build and sign a message
716
716
  * that simply does not carry what was asked for.
@@ -131,7 +131,7 @@ function _toDer(input) {
131
131
 
132
132
  // ---- stage 1: recipient selection (not secret-dependent) -------------------
133
133
  // Returns every recipient the key material may target. An explicit recipientIndex or an rid match
134
- // yields exactly one; unlabelled pwri / kekri recipients yield all matches for the caller to try.
134
+ // yields exactly one; unlabeled pwri / kekri recipients yield all matches for the caller to try.
135
135
  function _selectCandidates(recips, km, opts) {
136
136
  if (!recips.length) throw _err("cms/no-matching-recipient", "the message carries no RecipientInfos");
137
137
  if (opts.recipientIndex != null) {
@@ -741,7 +741,7 @@ async function _verifyAuthenticatedData(parsed, km, opts) {
741
741
  // || macAlg.oid` and `digestAlgorithm.name || .oid` never reach the `.oid` fallback because the
742
742
  // algorithm name was already validated against MAC_HASH / DIGEST_WC above.
743
743
  // * the `macKey == null` half of the random-key substitution fires only for a hand-crafted RSA v1.5
744
- // ktri (this producer emits OAEP); its behaviour is identical to the tested below-floor path (a
744
+ // ktri (this producer emits OAEP); its behavior is identical to the tested below-floor path (a
745
745
  // random key -> the MAC verify fails uniformly), so the < 16 vector covers the substitution.
746
746
  // _normKeyDer(key) -> { der, owned }: the recipient private key as PKCS#8 DER, and whether the
747
747
  // buffer is one this module made.
package/lib/cms-verify.js CHANGED
@@ -579,7 +579,7 @@ function _verifyOneCountersig(vDer, targetSig, parsedCerts) {
579
579
  // Coverage residual, and deliberately kept. Every value reaching here was already walked by the
580
580
  // decoder, which validates an id-countersignature by content and not by the attribute type. A
581
581
  // malformed one therefore refused the whole message before verify was entered, so this catch
582
- // cannot be reached through the public path. That is settled behaviour: a message the decoder
582
+ // cannot be reached through the public path. That is settled behavior: a message the decoder
583
583
  // has found to be malformed has no sound remainder to report a verdict over, so it is refused
584
584
  // whole instead of surfaced as one failed countersignature.
585
585
  // cms-verify.test.js pins that through the shipped verbs; this stays because the walk is also
@@ -744,7 +744,7 @@ function _verify(input, opts) {
744
744
  // read a promise turn later, and everything in it stays caller-owned across that gap: the array
745
745
  // can be re-pointed, an anchor's DER rewritten, the validation instant moved. Any of those would
746
746
  // have the chain judged against a configuration the caller never asked for while the verdict
747
- // reports the one they did. The same defence the input and the certificates already have.
747
+ // reports the one they did. The same defense the input and the certificates already have.
748
748
  var trustCfg = _snapshotTrust(opts);
749
749
  return Promise.all(parsed.signerInfos.map(function (si) {
750
750
  // res.valid reflects only the PRIMARY signerInfos; a countersignature / unsigned attribute is
package/lib/constants.js CHANGED
@@ -165,7 +165,7 @@ var LIMITS = {
165
165
  // is copied/tokenized so a hostile server cannot drive unbounded work parsing it
166
166
  // (CWE-770). 8 KiB clears any realistic multi-scheme Digest challenge.
167
167
  HTTP_AUTH_HEADER_MAX_BYTES: BYTES.kib(8),
168
- // Deterministic-CBOR codec ceilings (RFC 8949), the DER neighbours' siblings:
168
+ // Deterministic-CBOR codec ceilings (RFC 8949), the DER neighbors' siblings:
169
169
  // a whole-document cap refused before the walk, a nesting cap, and a per-value
170
170
  // bignum ceiling the document cap can't provide. Unlike DER_MAX_INTEGER_BYTES,
171
171
  // the bignum cap carries NO +1 sign octet -- a CBOR tag-2/3 bignum body is pure
package/lib/est.js CHANGED
@@ -1231,7 +1231,7 @@ function fullcmc(baseUrl, request, opts) {
1231
1231
  _knownOpts(opts, FULLCMC_OPTS, "fullcmc");
1232
1232
  der = _cmcRequestDer(request);
1233
1233
  // Confirm this IS a Full PKI Request before any of it goes over the wire. The
1234
- // bytes are about to be labelled `smime-type=CMC-request`, so a PKIResponse or
1234
+ // bytes are about to be labeled `smime-type=CMC-request`, so a PKIResponse or
1235
1235
  // an unparseable blob handed in by mistake would be POSTed to a CA under a
1236
1236
  // label that does not describe it -- and, since only an `issued` outcome is
1237
1237
  // correlated, could still come back as a pending or rejected verdict, making
@@ -1567,7 +1567,7 @@ function _assertAgrees(name, supplied, carried) {
1567
1567
  } else {
1568
1568
  // Through the shared authoring guard, not a bare BigInt(): a `number` above
1569
1569
  // Number.MAX_SAFE_INTEGER has already lost the digits that distinguish it from
1570
- // its neighbour, so converting it here would compare a value the caller never
1570
+ // its neighbor, so converting it here would compare a value the caller never
1571
1571
  // wrote. A transaction identifier is an unbounded INTEGER on the wire; large
1572
1572
  // ones are passed as a bigint, and this says so rather than rounding.
1573
1573
  same = carried != null &&
@@ -1671,7 +1671,7 @@ function _fullcmcResult(res, opts, wanted, sent) {
1671
1671
 
1672
1672
  // FC5 admits two smime-types, and the LABEL must agree with the BYTES: a
1673
1673
  // certs-only label over a Full PKI Response (or the reverse) is a server
1674
- // mislabelling its own body, and accepting either shape under either label
1674
+ // mislabeling its own body, and accepting either shape under either label
1675
1675
  // would make the content-type check decorative.
1676
1676
  // Through the shared case-insensitive accessor: HTTP header names are
1677
1677
  // case-insensitive (RFC 9110 sec. 5.1), the classifier already reads them that
package/lib/guard-all.js CHANGED
@@ -11,35 +11,35 @@
11
11
  // itself, with no guard re-implemented inline:
12
12
  //
13
13
  // guard.bytes.view / .source -- untrusted byte-source -> Buffer re-view
14
- // (detached-buffer fail-open defence)
14
+ // (detached-buffer fail-open defense)
15
15
  // guard.text.decode -- byte-source -> string, cap before copy
16
- // (parser-DoS string-allocation defence)
16
+ // (parser-DoS string-allocation defense)
17
17
  // guard.limits.cap / .depthCap -- config-time resource-cap validation
18
- // (recursion / allocation DoS defence)
18
+ // (recursion / allocation DoS defense)
19
19
  // guard.crypto.constantTimeEqual -- length-checked constant-time compare
20
- // (timing side-channel defence)
20
+ // (timing side-channel defense)
21
21
  // guard.range.int / .uint31 / .positiveInt31
22
22
  // -- bound a decoded integer before narrowing
23
- // to Number (silent-narrowing defence)
23
+ // to Number (silent-narrowing defense)
24
24
  // guard.time.assertValid / .within -- validate a Date-instant before a temporal
25
- // comparison (NaN-Date fail-open defence)
25
+ // comparison (NaN-Date fail-open defense)
26
26
  // guard.name.dnEqual / .rdnEqual / .assertNoControlBytes / .assertPrintableIa5
27
27
  // -- canonical DN identity + name-string integrity
28
28
  // guard.name.escapeControlBytes / .escapeDnValue
29
29
  // -- render-side name safety: control-byte + RFC 4514 escaping
30
- // (CVE-2009-2408 truncation / identity defence)
30
+ // (CVE-2009-2408 truncation / identity defense)
31
31
  // guard.encoding.base64url / .base64 / .hex
32
32
  // -- strict textual-encoding decode, canonical
33
- // + capped (encoding-malleability defence)
33
+ // + capped (encoding-malleability defense)
34
34
  // guard.identifier.assertCanonicalOid
35
35
  // -- canonical dotted-decimal OID string form
36
- // (canonicalization-divergence defence)
36
+ // (canonicalization-divergence defense)
37
37
  // guard.compress.bounded -- decompress an untrusted stream under a hard
38
38
  // output cap, whole-input (decompression-bomb +
39
- // trailing-frame malleability defence)
39
+ // trailing-frame malleability defense)
40
40
  // guard.header.assertField -- emitted MIME/RFC 5322 header field name +
41
41
  // value integrity (CR/LF/NUL header-injection
42
- // defence, CWE-93)
42
+ // defense, CWE-93)
43
43
  // guard.parsed.accept -- a CLAIMED-parsed structure carries every
44
44
  // field the consuming code dereferences
45
45
  // (type confusion / unverified provenance,
@@ -49,7 +49,7 @@
49
49
  // token of a guard (the Buffer.from(x.buffer, byteOffset) re-view, the
50
50
  // timingSafeEqual call, the MAX_DECODE_DEPTH_CEILING check) must appear only in
51
51
  // its guard module, so a new boundary cannot re-inline the shape and forget the
52
- // defence.
52
+ // defense.
53
53
 
54
54
  var bytes = require("./guard-bytes");
55
55
  var text = require("./guard-text");
@@ -59,6 +59,7 @@ var range = require("./guard-range");
59
59
  var time = require("./guard-time");
60
60
  var name = require("./guard-name");
61
61
  var encoding = require("./guard-encoding");
62
+ var der = require("./guard-der");
62
63
  var json = require("./guard-json");
63
64
  var identifier = require("./guard-identifier");
64
65
  var header = require("./guard-header");
@@ -76,6 +77,7 @@ module.exports = {
76
77
  time: time,
77
78
  name: name,
78
79
  encoding: encoding,
80
+ der: der,
79
81
  json: json,
80
82
  identifier: identifier,
81
83
  header: header,
@@ -11,7 +11,7 @@ var async = require("./guard-async");
11
11
  // guard-bytes -- fail-closed coercion of an untrusted byte-source input to a
12
12
  // Buffer view. One of the enforced choke points of the guard family: a
13
13
  // codebase-patterns detector requires every byte-input boundary to route
14
- // through here, so the defence below cannot be forgotten at a new boundary.
14
+ // through here, so the defense below cannot be forgotten at a new boundary.
15
15
  //
16
16
  // Defends the detached-buffer fail-OPEN: a transferred / structuredClone'd
17
17
  // Buffer or view has a detached backing ArrayBuffer and reads as ZERO-LENGTH.
@@ -85,7 +85,7 @@ function source(input, ErrorClass, code, label) {
85
85
 
86
86
  // snapshot(input, ErrorClass, code, label) -> private Buffer copy | throws ErrorClass
87
87
  //
88
- // The parse-then-verify time-of-check/time-of-use defence. A verification entry
88
+ // The parse-then-verify time-of-check/time-of-use defense. A verification entry
89
89
  // point that PARSES its input synchronously and then VERIFIES a signature over
90
90
  // the same bytes in a later promise turn is reading the caller's memory twice
91
91
  // with an await in between. Every byte range the parse surfaced (the signed
@@ -108,7 +108,7 @@ function snapshot(input, ErrorClass, code, label) {
108
108
  }
109
109
 
110
110
  // snapshotSource(input, ErrorClass, code, label) -> private Buffer copy | throws
111
- // The same parse-then-verify defence as `snapshot`, over the whole W3C BufferSource
111
+ // The same parse-then-verify defense as `snapshot`, over the whole W3C BufferSource
112
112
  // (raw ArrayBuffer / DataView / any typed-array view) and not only the
113
113
  // Buffer / Uint8Array contract. A parser that accepts a BufferSource must snapshot
114
114
  // the same set: leaving an ArrayBuffer or a DataView aliased reopens the window for
@@ -360,7 +360,7 @@ function _copyEntries(src, dst, ErrorClass, code, label, cap, depth, collect) {
360
360
  // caller passed disappears from Object.keys and an unknown-option check walks a different object
361
361
  // than the verb goes on to read. Shadowing matters for the same reason it is done at all: an own
362
362
  // copy is what makes an inherited value stop tracking the caller's prototype. A method is left
363
- // where it is, since copying a function would only move it, and it is the prototype's behaviour,
363
+ // where it is, since copying a function would only move it, and it is the prototype's behavior,
364
364
  // not the caller's data.
365
365
  function _copyNamed(src, dst, ErrorClass, code, label, cap, depth, collect) {
366
366
  var keys = _namesToCopy(src, dst);
@@ -22,7 +22,7 @@
22
22
  // unboundedly many encodings, and a digest over the compressed object would
23
23
  // stop identifying what it decompresses to.
24
24
  //
25
- // Both defences are applied here, once, for every algorithm, so a new consumer
25
+ // Both defenses are applied here, once, for every algorithm, so a new consumer
26
26
  // cannot pick up one and miss the other. Verified uniform across zlib, brotli
27
27
  // and zstd: each reports consumed input as `engine.bytesWritten` under
28
28
  // `info: true`, each raises ERR_BUFFER_TOO_LARGE on a cap breach, and each
@@ -125,7 +125,7 @@ function bounded(algorithm, stream, cap, E, codes, label) {
125
125
  throw new TypeError("guard.compress.bounded: stream must be a Buffer (route the input through guard.bytes.view first)");
126
126
  }
127
127
  // The ceiling is an authoring input: an undefined / NaN / fractional / negative
128
- // cap would silently disable the bomb defence. It must also be at least 1 --
128
+ // cap would silently disable the bomb defense. It must also be at least 1 --
129
129
  // node rejects maxOutputLength 0 with a different fault than a cap breach, so a
130
130
  // zero cap would surface as a malformed-stream verdict instead of a config error.
131
131
  if (!Number.isInteger(cap) || cap < 1) {