@blamejs/pki 0.5.3 → 0.5.4
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 +21 -1
- package/README.md +3 -3
- package/lib/cms-encrypt.js +7 -11
- package/lib/crl-sign.js +152 -18
- package/lib/path-validate.js +113 -28
- package/lib/schema-pkix.js +62 -0
- package/lib/tsp-sign.js +40 -19
- package/lib/webauthn-mds.js +9 -11
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -4,7 +4,27 @@ 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.4 — 2026-08-15
|
|
8
|
+
|
|
9
|
+
A path verdict says whether revocation was ever established, a trust anchor's own distrust metadata can no longer sit inert, and a CRL is asked what only a certificate can answer.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- pki.path.validate reports revocationChecked, taking the weakest outcome on the path: false when no revocationChecker was supplied, "determined" when every certificate got an explicit good or revoked answer, "waived" when softFail turned an undetermined one into a pass, and "undetermined" when one could not be answered at all. The per-certificate revocation check carries the status it was decided on and marks a waiver, so "checked, and it said good" is distinguishable from "could not check, and you waived it". Those were the same object before, which is why a stored verdict could not answer whether revocation was ever established.
|
|
14
|
+
- pki.path.validate reports anchorConstraints: the checkedPurpose the anchor's trust metadata was judged under, and whether the distrustAfter date and the purposes delegator map each applied. A bare anchor says it carried nothing to apply rather than saying nothing at all.
|
|
15
|
+
- pki.tsp.verify returns trusted alongside valid. The entire out-of-path TSA certificate validation runs only when a trustAnchor is supplied, so one boolean collapsed "the token's signature and structural bindings hold" with "the timestamp authority is one you accept" -- and a timestamp is archived precisely to be re-read years later, when that distinction is the whole question. Without an anchor trusted is false: a definite answer, on the refusal branch as well as the accepting one.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- A trust anchor carrying purpose-scoped metadata is no longer validated as though it carried none. distrustAfter and purposes are indexed by key purpose, so neither could apply unless the caller passed opts.checkPurpose -- an option absent from the verb's own documentation while SECURITY.md described the enforcement as unconditional. An anchor carrying that metadata with no purpose to select by is now a configuration fault (path/bad-input) rather than a constraint that silently does nothing.
|
|
20
|
+
- pki.tsp.verify names the timeStamping purpose when it validates the TSA chain, so an anchor's trust metadata reaches the decision. It already required that key purpose of the TSA certificate; asking the certificate without asking the anchor checked one end of the chain and left the other -- a root explicitly distrusted for timestamping still answered trusted.
|
|
21
|
+
- A revocation checker that throws, or whose promise rejects, fails the path with path/revocation-checker-error carrying the fault -- including under softFail. It was laundered into an unknown status and then waived, so a broken checker and a working one that could not reach the responder produced the same verdict, and a certificate could pass with no revocation result at all. softFail is the caller opting into an undetermined ANSWER, which the built-in CRL and OCSP checkers report as status "unknown" for every unreachable or unverifiable condition; neither throws, so a throw is the caller's own fault to see rather than waive.
|
|
22
|
+
- pki.crl.verify asks what only a certificate can answer. Given one, it now also checks that the certificate is the issuer the CRL names and that its keyUsage -- when it carries one -- asserts cRLSign, the rule this module's signing side already enforced. A signature verifying says only that some key signed these bytes, so a CRL minted under an end-entity certificate of the same CA verified as that CA's CRL. Both answers are false rather than a throw, so trying each candidate issuer in turn still works; handed a bare key there is no certificate to carry either restriction and the signature remains all that is checked.
|
|
23
|
+
- pki.crl.isRevoked checks the CRL's scope before looking for the serial. A serial means something only inside the set of certificates a CRL speaks for, and this verb is handed a serial and nothing else -- so a CRL speaking for part of its issuer's certificates is now refused rather than answered from. A delta CRL lists changes since a base, so an entry recording that a certificate was released reads as a revocation when the delta is read alone (crl/delta-not-authoritative); an indirect CRL carries other issuers' entries, whose serial numbers are unrelated to yours (crl/indirect-not-supported), as does any CRL carrying certificateIssuer on an entry while declaring itself direct -- that contradiction belongs to the list, not to whichever entry the serial matched. Every other issuingDistributionPoint narrows the CRL to one distribution point, one kind of certificate, or a subset of revocation reasons (crl/scope-not-authoritative): which part applies is decided against fields of the certificate, so a serial absent from such a CRL is not a certificate that is unrevoked. Each of these previously answered, and the answer could be the opposite of the truth. pki.path.crlChecker is the verb for all of them -- it is handed the certificate, merges a delta with its base, and performs the RFC 5280 sec. 6.3.3 scope correspondence.
|
|
24
|
+
- A certificate's keyUsage is read the same way at every boundary that asks what the certificate may do. keyUsage is a NamedBitList, so DER drops its trailing zero bits (X.690 sec. 11.2.2) and RFC 5280 sec. 4.2.1.3 requires at least one bit set -- rules the shared extension decoder enforces, and which the issuing side and pki.path.validate therefore applied. Four boundaries read the bits themselves and applied neither, so one certificate could be authorized here and called malformed everywhere else: pki.crl.verify accepting a CRL signer, pki.tsp.verify accepting a timestamp authority, pki.cms.encrypt accepting a recipient, and the FIDO metadata reader accepting the leaf that signs a catalogue. All four now route through the decoder, so a certificate this toolkit refuses to issue is a certificate it refuses to trust.
|
|
25
|
+
- An issuingDistributionPoint scope flag is read under the encoding rules that define it, in both the CRL verbs and the path validator, rather than by inspecting a content byte. Each flag is an IMPLICIT BOOLEAN, so DER admits exactly one content octet of 0x00 or 0xFF; a byte test read an empty flag as absent and a multi-octet one by whichever byte it indexed, and absent is the reading that lets a CRL whose scope cannot be established answer a serial anyway. Signing rejects a pre-encoded issuingDistributionPoint on the same terms, so this toolkit cannot emit a CRL whose scope a relying party would read differently.
|
|
26
|
+
|
|
27
|
+
## v0.5.3 — 2026-08-14
|
|
8
28
|
|
|
9
29
|
pki.webauthn checks the ceremony at registration, withholds a revoked model's anchors, and refuses a name comparison it cannot perform.
|
|
10
30
|
|
package/README.md
CHANGED
|
@@ -228,18 +228,18 @@ comment blocks, is at [pkijs.com](https://pkijs.com).
|
|
|
228
228
|
| `pki.cmc` | Build and interpret CMC messages (RFC 5272). `build(spec, signer)` assembles a Full PKI Request across all three request arms (PKCS#10, CRMF, other) and signs it through `pki.cms.sign` under `id-cct-PKIData`. Body-part identifiers are unique across the whole message and never the reserved 0; a caller's clash is refused rather than renumbered, since a control may already reference it. An Identity Proof V2 witness is computed over the `reqSequence` bytes exactly as emitted (§6.2.1 step 1) rather than a re-serialization, a POP Link Witness is emitted only alongside the POP Link Random control §6.3.1.1 requires beside it, and a renewal carries neither Identification nor Identity Proof in either version. `verify(response, sent)` takes what the CA returned plus the state the client retained and reduces it to one terminal outcome: `issued`, `pending`, `confirm-required`, `pop-required`, or `rejected`. It binds the exchange first — Transaction Identifier, the Sender and Recipient Nonce echo compared in constant time and by full value so a truncation cannot match, and the Data Return echo — with each check applying only if the client sent that half, and, once sent, an absent or differing echo being a refusal, which is the replay defence. `bodyPartIDs` extends the same rule to what the response is about: a status reporting on a body part the request never sent is refused, which the transaction and nonce cannot catch, because a server can echo both correctly while answering about a different message. Several status controls are permitted and the worst governs, so a failure cannot hide behind an earlier success; the absence of any status control is success, per §6.1.2. The carrier's signature must verify (§3.2.1.3.4): a conforming SignedData carries its own signer certificate, so the ordinary build-then-verify flow needs nothing extra and the verdict reports `signatureVerified: true`. Where the signer is found nowhere the posture is fail-closed with a named opt-out — supply `certs` with the responder's certificate, or `allowUnverified: true`, in which case the verdict reports `signatureVerified: false`. Doing neither is refused, the opt-out never excuses a signature that is present and wrong, and a carrier with no signer at all is refused outright. The response's own `cmsSequence` and `otherMsgs` come back raw, since a request whose only arm was the other-message form has no certificate to return and §4.1 puts its answer there. Nothing is trusted: issued certificates are read from the CMS certificate bag (§4.2) and surfaced raw for `pki.path.validate`, and a Publish Trust Anchors control is surfaced with `trusted: false` rather than added to a store — `build`, `verify` |
|
|
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
|
-
| `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
|
|
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
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` |
|
|
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, per-extension criticality is fixed by the RFC, 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, and `isRevoked(crl, serialNumber)` looks a serial up. 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, per-extension criticality is fixed by the RFC, 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` |
|
|
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 is signed by the key of a certification request it 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` |
|
|
241
241
|
| `pki.smime` | S/MIME message assembly, verification, encryption, and compression over the CMS layer (RFC 8551). `sign(content, signers, opts)` wraps a MIME entity in either form: `multipart/signed`, where the content stays readable in any MUA and a detached CMS SignedData rides alongside as `application/pkcs7-signature` with a matching `micalg`, or `application/pkcs7-mime; smime-type=signed-data`, where the whole entity is a base64 CMS SignedData. The signed bytes are the entity's §3.1.1 canonical form with CRLF line endings, and `verify(message, opts)` unwraps both forms and recomputes over the same canonicalizer, so a transport that re-wraps line endings still verifies while a tampered part fails. `encrypt(content, recipients, opts)` envelopes a MIME entity as an opaque `application/pkcs7-mime` message and `decrypt(message, keyMaterial, opts)` opens one, as `smime-type=authEnveloped-data` (AES-GCM, confidentiality and integrity, the default) or `smime-type=enveloped-data` (AES-CBC, confidentiality only, so `decrypt` reports `authenticated: false`, the §3.3 no-integrity caveat). The `smime-type` is derived from the CMS body rather than the header, and decryption is fail-closed and oracle-free. The crypto is entirely `pki.cms.sign` / `verify` / `encrypt` / `decrypt`, so it is algorithm-agnostic: any RSA / RSASSA-PSS / ECDSA / EdDSA / ML-DSA / SLH-DSA signer and any RSA-OAEP / ECDH / X25519 / X448 / AES-KW / PBKDF2 / ML-KEM recipient carries through. As with `cms.verify`, `verify` returns the per-signer verdict plus the recovered content, and `valid` and `trusted` are separate claims: `valid` says the signature is sound under a certificate the message carried, `trusted` says every signer chained to a root named in `opts.trustAnchors`. Anchoring here is validated for email at both ends of the chain — the signer certificate must carry `emailProtection` (RFC 8551 §4.4.4) and the anchor's own trust metadata must permit that purpose, since a root can be distrusted for email while remaining a good TLS root. Override either with `requiredEku` / `checkPurpose`. `compress(content, opts)` and `decompress(message, opts)` add the opaque `application/pkcs7-mime; smime-type=compressed-data; name=smime.p7z` frame (§3.6, RFC 3274), a size transform with no integrity or confidentiality (§2.4.5), bounded against a bomb; the recovered content, which may itself be signed or enveloped, is returned for the caller to re-verify. Header protection (RFC 9788): `sign` and `encrypt` take `opts.protectHeaders`, which inlines the caller's `opts.headers` on the Cryptographic Payload root (its Content-Type gaining `hp="clear"` when signed or `hp="cipher"` when encrypted) so the CMS signature or encryption covers them, defeating a transport that rewrites or reads Subject, From, and the rest. `verify` and `decrypt` surface the authenticated inner set as `protectedHeaders` plus `headerProtection { present, mode, fromMismatch, confidential, legacy }`, so a tampered outer header cannot alter it and `fromMismatch` flags an outer From that disagrees. Encryption applies a Header Confidentiality Policy: the default `hcp_baseline` obscures the outer Subject to `[...]` and removes Comments and Keywords, so the real values live only in the ciphertext, and `decrypt` recovers them. Every emitted header routes through a fail-closed injection guard that rejects a CR, LF, or NUL value and a non-ftext name, and a malformed or contradictory `hp` wrap fails closed as `smime/bad-header-protection` rather than silently downgrading. The CMS crypto is unchanged. Inbound legacy RFC 8551 header protection is recognized opt-in: `verify` and `decrypt` with `opts.legacyHeaderProtection` detect a legacy `message/rfc822`-wrapped payload by the RFC 9788 §4.10.1 four-condition identification and surface the inner headers under `headerProtection.legacy = { headers, mode, fromMismatch, confidential }`, where `headers` is an ordered `[{ name, value }]` array retaining legally repeated fields such as `Received`. Those never appear in `protectedHeaders` and never set `present: true`. Because a legacy message is structurally indistinguishable from an ordinary forwarded `message/rfc822`, this is an explicit heuristic (§4.10.2, "no strong end-to-end guarantees"): a caller keying trust off `present` or `protectedHeaders` is never misled, and only one that explicitly reads `headerProtection.legacy.headers` and cross-checks `legacy.fromMismatch` consumes it. It is off by default, and a nested crypto layer, an inner `hp=`, a non-`message/rfc822` payload, or a duplicate Content-Type reports `legacy: null`. Bidirectionally interoperable with `openssl smime` and `openssl cms`. Fail-closed with typed `smime/*` errors — `sign`, `verify`, `encrypt`, `decrypt`, `compress`, `decompress` |
|
|
242
|
-
| `pki.tsp` | Time-Stamp Protocol (RFC 3161). `sign(messageImprint, tsa, opts)` produces a TimeStampToken: a CMS SignedData over `pki.cms.sign` whose content is a `TSTInfo` carrying the timestamped message imprint, the TSA policy, a serial number, and `genTime` with optional accuracy, nonce, and ordering, plus the §2.4.2 signing-certificate attribute binding the token to the TSA certificate (SHA-2 imprints, any `pki.cms.sign` TSA key). `request` and `parseRequest` build and parse the TimeStampReq a client sends (imprint, requested policy, nonce, certReq); `response` and `parseResponse` handle the TimeStampResp a TSA returns, either a granted status wrapping a token or a rejection with PKIStatus and failure info, with the §2.4.2 status-to-token coupling enforced in both directions. `verify(token, data, opts)` verifies a token fail-closed: the CMS signature over the exact signed bytes, the message imprint recomputed from the data, the TSTInfo content type, the ESSCertID(V2) binding to the TSA certificate, the §2.3 critical timeStamping-only extendedKeyUsage, the request nonce when used, and, with a trust anchor supplied, full certification-path validation of the TSA certificate at the token's `genTime
|
|
242
|
+
| `pki.tsp` | Time-Stamp Protocol (RFC 3161). `sign(messageImprint, tsa, opts)` produces a TimeStampToken: a CMS SignedData over `pki.cms.sign` whose content is a `TSTInfo` carrying the timestamped message imprint, the TSA policy, a serial number, and `genTime` with optional accuracy, nonce, and ordering, plus the §2.4.2 signing-certificate attribute binding the token to the TSA certificate (SHA-2 imprints, any `pki.cms.sign` TSA key). `request` and `parseRequest` build and parse the TimeStampReq a client sends (imprint, requested policy, nonce, certReq); `response` and `parseResponse` handle the TimeStampResp a TSA returns, either a granted status wrapping a token or a rejection with PKIStatus and failure info, with the §2.4.2 status-to-token coupling enforced in both directions. `verify(token, data, opts)` verifies a token fail-closed: the CMS signature over the exact signed bytes, the message imprint recomputed from the data, the TSTInfo content type, the ESSCertID(V2) binding to the TSA certificate, the §2.3 critical timeStamping-only extendedKeyUsage, the request nonce when used, and, with a trust anchor supplied, full certification-path validation of the TSA certificate at the token's `genTime` — judged under the `timeStamping` purpose, so an anchor's own per-purpose trust metadata reaches the decision rather than sitting inert. It returns `{ valid, trusted, genTime, serialNumber, tstInfo, … }`, where `trusted` is the separate claim that the authority chained to an anchor you named: without one there is nothing to chain to and it is `false`, which is the distinction a timestamp re-read years later turns on — `sign`, `request`, `parseRequest`, `response`, `parseResponse`, `verify` |
|
|
243
243
|
| `pki.ocsp` | Online Certificate Status Protocol (RFC 6960), both the responder and relying-party surface. `buildRequest(query, opts)` builds an OCSPRequest for one or more `{ cert, issuer }` pairs, with the CertID hashed under SHA-1 by default per the RFC 5019 lightweight profile or under SHA-2, plus an optional RFC 9654 nonce and an optional requestor signature. `sign(responseData, responder, opts)` produces a signed BasicOCSPResponse over the exact `ResponseData` DER, from the issuing CA directly or a delegated responder, under any `pki.cms.sign` key including the post-quantum ML-DSA and SLH-DSA sets, with `good`, `revoked` (reason and time), or `unknown` per-certificate status. `buildErrorResponse(status)` produces the unsigned §2.3 error (`tryLater`, `unauthorized`, and the rest). `verify(response, opts)` verifies a response fail-closed against the same hardened gates `pki.path.ocspChecker` runs: the CertID binding, responder authorization (the issuing CA, or a CA-issued delegate bearing id-kp-OCSPSigning and id-pkix-ocsp-nocheck and passing the full out-of-path certificate gates), the signature over `tbsResponseDataBytes`, currency against `thisUpdate` and `nextUpdate`, and the request-nonce echo. It returns `{ status: "good" / "revoked" / "unknown", … }` and never silently accepts. Transport-free — `buildRequest`, `sign`, `buildErrorResponse`, `verify` |
|
|
244
244
|
| `pki.ct` | Certificate Transparency (RFC 6962). `parseSctList` decodes the `SignedCertificateTimestampList` a certificate or OCSP response carries, a TLS-presentation-language payload inside the §3.3 double DER wrap, into per-SCT log id, exact `timestamp` (BigInt), named signature algorithm, and raw signature. `reconstructSignedData` rebuilds the exact `digitally-signed` preimage, and `verifySct` verifies an SCT signature against a log's public key, routing an ECDSA signature through the strict DER-conformance gate and verifying through the crypto engine, resolving true or false and throwing a typed error on a structural fault. On the producing side, `encodeSctList` builds the extension value byte for byte as the exact inverse of `parseSctList`, and `signSct` performs a log's signing step. For trust, `parseLogList` ingests the CT log-list JSON into constraint-carrying trusted logs, recomputing each log's id as SHA-256 of its key and refusing a disagreeing id (a swapped key, §3.2) and decoding the state and temporal-interval constraints; `verifySctWithLogList` resolves the log key from an SCT's log id, enforces the state (usable, qualified, and readonly trusted; retired only before retirement; pending and rejected refused) and the temporal-interval window, then delegates the signature check to `verifySct`. `verifyLogListSignature(json, signature, publicKey)` verifies the detached `log_list.sig` over the raw log-list bytes against a caller-pinned signer key (RSASSA-PKCS1-v1.5/SHA-256 and an EC P-256 arm, with forgeable-key defenses failing closed), cross-checked against `openssl dgst`. `fetchLogList(opts)` turns that chain into a live client: it GETs the `log_list.json` and its detached `log_list.sig` over `pki.transport`, verifies the detached signature over the raw fetched bytes against a caller-pinned distributor key before parsing, so an unverified document is never parsed, read, cached, or surfaced, then ingests the same bytes through `parseLogList` and returns the trusted-log set plus the surfaced `version` and `timestamp`. There is no baked-in vendor URL or key, TLS trust is explicit with `rejectUnauthorized` always on, each response is size-capped before the trust chain, and the transport is injectable so the whole path is testable offline — `parseSctList`, `reconstructSignedData`, `verifySct`, `encodeSctList`, `signSct`, `parseLogList`, `verifySctWithLogList`, `verifyLogListSignature`, `fetchLogList` |
|
|
245
245
|
| `pki.merkle` | Merkle-tree proof verification (RFC 6962 / RFC 9162). `leafHash`, `nodeHash`, and `emptyRootHash` build the domain-separated (0x00 leaf, 0x01 node) SHA-256 tree hashes. `verifyInclusion` folds an audit proof back to a root, and `verifyConsistency` reconstructs both the old and new root, which is the append-only guarantee; each is constant-time-compared to a trusted checkpoint root. Fail-closed on bad geometry, sync hashing, transport-free — `leafHash`, `nodeHash`, `emptyRootHash`, `verifyInclusion`, `verifyConsistency` |
|
package/lib/cms-encrypt.js
CHANGED
|
@@ -26,7 +26,9 @@ var guard = require("./guard-all");
|
|
|
26
26
|
var pbes2 = require("./pbes2");
|
|
27
27
|
var b = asn1.build;
|
|
28
28
|
var subtle = webcrypto.webcrypto.subtle;
|
|
29
|
+
var pkix = require("./schema-pkix");
|
|
29
30
|
var CmsError = frameworkError.CmsError;
|
|
31
|
+
var _KU_NS = pkix.makeNS("cms", CmsError, oid);
|
|
30
32
|
var WRAP_KEK_LENGTHS = schemaCms.WRAP_KEK_LENGTHS;
|
|
31
33
|
|
|
32
34
|
function O(n) { return oid.byName(n); }
|
|
@@ -106,18 +108,12 @@ function _skiOf(cert) {
|
|
|
106
108
|
}
|
|
107
109
|
|
|
108
110
|
// keyUsage bit assertion (M9/M15): a recipient cert WITH a keyUsage extension MUST assert `bitName`.
|
|
109
|
-
|
|
111
|
+
// Through the shared reader, which applies the NamedBitList rules a local bit test does not: DER
|
|
112
|
+
// drops trailing zero bits (X.690 sec. 11.2.2) and sec. 4.2.1.3 requires at least one bit set, so
|
|
113
|
+
// reading the bits here would accept as a recipient a certificate the issuing side calls malformed.
|
|
110
114
|
function _assertKeyUsage(cert, bitName, arm) {
|
|
111
|
-
var
|
|
112
|
-
|
|
113
|
-
if (exts[i].name === "keyUsage" && exts[i].value != null) {
|
|
114
|
-
var ku;
|
|
115
|
-
try { ku = asn1.read.bitString(asn1.decode(exts[i].value)); } catch (e) { throw _err("cms/bad-input", "the recipient certificate's keyUsage extension is malformed", e); }
|
|
116
|
-
var idx = KU_BIT[bitName], byteI = idx >> 3, mask = 0x80 >> (idx & 7);
|
|
117
|
-
if (byteI >= ku.bytes.length || (ku.bytes[byteI] & mask) === 0) throw _err("cms/bad-key-usage", "the " + arm + " recipient certificate's keyUsage does not assert " + bitName);
|
|
118
|
-
return;
|
|
119
|
-
}
|
|
120
|
-
}
|
|
115
|
+
var ku = pkix.keyUsageOf(_KU_NS, cert, _err, "cms/bad-input", "recipient certificate's");
|
|
116
|
+
if (ku && ku[bitName] !== true) throw _err("cms/bad-key-usage", "the " + arm + " recipient certificate's keyUsage does not assert " + bitName);
|
|
121
117
|
}
|
|
122
118
|
|
|
123
119
|
// ---- ktri (RSA) : RSAES-OAEP, SHA-256 default (v1.5 never emitted) ---------
|
package/lib/crl-sign.js
CHANGED
|
@@ -30,6 +30,7 @@ var signScheme = require("./sign-scheme");
|
|
|
30
30
|
var guard = require("./guard-all");
|
|
31
31
|
var frameworkError = require("./framework-error");
|
|
32
32
|
var pkix = require("./schema-pkix");
|
|
33
|
+
var schema = require("./schema-engine");
|
|
33
34
|
var pkiBuild = require("./pki-build");
|
|
34
35
|
var constants = require("./constants");
|
|
35
36
|
require("./path-validate"); // side-effect: path-validate injects its signature engine into crl-verify at load
|
|
@@ -183,17 +184,22 @@ function _idpValue(idp) {
|
|
|
183
184
|
// Validate a pre-encoded IssuingDistributionPoint value against the same RFC 5280 sec. 5.2.5 profile the
|
|
184
185
|
// object form enforces: MUST NOT be empty; at most one of onlyContainsUserCerts [1] / onlyContainsCACerts [2]
|
|
185
186
|
// TRUE; onlyContainsAttributeCerts [5] MUST be FALSE; indirectCRL [4] deferred (crlChecker skips indirect CRLs).
|
|
187
|
+
// Read through the shared sec. 5.2.5 grammar, not a walk over the raw children: each scope flag is
|
|
188
|
+
// an IMPLICIT BOOLEAN whose meaning is fixed by the encoding rules, and a caller-supplied encoding
|
|
189
|
+
// is exactly where a flag that is not one content octet of 0x00 or 0xFF arrives. Deciding TRUE from
|
|
190
|
+
// a content byte would emit a CRL whose scope this toolkit and the relying party read differently.
|
|
191
|
+
var _PRE_ENCODED_IDP_SCHEMA = pkix.issuingDistributionPoint("crl/bad-idp");
|
|
186
192
|
function _validatePreEncodedIdp(inner) {
|
|
187
193
|
if (!inner.children || !inner.children.length) throw _err("crl/bad-idp", "pre-encoded issuingDistributionPoint MUST NOT be empty (RFC 5280 sec. 5.2.5)");
|
|
188
|
-
var
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
if (
|
|
193
|
-
if (c.tagNumber === 5 && isTrue) throw _err("crl/bad-idp", "onlyContainsAttributeCerts=TRUE is not permitted for a conforming CRL issuer (RFC 5280 sec. 5.2.5)");
|
|
194
|
-
if ((c.tagNumber === 1 || c.tagNumber === 2) && isTrue) scopeTrue++;
|
|
194
|
+
var f = schema.walk(_PRE_ENCODED_IDP_SCHEMA, inner, NS).fields;
|
|
195
|
+
// A present DEFAULT-FALSE flag encodes a default DER omits (X.690 sec. 11.5), so it is rejected
|
|
196
|
+
// whichever value it carries rather than counted as the FALSE it spells.
|
|
197
|
+
["onlyContainsUserCerts", "onlyContainsCACerts", "onlyContainsAttributeCerts"].forEach(function (name) {
|
|
198
|
+
if (f[name].present && f[name].value !== true) throw _err("crl/bad-idp", "pre-encoded issuingDistributionPoint " + name + " encodes its DEFAULT FALSE, which DER omits (X.690 sec. 11.5)");
|
|
195
199
|
});
|
|
196
|
-
if (
|
|
200
|
+
if (f.indirectCRL.present) throw _err("crl/bad-idp", _INDIRECT_IDP_DEFERRED);
|
|
201
|
+
if (f.onlyContainsAttributeCerts.present) throw _err("crl/bad-idp", "onlyContainsAttributeCerts=TRUE is not permitted for a conforming CRL issuer (RFC 5280 sec. 5.2.5)");
|
|
202
|
+
if (f.onlyContainsUserCerts.present && f.onlyContainsCACerts.present) throw _err("crl/bad-idp", "at most one of onlyContainsUserCerts / onlyContainsCACerts may be TRUE (RFC 5280 sec. 5.2.5)");
|
|
197
203
|
}
|
|
198
204
|
|
|
199
205
|
// freshestCRL / CRLDistributionPoints (sec. 5.2.6): SEQUENCE OF DistributionPoint carrying only
|
|
@@ -508,15 +514,42 @@ function _coerceCrl(crl) {
|
|
|
508
514
|
throw _err("crl/bad-input", "crl must be a CRL DER Buffer, a PEM string, or a parsed CRL (from pki.schema.crl.parse)");
|
|
509
515
|
}
|
|
510
516
|
|
|
511
|
-
|
|
517
|
+
// The issuer's key AND, when the caller supplied a certificate rather than a bare key, the
|
|
518
|
+
// certificate itself -- because a CRL signature that verifies says only that SOME key signed these
|
|
519
|
+
// bytes. Whether that key was allowed to sign a CRL, and whether it belongs to the issuer this CRL
|
|
520
|
+
// names, are separate questions that only a certificate can answer.
|
|
521
|
+
function _resolveIssuer(issuer) {
|
|
512
522
|
if (issuer == null) throw _err("crl/bad-input", "an issuer is required to verify a CRL");
|
|
513
|
-
if (Buffer.isBuffer(issuer)) { _assertValidSpki(issuer, "issuer SPKI"); return issuer; }
|
|
514
|
-
if (issuer.cert != null)
|
|
515
|
-
if (issuer.publicKey != null) { var spki = _reqDer(issuer.publicKey, "issuer.publicKey"); _assertValidSpki(spki, "issuer.publicKey"); return spki; }
|
|
516
|
-
if (issuer.subjectPublicKeyInfo && issuer.subjectPublicKeyInfo.bytes) return issuer.subjectPublicKeyInfo.bytes; // a parsed certificate
|
|
523
|
+
if (Buffer.isBuffer(issuer)) { _assertValidSpki(issuer, "issuer SPKI"); return { spki: issuer, cert: null }; }
|
|
524
|
+
if (issuer.cert != null) { var ic = _parseIssuerCert(issuer.cert); return { spki: ic.subjectPublicKeyInfo.bytes, cert: ic }; }
|
|
525
|
+
if (issuer.publicKey != null) { var spki = _reqDer(issuer.publicKey, "issuer.publicKey"); _assertValidSpki(spki, "issuer.publicKey"); return { spki: spki, cert: null }; }
|
|
526
|
+
if (issuer.subjectPublicKeyInfo && issuer.subjectPublicKeyInfo.bytes) return { spki: issuer.subjectPublicKeyInfo.bytes, cert: issuer }; // a parsed certificate
|
|
517
527
|
throw _err("crl/bad-input", "issuer must be { cert }, { publicKey } (SPKI DER), or a raw SPKI Buffer");
|
|
518
528
|
}
|
|
519
529
|
|
|
530
|
+
// RFC 5280 sec. 5.1.1.2 / 6.3.3: the certificate that signed a CRL must BE the CRL's issuer, and if
|
|
531
|
+
// it carries a keyUsage extension that extension must assert cRLSign. Neither is implied by the
|
|
532
|
+
// signature verifying: any key can sign any bytes, and a certificate restricted to digitalSignature
|
|
533
|
+
// chains to its root perfectly well while having no authority to revoke anything. The producing
|
|
534
|
+
// side of this same file already refuses to SIGN a CRL with a certificate lacking cRLSign; the
|
|
535
|
+
// verifying side asked neither question, so a CRL minted under an end-entity certificate of the
|
|
536
|
+
// same CA verified as that CA's CRL.
|
|
537
|
+
// Returns false rather than throwing, because both answers are statements about the CRL, not about
|
|
538
|
+
// the caller's input: "this CRL was not validly issued by the certificate you named". That keeps
|
|
539
|
+
// the verb's boolean contract, and keeps working the ordinary pattern of trying each candidate
|
|
540
|
+
// issuer to find which one issued a CRL. A MALFORMED keyUsage is different -- it is a defect in the
|
|
541
|
+
// caller's own certificate rather than a verdict about the CRL -- and throws.
|
|
542
|
+
function _issuerMaySign(parsed, cert) {
|
|
543
|
+
if (!guard.name.dnEqual(parsed.issuer.rdns, cert.subject.rdns, _err, "crl/bad-issuer", "the CRL issuer")) return false;
|
|
544
|
+
// Through the shared reader, not a local BIT STRING read: keyUsage is a NamedBitList, so DER drops
|
|
545
|
+
// its trailing zero bits and sec. 4.2.1.3 requires at least one bit set, and a boundary reading
|
|
546
|
+
// the bits itself applies neither -- authorizing here a certificate the signing side and the path
|
|
547
|
+
// validator both refuse as malformed.
|
|
548
|
+
var ku = pkix.keyUsageOf(NS, cert, _err, "crl/bad-issuer", "issuer certificate");
|
|
549
|
+
if (!ku) return true; // absent keyUsage places no restriction (sec. 4.2.1.3)
|
|
550
|
+
return ku.cRLSign === true;
|
|
551
|
+
}
|
|
552
|
+
|
|
520
553
|
/**
|
|
521
554
|
* @primitive pki.crl.verify
|
|
522
555
|
* @signature pki.crl.verify(crl, issuer) -> Promise<boolean>
|
|
@@ -531,8 +564,16 @@ function _resolveIssuerSpki(issuer) {
|
|
|
531
564
|
* (SPKI DER), or a raw SPKI `Buffer`. Verification composes the one path-validation signature engine
|
|
532
565
|
* `pki.path.crlChecker` uses -- the same algorithm-confusion (RFC 9814 sec. 4 key-OID == sig-OID) and
|
|
533
566
|
* EdDSA low-order-point gates -- so there is no second, weaker CRL verifier. It fails closed to `false` on
|
|
534
|
-
* any resolution, import, or verification fault; malformed input throws a typed `CrlError`.
|
|
535
|
-
*
|
|
567
|
+
* any resolution, import, or verification fault; malformed input throws a typed `CrlError`.
|
|
568
|
+
*
|
|
569
|
+
* Given a CERTIFICATE rather than a bare key, it also asks what only a certificate can answer: that
|
|
570
|
+
* the certificate is the issuer this CRL names, and that its keyUsage -- when it carries one -- asserts
|
|
571
|
+
* `cRLSign` (RFC 5280 sec. 4.2.1.3, the same rule this module's signing side already enforces). Either
|
|
572
|
+
* failing is `false`: a statement about the CRL, not about the caller's input, so trying each candidate
|
|
573
|
+
* issuer in turn still works. A signature verifying says only that SOME key signed these bytes; without
|
|
574
|
+
* those two questions a CRL minted under an end-entity certificate of the same CA verified as that CA's.
|
|
575
|
+
* Handed a bare SPKI there is no certificate to carry either restriction, and the signature is all that
|
|
576
|
+
* is checked. Currency and distribution-point scope remain `pki.path.crlChecker`.
|
|
536
577
|
*
|
|
537
578
|
* @example
|
|
538
579
|
* var pair = await pki.key.generate("Ed25519");
|
|
@@ -548,8 +589,12 @@ function _resolveIssuerSpki(issuer) {
|
|
|
548
589
|
function verify(crl, issuer) { return Promise.resolve().then(function () { return _verify(crl, issuer); }); }
|
|
549
590
|
function _verify(crl, issuer) {
|
|
550
591
|
var parsed = _coerceCrl(crl);
|
|
551
|
-
var
|
|
552
|
-
|
|
592
|
+
var resolved = _resolveIssuer(issuer);
|
|
593
|
+
// Asked BEFORE the signature: a certificate that may not sign CRLs, or that is not this CRL's
|
|
594
|
+
// issuer, is a caller-configuration answer that does not depend on the math, and spending a
|
|
595
|
+
// verification on it would let the cheaper question be answered by the more expensive one.
|
|
596
|
+
if (resolved.cert && !_issuerMaySign(parsed, resolved.cert)) return false;
|
|
597
|
+
return crlVerify.verifyCrlSignature(parsed, resolved.spki);
|
|
553
598
|
}
|
|
554
599
|
|
|
555
600
|
function _serialHexOf(serial) {
|
|
@@ -578,6 +623,22 @@ function _serialHexOf(serial) {
|
|
|
578
623
|
* revocationDate, crlEntryExtensions }`) or `null` when the serial is not listed. A structural lookup only --
|
|
579
624
|
* it does NOT verify the CRL signature or its currency; call `pki.crl.verify` / `pki.path.crlChecker` for that.
|
|
580
625
|
*
|
|
626
|
+
* It does check SCOPE first, because a serial number means something only within the set of
|
|
627
|
+
* certificates a CRL speaks for, and this verb is given a serial and nothing else -- so a CRL that
|
|
628
|
+
* speaks for part of its issuer's certificates is refused rather than answered from:
|
|
629
|
+
*
|
|
630
|
+
* - A DELTA CRL lists changes since a base, so a serial in it may be there to say the certificate
|
|
631
|
+
* was RELEASED; read alone, the entry meaning "no longer revoked" reads as "revoked"
|
|
632
|
+
* (`crl/delta-not-authoritative`). Merge it with its base through `pki.path.crlChecker`.
|
|
633
|
+
* - An INDIRECT CRL carries entries for other issuers, whose serials are unrelated to yours
|
|
634
|
+
* (`crl/indirect-not-supported`) -- as does any CRL carrying `certificateIssuer` on an entry
|
|
635
|
+
* while not declaring itself indirect, a contradiction about whose certificates it lists.
|
|
636
|
+
* - Any other `issuingDistributionPoint` narrows the CRL to one distribution point, one kind of
|
|
637
|
+
* certificate, or a subset of revocation reasons (`crl/scope-not-authoritative`). Which part
|
|
638
|
+
* applies is decided against fields of the CERTIFICATE, which this verb never sees, so an absent
|
|
639
|
+
* serial is not an unrevoked certificate. `pki.path.crlChecker` is handed the certificate and
|
|
640
|
+
* performs the RFC 5280 sec. 6.3.3 correspondence.
|
|
641
|
+
*
|
|
581
642
|
* @example
|
|
582
643
|
* var pair = await pki.key.generate("Ed25519");
|
|
583
644
|
* var signerKeyPkcs8 = await pki.key.export(pair.privateKey);
|
|
@@ -592,10 +653,83 @@ function _serialHexOf(serial) {
|
|
|
592
653
|
function isRevoked(crl, serialNumber) {
|
|
593
654
|
var parsed = _coerceCrl(crl);
|
|
594
655
|
var hex = _serialHexOf(serialNumber);
|
|
656
|
+
// SCOPE, before the serial is looked for at all. A serial number is only meaningful within the
|
|
657
|
+
// set of certificates a CRL actually speaks for, and two shapes of CRL speak for a different set
|
|
658
|
+
// than a caller matching on serial alone assumes. pki.path.crlChecker already refuses both; this
|
|
659
|
+
// verb answered from any CRL it was handed, so the toolkit knew the rule and applied it in one
|
|
660
|
+
// consumer -- which is how the standalone verb came to give the opposite answer.
|
|
661
|
+
//
|
|
662
|
+
// A DELTA CRL lists CHANGES since its base, so a serial present in it may be there to say the
|
|
663
|
+
// certificate was RELEASED (removeFromCRL); read alone, the extension that means "no longer
|
|
664
|
+
// revoked" reads as "revoked". The answer is only derivable from the delta MERGED with its base,
|
|
665
|
+
// which pki.path.crlChecker does.
|
|
666
|
+
if (_findExtOid(parsed.crlExtensions, "deltaCRLIndicator")) {
|
|
667
|
+
throw _err("crl/delta-not-authoritative", "this is a delta CRL: it lists changes since a base CRL, so a serial appearing in it may be RELEASED rather than revoked. Merge it with its base -- pki.path.crlChecker does -- rather than reading a revocation status out of the delta alone");
|
|
668
|
+
}
|
|
669
|
+
// An INDIRECT CRL carries entries for OTHER issuers' certificates, identified by a per-entry
|
|
670
|
+
// certificateIssuer attribute. Matching on serial alone attributes another issuer's revocation to
|
|
671
|
+
// this one's certificate of the same serial number -- serials are unique per issuer, not globally.
|
|
672
|
+
var idp = _findExtOid(parsed.crlExtensions, "issuingDistributionPoint");
|
|
673
|
+
if (idp) _assertAuthoritativeScope(idp);
|
|
674
|
+
// certificateIssuer (sec. 5.3.3) names the issuer an entry belongs to, and is meaningful ONLY on
|
|
675
|
+
// an indirect CRL -- which was refused above. One here means the CRL declares itself direct while
|
|
676
|
+
// carrying an entry that says otherwise, and the two readings disagree about whose certificates
|
|
677
|
+
// this list covers. That is a property of the CRL, so it is settled BEFORE the lookup and over
|
|
678
|
+
// EVERY entry: checking only the entry that matches would leave the not-listed answer -- the one
|
|
679
|
+
// that says the certificate is fine -- coming from a list whose own entries dispute what it lists.
|
|
595
680
|
for (var i = 0; i < parsed.revokedCertificates.length; i++) {
|
|
596
|
-
if (parsed.revokedCertificates[i].
|
|
681
|
+
if (_findExtOid(parsed.revokedCertificates[i].crlEntryExtensions, "certificateIssuer")) {
|
|
682
|
+
throw _err("crl/indirect-not-supported", "a revoked-certificate entry carries certificateIssuer, which names another issuer, on a CRL that does not declare itself indirect -- the entry and the CRL disagree about whose certificates this list covers, so no revocation status follows from it");
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
for (var j = 0; j < parsed.revokedCertificates.length; j++) {
|
|
686
|
+
if (parsed.revokedCertificates[j].serialNumberHex === hex) return parsed.revokedCertificates[j];
|
|
597
687
|
}
|
|
598
688
|
return null;
|
|
599
689
|
}
|
|
600
690
|
|
|
691
|
+
// One extension out of a parsed list, by registered OID name. Null-safe: an absent list is no
|
|
692
|
+
// extension rather than a fault, which is what every caller here means by it.
|
|
693
|
+
function _findExtOid(list, name) {
|
|
694
|
+
var want = O(name);
|
|
695
|
+
return (list || []).filter(function (e) { return e.oid === want; })[0] || null;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// The issuingDistributionPoint states which certificates a CRL speaks for (sec. 5.2.5), and every
|
|
699
|
+
// form of it narrows that set below what a bare serial lookup assumes. `isRevoked` is handed a
|
|
700
|
+
// serial and nothing else, so it cannot tell whether the certificate behind that serial falls in
|
|
701
|
+
// the scope -- that decision needs the certificate, which is what pki.path.crlChecker has and this
|
|
702
|
+
// verb does not. So the presence of the extension is the refusal, whichever field carries it:
|
|
703
|
+
//
|
|
704
|
+
// indirectCRL entries name their own issuers, and serials are unique per issuer: a match
|
|
705
|
+
// would attribute another issuer's revocation to your certificate.
|
|
706
|
+
// distributionPoint the CRL covers the certificates whose own distribution point corresponds to
|
|
707
|
+
// this one (sec. 6.3.3(b)(2)(i)) -- a byte-exact comparison against a field of
|
|
708
|
+
// the certificate.
|
|
709
|
+
// onlyContainsUserCerts / onlyContainsCACerts / onlyContainsAttributeCerts
|
|
710
|
+
// the CRL covers one KIND of certificate; a serial does not say which kind it
|
|
711
|
+
// names, so an absent entry says nothing about a certificate of another kind.
|
|
712
|
+
// onlySomeReasons the CRL covers some revocation REASONS; a certificate revoked for a reason
|
|
713
|
+
// outside the set is absent from it while being revoked.
|
|
714
|
+
//
|
|
715
|
+
// Written as a throwing assertion rather than a predicate so the malformed case cannot be mistaken
|
|
716
|
+
// for a verdict about the certificate. The extension is read through the shared sec. 5.2.5 grammar
|
|
717
|
+
// rather than by walking its children, because the flags are IMPLICIT BOOLEANs and only the encoding
|
|
718
|
+
// rules say what their bytes mean: a walk that tests a content byte reads an EMPTY [4] as an absent
|
|
719
|
+
// flag and a multi-octet one by whichever byte it indexes, and "absent, so unscoped" is precisely
|
|
720
|
+
// the reading that lets an unreadable scope license an answer.
|
|
721
|
+
var _IDP_SCHEMA = pkix.issuingDistributionPoint("crl/scope-not-authoritative");
|
|
722
|
+
var _INDIRECT_REFUSAL = "this CRL is marked indirect (issuingDistributionPoint indirectCRL): its entries name their own issuers, so a serial alone does not identify a certificate. Reading a revocation status from it by serial would attribute another issuer's revocation to yours";
|
|
723
|
+
var _SCOPED_REFUSAL = "this CRL carries an issuingDistributionPoint, so it speaks for part of its issuer's certificates rather than all of them, and which part is decided against fields of the certificate -- which this verb is not given. A serial absent from it is not a certificate that is unrevoked. Use pki.path.crlChecker, which is handed the certificate and performs the RFC 5280 sec. 6.3.3 scope correspondence";
|
|
724
|
+
function _assertAuthoritativeScope(ext) {
|
|
725
|
+
var m;
|
|
726
|
+
try { m = schema.walk(_IDP_SCHEMA, asn1.decode(ext.value), NS); }
|
|
727
|
+
catch (e) { throw _err("crl/scope-not-authoritative", "the issuingDistributionPoint cannot be read, so this CRL's scope cannot be established", e); }
|
|
728
|
+
var f = m.fields;
|
|
729
|
+
// Named first so the sharper reason wins: indirect is not merely a narrower scope, it is a list
|
|
730
|
+
// whose serials belong to other issuers.
|
|
731
|
+
if (f.indirectCRL.present && f.indirectCRL.value === true) throw _err("crl/indirect-not-supported", _INDIRECT_REFUSAL);
|
|
732
|
+
throw _err("crl/scope-not-authoritative", _SCOPED_REFUSAL);
|
|
733
|
+
}
|
|
734
|
+
|
|
601
735
|
module.exports = { sign: sign, verify: verify, isRevoked: isRevoked };
|
package/lib/path-validate.js
CHANGED
|
@@ -1127,13 +1127,35 @@ function validateCriticalExtensionStructure(cert) {
|
|
|
1127
1127
|
* The value-carrying options (`time`, `maxPathCerts`, `maxPolicyNodes`, the
|
|
1128
1128
|
* subtree seeds, `userInitialPolicySet`, `requiredEku`) are validated at the
|
|
1129
1129
|
* entry point -- a mis-shaped value throws `path/bad-input` rather than
|
|
1130
|
-
* silently not applying. Returns `{ valid,
|
|
1131
|
-
* results, workingPublicKey, workingPublicKeyAlgorithm,
|
|
1130
|
+
* silently not applying. Returns `{ valid, revocationChecked, anchorConstraints,
|
|
1131
|
+
* path, results, workingPublicKey, workingPublicKeyAlgorithm,
|
|
1132
1132
|
* workingPublicKeyParameters, validPolicyTree }` where `results[i].checks`
|
|
1133
1133
|
* carries a per-check reason code (`path/*`) for every step. Pure and
|
|
1134
1134
|
* re-entrant -- no input object is mutated. An empty path or a missing anchor
|
|
1135
1135
|
* throws a typed `PathError`.
|
|
1136
1136
|
*
|
|
1137
|
+
* `valid` alone cannot say whether revocation was ever established, so
|
|
1138
|
+
* `revocationChecked` answers separately, taking the WEAKEST outcome on the
|
|
1139
|
+
* path: `false` when no `revocationChecker` was supplied, `"determined"` when
|
|
1140
|
+
* every certificate got an explicit good or revoked answer, `"waived"` when
|
|
1141
|
+
* `softFail` turned an undetermined one into a pass, and `"undetermined"` when
|
|
1142
|
+
* one could not be answered at all and the path fails for it. The
|
|
1143
|
+
* per-certificate `revocation` check carries the `status` it was decided on and
|
|
1144
|
+
* marks a waiver, so "checked, good" is distinguishable from "could not check,
|
|
1145
|
+
* and you waived it" -- which a stored verdict is re-read to settle. A checker
|
|
1146
|
+
* that THROWS is a fault in the checker rather than a status it reported, so it
|
|
1147
|
+
* fails the path as `path/revocation-checker-error` carrying the fault whatever
|
|
1148
|
+
* `softFail` says -- `softFail` opts into an undetermined ANSWER, and the
|
|
1149
|
+
* built-in checkers report one as `{ status: "unknown" }` rather than throwing.
|
|
1150
|
+
*
|
|
1151
|
+
* `anchorConstraints` reports what the anchor's own trust metadata decided:
|
|
1152
|
+
* the `checkedPurpose` it was judged under, and whether the `distrustAfter`
|
|
1153
|
+
* date and the `purposes` delegator map each applied. That metadata is keyed BY
|
|
1154
|
+
* key purpose, so an anchor carrying it while `opts.checkPurpose` is absent is a
|
|
1155
|
+
* configuration fault (`path/bad-input`) rather than a constraint that silently
|
|
1156
|
+
* does nothing -- a root distrusted years ago must not quietly validate a
|
|
1157
|
+
* current leaf.
|
|
1158
|
+
*
|
|
1137
1159
|
* @example
|
|
1138
1160
|
* var pair = await pki.key.generate("Ed25519");
|
|
1139
1161
|
* var der = await pki.x509.sign({ subject: "example.com", subjectPublicKey: await pki.key.export(pair.publicKey),
|
|
@@ -1204,6 +1226,22 @@ async function validate(path, opts) {
|
|
|
1204
1226
|
// then fails the path closed instead of silently skipping the step.
|
|
1205
1227
|
var requireRevocation = opts.requireRevocation === true;
|
|
1206
1228
|
var failed = false;
|
|
1229
|
+
// Whether the revocation rule RAN, and whether any certificate's answer had to be waived. The
|
|
1230
|
+
// verdict reports the two separately because they are different claims: a caller re-reading a
|
|
1231
|
+
// stored result needs to tell "every certificate was determined not revoked" from "the step was
|
|
1232
|
+
// skipped" from "the step could not conclude and you had asked for that to pass".
|
|
1233
|
+
var revocationRan = false, revocationWaived = false, revocationUndetermined = false;
|
|
1234
|
+
// Which of the anchor's purpose-scoped constraints actually decided anything, reported so a
|
|
1235
|
+
// verdict can be re-read to tell an anchor that was judged from one that carried nothing to judge.
|
|
1236
|
+
var anchorDistrustApplied = false, anchorPurposeApplied = false;
|
|
1237
|
+
// A trust anchor carrying purpose-scoped metadata, validated with no purpose to select by, is a
|
|
1238
|
+
// configuration fault. The constraint is KEYED by purpose -- there is no way to apply
|
|
1239
|
+
// `distrustAfter.serverAuth` without being told the validation is about serverAuth -- so without
|
|
1240
|
+
// one the caller's stated intent would be discarded silently, and a root distrusted years ago
|
|
1241
|
+
// would validate a current leaf. An anchor carrying no such metadata is unaffected.
|
|
1242
|
+
if (!checkPurpose && opts.trustAnchor && _hasPurposeScopedMetadata(opts.trustAnchor)) {
|
|
1243
|
+
throw E("path/bad-input", "validate: the trust anchor carries purpose-scoped metadata (distrustAfter / purposes), which is keyed by key purpose -- supply opts.checkPurpose to say which purpose this validation is for, or the constraint cannot be applied");
|
|
1244
|
+
}
|
|
1207
1245
|
|
|
1208
1246
|
for (var idx = 0; idx < n; idx++) {
|
|
1209
1247
|
var i = idx + 1;
|
|
@@ -1274,16 +1312,48 @@ async function validate(path, opts) {
|
|
|
1274
1312
|
// 6.1.3(a)(3) revocation.
|
|
1275
1313
|
if (revocationChecker) {
|
|
1276
1314
|
var issuerCert = idx > 0 ? certs[idx - 1] : null; // the anchor issues cert[1]
|
|
1277
|
-
var rv;
|
|
1315
|
+
var rv, rvError = null;
|
|
1316
|
+
// A checker that THROWS -- or whose promise rejects -- is not a checker reporting "unknown".
|
|
1317
|
+
// Laundering the two together made a broken checker indistinguishable from a working one that
|
|
1318
|
+
// could not reach the responder, and under softFail both became a pass. The fault is carried
|
|
1319
|
+
// onto the check so an operator can tell their own bug from a network condition, and it fails
|
|
1320
|
+
// the path whatever softFail says (see the branch below).
|
|
1278
1321
|
try { rv = await revocationChecker.check(cert, { workingIssuerName: state.workingIssuerName, workingPublicKey: state.workingPublicKey, workingPublicKeyAlgorithm: state.workingPublicKeyAlgorithm, issuerCert: issuerCert }, { time: opts.time, historicalMode: opts.historicalMode === true }); }
|
|
1279
|
-
catch (
|
|
1322
|
+
catch (e) { rv = { status: "error" }; rvError = e; }
|
|
1280
1323
|
// ONLY an explicit "good" is a determined non-revocation; "revoked" fails;
|
|
1281
1324
|
// every other value ("unknown", an OCSP tryLater/unauthorized, a typo, a
|
|
1282
1325
|
// missing status) is undetermined and fails closed unless softFail.
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1326
|
+
//
|
|
1327
|
+
// The two `ok: true` outcomes are NOT the same claim and no longer the same object. "checked,
|
|
1328
|
+
// and it said good" and "could not check, and you waived it" read identically from a bare
|
|
1329
|
+
// boolean, which is the whole reason a stored verdict cannot be re-read to answer whether
|
|
1330
|
+
// revocation was ever established. Each entry now names the status it was decided on, and a
|
|
1331
|
+
// waiver marks itself.
|
|
1332
|
+
var rvStatus = (rv && typeof rv.status === "string") ? rv.status : "unknown";
|
|
1333
|
+
if (rv && rv.status === "good") { checks.push({ name: "revocation", ok: true, status: "good" }); }
|
|
1334
|
+
else if (rv && rv.status === "revoked") { checks.push({ name: "revocation", ok: false, status: "revoked", code: "path/revoked" }); failed = true; }
|
|
1335
|
+
else if (rvError) {
|
|
1336
|
+
// A checker that threw is a FAULT in the checker, not a status it could not reach, and
|
|
1337
|
+
// softFail is the caller opting into an undetermined ANSWER. The built-in CRL and OCSP
|
|
1338
|
+
// checkers return `{status:"unknown"}` for every unreachable or unverifiable condition and
|
|
1339
|
+
// never throw, so a throw here is the caller's own bug -- waiving it would pass the
|
|
1340
|
+
// certificate with no revocation result at all, which is the outcome softFail is asked for
|
|
1341
|
+
// and this is not.
|
|
1342
|
+
checks.push({ name: "revocation", ok: false, status: "error", code: "path/revocation-checker-error", error: rvError });
|
|
1343
|
+
revocationUndetermined = true;
|
|
1344
|
+
failed = true;
|
|
1345
|
+
}
|
|
1346
|
+
else if (softFail) {
|
|
1347
|
+
// No `error` slot here or below: a fault took the branch above, so anything reaching these
|
|
1348
|
+
// two is a status the checker actually reported.
|
|
1349
|
+
checks.push({ name: "revocation", ok: true, status: rvStatus, waived: true });
|
|
1350
|
+
revocationWaived = true;
|
|
1351
|
+
} else {
|
|
1352
|
+
checks.push({ name: "revocation", ok: false, status: rvStatus, code: "path/revocation-undetermined" });
|
|
1353
|
+
revocationUndetermined = true;
|
|
1354
|
+
failed = true;
|
|
1355
|
+
}
|
|
1356
|
+
revocationRan = true;
|
|
1287
1357
|
} else if (requireRevocation) {
|
|
1288
1358
|
// No checker was supplied but the caller demands a revocation determination:
|
|
1289
1359
|
// the step cannot be performed, so fail closed (never silently skip).
|
|
@@ -1357,6 +1427,7 @@ async function validate(path, opts) {
|
|
|
1357
1427
|
// before the comparison; an absent (undefined/null) date is no restriction.
|
|
1358
1428
|
var distrustDate = assertAnchorConstraints(ta, checkPurpose);
|
|
1359
1429
|
if (distrustDate != null) {
|
|
1430
|
+
anchorDistrustApplied = true;
|
|
1360
1431
|
// STRICTLY > : a leaf whose notBefore == the distrust date stays trusted
|
|
1361
1432
|
// (Mozilla certverifier isDistrustedCertificateChain: endEntityNotBefore
|
|
1362
1433
|
// <= distrustAfterTime -> not distrusted; the end-of-day ...235959Z
|
|
@@ -1365,8 +1436,11 @@ async function validate(path, opts) {
|
|
|
1365
1436
|
checks.push({ name: "distrustAfter", ok: false, code: "path/distrusted-after" }); failed = true;
|
|
1366
1437
|
}
|
|
1367
1438
|
}
|
|
1368
|
-
if (checkPurpose && ta.purposes
|
|
1369
|
-
|
|
1439
|
+
if (checkPurpose && ta.purposes) {
|
|
1440
|
+
anchorPurposeApplied = true;
|
|
1441
|
+
if (ta.purposes[checkPurpose] !== true) {
|
|
1442
|
+
checks.push({ name: "purposeTrust", ok: false, code: "path/purpose-not-trusted" }); failed = true;
|
|
1443
|
+
}
|
|
1370
1444
|
}
|
|
1371
1445
|
updateWorkingKey(state, cert); // 6.1.5(c),(d) -- key AND algorithm AND parameters
|
|
1372
1446
|
}
|
|
@@ -1404,6 +1478,20 @@ async function validate(path, opts) {
|
|
|
1404
1478
|
|
|
1405
1479
|
return {
|
|
1406
1480
|
valid: !failed,
|
|
1481
|
+
// What the revocation rule and the anchor's own trust metadata actually decided. `valid` alone
|
|
1482
|
+
// cannot answer either, and both are questions a stored verdict is re-read to settle: was this
|
|
1483
|
+
// certificate ever established as un-revoked, and was the anchor's distrust date consulted.
|
|
1484
|
+
// The WEAKEST outcome on the path, not the fact that a checker ran: a certificate nobody could
|
|
1485
|
+
// answer for leaves revocation unestablished however many others answered, and deriving the word
|
|
1486
|
+
// from "a checker ran" put that run on the same value as one that established every answer.
|
|
1487
|
+
revocationChecked: !revocationRan ? false
|
|
1488
|
+
: revocationUndetermined ? "undetermined"
|
|
1489
|
+
: revocationWaived ? "waived" : "determined",
|
|
1490
|
+
anchorConstraints: {
|
|
1491
|
+
checkedPurpose: checkPurpose || null,
|
|
1492
|
+
distrustAfterApplied: anchorDistrustApplied,
|
|
1493
|
+
purposeTrustApplied: anchorPurposeApplied,
|
|
1494
|
+
},
|
|
1407
1495
|
path: certs,
|
|
1408
1496
|
results: state.results,
|
|
1409
1497
|
workingPublicKey: state.workingPublicKey,
|
|
@@ -1446,24 +1534,10 @@ var OID_AUTHORITY_KEY_ID = oid.byName("authorityKeyIdentifier");
|
|
|
1446
1534
|
var OID_CRL_NUMBER = oid.byName("cRLNumber");
|
|
1447
1535
|
var OID_FRESHEST_CRL = oid.byName("freshestCRL");
|
|
1448
1536
|
|
|
1449
|
-
//
|
|
1450
|
-
//
|
|
1451
|
-
//
|
|
1452
|
-
|
|
1453
|
-
// through the engine so the trailing-field grammar (strictly-ascending tags, each
|
|
1454
|
-
// at most once) and the DER BOOLEAN value rules are the shared enforcement, not a
|
|
1455
|
-
// hand-walk; a present DEFAULT-FALSE flag encoding FALSE is the omitted default
|
|
1456
|
-
// (X.690 sec. 11.5) and rejects at the leaf-value level below.
|
|
1457
|
-
var IDP_SCHEMA = schema.seq([
|
|
1458
|
-
schema.trailing([
|
|
1459
|
-
{ tag: 0, name: "distributionPoint", schema: schema.any() },
|
|
1460
|
-
{ tag: 1, name: "onlyContainsUserCerts", schema: schema.implicitBoolean(1) },
|
|
1461
|
-
{ tag: 2, name: "onlyContainsCACerts", schema: schema.implicitBoolean(2) },
|
|
1462
|
-
{ tag: 3, name: "onlySomeReasons", schema: schema.implicitBitString(3) },
|
|
1463
|
-
{ tag: 4, name: "indirectCRL", schema: schema.implicitBoolean(4) },
|
|
1464
|
-
{ tag: 5, name: "onlyContainsAttributeCerts", schema: schema.implicitBoolean(5) },
|
|
1465
|
-
], { minTag: 0, maxTag: 5, unexpectedCode: "path/bad-idp", orderCode: "path/bad-idp" }),
|
|
1466
|
-
], { assert: "sequence", code: "path/bad-idp", what: "IssuingDistributionPoint" });
|
|
1537
|
+
// The RFC 5280 sec. 5.2.5 IssuingDistributionPoint grammar, shared with the CRL
|
|
1538
|
+
// verbs (pkix.issuingDistributionPoint) so the scope this validator reads and the
|
|
1539
|
+
// scope pki.crl.isRevoked refuses to answer past are read by the same rules.
|
|
1540
|
+
var IDP_SCHEMA = pkix.issuingDistributionPoint("path/bad-idp");
|
|
1467
1541
|
|
|
1468
1542
|
// RFC 5280 sec. 6.3.2(a): the legal members of reasons_mask are exactly the eight
|
|
1469
1543
|
// named ReasonFlags bits, 1..8 (`unused` bit 0 is not a reason, and `unspecified`
|
|
@@ -2437,6 +2511,17 @@ function coerceCert(input) {
|
|
|
2437
2511
|
// exposed for the same reason: a caller that may never reach the walk -- pki.cms.verify when no
|
|
2438
2512
|
// signer verified -- has to be able to reject a malformed anchor at ITS entry point, through this
|
|
2439
2513
|
// same definition, so configuration validity never depends on the message.
|
|
2514
|
+
// Does this anchor carry trust metadata that only a named key purpose can unlock? A non-empty
|
|
2515
|
+
// `distrustAfter` or `purposes` map is such metadata -- both are indexed BY purpose, so both are
|
|
2516
|
+
// inert without one. An EMPTY map states no constraint and is not a reason to refuse.
|
|
2517
|
+
function _hasPurposeScopedMetadata(ta) {
|
|
2518
|
+
if (!ta || typeof ta !== "object") return false;
|
|
2519
|
+
return ["distrustAfter", "purposes"].some(function (k) {
|
|
2520
|
+
var m = ta[k];
|
|
2521
|
+
return !!m && typeof m === "object" && Object.keys(m).length > 0;
|
|
2522
|
+
});
|
|
2523
|
+
}
|
|
2524
|
+
|
|
2440
2525
|
function assertAnchorConstraints(ta, checkPurpose) {
|
|
2441
2526
|
var d = (checkPurpose && ta && ta.distrustAfter) ? ta.distrustAfter[checkPurpose] : null;
|
|
2442
2527
|
if (d == null) return null;
|
package/lib/schema-pkix.js
CHANGED
|
@@ -669,6 +669,66 @@ function distributionPointName(ns, node, code) {
|
|
|
669
669
|
throw ns.E(code, "DistributionPointName must be fullName [0] or nameRelativeToCRLIssuer [1] (RFC 5280 sec. 4.2.1.13)");
|
|
670
670
|
}
|
|
671
671
|
|
|
672
|
+
// A certificate's keyUsage as the named booleans the shared sec. 4.2.1 decoder produces, or null
|
|
673
|
+
// when the certificate carries no keyUsage extension -- which places no restriction (sec. 4.2.1.3),
|
|
674
|
+
// a distinct answer from "carries one that permits nothing".
|
|
675
|
+
//
|
|
676
|
+
// Declared here because "may this certificate do X" is asked at five boundaries and the answer has
|
|
677
|
+
// to be the same at all of them. keyUsage is a NamedBitList: DER drops its trailing zero bits
|
|
678
|
+
// (X.690 sec. 11.2.2) and sec. 4.2.1.3 requires at least one bit set. A boundary that reads the
|
|
679
|
+
// bits with a plain BIT STRING read applies neither, so the same certificate is authorized there
|
|
680
|
+
// and rejected as malformed by the issuing side and the path validator -- one extension with two
|
|
681
|
+
// readings, and the permissive one deciding.
|
|
682
|
+
//
|
|
683
|
+
// `E(code, message, cause)` is the caller's typed error factory, so each boundary keeps its own
|
|
684
|
+
// domain while the RULE stays single-homed.
|
|
685
|
+
var _KU_DECODER = new WeakMap(); // one decoder table per namespace, built on first use
|
|
686
|
+
function keyUsageOf(ns, cert, E, code, label) {
|
|
687
|
+
var exts = (cert && cert.extensions) || [];
|
|
688
|
+
var want = ns.oid.byName("keyUsage");
|
|
689
|
+
for (var i = 0; i < exts.length; i++) {
|
|
690
|
+
if (exts[i].oid !== want || exts[i].value == null) continue;
|
|
691
|
+
var dec = _KU_DECODER.get(ns);
|
|
692
|
+
if (!dec) { dec = certExtensionDecoders(ns).byOid[want]; _KU_DECODER.set(ns, dec); }
|
|
693
|
+
try { return dec(exts[i].value); }
|
|
694
|
+
catch (e) { throw E(code, "the " + label + " keyUsage extension is malformed", e); }
|
|
695
|
+
}
|
|
696
|
+
return null;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// IssuingDistributionPoint ::= SEQUENCE { distributionPoint [0] OPTIONAL,
|
|
700
|
+
// onlyContainsUserCerts [1] DEFAULT FALSE, onlyContainsCACerts [2] DEFAULT FALSE,
|
|
701
|
+
// onlySomeReasons [3] ReasonFlags OPTIONAL, indirectCRL [4] DEFAULT FALSE,
|
|
702
|
+
// onlyContainsAttributeCerts [5] DEFAULT FALSE } (RFC 5280 sec. 5.2.5).
|
|
703
|
+
//
|
|
704
|
+
// Declared here rather than in a consumer because the extension answers a
|
|
705
|
+
// question -- which certificates does this CRL speak for -- that both the path
|
|
706
|
+
// validator and the standalone CRL verbs act on, and a scope flag read by a hand
|
|
707
|
+
// walk over the raw children is read by the reader's own rules instead of the
|
|
708
|
+
// encoding's. Each flag is an IMPLICIT BOOLEAN, so `implicitBoolean` is what
|
|
709
|
+
// holds it to one content octet of 0x00 or 0xFF (X.690 sec. 11.1); a hand walk
|
|
710
|
+
// testing a content byte reads an EMPTY [4] as absent and a multi-octet one by
|
|
711
|
+
// whichever byte it happens to index, and "absent" is the reading that grants
|
|
712
|
+
// permission to answer. `trailing` supplies the rest of the grammar: strictly
|
|
713
|
+
// ascending tags, each field at most once, nothing outside 0..5.
|
|
714
|
+
//
|
|
715
|
+
// A present DEFAULT-FALSE flag encoding FALSE is well-formed at the leaf but is
|
|
716
|
+
// still an encoding DER forbids (X.690 sec. 11.5 omits the default), so the
|
|
717
|
+
// caller decides what that means for it -- reported as a value of `false`
|
|
718
|
+
// against a `present` of true, never silently normalized away.
|
|
719
|
+
function issuingDistributionPoint(code) {
|
|
720
|
+
return schema.seq([
|
|
721
|
+
schema.trailing([
|
|
722
|
+
{ tag: 0, name: "distributionPoint", schema: schema.any() },
|
|
723
|
+
{ tag: 1, name: "onlyContainsUserCerts", schema: schema.implicitBoolean(1) },
|
|
724
|
+
{ tag: 2, name: "onlyContainsCACerts", schema: schema.implicitBoolean(2) },
|
|
725
|
+
{ tag: 3, name: "onlySomeReasons", schema: schema.implicitBitString(3) },
|
|
726
|
+
{ tag: 4, name: "indirectCRL", schema: schema.implicitBoolean(4) },
|
|
727
|
+
{ tag: 5, name: "onlyContainsAttributeCerts", schema: schema.implicitBoolean(5) },
|
|
728
|
+
], { minTag: 0, maxTag: 5, unexpectedCode: code, orderCode: code }),
|
|
729
|
+
], { assert: "sequence", code: code, what: "IssuingDistributionPoint" });
|
|
730
|
+
}
|
|
731
|
+
|
|
672
732
|
// certExtensionDecoders(ns) -- the ns-parameterized RFC 5280 sec. 4.2.1 extension
|
|
673
733
|
// VALUE decoders. `x509.parse` surfaces each extension as { oid, name, critical,
|
|
674
734
|
// value } with `value` the raw inner OCTET-STRING content (a Buffer); the path
|
|
@@ -1587,6 +1647,8 @@ module.exports = {
|
|
|
1587
1647
|
generalName: generalName,
|
|
1588
1648
|
generalNames: generalNames,
|
|
1589
1649
|
distributionPointName: distributionPointName,
|
|
1650
|
+
issuingDistributionPoint: issuingDistributionPoint,
|
|
1651
|
+
keyUsageOf: keyUsageOf,
|
|
1590
1652
|
generalizedTime: generalizedTime,
|
|
1591
1653
|
utf8Text: utf8Text,
|
|
1592
1654
|
rawNonEmptySequence: rawNonEmptySequence,
|
package/lib/tsp-sign.js
CHANGED
|
@@ -23,11 +23,12 @@ var pathValidate = require("./path-validate");
|
|
|
23
23
|
var pkiX509 = require("./schema-x509");
|
|
24
24
|
var smime = require("./schema-smime");
|
|
25
25
|
var schemaTsp = require("./schema-tsp");
|
|
26
|
-
var schema = require("./schema-engine");
|
|
27
26
|
var guard = require("./guard-all");
|
|
28
27
|
var frameworkError = require("./framework-error");
|
|
29
28
|
|
|
29
|
+
var pkix = require("./schema-pkix");
|
|
30
30
|
var TspError = frameworkError.TspError;
|
|
31
|
+
var _NS = pkix.makeNS("tsp", TspError, oid);
|
|
31
32
|
var b = asn1.build;
|
|
32
33
|
function _err(code, message, cause) { return new TspError(code, message, cause); }
|
|
33
34
|
function O(name) { return oid.byName(name); }
|
|
@@ -484,19 +485,12 @@ function _checkTsaCertUsage(tsaCertDer) {
|
|
|
484
485
|
// A keyUsage that forbids signing cannot mint a token (RFC 5280 sec. 4.2.1.3); an absent keyUsage
|
|
485
486
|
// is unrestricted. Require digitalSignature (bit 0) or nonRepudiation/contentCommitment (bit 1) --
|
|
486
487
|
// the signing bits, the TSA analogue of the OCSP-responder keyUsage gate.
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
// bits) that the shared certExtensionDecoders keyUsage decoder applies, so a non-minimal
|
|
494
|
-
// encoding other paths reject cannot slip a "permits signing" verdict through here.
|
|
495
|
-
schema.assertMinimalNamedBits(ku.unusedBits, ku.bytes, function (m) { throw _err("tsp/bad-key-usage", m); });
|
|
496
|
-
} catch (_e) { return "tsp/bad-key-usage"; }
|
|
497
|
-
var byte0 = ku.bytes.length ? ku.bytes[0] : 0;
|
|
498
|
-
if (!((byte0 >> 7) & 1) && !((byte0 >> 6) & 1)) return "tsp/bad-key-usage"; // no digitalSignature / nonRepudiation
|
|
499
|
-
}
|
|
488
|
+
// Through the shared reader, which applies the NamedBitList rules (X.690 sec. 11.2.2 minimal
|
|
489
|
+
// encoding, sec. 4.2.1.3 at least one bit set) this boundary was applying only half of.
|
|
490
|
+
var ku;
|
|
491
|
+
try { ku = pkix.keyUsageOf(_NS, cert, _err, "tsp/bad-key-usage", "TSA certificate"); }
|
|
492
|
+
catch (_e) { return "tsp/bad-key-usage"; }
|
|
493
|
+
if (ku && !ku.digitalSignature && !ku.nonRepudiation) return "tsp/bad-key-usage";
|
|
500
494
|
return true;
|
|
501
495
|
}
|
|
502
496
|
|
|
@@ -570,7 +564,7 @@ function _buildTsaChains(leaf, pool) {
|
|
|
570
564
|
* PEM (never a parsed object -- every checked field is read from the CMS-verified eContent, so a
|
|
571
565
|
* mutated parsed structure cannot desynchronize the checks from the signed bytes). `data` is the
|
|
572
566
|
* original bytes (hashed under the token's messageImprint algorithm) or a precomputed
|
|
573
|
-
* `{ hashAlgorithm, hashedMessage }`. Returns `{ valid, genTime, accuracy, serialNumber,
|
|
567
|
+
* `{ hashAlgorithm, hashedMessage }`. Returns `{ valid, trusted, genTime, accuracy, serialNumber,
|
|
574
568
|
* serialNumberHex, policy, nonce, tsa, tstInfo, signer, code?, reason? }`. `valid` is true only
|
|
575
569
|
* when the CMS signature, the imprint match, the eContentType, the ESSCertID(V2) binding, the
|
|
576
570
|
* RFC 3161 sec. 2.3 critical single-`timeStamping` extendedKeyUsage rule, the requested nonce (when
|
|
@@ -578,6 +572,13 @@ function _buildTsaChains(leaf, pool) {
|
|
|
578
572
|
* validation all pass. A conformance / trust failure of a well-formed token is a
|
|
579
573
|
* `{ valid:false, code }` verdict; malformed or config input throws a typed `TspError`.
|
|
580
574
|
*
|
|
575
|
+
* `trusted` is the second claim and is kept apart from the first. `valid` says the token's
|
|
576
|
+
* signature and structural bindings hold; `trusted` says the timestamp authority chained to an
|
|
577
|
+
* anchor this caller named. Without `trustAnchor` there is nothing to chain to and `trusted` is
|
|
578
|
+
* `false` -- a definite answer rather than a missing one, on the refusal branch as well as the
|
|
579
|
+
* accepting one. A timestamp is archived precisely to be re-read years later, and one boolean
|
|
580
|
+
* cannot answer both questions then.
|
|
581
|
+
*
|
|
581
582
|
* @opts trustAnchor Anchor `{ name, publicKey, algorithm }` -- runs `pki.path.validate` on the
|
|
582
583
|
* TSA certificate chain ordered from the token's embedded certificates
|
|
583
584
|
* (validity at genTime, requiredEku timeStamping, revocation), so a TSA under
|
|
@@ -625,8 +626,12 @@ async function verify(token, data, opts) {
|
|
|
625
626
|
// structure and decodes the TSTInfo FROM the raw eContent; a structural defect throws.
|
|
626
627
|
var parsed = schemaTsp.parseToken(tokenDer);
|
|
627
628
|
var tst = parsed.tstInfo;
|
|
629
|
+
// `trusted: false` on every refusal too, not only on the accepting return. A caller reading
|
|
630
|
+
// `res.trusted` must get an answer on both branches -- an undefined on the failure path is the
|
|
631
|
+
// same "cannot tell what was checked" the field was added to remove, and `!res.trusted` reading
|
|
632
|
+
// true by accident is not the same as its reading true because nothing anchored the TSA.
|
|
628
633
|
function fail(code, reason) {
|
|
629
|
-
return { valid: false, code: code, reason: reason || null, genTime: tst.genTime, accuracy: tst.accuracy, serialNumber: tst.serialNumber, serialNumberHex: tst.serialNumberHex, policy: tst.policy, nonce: tst.nonce, tsa: tst.tsa, tstInfo: tst, signer: null };
|
|
634
|
+
return { valid: false, trusted: false, code: code, reason: reason || null, genTime: tst.genTime, accuracy: tst.accuracy, serialNumber: tst.serialNumber, serialNumberHex: tst.serialNumberHex, policy: tst.policy, nonce: tst.nonce, tsa: tst.tsa, tstInfo: tst, signer: null };
|
|
630
635
|
}
|
|
631
636
|
// M12 -- the CMS signature over the exact RFC 5652 sec. 5.4 preimage (message-digest bound to the
|
|
632
637
|
// authenticated eContent). cms.verify re-parses the same bytes; a failure is a fail-closed verdict.
|
|
@@ -673,6 +678,14 @@ async function verify(token, data, opts) {
|
|
|
673
678
|
// key-param inheritance, requiredEku, optional revocation), only when a trustAnchor is supplied.
|
|
674
679
|
// The path is ordered from the token's embedded certificates (leaf + any intermediates), so a TSA
|
|
675
680
|
// issued under an intermediate CA -- not just directly under the anchor -- validates.
|
|
681
|
+
// `trusted` is the SECOND claim, kept apart from `valid`. `valid` says the token's signature and
|
|
682
|
+
// its structural bindings hold; whether the timestamp authority is one this caller accepts is
|
|
683
|
+
// answered only by the chain below, and only when an anchor was supplied. Collapsing the two into
|
|
684
|
+
// one boolean meant an archived verdict could not be re-read to tell whether the TSA was ever
|
|
685
|
+
// trusted -- which is exactly what a timestamp is archived to answer. Without an anchor there is
|
|
686
|
+
// nothing to chain to and `trusted` is false: a definite answer rather than a missing one, the
|
|
687
|
+
// same shape pki.cms.verify and pki.cmp.verify return.
|
|
688
|
+
var trusted = false;
|
|
676
689
|
if (opts.trustAnchor) {
|
|
677
690
|
var pathRes = null;
|
|
678
691
|
// tst.genTime floors to millisecond precision. When genTime carries sub-millisecond digits the true
|
|
@@ -693,22 +706,30 @@ async function verify(token, data, opts) {
|
|
|
693
706
|
// path.validate validates a FIXED path, so backtracking over same-subject issuer candidates
|
|
694
707
|
// happens here -- accept the TSA certificate if ANY enumerated chain validates to the anchor at
|
|
695
708
|
// both window endpoints.
|
|
709
|
+
//
|
|
710
|
+
// checkPurpose names timeStamping alongside requiredEku, and the pairing is the point. The EKU
|
|
711
|
+
// constrains the TSA CERTIFICATE; checkPurpose selects the ANCHOR's own trust metadata, which
|
|
712
|
+
// pki.path consults only when a purpose is named -- so asking one without the other checks one
|
|
713
|
+
// end of the chain and not the other, and a root explicitly distrusted for timestamping would
|
|
714
|
+
// still answer trusted. The purpose is not a caller choice here: this verb validates timestamp
|
|
715
|
+
// tokens and nothing else, so there is exactly one purpose its anchors can be judged under.
|
|
696
716
|
var chains = _buildTsaChains(pkiX509.parse(tsaCertDer), pool);
|
|
697
717
|
for (var ci = 0; ci < chains.length && !(pathRes && pathRes.valid); ci++) {
|
|
698
718
|
pathRes = await pathValidate.validate(chains[ci], {
|
|
699
|
-
time: floorT, trustAnchor: opts.trustAnchor, requiredEku: ["timeStamping"], revocationChecker: opts.revocationChecker,
|
|
719
|
+
time: floorT, trustAnchor: opts.trustAnchor, requiredEku: ["timeStamping"], checkPurpose: "timeStamping", revocationChecker: opts.revocationChecker,
|
|
700
720
|
});
|
|
701
721
|
if (pathRes.valid && ceilT !== floorT) {
|
|
702
722
|
pathRes = await pathValidate.validate(chains[ci], {
|
|
703
|
-
time: ceilT, trustAnchor: opts.trustAnchor, requiredEku: ["timeStamping"], revocationChecker: opts.revocationChecker,
|
|
723
|
+
time: ceilT, trustAnchor: opts.trustAnchor, requiredEku: ["timeStamping"], checkPurpose: "timeStamping", revocationChecker: opts.revocationChecker,
|
|
704
724
|
});
|
|
705
725
|
}
|
|
706
726
|
}
|
|
707
727
|
} catch (e) { return fail("tsp/untrusted-tsa", (e && e.message) || String(e)); }
|
|
708
728
|
if (!pathRes || !pathRes.valid) return fail("tsp/untrusted-tsa", "the TSA certificate did not validate to the trust anchor at genTime");
|
|
729
|
+
trusted = true;
|
|
709
730
|
}
|
|
710
731
|
return {
|
|
711
|
-
valid: true, genTime: tst.genTime, accuracy: tst.accuracy,
|
|
732
|
+
valid: true, trusted: trusted, genTime: tst.genTime, accuracy: tst.accuracy,
|
|
712
733
|
serialNumber: tst.serialNumber, serialNumberHex: tst.serialNumberHex,
|
|
713
734
|
policy: tst.policy, policyName: tst.policyName, nonce: tst.nonce, tsa: tst.tsa,
|
|
714
735
|
tstInfo: tst, signer: { cert: tsaCertDer, sid: signer.sid },
|
package/lib/webauthn-mds.js
CHANGED
|
@@ -20,7 +20,6 @@
|
|
|
20
20
|
// FIDO Metadata Service v3.0 sec. 3.1 / sec. 3.2, RFC 7515 (JWS).
|
|
21
21
|
|
|
22
22
|
var frameworkError = require("./framework-error");
|
|
23
|
-
var asn1 = require("./asn1-der");
|
|
24
23
|
var x509 = require("./schema-x509");
|
|
25
24
|
var guard = require("./guard-all");
|
|
26
25
|
var jose = require("./jose");
|
|
@@ -32,7 +31,10 @@ var edwardsPoint = require("./edwards-point");
|
|
|
32
31
|
var webcrypto = require("./webcrypto");
|
|
33
32
|
var nodeCrypto = require("crypto");
|
|
34
33
|
|
|
34
|
+
var oid = require("./oid");
|
|
35
|
+
var pkix = require("./schema-pkix");
|
|
35
36
|
var WebauthnError = frameworkError.WebauthnError;
|
|
37
|
+
var _KU_NS = pkix.makeNS("webauthn", WebauthnError, oid);
|
|
36
38
|
function _err(code, message, cause) { return new WebauthnError(code, message, cause); }
|
|
37
39
|
var C = constants.LIMITS;
|
|
38
40
|
|
|
@@ -183,17 +185,13 @@ var UNDERSTOOD_HEADER = Object.assign(Object.create(null), {
|
|
|
183
185
|
|
|
184
186
|
// The BLOB's signing certificate must be allowed to sign. RFC 5280 sec. 4.2.1.3 numbers
|
|
185
187
|
// digitalSignature bit 0 of the keyUsage BIT STRING; an absent extension places no restriction.
|
|
188
|
+
// Through the shared reader, which applies the NamedBitList rules a local bit test does not: DER
|
|
189
|
+
// drops trailing zero bits (X.690 sec. 11.2.2) and sec. 4.2.1.3 requires at least one bit set, so
|
|
190
|
+
// reading the bits here would let a certificate the rest of the toolkit calls malformed sign a BLOB.
|
|
186
191
|
function _assertLeafSigns(leaf) {
|
|
187
|
-
var
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
var ku;
|
|
191
|
-
try { ku = asn1.read.bitString(asn1.decode(exts[i].value)); }
|
|
192
|
-
catch (e) { throw _err("webauthn/bad-att-cert", "the metadata BLOB x5c leaf keyUsage extension is malformed", e); }
|
|
193
|
-
if (!ku.bytes.length || (ku.bytes[0] & 0x80) === 0) {
|
|
194
|
-
throw _err("webauthn/bad-att-cert", "the metadata BLOB x5c leaf keyUsage does not assert digitalSignature, so it may not sign the BLOB (RFC 5280 sec. 4.2.1.3)");
|
|
195
|
-
}
|
|
196
|
-
return;
|
|
192
|
+
var ku = pkix.keyUsageOf(_KU_NS, leaf, _err, "webauthn/bad-att-cert", "metadata BLOB x5c leaf");
|
|
193
|
+
if (ku && ku.digitalSignature !== true) {
|
|
194
|
+
throw _err("webauthn/bad-att-cert", "the metadata BLOB x5c leaf keyUsage does not assert digitalSignature, so it may not sign the BLOB (RFC 5280 sec. 4.2.1.3)");
|
|
197
195
|
}
|
|
198
196
|
}
|
|
199
197
|
|
package/package.json
CHANGED
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:38c27a59-75b3-42df-a97a-b59d75f2a0e9",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-08-
|
|
8
|
+
"timestamp": "2026-08-15T08:44:34.920Z",
|
|
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.4",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "pki",
|
|
25
|
-
"version": "0.5.
|
|
25
|
+
"version": "0.5.4",
|
|
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.4",
|
|
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.4",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|