@blamejs/pki 0.3.5 → 0.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,23 @@ 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.3.7 — 2026-07-17
8
+
9
+ Certification path building arrives as pki.path.build, and pki.lint gains seven RFC 5280 extension-criticality and CA-scope lints.
10
+
11
+ ### Added
12
+
13
+ - pki.path.build(leaf, opts) discovers the ordered certification path from a leaf up to a trust anchor over an untrusted pool of candidate CA certificates, then validates it -- the discovering complement of pki.path.validate. Candidates are matched by RFC 5280 name chaining, prioritized by the RFC 4158 heuristics (subjectKeyIdentifier/authorityKeyIdentifier match, anchor-adjacent issuer, CA and keyCertSign, validity), and searched depth-first with backtracking; every accept flows through pki.path.validate, so a name or key-identifier match is only an ordering hint and building never weakens a path-validation check. The search is bounded (a chain-length cap, a total-work cap on candidate expansions, and an identity-tuple visited-set) so a cross-certificate cycle or Bridge-CA fan-out terminates deterministically; the trust store accepts anchor tuples or self-signed root certificates, opts.validate:false returns the ordered path unvalidated, and the verdict is cross-checked against openssl verify. AIA caIssuers fetching is offline-only (supply fetched issuers in opts.candidates). RFC 4158 / RFC 5280.
14
+ - pki.lint.certificate flags seven RFC 5280 extension-criticality and CA-scope violations that parse but breach the certificate profile: basicConstraints (on a certificate-signing CA), nameConstraints, policyConstraints, and inhibitAnyPolicy must be marked critical (error); keyUsage should be critical (warn); nameConstraints must appear only in a CA certificate (error); and an end-entity certificate should carry a subjectKeyIdentifier (notice). The basicConstraints criticality rule applies only when the CA key validates certificate signatures (RFC 5280 4.2.1.9), so a CRL-signing-only CA carrying a non-critical basicConstraints is not falsely flagged.
15
+
16
+ ## v0.3.6 — 2026-07-17
17
+
18
+ pki.cmp.build gains the CA/responder side -- certificate, revocation, key-recovery, general, error, poll, and confirmation responses complete the RFC 9810 message surface.
19
+
20
+ ### Added
21
+
22
+ - pki.cmp.build message.body now accepts the CA/responder-side arms: ip / cp / kup / ccp (a CertRepMessage -- caPubs plus a response of CertResponse entries, each carrying a PKIStatusInfo and, under a granting status, a certifiedKeyPair), rp (a RevRepContent), krp (a KeyRecRepContent), genp (a general response), error (an ErrorMsgContent), pollRep (a poll response), and pkiconf (the final confirmation). They reuse the request-side header, envelope, ProtectedPart, and signature / PBMAC1 protection, and round-trip through pki.schema.cmp.parse. The RFC 9810 section 5.3.4 rules are enforced (a certifiedKeyPair only under a granting status and never with a failInfo, a validated certificate CHOICE, a single-response ccp). The private-key-transport / KEM encrypted forms ride a pre-encoded escape hatch.
23
+
7
24
  ## v0.3.5 — 2026-07-17
8
25
 
9
26
  pki.cmp.build assembles protected RFC 9810 CMP PKIMessages -- certificate requests, confirmations, revocations, and general messages, protected by a sender-key signature or a PBMAC1 shared secret.
package/README.md CHANGED
@@ -221,12 +221,12 @@ is callable today; nothing below is a stub.
221
221
  | `pki.acme` | RFC 8555 / 8737 / 8738 / 9773 ACME message layer over `pki.jose` — resource-object validators (closed status enums, conditional-required fields, unknown fields ignored), the three §7.1.6 state machines, request builders (newAccount + EAB, newOrder + `replaces`, finalize with CSR identifier-set match and account-key-reuse rejection, challenge responses, deactivation, revokeCert in both key modes, the keyChange nested JWS, POST-as-GET), the http-01 / dns-01 / tls-alpn-01 challenge computations, the dns/ip identifier validators, and the ARI certID (serial sign-padding preserved). A message layer, not an HTTP client — transport-injectable, fail-closed — `validate`, `identify`, `assertTransition`, the builders, `keyAuthorization`, `http01`, `dns01`, `tlsAlpn01Extension`, `verifyTlsAlpn01`, `ariCertId` |
222
222
  | `pki.schema.smime` | Decode S/MIME ESS signed-attribute values (RFC 5035 / RFC 8551) — `parseSigningCertificate` / `parseSigningCertificateV2` bind a signature to its signing certificate (cert hash, hash algorithm, issuer `GeneralNames` + serial), `parseSmimeCapabilities` decodes the ordered capability list, and `decodeAttribute` OID-dispatches a CMS attribute (enforcing the single-value rule, recognize-and-defer for unknown types). A companion decoder for CMS signed attributes, not an auto-routed format, fail-closed — `parseSigningCertificate`, `parseSigningCertificateV2`, `parseSmimeCapabilities`, `decodeAttribute` |
223
223
  | `pki.schema.engine` | The declarative ASN.1 structure-schema engine every format parser composes — `walk` / `encode` / `embeddedDer` plus the schema combinators |
224
- | `pki.path` | RFC 5280 §6 certification-path validation — `validate` runs the §6.1 state machine (signature chaining across RSA, ECDSA, EdDSA, ML-DSA, SLH-DSA and hybrid composite ML-DSA signatures — 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, the certificate-policy tree) over an ordered path and a trust anchor, returning a structured verdict with per-check reason codes, and enforces a `pki.trust` anchor's per-purpose distrust-after dates and delegator purposes via `checkPurpose`; `crlChecker` supplies CRL-based revocation — including partitioned/sharded CRLs, whose §6.3.3 Distribution Point ↔ IDP correspondence lets a corresponding full-reason shard establish non-revocation — and `ocspChecker` supplies OCSP-based revocation (RFC 6960 — CertID binding, responder authorization, signature, currency) over the same pluggable hook. Pure and re-entrant, fail-closed — `validate`, `crlChecker`, `ocspChecker` |
224
+ | `pki.path` | RFC 5280 §6 certification-path validation — `validate` runs the §6.1 state machine (signature chaining across RSA, ECDSA, EdDSA, ML-DSA, SLH-DSA and hybrid composite ML-DSA signatures — 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, the certificate-policy tree) over an ordered path and a trust anchor, returning a structured verdict with per-check reason codes, and enforces a `pki.trust` anchor's per-purpose distrust-after dates and delegator purposes via `checkPurpose`; `crlChecker` supplies CRL-based revocation — including partitioned/sharded CRLs, whose §6.3.3 Distribution Point ↔ IDP correspondence lets a corresponding full-reason shard establish non-revocation — and `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→anchor path `validate` accepts — name chaining plus the RFC 4158 §3.5 sort hints (AKI/SKI match, anchor-adjacent issuer, CA + keyCertSign, validity), a depth-first search with backtracking so the first path `validate` accepts 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`. Pure and re-entrant, fail-closed — `validate`, `build`, `crlChecker`, `ocspChecker` |
225
225
  | `pki.x509` | X.509 certificate issuance (RFC 5280 §4) — `sign(spec, issuer, opts)` builds and signs a certificate: 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; an `issuer` that is a key alone (self-signed — issuer equals subject, signed with that key) or a name + public key + key, or an issuing certificate + key (CA-signed). The signature algorithm is resolved from the signing key through the shared registry, so RSA (PKCS#1 v1.5 / 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 auto-derived by SHA-1 of the subject key), subject alternative names, and certificate policies from the spec — any other extension supplied as pre-encoded DER — 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` |
226
226
  | `pki.csr` | PKCS#10 certification-request issuance (RFC 2986 / RFC 2985) — `sign(spec, key, opts)` builds and signs a `CertificationRequest`: a `spec` of subject (a common-name string, an array of RDNs, or raw Name DER; may be empty), the public key being certified, an optional `extensionRequest` (requested v3 extensions — subject alternative names, key usage, extended key usage, basic constraints, certificate policies, subject key identifier, or an array of pre-encoded Extension DER — that a CA copies into the issued certificate), 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 (what `openssl req -verify` checks). The signature algorithm is resolved from the subject key, so RSA (PKCS#1 v1.5 / 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` |
227
227
  | `pki.attrcert` | RFC 5755 attribute-certificate issuance — `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` public-key-certificate reference, a `fromCertificate` binding derived from a certificate, or an object digest), the validity window (GeneralizedTime), an optional serial (positive, ≤ 20 octets; randomly generated when omitted), the `attributes` (the privilege syntaxes — 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 — 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 / 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` |
228
228
  | `pki.crmf` | RFC 4211 certificate-request-message issuance — `build(spec, key, opts)` assembles a `CertReqMessages`: a `spec` of `certReqId` (default 0; the RFC 9483 `-1` sentinel allowed), a `certTemplate` of the requested certificate fields (`subject`, `publicKey` — 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` (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 / 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` |
229
- | `pki.cmp` | RFC 9810 Certificate Management Protocol message building — `build(message, opts)` assembles a protected `PKIMessage`. `message.header` carries the `sender` / `recipient` GeneralNames (including the anonymous NULL-DN) plus optional transaction metadata (`transactionID`, `senderNonce` / `recipNonce`, `messageTime` as a GeneralizedTime, `senderKID` / `recipKID`, `freeText`, `generalInfo`); `message.body` is a single-key object naming the request or transaction arm `ir` / `cr` / `kur` (a `CertReqMessages` spec delegated to `pki.crmf.build`), `p10cr` (a PKCS#10 `CertificationRequest`), `certConf`, `pollReq`, `genm`, or `rr`. Protection is exactly one of `opts.{ key, cert }` — a signature over the message under the sender key, 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). The protection covers the exact DER of the virtual `ProtectedPart` (the header and body) and is self-verified before the message is returned; the `protectionAlg` is derived, never caller-set, so the message the parser accepts is coherent by construction. Returns DER, or a PEM `CMP` block with `opts.pem`; malformed input throws a typed `CmpError`. Parsing stays at `pki.schema.cmp.parse` — `build` |
229
+ | `pki.cmp` | RFC 9810 Certificate Management Protocol message building — `build(message, opts)` assembles a protected `PKIMessage`. `message.header` carries the `sender` / `recipient` GeneralNames (including the anonymous NULL-DN) plus optional transaction metadata (`transactionID`, `senderNonce` / `recipNonce`, `messageTime` as a GeneralizedTime, `senderKID` / `recipKID`, `freeText`, `generalInfo`); `message.body` is a single-key object naming the arm. Request-side: `ir` / `cr` / `kur` (a `CertReqMessages` spec delegated to `pki.crmf.build`), `p10cr` (a PKCS#10 `CertificationRequest`), `certConf`, `pollReq`, `genm`, `rr`. CA/responder-side: `ip` / `cp` / `kup` / `ccp` (a `CertRepMessage` — `caPubs` and `response` entries carrying a `PKIStatusInfo` and, under a granting status, a `certifiedKeyPair`), `rp` (revocation response), `genp`, `error`, `pollRep`, `krp` (key-recovery response), `pkiconf`. Protection is exactly one of `opts.{ key, cert }` — a signature over the message under the sender key, 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). The protection covers the exact DER of the virtual `ProtectedPart` (the header and body) and is self-verified before the message is returned; the `protectionAlg` is derived, never caller-set, so the message the parser accepts is coherent by construction. Returns DER, or a PEM `CMP` block with `opts.pem`; malformed input throws a typed `CmpError`. Parsing stays at `pki.schema.cmp.parse` — `build` |
230
230
  | `pki.cms` | RFC 5652 §5 CMS SignedData signing + signature verification — `sign(content, signers, opts)` produces a SignedData (attached or detached, one or many signers, 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 / EdDSA — 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. `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: when signed attributes are 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), otherwise directly over the content. It returns a per-signer verdict with the matched signer certificate; it does not chain that certificate to a trust anchor — that is the caller's step through `pki.path.validate`. **Content encryption** (RFC 5652/5083/5084/9629): `encrypt(content, recipients, opts)` produces an EnvelopedData, AuthEnvelopedData (AES-GCM, the authenticated default), or EncryptedData — recipients auto-dispatch off the certificate key to key-transport (RSAES-OAEP; v1.5 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 + RFC 3211 PWRI-KEK), or the post-quantum ML-KEM KEMRecipientInfo (RFC 9629/9936) — one fresh content key wrapped 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 / password-oracle freedom), and PKCS#1 v1.5 is decrypt-only under the RFC 3218 implicit-rejection countermeasure. **Compression** (RFC 3274): `compress(content, opts)` / `decompress(input, opts)` produce and consume a CompressedData (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` — a size transform with no integrity/confidentiality (RFC 8551 §2.4.5). Fail-closed with typed `cms/*` errors — `sign`, `verify`, `encrypt`, `decrypt`, `compress`, `decompress` |
231
231
  | `pki.smime` | RFC 8551 S/MIME message assembly, verification, encryption, and compression over the CMS layer — `sign(content, signers, opts)` wraps a MIME entity as a signed S/MIME message in either form: `multipart/signed` (clear-signed — the content stays readable in any MUA, a detached CMS SignedData rides alongside as `application/pkcs7-signature` with a matching `micalg`) or `application/pkcs7-mime; smime-type=signed-data` (opaque — the whole entity is a base64 CMS SignedData). The signed bytes are the entity's RFC 8551 §3.1.1 canonical form (CRLF line endings); `verify(message, opts)` unwraps both forms and recomputes over the same canonicalizer, so a transport that re-wraps line endings still verifies and 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 — `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, not the header, and decryption is fail-closed and oracle-free. The crypto is entirely `pki.cms.sign` / `verify` / `encrypt` / `decrypt` — 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 (algorithm-agnostic). Like `cms.verify`, `verify` returns the per-signer cryptographic verdict plus the recovered content; chaining a signer to a trust anchor is the caller's `pki.path.validate` step. `compress(content, opts)` / `decompress(message, opts)` add the opaque `application/pkcs7-mime; smime-type=compressed-data; name=smime.p7z` frame (RFC 8551 §3.6, RFC 3274) — a size transform with no integrity/confidentiality (§2.4.5), decompress bounded against a bomb; the recovered content, which may itself be signed or enveloped, is returned for the caller to re-verify. Bidirectionally interoperable with `openssl smime` / `openssl cms`. Fail-closed with typed `smime/*` errors — `sign`, `verify`, `encrypt`, `decrypt`, `compress`, `decompress` |
232
232
  | `pki.tsp` | RFC 3161 Time-Stamp Protocol — `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 / ordering), plus the RFC 3161 §2.4.2 signing-certificate attribute binding the token to the TSA certificate (SHA-2 imprints, any `pki.cms.sign` TSA key). `request` / `parseRequest` build and parse the TimeStampReq a client sends (imprint, requested policy, nonce, certReq), `response` / `parseResponse` the TimeStampResp a TSA returns — a granted status wrapping a token, or a rejection with PKIStatus and failure info, the §2.4.2 status↔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`, returning `{ valid, genTime, serialNumber, tstInfo, … }` — `sign`, `request`, `parseRequest`, `response`, `parseResponse`, `verify` |
@@ -239,7 +239,7 @@ is callable today; nothing below is a stub.
239
239
  | `pki.sigstore` | Offline verifier for a Sigstore bundle — the exact artifact `npm publish --provenance` produces and the registry serves. `verifyBundle` composes five fail-closed legs against caller-pinned trust (Fulcio CA roots + Rekor log keys, never trusted from the bundle): the DSSE signature over its PAE preimage under the Fulcio leaf key; the ephemeral Fulcio certificate chain, validated as of the Rekor log time; the RFC 9162 inclusion proof folded to a Rekor-signed tree root; the log entry bound to this exact signature; and the in-toto SLSA subject digest the caller confirms against the published artifact. Zero runtime deps — reuses the X.509 parser, RFC 5280 path validator, and Merkle verifier; the net-new codecs are the DSSE PAE byte-builder and a fail-closed JSON reader. `pae`, `parseBundle`, `verifyBundle` |
240
240
  | `pki.inspect` | Human-readable certificate inspection — the pure-JS equivalent of `openssl x509 -text`. `certificate(pem \| der \| parsed)` renders a familiar OpenSSL-style report: version, serial, signature algorithm, issuer/subject distinguished names, validity, public-key details (curve or modulus size + the raw point/modulus), every decoded extension with its critical flag, and the signature. Built over the strict parser and the two-way OID registry, so it names extension and algorithm OIDs an OpenSSL build shows only as raw bytes. No OpenSSL dependency; the format is stable and OpenSSL-familiar rather than pinned to one OpenSSL version; a malformed extension falls back to a hex dump rather than throwing — `certificate` |
241
241
  | `pki.webauthn` | WebAuthn / passkey attestation verification — offline trust evaluation of a W3C WebAuthn (Level 3) attestation. `parseAttestationObject(bytes)` decodes the CBOR attestation object + authenticatorData + COSE credential key over the strict `pki.cbor` codec; `verify(attestationObject, clientDataHash, opts)` checks the attestation-statement signature and each format's structural bindings for **packed / tpm / android-key / apple / fido-u2f / none** — the x5c leaf key, the apple nonce, the tpm `certInfo` Name/`extraData` over the `pubArea`, the android `KeyDescription`, the fido-u2f `verificationData` — binding the credential public key to each attestation (via the signed authenticatorData for packed/fido-u2f, or a cert/`pubArea`-key equality check for android-key/apple/tpm) and enforcing each leaf's certificate requirements. The credential-key check covers the full WebAuthn COSE algorithm set — ES256/384/512, RS256/384/512, PS256, EdDSA (Ed25519), and the RFC 9864 fully-specified identifiers **ESP256/384/512, Ed25519, and Ed448** — validating the public-key point on its curve, rejecting the compressed EC point form, and enforcing a minimally-encoded DER ECDSA signature. A verifier, not a ceremony client; fail-closed with typed `webauthn/*` errors. Chaining the returned trust path to a pinned root (and aaguid→root via FIDO MDS) is the caller's step through `pki.path.validate` — `parseAttestationObject`, `verify` |
242
- | `pki.lint` | Certificate linting — the zlint / pkilint of JavaScript. `certificate(pem \| der \| parsed, opts)` walks a parsed certificate and emits graded, advisory findings — each with a stable id, a severity (`fatal` > `error` > `warn` > `notice`), a source, a spec-clause citation, and a message — against the RFC 5280 profile plus a representative CA/Browser Forum TLS BR subset (serial sign/size, validity ordering + the the SC081v3 reducing validity schedule, keyCertSign coherence, unknown critical extensions, empty-subject SAN, SKI/AKI presence, SAN required + CN-in-SAN, dNSName syntax, serverAuth EKU, weak keys). Unlike every other entry the DATA path never throws: hostile bytes return a `fatal` `lint/unparseable` finding (with the strict parser's code) so a whole directory lints without a try/catch; only config-time misuse throws a typed `LintError`. `certificate`, `rules`, `profiles` |
242
+ | `pki.lint` | Certificate linting — the zlint / pkilint of JavaScript. `certificate(pem \| der \| parsed, opts)` walks a parsed certificate and emits graded, advisory findings — each with a stable id, a severity (`fatal` > `error` > `warn` > `notice`), a source, a spec-clause citation, and a message — against the RFC 5280 profile plus a representative CA/Browser Forum TLS BR subset (serial sign/size, validity ordering + the SC081v3 reducing validity schedule, keyCertSign coherence, extension criticality — basicConstraints/nameConstraints/policyConstraints/inhibitAnyPolicy must be critical and keyUsage should be, nameConstraints CA-scope, unknown critical extensions, empty-subject SAN, SKI/AKI presence including the end-entity subjectKeyIdentifier, SAN required + CN-in-SAN, dNSName syntax, serverAuth EKU, weak keys). Unlike every other entry the DATA path never throws: hostile bytes return a `fatal` `lint/unparseable` finding (with the strict parser's code) so a whole directory lints without a try/catch; only config-time misuse throws a typed `LintError`. `certificate`, `rules`, `profiles` |
243
243
  | `pki.C` / `pki.constants` | Version-stable constants — functional scale helpers (`C.TIME.*`, `C.BYTES.*`), codec `LIMITS`, `version` |
244
244
  | `pki.errors` | The `PkiError` taxonomy — `defineClass` plus `ConstantsError` / `Asn1Error` / `OidError` / `PemError` / `CertificateError` / `CrlError` / `CsrError` / `Pkcs8Error` / `CmsError` / `OcspError` / `TspError` / `AttrCertError` / `CrmfError` / `Pkcs12Error` / `CmpError` / `PathError` / `CtError` / `JoseError` / `AcmeError` / `WebauthnError` / `LintError`, each carrying a stable `code` in `domain/reason` form |
245
245
  | `pki` CLI | `pki version`, `pki oid <dotted\|name>`, `pki parse <cert>`, `pki inspect <cert>`, `pki lint <cert>`, `pki convert <file> --to der\|pem`, `pki verify <cert>... --anchor <cert>`, `pki sign <file> --cert <c> --key <k>` |
package/lib/cmp-build.js CHANGED
@@ -32,6 +32,7 @@ var oid = require("./oid");
32
32
  var cmp = require("./schema-cmp");
33
33
  var crmf = require("./crmf-sign");
34
34
  var csr = require("./schema-csr");
35
+ var crl = require("./schema-crl");
35
36
  var x509 = require("./schema-x509");
36
37
  var signScheme = require("./sign-scheme");
37
38
  var pkix = require("./schema-pkix");
@@ -58,10 +59,17 @@ var KNOWN_HEADER_KEYS = {
58
59
  sender: 1, recipient: 1, pvno: 1, messageTime: 1, senderKID: 1, recipKID: 1,
59
60
  transactionID: 1, senderNonce: 1, recipNonce: 1, freeText: 1, generalInfo: 1,
60
61
  };
61
- var KNOWN_BODY_KEYS = { ir: 1, cr: 1, kur: 1, p10cr: 1, certConf: 1, pollReq: 1, genm: 1, rr: 1 };
62
+ var KNOWN_BODY_KEYS = {
63
+ ir: 1, cr: 1, kur: 1, p10cr: 1, certConf: 1, pollReq: 1, genm: 1, rr: 1, // request-side
64
+ ip: 1, cp: 1, kup: 1, ccp: 1, krp: 1, rp: 1, genp: 1, error: 1, pollRep: 1, pkiconf: 1, // CA/responder-side
65
+ };
62
66
  // The PKIBody arm -> its EXPLICIT context tag number (schema-cmp.js BODY_ARMS). rr is [11], NOT [15].
63
- var BODY_TAG = { ir: 0, cr: 2, p10cr: 4, kur: 7, rr: 11, genm: 21, certConf: 24, pollReq: 25 };
67
+ var BODY_TAG = {
68
+ ir: 0, cr: 2, p10cr: 4, kur: 7, rr: 11, genm: 21, certConf: 24, pollReq: 25,
69
+ ip: 1, cp: 3, kup: 8, krp: 10, rp: 12, ccp: 14, pkiconf: 19, genp: 22, error: 23, pollRep: 26,
70
+ };
64
71
  var CRMF_BODY = { ir: 1, cr: 1, kur: 1 }; // arms whose content is a CertReqMessages via pki.crmf.build
72
+ var CERT_REP_ARM = { ip: 1, cp: 1, kup: 1, ccp: 1 }; // arms carrying a CertRepMessage
65
73
  var KNOWN_OPTS_KEYS = { key: 1, cert: 1, mac: 1, extraCerts: 1, pem: 1, pss: 1, digestAlgorithm: 1 };
66
74
  var KNOWN_MAC_KEYS = { secret: 1, salt: 1, iterationCount: 1, prf: 1, keyLength: 1, algorithm: 1 };
67
75
 
@@ -186,20 +194,23 @@ function _reqIdInt(v, code, what) {
186
194
  }
187
195
 
188
196
  // PKIFailureInfo ::= BIT STRING (named bits, RFC 9810 sec. 5.2.3) -- a minimal NamedBitList from bit names.
189
- function _encodeFailInfo(names) {
190
- if (!Array.isArray(names)) throw _err("cmp/bad-cert-status", "statusInfo.failInfo must be an array of PKIFailureInfo bit names");
197
+ function _encodeFailInfo(names, code) {
198
+ if (!Array.isArray(names)) throw _err(code, "statusInfo.failInfo must be an array of PKIFailureInfo bit names");
191
199
  return b.namedBitString(names.map(function (n) {
192
- if (typeof n !== "string" || FAIL_INFO_INDEX[n] === undefined) throw _err("cmp/bad-cert-status", "unknown PKIFailureInfo bit " + JSON.stringify(n));
200
+ if (typeof n !== "string" || FAIL_INFO_INDEX[n] === undefined) throw _err(code, "unknown PKIFailureInfo bit " + JSON.stringify(n));
193
201
  return FAIL_INFO_INDEX[n];
194
202
  }));
195
203
  }
196
204
 
197
- // PKIStatusInfo ::= SEQUENCE { status PKIStatus INTEGER, statusString PKIFreeText OPTIONAL, failInfo BIT STRING OPTIONAL }.
198
- function _encodePkiStatusInfo(si) {
199
- if (typeof si.status !== "number" || !Number.isInteger(si.status)) throw _err("cmp/bad-cert-status", "statusInfo.status must be a PKIStatus integer");
205
+ // PKIStatusInfo ::= SEQUENCE { status PKIStatus INTEGER, statusString PKIFreeText OPTIONAL, failInfo BIT STRING
206
+ // OPTIONAL }. `code` is the caller's typed error (default cmp/bad-status-info; certConf passes cmp/bad-cert-status).
207
+ function _encodePkiStatusInfo(si, code) {
208
+ code = code || "cmp/bad-status-info";
209
+ if (!si || typeof si !== "object" || Buffer.isBuffer(si)) throw _err(code, "a PKIStatusInfo must be an object { status, statusString?, failInfo? }");
210
+ if (typeof si.status !== "number" || !Number.isInteger(si.status) || si.status < 0 || si.status > 6) throw _err(code, "statusInfo.status must be a PKIStatus 0..6 (RFC 9810 sec. 5.2.3)");
200
211
  var children = [b.integer(BigInt(si.status))];
201
- if (si.statusString != null) children.push(_encodePkiFreeText(si.statusString, "cmp/bad-cert-status", "statusInfo.statusString"));
202
- if (si.failInfo != null) children.push(_encodeFailInfo(si.failInfo)); // strict order: status -> statusString -> failInfo (both optionals untagged)
212
+ if (si.statusString != null) children.push(_encodePkiFreeText(si.statusString, code, "statusInfo.statusString"));
213
+ if (si.failInfo != null) children.push(_encodeFailInfo(si.failInfo, code)); // strict order: status -> statusString -> failInfo (both optionals untagged)
203
214
  return b.sequence(children);
204
215
  }
205
216
 
@@ -211,7 +222,7 @@ function _encodeCertStatus(cs, state) {
211
222
  _reqOctets(cs.certHash, "certConf certHash"),
212
223
  b.integer(_reqIdInt(cs.certReqId, "cmp/bad-cert-status", "CertStatus certReqId")), // signed, -1 legal, unbounded
213
224
  ];
214
- if (cs.statusInfo != null) children.push(_encodePkiStatusInfo(cs.statusInfo));
225
+ if (cs.statusInfo != null) children.push(_encodePkiStatusInfo(cs.statusInfo, "cmp/bad-cert-status"));
215
226
  if (cs.hashAlg != null) {
216
227
  if (typeof cs.hashAlg !== "string" || !CERT_CONF_HASH_ALGS[cs.hashAlg]) throw _err("cmp/bad-name", "certConf hashAlg must be a hash algorithm (sha256 / sha384 / sha512 / sha3-256 / sha3-512 / sha1); got " + JSON.stringify(cs.hashAlg));
217
228
  children.push(b.explicit(0, _algIdNoParams(cs.hashAlg)));
@@ -264,6 +275,146 @@ function _encodeRevReqContent(list) {
264
275
  return b.sequence(list.map(_encodeRevDetails));
265
276
  }
266
277
 
278
+ // ---- response-side (CA/responder) PKIBody arm content encoders ----
279
+
280
+ // CertOrEncCert ::= CHOICE { certificate [0] EXPLICIT CMPCertificate, encryptedCert [1] EXPLICIT EncryptedKey }.
281
+ function _encodeCertOrEncCert(coec, state) {
282
+ if (!coec || typeof coec !== "object" || Buffer.isBuffer(coec)) throw _err("cmp/bad-cert-response", "certifiedKeyPair must carry certificate or encryptedCert");
283
+ if (coec.certificate != null && coec.encryptedCert != null) throw _err("cmp/bad-cert-response", "certOrEncCert is a CHOICE: supply exactly one of certificate or encryptedCert, not both");
284
+ if (coec.certificate != null) {
285
+ var certDer = _b.reqDer(coec.certificate, "certifiedKeyPair.certificate (a Certificate DER)");
286
+ try { x509.parse(certDer); } catch (e) { if (e instanceof CmpError) throw e; throw _err("cmp/bad-cert-response", "certifiedKeyPair.certificate is not a valid X.509 certificate", e); }
287
+ return b.explicit(0, b.raw(certDer)); // certificate [0] EXPLICIT
288
+ }
289
+ if (coec.encryptedCert != null) {
290
+ // encryptedCert [1] wraps an EncryptedKey (pre-encoded DER hatch in v1). Like the privateKey, only the
291
+ // EnvelopedData [0] form is cmp2021 -- the deprecated EncryptedValue (a universal SEQUENCE) is cmp2000.
292
+ var ec = _b.reqDer(coec.encryptedCert, "certifiedKeyPair.encryptedCert (a pre-encoded EncryptedKey DER)");
293
+ var ecNode;
294
+ try { ecNode = asn1.decode(ec); } catch (e) { throw _err("cmp/bad-cert-response", "certifiedKeyPair.encryptedCert is not valid DER", e); }
295
+ if (ecNode.tagClass === "context" && ecNode.tagNumber === 0) state.usesCmp2021 = true;
296
+ return b.explicit(1, b.raw(ec));
297
+ }
298
+ throw _err("cmp/bad-cert-response", "certifiedKeyPair must carry certificate or encryptedCert");
299
+ }
300
+
301
+ // CertifiedKeyPair ::= SEQUENCE { certOrEncCert, privateKey [0] EXPLICIT OPTIONAL, publicationInfo [1] EXPLICIT OPTIONAL }.
302
+ function _encodeCertifiedKeyPair(ckp, state) {
303
+ if (!ckp || typeof ckp !== "object" || Buffer.isBuffer(ckp)) throw _err("cmp/bad-cert-response", "certifiedKeyPair must be an object");
304
+ var children = [_encodeCertOrEncCert(ckp, state)]; // certificate / encryptedCert are direct keys (the parse-result shape)
305
+ if (ckp.privateKey != null) {
306
+ var pk = _b.reqDer(ckp.privateKey, "certifiedKeyPair.privateKey (a pre-encoded EncryptedKey DER)");
307
+ children.push(b.explicit(0, b.raw(pk)));
308
+ // EncryptedKey ::= CHOICE { encryptedValue EncryptedValue (a universal SEQUENCE, the deprecated cmp2000
309
+ // form), envelopedData [0] (a cmp2021 feature). Only the envelopedData form bumps pvno to cmp2021(3).
310
+ var pkNode;
311
+ try { pkNode = asn1.decode(pk); } catch (e) { throw _err("cmp/bad-cert-response", "certifiedKeyPair.privateKey is not valid DER", e); }
312
+ if (pkNode.tagClass === "context" && pkNode.tagNumber === 0) state.usesCmp2021 = true;
313
+ }
314
+ if (ckp.publicationInfo != null) children.push(b.explicit(1, b.raw(_b.reqDer(ckp.publicationInfo, "certifiedKeyPair.publicationInfo (a pre-encoded DER)"))));
315
+ return b.sequence(children);
316
+ }
317
+
318
+ // CertResponse ::= SEQUENCE { certReqId INTEGER, status PKIStatusInfo, certifiedKeyPair OPTIONAL, rspInfo OCTET STRING OPTIONAL }.
319
+ function _encodeCertResponse(cr, state) {
320
+ if (!cr || typeof cr !== "object" || Buffer.isBuffer(cr)) throw _err("cmp/bad-cert-response", "each CertResponse must be an object");
321
+ if (cr.status == null) throw _err("cmp/bad-cert-response", "CertResponse.status (a PKIStatusInfo) is required");
322
+ var hasCkp = cr.certifiedKeyPair != null;
323
+ // RFC 9810 sec. 5.3.4: failInfo XOR certifiedKeyPair; a certifiedKeyPair is allowed only under a granting
324
+ // status (accepted 0 / grantedWithMods 1).
325
+ if (hasCkp && cr.status.failInfo != null) throw _err("cmp/bad-cert-response", "a CertResponse must not carry both failInfo and certifiedKeyPair");
326
+ if (hasCkp && cr.status.status !== 0 && cr.status.status !== 1) throw _err("cmp/bad-cert-response", "a CertResponse certifiedKeyPair is allowed only under a granting status (accepted / grantedWithMods)");
327
+ var children = [b.integer(_reqIdInt(cr.certReqId, "cmp/bad-cert-response", "CertResponse certReqId")), _encodePkiStatusInfo(cr.status, "cmp/bad-cert-response")];
328
+ if (hasCkp) children.push(_encodeCertifiedKeyPair(cr.certifiedKeyPair, state));
329
+ if (cr.rspInfo != null) children.push(_reqOctets(cr.rspInfo, "CertResponse.rspInfo"));
330
+ return b.sequence(children);
331
+ }
332
+
333
+ // CertRepMessage ::= SEQUENCE { caPubs [1] EXPLICIT SEQUENCE OF CMPCertificate OPTIONAL, response SEQUENCE OF CertResponse }.
334
+ function _encodeCertRepMessage(spec, state, arm) {
335
+ if (!spec || typeof spec !== "object" || Buffer.isBuffer(spec)) throw _err("cmp/bad-cert-rep", arm + " must be an object { caPubs?, response }");
336
+ if (!Array.isArray(spec.response)) throw _err("cmp/bad-cert-rep", arm + ".response must be an array of CertResponse");
337
+ if (arm === "ccp" && spec.response.length !== 1) throw _err("cmp/bad-cert-rep", "a ccp CertRepMessage carries exactly one CertResponse (RFC 9810 App. D)");
338
+ var children = [];
339
+ if (spec.caPubs != null) { // caPubs [1] PRECEDES response (the optional-first x509-version shape)
340
+ if (!Array.isArray(spec.caPubs) || !spec.caPubs.length) throw _err("cmp/bad-cert-rep", arm + ".caPubs must be a non-empty array of certificate DERs");
341
+ children.push(b.explicit(1, b.sequence(spec.caPubs.map(function (c) { return _certRaw(c, "cmp/bad-cert-rep", "a caPubs entry"); }))));
342
+ }
343
+ children.push(b.sequence(spec.response.map(function (cr) { return _encodeCertResponse(cr, state); })));
344
+ return b.sequence(children);
345
+ }
346
+
347
+ // A raw Certificate DER, validated via the real parser before it is embedded.
348
+ function _certRaw(c, code, what) {
349
+ var der = _b.reqDer(c, what + " (a Certificate DER)");
350
+ try { x509.parse(der); } catch (e) { if (e instanceof CmpError) throw e; throw _err(code, what + " is not a valid X.509 certificate", e); }
351
+ return b.raw(der);
352
+ }
353
+
354
+ // RevRepContent ::= SEQUENCE { status SEQ OF PKIStatusInfo (min 1), revCerts [0] EXPLICIT SEQ OF CertId OPTIONAL, crls [1] EXPLICIT SEQ OF CertificateList OPTIONAL }.
355
+ function _encodeCertIdRr(cid) {
356
+ if (!cid || typeof cid !== "object" || Buffer.isBuffer(cid) || cid.issuer == null || cid.serialNumber == null) throw _err("cmp/bad-rev-rep", "each revCerts CertId must be { issuer, serialNumber }");
357
+ return b.sequence([_b.encodeGeneralName(cid.issuer), b.integer(_reqIdInt(cid.serialNumber, "cmp/bad-rev-rep", "CertId serialNumber"))]);
358
+ }
359
+ function _encodeRevRepContent(spec) {
360
+ if (!spec || typeof spec !== "object" || Buffer.isBuffer(spec)) throw _err("cmp/bad-rev-rep", "rp must be an object { status, revCerts?, crls? }");
361
+ if (!Array.isArray(spec.status) || !spec.status.length) throw _err("cmp/bad-rev-rep", "rp.status must be a non-empty array of PKIStatusInfo");
362
+ var children = [b.sequence(spec.status.map(function (si) { return _encodePkiStatusInfo(si, "cmp/bad-rev-rep"); }))];
363
+ if (spec.revCerts != null) {
364
+ if (!Array.isArray(spec.revCerts) || !spec.revCerts.length) throw _err("cmp/bad-rev-rep", "rp.revCerts must be a non-empty array of CertId");
365
+ children.push(b.explicit(0, b.sequence(spec.revCerts.map(_encodeCertIdRr))));
366
+ }
367
+ if (spec.crls != null) {
368
+ if (!Array.isArray(spec.crls) || !spec.crls.length) throw _err("cmp/bad-rev-rep", "rp.crls must be a non-empty array of CertificateList DERs");
369
+ children.push(b.explicit(1, b.sequence(spec.crls.map(function (c) {
370
+ var der = _b.reqDer(c, "crls entry (a CertificateList DER)");
371
+ try { crl.parse(der); } // each crls entry MUST be a valid X.509 CRL (CertificateList)
372
+ catch (e) { if (e instanceof CmpError) throw e; throw _err("cmp/bad-rev-rep", "a crls entry is not a valid CRL (CertificateList)", e); }
373
+ return b.raw(der);
374
+ }))));
375
+ }
376
+ return b.sequence(children);
377
+ }
378
+
379
+ // ErrorMsgContent ::= SEQUENCE { pKIStatusInfo, errorCode INTEGER OPTIONAL, errorDetails PKIFreeText OPTIONAL }.
380
+ function _encodeErrorMsgContent(spec) {
381
+ if (!spec || typeof spec !== "object" || Buffer.isBuffer(spec)) throw _err("cmp/bad-error", "error must be an object { pKIStatusInfo, errorCode?, errorDetails? }");
382
+ if (spec.pKIStatusInfo == null) throw _err("cmp/bad-error", "error.pKIStatusInfo is required");
383
+ var children = [_encodePkiStatusInfo(spec.pKIStatusInfo, "cmp/bad-error")];
384
+ if (spec.errorCode != null) children.push(b.integer(_reqIdInt(spec.errorCode, "cmp/bad-error", "error.errorCode")));
385
+ if (spec.errorDetails != null) children.push(_encodePkiFreeText(spec.errorDetails, "cmp/bad-error", "error.errorDetails"));
386
+ return b.sequence(children);
387
+ }
388
+
389
+ // PollRepContent ::= SEQUENCE OF SEQUENCE { certReqId INTEGER, checkAfter INTEGER (seconds, >= 0), reason PKIFreeText OPTIONAL }.
390
+ function _encodePollRepContent(list) {
391
+ if (!Array.isArray(list) || !list.length) throw _err("cmp/bad-poll-rep", "pollRep must be a non-empty array of { certReqId, checkAfter, reason? }");
392
+ return b.sequence(list.map(function (pr) {
393
+ if (!pr || typeof pr !== "object" || Buffer.isBuffer(pr)) throw _err("cmp/bad-poll-rep", "each pollRep entry must be an object");
394
+ if (typeof pr.checkAfter !== "number" || !Number.isInteger(pr.checkAfter) || pr.checkAfter < 0 || pr.checkAfter > 0x7fffffff) throw _err("cmp/bad-poll-rep", "pollRep checkAfter must be a non-negative uint31 delay in seconds (RFC 9810 sec. 5.3.22)");
395
+ var kids = [b.integer(_reqIdInt(pr.certReqId, "cmp/bad-poll-rep", "pollRep certReqId")), b.integer(BigInt(pr.checkAfter))];
396
+ if (pr.reason != null) kids.push(_encodePkiFreeText(pr.reason, "cmp/bad-poll-rep", "pollRep reason"));
397
+ return b.sequence(kids);
398
+ }));
399
+ }
400
+
401
+ // KeyRecRepContent ::= SEQUENCE { status PKIStatusInfo, newSigCert [0] EXPLICIT CMPCertificate OPTIONAL, caCerts [1] EXPLICIT SEQ OF CMPCertificate OPTIONAL, keyPairHist [2] EXPLICIT SEQ OF CertifiedKeyPair OPTIONAL }.
402
+ function _encodeKeyRecRepContent(spec, state) {
403
+ if (!spec || typeof spec !== "object" || Buffer.isBuffer(spec)) throw _err("cmp/bad-key-rec-rep", "krp must be an object { status, newSigCert?, caCerts?, keyPairHist? }");
404
+ if (spec.status == null) throw _err("cmp/bad-key-rec-rep", "krp.status is required");
405
+ var children = [_encodePkiStatusInfo(spec.status, "cmp/bad-key-rec-rep")];
406
+ if (spec.newSigCert != null) children.push(b.explicit(0, _certRaw(spec.newSigCert, "cmp/bad-key-rec-rep", "krp.newSigCert")));
407
+ if (spec.caCerts != null) {
408
+ if (!Array.isArray(spec.caCerts) || !spec.caCerts.length) throw _err("cmp/bad-key-rec-rep", "krp.caCerts must be a non-empty array of certificate DERs");
409
+ children.push(b.explicit(1, b.sequence(spec.caCerts.map(function (c) { return _certRaw(c, "cmp/bad-key-rec-rep", "a krp.caCerts entry"); }))));
410
+ }
411
+ if (spec.keyPairHist != null) {
412
+ if (!Array.isArray(spec.keyPairHist) || !spec.keyPairHist.length) throw _err("cmp/bad-key-rec-rep", "krp.keyPairHist must be a non-empty array of CertifiedKeyPair");
413
+ children.push(b.explicit(2, b.sequence(spec.keyPairHist.map(function (ckp) { return _encodeCertifiedKeyPair(ckp, state); }))));
414
+ }
415
+ return b.sequence(children);
416
+ }
417
+
267
418
  // Dispatch the single-key body object to its EXPLICIT-tagged arm. Returns { bodyTLV, usesCmp2021 }.
268
419
  function _encodeBody(bodySpec, key, opts) {
269
420
  if (!bodySpec || typeof bodySpec !== "object" || Buffer.isBuffer(bodySpec)) throw _err("cmp/bad-input", "message.body must be a single-key object");
@@ -299,8 +450,23 @@ function _encodeBody(bodySpec, key, opts) {
299
450
  inner = _encodePollReqContent(bodySpec.pollReq);
300
451
  } else if (arm === "genm") {
301
452
  inner = _encodeGenMsgContent(bodySpec.genm);
302
- } else { // rr
453
+ } else if (arm === "rr") {
303
454
  inner = _encodeRevReqContent(bodySpec.rr);
455
+ } else if (CERT_REP_ARM[arm]) { // ip / cp / kup / ccp -- CertRepMessage
456
+ inner = _encodeCertRepMessage(bodySpec[arm], state, arm);
457
+ } else if (arm === "rp") {
458
+ inner = _encodeRevRepContent(bodySpec.rp);
459
+ } else if (arm === "genp") { // GenRepContent == GenMsgContent
460
+ inner = _encodeGenMsgContent(bodySpec.genp);
461
+ } else if (arm === "error") {
462
+ inner = _encodeErrorMsgContent(bodySpec.error);
463
+ } else if (arm === "pollRep") {
464
+ inner = _encodePollRepContent(bodySpec.pollRep);
465
+ } else if (arm === "krp") {
466
+ inner = _encodeKeyRecRepContent(bodySpec.krp, state);
467
+ } else { // pkiconf -- PKIConfirmContent ::= NULL
468
+ if (bodySpec.pkiconf !== null && bodySpec.pkiconf !== true) throw _err("cmp/bad-input", "pkiconf takes null or true (PKIConfirmContent is NULL)");
469
+ inner = b.nullValue();
304
470
  }
305
471
  return Promise.resolve({ bodyTLV: b.explicit(tag, inner), usesCmp2021: state.usesCmp2021 });
306
472
  }
@@ -463,9 +629,13 @@ function _collectExtraCerts(opts, protCertDer) {
463
629
  * Build an RFC 9810 CMP `PKIMessage` -- the producing-side inverse of `pki.schema.cmp.parse`. `message` is
464
630
  * `{ header, body }`: `header` carries the `sender` / `recipient` GeneralNames plus optional transaction
465
631
  * metadata (`transactionID`, `senderNonce`, `messageTime`, `freeText`, `generalInfo`, ...); `body` is a
466
- * single-key object naming the request or transaction arm -- `{ ir }` / `{ cr }` / `{ kur }` (a
467
- * `CertReqMessages` spec delegated to `pki.crmf.build`), `{ p10cr }` (a PKCS#10 CertificationRequest DER),
468
- * `{ certConf }`, `{ pollReq }`, `{ genm }`, or `{ rr }`. The message is protected: `opts` carries EXACTLY
632
+ * single-key object naming the arm. Request-side: `{ ir }` / `{ cr }` / `{ kur }` (a `CertReqMessages` spec
633
+ * delegated to `pki.crmf.build`; the proof-of-possession key is `key` on the arm spec), `{ p10cr }` (a PKCS#10
634
+ * CertificationRequest DER), `{ certConf }`, `{ pollReq }`, `{ genm }`, `{ rr }`. CA/responder-side: `{ ip }` /
635
+ * `{ cp }` / `{ kup }` / `{ ccp }` (a `CertRepMessage` -- `caPubs` + `response` of `CertResponse` each with a
636
+ * `PKIStatusInfo` and, under a granting status, a `certifiedKeyPair`), `{ rp }` (`RevRepContent`), `{ genp }`,
637
+ * `{ error }` (`ErrorMsgContent`), `{ pollRep }`, `{ krp }` (`KeyRecRepContent`), `{ pkiconf }` (NULL). The
638
+ * message is protected: `opts` carries EXACTLY
469
639
  * ONE of `{ key, cert }` (a signature under the sender key over the message -- any registry algorithm, RSA
470
640
  * / ECDSA / EdDSA / ML-DSA / SLH-DSA / composite, resolved from the certificate) or `{ mac }` (a PBMAC1
471
641
  * shared-secret MAC). The protection is computed over the exact DER of the virtual
package/lib/constants.js CHANGED
@@ -204,6 +204,17 @@ var LIMITS = {
204
204
  // chain's tree holds a handful of nodes; the operator may override via
205
205
  // opts.maxPolicyNodes.
206
206
  PATH_MAX_POLICY_NODES: 4096,
207
+ // Certification-path BUILDING bounds (pki.path.build). The pool of candidate
208
+ // issuers is untrusted, so a cross-certificate cycle or Bridge-CA fan-out can
209
+ // grow the number and length of candidate paths without bound (RFC 4158 sec.
210
+ // 8.1). PATH_BUILD_MAX_DEPTH caps the chain length explored from the leaf --
211
+ // well under PATH_MAX_CERTS so a built path clears the validator's own bound
212
+ // on hand-off; a real hierarchy is a handful of hops. PATH_BUILD_MAX_CANDIDATES
213
+ // is the total-work ceiling: the builder ticks it once per candidate-issuer
214
+ // expansion, so a hostile mesh terminates deterministically. Both are operator
215
+ // overridable (opts.maxDepth / opts.maxCandidatesConsidered).
216
+ PATH_BUILD_MAX_DEPTH: 20,
217
+ PATH_BUILD_MAX_CANDIDATES: 1000,
207
218
  // PKCS#12 container ceilings. A PFX carries lists at three altitudes
208
219
  // (ContentInfos per AuthenticatedSafe, SafeBags per SafeContents,
209
220
  // attributes per bag) and can chain fresh DER blobs inside OCTET STRINGs,
package/lib/lint.js CHANGED
@@ -297,6 +297,18 @@ function _ecCurveName(spki) {
297
297
  } catch (_e) { return null; } // explicit / invalid EC parameters are not an approved named curve
298
298
  }
299
299
 
300
+ // RFC 5280 marks several extensions MUST (error) or SHOULD (warn) be critical. The shape is
301
+ // uniform: applies when the extension is present, fires when its raw `critical` flag is not
302
+ // true. The rule reads `ctx.raw(name).critical` WITHOUT decoding the value -- criticality is
303
+ // a structural property of the extension, independent of its contents.
304
+ function _criticalityRule(name, id, severity, citation, message) {
305
+ return {
306
+ id: id, severity: severity, source: "rfc5280", citation: citation, message: message,
307
+ appliesTo: function (cert, ctx) { return !!ctx.raw(name); },
308
+ check: function (cert, ctx) { var e = ctx.raw(name); return (e && e.critical !== true) ? true : null; },
309
+ };
310
+ }
311
+
300
312
  var RFC5280_RULES = [
301
313
  {
302
314
  id: "lint/rfc5280/serial-not-positive", severity: "error", source: "rfc5280", citation: "RFC 5280 4.1.2.2",
@@ -379,6 +391,51 @@ var RFC5280_RULES = [
379
391
  appliesTo: function (cert) { return !!(cert.issuer && cert.subject && cert.issuer.dn !== cert.subject.dn); },
380
392
  check: function (cert, ctx) { return ctx.raw("authorityKeyIdentifier") ? null : true; },
381
393
  },
394
+ {
395
+ // 4.2.1.9: MUST mark basicConstraints critical "in all CA certificates that contain
396
+ // public keys used to validate digital signatures on certificates" (keyCertSign). A CA
397
+ // key used exclusively for other purposes (CRL signing, key management) MAY carry a
398
+ // non-critical basicConstraints, so the rule applies only when keyCertSign is asserted
399
+ // (or keyUsage is absent) -- gating on cA alone would false-positive on a CRL-signing CA.
400
+ id: "lint/rfc5280/basic-constraints-not-critical", severity: "error", source: "rfc5280", citation: "RFC 5280 4.2.1.9",
401
+ message: "a CA certificate that validates certificate signatures must mark basicConstraints critical",
402
+ appliesTo: function (cert, ctx) {
403
+ var bc = ctx.decode("basicConstraints");
404
+ if (!(bc && bc.value && bc.value.cA === true)) return false;
405
+ var ku = ctx.decode("keyUsage");
406
+ return !ku || !ku.value || ku.value.keyCertSign === true;
407
+ },
408
+ check: function (cert, ctx) { var e = ctx.raw("basicConstraints"); return (e && e.critical !== true) ? true : null; },
409
+ },
410
+ _criticalityRule("nameConstraints", "lint/rfc5280/name-constraints-not-critical", "error",
411
+ "RFC 5280 4.2.1.10", "the nameConstraints extension must be marked critical"),
412
+ {
413
+ // 4.2.1.10: "The name constraints extension ... MUST be used only in a CA certificate."
414
+ id: "lint/rfc5280/name-constraints-not-ca", severity: "error", source: "rfc5280", citation: "RFC 5280 4.2.1.10",
415
+ message: "the nameConstraints extension must appear only in a CA certificate",
416
+ appliesTo: function (cert, ctx) { return !!ctx.raw("nameConstraints"); },
417
+ check: function (cert, ctx) { var bc = ctx.decode("basicConstraints"); return (bc && bc.value && bc.value.cA === true) ? null : true; },
418
+ },
419
+ _criticalityRule("policyConstraints", "lint/rfc5280/policy-constraints-not-critical", "error",
420
+ "RFC 5280 4.2.1.11", "the policyConstraints extension must be marked critical"),
421
+ _criticalityRule("inhibitAnyPolicy", "lint/rfc5280/inhibit-any-policy-not-critical", "error",
422
+ "RFC 5280 4.2.1.14", "the inhibitAnyPolicy extension must be marked critical"),
423
+ // 4.2.1.3: "When present, conforming CAs SHOULD mark this extension as critical." The
424
+ // recommendation directs the ISSUING CA whenever it includes keyUsage -- in ANY certificate,
425
+ // including an end-entity leaf -- and does NOT restrict it to certificates whose subject is
426
+ // itself a CA. So the rule is present-gated (matching zlint's w_ext_key_usage_not_critical),
427
+ // not CA-subject-gated; a SHOULD, hence warn.
428
+ _criticalityRule("keyUsage", "lint/rfc5280/key-usage-not-critical", "warn",
429
+ "RFC 5280 4.2.1.3", "the keyUsage extension should be marked critical"),
430
+ {
431
+ // 4.2.1.2: the SKI "SHOULD be included in all end entity certificates" (a SHOULD -> notice).
432
+ // The CA case is the separate ski-missing rule; this rule gates on the non-CA (leaf) path so
433
+ // the two are mutually exclusive and every certificate is covered by exactly one.
434
+ id: "lint/rfc5280/ski-missing-ee", severity: "notice", source: "rfc5280", citation: "RFC 5280 4.2.1.2",
435
+ message: "an end-entity certificate should carry a subjectKeyIdentifier extension",
436
+ appliesTo: function (cert, ctx) { var bc = ctx.decode("basicConstraints"); return !(bc && bc.value && bc.value.cA === true); },
437
+ check: function (cert, ctx) { return ctx.raw("subjectKeyIdentifier") ? null : true; },
438
+ },
382
439
  ];
383
440
 
384
441
  function _isTls(cert, ctx) { return ctx.isTlsServerCert; }
@@ -75,6 +75,8 @@ var OID = {
75
75
  extKeyUsage: oid.byName("extKeyUsage"),
76
76
  anyExtendedKeyUsage: oid.byName("anyExtendedKeyUsage"),
77
77
  cRLDistributionPoints: oid.byName("cRLDistributionPoints"),
78
+ subjectKeyIdentifier: oid.byName("subjectKeyIdentifier"),
79
+ authorityKeyIdentifier: oid.byName("authorityKeyIdentifier"),
78
80
  };
79
81
 
80
82
  // The set of extension OIDs the validator PROCESSES -- an unrecognized critical
@@ -2024,8 +2026,300 @@ function verifyOcspResponse(parsedResponse, cert, issuerCert, time, opts) {
2024
2026
  });
2025
2027
  }
2026
2028
 
2029
+ // ---- certification path BUILDING (pki.path.build, RFC 4158) ----------------
2030
+
2031
+ // A soft extension decode for the RFC 4158 sec. 3.5 SORT hints (AKI/SKI/
2032
+ // basicConstraints/keyUsage): a present-but-undecodable extension on an
2033
+ // UNTRUSTED pool candidate degrades to "no hint", never a hard fail -- the sort
2034
+ // weight is advisory, the branch is still tried, and validate is the authority.
2035
+ function softDecode(cert, extOid) {
2036
+ try { return decodeExt(cert, extOid); }
2037
+ catch (_e) { return null; }
2038
+ }
2039
+
2040
+ // dnEqual that fails a control-byte DN (CVE-2009-2408) closed to "not a match"
2041
+ // rather than rejecting the whole build -- one malformed pool cert must not
2042
+ // poison a buildable path (parity with selfIssued's swallow). The hard
2043
+ // name-chaining gate remains validate's own dnEqual over the chosen path.
2044
+ function nameMatchSoft(rdnsA, rdnsB) {
2045
+ try { return dnEqual(rdnsA, rdnsB); }
2046
+ catch (_e) { return false; }
2047
+ }
2048
+
2049
+ // A parsed extension entry the search's findExt dereferences by .oid and, for
2050
+ // the subjectAltName, by .value (a Buffer in the identity key).
2051
+ function _isExtensionEntry(e) { return !!e && typeof e.oid === "string" && Buffer.isBuffer(e.value); }
2052
+
2053
+ // The complete parsed-certificate shape build produces AND hands to validate --
2054
+ // every top-level field this module dereferences (grep-verified), each with the
2055
+ // type the code assumes. A claimed-parsed object satisfying this cannot throw a
2056
+ // raw TypeError anywhere in the search or the validate hand-off.
2057
+ function _isParsedCert(o) {
2058
+ return Buffer.isBuffer(o.tbsBytes) &&
2059
+ typeof o.serialNumberHex === "string" &&
2060
+ !!o.signatureAlgorithm && typeof o.signatureAlgorithm.oid === "string" &&
2061
+ !!o.signatureValue && Buffer.isBuffer(o.signatureValue.bytes) &&
2062
+ !!o.validity && o.validity.notBefore instanceof Date && o.validity.notAfter instanceof Date &&
2063
+ !!o.issuer && Array.isArray(o.issuer.rdns) &&
2064
+ !!o.subject && Array.isArray(o.subject.rdns) && Buffer.isBuffer(o.subject.bytes) &&
2065
+ !!o.subjectPublicKeyInfo && Buffer.isBuffer(o.subjectPublicKeyInfo.bytes) &&
2066
+ !!o.subjectPublicKeyInfo.algorithm && typeof o.subjectPublicKeyInfo.algorithm.oid === "string" &&
2067
+ !!o.subjectPublicKeyInfo.publicKey && Buffer.isBuffer(o.subjectPublicKeyInfo.publicKey.bytes) &&
2068
+ typeof o.subjectPublicKeyInfo.publicKey.unusedBits === "number" &&
2069
+ Array.isArray(o.extensions) && o.extensions.every(_isExtensionEntry);
2070
+ }
2071
+
2072
+ function coerceCert(input) {
2073
+ // An already-parsed certificate is passed through; a DER Buffer / PEM string is
2074
+ // parsed (and its typed error normalized by the caller). A claimed-parsed object
2075
+ // (a truthy tbsBytes) must carry the COMPLETE parsed-certificate shape build and
2076
+ // the validate hand-off dereference -- a bare { tbsBytes } object, or any partial
2077
+ // shape, fails closed here as a typed PathError rather than a raw TypeError deeper
2078
+ // in the walk or inside validate.
2079
+ if (input && typeof input === "object" && !Buffer.isBuffer(input) && input.tbsBytes !== undefined) {
2080
+ if (!_isParsedCert(input)) throw E("path/bad-input", "build: an input has tbsBytes but is not a well-formed parsed certificate");
2081
+ return input;
2082
+ }
2083
+ return x509.parse(input);
2084
+ }
2085
+
2086
+ // A trust-store entry is either a ready anchor tuple { name, publicKey,
2087
+ // algorithm, parameters? } or a self-signed root certificate reduced to that
2088
+ // tuple. The algorithm is the SPKI KEY-algorithm OID (the sec. 6.1.4(f)
2089
+ // parameter-inheritance value), mirroring trust.js _mkAnchor -- NOT the
2090
+ // signature OID. The anchor is an input to validate, never one of the path certs.
2091
+ function toAnchor(entry) {
2092
+ if (entry && typeof entry === "object" && !Buffer.isBuffer(entry) && entry.name && entry.publicKey && entry.algorithm) {
2093
+ // A ready anchor tuple: validate the shape build + validate consume -- name.rdns
2094
+ // (name matching), publicKey bytes and the algorithm OID (the sec. 6.1.4 key
2095
+ // hand-off). trustAnchors is a caller option, so a malformed tuple is a config
2096
+ // error and fails closed at entry, not a downstream no-path / soft verdict.
2097
+ if (!Array.isArray(entry.name.rdns) || !Buffer.isBuffer(entry.publicKey) || typeof entry.algorithm !== "string") {
2098
+ throw E("path/bad-input", "build: a trustAnchor tuple must be { name: { rdns: [...] }, publicKey: Buffer, algorithm: OID string }");
2099
+ }
2100
+ return entry;
2101
+ }
2102
+ var cert;
2103
+ try { cert = coerceCert(entry); }
2104
+ catch (e) { throw E("path/bad-input", "build: a trustAnchor entry must be a { name, publicKey, algorithm } tuple or a certificate", e); }
2105
+ var spki = cert.subjectPublicKeyInfo;
2106
+ return { name: cert.subject, publicKey: spki.bytes, algorithm: spki.algorithm.oid, parameters: spki.algorithm.parameters, subjectDer: cert.subject.bytes };
2107
+ }
2108
+
2109
+ // RFC 4158 sec. 2.4.2 loop key: the (subject DN + subjectAltName + subject
2110
+ // public key) tuple, NOT the DN alone. DN-alone keying wrongly prunes a
2111
+ // legitimate distinct cross-cert (cross-certs share a DN) and misses a same-key
2112
+ // loop; the key differentiates a rollover (same DN, new key) from a true repeat.
2113
+ function identityKey(cert) {
2114
+ var san = findExt(cert, OID.subjectAltName);
2115
+ return cert.subject.bytes.toString("hex") + "|" + (san ? san.value.toString("hex") : "") + "|" +
2116
+ cert.subjectPublicKeyInfo.bytes.toString("hex");
2117
+ }
2118
+
2119
+ function childAkiKeyId(cert) {
2120
+ var d = softDecode(cert, OID.authorityKeyIdentifier);
2121
+ return (d && d.value && d.value.keyIdentifier) ? d.value.keyIdentifier : null;
2122
+ }
2123
+
2124
+ // RFC 4158 sec. 3.5 candidate prioritization -- a SORT weight, never a gate.
2125
+ // Higher = tried first (fewer validate calls to reach a valid path). Every term
2126
+ // is a hint: a KID match, an anchor-adjacent issuer, CA + keyCertSign presence,
2127
+ // and validity at the check time all raise priority, but a lower-scoring
2128
+ // candidate is STILL attempted, and validate makes every accept decision.
2129
+ function scoreCandidate(cand, childAki, anchors, time) {
2130
+ var score = 0;
2131
+ if (childAki) {
2132
+ var d = softDecode(cand, OID.subjectKeyIdentifier);
2133
+ if (d && Buffer.isBuffer(d.value) && d.value.equals(childAki)) score += 1000; // sec. 3.5.12 KID match: heaviest
2134
+ }
2135
+ for (var i = 0; i < anchors.length; i++) {
2136
+ if (nameMatchSoft(cand.issuer.rdns, anchors[i].name.rdns)) { score += 100; break; } // sec. 3.5.15/.16 anchor-adjacent
2137
+ }
2138
+ var bc = softDecode(cand, OID.basicConstraints);
2139
+ if (bc && bc.value && bc.value.cA === true) score += 10; // sec. 3.5.1 basicConstraints cA
2140
+ var ku = softDecode(cand, OID.keyUsage);
2141
+ if (ku && ku.value && ku.value.keyCertSign === true) score += 10; // sec. 3.5.3 keyUsage keyCertSign
2142
+ var v = cand.validity;
2143
+ // allow:nan-date-comparison-unguarded -- cand.validity dates are codec-parsed (asn1 readTime rejects a NaN instant) and time is guard.time.assertValid'd at entry; the validity term is a fail-safe sort hint regardless.
2144
+ if (v && v.notBefore instanceof Date && v.notAfter instanceof Date &&
2145
+ v.notBefore.getTime() <= time.getTime() && time.getTime() <= v.notAfter.getTime()) score += 5; // sec. 3.5.4 validity
2146
+ return score;
2147
+ }
2148
+
2149
+ /**
2150
+ * @primitive pki.path.build
2151
+ * @signature pki.path.build(leaf, opts) -> Promise<{ valid, path, trustAnchor, result, candidatesConsidered }>
2152
+ * @since 0.3.7
2153
+ * @status experimental
2154
+ * @spec RFC 4158, RFC 5280
2155
+ * @related pki.path.validate, pki.schema.x509.parse, pki.trust.parseCertdata
2156
+ *
2157
+ * Discover the ordered certification path from a leaf certificate up to a trust anchor, over an
2158
+ * untrusted pool of candidate CA certificates, then validate it. `build` is the discovering
2159
+ * complement of `validate`: `validate` takes an already-ordered path and a trust anchor and runs
2160
+ * the 6.1 state machine; `build` takes a leaf, an unordered pool of candidate issuers, and a
2161
+ * trust store, and searches for the ordered leaf->anchor path `validate` accepts.
2162
+ *
2163
+ * Candidate issuers are matched by RFC 5280 7.1 name chaining, prioritized by the RFC 4158 3.5
2164
+ * heuristics (a subjectKeyIdentifier/authorityKeyIdentifier match, an anchor-adjacent issuer,
2165
+ * CA + keyCertSign, validity at the check time -- all hints, never filters), and searched
2166
+ * depth-first with backtracking: the first ordered path that `pki.path.validate` accepts wins. A
2167
+ * name or key-identifier match is only an ordering hint; every accept flows through `validate`,
2168
+ * so `build` never weakens or duplicates a 6.1 check. The search over the untrusted pool is
2169
+ * bounded -- a depth cap on chain length, a total-work cap on candidate expansions, and a
2170
+ * visited-set keyed on the (subject, subjectAltName, public key) tuple -- so a cross-certificate
2171
+ * cycle or Bridge-CA fan-out terminates deterministically rather than growing without bound.
2172
+ *
2173
+ * `leaf` is a DER `Buffer`, a PEM string, or an already-parsed `pki.schema.x509` object. Returns
2174
+ * `{ valid, path, trustAnchor, result, candidatesConsidered }`, where `path` is the ordered array
2175
+ * `validate` consumes (anchor-proximal first, leaf last, the anchor excluded). Fail-closed: bad
2176
+ * options throw `path/bad-input`; no chain to any anchor throws `path/no-path`; chains that
2177
+ * assemble but none validate return `{ valid:false }` with the best failing `validate` result;
2178
+ * the search bound throws `path/build-limit`. AIA caIssuers fetching is out of scope -- `build` is
2179
+ * offline (zero network); supply fetched issuers in `opts.candidates`.
2180
+ *
2181
+ * @opts candidates The untrusted candidate CA pool (array of DER/PEM/parsed certs; alias `intermediates`).
2182
+ * @opts trustAnchors The trust store (non-empty array of `{ name, publicKey, algorithm }` tuples or self-signed root certificates).
2183
+ * @opts time The check date (`Date`, required); forwarded to every internal `validate` call.
2184
+ * @opts maxDepth Chain-length depth cap (default `C.LIMITS.PATH_BUILD_MAX_DEPTH`).
2185
+ * @opts maxCandidatesConsidered Total-work cap on candidate expansions (default `C.LIMITS.PATH_BUILD_MAX_CANDIDATES`).
2186
+ * @opts validate `false` returns the ordered path without validating (pure-builder mode; default `true`).
2187
+ * @opts (validate options) Every `pki.path.validate` option (`requiredEku`, `revocationChecker`, `checkPurpose`, the initial policy inputs, ...) is forwarded unchanged.
2188
+ * @example
2189
+ * var result = await pki.path.build(pemString, {
2190
+ * candidates: [], // untrusted intermediates (the openssl -untrusted set)
2191
+ * trustAnchors: [pemString], // a self-signed root, or a { name, publicKey, algorithm } tuple
2192
+ * time: new Date(),
2193
+ * });
2194
+ * result.valid; // true when a path to a trust anchor was found and validated
2195
+ */
2196
+ async function build(leaf, opts) {
2197
+ opts = opts || {};
2198
+ if (typeof opts !== "object" || Buffer.isBuffer(opts)) throw E("path/bad-input", "build: opts must be an object");
2199
+ var leafCert;
2200
+ try { leafCert = coerceCert(leaf); }
2201
+ catch (e) { throw E("path/bad-input", "build: the leaf certificate did not parse", e); }
2202
+
2203
+ var poolInput = opts.candidates !== undefined ? opts.candidates : opts.intermediates;
2204
+ if (poolInput === undefined) poolInput = [];
2205
+ if (!Array.isArray(poolInput)) throw E("path/bad-input", "build: opts.candidates must be an array of certificates");
2206
+ // A pool larger than the absolute ceiling is rejected at entry (bounds the
2207
+ // parse work on an untrusted bundle) -- distinct from the per-search tick
2208
+ // budget below, which an operator may set lower to cap the DFS.
2209
+ var poolCeiling = constants.LIMITS.PATH_BUILD_MAX_CANDIDATES;
2210
+ if (poolInput.length > poolCeiling) throw E("path/bad-input", "build: the candidate pool has " + poolInput.length + " certificates, exceeding the " + poolCeiling + " ceiling");
2211
+ var pool;
2212
+ try { pool = poolInput.map(coerceCert); }
2213
+ catch (e) { throw E("path/bad-input", "build: a candidate certificate did not parse", e); }
2214
+
2215
+ if (!Array.isArray(opts.trustAnchors) || opts.trustAnchors.length === 0) throw E("path/bad-input", "build: opts.trustAnchors must be a non-empty array of anchor tuples or root certificates");
2216
+ var anchors = opts.trustAnchors.map(toAnchor);
2217
+
2218
+ guard.time.assertValid(opts.time, E, "path/bad-input", "build: opts.time (the validity-window check date, forwarded to validate)");
2219
+ // The chain-length bound is tied to the EFFECTIVE maxPathCerts the interleaved
2220
+ // validate uses: a built path holds hop + 1 certificates (the leaf plus each
2221
+ // intermediate), so capping hops at maxPathCerts - 1 keeps every path validate
2222
+ // sees within its own per-path ceiling -- otherwise a maxDepth of maxPathCerts
2223
+ // would assemble a maxPathCerts + 1 path that validate rejects with
2224
+ // path/bad-input. The same cap value is computed the way validate computes it
2225
+ // (opts.maxPathCerts forwarded), and the explicit-stack search below carries no
2226
+ // native-recursion stack risk regardless of the depth.
2227
+ var effectiveMaxCerts = guard.limits.cap(opts.maxPathCerts, "build: opts.maxPathCerts", constants.LIMITS.PATH_MAX_CERTS, { E: E, code: "path/bad-input", min: 1 });
2228
+ // depthCeiling is 0 when maxPathCerts is 1: the only path within the limit is a
2229
+ // single leaf directly under an anchor (a zero-hop search). maxDepth's minimum
2230
+ // is therefore 0, not 1 -- a zero-hop search is legitimate and a maxPathCerts of
2231
+ // 1 must not throw before the anchor is even checked.
2232
+ var depthCeiling = effectiveMaxCerts - 1;
2233
+ var maxDepth = guard.limits.cap(opts.maxDepth, "build: opts.maxDepth", Math.min(constants.LIMITS.PATH_BUILD_MAX_DEPTH, depthCeiling), { E: E, code: "path/bad-input", min: 0, max: depthCeiling });
2234
+ var maxConsidered = guard.limits.cap(opts.maxCandidatesConsidered, "build: opts.maxCandidatesConsidered", poolCeiling, { E: E, code: "path/bad-input", min: 1 });
2235
+ var doValidate = opts.validate !== false;
2236
+
2237
+ // The build-specific options are consumed here; every remaining validate
2238
+ // option is forwarded unchanged to the interleaved validate call. Object.keys
2239
+ // enumerates only own enumerable properties, so no prototype-pollution belt.
2240
+ var BUILD_ONLY_OPT = { candidates: 1, intermediates: 1, trustAnchors: 1, maxDepth: 1, maxCandidatesConsidered: 1, validate: 1 };
2241
+ var forwarded = {};
2242
+ Object.keys(opts).forEach(function (k) { if (!BUILD_ONLY_OPT[k]) forwarded[k] = opts[k]; });
2243
+ function validateOpts(anchor) {
2244
+ var vo = {};
2245
+ Object.keys(forwarded).forEach(function (f) { vo[f] = forwarded[f]; });
2246
+ vo.trustAnchor = anchor;
2247
+ return vo;
2248
+ }
2249
+
2250
+ // The DoS terminator: tick() once per candidate-issuer expansion so a hostile
2251
+ // mesh cannot fan the search without bound (RFC 4158 8.1). A breach throws
2252
+ // path/build-limit. candidatesConsidered mirrors the tick count for the caller.
2253
+ var counter = guard.limits.counter(maxConsidered, E, "path/build-limit", "build: candidate-issuer expansion");
2254
+ var considered = 0;
2255
+ var anyChainAssembled = false;
2256
+ var bestFail = null;
2257
+ var success = null;
2258
+
2259
+ // A bounded, EXPLICIT-stack forward DFS from the leaf toward an anchor -- no
2260
+ // native recursion, so an operator-raised maxDepth cannot overflow the stack
2261
+ // over an untrusted graph. Each frame is a candidate chain [top, ...toward the
2262
+ // leaf] (leaf last) plus the identity keys already on it; a frame is expanded
2263
+ // by prepending a name-chaining issuer. Highest-priority candidates are pushed
2264
+ // LAST so they are popped (explored) FIRST -- depth-first in priority order.
2265
+ // Caps are enforced before every expansion; a candidate whose (subject, SAN,
2266
+ // public key) tuple is already on the chain is a loop and is pruned.
2267
+ var stack = [{ chain: [leafCert], hop: 0, keys: new Set([identityKey(leafCert)]) }];
2268
+ while (stack.length && !success) {
2269
+ var frame = stack.pop();
2270
+ var current = frame.chain[0];
2271
+
2272
+ // Terminate: is `current` issued by a configured anchor? An anchor-adjacent
2273
+ // cert completes a candidate path; validate is the authority on acceptance.
2274
+ // A found path breaks out here and the enclosing while exits on !success.
2275
+ for (var ai = 0; ai < anchors.length; ai++) {
2276
+ if (!nameMatchSoft(current.issuer.rdns, anchors[ai].name.rdns)) continue;
2277
+ if (!doValidate) { success = { path: frame.chain.slice(), trustAnchor: anchors[ai] }; break; }
2278
+ anyChainAssembled = true;
2279
+ var res = await validate(frame.chain, validateOpts(anchors[ai]));
2280
+ if (res.valid) { success = { valid: true, path: frame.chain.slice(), trustAnchor: anchors[ai], result: res }; break; }
2281
+ if (!bestFail) bestFail = { path: frame.chain.slice(), trustAnchor: anchors[ai], result: res };
2282
+ }
2283
+ if (success || frame.hop >= maxDepth) continue; // depth cap: this branch is exhausted
2284
+
2285
+ var childAki = childAkiKeyId(current);
2286
+ var scored = [];
2287
+ for (var pi = 0; pi < pool.length; pi++) {
2288
+ if (nameMatchSoft(pool[pi].subject.rdns, current.issuer.rdns)) {
2289
+ scored.push({ cand: pool[pi], score: scoreCandidate(pool[pi], childAki, anchors, opts.time) });
2290
+ }
2291
+ }
2292
+ // Ascending sort -> push lowest priority first so the highest is on top.
2293
+ scored.sort(function (a, bb) { return a.score - bb.score; });
2294
+ for (var ci = 0; ci < scored.length; ci++) {
2295
+ counter.tick(); // breadth / total-work cap -> throws path/build-limit
2296
+ considered += 1;
2297
+ var cand = scored[ci].cand;
2298
+ var candKey = identityKey(cand);
2299
+ if (frame.keys.has(candKey)) continue; // the (subject,SAN,key) tuple is already on this branch -> a loop, prune
2300
+ var childKeys = new Set(frame.keys);
2301
+ childKeys.add(candKey);
2302
+ stack.push({ chain: [cand].concat(frame.chain), hop: frame.hop + 1, keys: childKeys });
2303
+ }
2304
+ }
2305
+
2306
+ if (success) {
2307
+ if (doValidate) return { valid: true, path: success.path, trustAnchor: success.trustAnchor, result: success.result, candidatesConsidered: considered };
2308
+ return { path: success.path, trustAnchor: success.trustAnchor, candidatesConsidered: considered };
2309
+ }
2310
+ if (anyChainAssembled) {
2311
+ // Chains reached an anchor but none validated -> the soft verdict carrying
2312
+ // the best failing validate result (parity with validate; never a throw).
2313
+ return { valid: false, path: bestFail.path, trustAnchor: bestFail.trustAnchor, result: bestFail.result, candidatesConsidered: considered };
2314
+ }
2315
+ // No chain to any configured anchor could even be assembled -- a permanent
2316
+ // structural verdict (name/key chaining dead-ended before the trust store).
2317
+ throw E("path/no-path", "build: no certification path from the leaf to any configured trust anchor could be assembled");
2318
+ }
2319
+
2027
2320
  module.exports = {
2028
2321
  validate: validate,
2322
+ build: build,
2029
2323
  crlChecker: crlChecker,
2030
2324
  ocspChecker: ocspChecker,
2031
2325
  verifyOcspResponse: verifyOcspResponse,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/pki",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
4
4
  "description": "Pure-JavaScript PKI toolkit that owns its stack — X.509, ASN.1/DER, CMS, PQC-first.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
package/sbom.cdx.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.5",
5
- "serialNumber": "urn:uuid:9ded865a-a922-4de1-a8f0-bd02d3b29a2e",
5
+ "serialNumber": "urn:uuid:dc01f613-ee62-4b7d-b2d5-42e3bb2f5560",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-07-17T19:09:09.529Z",
8
+ "timestamp": "2026-07-18T01:11:02.665Z",
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.3.5",
22
+ "bom-ref": "@blamejs/pki@0.3.7",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.3.5",
25
+ "version": "0.3.7",
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.3.5",
29
+ "purl": "pkg:npm/%40blamejs/pki@0.3.7",
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.3.5",
57
+ "ref": "@blamejs/pki@0.3.7",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]