@blamejs/pki 0.3.6 → 0.3.8

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,24 @@ 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.8 — 2026-07-18
8
+
9
+ Human-readable inspection extends to CRLs, CSRs, and CMS messages, with a pki.schema.detectFormat companion.
10
+
11
+ ### Added
12
+
13
+ - pki.inspect.crl / .csr / .cms render a certificate revocation list, a PKCS#10 certification request, and a CMS message as OpenSSL-familiar text reports (openssl crl -text / req -text / cms -cmsout -print), and pki.inspect.any detects the format of a DER/PEM input and routes it to the matching report. Each composes the certificate inspector's shipped field renderers, resolves every extension/attribute/algorithm/content-type OID through the registry (unknown -> dotted, undecodable value -> raw octets), and is best-effort: a malformed part hex-dumps rather than failing the report, and only entry-point coercion throws (inspect/bad-crl / inspect/bad-csr / inspect/bad-cms / inspect/unsupported-format). Cross-checked field-for-field against OpenSSL. RFC 5280 / RFC 2986 / RFC 5652.
14
+ - pki.schema.detectFormat(input) returns the registered PKI format name a DER Buffer or PEM string encodes -- one of pki.schema.all() -- without parsing it, or null when it matches no registered format. The detection half of pki.schema.parse, over the same authoritative format ordering.
15
+
16
+ ## v0.3.7 — 2026-07-17
17
+
18
+ Certification path building arrives as pki.path.build, and pki.lint gains seven RFC 5280 extension-criticality and CA-scope lints.
19
+
20
+ ### Added
21
+
22
+ - 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.
23
+ - 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.
24
+
7
25
  ## v0.3.6 — 2026-07-17
8
26
 
9
27
  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` |
@@ -237,9 +237,9 @@ is callable today; nothing below is a stub.
237
237
  | `pki.shbs` | Stateful hash-based signature **verification** — HSS/LMS (RFC 8554), carried in X.509 by RFC 9802 and CMS by RFC 9708, profiled by NIST SP 800-208 (CNSA 2.0 firmware signing). `verify` checks an HSS signature (every level must pass) and `verifyLms` a single-tree LMS, over the raw public-key / signature blobs the parsers already surface. Pure public-input SHA-256 / SHAKE256 hashing, a data-driven typecode registry, bounds-before-slice reads; a malformed blob throws a typed `ShbsError`, a well-formed-but-wrong signature returns `false`. **Verify only by design** — stateful signing needs atomic one-time-key state that belongs in an HSM — `verify`, `verifyLms` |
238
238
  | `pki.hpke` | Hybrid Public Key Encryption (RFC 9180) — the encrypt-to-a-public-key primitive behind TLS ECH, MLS, and OHTTP. `setupS`/`setupR` establish a sender/recipient context (KEM encapsulation + HKDF key schedule); the context's `seal`/`open` AEAD-encrypt with a sequence-counter nonce and `export` derives further secrets; `seal`/`open` are single-shot wrappers. DHKEM (P-256, P-521, X25519, X448) × HKDF-SHA256/SHA512 × AES-GCM/ChaCha20Poly1305/export-only × all four modes, proven against the RFC 9180 Appendix A vectors. DHKEM(P-384) and HKDF-SHA384 are RFC-registered but Appendix A ships no vector for them, so they fail closed until an authoritative KAT exists. Pure composition over `node:crypto`; ML-KEM / X-Wing are a registry data-row extension pending stable drafts — `suites`, `setupS`, `setupR`, `seal`, `open` |
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
- | `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` |
240
+ | `pki.inspect` | Human-readable inspection — the pure-JS equivalent of `openssl x509/crl/req/cms -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. `crl` / `csr` / `cms` render the non-certificate formats the same way — a CRL like `openssl crl -text` (issuer, Last/Next Update, CRL extensions, each revoked entry with its serial, revocation date, and named reason), a CSR like `openssl req -text` (subject, key, requested extensions and attributes), and a CMS message like `openssl cms -cmsout -print` (a SignedData's content type, digest algorithms, embedded certificates, and each SignerInfo with its signer identifier, algorithms, attributes, and signature; a non-SignedData ContentInfo gets a stable summary) — and `any(input)` detects the format and routes to the right report. Built over the strict parsers and the two-way OID registry, reusing one set of field renderers, so it names extension/algorithm OIDs an OpenSSL build shows only as raw bytes and never drifts. No OpenSSL dependency; the format is stable and OpenSSL-familiar rather than pinned to one OpenSSL version; a malformed part falls back to a hex dump rather than throwing — `certificate`, `crl`, `csr`, `cms`, `any` |
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/inspect.js CHANGED
@@ -27,6 +27,10 @@ var constants = require("./constants");
27
27
  var asn1 = require("./asn1-der");
28
28
  var oid = require("./oid");
29
29
  var x509 = require("./schema-x509");
30
+ var crl = require("./schema-crl");
31
+ var csr = require("./schema-csr");
32
+ var cms = require("./schema-cms");
33
+ var schemaAll = require("./schema-all");
30
34
  var pkix = require("./schema-pkix");
31
35
  var guard = require("./guard-all");
32
36
 
@@ -590,8 +594,395 @@ function certificate(input) {
590
594
  return L.join("\n") + "\n";
591
595
  }
592
596
 
597
+ // ---- CRL / CSR / CMS coercion (the _parse model, per format) -----------------
598
+
599
+ // Each fast-path completeness check mirrors _looksParsed: a bare/partial object
600
+ // with only the marker field is NOT a parsed result, so it throws the documented
601
+ // typed inspect/bad-input rather than dereferencing a missing field.
602
+ function _looksParsedCrl(o) {
603
+ return typeof o.version === "number" && o.issuer && typeof o.issuer === "object" &&
604
+ o.thisUpdate != null && Array.isArray(o.revokedCertificates) && Array.isArray(o.crlExtensions) &&
605
+ o.signatureAlgorithm && typeof o.signatureAlgorithm === "object";
606
+ }
607
+ function _looksParsedCsr(o) {
608
+ return typeof o.version === "number" && o.subject && typeof o.subject === "object" &&
609
+ o.subjectPublicKeyInfo && typeof o.subjectPublicKeyInfo === "object" &&
610
+ Array.isArray(o.attributes) && o.signatureAlgorithm && typeof o.signatureAlgorithm === "object";
611
+ }
612
+ function _looksParsedCms(o) {
613
+ if (typeof o.contentType !== "string" || typeof o.contentTypeName !== "string" || typeof o.version !== "number") return false;
614
+ // A real parse result is one of the six dispatched content types AND carries that type's
615
+ // required structural fields (_CMS_SHAPE). An unrecognized contentType, or a recognized one
616
+ // missing its fields, is not a parse result: fail closed as inspect/bad-input, not a partial render.
617
+ var shape = _CMS_SHAPE[o.contentType];
618
+ return shape ? shape(o) : false;
619
+ }
620
+ // The shared inspect coercion boundary, composed by every direct inspector AND any():
621
+ // pemLabel:null unwraps a PEM string OR a PEM-armored Buffer (a .pem read with
622
+ // fs.readFileSync) under ANY block label -- so an aliased label (PKCS7 for CMS,
623
+ // CRL for an X509 CRL) routes like any() instead of hitting a canonical-label
624
+ // mismatch -- while a raw DER Buffer / Uint8Array passes through the detached-store guard.
625
+ var _INSPECT_ENTRY = { pemLabel: null, PemError: InspectError, ErrorClass: InspectError, prefix: "inspect" };
626
+ // A parameterized _parse clone: marker field, fast-path completeness check, then
627
+ // the shared label-agnostic coercion to DER, then parse(der) inside try.
628
+ function _coerce(input, spec) {
629
+ if (input && typeof input === "object" && !Buffer.isBuffer(input) && !(input instanceof Uint8Array) && input[spec.marker] !== undefined) {
630
+ if (!spec.looksParsed(input)) throw _err("inspect/bad-input", "input has a " + spec.marker + " property but is not a complete " + spec.parsedName + " result");
631
+ return input;
632
+ }
633
+ var der;
634
+ // Compose the ONE coercion every format parser + any() use, so a PEM string and a
635
+ // PEM-armored Buffer unwrap under any label (not just the format's canonical one)
636
+ // and a detached-store Buffer fails closed here, not divergently per inspector.
637
+ try { der = pkix.coerceToDer(input, _INSPECT_ENTRY); }
638
+ catch (e) { throw _err("inspect/bad-input", "input must be a parsed " + spec.what + ", a DER Buffer, or a PEM block", e); }
639
+ try { return spec.parse(der); }
640
+ catch (e) { throw _err(spec.badCode, "input is not a well-formed " + spec.what, e); }
641
+ }
642
+ function _parseCrl(input) { return _coerce(input, { marker: "thisUpdate", looksParsed: _looksParsedCrl, parsedName: "pki.schema.crl.parse", parse: crl.parse, badCode: "inspect/bad-crl", what: "X.509 CRL" }); }
643
+ function _parseCsr(input) { return _coerce(input, { marker: "certificationRequestInfoBytes", looksParsed: _looksParsedCsr, parsedName: "pki.schema.csr.parse", parse: csr.parse, badCode: "inspect/bad-csr", what: "PKCS#10 certification request" }); }
644
+ function _parseCms(input) { return _coerce(input, { marker: "contentType", looksParsed: _looksParsedCms, parsedName: "pki.schema.cms.parse", parse: cms.parse, badCode: "inspect/bad-cms", what: "CMS message" }); }
645
+
646
+ // ---- shared attribute-value renderer (CSR attributes + CMS signed/unsigned) ---
647
+
648
+ // Attribute/extension dispatch keys on the STABLE OID, never the display name a
649
+ // pki.oid.register() override can change (the schema stores the overridden name).
650
+ var OID_EXTENSION_REQUEST = oid.byName("extensionRequest");
651
+ var OID_CONTENT_TYPE = oid.byName("contentType");
652
+ var OID_MESSAGE_DIGEST = oid.byName("messageDigest");
653
+ var OID_SIGNING_TIME = oid.byName("signingTime");
654
+
655
+ // Decode the known content-binding attribute types for display; anything else, or
656
+ // a decode failure, falls back to printable-or-hex. Never throws once parsed.
657
+ // Keyed on the attribute's OID (stable), not its display name (register-mutable).
658
+ function _attrValue(typeOid, rawDer, inner) {
659
+ try {
660
+ if (typeOid === OID_CONTENT_TYPE) { var ct = asn1.read.oid(asn1.decode(rawDer)); return inner + (oid.name(ct) || ct); }
661
+ if (typeOid === OID_MESSAGE_DIGEST) return _hexColon(asn1.read.octetString(asn1.decode(rawDer)), { wrap: 16, indent: inner.length });
662
+ if (typeOid === OID_SIGNING_TIME) return inner + _date(asn1.read.time(asn1.decode(rawDer)));
663
+ } catch (_e) { /* fall through to the raw fallback */ }
664
+ return _fallback(rawDer, inner);
665
+ }
666
+
667
+ // ---- CRL report --------------------------------------------------------------
668
+
669
+ // The CRL decoder PRE-DECODES three extension values to a JS type (not the raw
670
+ // Buffer the shared _extension consumes): cRLNumber -> BigInt, reasonCode ->
671
+ // Number, invalidityDate -> Date. Dispatch on the STABLE OID exactly as the decoder
672
+ // does (schema-crl decodeExt), NOT the display name -- pki.oid.register() can override
673
+ // a built-in name, which would skip these branches and hand _extension a non-Buffer
674
+ // value (rendering "(empty)" for the CRL number or reason). deltaCRLIndicator is a
675
+ // raw-Buffer extension the decoder does NOT pre-decode, but its value is a bare INTEGER
676
+ // (BaseCRLNumber) the operator needs in decimal to relate a delta CRL to its base -- so
677
+ // decode it here for display (best-effort: a malformed value falls through to hex).
678
+ // Delegate every other raw-value extension (AKI / IDP / freshestCRL / certificateIssuer)
679
+ // to the shared _extension verbatim.
680
+ var OID_CRL_NUMBER = oid.byName("cRLNumber");
681
+ var OID_REASON_CODE = oid.byName("reasonCode");
682
+ var OID_INVALIDITY_DATE = oid.byName("invalidityDate");
683
+ var OID_DELTA_CRL_INDICATOR = oid.byName("deltaCRLIndicator");
684
+ function _crlExtension(ext, pad) {
685
+ var label = EXT_LABEL[ext.name] || ext.name || ext.oid;
686
+ var header = pad + label + ":" + (ext.critical ? " critical" : "");
687
+ var inner = pad + " ";
688
+ if (ext.oid === OID_CRL_NUMBER && typeof ext.value === "bigint") return header + "\n" + inner + String(ext.value);
689
+ if (ext.oid === OID_REASON_CODE && typeof ext.value === "number") return header + "\n" + inner + (NAMES.CRL_REASON[ext.value] || String(ext.value));
690
+ if (ext.oid === OID_INVALIDITY_DATE && ext.value instanceof Date) return header + "\n" + inner + _date(ext.value);
691
+ if (ext.oid === OID_DELTA_CRL_INDICATOR && Buffer.isBuffer(ext.value)) {
692
+ try { return header + "\n" + inner + "BaseCRLNumber: " + String(asn1.read.integer(asn1.decode(ext.value))); }
693
+ catch (_e) { /* malformed BaseCRLNumber -> fall through to the shared _extension (hex) */ }
694
+ }
695
+ return _extension(ext, pad);
696
+ }
697
+
698
+ /**
699
+ * @primitive pki.inspect.crl
700
+ * @signature pki.inspect.crl(input) -> string
701
+ * @since 0.3.8
702
+ * @status experimental
703
+ * @spec RFC 5280
704
+ * @related pki.schema.crl.parse, pki.inspect.certificate
705
+ *
706
+ * Render a certificate revocation list as an `openssl crl -text`-familiar text
707
+ * report: issuer, Last/Next Update, the CRL extensions, each revoked entry (serial,
708
+ * revocation date, entry extensions), and the signature. `input` is a PEM string, a
709
+ * DER Buffer, or a `pki.schema.crl.parse` result; a non-CRL throws
710
+ * `inspect/bad-crl`, a wrong-type input `inspect/bad-input`. A malformed individual
711
+ * extension renders as hex rather than failing the report.
712
+ *
713
+ * @example
714
+ * pki.inspect.crl(crlDer).split("\n")[0]; // "Certificate Revocation List (CRL):"
715
+ */
716
+ function crlReport(input) {
717
+ var c = _parseCrl(input);
718
+ var L = ["Certificate Revocation List (CRL):"];
719
+ L.push(" Version " + c.version + " (0x" + (c.version - 1).toString(16) + ")");
720
+ L.push(" Signature Algorithm: " + _algName(c.signatureAlgorithm));
721
+ L.push(" Issuer: " + _dnString(c.issuer));
722
+ L.push(" Last Update: " + _date(c.thisUpdate));
723
+ L.push(" Next Update: " + (c.nextUpdate ? _date(c.nextUpdate) : "NONE"));
724
+ if (c.crlExtensions.length) {
725
+ L.push(" CRL extensions:");
726
+ c.crlExtensions.forEach(function (ext) { L.push(_crlExtension(ext, " ")); });
727
+ }
728
+ if (c.revokedCertificates.length) {
729
+ L.push("Revoked Certificates:");
730
+ c.revokedCertificates.forEach(function (e) {
731
+ L.push(" " + _serial(e, 8));
732
+ L.push(" Revocation Date: " + _date(e.revocationDate));
733
+ if ((e.crlEntryExtensions || []).length) {
734
+ L.push(" CRL entry extensions:");
735
+ e.crlEntryExtensions.forEach(function (ext) { L.push(_crlExtension(ext, " ")); });
736
+ }
737
+ });
738
+ } else {
739
+ L.push("No Revoked Certificates.");
740
+ }
741
+ L.push(" Signature Algorithm: " + _algName(c.signatureAlgorithm));
742
+ var sig = c.signatureValue && (c.signatureValue.bytes || c.signatureValue);
743
+ if (Buffer.isBuffer(sig)) { L.push(" Signature Value:"); L.push(_hexColon(sig, { wrap: 16, indent: 8 })); }
744
+ return L.join("\n") + "\n";
745
+ }
746
+
747
+ // ---- CSR report --------------------------------------------------------------
748
+
749
+ function _attribute(attr, pad) {
750
+ var inner = pad + " ";
751
+ // extensionRequest carries decoded RFC 5280 extensions (the cert-extension shape)
752
+ // -> render each through the shared _extension, identically to a certificate's.
753
+ if (attr.type === OID_EXTENSION_REQUEST && Array.isArray(attr.extensions)) {
754
+ var lines = [pad + "Requested Extensions:"];
755
+ attr.extensions.forEach(function (ext) { lines.push(_extension(ext, inner)); });
756
+ return lines.join("\n");
757
+ }
758
+ var header = pad + (attr.name || oid.name(attr.type) || attr.type) + ":";
759
+ var vals = (attr.values || []).map(function (v) { return _attrValue(attr.type, v, inner); });
760
+ return vals.length ? header + "\n" + vals.join("\n") : header;
761
+ }
762
+
763
+ /**
764
+ * @primitive pki.inspect.csr
765
+ * @signature pki.inspect.csr(input) -> string
766
+ * @since 0.3.8
767
+ * @status experimental
768
+ * @spec RFC 2986
769
+ * @related pki.schema.csr.parse, pki.inspect.certificate
770
+ *
771
+ * Render a PKCS#10 certification request as an `openssl req -text`-familiar text
772
+ * report: subject, the subject public key, the requested extensions and other
773
+ * attributes, and the signature. `input` is a PEM string, a DER Buffer, or a
774
+ * `pki.schema.csr.parse` result; a non-CSR throws `inspect/bad-csr`, a wrong-type
775
+ * input `inspect/bad-input`. Best-effort like `certificate`.
776
+ *
777
+ * @example
778
+ * pki.inspect.csr(csrDer).split("\n")[0]; // "Certificate Request:"
779
+ */
780
+ function csrReport(input) {
781
+ var c = _parseCsr(input);
782
+ var L = ["Certificate Request:", " Data:"];
783
+ L.push(" Version: " + c.version + " (0x" + (c.version - 1).toString(16) + ")");
784
+ L.push(" Subject: " + _dnString(c.subject));
785
+ L.push(" Subject Public Key Info:");
786
+ L.push(_keyBlock(c.subjectPublicKeyInfo, " "));
787
+ L.push(" Attributes:");
788
+ if (c.attributes.length) c.attributes.forEach(function (attr) { L.push(_attribute(attr, " ")); });
789
+ else L.push(" (none)");
790
+ L.push(" Signature Algorithm: " + _algName(c.signatureAlgorithm));
791
+ var sig = c.signatureValue && (c.signatureValue.bytes || c.signatureValue);
792
+ if (Buffer.isBuffer(sig)) { L.push(" Signature Value:"); L.push(_hexColon(sig, { wrap: 16, indent: 8 })); }
793
+ return L.join("\n") + "\n";
794
+ }
795
+
796
+ // ---- CMS report --------------------------------------------------------------
797
+
798
+ // Dispatch on the STABLE contentType OID, never the display name: pki.oid.register()
799
+ // lets an application override the built-in "signedData" name, and dispatching on the
800
+ // mutable name would then misroute a valid SignedData to the generic summary.
801
+ var OID_SIGNED_DATA = oid.byName("signedData");
802
+ // pki.schema.cms.parse dispatches ONLY these six RFC 5652 content types (every other
803
+ // OID throws cms/unsupported- or cms/unknown-content-type -- it never yields an object).
804
+ // Each maps to a predicate over the REQUIRED (non-OPTIONAL) fields of its top-level
805
+ // SEQUENCE, so the parsed-object fast path in _looksParsedCms accepts a genuine parse
806
+ // result but a partial/hand-built object (the type OID + name + version, none of the
807
+ // structural fields) fails closed as inspect/bad-input -- the documented boundary.
808
+ function _isObj(x) { return !!x && typeof x === "object"; }
809
+ var _CMS_SHAPE = {};
810
+ _CMS_SHAPE[OID_SIGNED_DATA] = function (o) { return Array.isArray(o.digestAlgorithms) && _isObj(o.encapContentInfo) && Array.isArray(o.signerInfos); }; // sec. 5.1
811
+ _CMS_SHAPE[oid.byName("envelopedData")] = function (o) { return Array.isArray(o.recipientInfos) && _isObj(o.encryptedContentInfo); }; // sec. 6.1
812
+ _CMS_SHAPE[oid.byName("encryptedData")] = function (o) { return _isObj(o.encryptedContentInfo); }; // sec. 8
813
+ _CMS_SHAPE[oid.byName("authData")] = function (o) { return Array.isArray(o.recipientInfos) && _isObj(o.macAlgorithm) && _isObj(o.encapContentInfo) && Buffer.isBuffer(o.mac); }; // sec. 9.1
814
+ _CMS_SHAPE[oid.byName("authEnvelopedData")] = function (o) { return Array.isArray(o.recipientInfos) && _isObj(o.encryptedContentInfo) && Buffer.isBuffer(o.mac); }; // RFC 5083 (build surfaces encryptedContentInfo)
815
+ _CMS_SHAPE[oid.byName("compressedData")] = function (o) { return _isObj(o.compressionAlgorithm) && _isObj(o.encapContentInfo); }; // RFC 3274
816
+
817
+ // Coverage residuals in the report assemblers -- verified-hard-to-reach, not gaps:
818
+ // * The `x.bytes || x` / `oid.name(o) || o` / `a.name || oid.name || a.type` fallbacks are
819
+ // belts for shapes the strict parsers never produce (a parsed CSR always carries
820
+ // signatureValue.bytes; a decoded algorithm always names its OID) -- they mirror
821
+ // certificate()'s own defensive fallbacks.
822
+ // * The embedded-CRL delegation and the CONTEXT-tagged embedded-element summary need a CMS
823
+ // carrying a crls element or an attribute-certificate CHOICE alternative, and the
824
+ // AuthenticatedData macAlgorithm / countersignature-unsignedAttrs arms need CMS shapes the
825
+ // toolkit's own producers (cms.sign / encrypt / compress) do not emit; every such arm is
826
+ // driven best-effort (never throws) and its structural sibling (embedded Certificate,
827
+ // envelopedData recipientInfo, CompressedData compressionAlgorithm, signed attributes) is
828
+ // covered.
829
+
830
+ // An embedded certificate/CRL element {bytes,tagClass,tagNumber}: a UNIVERSAL
831
+ // SEQUENCE is a real Certificate/CertificateList -> delegate to the full sub-report
832
+ // (guarded; a one-line summary on any failure); a CONTEXT-tagged CHOICE alternative
833
+ // (attribute certificate / other) renders a one-line tag+size summary, never parsed.
834
+ function _cmsEmbedded(kind, el, pad) {
835
+ if (el.tagClass === "universal") {
836
+ try {
837
+ var sub = (kind === "CRL") ? crlReport(el.bytes) : certificate(el.bytes);
838
+ return pad + kind + ":\n" + sub.replace(/\n$/, "").split("\n").map(function (l) { return pad + " " + l; }).join("\n");
839
+ } catch (_e) { /* fall through to the summary */ }
840
+ }
841
+ return pad + kind + " [" + el.tagClass + " " + el.tagNumber + "] (" + el.bytes.length + " bytes)";
842
+ }
843
+
844
+ function _signerInfoAttrs(title, attrs, pad) {
845
+ var lines = [pad + title + ":"];
846
+ var inner = pad + " ";
847
+ attrs.forEach(function (a) {
848
+ var vals = (a.values || []).map(function (v) { return _attrValue(a.type, v, inner + " "); });
849
+ lines.push(inner + (a.name || oid.name(a.type) || a.type) + ":");
850
+ vals.forEach(function (v) { lines.push(v); });
851
+ });
852
+ return lines.join("\n");
853
+ }
854
+
855
+ function _signerInfo(si, pad) {
856
+ var inner = pad + " ";
857
+ var L = [pad + "SignerInfo:", inner + "Version: " + si.version];
858
+ if (si.sid && si.sid.serialNumberHex !== undefined) {
859
+ L.push(inner + "Issuer: " + _dnString(si.sid.issuer));
860
+ L.push(inner + _serial(si.sid, pad.length + 8));
861
+ } else if (si.sid && Buffer.isBuffer(si.sid.subjectKeyIdentifier)) {
862
+ L.push(inner + "Subject Key Identifier: " + _hexColon(si.sid.subjectKeyIdentifier, {}));
863
+ }
864
+ L.push(inner + "Digest Algorithm: " + _algName(si.digestAlgorithm));
865
+ if (si.signedAttrs && si.signedAttrs.length) L.push(_signerInfoAttrs("Signed Attributes", si.signedAttrs, inner));
866
+ L.push(inner + "Signature Algorithm: " + _algName(si.signatureAlgorithm));
867
+ if (si.unsignedAttrs && si.unsignedAttrs.length) L.push(_signerInfoAttrs("Unsigned Attributes", si.unsignedAttrs, inner));
868
+ if (Buffer.isBuffer(si.signature)) { L.push(inner + "Signature Value:"); L.push(_hexColon(si.signature, { wrap: 16, indent: pad.length + 8 })); }
869
+ return L.join("\n");
870
+ }
871
+
872
+ /**
873
+ * @primitive pki.inspect.cms
874
+ * @signature pki.inspect.cms(input) -> string
875
+ * @since 0.3.8
876
+ * @status experimental
877
+ * @spec RFC 5652
878
+ * @related pki.schema.cms.parse, pki.inspect.certificate
879
+ *
880
+ * Render a CMS message as an `openssl cms -cmsout -print`-familiar text report. A
881
+ * SignedData shows the content type, digest algorithms, encapsulated content,
882
+ * embedded certificates/CRLs, and each SignerInfo (signer identifier, algorithms,
883
+ * signed/unsigned attributes, signature); a non-SignedData ContentInfo renders a
884
+ * stable top-field summary. `input` is a PEM string, a DER Buffer, or a
885
+ * `pki.schema.cms.parse` result; a non-CMS throws `inspect/bad-cms`. Best-effort.
886
+ *
887
+ * @example
888
+ * pki.inspect.cms(cmsDer).split("\n")[0]; // "CMS ContentInfo:"
889
+ */
890
+ // A ContentInfo whose content type pki.schema.cms.parse does not dispatch (id-data,
891
+ // digestedData, ...) is a VALID CMS the parser defers, not a malformed message -- render an
892
+ // outer-only summary (the named content type) rather than failing the report.
893
+ function _cmsOuterSummary(input) {
894
+ var ct = null;
895
+ try {
896
+ var der = pkix.coerceToDer(input, _INSPECT_ENTRY);
897
+ ct = asn1.read.oid(asn1.decode(der).children[0]);
898
+ } catch (_e) { /* best-effort: name unknown */ }
899
+ return "CMS ContentInfo:\n Content Type: " + (ct ? (oid.name(ct) || ct) + " (" + ct + ")" : "unknown") +
900
+ "\n (content type not further parsed; outer ContentInfo only)\n";
901
+ }
902
+
903
+ function cmsReport(input) {
904
+ var m;
905
+ try { m = _parseCms(input); }
906
+ catch (e) {
907
+ // A ContentInfo whose contentType the parser does not dispatch is a valid CMS, not a malformed
908
+ // message: a known-but-deferred type (cms/unsupported-content-type, e.g. id-data) or a private/
909
+ // unregistered OID (cms/unknown-content-type). Either renders the outer-only summary.
910
+ var cc = e && e.cause && e.cause.code;
911
+ if (e && e.code === "inspect/bad-cms" && (cc === "cms/unsupported-content-type" || cc === "cms/unknown-content-type")) return _cmsOuterSummary(input);
912
+ throw e;
913
+ }
914
+ var L = ["CMS ContentInfo:"];
915
+ L.push(" Content Type: " + (m.contentTypeName || oid.name(m.contentType) || m.contentType) + " (" + m.contentType + ")");
916
+ if (m.contentType === OID_SIGNED_DATA) {
917
+ L.push(" SignedData:");
918
+ L.push(" Version: " + m.version);
919
+ L.push(" Digest Algorithms:");
920
+ (m.digestAlgorithms || []).forEach(function (a) { L.push(" " + _algName(a)); });
921
+ if (m.encapContentInfo) {
922
+ L.push(" Encapsulated Content Info:");
923
+ L.push(" Content Type: " + (oid.name(m.encapContentInfo.eContentType) || m.encapContentInfo.eContentType) + " (" + m.encapContentInfo.eContentType + ")");
924
+ L.push(" " + (m.encapContentInfo.eContent == null ? "<no content (detached)>" : (m.encapContentInfo.eContent.length + " content byte(s)")));
925
+ }
926
+ (m.certificates || []).forEach(function (el) { L.push(_cmsEmbedded("Certificate", el, " ")); });
927
+ (m.crls || []).forEach(function (el) { L.push(_cmsEmbedded("CRL", el, " ")); });
928
+ (m.signerInfos || []).forEach(function (si) { L.push(_signerInfo(si, " ")); });
929
+ } else {
930
+ // Non-SignedData: a stable top-field summary (no plaintext to show); never throws.
931
+ L.push(" " + (m.contentTypeName || "content") + ":");
932
+ if (m.version != null) L.push(" Version: " + m.version);
933
+ (m.recipientInfos || []).forEach(function (ri) { L.push(" RecipientInfo: " + (ri.type || "?") + (ri.ridType ? " (" + ri.ridType + ")" : "")); });
934
+ if (m.encryptedContentInfo) {
935
+ L.push(" Content Type: " + (oid.name(m.encryptedContentInfo.contentType) || m.encryptedContentInfo.contentType));
936
+ if (m.encryptedContentInfo.contentEncryptionAlgorithm) L.push(" Content Encryption Algorithm: " + _algName(m.encryptedContentInfo.contentEncryptionAlgorithm));
937
+ }
938
+ if (m.macAlgorithm) L.push(" MAC Algorithm: " + _algName(m.macAlgorithm));
939
+ if (m.compressionAlgorithm) L.push(" Compression Algorithm: " + _algName(m.compressionAlgorithm));
940
+ }
941
+ return L.join("\n") + "\n";
942
+ }
943
+
944
+ // ---- unified detect-and-dispatch ---------------------------------------------
945
+
946
+ var _INSPECT_BY_FORMAT = { x509: certificate, crl: crlReport, csr: csrReport, cms: cmsReport };
947
+
948
+ /**
949
+ * @primitive pki.inspect.any
950
+ * @signature pki.inspect.any(input) -> string
951
+ * @since 0.3.8
952
+ * @status experimental
953
+ * @spec RFC 5280
954
+ * @related pki.schema.detectFormat, pki.inspect.certificate
955
+ *
956
+ * Detect which PKI format `input` (a PEM string or DER Buffer) encodes and render
957
+ * it with the matching report -- the inspect analogue of `pki.schema.parse`. Routes
958
+ * a certificate / CRL / CSR / CMS to `certificate` / `crl` / `csr` / `cms`; a
959
+ * detected but out-of-scope format (OCSP, TSP, PKCS#8/#12, CRMF, CMP, ...) throws
960
+ * `inspect/unsupported-format` naming it, and an unrecognized input
961
+ * `inspect/bad-input`.
962
+ *
963
+ * @example
964
+ * pki.inspect.any(der); // routes to the right report by detected format
965
+ */
966
+ function any(input) {
967
+ // Unwrap to DER ONCE (any PEM label), detect from the DER, then route the DER Buffer -- the
968
+ // renderer parses a Buffer directly and never re-applies its own strict PEM label.
969
+ var der, fmt;
970
+ try {
971
+ der = pkix.coerceToDer(input, _INSPECT_ENTRY);
972
+ fmt = schemaAll.detectFormat(der); // decodes the root -- a non-DER Buffer throws here, not at coerce
973
+ } catch (e) { throw _err("inspect/bad-input", "input is not a decodable DER Buffer or PEM string", e); }
974
+ if (fmt === null) throw _err("inspect/bad-input", "input does not match any registered PKI format");
975
+ var render = _INSPECT_BY_FORMAT[fmt];
976
+ if (!render) throw _err("inspect/unsupported-format", "inspect does not support the detected format \"" + fmt + "\" (supported: certificate, crl, csr, cms)");
977
+ return render(der);
978
+ }
979
+
593
980
  module.exports = {
594
981
  certificate: certificate,
982
+ crl: crlReport,
983
+ csr: csrReport,
984
+ cms: cmsReport,
985
+ any: any,
595
986
  // The extension names certificate() renders to their decoded values (vs the raw
596
987
  // hex fallback). The inspect test asserts this covers every extension the shared
597
988
  // decoders decode, so a newly-decodable extension cannot silently hex-dump.
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/lib/schema-all.js CHANGED
@@ -290,6 +290,34 @@ function parse(input) {
290
290
  throw new SchemaError("schema/unknown-format", "input does not match any registered PKI format (" + all().join(", ") + ")");
291
291
  }
292
292
 
293
+ /**
294
+ * @primitive pki.schema.detectFormat
295
+ * @signature pki.schema.detectFormat(input) -> string | null
296
+ * @since 0.3.8
297
+ * @status experimental
298
+ * @spec RFC 5280
299
+ * @related pki.schema.parse, pki.schema.all
300
+ *
301
+ * Detect which registered PKI format `input` (a DER `Buffer` or PEM string)
302
+ * encodes and return its name -- one of `pki.schema.all()` -- WITHOUT parsing it,
303
+ * or `null` when the decoded bytes match no registered format. This is the
304
+ * detection half of `pki.schema.parse`, running the same authoritative `FORMATS`
305
+ * ordering, exposed for a caller (e.g. `pki.inspect.any`) that needs the format
306
+ * name rather than the parsed result. Input that does not decode as DER throws the
307
+ * same coercion / decode error `parse` throws.
308
+ *
309
+ * @example
310
+ * pki.schema.detectFormat(der); // "x509" | "crl" | "csr" | "cms" | ... | null
311
+ */
312
+ function detectFormat(input) {
313
+ var der = pkix.coerceToDer(input, ENTRY);
314
+ var root = pkix.decodeRoot(der, ENTRY);
315
+ for (var i = 0; i < FORMATS.length; i++) {
316
+ if (FORMATS[i].detect(root)) return FORMATS[i].name;
317
+ }
318
+ return null;
319
+ }
320
+
293
321
  // Curated public surface: each format exposes only its operator primitives. The
294
322
  // `matches` detector is internal dispatch infrastructure (used by FORMATS above),
295
323
  // not an operator API, so it is NOT re-exported here.
@@ -313,4 +341,5 @@ module.exports = {
313
341
  smime: { parseSigningCertificate: smime.parseSigningCertificate, parseSigningCertificateV2: smime.parseSigningCertificateV2, parseSmimeCapabilities: smime.parseSmimeCapabilities, decodeAttribute: smime.decodeAttribute },
314
342
  all: all,
315
343
  parse: parse,
344
+ detectFormat: detectFormat,
316
345
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/pki",
3
- "version": "0.3.6",
3
+ "version": "0.3.8",
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:7df7ec92-2c30-46ba-acd2-036a52d58ca2",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-07-17T19:50:12.519Z",
8
+ "timestamp": "2026-07-18T08:43:20.402Z",
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.8",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.3.6",
25
+ "version": "0.3.8",
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.8",
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.8",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]