@blamejs/pki 0.5.9 → 0.5.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +80 -19
  2. package/README.md +1 -1
  3. package/lib/acme.js +5 -5
  4. package/lib/asn1-der.js +1 -1
  5. package/lib/attrcert-sign.js +1 -1
  6. package/lib/cbor-det.js +4 -4
  7. package/lib/cmc-build.js +5 -5
  8. package/lib/cmc-verify.js +14 -11
  9. package/lib/cms-decrypt.js +2 -2
  10. package/lib/cms-sign.js +3 -5
  11. package/lib/cms-verify.js +9 -6
  12. package/lib/constants.js +1 -1
  13. package/lib/crl-sign.js +1 -1
  14. package/lib/crmf-sign.js +1 -1
  15. package/lib/ct.js +8 -5
  16. package/lib/est.js +20 -14
  17. package/lib/guard-all.js +12 -12
  18. package/lib/guard-bytes.js +568 -82
  19. package/lib/guard-compress.js +2 -2
  20. package/lib/guard-der.js +1 -1
  21. package/lib/guard-identifier.js +694 -6
  22. package/lib/guard-limits.js +2 -2
  23. package/lib/guard-parsed.js +10 -6
  24. package/lib/guard-range.js +1 -1
  25. package/lib/guard-secret.js +1 -1
  26. package/lib/guard-text.js +1 -1
  27. package/lib/guard-time.js +65 -4
  28. package/lib/hpke.js +1 -1
  29. package/lib/http-digest.js +1 -1
  30. package/lib/inspect.js +8 -3
  31. package/lib/jose.js +1 -1
  32. package/lib/key.js +48 -6
  33. package/lib/lint.js +15 -8
  34. package/lib/ocsp-verify.js +10 -4
  35. package/lib/ocsp.js +35 -6
  36. package/lib/oid.js +1 -1
  37. package/lib/path-validate.js +99 -22
  38. package/lib/schema-c509.js +5 -5
  39. package/lib/schema-cmp.js +1 -1
  40. package/lib/schema-crl.js +1 -1
  41. package/lib/schema-engine.js +2 -2
  42. package/lib/schema-pkix.js +1 -1
  43. package/lib/sigstore.js +6 -2
  44. package/lib/smime.js +1 -1
  45. package/lib/trust.js +2 -2
  46. package/lib/tsp-sign.js +2 -2
  47. package/lib/validator-attcert.js +3 -3
  48. package/lib/validator-cose.js +3 -3
  49. package/lib/webauthn-mds.js +17 -17
  50. package/lib/webauthn.js +12 -12
  51. package/lib/webcrypto.js +16 -7
  52. package/lib/x509-sign.js +1 -1
  53. package/package.json +4 -2
  54. package/sbom.cdx.json +6 -6
package/CHANGELOG.md CHANGED
@@ -4,7 +4,68 @@ 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
7
+ ## v0.5.11 — 2026-08-19
8
+
9
+ An option a verb never reads is now refused, so a misspelled `password` on key export can no longer leave a private key unprotected.
10
+
11
+ ### Changed
12
+
13
+ - `pki.key` (encrypt, decrypt, export, import, generate, publicFromPrivate), `pki.path.validate`, `pki.path.build`, `pki.lint.certificate` and `pki.ocsp` (buildRequest, sign, verify) throw `<domain>/bad-input` on an option they do not read, instead of ignoring it. A call passing an option that did nothing before will now fail; the message names the unknown key, so the fix is to correct or drop it.
14
+ - The same verbs require an options object whose options are values. One supplied through a getter is refused, naming the option, because a getter is asked afresh on every read and the value the check saw is not necessarily the one the verb uses. A caller computing an option can read it into a plain object at the call: `pki.key.export(key, { format: computeFormat() })`. Methods are unaffected, so a class instance carrying them is still an options bag.
15
+ - Where two verbs spell the same idea differently, the refusal says so. `pki.path.validate` takes `trustAnchor` and `pki.path.build` takes `trustAnchors`, and each names the other, because carrying the wrong spelling between them previously bought no anchoring and no error. `pki.key.encrypt` chooses `iterations` while `pki.key.decrypt` caps `maxIterations`; `pki.ocsp` spells the nonce three ways across buildRequest, sign and verify because it means three different things.
16
+ - `pki.path.build` accepts every `pki.path.validate` option, since it forwards them to each internal validation. That union is derived from validate's own list rather than restated, so the two cannot drift apart.
17
+
18
+ ### Fixed
19
+
20
+ - `pki.sigstore.verifyBundle(bundle, { time })` validates the Fulcio chain at the instant the Date holds. `time` is accepted by its internal slot, so a Date subclass reaches the check; the instant was then read back through `getTime`, which a subclass answers. A caller-supplied Date reporting the log time made the ephemeral signing certificate, whose validity is about ten minutes wide, look current whenever the caller chose to check it. Every read of a Date across the toolkit now goes through the intrinsic, so what a comparison uses is the instant a Date holds rather than the one it reports.
21
+ - `pki.path.validate` and `pki.ocsp.verify` decide the validity window on the instant a Date holds. Both compared the caller's `time` against a certificate's, CRL's or response's Date as objects, and comparing two Dates coerces each one through `Symbol.toPrimitive` or `valueOf`, which a caller's subclass answers. `path.validate` checked `opts.time` for a valid instant at entry and then compared it through a door that check never used, so a Date holding one moment and reporting an earlier one made an expired certificate validate. Both now narrow every operand to its held instant before comparing.
22
+ - A field inherited from an object a caller built over the prototype every typed array shares is reported as the unknown option it is. The kinds were recognized from that shared parent, so any level minted the same way read as one the language installs members on, and a bag inheriting `BYTES_PER_ELEMENT` from it reached `pki.key.generate` unremarked. The concrete kinds are now found by asking the runtime for them and matched by identity, which keeps a kind added later covered the day it lands without admitting anything that merely sits above the same prototype.
23
+ - A key from another WebCrypto implementation reaches the verb even where that implementation keeps its internals under a symbol. Every verb reads its options by name, so nothing under a symbol key can be read as one, and refusing a handle for carrying one protected nothing while turning a documented input away. The options door is unchanged and still reports a symbol on a bag passed as options, which is the case that matters: a caller who wrote one there meant it as an option and no verb will read it. A name a verb could read is still refused on a key handle.
24
+ - A `node:crypto` KeyObject reaches the verb it was passed to. `pki.hpke` documents one as a key input, and its material lives behind an internal slot, so what a copy holds is the shape of a key and none of the key: the object the verb received could not export, sign or derive. It is now recognized from that slot and handed on as itself, which is how a WebCrypto CryptoKey was already treated. A value wearing `KeyObject.prototype` with no key behind it is still copied, and the raw `TypeError` its `type` getter raises is now the calling module's typed refusal with that fault as its cause.
25
+ - The tables naming what each uncopyable kind carries no longer answer for names they inherit. Built as ordinary objects, a table answered `toString` with the function every object in the language inherits, so `toString`, `constructor`, `valueOf`, `hasOwnProperty` and `__proto__` read as published surface on a `RegExp`, an `Error`, a `CryptoKey` and a thenable alike. A caller could write any of the five onto such an object and have it handed to a verb by reference. Membership is now a fact about the table.
26
+ - A field written with `Object.defineProperty` is seen by the check that decides whether a handle may be passed on by reference. A `RegExp`, an `Error`, a key handle and a thenable cannot be copied, so a verb receives the caller's own object and a field on it stays the caller's to change after every check has read it. That check passed over non-enumerable properties on the reasoning that a caller adds fields by assignment; `Object.defineProperty` belongs to a caller as much as to a platform, so a `RegExp` from another realm carrying a hidden `detached` reached `pki.cms.sign`, and flipping it after the call moved the content out of the message the call had already been asked to sign. What is asked now is whether the property can still move: a field that is writable, configurable, or backed by a getter is refused, and so is one whose value can move even where the binding cannot, since a frozen slot holding a `Date` is an instant anybody can still change. A field that is settled through to its value rides along, which is how an implementation writes the internal state a key handle carries.
27
+ - A `Date` copy answers every `Date` method the way a `Date` holding that instant answers. A method written onto the value itself shadows the language's, and the copy carried it: an own `getUTCFullYear` that throws came across onto the copy and threw out of `pki.cms.sign` while the signing time was being encoded. The names the language supplies are read off `Date.prototype`, and what decides is the value rather than the name: a function under one of those names is behavior and is left behind, while a plain value under the same name is a field and comes across, so an option misspelled as `getTime` still reaches the check that refuses an option no verb reads.
28
+ - An element supplied through a getter is refused where a field already was. The deep copy taken at a verb's entry reads each element once and stores the result, so a getter that answers differently on the next read left the check nothing to find. The refusal covered an options bag's named fields and passed over its indices, which is where it pays most: a list element could answer as a trusted signer to the check and as something else afterwards.
29
+ - A signer, anchor or policy list whose elements come from its prototype reaches the verb intact. An array resolves a hole through its prototype chain, so `Object.setPrototypeOf(list, {0: signer})` reads as a one-element list to any consumer while reporting no element of its own. The copy taken at the door enumerated only the array's own keys, so the verb received a hole where the caller passed a value: `pki.cms.sign` refused a list it had been given a signer for. Elements at or past `length` are still left out, since no length-bounded read reaches them.
30
+ - `pki.key.export(key, { password })` no longer writes an unprotected private key. Export serializes; it has never encrypted. The option was ignored, so the file on disk was a plaintext PKCS#8 while the call site named a password. It now throws `key/bad-input` naming `pki.key.encrypt(key, password)`, whose result is what to export.
31
+ - An option carried on a prototype, defined non-enumerably, supplied through a getter, or planted on `Object.prototype` is seen by the check that refuses unknown options. It read own enumerable names only. Each of those four shapes answered `opts.password` while showing the check nothing, and `pki.key.export` returned the private key in the clear, which is the case the refusal exists to prevent. A polluted `Object.prototype` is covered too, where `{}` carries no option of its own; the check reports the planted name and stays silent on the built-ins it was seeded with at load. One thing is still skipped: a method, meaning a data property holding a function that the prototype chain also supplies under that name. So `constructor` and the methods a class defines stay machinery, and an instance of a caller's own class remains a valid options bag whether the verb inspects it directly or after copying it.
32
+ - A `Symbol`-named option is reported rather than passed over. The check enumerated with `Object.getOwnPropertyNames`, which never returns a Symbol key, so one was accepted in silence. Such a name answers no `opts.password` and reaches no verb, but it is still an option supplied and never read, which is what the refusal is for. The message names it as `Symbol(name)`.
33
+ - A name planted on `Object.prototype` before this package loads is reported, whether it holds a value or a function. The built-ins were recognized by reading `Object.prototype` at load, which is the polluted runtime answering the question, so a name already present was taken for one and `pki.key.export` returned a plaintext private key. They are now the twelve members the language specifies, each still required to have the shape a real one has. The level they are looked for at is the last one an object's chain reaches, which is where the root sits in every realm, so a bag built in a `vm` context or a worker thread is read the same way as a local one. A verb called with no options is unaffected, since it is handed a bag with no prototype and inherits nothing.
34
+ - `pki.path.build` forwards the caller's own value for a validation option even on a runtime whose `Object.prototype` carries the same name. It chose what to forward by reading a build-only table with the option name, which answers for any inherited name, so a caller's own `softFail` was taken for a build-only option and dropped; the set it was dropped from then re-read the inherited value. A caller who wrote `softFail: false` could receive a valid path built on an undetermined revocation result they had refused to waive.
35
+ - A number, a string or a boolean passed where an options bag belongs is refused by the verb rather than raising a `TypeError` about a reflection method the caller never used.
36
+ - A value that inherits from `Date`, `Map`, `Set` or `ArrayBuffer` while holding none of one raises the verb's own `<domain>/bad-input` instead of a raw `TypeError`. These kinds were recognized by their prototype. `Object.create(Date.prototype)` carries that whole prototype and holds no instant, so it passed as a Date and the read behind it threw the language's error out of `pki.cms.sign`, `pki.cms.verify`, `pki.x509.sign`, `pki.path.validate` and the argument copy every signing verb makes. The kind is now read off the value itself. That answers the other direction too, since a `Date` or a `Map` built in a `vm` context or a worker thread holds its kind while inheriting from that context's prototypes, and the prototype test called those neither.
37
+ - A `Date`'s instant is read from the Date itself, so a class of the caller's can no longer answer with a different one. `getTime` is an ordinary method on `Date.prototype`, and `class T extends Date { getTime() { ... } }` answers it: one that threw left the time guard through the caller's own exception, and one answering afresh on each call made the instant validated and the instant compared two different reads of the same argument. `pki.cms.verify` copies a caller's validation time and trust-anchor dates the same way, so the policy checked and the policy applied hold one value.
38
+ - A byte view's bytes are read from the view itself, so a class of the caller's can no longer say where they are. `buffer`, `byteOffset` and `byteLength` are accessors on the shared typed-array prototype, and `class Opts extends Uint8Array { get buffer() { return elsewhere; } }` answers all three. One that lied substituted the memory: an array holding `AA AA AA AA` whose `buffer` returned another store had every byte door hand back that other store's bytes, so what a verb went on to hash, sign or parse was not what was in the array it was given. One that threw left those doors through the caller's own exception. `pki.webcrypto.getRandomValues` measured its 64 KiB ceiling the same way, and a subclass reporting `byteLength` of 0 passed the ceiling while the fill wrote the array's real length. It also takes a view over a `SharedArrayBuffer` again, which is a supported W3C argument the platform fills: the refusal of shared memory answers a question about input, where bytes another thread can rewrite after they have been checked cannot be checked at all, and a write target the caller chose is the other direction.
39
+ - A copied argument comes back as a plain one of its kind, so a verb reading it can no longer be sent into the caller's own code. The copy used to be given the original's prototype back, which left it dispatching there: `list.map(...)` inside a signing verb reached an override on an `Array` subclass, `getUTCFullYear()` reached one on a `Date` subclass, and an override that threw surfaced the caller's exception from inside a verb whose contract is a typed error. A prototype can also be rewritten after the call has begun, so a copy taken to stop the argument changing under the verb changed with it. An instance of a caller's own class copies to a plain object the same way, and a dictionary with no prototype keeps having none, since a copy that gained `Object.prototype` would inherit whatever a runtime has planted there. The names the copy reports are unchanged, which is what restoring the prototype had been for.
40
+ - Copying a caller's argument keeps the kind and the instant the value itself holds. The copy chose a byte view's kind through `v.constructor` and its element width through `v.BYTES_PER_ELEMENT`, and took a `Date`'s instant through `getTime`: all three are ordinary properties a subclass answers, so one that threw took the copy out through the caller's own exception, and one that lied produced a copy of a different kind, a different element count, or a different instant than the value being copied. Which kinds those are is taken from the runtime rather than listed, so a typed array of a kind the language adds is copied as itself from the day the runtime names it, and a view of a kind the runtime does not name is refused rather than built through whatever the value says its constructor is. Reading an array element is typed the same way reading a named field already was, so an accessor at an index no longer leaves the copy through its author's own exception while the identical getter under a name raises `<domain>/bad-input`. A `Map` or a `Set` is walked with the language's own `forEach` for the same reason, and the refusal for a value whose state cannot be copied names the kind from what the value is, so a `constructor` that throws no longer replaces the message that says why it was refused.
41
+ - A response-body size cap counts the bytes the body holds. `pki.est` and `pki.acme` take an injectable transport, so the response is a value their caller supplies, and `length` on a Buffer is shadowable by an own property: one reporting 0 carried a body of any size past the cap that exists to bound it (RFC 7030 sec. 6). `pki.est.challengePasswordFromTlsUnique` read its emptiness test the same way, and refused channel-binding bytes that were there.
42
+ - Bytes built in a `vm` context or a worker thread are taken as bytes. The doors deciding whether an argument is a byte container measured it against this realm's `Uint8Array` and `ArrayBuffer`, so a buffer from another context was turned away; at a door that also accepts a parsed structure it was then refused as a rebuilt one, which reads as a complaint about the certificate rather than about the container it arrived in. The four accepted forms are decided in one place now, from what the value holds.
43
+ - An options bag built over a built-in's prototype, or built in another realm, reads the same before and after a verb copies it. Two walks answered the question of which names a caller supplied, and they disagreed. The copy stopped only at this realm's `Object.prototype`, so a foreign bag had its `__proto__` accessor copied down as a supplied name; and away from a collection it read every level of the chain, so `Object.create(Array.prototype)` gained `length` and `Symbol.unscopables`. Either way a bag the check had just accepted was refused once it had been copied. One walk now answers for both.
44
+ - The options a verb reads are the options that were checked. Every one is read once as the verb receives them, and the names are taken again afterwards. Checking the names and reading them were two separate moments before, and a caller's getter sits between: `{ get format() { this.password = "pw"; return "der"; } }` showed only `format` while the check ran and grew `password` when the verb read `format`, so `pki.key.export` accepted it and returned a private key in the clear. An option supplied through a getter is therefore refused, and the message names it. A getter answers afresh every time it is read, so the value the check saw says nothing about the one the verb goes on to use, and a name checked once can be joined by another the next time it runs. That covers the getter on the object, the one a caller's class supplies, and the one that waits several reads before adding anything. A class that defines methods is untouched: a method is machinery, not an option.
45
+ - A name planted on `Array.prototype`, `Map.prototype` or another collection's prototype is reported. Those levels were passed over whole as the kind's own, so on a runtime carrying `Array.prototype.password` an empty array answered `opts.password` while the check saw nothing, and `pki.key.export` returned an unprotected private key for it. `Object.prototype` was already read for this reason. Every level is read the same way now, and what a kind puts on its prototype is passed over by name, and only where the member is still shaped the way the language leaves it, so a planted value borrowing one of those names is reported too. The names passed over are the ones that kind defines and none a neighboring kind defines: a weak collection counts nothing and has no `size`, a `SharedArrayBuffer` is `growable` where an `ArrayBuffer` is `resizable` and `detached`, and a `DataView` reads its bytes through methods, so it has no elements and no `length` and both are ordinary options on one. Naming the members rather than holding this realm's prototypes also settles a bag built in a `vm` context or a worker thread, whose prototypes are that realm's.
46
+ - An option carried on the prototype of an array, a byte view, a Map, a Set or an ArrayBuffer is seen. The check read only such a bag's own names, on the grounds that these kinds carry `buffer`, `byteLength`, `size` and the like on their prototypes and reporting those would refuse a shape the toolkit supports. `Object.setPrototypeOf([], { password: "pw" })` has none of that in its chain and still answers `opts.password`, and `pki.key.export` returned a plaintext private key for it. The chain is now read, with only the levels that describe a kind passed over, so a subclass's own additions are reported too. Copying such a bag keeps its class, so a verb handed the copy reads the same names off it that the check read off the original.
47
+ - A byte view whose class overrides `length` reports it as the option it is. `length` is structure only where the language puts it on the value itself: an array carries an own one that no prototype can shadow, while a byte view carries a prototype accessor, so `class Opts extends Uint8Array { get length() {} }` answers `opts.length` with what its author wrote. The name was passed over for every indexed kind, which hid that one.
48
+ - A property on an array whose name merely looks numeric is treated as the ordinary named property it is. `"-1"`, `"1.5"`, `"NaN"`, `"Infinity"` and `"4294967295"` are not array indices, and taking them for structure meant an unknown option under one of those names was accepted in silence and dropped from the copy the verb was given.
49
+ - An array argument costs what it holds rather than what it measures. Copying walked from zero to `length`, so an array with a high index or an assigned `length` did that many iterations for however few elements it carried.
50
+ - Copying a caller's argument no longer drops a name they added to it. A `Date` came back as its instant alone, and an own `constructor` field was skipped along with the inherited one a class defines, so in both cases the copy held nothing while the original still answered and the checks read the copy.
51
+ - A `Proxy` whose `getPrototypeOf` trap throws is refused with the verb's own `<domain>/bad-input` rather than the trap's raw error. Reading a prototype chain runs that trap, and the chain was read before the value was recognized as a `Proxy` at all, so the caller's own exception escaped a boundary whose contract is a typed error. Every question about the chain now comes after that decision, at all three doors: the copy path, the one that reports an object's option names, and the one a verb normalizes its options argument at, where the chain was walked by the test for a Buffer in the options position. The kind of an options argument is read off the value itself and never off its type tag, so a caller's `Symbol.toStringTag` is not consulted: a getter under it cannot run, and writing `Arguments` there names nothing. Whether the argument is a `Buffer` is settled by walking its prototype chain rather than by `Buffer.isBuffer`, which is an `instanceof` against an ordinary extensible function: a `Symbol.hasInstance` planted on `Buffer` decided that question, and could answer it by throwing.
52
+ - A `Proxy` is refused as an options bag, and as any argument these verbs copy. It answers `ownKeys` and `get` from two independent traps, so one reporting no keys while returning a value for `password` presents an object every enumeration calls empty and every read calls populated. `pki.key.export` accepted such a bag and returned a plaintext private key, which is the case the refusal exists to prevent, and a copy of one silently held nothing the caller had supplied. No enumeration can close that, since it reads only what a trap chooses to say, so the shape is refused by identity rather than probed for a contradiction.
53
+ - An argument whose prototype chain is a cycle no longer hangs the call or raises an untyped `RangeError`. A Proxy may report itself as its own prototype while its target stays extensible. Reading an object's option names, copying it, and any `instanceof` inside the copy all walk that chain, and none of them terminated. Reading the names now stops once it revisits an object, which loses nothing since a second lap finds no new name, and copying refuses the shape with the caller's own `<domain>/bad-input` code.
54
+ - At every verb named here, an options argument of `false`, `0`, `""` or `NaN` is refused rather than read as no options at all. Verbs outside that list still read a falsy options argument as though none were given, and are a later cut. The check that an options argument is an object ran after the argument had already been replaced by an empty object, and that replacement treated any falsy value as absent, so those four reached the body as `{}`. `pki.cms.sign`, `pki.cms.countersign` and `pki.cms.verify` carried this before this release; the verbs gaining option checks here would have inherited it. `null` and `undefined` still mean no options, and a Buffer in the options position is now named as the argument-order mistake it is. The boxed forms go the same way: `new Number(0)` and `new Boolean(false)` carry no option of their own, so they passed as a bag holding nothing, which is the same silence one wrapper away. A boxed primitive, an `Error`, a `Promise` and an `arguments` object are each refused by name, so the message says which argument was passed rather than naming a field of the engine's own.
55
+
56
+ ## v0.5.10 — 2026-08-18
57
+
58
+ 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.
59
+
60
+ ### Added
61
+
62
+ - `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.
63
+
64
+ ### Changed
65
+
66
+ - 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.
67
+
68
+ ## v0.5.9 — 2026-08-17
8
69
 
9
70
  A certificate can now carry an internationalized email address, which this toolkit could read and never write.
10
71
 
@@ -15,7 +76,7 @@ A certificate can now carry an internationalized email address, which this toolk
15
76
 
16
77
  ### Fixed
17
78
 
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.
79
+ - 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.
19
80
 
20
81
  ## v0.5.8 — 2026-08-17
21
82
 
@@ -24,7 +85,7 @@ Four verdicts that answered a question nobody had asked now say what they checke
24
85
  ### Added
25
86
 
26
87
  - 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.
88
+ - 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.
28
89
 
29
90
  ### Changed
30
91
 
@@ -34,7 +95,7 @@ Four verdicts that answered a question nobody had asked now say what they checke
34
95
 
35
96
  - 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
97
  - 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.
98
+ - 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.
38
99
 
39
100
  ## v0.5.7 — 2026-08-16
40
101
 
@@ -212,12 +273,12 @@ CMC (Certificate Management over CMS) ships end to end: build a Full PKI Request
212
273
  - 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.
213
274
  - 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.
214
275
  - 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.
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.
276
+ - 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.
216
277
  - 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.
217
278
  - 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.
218
279
  - 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.
219
280
  - 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.
281
+ - 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.
221
282
  - 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.
222
283
 
223
284
  ## v0.4.15 — 2026-08-10
@@ -251,7 +312,7 @@ Every key-establishment secret this library allocates is now wiped when it stops
251
312
 
252
313
  ### Changed
253
314
 
254
- - 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.
315
+ - 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.
255
316
 
256
317
  ### Fixed
257
318
 
@@ -289,7 +350,7 @@ A KEM shared secret and the key it derives are now wiped as soon as they stop be
289
350
  ### Changed
290
351
 
291
352
  - 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.
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.
353
+ - 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.
293
354
 
294
355
  ### Fixed
295
356
 
@@ -312,7 +373,7 @@ A CMS message can no longer declare one content cipher and be opened with anothe
312
373
 
313
374
  ### Fixed
314
375
 
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.
376
+ - 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.
316
377
  - 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.
317
378
 
318
379
  ## v0.4.11 — 2026-08-09
@@ -323,20 +384,20 @@ A WebAuthn attestation can now be bound to the roots the authenticator's own mod
323
384
 
324
385
  - 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
386
  - 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.
387
+ - 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.
327
388
  - 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
389
  - 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.
390
+ - 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.
330
391
  - 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.
331
392
  - 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.
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.
393
+ - 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.
333
394
  - 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.
334
395
  - 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.
335
396
 
336
397
  ### Changed
337
398
 
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.
399
+ - 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.
400
+ - 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.
340
401
 
341
402
  ### Fixed
342
403
 
@@ -351,7 +412,7 @@ A TPM attestation now reports the credential key's own object attributes and acc
351
412
  - 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.
352
413
  - 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
414
  - 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.
415
+ - 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.
355
416
  - 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.
356
417
 
357
418
  ### Fixed
@@ -445,7 +506,7 @@ pki.tls encodes and decodes RFC 8879 compressed certificate messages (the larges
445
506
 
446
507
  ### Changed
447
508
 
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.
509
+ - 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.
449
510
 
450
511
  ### Fixed
451
512
 
@@ -1400,7 +1461,7 @@ SLH-DSA signatures verify in certification-path validation, and the post-quantum
1400
1461
  ### Fixed
1401
1462
 
1402
1463
  - 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.
1464
+ - 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.
1404
1465
 
1405
1466
  ## v0.1.20 — 2026-07-09
1406
1467
 
@@ -1580,7 +1641,7 @@ A PKCS#10 certification-request parser joins the pki.schema family.
1580
1641
 
1581
1642
  ### Changed
1582
1643
 
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.
1644
+ - 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.
1584
1645
 
1585
1646
  ### Security
1586
1647
 
@@ -1650,7 +1711,7 @@ WebCrypto EC key import validates the curve against the key material.
1650
1711
 
1651
1712
  ### Security
1652
1713
 
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.
1714
+ - 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.
1654
1715
 
1655
1716
  ## v0.1.2 — 2026-07-04
1656
1717
 
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
@@ -1883,7 +1883,7 @@ function client(directoryUrl, opts) {
1883
1883
  // Measure an injected string body as UTF-8 -- the exact bytes _jsonInput re-encodes it to before the
1884
1884
  // decoder sees it -- so a non-ASCII body (e.g. multi-byte emoji, ~half the byte count under latin1)
1885
1885
  // cannot slip past the cap and reach the JSON parser.
1886
- var blen = Buffer.isBuffer(res.body) ? res.body.length : Buffer.byteLength(String(res.body == null ? "" : res.body), "utf8");
1886
+ var blen = Buffer.isBuffer(res.body) ? guard.bytes.lengthOf(res.body) : Buffer.byteLength(String(res.body == null ? "" : res.body), "utf8");
1887
1887
  if (blen > budgets.maxResponseBytes) throw E("acme/response-too-large", "the response body (" + blen + " bytes) exceeds the " + budgets.maxResponseBytes + "-byte cap");
1888
1888
  if ((method === "GET" || method === "HEAD") && res.status >= 300 && res.status < 400) {
1889
1889
  if (redirects >= budgets.maxRedirects) throw E("acme/too-many-redirects", "the redirect budget of " + budgets.maxRedirects + " was exceeded");
@@ -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");
@@ -2216,7 +2216,7 @@ function client(directoryUrl, opts) {
2216
2216
  function clk() { var t = _rawClk(); if (typeof t !== "number" || !isFinite(t)) throw E("acme/bad-input", "the renewalInfo clock returned a non-finite value"); return t; }
2217
2217
  // RFC 9773 sec. 4.3: a client MUST NOT check a certificate's RenewalInfo after it has expired. Gate BEFORE the
2218
2218
  // unauthenticated GET -- the same expiry test renewalWindow applies -- so the raw verb cannot query a dead cert.
2219
- var notAfterMs = x509.parse(certDer).validity.notAfter.getTime();
2219
+ var notAfterMs = guard.time.instantOf(x509.parse(certDer).validity.notAfter);
2220
2220
  // allow:nan-date-comparison-unguarded -- notAfter is a codec-parsed cert date (asn1 readTime rejects a NaN instant).
2221
2221
  if (clk() > notAfterMs) throw E("acme/certificate-expired", "the certificate is already past its notAfter; a client MUST NOT check RenewalInfo after it has expired (RFC 9773 sec. 4.3)");
2222
2222
  var certId = ariCertId(certDer);
@@ -2282,7 +2282,7 @@ function client(directoryUrl, opts) {
2282
2282
  // path-validate's `t > notAfter` expiry test -- only a cert strictly past notAfter has nothing to renew.
2283
2283
  // allow:nan-date-comparison-unguarded -- notAfter is a codec-parsed cert date (asn1 readTime rejects a NaN
2284
2284
  // instant) and the window start/end below are rfc3339-validated by validateRenewalInfo; neither is NaN.
2285
- if (now > notAfter.getTime()) throw E("acme/certificate-expired", "the certificate is already past its notAfter; there is nothing to renew (RFC 9773 sec. 4.3)");
2285
+ if (now > guard.time.instantOf(notAfter)) throw E("acme/certificate-expired", "the certificate is already past its notAfter; there is nothing to renew (RFC 9773 sec. 4.3)");
2286
2286
  if (o.replaced === true) throw E("acme/certificate-replaced", "the caller asserts this certificate has already been replaced (RFC 9773 sec. 4.3)");
2287
2287
  return _renewalInfo(certDer, clk, RENEWAL_RETRY_MAX_SECONDS).then(function (ri) {
2288
2288
  var w = ri.renewalInfo.suggestedWindow;
@@ -2293,7 +2293,7 @@ function client(directoryUrl, opts) {
2293
2293
  // must renew BEFORE the cert dies), so a CA suggestedWindow that extends past notAfter is clamped to it
2294
2294
  // -- selection stays uniform within the still-valid sub-window. If the whole window is past notAfter,
2295
2295
  // effStart collapses to effEnd (renew at the last valid instant).
2296
- var notAfterMs = notAfter.getTime();
2296
+ var notAfterMs = guard.time.instantOf(notAfter);
2297
2297
  var effEnd = Math.min(endMs, notAfterMs), effStart = Math.min(startMs, effEnd);
2298
2298
  // RFC 9773 sec. 4.2: if the caller passed a prior result (opts.previous) whose suggestedWindow is UNCHANGED,
2299
2299
  // REUSE its selectedTime (clamped to the still-valid, notAfter-bounded window) instead of drawing again, so
package/lib/asn1-der.js CHANGED
@@ -758,7 +758,7 @@ function readTime(node, opts) {
758
758
  d.setUTCFullYear(year, month - 1, day);
759
759
  d.setUTCHours(hour, min, sec, ms);
760
760
  // Coverage residual -- regex-validated fields are finite integers and Date rolls over (never NaN); out-of-range is caught by the component round-trip check below.
761
- if (isNaN(d.getTime())) throw new Asn1Error("asn1/bad-time", "unparseable time " + JSON.stringify(s));
761
+ if (isNaN(guard.time.instantOf(d))) throw new Asn1Error("asn1/bad-time", "unparseable time " + JSON.stringify(s));
762
762
  // Date/setUTC* silently roll over out-of-range fields (Feb 30 -> Mar 2,
763
763
  // month 13 -> next Jan, hour 25 -> +1 day). Reject unless every component
764
764
  // round-trips exactly.
@@ -175,7 +175,7 @@ function _encodeValidity(notBefore, notAfter) {
175
175
  guard.time.assertValid(notBefore, _err, "attrcert/bad-input", "notBeforeTime");
176
176
  guard.time.assertValid(notAfter, _err, "attrcert/bad-input", "notAfterTime");
177
177
  // allow:nan-date-comparison-unguarded -- both operands are guard.time.assertValid'd above.
178
- if (notBefore.getTime() > notAfter.getTime()) throw _err("attrcert/bad-input", "notBeforeTime must not be after notAfterTime (RFC 5755 sec. 4.2.6)");
178
+ if (guard.time.instantOf(notBefore) > guard.time.instantOf(notAfter)) throw _err("attrcert/bad-input", "notBeforeTime must not be after notAfterTime (RFC 5755 sec. 4.2.6)");
179
179
  return b.sequence([b.generalizedTime(notBefore), b.generalizedTime(notAfter)]);
180
180
  }
181
181
 
package/lib/cbor-det.js CHANGED
@@ -616,7 +616,7 @@ function readTime(node) {
616
616
  var d = new Date(ns < 0 ? -constants.TIME.seconds(-ns) : constants.TIME.seconds(ns));
617
617
  // Coverage residual -- the epoch-window bound above already rejects any value the
618
618
  // Date constructor cannot represent, so this backstop is defense-in-depth.
619
- if (isNaN(d.getTime())) throw new CborError("cbor/bad-time", "epoch time out of range");
619
+ if (isNaN(guard.time.instantOf(d))) throw new CborError("cbor/bad-time", "epoch time out of range");
620
620
  return d;
621
621
  }
622
622
 
@@ -730,14 +730,14 @@ var build = {
730
730
  },
731
731
  time: function (v) {
732
732
  var ms;
733
- if (v instanceof Date) {
734
- ms = v.getTime();
733
+ if (guard.time.isDate(v)) {
734
+ ms = guard.time.instantOf(v);
735
735
  if (Number.isNaN(ms)) throw new CborError("cbor/bad-time", "build.time: an Invalid Date has no epoch value");
736
736
  // read.time reads only an integer (second-granularity) tag-1, so a sub-second Date cannot round-trip:
737
737
  // reject it rather than silently truncate the milliseconds.
738
738
  if (ms % 1000 !== 0) throw new CborError("cbor/bad-time", "build.time: a Date with sub-second precision cannot be a second-granularity CBOR epoch; round to whole seconds");
739
739
  }
740
- var secs = v instanceof Date ? BigInt(Math.floor(ms / 1000)) : (typeof v === "bigint" ? v : BigInt(v));
740
+ var secs = guard.time.isDate(v) ? BigInt(Math.floor(ms / 1000)) : (typeof v === "bigint" ? v : BigInt(v));
741
741
  // Mirror read.time's bound so build.time never emits a tag-1 the strict reader would reject as out of range.
742
742
  if (secs < -_MAX_EPOCH_SECONDS || secs > _MAX_EPOCH_SECONDS) throw new CborError("cbor/bad-time", "build.time: epoch seconds " + secs + " are outside the representable Date range");
743
743
  return build.tag(1, build.int(secs));
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.
@@ -415,11 +415,14 @@ function _snapshotIfBytes(input) {
415
415
  // same door as a Buffer, and copying only Buffer and Uint8Array would leave those aliased across
416
416
  // the very gap this closes -- reopening the window for exactly the inputs that came in by the
417
417
  // wider one. A DataView is copied over its OWN window, not the whole buffer it happens to sit in.
418
+ // One call, because guard.bytes decides what a byte form is and where its bytes are. The three
419
+ // hand-rolled branches this replaces read `v.buffer`, `v.byteOffset` and `v.byteLength` off the
420
+ // value, and all three are accessors a caller's subclass can answer: one that lied copied a
421
+ // different store than the array held, so the bytes checked here and the bytes signed later were
422
+ // two different reads.
418
423
  function _copyAnyBytes(v) {
419
- if (Buffer.isBuffer(v) || v instanceof Uint8Array) return guard.bytes.snapshot(v, CmcError, "cmc/bad-input", "a byte field of the request");
420
- if (ArrayBuffer.isView(v)) return Buffer.from(new Uint8Array(v.buffer, v.byteOffset, v.byteLength));
421
- if (v instanceof ArrayBuffer) return Buffer.from(new Uint8Array(v));
422
- return v;
424
+ if (!guard.bytes.isByteSource(v)) return v;
425
+ return guard.bytes.snapshotSource(v, CmcError, "cmc/bad-input", "a byte field of the request");
423
426
  }
424
427
 
425
428
  function _snapshotSent(sent) {
@@ -429,7 +432,7 @@ function _snapshotSent(sent) {
429
432
  // integer through. A Transaction Identifier is an unbounded INTEGER on the wire,
430
433
  // so one above Number.MAX_SAFE_INTEGER has already been rounded by the time it
431
434
  // arrives as a `number` -- 9007199254740993 is 9007199254740992 before this code
432
- // sees it, and a response echoing that NEIGHBOURING identifier would compare
435
+ // sees it, and a response echoing that NEIGHBORING identifier would compare
433
436
  // equal. Held here rather than converted, so a value too large to be a number is
434
437
  // refused with the shape to use instead (a bigint) rather than silently bound to
435
438
  // something adjacent.
@@ -599,7 +602,7 @@ function _assertAuthentic(body, sent, responseBytes) {
599
602
  .then(function (res) {
600
603
  if (res.valid) return true;
601
604
  // 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
605
+ // and the opt-out may only be honored when none of them was actually
603
606
  // checked and rejected: one signer whose certificate is missing must not
604
607
  // let a DIFFERENT signer's failed signature through beside it.
605
608
  var failed = (res.signers || []).filter(function (s) { return !s.ok; });
@@ -710,7 +713,7 @@ function _shape(body, sent, signatureVerified, bound) {
710
713
  * `spec.transactionId` (number|bigint), `spec.senderNonce` and `spec.dataReturn` (bytes) attach the
711
714
  * exchange-binding controls (RFC 5272 sec. 6.6 / 6.4), the same three `pki.cmc.verify` checks the
712
715
  * 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
716
+ * `spec.controls`, because a request that quietly omits them has no replay defense and neither end
714
717
  * can tell: the verifier only enforces the halves the client says it sent. An unrecognized spec
715
718
  * field is refused for the same reason: a misspelling would otherwise build and sign a message
716
719
  * 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.