@blamejs/pki 0.5.9 → 0.5.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -19
- package/README.md +1 -1
- package/lib/acme.js +1 -1
- package/lib/cmc-build.js +5 -5
- package/lib/cmc-verify.js +7 -7
- package/lib/cms-decrypt.js +2 -2
- package/lib/cms-verify.js +2 -2
- package/lib/constants.js +1 -1
- package/lib/est.js +3 -3
- package/lib/guard-all.js +12 -12
- package/lib/guard-bytes.js +4 -4
- package/lib/guard-compress.js +2 -2
- package/lib/guard-der.js +1 -1
- package/lib/guard-identifier.js +3 -3
- package/lib/guard-limits.js +2 -2
- package/lib/guard-parsed.js +1 -1
- package/lib/guard-range.js +1 -1
- package/lib/guard-secret.js +1 -1
- package/lib/guard-text.js +1 -1
- package/lib/hpke.js +1 -1
- package/lib/http-digest.js +1 -1
- package/lib/jose.js +1 -1
- package/lib/lint.js +1 -1
- package/lib/ocsp.js +1 -1
- package/lib/oid.js +1 -1
- package/lib/path-validate.js +5 -5
- package/lib/schema-c509.js +2 -2
- package/lib/schema-cmp.js +1 -1
- package/lib/schema-crl.js +1 -1
- package/lib/schema-engine.js +2 -2
- package/lib/schema-pkix.js +1 -1
- package/lib/smime.js +1 -1
- package/lib/validator-cose.js +3 -3
- package/lib/webauthn-mds.js +12 -12
- package/lib/webauthn.js +11 -11
- package/lib/webcrypto.js +1 -1
- package/package.json +4 -2
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -4,7 +4,19 @@ 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.
|
|
7
|
+
## v0.5.10 — 2026-08-18
|
|
8
|
+
|
|
9
|
+
The documentation and the package's own source comments settle on one spelling of the words they use in both, and a gate keeps them there.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `npm run check:spelling` reports any word in the repository that has a second accepted spelling. It runs in `npm run gates`, on every pull request, and again before the published tarball is packed. The check is whole-word and case-insensitive, both to avoid a failure mode: a substring match reports `publicEncrypt` as a misspelling, and a case-sensitive one walks past the same word capitalized or upper-cased. It self-tests on planted forms before reporting, so a word list that has stopped matching cannot pass as a clean tree.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- Documentation and source comments now use the US spellings behavior, recognize, unrecognized, labeled, honored, license, defense, neighbor, authorize, initialization, enrollment, signaled, modeled, favor and fulfill. 267 occurrences across 98 files no longer carry a second spelling: the README, the security policy, thirteen release notes and the changelog generated from them, the status-lifecycle record, the comments and error text in lib/, the test suite, and the release and wiki tooling. That figure counts words whose spelling changed, so a line edited for another reason that happened to contain one of these words is not counted twice. One word settles the other way: catalogue, which this repository already used by 185 uses to 32, so the checked form is that one and the US spelling is what now reports. Simple Certificate Enrolment Protocol is RFC 8894's title, quoted as published and allowed only on a line carrying that title in full, so the exception cannot spread to the word.
|
|
18
|
+
|
|
19
|
+
## v0.5.9 — 2026-08-17
|
|
8
20
|
|
|
9
21
|
A certificate can now carry an internationalized email address, which this toolkit could read and never write.
|
|
10
22
|
|
|
@@ -15,7 +27,7 @@ A certificate can now carry an internationalized email address, which this toolk
|
|
|
15
27
|
|
|
16
28
|
### Fixed
|
|
17
29
|
|
|
18
|
-
- pki.smime.verify's sender binding is now exercised against certificates carrying an otherName. Two
|
|
30
|
+
- pki.smime.verify's sender binding is now exercised against certificates carrying an otherName. Two behaviors that previously had no conformance vector are pinned: a certificate whose subjectAltName carries an SmtpUTF8Mailbox does not let a legacy subject distinguished-name emailAddress speak for it, and an otherName unrelated to email, such as a Microsoft user principal name, neither erases a matching rfc822Name nor turns a definite non-match into an undecidable one.
|
|
19
31
|
|
|
20
32
|
## v0.5.8 — 2026-08-17
|
|
21
33
|
|
|
@@ -24,7 +36,7 @@ Four verdicts that answered a question nobody had asked now say what they checke
|
|
|
24
36
|
### Added
|
|
25
37
|
|
|
26
38
|
- pki.cms.decrypt reports originAuthenticated, authenticatedBy and originatorInfo. authenticated is a claim about the content and the key that opened it; it never described who sent the message. originAuthenticated is false for every recipient type the toolkit supports: a ktri or ephemeral-static kari message is minted by anyone holding the recipient's public key, and a pwri or kekri message by any co-recipient sharing the secret. authenticatedBy names what the integrity rests on. originatorInfo is now surfaced rather than decoded and discarded, and is documented as unauthenticated: it sits outside the AEAD's authenticated data, so it is a hint the sender chose, and any certificate it carries must be validated before use. To bind a sender, verify a signature over the plaintext.
|
|
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
|
|
39
|
+
- pki.smime.verify accepts expectedSender and reports a sender block of { checked, expected, source, identities, match }. A signature proves a key signed; it does not prove the message came from the mailbox the reader sees. match is true only when the signer certificate asserts the address, compared under RFC 5280 section 7.5: the local-part exactly, the host-part case-insensitively. The address is read from the subjectAltName rfc822Name entries (RFC 8550 section 4.4.3), and where the extension carries none, from the subject distinguished name's PKCS #9 emailAddress attribute, which RFC 8550 section 3 requires a receiving agent to recognize. Where both are present the extension is authoritative, so a stale subject value cannot satisfy expectedSender while the extension names a different mailbox. It is three-valued: false when every identity was comparable and none matched, null when the question went unanswered, and null is not a pass, so a caller enforcing sender binding tests match === true. identities lists what the certificate actually asserts. With no expectedSender a single outer From is used and reported as source: "from", which is advisory, because on a message without header protection that header is attacker-controlled.
|
|
28
40
|
|
|
29
41
|
### Changed
|
|
30
42
|
|
|
@@ -34,7 +46,7 @@ Four verdicts that answered a question nobody had asked now say what they checke
|
|
|
34
46
|
|
|
35
47
|
- An rfc822Name identity comparison no longer folds the host-part with a Unicode-aware lowercase. U+212A KELVIN SIGN lowercases to ASCII k, so ban<U+212A>.com and bank.com are different byte strings, separately registrable, that compared equal and read as one email identity. The host-part is now folded across A-Z only, which is the case-insensitive ASCII comparison RFC 5280 section 7.5 authorizes and no more. The local-part was already compared exactly and stays that way: RFC 8398 section 5 requires that it not be transformed in any way, including by case folding.
|
|
36
48
|
- pki.merkle.verifyConsistency refuses a proof whose older tree is empty and whose newer tree is not, as merkle/no-consistency-claim. RFC 6962 section 2.1.2 defines a consistency proof for 0 < oldSize < newSize. An empty tree is a prefix of every tree by definition, so there was no proof to check and nothing bound the newRoot that was passed: any value returned true, including a root from a different log. Two empty trees are unchanged and still check each root against the empty root hash.
|
|
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
|
|
49
|
+
- pki.cmc.verify refuses a Full PKI Response that carries nothing tying it to a request, as cmc/unbound-response. Every binding the module could check was previously conditional on the caller having supplied the matching value, and nothing in the verdict reported whether any of them ran, so a response captured from an earlier successful enrollment against the same CA verified identically. Pass what the request retained (transactionId, senderNonce, whose echo is the replay defense of RFC 5272 section 6.6, or dataReturn), or allowUnbound: true to interpret a response that could be a replay of any earlier exchange.
|
|
38
50
|
|
|
39
51
|
## v0.5.7 — 2026-08-16
|
|
40
52
|
|
|
@@ -212,12 +224,12 @@ CMC (Certificate Management over CMS) ships end to end: build a Full PKI Request
|
|
|
212
224
|
- pki.cmc.build takes transactionId, senderNonce and dataReturn as named spec fields, the same three pki.cmc.verify checks a response against. They previously had to be hand-encoded into spec.controls, and an unrecognized spec field was accepted in silence, so a request written the obvious way built, signed and sent with no exchange binding at all. Neither end could detect that, because the verifier only enforces the halves the client says it sent. An unknown spec field is now refused.
|
|
213
225
|
- pki.cms.verify and pki.cmc.verify parse a private copy of a Buffer input, so the value they report and the bytes the signature was checked against are provably the same. Both decode the message synchronously and check signatures in a later turn, which left every byte range the parse surfaced, the signed content above all, a view into the caller's memory across that gap. A buffer rewritten in between could yield a result describing one message while the signature covered another, and the everyday way to hit it is a pooled read buffer recycled across concurrent verifies. A PEM string or an already-parsed object is untouched, so neither call accepts less than it did.
|
|
214
226
|
- Every example in the API documentation runs against the shipped package as part of the test suite, with no fixtures supplied to it, and the ones that did not run have been corrected. An example that quietly depended on a value the surrounding text never defined would previously have failed only for the operator who pasted it.
|
|
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
|
|
227
|
+
- pki.webauthn.verify copies opts.rootCertificates synchronously, so pinned attestation roots cannot be swapped out from under the check. The roots are not read until the attestation verifier resolves, a later promise turn, and both the array and each DER buffer stayed caller-owned across it: a caller recycling the array or overwriting a certificate's bytes in that gap had the attestation anchored against the replacement roots while the verdict still reported anchoredTo: "rootCertificates". Both are now copied at the entry point, the same defense the assertion input already had, and the documented parsed-certificate form is deep-copied and never passed by reference, since the anchor comparison reads its nested subject and subjectPublicKeyInfo buffers. opts.safetyNetRoots carried the identical window one level down, since a format verifier reads it a microtask after the call returns, and is snapshotted at the same boundary.
|
|
216
228
|
- In a compound attestation, an element the metadata catalogue does not list can no longer launder a revoked sibling. The two governance failures are not equal: metadata-not-found is the one outcome a caller may fall back to opts.rootCertificates on, and that fallback covers the whole statement. Governance stopped at the first failing element, and a compound's element order is not signed, so placing an unlisted element first raised the fallback error before a listed-but-revoked sibling was ever consulted, and the statement anchored against the pinned roots instead. Every element is now governed, and every listed element chain-validated against the roots its own entry registers, before any outcome is chosen. So a disqualifying status report outranks an unlisted sibling from either position, and a listed element whose path reaches the caller's pinned roots but not its own registered roots can no longer ride out on that sibling's fallback either. A compound in which no element is listed still falls back as documented.
|
|
217
229
|
- pki.ocsp.sign copies the responder key synchronously. The key is not read until several promise turns after the call, and capturing only the reference stopped responder.key being reassigned but not the PKCS#8 bytes, or a composite key's components, being rewritten in place. Either produced a response carrying this responder's identifier and embedded certificate over a signature made by different key material, which no relying party can verify. A CryptoKey is opaque and a PEM string immutable, so both were already safe. A composite descriptor is cloned whether or not its components currently hold bytes: one carrying two PEM strings has nothing mutable inside it, but the object is still the caller's, and reassigning a component reaches the deferred sign just as rewriting a buffer would.
|
|
218
230
|
- An Apple attestation certificate whose anonymous-attestation extension carries more than the nonce is refused. AppleAnonymousAttestation is a SEQUENCE of exactly one field wrapping exactly one value, but the decoder read the first child and ignored the rest, so a certificate with a trailing field beside the nonce, a second value inside the EXPLICIT [1] wrapper, or a non-SEQUENCE outer value was accepted. That extension exists to carry the value the attestation binds to, so an ambiguous encoding of it is not a shape the verifier gets to pick a reading from; arity is now enforced as part of the declared type.
|
|
219
231
|
- A key identifier and a pre-encoded CertID must now be bytes, and are no longer coerced. pki.cms.sign takes a key-only signer's keyIdentifier and pki.ocsp.sign takes a response entry's certID straight into the encoding, and both previously ran the value through Buffer.from, which accepts far more than it should mean: Buffer.from(20) allocates twenty zero octets and Buffer.from("a1b2") takes the ASCII of the text where a reader means two octets. Either produced a structurally valid but wrong SignerIdentifier or CertID, inside a message that then gets signed and that no verifier can match back. A Buffer or Uint8Array is accepted as before; anything else is now cms/bad-input or ocsp/bad-input.
|
|
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
|
|
232
|
+
- An OCSP response whose nonce does not echo the request no longer downgrades a revoked verdict to unknown. pki.ocsp.verify applies that downgrade to good only. Revocation does not go stale the way non-revocation does, so discarding a signed, current, authorized revoked response because it was replayed would hand a soft-failing caller the certificate the responder just refused: the anti-replay defense would become the thing that accepts it. The verdict was also self-contradictory, reporting status unknown while carrying revocationReason keyCompromise. nonceMatched: false still reports that the response was not bound to this request, and the field is now three-state and always present (true bound, false not bound, null when the client sent no nonce), so a caller can tell a check that ran from one that was never asked for.
|
|
221
233
|
- Corrections to the repository documentation, each of which would have misled a reader who acted on it. SUPPORT.md described additive APIs as minor releases; pre-1.0 they ship as patches, and a minor is an explicit decision recorded in the release notes. ARCHITECTURE.md, CONTRIBUTING.md and the interop test guide showed pki.x509.parse, which does not exist: parsing is pki.schema.x509.parse, and pki.<format> is the issuing half throughout. ARCHITECTURE.md listed namespaces as future that have shipped, and omitted the schema, guard and validator families entirely. THREAT-MODEL.md marked path validation, signature verification, CMS decryption and ML-KEM decapsulation as targeted when all four have shipped, and linked to a section that no longer exists. ROADMAP.md reported CRL reason-shard accumulation and delta-CRL merge as planned in one entry while describing them as shipped in another. ML-KEM encapsulation and decapsulation were described as roadmap items; both ship and are what the CMS KEMRecipientInfo arm rides on. The interoperability acceptance gate was described as running against NSS, Windows CAPI and macOS Keychain alongside OpenSSL; only the OpenSSL cross-checks are wired, and the others are roadmap. The format detectors behind pki.schema.parse were described as mutually exclusive regardless of registration order; order is load-bearing where two overlap, so a CMP PKIMessage sits ahead of the OCSP-request probe and a v1 attribute certificate ahead of X.509. The fuzzing guide listed 27 of the 55 harnesses. The README carried two separate entries for pki.tls. The published pki.transport response contract named three fields where the transport returns four: the omitted tls field is what pki.est.serverkeygen reads to assert the channel can protect a server-generated private key, and a transport that reports no cipher is trusted, so an operator injecting a substitute built to the documented contract silently skipped that check. EST channel binding was described as shipped; the challengePassword builder and the server-instruction flag ship, but nothing produces the RFC 5929 tls-unique value and the shared transport does not expose it, so the attribute cannot be driven end to end.
|
|
222
234
|
|
|
223
235
|
## v0.4.15 — 2026-08-10
|
|
@@ -251,7 +263,7 @@ Every key-establishment secret this library allocates is now wiped when it stops
|
|
|
251
263
|
|
|
252
264
|
### Changed
|
|
253
265
|
|
|
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.
|
|
266
|
+
- Raw secret key material is now reached through a single path that clears the copy it hands out, so a new operation cannot obtain that material without the wipe. Behavior of the public API is unchanged.
|
|
255
267
|
|
|
256
268
|
### Fixed
|
|
257
269
|
|
|
@@ -289,7 +301,7 @@ A KEM shared secret and the key it derives are now wiped as soon as they stop be
|
|
|
289
301
|
### Changed
|
|
290
302
|
|
|
291
303
|
- The ML-KEM ciphertext-length check FIPS 203 sec. 7.3 requires of a decapsulating party is now performed by the crypto engine, so a caller reaching decapsulateBits directly is covered too; before, only the CMS path that happens to call it today was. It reports webcrypto/bad-kem-ciphertext, naming the parameter set and both lengths, where the failure was previously indistinguishable from any other decapsulation fault; a ciphertext whose length is valid for some other parameter set is refused on those terms, and no longer reads as merely short. The check is on length alone: a correct-length ciphertext that has been tampered with still resolves to a pseudo-random shared secret, because turning that into an error would give an attacker a decryption oracle. No engine detail reaches a CMS caller: a structurally valid message whose decryption fails for any secret-dependent reason still reports the single uniform cms/decrypt-failed verdict. A message whose ML-KEM ciphertext length does not match the parameter set the message itself declares is a separate case and always was (including a length that would be valid for a different set), because the strict parser rejects it up front and names it: the mismatch is a structural fault, decidable from the message alone, with nothing about it depending on a key.
|
|
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.
|
|
304
|
+
- The ML-KEM parameter sizes resolve from one registry instead of three separate tables in three modules. The encapsulation-key lengths were already duplicated verbatim in two of them, and each new consumer meant another copy that could drift; a parameter set is a property of the algorithm identifier, so it now lives beside the registry that resolves one. Behavior is unchanged.
|
|
293
305
|
|
|
294
306
|
### Fixed
|
|
295
307
|
|
|
@@ -312,7 +324,7 @@ A CMS message can no longer declare one content cipher and be opened with anothe
|
|
|
312
324
|
|
|
313
325
|
### Fixed
|
|
314
326
|
|
|
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
|
|
327
|
+
- A CMS content cipher is now bound to the container that declares it. An EnvelopedData must name a CBC cipher and an AuthEnvelopedData an AEAD one, checked before the content-encryption key is used; a mismatch is refused as an unsupported algorithm naming both the cipher and the container. Previously only the cipher's key length was resolved, and because AES-CBC and AES-GCM share key lengths, an EnvelopedData whose algorithm identifier had been changed to the same-size AES-GCM identifier decrypted successfully as unauthenticated CBC while reporting the AEAD algorithm in its result. A caller inspecting contentEncryptionAlgorithm to establish that the content was authenticated was answered from a field the decryption had not honored. The reverse pairing was refused only incidentally, by a later dereference of parameters the AEAD path expects, and no stated rule refused it.
|
|
316
328
|
- The password-recipient inner cipher is resolved through the same identifier-keyed table. It previously required a CBC mode by matching the algorithm identifier's display name, which pki.oid.register can rebind, so a caller that had registered a name over a built-in one could change which ciphers that check admitted.
|
|
317
329
|
|
|
318
330
|
## v0.4.11 — 2026-08-09
|
|
@@ -323,20 +335,20 @@ A WebAuthn attestation can now be bound to the roots the authenticator's own mod
|
|
|
323
335
|
|
|
324
336
|
- pki.webauthn.verifyMetadataBlob reads a FIDO Metadata Service (MDS v3) BLOB and returns its entries indexed by aaguid. The BLOB is a JWS: its signature is checked under the certificate in its own header, that chain is validated to one of the roots the caller pins, and only then is the payload parsed. The ordering is the point, because a reader that parses first hands an attacker every structure behind the signature.
|
|
325
337
|
- The catalogue's freshness is enforced, and enforced again wherever it is used. A BLOB whose sequence number does not exceed the one the caller already holds is refused as a rollback, and requireRollbackCheck makes supplying that number mandatory so the check cannot be skipped by forgetting the option. A BLOB past its nextUpdate is refused as stale. A verified result is an ordinary object a relying party may cache, so its expiry is re-checked each time it is passed to verify, and a catalogue fetched while current cannot keep authorizing an authenticator whose status reports have since revoked it. Both checks fail closed, and the caller's own allowStale decision rides on the result, so it need not be repeated.
|
|
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
|
|
338
|
+
- Passing the verified result to pki.webauthn.verify as opts.metadata binds the attestation to its own model: the authenticator's registered attestation roots are resolved from its identifier, and its trust path must fully validate to one of them. That means signature chaining, validity and constraints: the same path validation any certificate chain gets. An authenticator whose model the catalogue does not list, whose entry registers no attestation root, or whose status reports disqualify it, is refused. Which reports disqualify is selectable: any report ever filed, only the most recent one so that a later remediation clears an earlier revocation, or a predicate of the caller's own. An unrecognized status is ignored by default, as the specification requires, or treated as disqualifying on request.
|
|
327
339
|
- Authenticators that carry no aaguid are covered too. A U2F authenticator declares no model identity, and the catalogue keys it by the key identifiers of its attestation certificates instead; both key spaces are indexed and looked up, so a U2F registration binds to its registered roots and is not refused as unlisted. The identifier is read from the entry and from its metadata statement, since live entries populate both, and is computed as RFC 5280 sec. 4.2.1.2 method 1 defines it. A compound attestation is covered as well: its elements carry independent trust paths, and every one of them must reach a registered root.
|
|
328
340
|
- Which identifier is allowed to select an entry depends on what the attestation signature covers. The fido-u2f signature is computed over named fields and does not include the aaguid, so for that format the certificate decides and the declared aaguid is ignored; otherwise setting it to a listed model that shares the vendor's registered root would resolve to that model's entry and skip the real one's status reports. For relying parties: res.aaguid reports what the authenticator presented and is not signature-bound for that format; res.metadata.aaguid names the entry that actually matched.
|
|
329
|
-
- Only a catalogue this library verified can decide anything. A metadata result is
|
|
341
|
+
- Only a catalogue this library verified can decide anything. A metadata result is recognized by its provenance. Shape is not enough on its own, so an object restored from a cache is refused as a catalogue. It has been through none of the signature and chain checks, and its contents are whatever an attacker able to write that cache chose. Re-verify the BLOB instead, which the freshness rule asks for anyway. The verified result is also frozen. The catalogue that decides a later verification is therefore the one the signature covered, unedited.
|
|
330
342
|
- A stored attestation is anchored at the instant its own format judged it. An android-safetynet response carries its signing time and its service chain has usually expired since, so the metadata anchor check reuses that instant in place of the current clock; otherwise it would refuse the very registration the format verifier had just accepted. An explicit opts.time still takes precedence.
|
|
331
343
|
- Status reports are read against the instant being judged: a report dated in the future has not taken effect, so it cannot displace a revocation that is in force now, and a deliberately historical verification does not see reports filed after the time it asks about.
|
|
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
|
|
344
|
+
- A status report that names a single certificate is judged against the certificate actually presented. A whole batch of authenticators is commonly listed under one entry, so a key-compromise report naming one attestation certificate denies only that one, whatever else the entry covers; a report that names nothing, or names something that does not decode, still applies to the entry as a whole. Trust anchors are recognized by name and public key. Self-issuedness is not the test, so a chain terminating in a cross-signed form of a root you supplied still anchors to it.
|
|
333
345
|
- The BLOB's own signing certificate must be permitted to sign. A certificate that carries a key-usage extension omitting digitalSignature is refused before its key is used to check the BLOB signature, so a certificate restricted to some other purpose cannot confer metadata-signing authority just because it chains to the root you supplied.
|
|
334
346
|
- pki.webauthn.metadataFor looks an entry up against a verified result only, never raw bytes, so a lookup cannot be answered out of a catalogue nobody verified. It takes either identifier (an aaguid or an attestation-certificate key identifier), dispatching on the form, which are disjoint by shape. pki.webauthn.metadataAnchors decodes an entry's registered attestation roots one entry at a time, since a handful of certificates in the live metadata do not parse under a strict decoder and decoding everything up front would let one vendor's malformed root refuse the entire catalogue for every other authenticator in it.
|
|
335
347
|
|
|
336
348
|
### Changed
|
|
337
349
|
|
|
338
|
-
- pki.webauthn.verify now rejects an
|
|
339
|
-
- Configuration objects across certificate, CRL, attribute-certificate, CSR, and CMP issuance now reject an
|
|
350
|
+
- pki.webauthn.verify now rejects an unrecognized option key, where it used to ignore one, and validates opts.time at the boundary. Every option it takes either gates the verdict or supplies the trust material a gate needs, so a misspelled key was not harmless: asking for metadata enforcement and mistyping the key left the gate switched off and returned a pass the caller believed had been checked against the catalogue. An invalid time is now reported as the configuration fault it is, and no longer surfaces later as an authenticator trust failure.
|
|
351
|
+
- Configuration objects across certificate, CRL, attribute-certificate, CSR, and CMP issuance now reject an unrecognized option key through one shared check, replacing a dozen separate ones. Two cases that a hand-written check gets wrong are fixed everywhere at once: a key that every JavaScript object inherits, such as constructor or toString, is no longer accepted as a recognized option name, and an option object built by parsing JSON that carries its own __proto__ key is now inspected; it used to be skipped. The wording of every rejection is unchanged. This matters because the failure is silent in the quietest possible way: a misspelled option key leaves the default in force, so a caller who asked for a stricter check gets the looser behavior and no error anywhere.
|
|
340
352
|
|
|
341
353
|
### Fixed
|
|
342
354
|
|
|
@@ -351,7 +363,7 @@ A TPM attestation now reports the credential key's own object attributes and acc
|
|
|
351
363
|
- A verified TPM attestation reports the credential key's object attributes as named flags, the raw attribute word, and the key's access-policy digest. Previously both fields were read past and discarded, so a relying party that wanted to know whether the key was bound to its TPM had to re-parse the public area itself.
|
|
352
364
|
- opts.tpmPolicy requires any of those attributes by name, in either direction, since an attribute may be required set or required clear; it refuses the attestation naming which one disagreed. A single profile, hardware-bound, is the shorthand for the six attributes every genuine attestation examined agrees on: the key is bound to one TPM and one parent, the TPM generated it, it can sign, and it is neither a restricted key nor an X.509 signing key. An explicit attribute layers over the profile, so one flag can be overridden without losing the rest. Three attributes are deliberately absent from it, because they differ across genuine authenticators and requiring any of them would reject working hardware.
|
|
353
365
|
- The policy also covers the key's access policy: requiring that one is present at all and is not the empty policy, and requiring it to be one of an allow-list of digests, compared in constant time. Two structural opt-ins are available for callers who want them: rejecting an attribute word that sets a bit the specification reserves, and rejecting attribute combinations the specification does not define.
|
|
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
|
|
366
|
+
- A mistyped policy is refused when it is set: an unknown key at any level, an unknown profile, an unknown attribute name, a non-boolean value, or an allow-list entry that is not a certificate digest all fail immediately, so a typo can never silently disable the check a caller believes they enabled. Names that every JavaScript object inherits are not accepted as policy names either, since a lookup would otherwise report them as recognized and leave the policy applying nothing. Requiring that the TPM generated the key without also requiring the key be non-duplicable is refused for the same reason. On its own it establishes nothing, because the key could have been imported.
|
|
355
367
|
- Requesting a TPM policy also requires an attestation that can satisfy it. The policy is evaluated against the TPM public area, which only a TPM attestation carries, so an attestation in any other format would never reach it, and a relying party that demanded a TPM-bound key would have accepted a credential with no attestation at all. Such a request is now refused up front, naming the format, and a compound attestation qualifies only when it actually contains a TPM statement.
|
|
356
368
|
|
|
357
369
|
### Fixed
|
|
@@ -445,7 +457,7 @@ pki.tls encodes and decodes RFC 8879 compressed certificate messages (the larges
|
|
|
445
457
|
|
|
446
458
|
### Changed
|
|
447
459
|
|
|
448
|
-
- pki.cms.sign and pki.cms.verify compute their message digests through the crypto engine; neither holds a private digest table any more.
|
|
460
|
+
- pki.cms.sign and pki.cms.verify compute their message digests through the crypto engine; neither holds a private digest table any more. Behavior is unchanged; the digest algorithms a signer and a verifier accept are now defined in one place.
|
|
449
461
|
|
|
450
462
|
### Fixed
|
|
451
463
|
|
|
@@ -1400,7 +1412,7 @@ SLH-DSA signatures verify in certification-path validation, and the post-quantum
|
|
|
1400
1412
|
### Fixed
|
|
1401
1413
|
|
|
1402
1414
|
- The shared AlgorithmIdentifier decoder now rejects a present parameters field on the algorithms whose parameters MUST be absent, namely ML-DSA, SLH-DSA, Ed25519, Ed448, X25519, and X448 (RFC 9909 §3, RFC 9814 §4, RFC 9881 §2, RFC 8410 §3), failing closed with a <format>/bad-algorithm-parameters code. Previously a stray explicit NULL or arbitrary bytes in that field were surfaced raw. The rule is enforced once in the shared decoder, so every format that names an algorithm inherits it; a conforming identifier, which omits the field, is unaffected.
|
|
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
|
|
1415
|
+
- Certification-path validation now enforces issuer-key / signature-algorithm consistency for the one-shot families whose public-key OID equals the signature OID (EdDSA, ML-DSA, and SLH-DSA), rejecting a mismatch with a path/algorithm-mismatch reason (RFC 9814 §4). Because the underlying WebCrypto import binds a public key of a different type to the requested algorithm name and verifies with the real key, a certificate or CRL signed by one key type but labeling its signatureAlgorithm as another one-shot type could otherwise validate; the check closes that algorithm-confusion path for both the certificate signature and the CRL revocation checker.
|
|
1404
1416
|
|
|
1405
1417
|
## v0.1.20 — 2026-07-09
|
|
1406
1418
|
|
|
@@ -1580,7 +1592,7 @@ A PKCS#10 certification-request parser joins the pki.schema family.
|
|
|
1580
1592
|
|
|
1581
1593
|
### Changed
|
|
1582
1594
|
|
|
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
|
|
1595
|
+
- C.TIME.ms is renamed to C.TIME.milliseconds, so every C.TIME duration helper now reads as a full word (milliseconds, seconds, minutes, hours, days, weeks). The behavior is unchanged: it still returns an integer millisecond count.
|
|
1584
1596
|
|
|
1585
1597
|
### Security
|
|
1586
1598
|
|
|
@@ -1650,7 +1662,7 @@ WebCrypto EC key import validates the curve against the key material.
|
|
|
1650
1662
|
|
|
1651
1663
|
### Security
|
|
1652
1664
|
|
|
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
|
|
1665
|
+
- pki.webcrypto.subtle.importKey now derives an imported EC key's named curve from the key material and enforces it across the spki, pkcs8 and jwk formats. Previously it trusted the caller-supplied namedCurve without checking it against the key, so a key on an unsupported curve (for example secp256k1) imported as an approved curve, and a key on one curve could be labeled as another, an algorithm-confusion vector in which the CryptoKey's algorithm disagreed with its key material. A curve the framework does not support is now rejected (NotSupportedError) and a namedCurve that does not match the key is rejected (DataError); generateKey already enforced this, and import now matches it. The raw-key format was already validated against its declared curve and is unchanged.
|
|
1654
1666
|
|
|
1655
1667
|
## v0.1.2 — 2026-07-04
|
|
1656
1668
|
|
package/README.md
CHANGED
|
@@ -225,7 +225,7 @@ comment blocks, is at [pkijs.com](https://pkijs.com).
|
|
|
225
225
|
| `pki.jose` | Flattened JWS (RFC 7515) and JWK thumbprints (RFC 7638). `sign` and `verify` run a Flattened JWS against declarative profiles (ACME outer, EAB inner, keyChange inner) that carry the required and forbidden header rules as data. `base64url` is the strict RFC 4648 §5 codec, rejecting padding, non-alphabet characters, and non-canonical trailing bits. `parseJson` is a bounded reader that refuses duplicate members at any depth. `thumbprint` is the RFC 7638 / 8037 / 9964 canonical digest. The algorithm registry binds each `alg` to its key type (ES/RS/PS/EdDSA/ML-DSA), leaving no code path for `alg:none`, an RS256→HS256 key confusion, or an all-zero ECDSA signature; `assertPublicJwk` refuses a JWK carrying private material, so an exported private key is never published. `opts.key` names the key a message must be signed under and governs: where the profile also permits an embedded header `jwk`, the two must be the same key — compared as RFC 7638 thumbprints, so member order cannot make equal keys differ — and a disagreement is refused rather than resolved in the message's favor. `keySource` reports which key answered, since a signature checked against a key you named is a different claim from one checked against the key the message carried — `sign`, `verify`, `base64url`, `parseJson`, `thumbprint`, `assertPublicJwk` |
|
|
226
226
|
| `pki.acme` | ACME (RFC 8555 / 8737 / 8738 / 9773). `client(directoryUrl, opts)` is a stateful client driving a live CA directory over `pki.transport`: `newAccount`, `newOrder`, `newAuthz`, `getOrder`, `getAuthorization`, `getChallenge`, `respondToChallenge`, `finalize`, `pollOrder`, `pollAuthorization`, and `downloadCertificate` walk the issuance flow, with `newAuthz` pre-authorizing a single identifier (§7.4.1) and `downloadCertificate` choosing among alternate chains (`Link rel="alternate"`, §7.4.2, via a `selectChain` predicate bounded by `maxAlternates`). `revokeCert` (account-key or certificate-key signed), `keyChange`, `deactivateAccount`, `deactivateAuthorization`, `renewalInfo` (ARI), and `renewalWindow` (the RFC 9773 §4.2/4.3 renewal decision) complete the lifecycle. Every URL is HTTPS only, an explicit trust anchor is required, each request carries a fresh single-use nonce with a bounded badNonce retry, reads are POST-as-GET, polling is bounded and sleeps on a Retry-After via an injectable sleeper capped by a poll count and a total-wait budget, and every response body is size-capped. The transport is injectable via `opts.transport`. Over the message layer it composes resource-object validators (closed status enums, conditional-required fields, unknown fields ignored), the three §7.1.6 state machines, the request builders (newAccount with External Account Binding, newOrder with `replaces`, finalize with a CSR identifier-set match and account-key-reuse rejection, challenge responses, deactivation, revokeCert in both key modes, the keyChange nested JWS, POST-as-GET), the http-01 / dns-01 / tls-alpn-01 challenge computations, the dns and ip identifier validators, and the ARI certID with serial sign-padding preserved — `client`, `validate`, `identify`, `assertTransition`, the builders, `keyAuthorization`, `http01`, `dns01`, `tlsAlpn01Extension`, `verifyTlsAlpn01`, `ariCertId` |
|
|
227
227
|
| `pki.schema.smime` | S/MIME ESS signed-attribute values (RFC 5035 / RFC 8551). `parseSigningCertificate` and `parseSigningCertificateV2` bind a signature to its signing certificate (cert hash, hash algorithm, issuer `GeneralNames` and serial); `parseSmimeCapabilities` decodes the ordered capability list; `decodeAttribute` dispatches a CMS attribute by OID, enforcing the single-value rule and deferring on unknown types. A companion decoder for CMS signed attributes rather than an auto-routed format — `parseSigningCertificate`, `parseSigningCertificateV2`, `parseSmimeCapabilities`, `decodeAttribute` |
|
|
228
|
-
| `pki.cmc` | Build and interpret CMC messages (RFC 5272). `build(spec, signer)` assembles a Full PKI Request across all three request arms (PKCS#10, CRMF, other) and signs it through `pki.cms.sign` under `id-cct-PKIData`. Body-part identifiers are unique across the whole message and never the reserved 0; a caller's clash is refused rather than renumbered, since a control may already reference it. An Identity Proof V2 witness is computed over the `reqSequence` bytes exactly as emitted (§6.2.1 step 1) rather than a re-serialization, a POP Link Witness is emitted only alongside the POP Link Random control §6.3.1.1 requires beside it, and a renewal carries neither Identification nor Identity Proof in either version. `verify(response, sent)` takes what the CA returned plus the state the client retained and reduces it to one terminal outcome: `issued`, `pending`, `confirm-required`, `pop-required`, or `rejected`. It binds the exchange first — Transaction Identifier, the Sender and Recipient Nonce echo compared in constant time and by full value so a truncation cannot match, and the Data Return echo — with each check applying only if the client sent that half, and, once sent, an absent or differing echo being a refusal, which is the replay
|
|
228
|
+
| `pki.cmc` | Build and interpret CMC messages (RFC 5272). `build(spec, signer)` assembles a Full PKI Request across all three request arms (PKCS#10, CRMF, other) and signs it through `pki.cms.sign` under `id-cct-PKIData`. Body-part identifiers are unique across the whole message and never the reserved 0; a caller's clash is refused rather than renumbered, since a control may already reference it. An Identity Proof V2 witness is computed over the `reqSequence` bytes exactly as emitted (§6.2.1 step 1) rather than a re-serialization, a POP Link Witness is emitted only alongside the POP Link Random control §6.3.1.1 requires beside it, and a renewal carries neither Identification nor Identity Proof in either version. `verify(response, sent)` takes what the CA returned plus the state the client retained and reduces it to one terminal outcome: `issued`, `pending`, `confirm-required`, `pop-required`, or `rejected`. It binds the exchange first — Transaction Identifier, the Sender and Recipient Nonce echo compared in constant time and by full value so a truncation cannot match, and the Data Return echo — with each check applying only if the client sent that half, and, once sent, an absent or differing echo being a refusal, which is the replay defense. `bodyPartIDs` extends the same rule to what the response is about: a status reporting on a body part the request never sent is refused, which the transaction and nonce cannot catch, because a server can echo both correctly while answering about a different message. Several status controls are permitted and the worst governs, so a failure cannot hide behind an earlier success; the absence of any status control is success, per §6.1.2. The carrier's signature must verify (§3.2.1.3.4): a conforming SignedData carries its own signer certificate, so the ordinary build-then-verify flow needs nothing extra and the verdict reports `signatureVerified: true`. Where the signer is found nowhere the posture is fail-closed with a named opt-out — supply `certs` with the responder's certificate, or `allowUnverified: true`, in which case the verdict reports `signatureVerified: false`. Doing neither is refused, the opt-out never excuses a signature that is present and wrong, and a carrier with no signer at all is refused outright. The response's own `cmsSequence` and `otherMsgs` come back raw, since a request whose only arm was the other-message form has no certificate to return and §4.1 puts its answer there. Nothing is trusted: issued certificates are read from the CMS certificate bag (§4.2) and surfaced raw for `pki.path.validate`, and a Publish Trust Anchors control is surfaced with `trusted: false` rather than added to a store — `build`, `verify` |
|
|
229
229
|
| `pki.schema.cmc` | Decode CMC messages (RFC 5272 as updated by RFC 6402): a Full PKI Request (`PKIData`) or Full PKI Response (`PKIResponse`) riding inside a CMS SignedData, reached by the encapsulated content type (`id-cct-PKIData` / `id-cct-PKIResponse`). Controls are surfaced in wire order with their values raw, so an unrecognized one is data rather than a fault. Tagged requests decode across all three arms. The status verdicts (`CMCStatusInfo` v1 and `CMCStatusInfoV2`) are collected as an ordered list, and the RFC 6402 two-module `OtherStatusInfo` ambiguity — `pendInfo` and `extendedFailInfo` are both untagged SEQUENCEs in the 1988 module, told apart only by their first element — is resolved by inspection and refused when it cannot be told apart. Body-part identity is unique across the whole message rather than per sequence, 0 is reserved as the reference to the enclosing PKIData, and the `reqSequence` bytes are surfaced exactly as they appeared so an Identity Proof witness is computed over the wire bytes. A companion decoder for CMS content rather than an auto-routed format — `parse`, `parsePkiData`, `parsePkiResponse` |
|
|
230
230
|
| `pki.schema.engine` | The declarative ASN.1 structure-schema engine every format parser composes — `walk`, `encode`, `embeddedDer`, and the schema combinators |
|
|
231
231
|
| `pki.path` | Certification-path validation (RFC 5280 §6). `validate` runs the §6.1 state machine over an ordered path and a trust anchor: signature chaining across RSA, ECDSA, EdDSA, ML-DSA, SLH-DSA and hybrid composite ML-DSA (a composite is accepted only when both its post-quantum and traditional components verify), validity windows, name chaining, basic constraints and path length, key usage, name constraints, and the certificate-policy tree. It returns a structured verdict with per-check reason codes and enforces a `pki.trust` anchor's per-purpose distrust-after dates and delegator purposes through `checkPurpose` — an anchor that carries either one with no purpose named to select by is refused as a configuration fault rather than validated as though it carried none. The verdict says what was established. `revocationChecked` takes the weakest outcome on the path: `false` with no checker supplied, `"determined"` when every certificate got an explicit good or revoked answer, `"waived"` when `softFail` turned an undetermined one into a pass, and `"undetermined"` when one could not be answered at all — which includes a checker that throws, a fault `softFail` does not waive because `softFail` opts into an undetermined answer, not into a broken checker. `anchorConstraints` names the purpose the anchor was judged under and whether each of its two constraints applied. `crlChecker` supplies CRL-based revocation, covering partitioned and sharded CRLs, whose §6.3.3 Distribution Point ↔ IDP correspondence lets corresponding shards accumulate reason coverage until all eight revocation reasons are covered, and delta CRLs, merged onto the complete CRL they may be combined with (§5.2.4) so a held certificate its delta releases reads good, while a delta that merges with nothing still reports what it lists and still withholds good. `ocspChecker` supplies OCSP-based revocation (RFC 6960: CertID binding, responder authorization, signature, currency) over the same pluggable hook. `build(leaf, opts)` is the discovering complement (RFC 4158): from a leaf, an untrusted pool of candidate CA certificates, and a trust store, it finds the ordered leaf-to-anchor path `validate` accepts, using name chaining plus the RFC 4158 §3.5 sort hints (an AKI/SKI match, an anchor-adjacent issuer, CA plus keyCertSign, validity at the check time — ordering hints, never filters), a depth-first search with backtracking so the first accepted path wins, and a bounded search (chain-length cap, candidate-expansion cap, identity-tuple visited set) so a cross-certificate cycle or Bridge-CA fan-out terminates deterministically. Every accept flows through `validate`, and its verdict is cross-checked against `openssl verify`. Opt-in AIA `caIssuers` fetching (`opts.fetchAia: true`) discovers a missing intermediate from a certificate's Authority Information Access URL (§4.2.2.1) over `pki.transport`, triggered only on a pool miss and bounded against SSRF and amplification: HTTPS only, a total fetch budget that caps fetching silently rather than throwing, a per-cert URL cap, a build-wide URL dedupe, a response-size and certificate-count cap, no redirect following, and every fault a silent skip. The TLS trust (`opts.tls`) stays distinct from the PKI `trustAnchors`, and every fetched certificate remains untrusted pool material that still flows through `validate` (never a trust anchor). It is off by default, so the default build is byte-identical offline. Pure and re-entrant — `validate`, `build`, `crlChecker`, `ocspChecker` |
|
package/lib/acme.js
CHANGED
|
@@ -2024,7 +2024,7 @@ function client(directoryUrl, opts) {
|
|
|
2024
2024
|
if (!authz.identifier || authz.identifier.type !== canon.type || authz.identifier.value !== canon.value || authz.wildcard === true) throw E("acme/identifier-mismatch", "the returned authorization does not match the requested identifier (RFC 8555 sec. 7.4.1)");
|
|
2025
2025
|
// A pre-authorization's status "MUST be 'pending' unless the server has out-of-band information about the
|
|
2026
2026
|
// client's authorization status" (sec. 7.4.1): the normal case is a fresh "pending" authz the caller then
|
|
2027
|
-
//
|
|
2027
|
+
// fulfills, but the CA MAY instead return an already-"valid" authz (the identifier is already authorized for
|
|
2028
2028
|
// this account -- no challenge to answer). Both are usable; only a terminal failed state ("invalid"/
|
|
2029
2029
|
// "deactivated"/"expired"/"revoked") is not the authorization the pre-auth flow can proceed with.
|
|
2030
2030
|
if (authz.status !== "pending" && authz.status !== "valid") throw E("acme/unexpected-authorization-status", "newAuthz returned an authorization that is neither pending nor already valid (status " + JSON.stringify(authz.status) + "); RFC 8555 sec. 7.4.1");
|
package/lib/cmc-build.js
CHANGED
|
@@ -83,7 +83,7 @@ var KNOWN_SPEC_KEYS = {
|
|
|
83
83
|
// The exchange binding (RFC 5272 sec. 6.6 / 6.4). First-class here because
|
|
84
84
|
// pki.cmc.verify names these same three when checking the response: a builder
|
|
85
85
|
// that made the caller hand-encode them while the verifier took them by name is
|
|
86
|
-
// the asymmetry that lets a request ship with no replay
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
291
|
+
* echo is a refusal: that asymmetry is the replay defense. `bodyPartIDs` is the same rule applied
|
|
292
292
|
* to what the response is ABOUT -- a status naming a body part the request never sent is refused
|
|
293
293
|
* with `cmc/body-part-unknown`, which the transaction and nonce cannot catch, since a server can
|
|
294
294
|
* echo both correctly while reporting on something else.
|
|
@@ -429,7 +429,7 @@ function _snapshotSent(sent) {
|
|
|
429
429
|
// integer through. A Transaction Identifier is an unbounded INTEGER on the wire,
|
|
430
430
|
// so one above Number.MAX_SAFE_INTEGER has already been rounded by the time it
|
|
431
431
|
// arrives as a `number` -- 9007199254740993 is 9007199254740992 before this code
|
|
432
|
-
// sees it, and a response echoing that
|
|
432
|
+
// sees it, and a response echoing that NEIGHBORING identifier would compare
|
|
433
433
|
// equal. Held here rather than converted, so a value too large to be a number is
|
|
434
434
|
// refused with the shape to use instead (a bigint) rather than silently bound to
|
|
435
435
|
// something adjacent.
|
|
@@ -599,7 +599,7 @@ function _assertAuthentic(body, sent, responseBytes) {
|
|
|
599
599
|
.then(function (res) {
|
|
600
600
|
if (res.valid) return true;
|
|
601
601
|
// EVERY failing signer, not just the first. A response may carry several,
|
|
602
|
-
// and the opt-out may only be
|
|
602
|
+
// and the opt-out may only be honored when none of them was actually
|
|
603
603
|
// checked and rejected: one signer whose certificate is missing must not
|
|
604
604
|
// let a DIFFERENT signer's failed signature through beside it.
|
|
605
605
|
var failed = (res.signers || []).filter(function (s) { return !s.ok; });
|
|
@@ -710,7 +710,7 @@ function _shape(body, sent, signatureVerified, bound) {
|
|
|
710
710
|
* `spec.transactionId` (number|bigint), `spec.senderNonce` and `spec.dataReturn` (bytes) attach the
|
|
711
711
|
* exchange-binding controls (RFC 5272 sec. 6.6 / 6.4), the same three `pki.cmc.verify` checks the
|
|
712
712
|
* response against. They are named fields, not something to hand-encode into
|
|
713
|
-
* `spec.controls`, because a request that quietly omits them has no replay
|
|
713
|
+
* `spec.controls`, because a request that quietly omits them has no replay defense and neither end
|
|
714
714
|
* can tell: the verifier only enforces the halves the client says it sent. An unrecognized spec
|
|
715
715
|
* field is refused for the same reason: a misspelling would otherwise build and sign a message
|
|
716
716
|
* that simply does not carry what was asked for.
|
package/lib/cms-decrypt.js
CHANGED
|
@@ -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;
|
|
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
|
|
744
|
+
// ktri (this producer emits OAEP); its behavior is identical to the tested below-floor path (a
|
|
745
745
|
// random key -> the MAC verify fails uniformly), so the < 16 vector covers the substitution.
|
|
746
746
|
// _normKeyDer(key) -> { der, owned }: the recipient private key as PKCS#8 DER, and whether the
|
|
747
747
|
// buffer is one this module made.
|
package/lib/cms-verify.js
CHANGED
|
@@ -579,7 +579,7 @@ function _verifyOneCountersig(vDer, targetSig, parsedCerts) {
|
|
|
579
579
|
// Coverage residual, and deliberately kept. Every value reaching here was already walked by the
|
|
580
580
|
// decoder, which validates an id-countersignature by content and not by the attribute type. A
|
|
581
581
|
// malformed one therefore refused the whole message before verify was entered, so this catch
|
|
582
|
-
// cannot be reached through the public path. That is settled
|
|
582
|
+
// cannot be reached through the public path. That is settled behavior: a message the decoder
|
|
583
583
|
// has found to be malformed has no sound remainder to report a verdict over, so it is refused
|
|
584
584
|
// whole instead of surfaced as one failed countersignature.
|
|
585
585
|
// cms-verify.test.js pins that through the shipped verbs; this stays because the walk is also
|
|
@@ -744,7 +744,7 @@ function _verify(input, opts) {
|
|
|
744
744
|
// read a promise turn later, and everything in it stays caller-owned across that gap: the array
|
|
745
745
|
// can be re-pointed, an anchor's DER rewritten, the validation instant moved. Any of those would
|
|
746
746
|
// have the chain judged against a configuration the caller never asked for while the verdict
|
|
747
|
-
// reports the one they did. The same
|
|
747
|
+
// reports the one they did. The same defense the input and the certificates already have.
|
|
748
748
|
var trustCfg = _snapshotTrust(opts);
|
|
749
749
|
return Promise.all(parsed.signerInfos.map(function (si) {
|
|
750
750
|
// res.valid reflects only the PRIMARY signerInfos; a countersignature / unsigned attribute is
|
package/lib/constants.js
CHANGED
|
@@ -165,7 +165,7 @@ var LIMITS = {
|
|
|
165
165
|
// is copied/tokenized so a hostile server cannot drive unbounded work parsing it
|
|
166
166
|
// (CWE-770). 8 KiB clears any realistic multi-scheme Digest challenge.
|
|
167
167
|
HTTP_AUTH_HEADER_MAX_BYTES: BYTES.kib(8),
|
|
168
|
-
// Deterministic-CBOR codec ceilings (RFC 8949), the DER
|
|
168
|
+
// Deterministic-CBOR codec ceilings (RFC 8949), the DER neighbors' siblings:
|
|
169
169
|
// a whole-document cap refused before the walk, a nesting cap, and a per-value
|
|
170
170
|
// bignum ceiling the document cap can't provide. Unlike DER_MAX_INTEGER_BYTES,
|
|
171
171
|
// the bignum cap carries NO +1 sign octet -- a CBOR tag-2/3 bignum body is pure
|
package/lib/est.js
CHANGED
|
@@ -1231,7 +1231,7 @@ function fullcmc(baseUrl, request, opts) {
|
|
|
1231
1231
|
_knownOpts(opts, FULLCMC_OPTS, "fullcmc");
|
|
1232
1232
|
der = _cmcRequestDer(request);
|
|
1233
1233
|
// Confirm this IS a Full PKI Request before any of it goes over the wire. The
|
|
1234
|
-
// bytes are about to be
|
|
1234
|
+
// bytes are about to be labeled `smime-type=CMC-request`, so a PKIResponse or
|
|
1235
1235
|
// an unparseable blob handed in by mistake would be POSTed to a CA under a
|
|
1236
1236
|
// label that does not describe it -- and, since only an `issued` outcome is
|
|
1237
1237
|
// correlated, could still come back as a pending or rejected verdict, making
|
|
@@ -1567,7 +1567,7 @@ function _assertAgrees(name, supplied, carried) {
|
|
|
1567
1567
|
} else {
|
|
1568
1568
|
// Through the shared authoring guard, not a bare BigInt(): a `number` above
|
|
1569
1569
|
// Number.MAX_SAFE_INTEGER has already lost the digits that distinguish it from
|
|
1570
|
-
// its
|
|
1570
|
+
// its neighbor, so converting it here would compare a value the caller never
|
|
1571
1571
|
// wrote. A transaction identifier is an unbounded INTEGER on the wire; large
|
|
1572
1572
|
// ones are passed as a bigint, and this says so rather than rounding.
|
|
1573
1573
|
same = carried != null &&
|
|
@@ -1671,7 +1671,7 @@ function _fullcmcResult(res, opts, wanted, sent) {
|
|
|
1671
1671
|
|
|
1672
1672
|
// FC5 admits two smime-types, and the LABEL must agree with the BYTES: a
|
|
1673
1673
|
// certs-only label over a Full PKI Response (or the reverse) is a server
|
|
1674
|
-
//
|
|
1674
|
+
// mislabeling its own body, and accepting either shape under either label
|
|
1675
1675
|
// would make the content-type check decorative.
|
|
1676
1676
|
// Through the shared case-insensitive accessor: HTTP header names are
|
|
1677
1677
|
// case-insensitive (RFC 9110 sec. 5.1), the classifier already reads them that
|
package/lib/guard-all.js
CHANGED
|
@@ -11,35 +11,35 @@
|
|
|
11
11
|
// itself, with no guard re-implemented inline:
|
|
12
12
|
//
|
|
13
13
|
// guard.bytes.view / .source -- untrusted byte-source -> Buffer re-view
|
|
14
|
-
// (detached-buffer fail-open
|
|
14
|
+
// (detached-buffer fail-open defense)
|
|
15
15
|
// guard.text.decode -- byte-source -> string, cap before copy
|
|
16
|
-
// (parser-DoS string-allocation
|
|
16
|
+
// (parser-DoS string-allocation defense)
|
|
17
17
|
// guard.limits.cap / .depthCap -- config-time resource-cap validation
|
|
18
|
-
// (recursion / allocation DoS
|
|
18
|
+
// (recursion / allocation DoS defense)
|
|
19
19
|
// guard.crypto.constantTimeEqual -- length-checked constant-time compare
|
|
20
|
-
// (timing side-channel
|
|
20
|
+
// (timing side-channel defense)
|
|
21
21
|
// guard.range.int / .uint31 / .positiveInt31
|
|
22
22
|
// -- bound a decoded integer before narrowing
|
|
23
|
-
// to Number (silent-narrowing
|
|
23
|
+
// to Number (silent-narrowing defense)
|
|
24
24
|
// guard.time.assertValid / .within -- validate a Date-instant before a temporal
|
|
25
|
-
// comparison (NaN-Date fail-open
|
|
25
|
+
// comparison (NaN-Date fail-open defense)
|
|
26
26
|
// guard.name.dnEqual / .rdnEqual / .assertNoControlBytes / .assertPrintableIa5
|
|
27
27
|
// -- canonical DN identity + name-string integrity
|
|
28
28
|
// guard.name.escapeControlBytes / .escapeDnValue
|
|
29
29
|
// -- render-side name safety: control-byte + RFC 4514 escaping
|
|
30
|
-
// (CVE-2009-2408 truncation / identity
|
|
30
|
+
// (CVE-2009-2408 truncation / identity defense)
|
|
31
31
|
// guard.encoding.base64url / .base64 / .hex
|
|
32
32
|
// -- strict textual-encoding decode, canonical
|
|
33
|
-
// + capped (encoding-malleability
|
|
33
|
+
// + capped (encoding-malleability defense)
|
|
34
34
|
// guard.identifier.assertCanonicalOid
|
|
35
35
|
// -- canonical dotted-decimal OID string form
|
|
36
|
-
// (canonicalization-divergence
|
|
36
|
+
// (canonicalization-divergence defense)
|
|
37
37
|
// guard.compress.bounded -- decompress an untrusted stream under a hard
|
|
38
38
|
// output cap, whole-input (decompression-bomb +
|
|
39
|
-
// trailing-frame malleability
|
|
39
|
+
// trailing-frame malleability defense)
|
|
40
40
|
// guard.header.assertField -- emitted MIME/RFC 5322 header field name +
|
|
41
41
|
// value integrity (CR/LF/NUL header-injection
|
|
42
|
-
//
|
|
42
|
+
// defense, CWE-93)
|
|
43
43
|
// guard.parsed.accept -- a CLAIMED-parsed structure carries every
|
|
44
44
|
// field the consuming code dereferences
|
|
45
45
|
// (type confusion / unverified provenance,
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
// token of a guard (the Buffer.from(x.buffer, byteOffset) re-view, the
|
|
50
50
|
// timingSafeEqual call, the MAX_DECODE_DEPTH_CEILING check) must appear only in
|
|
51
51
|
// its guard module, so a new boundary cannot re-inline the shape and forget the
|
|
52
|
-
//
|
|
52
|
+
// defense.
|
|
53
53
|
|
|
54
54
|
var bytes = require("./guard-bytes");
|
|
55
55
|
var text = require("./guard-text");
|
package/lib/guard-bytes.js
CHANGED
|
@@ -11,7 +11,7 @@ var async = require("./guard-async");
|
|
|
11
11
|
// guard-bytes -- fail-closed coercion of an untrusted byte-source input to a
|
|
12
12
|
// Buffer view. One of the enforced choke points of the guard family: a
|
|
13
13
|
// codebase-patterns detector requires every byte-input boundary to route
|
|
14
|
-
// through here, so the
|
|
14
|
+
// through here, so the defense below cannot be forgotten at a new boundary.
|
|
15
15
|
//
|
|
16
16
|
// Defends the detached-buffer fail-OPEN: a transferred / structuredClone'd
|
|
17
17
|
// Buffer or view has a detached backing ArrayBuffer and reads as ZERO-LENGTH.
|
|
@@ -85,7 +85,7 @@ function source(input, ErrorClass, code, label) {
|
|
|
85
85
|
|
|
86
86
|
// snapshot(input, ErrorClass, code, label) -> private Buffer copy | throws ErrorClass
|
|
87
87
|
//
|
|
88
|
-
// The parse-then-verify time-of-check/time-of-use
|
|
88
|
+
// The parse-then-verify time-of-check/time-of-use defense. A verification entry
|
|
89
89
|
// point that PARSES its input synchronously and then VERIFIES a signature over
|
|
90
90
|
// the same bytes in a later promise turn is reading the caller's memory twice
|
|
91
91
|
// with an await in between. Every byte range the parse surfaced (the signed
|
|
@@ -108,7 +108,7 @@ function snapshot(input, ErrorClass, code, label) {
|
|
|
108
108
|
}
|
|
109
109
|
|
|
110
110
|
// snapshotSource(input, ErrorClass, code, label) -> private Buffer copy | throws
|
|
111
|
-
// The same parse-then-verify
|
|
111
|
+
// The same parse-then-verify defense as `snapshot`, over the whole W3C BufferSource
|
|
112
112
|
// (raw ArrayBuffer / DataView / any typed-array view) and not only the
|
|
113
113
|
// Buffer / Uint8Array contract. A parser that accepts a BufferSource must snapshot
|
|
114
114
|
// the same set: leaving an ArrayBuffer or a DataView aliased reopens the window for
|
|
@@ -360,7 +360,7 @@ function _copyEntries(src, dst, ErrorClass, code, label, cap, depth, collect) {
|
|
|
360
360
|
// caller passed disappears from Object.keys and an unknown-option check walks a different object
|
|
361
361
|
// than the verb goes on to read. Shadowing matters for the same reason it is done at all: an own
|
|
362
362
|
// copy is what makes an inherited value stop tracking the caller's prototype. A method is left
|
|
363
|
-
// where it is, since copying a function would only move it, and it is the prototype's
|
|
363
|
+
// where it is, since copying a function would only move it, and it is the prototype's behavior,
|
|
364
364
|
// not the caller's data.
|
|
365
365
|
function _copyNamed(src, dst, ErrorClass, code, label, cap, depth, collect) {
|
|
366
366
|
var keys = _namesToCopy(src, dst);
|
package/lib/guard-compress.js
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
// unboundedly many encodings, and a digest over the compressed object would
|
|
23
23
|
// stop identifying what it decompresses to.
|
|
24
24
|
//
|
|
25
|
-
// Both
|
|
25
|
+
// Both defenses are applied here, once, for every algorithm, so a new consumer
|
|
26
26
|
// cannot pick up one and miss the other. Verified uniform across zlib, brotli
|
|
27
27
|
// and zstd: each reports consumed input as `engine.bytesWritten` under
|
|
28
28
|
// `info: true`, each raises ERR_BUFFER_TOO_LARGE on a cap breach, and each
|
|
@@ -125,7 +125,7 @@ function bounded(algorithm, stream, cap, E, codes, label) {
|
|
|
125
125
|
throw new TypeError("guard.compress.bounded: stream must be a Buffer (route the input through guard.bytes.view first)");
|
|
126
126
|
}
|
|
127
127
|
// The ceiling is an authoring input: an undefined / NaN / fractional / negative
|
|
128
|
-
// cap would silently disable the bomb
|
|
128
|
+
// cap would silently disable the bomb defense. It must also be at least 1 --
|
|
129
129
|
// node rejects maxOutputLength 0 with a different fault than a cap breach, so a
|
|
130
130
|
// zero cap would surface as a malformed-stream verdict instead of a config error.
|
|
131
131
|
if (!Number.isInteger(cap) || cap < 1) {
|
package/lib/guard-der.js
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
// Every entry point takes the CALLER's error factory and code, so a c509 splice reports in
|
|
27
27
|
// the c509 domain and a certificate builder in the x509 domain.
|
|
28
28
|
// asn1-der composes this guard family, so requiring the codec at module scope would read its
|
|
29
|
-
// exports mid-
|
|
29
|
+
// exports mid-initialization and see an empty object. The require and the table it builds are
|
|
30
30
|
// therefore both deferred to first use -- the documented circular-load exception to top-of-file
|
|
31
31
|
// requires. Guards sit BELOW the codec in the dependency order; this is the one place that
|
|
32
32
|
// order is inverted, and it is inverted lazily.
|
package/lib/guard-identifier.js
CHANGED
|
@@ -84,9 +84,9 @@ function assertCanonicalOid(str, E, code, label, boundsCode) {
|
|
|
84
84
|
// The shape this replaces was hand-written in a dozen callers, and it is worth one home because
|
|
85
85
|
// getting it wrong fails open in the quietest possible way: an options object whose key is
|
|
86
86
|
// misspelled silently carries the default, so a caller who asked for a stricter check gets the
|
|
87
|
-
// looser
|
|
87
|
+
// looser behavior and no error. Two details are easy to lose in a re-inline and are fixed here.
|
|
88
88
|
// `known` is consulted with hasOwnProperty and not a truthiness test, so an inherited Object
|
|
89
|
-
// member ("constructor", "toString") cannot read as a
|
|
89
|
+
// member ("constructor", "toString") cannot read as a recognized key; and the walk is over own
|
|
90
90
|
// enumerable keys, so a `__proto__` arriving from JSON is inspected instead of skipped.
|
|
91
91
|
//
|
|
92
92
|
// @enforced-by behavioral -- an options-shape walk has no rename-proof code shape; the RED vectors
|
|
@@ -99,7 +99,7 @@ function assertCanonicalOid(str, E, code, label, boundsCode) {
|
|
|
99
99
|
//
|
|
100
100
|
// @enforced-by guard-shape-reinlined
|
|
101
101
|
// The shape requires the throw: an identical walk whose body filters on the same table (copying the
|
|
102
|
-
//
|
|
102
|
+
// recognized keys onward instead of rejecting the unrecognized ones) is a different operation with
|
|
103
103
|
// no fail-open risk, and must not be dragged through a guard that only knows how to reject.
|
|
104
104
|
// @guard-shape Object\.keys\(\w+(?:\.\w+)*\)\.forEach\(function \((\w+)\) \{\s*if \(![\w.]+\[\1\]\) (?:\{\s*)?throw
|
|
105
105
|
function assertKnownKeys(obj, known, E, code, message) {
|
package/lib/guard-limits.js
CHANGED
|
@@ -90,7 +90,7 @@ function depthCap(value, key, dflt) {
|
|
|
90
90
|
function counter(max, E, code, label) {
|
|
91
91
|
// The ceiling is an authoring input: an undefined / NaN / fractional max
|
|
92
92
|
// builds a counter whose `n > max` never fires, leaving a silently dead fanout
|
|
93
|
-
//
|
|
93
|
+
// defense. Reject at construction (config-time TypeError).
|
|
94
94
|
if (!Number.isInteger(max) || max < 0) {
|
|
95
95
|
throw new TypeError("guard.limits.counter: max must be a non-negative integer");
|
|
96
96
|
}
|
|
@@ -117,7 +117,7 @@ function counter(max, E, code, label) {
|
|
|
117
117
|
// over-size RED vectors driving the composing verifiers are the guard.
|
|
118
118
|
function byteCap(buf, max, E, code, label) {
|
|
119
119
|
// The ceiling is an authoring input: an undefined / NaN / fractional / negative
|
|
120
|
-
// max makes `length > max` never fire, and the size
|
|
120
|
+
// max makes `length > max` never fire, and the size defense is then silently
|
|
121
121
|
// dead. Reject at config time (TypeError), regardless of the value currency E selects.
|
|
122
122
|
if (!Number.isInteger(max) || max < 0) {
|
|
123
123
|
throw new TypeError("guard.limits.byteCap: max must be a non-negative integer");
|
package/lib/guard-parsed.js
CHANGED
|
@@ -44,7 +44,7 @@ var bytes = require("./guard-bytes");
|
|
|
44
44
|
// ArrayBuffer. Testing `Buffer.isBuffer(x) || x instanceof Uint8Array` is narrower than what the
|
|
45
45
|
// verbs document, and the narrowness is invisible in the good case: an ArrayBuffer would fall
|
|
46
46
|
// through to the object branch and be refused as a rebuilt structure, which reads to the caller as
|
|
47
|
-
// "your certificate is malformed" when what happened is that their container was not
|
|
47
|
+
// "your certificate is malformed" when what happened is that their container was not recognized.
|
|
48
48
|
// guard.bytes is what finally reads them, and it accepts all four, so the door has to as well.
|
|
49
49
|
function _isBytes(x) {
|
|
50
50
|
return Buffer.isBuffer(x) || ArrayBuffer.isView(x) || x instanceof ArrayBuffer;
|
package/lib/guard-range.js
CHANGED
|
@@ -105,7 +105,7 @@ function uint64(value, E, code, label) {
|
|
|
105
105
|
// take. Accepts a bigint, or a Number that is a safe integer (isSafeInteger, not
|
|
106
106
|
// isInteger, because a Number above 2^53 has already lost precision by the time it
|
|
107
107
|
// arrives), so a large identifier MUST come as a bigint and cannot be silently
|
|
108
|
-
// rounded to a
|
|
108
|
+
// rounded to a neighbor. Everything else is a config-time reject.
|
|
109
109
|
//
|
|
110
110
|
// Distinct from int() on purpose: int() BOUNDS a value the wire decoded and narrows
|
|
111
111
|
// it to Number; this one is unbounded and returns BigInt, because the field has no
|
package/lib/guard-secret.js
CHANGED
|
@@ -72,7 +72,7 @@ function zeroize(value, ErrorClass, code, label) {
|
|
|
72
72
|
// @enforced-by behavioral -- this is a loop over zeroize, which carries the family's only
|
|
73
73
|
// rename-proof shape (the `.fill(0)` above). It introduces no shape of its own, so a lexical
|
|
74
74
|
// detector here would anchor on a renameable symbol and go silently green (drift rule sec. 3).
|
|
75
|
-
// The
|
|
75
|
+
// The behavioral guards are guard-secret.test.js (holes tolerated, every member cleared) and the
|
|
76
76
|
// CMS vectors that assert the shared secret and KEK are wiped on both the success and failure paths.
|
|
77
77
|
function zeroizeAll(list, ErrorClass, code, label) {
|
|
78
78
|
if (!list) return list;
|
package/lib/guard-text.js
CHANGED
|
@@ -50,7 +50,7 @@ function decode(input, maxBytes, ErrorClass, spec) {
|
|
|
50
50
|
if (Buffer.isBuffer(input)) {
|
|
51
51
|
// Re-view through the byte guard first so a detached backing ArrayBuffer (a
|
|
52
52
|
// transferred / structuredClone'd Buffer, which reads as zero-length) fails
|
|
53
|
-
// closed here -- the same detached-buffer
|
|
53
|
+
// closed here -- the same detached-buffer defense the byte boundaries get --
|
|
54
54
|
// instead of being decoded as an empty string. Then cap, then decode.
|
|
55
55
|
input = bytes.view(input, ErrorClass, spec.badInput, spec.label);
|
|
56
56
|
if (input.length > maxBytes) throw new ErrorClass(spec.tooLarge, spec.label + " exceeds the size cap");
|
package/lib/hpke.js
CHANGED
|
@@ -390,7 +390,7 @@ function _recipPrivate(suite, sk) {
|
|
|
390
390
|
// psk / auth / auth-psk. An unknown mode must fail closed, never key-schedule
|
|
391
391
|
// with an out-of-registry mode byte.
|
|
392
392
|
// The sender and the recipient read the same option object from OPPOSITE ends, so each direction
|
|
393
|
-
// gets its own table. A shared union
|
|
393
|
+
// gets its own table. A shared union recognizes every name at both ends and so accepts the one that
|
|
394
394
|
// cannot do anything there: `senderPublicKey` handed to setupS, or
|
|
395
395
|
// `senderKey` handed to setupR, is silently ignored, which is the exact silence these tables exist
|
|
396
396
|
// to remove, in a wider form. An option that means nothing where it was passed is a misunderstanding
|
package/lib/http-digest.js
CHANGED
|
@@ -144,7 +144,7 @@ function _validateDigest(paramText, E, code) {
|
|
|
144
144
|
? p.domain.value.replace(/^\s+|\s+$/g, "").split(/\s+/) : null;
|
|
145
145
|
// charset (RFC 7616 sec. 3.3): the one permitted value is the unquoted token "UTF-8". A quoted charset, or any
|
|
146
146
|
// other value, is malformed, since answering it would hash the credentials in the wrong encoding, so it is
|
|
147
|
-
// rejected (and thus skipped during multi-offer selection in
|
|
147
|
+
// rejected (and thus skipped during multi-offer selection in favor of a conforming offer).
|
|
148
148
|
if (p.charset && (p.charset.quoted || String(p.charset.value).toUpperCase() !== "UTF-8")) throw E(code, "the Digest charset must be the unquoted token UTF-8 (RFC 7616 sec. 3.3)");
|
|
149
149
|
// stale (RFC 7616 sec. 3.3) is the unquoted token "true" or "false". A quoted or otherwise invalid value
|
|
150
150
|
// (stale=maybe) is malformed and rejected (thus skipped in selection), never parsed as a false flag that
|
package/lib/jose.js
CHANGED
|
@@ -368,7 +368,7 @@ function assertPublicJwk(jwk) {
|
|
|
368
368
|
// `key` NAMES the key this message must be signed under and `profile` selects which header rules
|
|
369
369
|
// apply -- both narrow what verifies, so a misspelling of either silently widens it back to the
|
|
370
370
|
// default. That is the shape this check exists for: the caller asked for something stricter and got
|
|
371
|
-
// the looser
|
|
371
|
+
// the looser behavior, with nothing said.
|
|
372
372
|
var _VERIFY_KEYS = { key: 1, profile: 1 };
|
|
373
373
|
async function verify(jws, opts) {
|
|
374
374
|
opts = opts || {};
|
package/lib/lint.js
CHANGED
|
@@ -231,7 +231,7 @@ function _subjectCNs(cert) {
|
|
|
231
231
|
return out;
|
|
232
232
|
}
|
|
233
233
|
|
|
234
|
-
// An extension's criticality is
|
|
234
|
+
// An extension's criticality is honored (RFC 5280 4.2: a consumer MUST reject a critical extension
|
|
235
235
|
// it cannot process) only when this toolkit actually processes its semantics. That authority is
|
|
236
236
|
// path-validate's PROCESSED_EXTENSIONS (the RFC 5280 sec. 6.1 path-processing set), never the decoder
|
|
237
237
|
// table: certExtensionDecoders also decodes many extensions purely for display (qcStatements, the MS
|
package/lib/ocsp.js
CHANGED
|
@@ -530,7 +530,7 @@ function verify(response, opts) {
|
|
|
530
530
|
// the closed direction for `revoked`: revocation does not expire the way
|
|
531
531
|
// non-revocation does, so discarding a signed, current, authorized revoked
|
|
532
532
|
// verdict because it was replayed would hand a soft-fail caller the certificate
|
|
533
|
-
// the responder just refused -- turning the anti-replay
|
|
533
|
+
// the responder just refused -- turning the anti-replay defense into the thing
|
|
534
534
|
// that accepts a revoked certificate. `nonceMatched: false` still reports that
|
|
535
535
|
// this response was not bound to this request.
|
|
536
536
|
if (!matched && verdict.status === "good") {
|
package/lib/oid.js
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
* attribute types, `2.5.29` for the extensions, `2.16.840.1.101.3.4` for
|
|
23
23
|
* the NIST algorithms), and each member names only its trailing arc. The
|
|
24
24
|
* full OID is derived from base + leaf at load, so the arc hierarchy that
|
|
25
|
-
* IS the OID namespace is
|
|
25
|
+
* IS the OID namespace is modeled directly instead of re-spelled per
|
|
26
26
|
* entry. It covers the RFC 5280 attribute types and extensions, the
|
|
27
27
|
* classical signature / public-key / digest algorithms, and the
|
|
28
28
|
* NIST-assigned post-quantum arcs (ML-DSA, ML-KEM, SLH-DSA). Operators
|
package/lib/path-validate.js
CHANGED
|
@@ -306,7 +306,7 @@ function resolveDescriptor(sigAlg) {
|
|
|
306
306
|
// defense). For the one-shot families whose public key shares the signature OID
|
|
307
307
|
// (EdDSA, ML-DSA, SLH-DSA), Node's WebCrypto imports an SPKI of a DIFFERENT type
|
|
308
308
|
// under the requested name and verifies with the real key, so an Ed25519-signed
|
|
309
|
-
// certificate
|
|
309
|
+
// certificate labeled SLH-DSA would otherwise validate. Enforce structurally:
|
|
310
310
|
// the issuer SPKI's algorithm OID MUST equal the signature algorithm OID. (For
|
|
311
311
|
// RSA/ECDSA -- different key vs signature OIDs -- WebCrypto's import already rejects
|
|
312
312
|
// a mismatched key type, so `sameKeyOid` is not set and this is a no-op.)
|
|
@@ -1753,7 +1753,7 @@ function classifyCrls(parsed) {
|
|
|
1753
1753
|
// sec. 5.2.4 @3447 makes deltaCRLIndicator a MUST-be-critical extension. A non-critical one
|
|
1754
1754
|
// is non-conforming, so it does not earn the NEW capability of being merged -- merging can
|
|
1755
1755
|
// RELEASE a certificate, and that must rest on a conforming indicator. It is still classified
|
|
1756
|
-
// as a delta and still consulted for revocation, which is the shipped
|
|
1756
|
+
// as a delta and still consulted for revocation, which is the shipped behavior and the
|
|
1757
1757
|
// conservative direction.
|
|
1758
1758
|
if (crlNumberWithinBound(n) && deltaCritical) { rec.baseCrlNumber = n; rec.mergeable = true; }
|
|
1759
1759
|
} catch (_e) {
|
|
@@ -2393,7 +2393,7 @@ function ocspChecker(responses) {
|
|
|
2393
2393
|
* `pki.schema.ocsp.parseResponse` result. A REBUILT parsed response is refused. A
|
|
2394
2394
|
* signature check has three parts -- the signature, the algorithm that verifies it,
|
|
2395
2395
|
* and the bytes it covers -- and on a parsed object all three are separate properties:
|
|
2396
|
-
* a genuine CA signature over a certificate that CA issued,
|
|
2396
|
+
* a genuine CA signature over a certificate that CA issued, relabeled, verifies as a
|
|
2397
2397
|
* ResponseData signature for a response that never existed. The parser marks what it
|
|
2398
2398
|
* returns, so those three are known to have been derived together from one byte
|
|
2399
2399
|
* string; `Object.assign`, spread and a JSON round-trip all drop the mark, which is
|
|
@@ -2795,7 +2795,7 @@ async function _fetchAiaIssuers(current, aia) {
|
|
|
2795
2795
|
for (var c = 0; c < certs.length; c++) {
|
|
2796
2796
|
var parsed;
|
|
2797
2797
|
try { parsed = coerceCert(certs[c]); }
|
|
2798
|
-
catch (_e3) { /* allow:swallow-unverified verified-unreachable: every cert here already passed the IDENTICAL x509.parse in _aiaParseBody (single-DER validates `body`; certs-only validates each via parseCertsOnly), so coerceCert re-parsing the same bytes cannot throw -- the guard stays as
|
|
2798
|
+
catch (_e3) { /* allow:swallow-unverified verified-unreachable: every cert here already passed the IDENTICAL x509.parse in _aiaParseBody (single-DER validates `body`; certs-only validates each via parseCertsOnly), so coerceCert re-parsing the same bytes cannot throw -- the guard stays as defense-in-depth */ continue; }
|
|
2799
2799
|
out.push(parsed);
|
|
2800
2800
|
}
|
|
2801
2801
|
}
|
|
@@ -3111,7 +3111,7 @@ module.exports = {
|
|
|
3111
3111
|
// The set of extension OIDs whose CRITICAL semantics this validator processes (RFC 5280 sec. 6.1).
|
|
3112
3112
|
// Exposed so a linter can distinguish "processed" from "merely decoded" and stay consistent with
|
|
3113
3113
|
// the path-validation verdict on a critical extension -- a decoder in certExtensionDecoders is NOT
|
|
3114
|
-
// by itself proof the criticality is
|
|
3114
|
+
// by itself proof the criticality is honored. Both sets are frozen so a caller cannot mutate them.
|
|
3115
3115
|
PROCESSED_EXTENSIONS: PROCESSED_EXTENSIONS,
|
|
3116
3116
|
// Extensions that ARE processed for an intermediate CA but are unprocessed on the target/leaf, so a
|
|
3117
3117
|
// critical instance on the target fails closed (RFC 5280 sec. 6.1.5(f)) -- policyMappings is
|
package/lib/schema-c509.js
CHANGED
|
@@ -1650,7 +1650,7 @@ function _sdaToDer(node, isNative) {
|
|
|
1650
1650
|
if (tname === undefined) throw _err("c509/bad-extensions", "a subjectDirectoryAttributes attribute type int " + ti + " has no C509 sec. 8.6 registry row");
|
|
1651
1651
|
// countryName / serialNumber carry a CHARACTER restriction (draft sec. 3.1.4 "SHALL contain only
|
|
1652
1652
|
// characters from the 74-character ASCII subset permitted by PrintableString"), and not a sign override --
|
|
1653
|
-
// _reconAttrValue asserts the charset and
|
|
1653
|
+
// _reconAttrValue asserts the charset and honors the declared string type. Requiring the negative sign
|
|
1654
1654
|
// here would also make these attributes unrepresentable in a NATIVE certificate, whose ints SHALL all be
|
|
1655
1655
|
// non-negative (same sec.), so the rule is the charset, not the sign.
|
|
1656
1656
|
for (var vi = 0; vi < valuesNode.children.length; vi++) {
|
|
@@ -1846,7 +1846,7 @@ function _reconAttrValue(rdn) {
|
|
|
1846
1846
|
if (rdn.type === "emailAddress") return b.ia5(s);
|
|
1847
1847
|
// serialNumber / countryName carry a CHARACTER restriction, not a string-type override: draft sec. 3.1.4
|
|
1848
1848
|
// "SHALL contain only characters from the 74-character ASCII subset permitted by PrintableString". Enforce
|
|
1849
|
-
// that on the CHARACTERS and still
|
|
1849
|
+
// that on the CHARACTERS and still honor the sign for the string type. Coercing them to PrintableString
|
|
1850
1850
|
// regardless of sign would make the +N and -N encodings of one value reconstruct IDENTICAL DER, so a single
|
|
1851
1851
|
// X.509 signature would cover two distinct C509 encodings (a malleability window in the type-3 transform).
|
|
1852
1852
|
// b.printable IS the PrintableString charset authority -- run it for the assert even when the sign selects
|
package/lib/schema-cmp.js
CHANGED
|
@@ -460,7 +460,7 @@ var CERT_RESPONSE = schema.seq([
|
|
|
460
460
|
// accepted (0) or grantedWithMods (1). Any other status (rejection, waiting,
|
|
461
461
|
// the revocation / keyUpdate warnings) denies or defers the request, so a
|
|
462
462
|
// certificate under it is a malformed response even when no explicit failInfo
|
|
463
|
-
// bit is set (a rejection is commonly
|
|
463
|
+
// bit is set (a rejection is commonly signaled by status alone). Keying the
|
|
464
464
|
// rule off failInfo presence alone would let a bare-rejection status ship a
|
|
465
465
|
// certificate (RFC 9810 sec. 5.3.4).
|
|
466
466
|
if (certifiedKeyPair !== null && status.status.code !== 0 && status.status.code !== 1) {
|
package/lib/schema-crl.js
CHANGED
|
@@ -41,7 +41,7 @@ var CRL_REASONS = pkix.CRL_REASON_NAMES;
|
|
|
41
41
|
|
|
42
42
|
// Extension-value decoding is keyed off the stable dotted OID (resolved once at
|
|
43
43
|
// load from the canonical name) and not the mutable display name, so a caller's
|
|
44
|
-
// pki.oid.register() display override cannot change parse
|
|
44
|
+
// pki.oid.register() display override cannot change parse behavior.
|
|
45
45
|
var OID_CRL_NUMBER = oid.byName("cRLNumber");
|
|
46
46
|
var OID_REASON_CODE = oid.byName("reasonCode");
|
|
47
47
|
var OID_INVALIDITY_DATE = oid.byName("invalidityDate");
|
package/lib/schema-engine.js
CHANGED
|
@@ -40,11 +40,11 @@ function _fail(ctx, code, message) {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
// ---- shape assertions ------------------------------------------------
|
|
43
|
-
// A schema's `assert` mode is a real
|
|
43
|
+
// A schema's `assert` mode is a real behavior-preservation control: some
|
|
44
44
|
// hand-written guards checked the universal SEQUENCE tag (algorithmIdentifier,
|
|
45
45
|
// Name), others checked only that the node had children (Validity, SPKI, the
|
|
46
46
|
// tbs body, an AttributeTypeAndValue). Collapsing them to one "is a SEQUENCE"
|
|
47
|
-
// check would silently change
|
|
47
|
+
// check would silently change behavior on SET-wrapped input, so each is kept.
|
|
48
48
|
|
|
49
49
|
function _assertShape(schema, node, ctx) {
|
|
50
50
|
var mode = schema.assert || "sequence";
|
package/lib/schema-pkix.js
CHANGED
|
@@ -1228,7 +1228,7 @@ function certExtensionDecoders(ns) {
|
|
|
1228
1228
|
|
|
1229
1229
|
// qcStatements ::= SEQUENCE OF QCStatement (RFC 3739 sec. 3.2.6). QCStatement ::= SEQUENCE {
|
|
1230
1230
|
// statementId OBJECT IDENTIFIER, statementInfo ANY DEFINED BY statementId OPTIONAL }. Known statementIds
|
|
1231
|
-
// (RFC 3739 id-qcs + the ETSI EN 319 412-5 esi4
|
|
1231
|
+
// (RFC 3739 id-qcs + the ETSI EN 319 412-5 esi4 catalogue) decode their statementInfo; an unknown statementId
|
|
1232
1232
|
// is preserved OPAQUE (the RFC 3739 open type), never rejected. The DEFINED-BY selection is an OID re-dispatch.
|
|
1233
1233
|
function _qcStr(node, tag, C, what) {
|
|
1234
1234
|
if (!node || node.tagClass !== "universal" || node.tagNumber !== tag) throw ns.E(C, what);
|
package/lib/smime.js
CHANGED
|
@@ -846,7 +846,7 @@ function _capped(msg) {
|
|
|
846
846
|
* compared under RFC 5280 sec. 7.5: the local-part exactly, the host-part case-insensitively. The
|
|
847
847
|
* address is read from the `subjectAltName` `rfc822Name` entries (RFC 8550 sec. 4.4.3) and, where the
|
|
848
848
|
* extension carries none, from the subject DN's PKCS #9 `emailAddress` attribute, which RFC 8550 sec. 3
|
|
849
|
-
* requires a receiving agent to
|
|
849
|
+
* requires a receiving agent to recognize. Where both are present the extension wins. Only a signer
|
|
850
850
|
* whose signature verified contributes an identity, so a tampered message cannot report a binding. `match` is THREE-valued and a caller enforcing sender binding tests
|
|
851
851
|
* `match === true` -- `false` means every identity was comparable and none matched, and `null` means the
|
|
852
852
|
* question was not answered (no `expectedSender` and no single outer `From`, a signer certificate
|
package/lib/validator-cose.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// validator-cose -- the SINGLE home for "is this a conformant WebAuthn credential
|
|
9
9
|
// COSE_Key" (RFC 9052 sec. 7 structure + RFC 9053 EC2/OKP/RSA key parameters + the
|
|
10
10
|
// CTAP2 canonical-CBOR profile WebAuthn sec. 6.5.1 imposes). Sibling to the guard
|
|
11
|
-
// family: where a guard owns a CVE-class fail-closed
|
|
11
|
+
// family: where a guard owns a CVE-class fail-closed defense once, a validator owns a
|
|
12
12
|
// decoded TYPE's COMPLETE conformance rule set once, so a format module composes the
|
|
13
13
|
// family instead of re-deriving a partial subset inline (the drift that leaks MUSTs
|
|
14
14
|
// out one review round at a time). Enforced by the validator-shape-reinlined
|
|
@@ -242,10 +242,10 @@ function assertKeyMaterial(key, E, code, unsupportedCode) {
|
|
|
242
242
|
// PROFILE: the declared alg must match the key type (and, for EC2, the curve).
|
|
243
243
|
var prof = ALG_PROFILE[String(key.alg)];
|
|
244
244
|
// An algorithm this verifier does not implement is not a malformed key. The key can be perfectly
|
|
245
|
-
// well-formed (the same bytes may parse under a
|
|
245
|
+
// well-formed (the same bytes may parse under a neighboring algorithm id), and a relying
|
|
246
246
|
// party migrating credential rows written elsewhere needs to tell "I cannot check this
|
|
247
247
|
// algorithm" from "these bytes are wrong", since only one of those is fixable by re-registering.
|
|
248
|
-
// Callers that do not distinguish the two pass one code and keep the previous
|
|
248
|
+
// Callers that do not distinguish the two pass one code and keep the previous behavior.
|
|
249
249
|
if (!prof) throw new E(unsupportedCode || code, "unsupported credential key algorithm " + key.alg);
|
|
250
250
|
if (prof.kty !== key.kty) throw bad("credential key algorithm " + key.alg + " is inconsistent with key type " + key.kty);
|
|
251
251
|
if (prof.crv != null && prof.crv !== key.crv) throw bad("credential key algorithm " + key.alg + " requires a different curve");
|
package/lib/webauthn-mds.js
CHANGED
|
@@ -85,7 +85,7 @@ function _schemeE(kind, message, cause) { return new WebauthnError("webauthn/" +
|
|
|
85
85
|
// WebCrypto algorithms.
|
|
86
86
|
//
|
|
87
87
|
// Null-prototype: `alg` is attacker-supplied, and an inherited Object member would otherwise
|
|
88
|
-
// resolve to a truthy non-row and read as a
|
|
88
|
+
// resolve to a truthy non-row and read as a recognized algorithm.
|
|
89
89
|
function _deriveBlobAlgs() {
|
|
90
90
|
var out = Object.create(null);
|
|
91
91
|
jose.sigAlgs().forEach(function (row) {
|
|
@@ -120,7 +120,7 @@ function _blobAlgParams(algRow, leafAlgName) {
|
|
|
120
120
|
|
|
121
121
|
// The status values that deny trust (MDS v3.0 sec. 3.1.4). An unknown status is IGNORED for the
|
|
122
122
|
// gate and surfaced raw -- the specification requires that a verifier not fail on a status it does
|
|
123
|
-
// not
|
|
123
|
+
// not recognize -- unless the caller opts into refusing them.
|
|
124
124
|
var DISQUALIFYING = Object.assign(Object.create(null), {
|
|
125
125
|
REVOKED: 1, ATTESTATION_KEY_COMPROMISE: 1, USER_KEY_REMOTE_COMPROMISE: 1,
|
|
126
126
|
USER_KEY_PHYSICAL_COMPROMISE: 1, USER_VERIFICATION_BYPASS: 1,
|
|
@@ -140,7 +140,7 @@ var _BLOB_OPTS = Object.assign(Object.create(null), {
|
|
|
140
140
|
function _isPlainObject(v) { return !!v && typeof v === "object" && !Array.isArray(v); }
|
|
141
141
|
|
|
142
142
|
// The results this module actually produced. Membership is the PROVENANCE check: a metadata object
|
|
143
|
-
// is only allowed to decide trust if verifyMetadataBlob made it.
|
|
143
|
+
// is only allowed to decide trust if verifyMetadataBlob made it. Recognizing it by shape instead
|
|
144
144
|
// would accept anything carrying the right property names, including a catalogue deserialized
|
|
145
145
|
// from a cache, where nothing establishes that the signature and chain were ever checked, and an
|
|
146
146
|
// attacker who can write that cache chooses which roots an authenticator is allowed to chain to.
|
|
@@ -207,7 +207,7 @@ function _isAnchorItself(cert, anchor) {
|
|
|
207
207
|
//
|
|
208
208
|
// An already-parsed certificate is taken as-is, never re-encoded and re-parsed, but the
|
|
209
209
|
// recognition test names every field the anchor is later read for: the subject name, the public
|
|
210
|
-
// key bytes, and the signature algorithm OID.
|
|
210
|
+
// key bytes, and the signature algorithm OID. Recognizing an object on a looser test lets something
|
|
211
211
|
// that is merely certificate-SHAPED through: a parsed CSR satisfies "has a subject and an SPKI", and
|
|
212
212
|
// a hand-built object literal satisfies it too and then raises a raw TypeError from deep inside the
|
|
213
213
|
// path validator, which is an untyped throw escaping a public verb.
|
|
@@ -250,8 +250,8 @@ function _verifyMetadataBlob(blob, opts) {
|
|
|
250
250
|
throw _err("webauthn/bad-input", "opts.previousNo must be a non-negative safe integer");
|
|
251
251
|
}
|
|
252
252
|
// The boolean options are checked for TYPE, not merely compared against true. A caller who wrote
|
|
253
|
-
// `rejectUnknownStatus: "true"` from a config file is asking for the stricter
|
|
254
|
-
// against `true` silently records the policy as off and the authenticator carrying an
|
|
253
|
+
// `rejectUnknownStatus: "true"` from a config file is asking for the stricter behavior; comparing
|
|
254
|
+
// against `true` silently records the policy as off and the authenticator carrying an unrecognized
|
|
255
255
|
// status is then accepted -- the fail-open the option exists to prevent.
|
|
256
256
|
["requireRollbackCheck", "allowStale", "rejectUnknownStatus"].forEach(function (k) {
|
|
257
257
|
if (opts[k] !== undefined && typeof opts[k] !== "boolean") {
|
|
@@ -326,7 +326,7 @@ function _verifyMetadataBlob(blob, opts) {
|
|
|
326
326
|
maxBytes: C.MDS_BLOB_HEADER_MAX_BYTES, maxDepth: C.JSON_MAX_DEPTH,
|
|
327
327
|
tooLarge: "webauthn/too-large", badJson: "webauthn/bad-metadata-blob",
|
|
328
328
|
// Every code the guard can raise is named. An omitted one falls back to the framework default
|
|
329
|
-
// and the module's own
|
|
329
|
+
// and the module's own defenses -- duplicate-member smuggling, the depth cap -- surface under
|
|
330
330
|
// a generic code no webauthn/* consumer can switch on.
|
|
331
331
|
tooDeep: "webauthn/bad-metadata-blob", duplicateMember: "webauthn/bad-metadata-blob",
|
|
332
332
|
badInput: "webauthn/bad-metadata-blob", label: "the metadata BLOB header",
|
|
@@ -449,7 +449,7 @@ function _verifyMetadataBlob(blob, opts) {
|
|
|
449
449
|
// anchors through this same walk while keeping the verdict its own callers already handle. A second copy
|
|
450
450
|
// of the walk is not merely duplication: the anchor-stripping rule is subtle (a terminal
|
|
451
451
|
// certificate that IS the anchor is identified by subject name AND public key, which is what
|
|
452
|
-
//
|
|
452
|
+
// recognizes a cross-signed root), and a copy that got it slightly differently would refuse a valid
|
|
453
453
|
// chain in one place and accept a self-validated one in another.
|
|
454
454
|
function _chainToAnchor(chain, anchors, at, what, code) {
|
|
455
455
|
var subject = what || "metadata BLOB certificate chain";
|
|
@@ -509,7 +509,7 @@ function _staleAfter(nextUpdate) {
|
|
|
509
509
|
// has to be re-established every time it decides something, not only when it was parsed. A
|
|
510
510
|
// catalogue fetched before its nextUpdate and reused a month later would otherwise keep authorizing
|
|
511
511
|
// an authenticator whose status reports have since revoked it, which is precisely what nextUpdate
|
|
512
|
-
// exists to prevent. The caller's original allowStale decision is carried on the result and
|
|
512
|
+
// exists to prevent. The caller's original allowStale decision is carried on the result and honored
|
|
513
513
|
// here, so opting out stays opted out and never silently reappears at the point of use.
|
|
514
514
|
function assertFresh(metadata, at, label) {
|
|
515
515
|
if (!metadata || metadata.allowStale === true || typeof metadata.nextUpdate !== "string") return;
|
|
@@ -696,7 +696,7 @@ function metadataFor(metadata, identifier) {
|
|
|
696
696
|
return null;
|
|
697
697
|
}
|
|
698
698
|
|
|
699
|
-
// The options metadataAnchors
|
|
699
|
+
// The options metadataAnchors recognizes. Null-prototype for the same reason every other
|
|
700
700
|
// caller-keyed table in this namespace is: a supplied key must not resolve to an inherited member.
|
|
701
701
|
var _ANCHOR_OPTS = Object.assign(Object.create(null), { metadata: 1, time: 1, certificate: 1 });
|
|
702
702
|
|
|
@@ -846,13 +846,13 @@ function statusDenied(entry, metadata, leaf, at) {
|
|
|
846
846
|
if (s === null) return false;
|
|
847
847
|
// Scope was settled above, so anything still here applies to this authenticator.
|
|
848
848
|
if (DISQUALIFYING[s]) return true;
|
|
849
|
-
// An
|
|
849
|
+
// An unrecognized status is IGNORED for the gate unless the caller opted in: the specification
|
|
850
850
|
// requires a verifier not to fail on a status value it does not know.
|
|
851
851
|
return rejectUnknown && !_KNOWN_STATUS[s];
|
|
852
852
|
});
|
|
853
853
|
}
|
|
854
854
|
|
|
855
|
-
// Status values this library
|
|
855
|
+
// Status values this library recognizes as non-disqualifying, so `rejectUnknownStatus` can tell an
|
|
856
856
|
// unknown value from a known-good one.
|
|
857
857
|
var _KNOWN_STATUS = Object.assign(Object.create(null), {
|
|
858
858
|
NOT_FIDO_CERTIFIED: 1, SELF_ASSERTION_SUBMITTED: 1, FIDO_CERTIFIED: 1, FIDO_CERTIFIED_L1: 1,
|
package/lib/webauthn.js
CHANGED
|
@@ -474,8 +474,8 @@ var ATT_STMT_MAJOR = { compound: 4 };
|
|
|
474
474
|
var _AAGUID_SIGNED_BY_FMT = Object.assign(Object.create(null), { "fido-u2f": false });
|
|
475
475
|
function _aaguidIsSigned(fmt) { return _AAGUID_SIGNED_BY_FMT[fmt] !== false; }
|
|
476
476
|
|
|
477
|
-
// The options pki.webauthn.verify
|
|
478
|
-
// resolve to an inherited Object member and read as
|
|
477
|
+
// The options pki.webauthn.verify recognizes. Null-prototype, so a caller-supplied key cannot
|
|
478
|
+
// resolve to an inherited Object member and read as recognized.
|
|
479
479
|
var _VERIFY_OPTS = Object.assign(Object.create(null), {
|
|
480
480
|
time: 1, metadata: 1, tpmPolicy: 1, safetyNetRoots: 1, verifySafetyNetJws: 1, requireCtsProfileMatch: 1,
|
|
481
481
|
expectedRpId: 1, requireUserPresence: 1, requireUserVerification: 1, allowedAlgorithms: 1,
|
|
@@ -511,7 +511,7 @@ var _FORMAT_SCOPED_BOOLEAN_OPTS = ["verifySafetyNetJws", "requireCtsProfileMatch
|
|
|
511
511
|
// so a caller that recycles the array or overwrites a certificate's bytes in between
|
|
512
512
|
// would have the attestation anchored against the replacement roots while the verdict
|
|
513
513
|
// still reports `anchoredTo: "rootCertificates"` (CWE-367 reaching a wrong trust
|
|
514
|
-
// decision). Same
|
|
514
|
+
// decision). Same defense, and the same reason, as the assertion input's snapshot.
|
|
515
515
|
// Shape faults are NOT raised here: the array is validated where it is consumed, so
|
|
516
516
|
// supplying a malformed pin alongside a metadata catalogue that answers keeps failing
|
|
517
517
|
// exactly where it did before.
|
|
@@ -645,7 +645,7 @@ function _applyCallerRoots(res, supplied, vopts, onlyPaths) {
|
|
|
645
645
|
var at = vopts.time !== undefined ? vopts.time : new Date();
|
|
646
646
|
// EVERY path, not merely one: a compound's elements are independent claims, and
|
|
647
647
|
// accepting the whole because one element anchored would let an unanchored element
|
|
648
|
-
// ride along on its
|
|
648
|
+
// ride along on its neighbor's trust.
|
|
649
649
|
return paths.reduce(function (p, info) {
|
|
650
650
|
return p.then(function () {
|
|
651
651
|
var pathAt = vopts.time !== undefined ? vopts.time : (info.at || at);
|
|
@@ -1114,7 +1114,7 @@ var VERIFIERS = {
|
|
|
1114
1114
|
});
|
|
1115
1115
|
});
|
|
1116
1116
|
}, Promise.resolve()).then(function () {
|
|
1117
|
-
// sec. 8.9 lists the supported attestation type as "Any" and
|
|
1117
|
+
// sec. 8.9 lists the supported attestation type as "Any" and authorizes returning
|
|
1118
1118
|
// "implementation-specific values representing any combination of outputs". A distinct type
|
|
1119
1119
|
// rather than a merge: collapsing to the strongest element would let a wrapper upgrade a
|
|
1120
1120
|
// caller's attestationType check, and collapsing to the weakest would spuriously fail one.
|
|
@@ -1388,7 +1388,7 @@ function verify(attestationObject, clientDataHash, opts) {
|
|
|
1388
1388
|
// Every option here either GATES the verdict or supplies the trust material a gate needs, so a
|
|
1389
1389
|
// misspelled key is not a harmless no-op: `metdata` leaves the metadata gate switched off and the
|
|
1390
1390
|
// call returns a pass the caller believes was checked against the catalogue. Reject an
|
|
1391
|
-
//
|
|
1391
|
+
// unrecognized key at the boundary, and validate the one shared option every arm reads, so a bad
|
|
1392
1392
|
// instant is a config fault here rather than a trust failure reported from deep inside a chain.
|
|
1393
1393
|
try {
|
|
1394
1394
|
if (!_isPlainObject(opts)) throw _err("webauthn/bad-input", "opts must be an object");
|
|
@@ -1406,7 +1406,7 @@ function verify(attestationObject, clientDataHash, opts) {
|
|
|
1406
1406
|
// The boolean switches are typed HERE rather than in the arm that reads them. Validated only
|
|
1407
1407
|
// inside the android-safetynet arm, `requireCtsProfileMatch: "true"` reaching any other format
|
|
1408
1408
|
// is never examined at all -- a truthy string that demands nothing, silently accepted. A
|
|
1409
|
-
//
|
|
1409
|
+
// recognized key with an unusable value is the same class of caller mistake as an unrecognized
|
|
1410
1410
|
// key, and belongs at the same boundary.
|
|
1411
1411
|
_FORMAT_SCOPED_BOOLEAN_OPTS.forEach(function (k) {
|
|
1412
1412
|
if (opts[k] !== undefined && typeof opts[k] !== "boolean") throw _err("webauthn/bad-input", "opts." + k + " must be a boolean");
|
|
@@ -1597,7 +1597,7 @@ function verify(attestationObject, clientDataHash, opts) {
|
|
|
1597
1597
|
function _applyMetadata(res, att, opts) {
|
|
1598
1598
|
var md = opts.metadata;
|
|
1599
1599
|
// Provenance, not shape: only a catalogue this toolkit actually verified may decide which roots
|
|
1600
|
-
// an authenticator is allowed to chain to.
|
|
1600
|
+
// an authenticator is allowed to chain to. Recognizing it by its property names would accept a
|
|
1601
1601
|
// hand-built object, or one restored from a cache an attacker can write -- neither of which has
|
|
1602
1602
|
// been through the signature and chain checks that give the catalogue its authority.
|
|
1603
1603
|
if (!mds.isVerifiedResult(md)) {
|
|
@@ -1703,7 +1703,7 @@ function _applyMetadata(res, att, opts) {
|
|
|
1703
1703
|
mds.assertFresh(md, at, "the metadata supplied as opts.metadata");
|
|
1704
1704
|
// EVERY path must pass, not merely one of them: for a compound attestation each element is an
|
|
1705
1705
|
// independent claim, and accepting the whole because one element anchored would let an
|
|
1706
|
-
// unanchored -- or revoked -- element ride along on its
|
|
1706
|
+
// unanchored -- or revoked -- element ride along on its neighbor's trust.
|
|
1707
1707
|
// Govern EVERY path BEFORE any is chain-validated, and let the SEVEREST outcome decide
|
|
1708
1708
|
// rather than the first one reached. A compound's elements are independent claims and
|
|
1709
1709
|
// its element order is NOT signed, so aborting on the first failure lets that order pick
|
|
@@ -1836,7 +1836,7 @@ void constants;
|
|
|
1836
1836
|
* (the default; any disqualifying report ever filed), `"latest-by-date"` (only the
|
|
1837
1837
|
* most recent report counts, so a later remediation clears an earlier revocation),
|
|
1838
1838
|
* or a function receiving the raw report array and returning true to deny.
|
|
1839
|
-
* - `rejectUnknownStatus` -- treat a status this toolkit does not
|
|
1839
|
+
* - `rejectUnknownStatus` -- treat a status this toolkit does not recognize as
|
|
1840
1840
|
* disqualifying. Off by default: the specification requires an unknown status be
|
|
1841
1841
|
* ignored, never failed on.
|
|
1842
1842
|
*
|
|
@@ -2146,7 +2146,7 @@ function parseAuthenticatorData(bytes) {
|
|
|
2146
2146
|
return _parseAuthData(_bytesArg(bytes, "authenticatorData"), _err);
|
|
2147
2147
|
}
|
|
2148
2148
|
|
|
2149
|
-
// The options pki.webauthn.verifyAssertion
|
|
2149
|
+
// The options pki.webauthn.verifyAssertion recognizes, null-prototype for the same
|
|
2150
2150
|
// reason _VERIFY_OPTS is.
|
|
2151
2151
|
var _ASSERT_OPTS = Object.assign(Object.create(null), {
|
|
2152
2152
|
authenticatorData: 1, clientDataHash: 1, clientDataJSON: 1, signature: 1,
|
package/lib/webcrypto.js
CHANGED
|
@@ -1344,7 +1344,7 @@ function exportAnyKey(key, E, code) {
|
|
|
1344
1344
|
// A separately-installed copy of this engine is the same code under a different class identity, so
|
|
1345
1345
|
// its keys fail the `instanceof` above while carrying the very handle this engine reads -- and no
|
|
1346
1346
|
// WebCrypto implementation but that copy would accept them. Read the handle directly. The
|
|
1347
|
-
// extractable check above stays AHEAD of this: the handle can always be read, so
|
|
1347
|
+
// extractable check above stays AHEAD of this: the handle can always be read, so honoring the
|
|
1348
1348
|
// key's own extractable promise is this branch's responsibility, not the crypto library's.
|
|
1349
1349
|
var handle = key._handle;
|
|
1350
1350
|
if (handle instanceof nodeCrypto.KeyObject && handle.type === key.type) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@blamejs/pki",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.10",
|
|
4
4
|
"description": "Pure-JavaScript PKI toolkit that owns its stack — X.509, ASN.1/DER, CMS, PQC-first.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "blamejs contributors",
|
|
@@ -68,9 +68,11 @@
|
|
|
68
68
|
"test": "node test/smoke.js",
|
|
69
69
|
"lint": "eslint --max-warnings 0 .",
|
|
70
70
|
"fuzz": "npm ci --prefix fuzz && npx --prefix fuzz jazzer fuzz/asn1-der.fuzz.js -- -max_total_time=60",
|
|
71
|
-
"gates": "node test/layer-0-primitives/codebase-patterns.test.js && node scripts/validate-source-comment-blocks.js && node scripts/check-api-snapshot.js",
|
|
71
|
+
"gates": "node test/layer-0-primitives/codebase-patterns.test.js && node scripts/validate-source-comment-blocks.js && node scripts/check-api-snapshot.js && node scripts/check-spelling-consistency.js",
|
|
72
|
+
"check:spelling": "node scripts/check-spelling-consistency.js",
|
|
72
73
|
"coverage": "c8 --include=lib/** --include=index.js --reporter=text-summary --reporter=lcov node test/smoke.js",
|
|
73
74
|
"check:prose": "node scripts/check-operator-prose.js",
|
|
75
|
+
"check:prose:all": "node -e \"process.env.PKI_PROSE_CHECKS='all';var r=require('child_process').spawnSync(process.execPath,['scripts/check-operator-prose.js'],{stdio:'inherit'});if(r.error)throw r.error;process.exit(r.signal?1:r.status)\"",
|
|
74
76
|
"check:swallows": "node scripts/check-swallow-coverage.js",
|
|
75
77
|
"coverage:gated": "npm run coverage && npm run check:swallows",
|
|
76
78
|
"coverage:unified": "node scripts/coverage-unified.js",
|
package/sbom.cdx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.5",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:b51c65a4-3df8-4dec-af37-301d0b285af6",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-08-
|
|
8
|
+
"timestamp": "2026-08-18T10:39:56.596Z",
|
|
9
9
|
"lifecycles": [
|
|
10
10
|
{
|
|
11
11
|
"phase": "build"
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
}
|
|
20
20
|
],
|
|
21
21
|
"component": {
|
|
22
|
-
"bom-ref": "@blamejs/pki@0.5.
|
|
22
|
+
"bom-ref": "@blamejs/pki@0.5.10",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "pki",
|
|
25
|
-
"version": "0.5.
|
|
25
|
+
"version": "0.5.10",
|
|
26
26
|
"scope": "required",
|
|
27
27
|
"author": "blamejs contributors",
|
|
28
28
|
"description": "Pure-JavaScript PKI toolkit that owns its stack — X.509, ASN.1/DER, CMS, PQC-first.",
|
|
29
|
-
"purl": "pkg:npm/%40blamejs/pki@0.5.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/pki@0.5.10",
|
|
30
30
|
"properties": [],
|
|
31
31
|
"externalReferences": [
|
|
32
32
|
{
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"components": [],
|
|
55
55
|
"dependencies": [
|
|
56
56
|
{
|
|
57
|
-
"ref": "@blamejs/pki@0.5.
|
|
57
|
+
"ref": "@blamejs/pki@0.5.10",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|