@blamejs/pki 0.3.7 → 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 +9 -0
- package/README.md +1 -1
- package/lib/inspect.js +391 -0
- package/lib/schema-all.js +29 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
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.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
|
+
|
|
7
16
|
## v0.3.7 — 2026-07-17
|
|
8
17
|
|
|
9
18
|
Certification path building arrives as pki.path.build, and pki.lint gains seven RFC 5280 extension-criticality and CA-scope lints.
|
package/README.md
CHANGED
|
@@ -237,7 +237,7 @@ 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
|
|
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
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` |
|
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/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
package/sbom.cdx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.5",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:7df7ec92-2c30-46ba-acd2-036a52d58ca2",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-07-
|
|
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.
|
|
22
|
+
"bom-ref": "@blamejs/pki@0.3.8",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "pki",
|
|
25
|
-
"version": "0.3.
|
|
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.
|
|
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.
|
|
57
|
+
"ref": "@blamejs/pki@0.3.8",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|