@blamejs/pki 0.3.6 → 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,15 @@ 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
+
7
16
  ## v0.3.6 — 2026-07-17
8
17
 
9
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.
package/README.md CHANGED
@@ -221,7 +221,7 @@ 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` |
@@ -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/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.6",
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:552d3506-7a4d-4927-bd2e-3faadaa00c02",
5
+ "serialNumber": "urn:uuid:dc01f613-ee62-4b7d-b2d5-42e3bb2f5560",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-07-17T19:50:12.519Z",
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.6",
22
+ "bom-ref": "@blamejs/pki@0.3.7",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.3.6",
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.6",
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.6",
57
+ "ref": "@blamejs/pki@0.3.7",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]