@blamejs/pki 0.5.11 → 0.5.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,7 +4,35 @@ 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.11 — 2026-08-19
7
+ ## v0.5.13 — 2026-08-19
8
+
9
+ `pki.csr.verify` checks an inbound certification request's proof of possession, so a CA built on this toolkit can tell that the requester holds the key they are asking it to certify.
10
+
11
+ ### Added
12
+
13
+ - `pki.csr.verify(request)` verifies a certification request's signature over its exact `certificationRequestInfo` bytes under the `subjectPKInfo` inside them (RFC 2986 sec. 4.2), the check `openssl req -verify` performs. `request` is DER, PEM, or a parsed request. It composes the one path-validation signature engine, with the same algorithm-confusion (RFC 9814 sec. 4) and EdDSA low-order-point gates, in place of the producing side's self-check, which waives that gate because it runs over a key the caller already controls. It fails closed on any import or verification fault, and malformed input throws a typed `CsrError`.
14
+ - The answer is `{ verified, subject, subjectPublicKeyInfo, attributes, certificationRequestInfoBytes }`, every field re-derived from the bytes the signature covers. Issue from those. A CA that normalizes a request before verifying it holds an object carrying its own edits, and a bare boolean would report on the signed bytes while the certificate got built from the edits; the fields travel with the verdict so the two cannot come apart.
15
+ - What `verified: true` establishes is stated in full, because the bound is the point: the producer held the private half of the key inside the request, and the subject and every requested extension are the ones covered by that signature. A CSR carries no issuer and its key is self-asserted, so a requester free to choose both can prove possession of a key they generated a moment ago under any name they like. Binding that name to an identity stays with the enrollment protocol.
16
+
17
+ ### Changed
18
+
19
+ - `pki.schema.csr.parse` now records the bytes it read, the way the certificate, CRL and CMS parsers do, and `pki.csr.verify` accepts only a result carrying that record. A parsed request presents the signed byte range and the fields that range encodes as separate properties, so a rebuilt one (`Object.assign`, a spread, a JSON round-trip) can hold a genuine requester's signed bytes beside a substituted subject: the proof of possession verifies from the recorded range while the certificate a CA issues is for a name nobody signed. Passing DER or PEM is unaffected, and a parse result used as-is still works.
20
+ - One visible consequence of that record: the raw byte views on a parsed request (`certificationRequestInfoBytes`, `tbsBytes`, `subject.bytes` and the rest) now read from the parser's own copy of the input rather than from your buffer, matching what `pki.schema.x509.parse` has always done. Writing into the buffer you passed no longer changes what an already-parsed request reports, and writing through one of those views no longer reaches your buffer. Code that read the fields is unaffected; code that relied on either aliasing needs to re-parse instead.
21
+
22
+ ## v0.5.12 — 2026-08-19
23
+
24
+ `pki.crl.isRevoked` can be asked at an instant, and a CRL that stopped speaking for that instant is refused rather than read as a clean bill of health.
25
+
26
+ ### Added
27
+
28
+ - `pki.crl.isRevoked(crl, serialNumber, { time })` asks the question at an instant. A CRL whose `nextUpdate` has passed, whose `thisUpdate` is later, or which carries no `nextUpdate` at all is refused with `crl/not-current` instead of answered from: outside the window a CRL states, an absent serial says nothing about the certificate, and a list stating no window cannot be told from a replayed copy. This joins the refusals already made for a delta, indirect or narrowed-scope CRL, on the same reasoning -- a serial means something only within the set, and the span, a CRL speaks for. `pki.path.crlChecker` is unchanged and remains the verb that decides currency against material it fetched itself.
29
+ - `opts.historicalMode` reads a revocation entry against that same instant, the way `pki.path.crlChecker` reads one. By default a listed serial is revoked whatever its `revocationDate` says, since a date in the future is post-dating or clock skew and must not read good; set `historicalMode` -- validating as of a past instant, a timestamped signature say -- and an entry dated after that instant has not yet applied. Given without `time` it names no instant to read against and is refused.
30
+
31
+ ### Changed
32
+
33
+ - `pki.crl.isRevoked` takes a third argument. It is optional and every existing call keeps its behavior and its answer: without `time` the verb is the structural lookup it has always been, and its documentation now names the question that then goes unasked, so `null` reads as "not listed on this CRL" rather than "not revoked". An option it does not read is refused rather than ignored.
34
+
35
+ ## v0.5.11 — 2026-08-18
8
36
 
9
37
  An option a verb never reads is now refused, so a misspelled `password` on key export can no longer leave a private key unprotected.
10
38
 
package/README.md CHANGED
@@ -230,11 +230,11 @@ comment blocks, is at [pkijs.com](https://pkijs.com).
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` |
232
232
  | `pki.x509` | Certificate issuance (RFC 5280 §4). `sign(spec, issuer, opts)` builds and signs a certificate from a `spec` of subject (a common-name string, an array of RDNs, or raw Name DER), the public key being certified, the validity window, an optional serial, and an optional `extensions` object. The `issuer` is a key alone (self-signed: issuer equals subject, signed with that key), a name plus public key plus key, or an issuing certificate plus key. The signature algorithm is resolved from the signing key through the shared registry, so RSA (PKCS#1 v1.5 or PSS via `opts.pss`), ECDSA P-256/384/521, Ed25519, Ed448, ML-DSA-44/65/87, the twelve SLH-DSA sets, and the composite arms all issue without a per-algorithm branch. It encodes basic constraints, key usage, extended key usage, subject and authority key identifiers (the SKI derived by SHA-1 of the subject key), subject alternative names, and certificate policies from the spec, taking any other extension as pre-encoded DER. It derives the version from the field set and enforces the serial bounds, the UTCTime/GeneralizedTime cutover, the DER default omissions, and the CA cross-field rules; a violation throws a typed `CertificateError`. Returns DER, or a PEM `CERTIFICATE` with `opts.pem`. Every arm is independently verified by OpenSSL. Parsing stays at `pki.schema.x509.parse` — `sign` |
233
- | `pki.csr` | PKCS#10 certification-request issuance (RFC 2986 / RFC 2985). `sign(spec, key, opts)` builds and signs a `CertificationRequest` from a `spec` of subject (which may be empty), the public key being certified, an optional `extensionRequest` carrying the requested v3 extensions a CA copies into the issued certificate (subject alternative names, key usage, extended key usage, basic constraints, certificate policies, subject key identifier, or an array of pre-encoded Extension DER), and an optional `challengePassword`. `key` (or `{ key }`) is the subject's own private key: the request is self-signed to prove possession of the private half of `subjectPublicKey`, and that proof is verified before the request is returned, which is what `openssl req -verify` checks. The signature algorithm is resolved from the subject key, so RSA (PKCS#1 v1.5 or PSS via `opts.pss`), ECDSA, EdDSA, ML-DSA, SLH-DSA, and the composite arms all sign without a per-algorithm branch. Returns DER, or a PEM `CERTIFICATE REQUEST` with `opts.pem`; malformed input throws a typed `CsrError`. Parsing stays at `pki.schema.csr.parse` — `sign` |
233
+ | `pki.csr` | PKCS#10 certification-request issuance (RFC 2986 / RFC 2985). `sign(spec, key, opts)` builds and signs a `CertificationRequest` from a `spec` of subject (which may be empty), the public key being certified, an optional `extensionRequest` carrying the requested v3 extensions a CA copies into the issued certificate (subject alternative names, key usage, extended key usage, basic constraints, certificate policies, subject key identifier, or an array of pre-encoded Extension DER), and an optional `challengePassword`. `key` (or `{ key }`) is the subject's own private key: the request is self-signed to prove possession of the private half of `subjectPublicKey`, and that proof is verified before the request is returned, which is what `openssl req -verify` checks. The signature algorithm is resolved from the subject key, so RSA (PKCS#1 v1.5 or PSS via `opts.pss`), ECDSA, EdDSA, ML-DSA, SLH-DSA, and the composite arms all sign without a per-algorithm branch. Returns DER, or a PEM `CERTIFICATE REQUEST` with `opts.pem`; malformed input throws a typed `CsrError`. `verify(request)` asks the same question of a request someone else produced, over its exact signed `certificationRequestInfo` bytes under the `subjectPKInfo` inside them, through the one path-validation signature engine. It answers `{ verified, subject, subjectPublicKeyInfo, attributes, certificationRequestInfoBytes }`, all re-derived from the signed bytes, so a CA issues from the fields the signature covers rather than from the object it passed in. A `verified: true` says the producer held that private key and that the subject and requested extensions are the ones they signed; who they are is the enrollment protocol's question, not this one. Parsing stays at `pki.schema.csr.parse` — `sign`, `verify` |
234
234
  | `pki.attrcert` | Attribute-certificate issuance (RFC 5755). `sign(spec, issuer, opts)` builds and signs an `AttributeCertificate` as an Attribute Authority: a `spec` of `holder` (exactly one of an entity name, a `baseCertificateID` reference, a `fromCertificate` binding, or an object digest), the validity window as GeneralizedTime, an optional serial (positive, at most 20 octets, randomly generated when omitted), the `attributes` (role, clearance, group, chargingIdentity, accessIdentity, authenticationInfo, or pre-encoded Attribute DER), and optional `extensions` (auditIdentity, targetInformation, noRevAvail, aaControls, acProxying, authorityKeyIdentifier, or pre-encoded Extension DER) each with its RFC 5755 criticality. An attribute certificate is never self-signed, so the `issuer` is the signing AA, supplied as `{ cert, key }` or `{ name, publicKey, key }`. The signature algorithm is resolved from the AA key, so RSA (PKCS#1 v1.5 or PSS via `opts.pss`), ECDSA, EdDSA, ML-DSA, SLH-DSA, and the composite arms all sign without a per-algorithm branch, and the signature is verified under the AA public key before the certificate is returned. Returns DER, or a PEM `ATTRIBUTE CERTIFICATE` with `opts.pem`; malformed input throws a typed `AttrCertError`. Parsing stays at `pki.schema.attrcert.parse` — `sign` |
235
235
  | `pki.crmf` | Certificate-request-message issuance (RFC 4211). `build(spec, key, opts)` assembles a `CertReqMessages` from a `spec` of `certReqId` (default 0, with the RFC 9483 `-1` sentinel allowed), a `certTemplate` of the requested fields (`subject`, `publicKey` as the SPKI DER of the key being certified, `validity`, requested `extensions`, an optional `version` 2), optional `controls` and `regInfo` (regToken, authenticator, utf8Pairs, oldCertID, protocolEncrKey, or pre-encoded `AttributeTypeAndValue` DER), and an optional `pop` selector. `key` (or `{ key }`) is the requester's private key: the message carries a `POPOSigningKey` proof of possession signed with the private half of `certTemplate.publicKey` and verified before the message is returned, exactly as a PKCS#10 CSR proves possession. A complete template signs the `CertRequest`; an incomplete one signs a `POPOSigningKeyInput`. The signature algorithm is resolved from the requested public key, so RSA (PKCS#1 v1.5 or PSS via `opts.pss`), ECDSA, EdDSA, ML-DSA, SLH-DSA, and the composite arms all sign without a per-algorithm branch. `key` is optional for a `raVerified` proof. Pass an array of specs for a batch; the CA-assigned template fields are never emitted. Returns DER, or a PEM block with `opts.pem`; malformed input throws a typed `CrmfError`. Parsing stays at `pki.schema.crmf.parse` — `build` |
236
236
  | `pki.cmp` | CMP message building, transfer, and verification (RFC 9810). `build(message, opts)` assembles a protected `PKIMessage`. `message.header` carries the `sender` and `recipient` GeneralNames (including the anonymous NULL-DN) plus optional transaction metadata; `message.body` is a single-key object naming the arm — request-side `ir`, `cr`, `kur`, `p10cr`, `certConf`, `pollReq`, `genm`, `rr`, and responder-side `ip`, `cp`, `kup`, `ccp`, `rp`, `genp`, `error`, `pollRep`, `krp`, `pkiconf`. Protection is exactly one of `opts.{ key, cert }`, a signature under the sender key with the algorithm resolved from the signer certificate so RSA (PKCS#1 v1.5 / PSS), ECDSA, EdDSA, ML-DSA, SLH-DSA, and the composite arms all sign without a per-algorithm branch, or `opts.mac`, a PBMAC1 shared-secret HMAC (RFC 9481 / 9579, PBKDF2-derived). Protection covers the exact DER of the virtual `ProtectedPart` and is self-verified before the message is returned, and `protectionAlg` is derived rather than caller-set, so the message the parser accepts is coherent by construction. `transfer(url, message, opts)` carries a built message to a CMP endpoint over `pki.transport` (RFC 9811): one POST of the DER PKIMessage, with the response classified fail-closed — 200 only for success, a non-200 2xx or an un-followed 3xx refused, a 4xx or 5xx carrying a CMP error PKIMessage forwarded as the integrity-protected verdict — and protection surfaced rather than verified. `wellKnownUrl(base, opts)` builds the §3.4 `/.well-known/cmp` request-URIs. `verify(message, opts)` checks the protection on an incoming message, either a signature through the same certification-path engine `pki.crl.verify` and `pki.ocsp.verify` use, with the EdDSA low-order-point and algorithm-confusion gates, or a PBMAC1 MAC recomputed from `opts.sharedSecret` and the message's own PBKDF2 parameters and constant-time compared, over the exact `ProtectedPart` reconstructed from the parser's raw slices. It is fail-closed on an unprotected message, a legacy or KEM MAC algorithm, an omitted keyLength, or a SHA-1 PRF, and returns a `{ valid, trusted, protectionType, signer, ... }` verdict. With `opts.trustAnchors` the signer certificate is fully path-validated (RFC 5280 §6.1 plus the RFC 9483 §3.2 `keyUsage.digitalSignature` gate) at a trusted current time, or an explicit `opts.time` for historical verification and never the message's self-asserted `messageTime`, before it is reported trusted; without one the verdict is crypto-only and the signer certificate is surfaced to anchor. `session(opts)` returns a stateful enrollment session whose `enroll(request)` drives a full `ir` / `cr` / `kur` / `p10cr` transaction over the shared transport, composing `build`, `transfer`, and `verify`. Every response is protection-verified, signer-trusted, and bound to the exchange (a stable `transactionID`, a fresh-`senderNonce` and echoed-`recipNonce` chain) before its body is read, with a bounded `pollReq` / `pollRep` loop for a `waiting` status and a `certConf` / `pkiConf` (or implicit) confirmation carrying an explicit `hashAlg` for a signature algorithm that does not convey its hash. It returns a terminal `{ outcome, certificate, chain, status, trusted, confirmed, implicitConfirm, transactionID, polls, transcript }`. The signature flavor requires `opts.trustAnchors` to authenticate the CA; a verified rejection or error and an exhausted poll budget are terminal verdicts, while a tampered, untrusted, or desynchronized response is a typed throw. Returns DER, or a PEM `CMP` block with `opts.pem`; malformed input throws a typed `CmpError`. Parsing stays at `pki.schema.cmp.parse` — `build`, `transfer`, `wellKnownUrl`, `verify`, `session` |
237
- | `pki.crl` | CRL issuance and verification (RFC 5280 §5). `sign(spec, issuer, opts)` builds and signs a `CertificateList` from a `spec` of `thisUpdate` and `nextUpdate`, an optional `crlNumber`, a `revoked` array (each entry a `serialNumber` and `revocationDate` with an optional `reason` or `invalidityDate`), and an optional `extensions` object (authority key identifier, issuing distribution point, delta-CRL indicator, freshest CRL, authority information access) or an array of pre-encoded Extension DER, with an `issuer` of `{ cert, key }` or `{ name, publicKey, key }`. The signature algorithm is resolved from the issuer key, so RSA (PKCS#1 v1.5 or PSS via `opts.pss`), ECDSA, EdDSA, ML-DSA, SLH-DSA, and the composite arms all sign without a per-algorithm branch. The version is derived from the extension set (v2 when any CRL or entry extension is present, else v1), the outer `signatureAlgorithm` matches `tbsCertList.signature`, an empty revocation list omits the field rather than emitting an empty SEQUENCE, `reasonCode` is an ENUMERATED and `invalidityDate` is always GeneralizedTime, the RFC fixes per-extension criticality, and the produced signature is verified under the issuer key before return. `verify(crl, issuer)` checks a CRL signature through the one path-validation signature engine, algorithm-confusion and EdDSA low-order gates included; handed a certificate rather than a bare key it also asks what only a certificate can answer — that the certificate is the issuer the CRL names, and that its `keyUsage`, when present, asserts `cRLSign` — so a CRL minted under an end-entity certificate of the same CA does not verify as that CA's. `isRevoked(crl, serialNumber)` looks a serial up, and first checks that the CRL is one a serial can be looked up in at all — it is handed a serial and nothing else, so a CRL that speaks for part of its issuer's certificates is refused rather than answered from. A delta CRL lists changes since a base, so an entry recording a release reads as a revocation when read alone; an indirect CRL carries other issuers' entries, whose serials are unrelated to yours; and any other `issuingDistributionPoint` narrows the CRL to one distribution point, one kind of certificate, or a subset of revocation reasons, none of which a serial can be matched against. `pki.path.crlChecker` is the verb for all of them: it is handed the certificate, merges a delta with its base, and performs the §6.3.3 correspondence. Returns DER, or a PEM `X509 CRL` with `opts.pem`; malformed input throws a typed `CrlError`. Parsing stays at `pki.schema.crl.parse` — `sign`, `verify`, `isRevoked` |
237
+ | `pki.crl` | CRL issuance and verification (RFC 5280 §5). `sign(spec, issuer, opts)` builds and signs a `CertificateList` from a `spec` of `thisUpdate` and `nextUpdate`, an optional `crlNumber`, a `revoked` array (each entry a `serialNumber` and `revocationDate` with an optional `reason` or `invalidityDate`), and an optional `extensions` object (authority key identifier, issuing distribution point, delta-CRL indicator, freshest CRL, authority information access) or an array of pre-encoded Extension DER, with an `issuer` of `{ cert, key }` or `{ name, publicKey, key }`. The signature algorithm is resolved from the issuer key, so RSA (PKCS#1 v1.5 or PSS via `opts.pss`), ECDSA, EdDSA, ML-DSA, SLH-DSA, and the composite arms all sign without a per-algorithm branch. The version is derived from the extension set (v2 when any CRL or entry extension is present, else v1), the outer `signatureAlgorithm` matches `tbsCertList.signature`, an empty revocation list omits the field rather than emitting an empty SEQUENCE, `reasonCode` is an ENUMERATED and `invalidityDate` is always GeneralizedTime, the RFC fixes per-extension criticality, and the produced signature is verified under the issuer key before return. `verify(crl, issuer)` checks a CRL signature through the one path-validation signature engine, algorithm-confusion and EdDSA low-order gates included; handed a certificate rather than a bare key it also asks what only a certificate can answer — that the certificate is the issuer the CRL names, and that its `keyUsage`, when present, asserts `cRLSign` — so a CRL minted under an end-entity certificate of the same CA does not verify as that CA's. `isRevoked(crl, serialNumber, opts)` looks a serial up, and first checks that the CRL is one a serial can be looked up in at all — it is handed a serial and nothing else, so a CRL that speaks for part of its issuer's certificates is refused rather than answered from. A delta CRL lists changes since a base, so an entry recording a release reads as a revocation when read alone; an indirect CRL carries other issuers' entries, whose serials are unrelated to yours; and any other `issuingDistributionPoint` narrows the CRL to one distribution point, one kind of certificate, or a subset of revocation reasons, none of which a serial can be matched against. A CRL speaks for a span as well as a set, so `opts.time` states the instant the question is asked at and a list that has stopped speaking for it — `nextUpdate` passed, `thisUpdate` later, or no `nextUpdate` stated at all — is refused rather than read as a clean bill of health; without `opts.time` the lookup is structural and an absent serial means only that it is not on that list. `pki.path.crlChecker` is the verb for all of them: it is handed the certificate, merges a delta with its base, performs the §6.3.3 correspondence, and decides currency against the material it fetched. Returns DER, or a PEM `X509 CRL` with `opts.pem`; malformed input throws a typed `CrlError`. Parsing stays at `pki.schema.crl.parse` — `sign`, `verify`, `isRevoked` |
238
238
  | `pki.key` | Key-material lifecycle (RFC 5958 / RFC 8018). `encrypt(privateKey, password, opts)` wraps a PKCS#8 private key (DER, PEM, or an extractable `CryptoKey`) into an `EncryptedPrivateKeyInfo` under PBES2 (PBKDF2 with AES-CBC-Pad), where `opts` selects the `cipher` (`aes-256-cbc` default, `aes-192-cbc`, `aes-128-cbc`), the `prf` (`hmacWithSHA256` default, SHA-384/512, SHA-1), the `iterations` (default 600000), and the `salt`. The plaintext is validated as PKCS#8 before encryption, a default `prf` and `keyLength` are omitted so the parameters are byte-exact with OpenSSL, and the output is re-parsed before return. `decrypt(encrypted, password, opts)` recovers the inner `PrivateKeyInfo`, re-validated through `pki.schema.pkcs8.parse`: only PBES2 / PBKDF2 / AES-CBC is accepted (PBES1, PBMAC1, and scrypt are refused), the salt and iteration count are bounded before any derivation (`opts.maxIterations` lowers the cap), and a malformed parameter set or wrong-length IV is a distinct typed error. Because a MAC-less PBES2-CBC decrypt must not become a padding oracle (RFC 8018 §8), a wrong password and a valid-pad-but-not-a-key both surface the one uniform `key/decrypt-failed`. `export(key, opts)` and `import(input, opts)` move a private key as PKCS#8 or a public key as SubjectPublicKeyInfo. The key may come from the platform's WebCrypto or from a separately installed copy of this toolkit, and is exported through whichever holds its material; a non-extractable key, or one whose implementation keeps its material out of reach, is refused with that as the reason. Encoding is delegated to WebCrypto, so RSA carries an explicit NULL, EC a named curve, and Ed25519/Ed448/X25519/X448 omit parameters, and an ambiguous RSA or EC import requires `opts.algorithm`. `generate(algorithm, opts)` produces a key pair over RSA, ECDSA/ECDH, the Edwards and Montgomery curves, and the FIPS post-quantum ML-DSA and ML-KEM; `publicFromPrivate(privateKey)` derives the public key. Returns DER or PEM, with a typed `KeyError` on failure. Parsing stays at `pki.schema.pkcs8.parse` — `encrypt`, `decrypt`, `export`, `import`, `generate`, `publicFromPrivate` |
239
239
  | `pki.pkcs12` | PKCS#12 (.p12/.pfx) issuance and reading (RFC 7292 / RFC 9579). `build(spec, opts)` assembles a store from the OpenSSL-style `{ key, cert, ca?, friendlyName?, localKeyId? }` or the full `{ safeContents: [...] }`, where each element is a plaintext or PBES2-encrypted `SafeContents` of key, shroudedKey, cert, crl, secret, or nested `safeContents` bags. Keys and certs are validated before wrapping, and `friendlyName` (BMPString) and `localKeyId` are single-value. Integrity is a classic Appendix B HMAC (the default, for maximum interoperability) or an RFC 9579 PBMAC1 (`opts.mac.algorithm`) over SHA-256/384/512, with shrouded keys and cert safes encrypted under RFC 8018 PBES2 (AES-128/192/256-CBC). Every password is encoded the PKCS#12 way — BMPString+NULL for the classic MAC, UTF-8 for the PBES2 bags and PBMAC1 — which is what OpenSSL and NSS consume, so a file it emits opens in both, cross-checked bidirectionally. The MAC covers the exact AuthenticatedSafe byte range, a DEFAULT-1 `MacData.iterations` is rejected up front, and the store is re-parsed before return. `verifyMac(pfx, password, opts)` recomputes a classic or PBMAC1 MAC over `macedBytes` and constant-time-compares it, throwing on a MAC-less or public-key-integrity store. Public-key integrity (`opts.integrity.mode: "public-key"`) wraps the AuthenticatedSafe in a CMS SignedData instead of a MAC, signed by any `pki.cms.sign` signer and carrying no MacData (§4); privacy stays independent, so `password` still PBES2-encrypts the bags. Public-key privacy wraps a SafeContents as a CMS EnvelopedData (AES-CBC, `id-envelopedData`, never GCM) encrypted to recipient public keys through the `pki.cms.encrypt` recipient model, via per-safe `recipients` or the `opts.recipientCerts` convenience, restricted to certificate recipients (RSA-OAEP, ECDH, X25519, X448, ML-KEM) since a password or KEK recipient could not be reopened by `open`. All four integrity-by-privacy combinations are permitted (§3.1). `open(pfx, password, opts)` reads a store back: it verifies the MAC first, so a wrong password is the MAC verdict rather than a decrypt error, then PBES2-decrypts every privacy safe and shrouded key bag and returns `{ integrityMode, macVerified, signers, keys, certs, crls, secrets }` — keys as re-validated PKCS#8 DER, certs, CRLs and secrets as raw DER, all with `friendlyName` and `localKeyId`, nested safes recursively. A MAC-less store is refused unless `opts.allowUnauthenticated`. A public-key-integrity store is verified through its CMS SignedData signature first (`pkcs12/signature-invalid` on failure), with the signer surfaced in `signers` but never trust-chained, which remains the caller's `pki.path.validate` step. A legacy-PBE store's Appendix C 3DES and RC2 bags are decrypted, RC2 through an in-tree RFC 2268 cipher, so an `openssl pkcs12 -legacy` or NSS store opens; the legacy RC4 schemes are refused. An `id-envelopedData` safe is decrypted with `opts.recipientKey` after the integrity gate (`pkcs12/no-recipient-key` when absent), every recipient-side fault and every post-integrity decrypt failure collapsing to the uniform `pkcs12/decrypt-failed`, and `opts.keys: 'crypto'` imports each key to a `CryptoKey`. It reads what OpenSSL and NSS produce. Returns DER or a PEM `PKCS12`, with a typed `Pkcs12Error` on failure. Parsing stays at `pki.schema.pkcs12.parse` — `build`, `verifyMac`, `open` |
240
240
  | `pki.cms` | CMS signing, verification, encryption, and compression (RFC 5652). `sign(content, signers, opts)` produces a SignedData (§5), attached or detached, with one or many signers over RSA, RSASSA-PSS, ECDSA, EdDSA, the post-quantum ML-DSA-44/65/87 (RFC 9882) and SLH-DSA (all twelve FIPS 205 sets, RFC 9814), and composite ML-DSA pairing ML-DSA with a traditional RSA, ECDSA, or EdDSA key (accepted only when both components verify, draft-ietf-lamps-cms-composite-sigs). It builds the signed attributes (content-type, message-digest, signing-time) as canonical DER, signs the exact §5.4 preimage, and emits a DER `Buffer` or PEM. A signer may also be key-only — `{ key, spki, keyIdentifier }` with no certificate — which RFC 5272 §3.2 requires when a Full PKI Request's signer is the key of a certification request that request carries: the signer identifier takes the subjectKeyIdentifier form carrying the identifier the request declares, the signature scheme resolves from the request's own public key, and no certificate is embedded. `verify(input, opts)` parses a SignedData over the strict `pki.schema.cms` codec, locates each SignerInfo's signer certificate by its issuerAndSerialNumber or subjectKeyIdentifier, and checks the signature over the exact §5.4 preimage: with signed attributes present it confirms the message-digest attribute equals the content digest and verifies over the DER re-encoding of the SignedAttributes (the on-wire `[0]` tag replaced by a universal SET OF), and otherwise directly over the content. It returns a per-signer verdict with the matched signer certificate. `valid` and `trusted` are separate claims and neither implies the other: a SignedData carries its own certificates, so `valid` says the signature is sound under one of them and nothing about who signed, while `trusted` says every signer chained to a root named in `opts.trustAnchors`, validated through the same RFC 5280 path engine `pki.path.validate` uses. Without anchors there is nothing to chain to and `trusted` is `false`; anchors that cannot be read throw, rather than reading as untrusted. An unrecognized option is refused rather than ignored. `countersign(cms, signers, opts)` adds a countersignature (§11.4) — a `SignerInfo` over the countersigned SignerInfo's signature value, any signer algorithm, nestable, with the primary bytes preserved so it still verifies — attached as the id-countersignature unsigned attribute; `verify` returns each countersignature's verdict under `signers[i].countersignatures` and every unsigned attribute — including an RFC 3161 timestamp token, attachable via `sign`'s `unsignedAttributes` — under `signers[i].unsignedAttrs`, surfaced unauthenticated. `encrypt(content, recipients, opts)` produces an EnvelopedData, AuthEnvelopedData (AES-GCM, the authenticated default), or EncryptedData, with recipients auto-dispatched off the certificate key to key transport (RSAES-OAEP; v1.5 is never emitted), key agreement (ephemeral-static ECDH over P-256/384/521 with the X9.63 KDF, and X25519/X448 with HKDF), symmetric key wrap, password (PBKDF2 with RFC 3211 PWRI-KEK), or the post-quantum ML-KEM KEMRecipientInfo (RFC 9629/9936), wrapping one fresh content key for every recipient. `decrypt(input, keyMaterial, opts)` recovers the content through the matching arm and returns it with an `authenticated` flag; every secret-dependent failure collapses to one uniform `cms/decrypt-failed` verdict (Bleichenbacher, EFAIL, and password-oracle freedom), and PKCS#1 v1.5 is decrypt-only under the RFC 3218 implicit-rejection countermeasure. Every key-establishment secret the toolkit allocates is wiped once used, on the failing path as well as the succeeding one: the KEM shared secret and its derived key-encryption key, the raw ECDH / X25519 / X448 agreement secret, a password-derived key-encryption key, and the content-encryption key itself, cleared once the message is complete since all recipients share it. Caller-supplied key material is never written to (best-effort; NIST SP 800-227 §4.2, RFC 9629 §7). `authenticate(content, recipients, opts)` produces an `id-ct-authData` (§9): cleartext content plus an HMAC-SHA-256/384/512 MAC, authenticated but not encrypted, with the fresh MAC key wrapped for every recipient through the same RecipientInfo model. The MAC covers the authenticated attributes (content-type and message-digest) re-tagged to the EXPLICIT SET OF (§9.2), or the content octets directly; `decrypt` recovers the MAC key, recomputes the MAC and independently the message-digest (§9.3), and releases the content only after both pass, with every secret-dependent failure collapsing to the uniform `cms/decrypt-failed`. `compress(content, opts)` and `decompress(input, opts)` produce and consume a CompressedData (RFC 3274; ZLIB, version 0, id-alg-zlibCompress); decompress bounds the uncompressed output at 16 MiB and stops before it is materialized, so a decompression bomb fails closed as `cms/decompress-too-large`. Compression is a size transform with no integrity or confidentiality (RFC 8551 §2.4.5). Fail-closed with typed `cms/*` errors — `sign`, `verify`, `countersign`, `encrypt`, `authenticate`, `decrypt`, `compress`, `decompress` |
package/lib/crl-sign.js CHANGED
@@ -629,7 +629,7 @@ function _serialHexOf(serial) {
629
629
 
630
630
  /**
631
631
  * @primitive pki.crl.isRevoked
632
- * @signature pki.crl.isRevoked(crl, serialNumber) -> entry | null
632
+ * @signature pki.crl.isRevoked(crl, serialNumber, opts?) -> entry | null
633
633
  * @since 0.3.9
634
634
  * @status stable
635
635
  * @spec RFC 5280 sec. 5.1.2.6
@@ -638,8 +638,22 @@ function _serialHexOf(serial) {
638
638
  * Look a certificate serial number up in a CRL's `revokedCertificates` list. `crl` is a DER `Buffer`, a
639
639
  * PEM string, or a parsed CRL; `serialNumber` is a `BigInt`, a safe integer, a decimal / `0x`-hex string,
640
640
  * or a magnitude `Buffer`. Returns the matching revoked-certificate entry (`{ serialNumber, serialNumberHex,
641
- * revocationDate, crlEntryExtensions }`) or `null` when the serial is not listed. A structural lookup only --
642
- * it does not verify the CRL signature or its currency; call `pki.crl.verify` / `pki.path.crlChecker` for that.
641
+ * revocationDate, crlEntryExtensions }`) or `null` when the serial is not listed. It does not verify the CRL
642
+ * signature; call `pki.crl.verify` or `pki.path.crlChecker` for that.
643
+ *
644
+ * With `opts.time` and `opts.historicalMode`, an entry is read against that instant the way
645
+ * `pki.path.crlChecker` reads it: by default a listed serial is revoked whatever its `revocationDate`
646
+ * says, since a date in the future is post-dating or clock skew and must not read good, and only an
647
+ * explicit historical reading has an entry dated after the instant not yet applying. `historicalMode`
648
+ * without `time` names no instant to read against and is refused.
649
+ *
650
+ * Pass `opts.time` to ask the question at an instant, and a CRL that does not speak for that instant is
651
+ * refused rather than answered from (`crl/not-current`): one whose `thisUpdate` is later, one whose
652
+ * `nextUpdate` has passed, and one carrying no `nextUpdate` at all, which states no window and so cannot
653
+ * be told from a replayed copy. Without `opts.time` currency goes unasked and the verb is the structural
654
+ * lookup it has always been -- `null` then means "not listed on this CRL", which is weaker than "not
655
+ * revoked". `pki.path.crlChecker` decides currency against the material it fetched and is the verb to
656
+ * reach for when the answer has to mean the stronger thing.
643
657
  *
644
658
  * It does check scope first, because a serial number means something only within the set of
645
659
  * certificates a CRL speaks for, and this verb is given a serial and nothing else. So a CRL that
@@ -668,9 +682,56 @@ function _serialHexOf(serial) {
668
682
  * { cert: signerCertDer, key: signerKeyPkcs8 });
669
683
  * pki.crl.isRevoked(crlDer, 0x1234n) ? "revoked" : "not listed";
670
684
  */
671
- function isRevoked(crl, serialNumber) {
685
+ var KNOWN_IS_REVOKED_KEYS = { time: 1, historicalMode: 1 };
686
+ function isRevoked(crl, serialNumber, opts) {
687
+ var o = guard.identifier.optionsObject(opts, _err, "crl/bad-input", "pki.crl.isRevoked options");
688
+ guard.identifier.assertKnownKeys(o, KNOWN_IS_REVOKED_KEYS, _err, "crl/bad-input", function (k) {
689
+ return "pki.crl.isRevoked has an unknown option " + JSON.stringify(k) +
690
+ "; it takes `time` and `historicalMode`";
691
+ });
692
+ // Both name the same instant, so one without the other asks nothing: historicalMode says how to
693
+ // read an entry against the time, and there is no time to read it against.
694
+ if (o.historicalMode !== undefined && o.time === undefined) {
695
+ throw _err("crl/bad-input", "pki.crl.isRevoked opts.historicalMode says how to read an entry " +
696
+ "against the instant asked about, so it needs opts.time as well");
697
+ }
698
+ if (o.historicalMode !== undefined && typeof o.historicalMode !== "boolean") {
699
+ throw _err("crl/bad-input", "pki.crl.isRevoked opts.historicalMode must be a boolean");
700
+ }
672
701
  var parsed = _coerceCrl(crl);
673
702
  var hex = _serialHexOf(serialNumber);
703
+ // CURRENCY, asked on the same footing as scope below and for the same reason: a CRL states the
704
+ // window it speaks for, and outside that window a serial being absent says nothing about the
705
+ // certificate -- the issuer promised a further list by `nextUpdate` and this one predates
706
+ // whatever it says. Read alone, `null` from an expired CRL reads as "not revoked", which is the
707
+ // composition this refusal exists to stop.
708
+ //
709
+ // Only when a time is given. This verb is handed a CRL and a serial, and the instant a question
710
+ // is asked at is the caller's to state throughout this toolkit rather than something read off
711
+ // the system clock here. Without one the verb stays the structural lookup it has always been,
712
+ // and its docstring says which question then went unasked.
713
+ if (o.time !== undefined) {
714
+ guard.time.assertValid(o.time, _err, "crl/bad-input", "pki.crl.isRevoked opts.time");
715
+ var at = guard.time.instantOf(o.time);
716
+ if (at < guard.time.instantOf(parsed.thisUpdate)) {
717
+ throw _err("crl/not-current", "this CRL takes effect at " + parsed.thisUpdate.toISOString() +
718
+ ", after the time asked about, so it says nothing about the certificate then");
719
+ }
720
+ if (!parsed.nextUpdate) {
721
+ throw _err("crl/not-current", "this CRL carries no nextUpdate, so it states no window it " +
722
+ "speaks for and a replayed copy cannot be told from a current one (RFC 5280 sec. 5.1.2.5 " +
723
+ "requires one of a conforming CA); ask pki.path.crlChecker, which decides currency against " +
724
+ "the material it fetched");
725
+ }
726
+ // Strictly past, so a time equal to nextUpdate is still covered -- the same boundary
727
+ // pki.path.crlChecker and the OCSP currency check use, and RFC 5280 sec. 5.1.2.5 makes
728
+ // nextUpdate the date BY WHICH the next list arrives rather than the moment this one lapses.
729
+ if (at > guard.time.instantOf(parsed.nextUpdate)) {
730
+ throw _err("crl/not-current", "this CRL was superseded after " +
731
+ parsed.nextUpdate.toISOString() + "; a serial missing from it says nothing about the " +
732
+ "certificate at the time asked about, since the issuer promised a further list by then");
733
+ }
734
+ }
674
735
  // SCOPE, before the serial is looked for at all. A serial number is only meaningful within the
675
736
  // set of certificates a CRL actually speaks for, and two shapes of CRL speak for a different set
676
737
  // than a caller matching on serial alone assumes. pki.path.crlChecker already refuses both; this
@@ -701,7 +762,17 @@ function isRevoked(crl, serialNumber) {
701
762
  }
702
763
  }
703
764
  for (var j = 0; j < parsed.revokedCertificates.length; j++) {
704
- if (parsed.revokedCertificates[j].serialNumberHex === hex) return parsed.revokedCertificates[j];
765
+ if (parsed.revokedCertificates[j].serialNumberHex !== hex) continue;
766
+ // A revocation takes effect on its revocationDate (RFC 5280 sec. 5.3), and how that reads
767
+ // against the instant asked about is the same question pki.path.crlChecker answers -- so the
768
+ // same answer is given here. Asked at the present moment a listed serial is revoked whatever
769
+ // that date says, because a future one is post-dating or clock skew and must not read good.
770
+ // Only an explicit historical reading -- validating as of a past instant, a timestamped
771
+ // signature say -- has an entry dated after that instant not yet applying.
772
+ if (o.historicalMode === true &&
773
+ guard.time.instantOf(parsed.revokedCertificates[j].revocationDate) >
774
+ guard.time.instantOf(o.time)) continue;
775
+ return parsed.revokedCertificates[j];
705
776
  }
706
777
  return null;
707
778
  }
package/lib/crl-verify.js CHANGED
@@ -11,7 +11,9 @@
11
11
  // It is not wired into index.js and is reached only by require.
12
12
 
13
13
  var guard = require("./guard-all");
14
+ var frameworkError = require("./framework-error");
14
15
 
16
+ var CrlError = frameworkError.CrlError;
15
17
  var _engine = null; // path-validate's _verifyWithSpki, injected at path-validate's module load via setEngine.
16
18
 
17
19
  function setEngine(verifyWithSpki) { _engine = verifyWithSpki; }
@@ -19,8 +21,11 @@ function setEngine(verifyWithSpki) { _engine = verifyWithSpki; }
19
21
  // Verify a parsed CRL's signature over its raw tbsCertList bytes under the issuer SubjectPublicKeyInfo DER.
20
22
  // Fail-closed to false on a non-octet-aligned signature or any engine fault (the engine never throws out).
21
23
  function verifyCrlSignature(crl, spkiBytes) {
22
- // _engine is injected by path-validate at its module load (setEngine); pki.crl.sign requires path-validate
23
- // for exactly that side-effect, so the engine is always set before a verify runs.
24
+ // pki.crl.sign requires path-validate for the injection side-effect, so the engine is set on every
25
+ // route that reaches here through the public verb. A future internal consumer that requires this
26
+ // module without it would otherwise call null and surface a raw TypeError out of a verify, which
27
+ // names neither the defect nor a verdict, so the wiring is asserted rather than assumed.
28
+ if (_engine == null) throw new CrlError("crl/bad-input", "the crl-verify signature engine is not initialized (require pki before use)");
24
29
  if (!guard.crypto.isOctetAligned(crl.signatureValue)) return Promise.resolve(false); // non-octet-aligned signature
25
30
  return _engine(crl.signatureAlgorithm, crl.signatureValue.bytes, spkiBytes, crl.tbsBytes);
26
31
  }
package/lib/csr-sign.js CHANGED
@@ -29,6 +29,8 @@ var pkix = require("./schema-pkix");
29
29
  var pkiBuild = require("./pki-build");
30
30
  var frameworkError = require("./framework-error");
31
31
  var guard = require("./guard-all");
32
+ require("./path-validate"); // side-effect: path-validate injects its signature engine into csr-verify at load
33
+ var csrVerify = require("./csr-verify");
32
34
 
33
35
  var CsrError = frameworkError.CsrError;
34
36
  var KNOWN_SPEC_KEYS = { subject: 1, subjectPublicKey: 1, extensionRequest: 1, challengePassword: 1, attributes: 1 };
@@ -175,4 +177,74 @@ function _sign(spec, key, opts) {
175
177
  });
176
178
  }
177
179
 
178
- module.exports = { sign: sign };
180
+ function _coerceCsr(request) {
181
+ return guard.parsed.acceptDerived(request, "csr", csr.parse, _err, "csr/bad-input", "the certification request");
182
+ }
183
+
184
+ /**
185
+ * @primitive pki.csr.verify
186
+ * @signature pki.csr.verify(request) -> Promise<{ verified, subject, subjectPublicKeyInfo, attributes, certificationRequestInfoBytes }>
187
+ * @since 0.5.13
188
+ * @status stable
189
+ * @spec RFC 2986 sec. 4.2
190
+ * @defends csr-proof-of-possession-bypass (CWE-347)
191
+ * @related pki.csr.sign, pki.schema.csr.parse, pki.x509.sign
192
+ *
193
+ * Verify a certification request's signature over its exact parsed `certificationRequestInfo` bytes.
194
+ * `request` is a DER `Buffer`, a PEM string, or a parsed request. A CSR carries no issuer: the
195
+ * verifying key is the `subjectPKInfo` inside the signed preimage, so this is the proof of
196
+ * possession `openssl req -verify` checks, and a CA that issues without it certifies a key the
197
+ * requester may not hold.
198
+ *
199
+ * The result carries `verified` alongside the `subject`, `subjectPublicKeyInfo`, `attributes` and
200
+ * `certificationRequestInfoBytes` that were verified, all re-derived from the request's own bytes.
201
+ * Issue from those rather than from the argument: a request normalized in place before verifying
202
+ * leaves the caller holding edited fields, and a bare boolean would answer about the signed bytes
203
+ * while the certificate got built from the edits.
204
+ *
205
+ * What `true` establishes is bounded, and the bound is the point. It says the producer held the
206
+ * private half of the key inside this request, over bytes that include the subject name and every
207
+ * requested extension, so none of them were altered after signing. It says nothing about who the
208
+ * producer is: the key is self-asserted, the name is self-asserted, and a requester free to choose
209
+ * both can prove possession of a key they generated a moment ago under any name they like. Binding
210
+ * that name to an identity is the enrollment protocol's job -- `pki.est`, `pki.cmc`, `pki.cmp`, or
211
+ * an out-of-band check -- and remains one after this returns `true`.
212
+ *
213
+ * Verification composes the one path-validation signature engine, with the same algorithm-confusion
214
+ * (RFC 9814 sec. 4 key-OID == sig-OID) and EdDSA low-order-point gates, rather than the self-check
215
+ * this module's signing side runs over a key the caller already controls. It fails closed to
216
+ * `false` on any import or verification fault; malformed input throws a typed `CsrError`.
217
+ *
218
+ * @example
219
+ * var pair = await pki.key.generate("Ed25519");
220
+ * var spki = await pki.key.export(pair.publicKey);
221
+ * var pkcs8 = await pki.key.export(pair.privateKey);
222
+ * var req = await pki.csr.sign({ subject: "CN=device-42", subjectPublicKey: spki }, { key: pkcs8 });
223
+ * var r = await pki.csr.verify(req);
224
+ * // Issue from r.subject / r.subjectPublicKeyInfo / r.attributes, which are the verified fields.
225
+ * var issued = r.verified
226
+ * ? await pki.x509.sign({ subject: r.subject.dn, subjectPublicKey: r.subjectPublicKeyInfo.bytes,
227
+ * notBefore: new Date("2026-01-01T00:00:00Z"), notAfter: new Date("2027-01-01T00:00:00Z") },
228
+ * { key: pkcs8 })
229
+ * : null;
230
+ */
231
+ function verify(request) { return guard.async.deferred(function () { return _verify(request); }); }
232
+ function _verify(request) {
233
+ // The fields come off the structure the door re-derived from the recorded bytes, never off the
234
+ // argument. A caller who normalizes a parse result in place -- rewriting subject or attributes
235
+ // before verifying -- still holds their edited object afterwards, and a bare boolean would let
236
+ // them issue from it while the verdict was about something else. Handing back the verified
237
+ // structure means the fields a CA acts on are the fields the signature covers.
238
+ var parsed = _coerceCsr(request);
239
+ return Promise.resolve(csrVerify.verifyCsrSignature(parsed)).then(function (ok) {
240
+ return {
241
+ verified: ok === true,
242
+ subject: parsed.subject,
243
+ subjectPublicKeyInfo: parsed.subjectPublicKeyInfo,
244
+ attributes: parsed.attributes,
245
+ certificationRequestInfoBytes: parsed.certificationRequestInfoBytes,
246
+ };
247
+ });
248
+ }
249
+
250
+ module.exports = { sign: sign, verify: verify };
@@ -0,0 +1,44 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (c) blamejs contributors
3
+ "use strict";
4
+ //
5
+ // @internal -- the single PKCS#10 proof-of-possession signature seam for an INBOUND request, shared
6
+ // by pki.csr.verify and by every enrollment path that accepts one, so all of them route through one
7
+ // signature engine (algorithm-confusion + EdDSA low-order + composite gates) and no second, weaker
8
+ // verifier of untrusted requests can drift. The producing side runs its own check over what it just
9
+ // signed (pki-build's assertSignatureVerifies, called from csr-sign), which is a different question
10
+ // about a key the caller already controls. path-validate owns the engine (_verifyWithSpki) and
11
+ // injects it here at its module load (setEngine). The seam lives in this internal module, not on
12
+ // path-validate's module.exports, which keeps it off the public pki.path surface. It is not wired
13
+ // into index.js and is reached only by require.
14
+ //
15
+ // It deliberately does NOT compose pki-build's assertSignatureVerifies. That helper is a producing-
16
+ // side self-check over a key the caller already controls, and it waives the EdDSA low-order-point
17
+ // gate on exactly that reasoning. An inbound request is untrusted input, where the gate is the
18
+ // point.
19
+
20
+ var guard = require("./guard-all");
21
+ var frameworkError = require("./framework-error");
22
+
23
+ var CsrError = frameworkError.CsrError;
24
+ var _engine = null; // path-validate's _verifyWithSpki, injected at path-validate's module load via setEngine.
25
+
26
+ function setEngine(verifyWithSpki) { _engine = verifyWithSpki; }
27
+
28
+ // Verify a parsed certification request's signature over its exact certificationRequestInfo bytes
29
+ // under the subjectPKInfo the request carries. A CSR has no issuer: the verifying key is the one
30
+ // inside the signed preimage, which is what makes the signature a proof of possession and nothing
31
+ // more. Fail-closed to false on a non-octet-aligned signature or any engine fault (the engine
32
+ // never throws out).
33
+ function verifyCsrSignature(parsed) {
34
+ // pki.csr.sign requires path-validate for the injection side-effect, so the engine is set on every
35
+ // route that reaches here through the public verb. A future internal consumer that requires this
36
+ // module without it would otherwise call null and surface a raw TypeError out of a verify, which
37
+ // names neither the defect nor a verdict, so the wiring is asserted rather than assumed.
38
+ if (_engine == null) throw new CsrError("csr/bad-input", "the csr-verify signature engine is not initialized (require pki before use)");
39
+ if (!guard.crypto.isOctetAligned(parsed.signatureValue)) return Promise.resolve(false); // non-octet-aligned signature
40
+ return _engine(parsed.signatureAlgorithm, parsed.signatureValue.bytes,
41
+ parsed.subjectPublicKeyInfo.bytes, parsed.certificationRequestInfoBytes);
42
+ }
43
+
44
+ module.exports = { setEngine: setEngine, verifyCsrSignature: verifyCsrSignature };
@@ -512,11 +512,13 @@ var _CLAIMS = {
512
512
  certificate: ["tbsBytes", "subjectPublicKeyInfo", "serialNumberHex"],
513
513
  crl: ["tbsBytes", "revokedCertificates", "crlExtensions"],
514
514
  cms: ["signerInfos", "encapContentInfo"],
515
+ csr: ["certificationRequestInfoBytes", "subjectPublicKeyInfo", "attributes"],
515
516
  };
516
517
  var _WHY = {
517
518
  certificate: "the signed byte range, the signature and the fields that range encodes are separate properties of a parsed object, so a rebuilt certificate (Object.assign, spread, a JSON round-trip) could have them describe different certificates: keep a real CA certificate's signed bytes and signature, replace only its public key, and every field is still well-formed",
518
519
  crl: "the signed byte range, the revocation list and the scope extensions are separate properties of a parsed object, so a rebuilt CRL could have them describe different CRLs: empty the revocation list and a correctly signed CRL reports a revoked certificate as good",
519
520
  cms: "the signed attribute bytes, the signature, the encapsulated content and the certificates that verify it are separate properties of a parsed object, so a rebuilt SignedData could have them describe different messages: keep a genuine signer's signature and signed attributes, put other content beside them, and every part of the check passes for content that signer never signed",
521
+ csr: "the signed byte range, the key it proves possession of and the subject and requested extensions that range encodes are separate properties of a parsed object, so a rebuilt request could have them describe different requests: keep a genuine requester's signed bytes and signature, replace only the subject or the extensionRequest, and the proof of possession still verifies while the certificate a CA issues from those fields is for a name and a set of extensions nobody signed",
520
522
  };
521
523
  // @enforced-by guard-shape-reinlined -- shares the fromTrustedSource shape it composes: a door that
522
524
  // tests the claim fields itself, instead of routing here, is the re-inline both replace.
@@ -540,7 +542,7 @@ function acceptDerived(input, kind, parse, E, code, label) {
540
542
  throw E(code, who + " must be a " + kind + " DER Buffer, a PEM string, or a parsed " + kind);
541
543
  }
542
544
  }
543
- var ns = { certificate: "x509", crl: "crl", cms: "cms" }[kind];
545
+ var ns = { certificate: "x509", crl: "crl", cms: "cms", csr: "csr" }[kind];
544
546
  return fromTrustedSource(input, kind, claims, parse, E, code,
545
547
  who + " must be its DER bytes, a PEM string, or an unmodified pki.schema." + ns +
546
548
  ".parse result: " + _WHY[kind]);
@@ -40,6 +40,7 @@ var crl = require("./schema-crl");
40
40
  var ocsp = require("./schema-ocsp");
41
41
  var ocspVerify = require("./ocsp-verify");
42
42
  var crlVerify = require("./crl-verify");
43
+ var csrVerify = require("./csr-verify");
43
44
  var cmpVerify = require("./cmp-verify");
44
45
  var cmsVerify = require("./cms-verify");
45
46
  var cmpSession = require("./cmp-session");
@@ -2269,6 +2270,11 @@ function _verifyWithSpki(sigAlg, rawSig, spkiBytes, tbsBytes) {
2269
2270
  // the seam on the public pki.path surface.
2270
2271
  crlVerify.setEngine(_verifyWithSpki);
2271
2272
 
2273
+ // The same seam for PKCS#10 proof of possession, so pki.csr.verify checks an inbound certification
2274
+ // request through this ONE engine rather than the producing side's self-check, which waives the
2275
+ // EdDSA low-order-point gate because it runs over a key the caller already controls.
2276
+ csrVerify.setEngine(_verifyWithSpki);
2277
+
2272
2278
  // ---- the OCSP revocation checker (RFC 6960) -------------------------------
2273
2279
 
2274
2280
  // The OCSP response-verification core (responder authorization + CertID binding + currency +
package/lib/schema-csr.js CHANGED
@@ -185,7 +185,10 @@ var CERTIFICATION_REQUEST = pkix.signedEnvelope(NS, CERTIFICATION_REQUEST_INFO,
185
185
  * csr.subject.dn; // -> "CN=req.example"
186
186
  * csr.attributes[0].type; // -> "1.2.840.113549.1.9.14"
187
187
  */
188
- var parse = pkix.makeParser({ pemLabel: "CERTIFICATE REQUEST", PemError: PemError, ErrorClass: CsrError, prefix: "csr", what: "certification request", topSchema: CERTIFICATION_REQUEST, ns: NS });
188
+ // Recording, because pki.csr.verify reaches a verdict from this result: the certificationRequestInfo
189
+ // byte range, the key it proves possession of, and the subject and requested extensions that range
190
+ // encodes are separate properties here, so the record is what binds them to one another.
191
+ var parse = pkix.makeRecordingParser({ pemLabel: "CERTIFICATE REQUEST", PemError: PemError, ErrorClass: CsrError, prefix: "csr", what: "certification request", topSchema: CERTIFICATION_REQUEST, ns: NS }, "csr");
189
192
 
190
193
  /**
191
194
  * @primitive pki.schema.csr.pemDecode
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/pki",
3
- "version": "0.5.11",
3
+ "version": "0.5.13",
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",
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:70c8692d-688f-45e6-a4af-49916d0b01c7",
5
+ "serialNumber": "urn:uuid:4bd2d60e-9fd1-4008-8d42-7a9d790812ac",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-08-19T06:52:36.445Z",
8
+ "timestamp": "2026-08-19T09:39:41.731Z",
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.11",
22
+ "bom-ref": "@blamejs/pki@0.5.13",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.5.11",
25
+ "version": "0.5.13",
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.11",
29
+ "purl": "pkg:npm/%40blamejs/pki@0.5.13",
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.11",
57
+ "ref": "@blamejs/pki@0.5.13",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]