@blamejs/pki 0.2.3 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/README.md +1 -0
- package/index.js +6 -0
- package/lib/constants.js +96 -0
- package/lib/framework-error.js +6 -0
- package/lib/guard-all.js +2 -0
- package/lib/guard-name.js +50 -1
- package/lib/inspect.js +485 -0
- package/lib/schema-attrcert.js +3 -1
- package/lib/schema-ocsp.js +2 -1
- package/lib/schema-pkix.js +10 -25
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,14 @@ 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.2.4 — 2026-07-12
|
|
8
|
+
|
|
9
|
+
Human-readable certificate inspection joins the toolkit as pki.inspect.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- pki.inspect.certificate(input) renders a certificate (a PEM string, a DER Buffer, or a pki.schema.x509.parse result) as a human-readable OpenSSL-x509-text-style report, composed over the strict X.509 parser, the shared RFC 5280 extension decoders, and the two-way OID registry. Standard extensions (basic constraints, key usage, extended key usage with purpose names, subject/issuer alternative names, subject and authority key identifiers, and more) decode to their content; an extension with no decoder is named from the registry and shown as its string or a hex dump. A value that is not a certificate, DER, or PEM throws inspect/bad-input; malformed certificate bytes throw inspect/bad-certificate; a malformed single extension never sinks the report. The error taxonomy gains InspectError (inspect/*). RFC 5280.
|
|
14
|
+
|
|
7
15
|
## v0.2.3 — 2026-07-12
|
|
8
16
|
|
|
9
17
|
Offline Sigstore bundle verification joins the toolkit as pki.sigstore.
|
package/README.md
CHANGED
|
@@ -227,6 +227,7 @@ is callable today; nothing below is a stub.
|
|
|
227
227
|
| `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` |
|
|
228
228
|
| `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` |
|
|
229
229
|
| `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` |
|
|
230
|
+
| `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` |
|
|
230
231
|
| `pki.C` / `pki.constants` | Version-stable constants — functional scale helpers (`C.TIME.*`, `C.BYTES.*`), codec `LIMITS`, `version` |
|
|
231
232
|
| `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`, each carrying a stable `code` in `domain/reason` form |
|
|
232
233
|
| `pki` CLI | `pki version`, `pki oid <dotted\|name>`, `pki parse <cert>` |
|
package/index.js
CHANGED
|
@@ -44,6 +44,7 @@ var est = require("./lib/est");
|
|
|
44
44
|
var jose = require("./lib/jose");
|
|
45
45
|
var acme = require("./lib/acme");
|
|
46
46
|
var trust = require("./lib/trust");
|
|
47
|
+
var inspect = require("./lib/inspect");
|
|
47
48
|
|
|
48
49
|
module.exports = {
|
|
49
50
|
version: constants.version,
|
|
@@ -110,6 +111,11 @@ module.exports = {
|
|
|
110
111
|
// root list omits); pki.trust.anchor hands one to pki.path.validate. Offline:
|
|
111
112
|
// the operator supplies the text; no fetch.
|
|
112
113
|
trust: trust,
|
|
114
|
+
// `inspect` is human-readable inspection -- pki.inspect.certificate(pem|der|parsed)
|
|
115
|
+
// renders an OpenSSL-familiar `x509 -text`-style report from the strict parser +
|
|
116
|
+
// OID registry, naming extension/algorithm OIDs OpenSSL shows only as raw bytes.
|
|
117
|
+
// Pure, no OpenSSL dependency; best-effort (a bad extension falls back to hex).
|
|
118
|
+
inspect: inspect,
|
|
113
119
|
// A ready W3C Crypto instance (globalThis.crypto shape) with the classes for
|
|
114
120
|
// constructing more attached under the same namespace (pki.webcrypto.CryptoKey,
|
|
115
121
|
// .SubtleCrypto, .Crypto, .WebCryptoError). PQC-first, classical-capable, zero-dep.
|
package/lib/constants.js
CHANGED
|
@@ -257,6 +257,101 @@ var LIMITS = {
|
|
|
257
257
|
TRUST_MAX_CSV_FIELD_BYTES: BYTES.kib(64),
|
|
258
258
|
};
|
|
259
259
|
|
|
260
|
+
// NAMES -- display-naming conventions shared by the human-readable renderers
|
|
261
|
+
// (pki.inspect today; a future lint / CLI report tomorrow). Centralized so every
|
|
262
|
+
// consumer speaks ONE convention -- the OpenSSL-familiar labels an operator expects
|
|
263
|
+
// -- rather than each renderer copying a table that silently drifts. These are
|
|
264
|
+
// DISPLAY strings, not wire identifiers (those resolve through pki.oid): a value here
|
|
265
|
+
// never affects parse or verify, only how a decoded field is titled in a report.
|
|
266
|
+
var NAMES = {
|
|
267
|
+
// ==== Distinguished-name attribute short names (RFC 4514 sec. 3 + RFC 4519 / CABF) ==
|
|
268
|
+
// The single source of truth: this table was previously copied in both
|
|
269
|
+
// lib/inspect.js and lib/schema-pkix.js and had already drifted (STREET vs street,
|
|
270
|
+
// SERIALNUMBER vs serialNumber). An entry whose type the OID registry does not yet
|
|
271
|
+
// resolve is simply never hit -- forward entries (jurisdiction*, organizationIdentifier)
|
|
272
|
+
// are harmless until their arc is registered.
|
|
273
|
+
DN_SHORT: {
|
|
274
|
+
commonName: "CN", surname: "SN", serialNumber: "SERIALNUMBER", countryName: "C",
|
|
275
|
+
localityName: "L", stateOrProvinceName: "ST", streetAddress: "STREET",
|
|
276
|
+
organizationName: "O", organizationalUnitName: "OU", title: "title", givenName: "GN",
|
|
277
|
+
initials: "initials", generationQualifier: "generationQualifier", distinguishedNameQualifier: "dnQualifier",
|
|
278
|
+
pseudonym: "pseudonym", domainComponent: "DC", userId: "UID", emailAddress: "emailAddress",
|
|
279
|
+
businessCategory: "businessCategory", postalCode: "postalCode", name: "name",
|
|
280
|
+
jurisdictionCountryName: "jurisdictionC", jurisdictionStateOrProvinceName: "jurisdictionST",
|
|
281
|
+
jurisdictionLocalityName: "jurisdictionL", organizationIdentifier: "organizationIdentifier",
|
|
282
|
+
},
|
|
283
|
+
// ==== X.509v3 extension section titles (OpenSSL-familiar); an extension absent
|
|
284
|
+
// here is titled from the OID registry name / dotted OID ====
|
|
285
|
+
EXTENSION: {
|
|
286
|
+
basicConstraints: "X509v3 Basic Constraints", keyUsage: "X509v3 Key Usage",
|
|
287
|
+
extKeyUsage: "X509v3 Extended Key Usage", subjectAltName: "X509v3 Subject Alternative Name",
|
|
288
|
+
issuerAltName: "X509v3 Issuer Alternative Name", subjectKeyIdentifier: "X509v3 Subject Key Identifier",
|
|
289
|
+
authorityKeyIdentifier: "X509v3 Authority Key Identifier", certificatePolicies: "X509v3 Certificate Policies",
|
|
290
|
+
policyMappings: "X509v3 Policy Mappings", policyConstraints: "X509v3 Policy Constraints",
|
|
291
|
+
inhibitAnyPolicy: "X509v3 Inhibit Any Policy", nameConstraints: "X509v3 Name Constraints",
|
|
292
|
+
cRLDistributionPoints: "X509v3 CRL Distribution Points", freshestCRL: "X509v3 Freshest CRL",
|
|
293
|
+
subjectInfoAccess: "Subject Information Access", authorityInfoAccess: "Authority Information Access",
|
|
294
|
+
cRLNumber: "X509v3 CRL Number", deltaCRLIndicator: "X509v3 Delta CRL Indicator",
|
|
295
|
+
issuingDistributionPoint: "X509v3 Issuing Distribution Point", cRLReason: "X509v3 CRL Reason Code",
|
|
296
|
+
invalidityDate: "Invalidity Date", certificateIssuer: "X509v3 Certificate Issuer",
|
|
297
|
+
signedCertificateTimestampList: "CT Precertificate SCTs", precertificatePoison: "CT Precertificate Poison",
|
|
298
|
+
},
|
|
299
|
+
// ==== KeyUsage bits (RFC 5280 sec. 4.2.1.3, bit order) ====
|
|
300
|
+
KEY_USAGE: {
|
|
301
|
+
digitalSignature: "Digital Signature", nonRepudiation: "Non Repudiation", keyEncipherment: "Key Encipherment",
|
|
302
|
+
dataEncipherment: "Data Encipherment", keyAgreement: "Key Agreement", keyCertSign: "Certificate Sign",
|
|
303
|
+
cRLSign: "CRL Sign", encipherOnly: "Encipher Only", decipherOnly: "Decipher Only",
|
|
304
|
+
},
|
|
305
|
+
// ==== ExtendedKeyUsage purposes (RFC 5280 sec. 4.2.1.12 + common); an unlisted
|
|
306
|
+
// purpose shows its registry name / OID ====
|
|
307
|
+
EXT_KEY_USAGE: {
|
|
308
|
+
anyExtendedKeyUsage: "Any Extended Key Usage", serverAuth: "TLS Web Server Authentication",
|
|
309
|
+
clientAuth: "TLS Web Client Authentication", codeSigning: "Code Signing",
|
|
310
|
+
emailProtection: "E-mail Protection", timeStamping: "Time Stamping", ocspSigning: "OCSP Signing",
|
|
311
|
+
},
|
|
312
|
+
// ==== GeneralName CHOICE labels, by context tag number (RFC 5280 sec. 4.2.1.6) ====
|
|
313
|
+
GENERAL_NAME: { 0: "othername", 1: "email", 2: "DNS", 3: "X400Name", 4: "DirName", 5: "EdiPartyName", 6: "URI", 7: "IP Address", 8: "Registered ID" },
|
|
314
|
+
// ==== Named-curve -> NIST alias, for the EC key line ====
|
|
315
|
+
NIST_CURVE: { prime256v1: "P-256", secp384r1: "P-384", secp521r1: "P-521" },
|
|
316
|
+
// ==== ReasonFlags BIT STRING bit -> name (RFC 5280 sec. 4.2.1.13, a CRL
|
|
317
|
+
// distribution point's revocation scope; bit 0 is "unused"). Distinct from the
|
|
318
|
+
// CRLReason ENUMERATED below -- a BIT STRING of scopes, not a single code. ====
|
|
319
|
+
REASON_FLAGS: {
|
|
320
|
+
1: "Key Compromise", 2: "CA Compromise", 3: "Affiliation Changed", 4: "Superseded",
|
|
321
|
+
5: "Cessation Of Operation", 6: "Certificate Hold", 7: "Privilege Withdrawn", 8: "AA Compromise",
|
|
322
|
+
},
|
|
323
|
+
// ==== CRLReason ENUMERATED value names (RFC 5280 sec. 5.3.1; value 7 is unused) ====
|
|
324
|
+
CRL_REASON: {
|
|
325
|
+
"0": "unspecified", "1": "keyCompromise", "2": "cACompromise", "3": "affiliationChanged",
|
|
326
|
+
"4": "superseded", "5": "cessationOfOperation", "6": "certificateHold",
|
|
327
|
+
"8": "removeFromCRL", "9": "privilegeWithdrawn", "10": "aACompromise",
|
|
328
|
+
},
|
|
329
|
+
// ==== OCSPResponseStatus value names (RFC 6960 sec. 4.2.1; value 4 is unassigned) ====
|
|
330
|
+
OCSP_STATUS: { "0": "successful", "1": "malformedRequest", "2": "internalError", "3": "tryLater", "5": "sigRequired", "6": "unauthorized" },
|
|
331
|
+
// ==== Attribute-cert objectDigestInfo digested-object types (RFC 5755 sec. 4.4.2) ====
|
|
332
|
+
OBJECT_DIGEST_TYPE: { "0": "publicKey", "1": "publicKeyCert", "2": "otherObjectTypes" },
|
|
333
|
+
// ==== Public-key / signature algorithm display names, classical + PQC. Forward-
|
|
334
|
+
// looking: the toolkit is PQC-first, so the FIPS 203/204/205 names render as-is;
|
|
335
|
+
// a consumer that finds no entry shows the OID registry name / dotted OID ====
|
|
336
|
+
ALGORITHM: {
|
|
337
|
+
rsaEncryption: "rsaEncryption", rsassaPss: "rsassaPss", rsaesOaep: "rsaesOaep", ecPublicKey: "id-ecPublicKey",
|
|
338
|
+
ed25519: "ED25519", ed448: "ED448", x25519: "X25519", x448: "X448",
|
|
339
|
+
"ML-DSA-44": "ML-DSA-44", "ML-DSA-65": "ML-DSA-65", "ML-DSA-87": "ML-DSA-87",
|
|
340
|
+
"ML-KEM-512": "ML-KEM-512", "ML-KEM-768": "ML-KEM-768", "ML-KEM-1024": "ML-KEM-1024",
|
|
341
|
+
"SLH-DSA-SHA2-128s": "SLH-DSA-SHA2-128s", "SLH-DSA-SHA2-128f": "SLH-DSA-SHA2-128f",
|
|
342
|
+
"SLH-DSA-SHA2-192s": "SLH-DSA-SHA2-192s", "SLH-DSA-SHA2-256s": "SLH-DSA-SHA2-256s",
|
|
343
|
+
},
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
// NAMES is BOTH a display table (pki.inspect) and the strict parsers' legal-value
|
|
347
|
+
// allow-list: the CRL reasonCode, OCSP response-status, and attribute-cert object-
|
|
348
|
+
// digest decoders check membership against CRL_REASON / OCSP_STATUS / OBJECT_DIGEST_TYPE
|
|
349
|
+
// to fail closed on an RFC-reserved / undefined value. Deep-freeze it so a caller can
|
|
350
|
+
// NEVER widen what those decoders accept by mutating the "display-only" table (e.g.
|
|
351
|
+
// adding CRLReason 7, RFC-reserved, which would otherwise make the decoders fail open).
|
|
352
|
+
Object.keys(NAMES).forEach(function (k) { Object.freeze(NAMES[k]); });
|
|
353
|
+
Object.freeze(NAMES);
|
|
354
|
+
|
|
260
355
|
// Single-sourced from the package manifest so the reported version can
|
|
261
356
|
// never drift from the published package (package.json is always present
|
|
262
357
|
// in the installed tarball). A hand-maintained literal here silently
|
|
@@ -267,5 +362,6 @@ module.exports = {
|
|
|
267
362
|
TIME: TIME,
|
|
268
363
|
BYTES: BYTES,
|
|
269
364
|
LIMITS: LIMITS,
|
|
365
|
+
NAMES: NAMES,
|
|
270
366
|
version: VERSION,
|
|
271
367
|
};
|
package/lib/framework-error.js
CHANGED
|
@@ -293,6 +293,11 @@ var HpkeError = defineClass("HpkeError", { withCause: true });
|
|
|
293
293
|
// expected in-toto Statement. Carries the underlying leaf fault (an x509/*,
|
|
294
294
|
// merkle/*, or asn1/* error) as `.cause`.
|
|
295
295
|
var SigstoreError = defineClass("SigstoreError", { withCause: true });
|
|
296
|
+
// InspectError -- a bad input to the human-readable inspector (pki.inspect): a
|
|
297
|
+
// value that is neither a parsed structure, DER Buffer, nor PEM string, or a PEM
|
|
298
|
+
// whose label does not decode. Rendering itself is best-effort and never throws on
|
|
299
|
+
// a malformed field -- an undecodable extension falls back to a hex dump.
|
|
300
|
+
var InspectError = defineClass("InspectError", { withCause: true });
|
|
296
301
|
|
|
297
302
|
module.exports = {
|
|
298
303
|
PkiError: PkiError,
|
|
@@ -319,6 +324,7 @@ module.exports = {
|
|
|
319
324
|
ShbsError: ShbsError,
|
|
320
325
|
HpkeError: HpkeError,
|
|
321
326
|
SigstoreError: SigstoreError,
|
|
327
|
+
InspectError: InspectError,
|
|
322
328
|
MerkleError: MerkleError,
|
|
323
329
|
SmimeError: SmimeError,
|
|
324
330
|
CsrattrsError: CsrattrsError,
|
package/lib/guard-all.js
CHANGED
|
@@ -23,6 +23,8 @@
|
|
|
23
23
|
// to Number (silent-narrowing defence)
|
|
24
24
|
// guard.name.dnEqual / .rdnEqual / .assertNoControlBytes / .assertPrintableIa5
|
|
25
25
|
// -- canonical DN identity + name-string integrity
|
|
26
|
+
// guard.name.escapeControlBytes / .escapeDnValue
|
|
27
|
+
// -- render-side name safety: control-byte + RFC 4514 escaping
|
|
26
28
|
// (CVE-2009-2408 truncation / identity defence)
|
|
27
29
|
// guard.encoding.base64url / .base64 / .hex
|
|
28
30
|
// -- strict textual-encoding decode, canonical
|
package/lib/guard-name.js
CHANGED
|
@@ -108,4 +108,53 @@ function dnEqual(rdnsA, rdnsB, E, code, label) {
|
|
|
108
108
|
return true;
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
|
|
111
|
+
// escapeControlBytes(str) -> str. The render-side sibling of assertNoControlBytes:
|
|
112
|
+
// where a name string must still be DISPLAYED best-effort (a human-readable report,
|
|
113
|
+
// a log line) rather than rejected, neutralize every C0 control byte and DEL by
|
|
114
|
+
// rendering it as \xHH. A bare CR / LF / NUL in a decoded dNSName or DN value would
|
|
115
|
+
// otherwise forge or overwrite report lines in a terminal or log (CWE-117 output-log
|
|
116
|
+
// injection / CWE-116 improper output encoding). Non-control bytes pass through.
|
|
117
|
+
// Written as a charCodeAt scan, not a /[\x00-\x1f]/ regex: eslint's no-control-regex
|
|
118
|
+
// (correctly) refuses control characters in a regex literal, and the control-byte
|
|
119
|
+
// range test is the rename-proof shape the detector keys on regardless.
|
|
120
|
+
// @enforced-by guard-shape-reinlined
|
|
121
|
+
// @guard-shape < 0x20 \|\| \w+ === 0x7f
|
|
122
|
+
function escapeControlBytes(str) {
|
|
123
|
+
var s = String(str), out = "";
|
|
124
|
+
for (var i = 0; i < s.length; i++) {
|
|
125
|
+
var c = s.charCodeAt(i);
|
|
126
|
+
out += (c < 0x20 || c === 0x7f) ? "\\x" + (c < 16 ? "0" : "") + c.toString(16).toUpperCase() : s.charAt(i);
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// escapeDnValue(v) -> str. RFC 4514 sec. 2.4 string-representation escaping of a DN
|
|
132
|
+
// attribute value (the input is the already-unwrapped semantic string): backslash-
|
|
133
|
+
// escape the always-specials , + " \ < > ; and a NUL (as \00), then the positional
|
|
134
|
+
// rules -- a trailing space as '\ ' and a leading '#' or space as '\#' / '\ '. Without
|
|
135
|
+
// this a value like `foo, CN=admin` renders as two RDNs, and a literal `#05` collides
|
|
136
|
+
// with the hexstring form, so the report misstates a subject/issuer name (CWE-116).
|
|
137
|
+
// The one place a DN attribute value is made display-safe; pki.schema.pkix's DN
|
|
138
|
+
// rendering composes it, and pki.inspect reuses that parser output (name.dn).
|
|
139
|
+
// @enforced-by behavioral -- the RFC 4514 separator class carries a quote inside a
|
|
140
|
+
// regex literal, which the codebase-patterns literal-stripper mis-tokenizes, so no
|
|
141
|
+
// rename-proof shape is detectable; the guard-name RED vectors + the schema-pkix DN
|
|
142
|
+
// round-trip vectors (a comma / plus / leading '#' renders backslash-escaped) are the guard.
|
|
143
|
+
function escapeDnValue(v) {
|
|
144
|
+
var s = String(v).replace(/([,+"\\<>;])/g, "\\$1"), out = "";
|
|
145
|
+
// RFC 4514 sec. 2.4: a NUL / control octet -> '\' + two hex digits, so an embedded
|
|
146
|
+
// CR / LF / NUL in a decoded DN value can never forge a report line when displayed.
|
|
147
|
+
for (var i = 0; i < s.length; i++) {
|
|
148
|
+
var c = s.charCodeAt(i);
|
|
149
|
+
out += (c < 0x20 || c === 0x7f) ? "\\" + (c < 16 ? "0" : "") + c.toString(16).toUpperCase() : s.charAt(i);
|
|
150
|
+
}
|
|
151
|
+
if (out.length && out.charAt(out.length - 1) === " ") out = out.slice(0, -1) + "\\ ";
|
|
152
|
+
if (out.charAt(0) === "#" || out.charAt(0) === " ") out = "\\" + out;
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
module.exports = {
|
|
157
|
+
assertNoControlBytes: assertNoControlBytes, assertPrintableIa5: assertPrintableIa5,
|
|
158
|
+
dnEqual: dnEqual, rdnEqual: rdnEqual,
|
|
159
|
+
escapeControlBytes: escapeControlBytes, escapeDnValue: escapeDnValue,
|
|
160
|
+
};
|
package/lib/inspect.js
ADDED
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (c) blamejs contributors
|
|
3
|
+
"use strict";
|
|
4
|
+
/**
|
|
5
|
+
* @module pki.inspect
|
|
6
|
+
* @nav Tooling
|
|
7
|
+
* @title Inspect
|
|
8
|
+
* @intro Human-readable inspection of a parsed certificate -- the pure-JS
|
|
9
|
+
* equivalent of `openssl x509 -text`. `certificate(input)` ingests a PEM string,
|
|
10
|
+
* a DER Buffer, or an already-parsed certificate and returns a familiar
|
|
11
|
+
* OpenSSL-style report: version, serial, signature algorithm, the issuer and
|
|
12
|
+
* subject distinguished names, the validity window, the public-key details
|
|
13
|
+
* (curve or modulus size plus the raw point/modulus), every decoded extension
|
|
14
|
+
* with its critical flag, and the signature. It renders purely from the toolkit's
|
|
15
|
+
* own strict parser and two-way OID registry -- no OpenSSL dependency, and no
|
|
16
|
+
* drift-prone second naming table -- so it names extension and algorithm OIDs an
|
|
17
|
+
* OpenSSL build shows only as raw bytes. The format is stable and OpenSSL-*familiar*
|
|
18
|
+
* rather than byte-identical to any one OpenSSL version (those disagree across
|
|
19
|
+
* releases). Rendering is best-effort: a malformed extension falls back to a hex
|
|
20
|
+
* dump rather than throwing.
|
|
21
|
+
* @spec RFC 5280
|
|
22
|
+
* @card Read a certificate like `openssl x509 -text`, in pure JS.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
var frameworkError = require("./framework-error");
|
|
26
|
+
var constants = require("./constants");
|
|
27
|
+
var asn1 = require("./asn1-der");
|
|
28
|
+
var oid = require("./oid");
|
|
29
|
+
var x509 = require("./schema-x509");
|
|
30
|
+
var pkix = require("./schema-pkix");
|
|
31
|
+
var guard = require("./guard-all");
|
|
32
|
+
|
|
33
|
+
// Display-naming conventions are centralized in pki.C.NAMES (shared with the strict
|
|
34
|
+
// parsers so the labels can't drift); this module only composes them.
|
|
35
|
+
var NAMES = constants.NAMES;
|
|
36
|
+
|
|
37
|
+
var InspectError = frameworkError.InspectError;
|
|
38
|
+
function _err(code, message, cause) { return new InspectError(code, message, cause); }
|
|
39
|
+
|
|
40
|
+
// A dedicated namespace + decoder set: the shared RFC 5280 extension decoders,
|
|
41
|
+
// composed exactly as path-validate / acme compose them. Decode failures here are
|
|
42
|
+
// caught by the renderer and fall back to a hex dump (inspection is best-effort).
|
|
43
|
+
var NS = pkix.makeNS("inspect", InspectError, oid);
|
|
44
|
+
var EXT_DECODERS = pkix.certExtensionDecoders(NS).byOid;
|
|
45
|
+
|
|
46
|
+
// ---- formatting helpers ------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
var HEX = "0123456789abcdef";
|
|
49
|
+
function _hexColon(buf, opts) {
|
|
50
|
+
opts = opts || {};
|
|
51
|
+
var hex = [];
|
|
52
|
+
for (var i = 0; i < buf.length; i++) {
|
|
53
|
+
var b = buf[i], s = HEX[(b >> 4) & 0xf] + HEX[b & 0xf];
|
|
54
|
+
hex.push(opts.upper ? s.toUpperCase() : s);
|
|
55
|
+
}
|
|
56
|
+
if (!opts.wrap) return hex.join(":");
|
|
57
|
+
// Wrap at `opts.wrap` bytes per line, each line indented by `opts.indent`.
|
|
58
|
+
var pad = " ".repeat(opts.indent || 0), lines = [];
|
|
59
|
+
for (var j = 0; j < hex.length; j += opts.wrap) {
|
|
60
|
+
var chunk = hex.slice(j, j + opts.wrap).join(":");
|
|
61
|
+
lines.push(pad + chunk + (j + opts.wrap < hex.length ? ":" : ""));
|
|
62
|
+
}
|
|
63
|
+
return lines.join("\n");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Control-byte neutralization for a GeneralName string value routes through the
|
|
67
|
+
// guard family (the guard-shape-reinlined detector protects the shape).
|
|
68
|
+
// @guard-via guard\.name\.escape
|
|
69
|
+
var _clean = guard.name.escapeControlBytes;
|
|
70
|
+
|
|
71
|
+
// The DN display string. pki.schema.pkix already assembles a fully RFC 4514-escaped
|
|
72
|
+
// dn (short names from pki.C.NAMES.DN_SHORT, values escaped via guard.name.escapeDnValue,
|
|
73
|
+
// with the '#'-hex / leading-'\' sentinel handled), so reuse it rather than re-escaping
|
|
74
|
+
// the already-escaped values (which would double-escape a leading '#' / '\').
|
|
75
|
+
function _dnString(name) { return (name && name.dn) || ""; }
|
|
76
|
+
|
|
77
|
+
var MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
78
|
+
function _two(n) { return (n < 10 ? "0" : "") + n; }
|
|
79
|
+
// OpenSSL date: "Jul 4 07:00:27 2026 GMT" (month, space-padded day, time, year, GMT).
|
|
80
|
+
function _date(iso) {
|
|
81
|
+
var d = (iso instanceof Date) ? iso : new Date(iso);
|
|
82
|
+
if (isNaN(d.getTime())) return String(iso);
|
|
83
|
+
var day = d.getUTCDate(), dd = (day < 10 ? " " : "") + day;
|
|
84
|
+
return MONTHS[d.getUTCMonth()] + " " + dd + " " +
|
|
85
|
+
_two(d.getUTCHours()) + ":" + _two(d.getUTCMinutes()) + ":" + _two(d.getUTCSeconds()) +
|
|
86
|
+
" " + d.getUTCFullYear() + " GMT";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function _algName(a) { return (a && (a.name || a.oid)) || "unknown"; }
|
|
90
|
+
|
|
91
|
+
// ---- serial + public key -----------------------------------------------------
|
|
92
|
+
|
|
93
|
+
function _serial(cert, indent) {
|
|
94
|
+
var hex = cert.serialNumberHex || "";
|
|
95
|
+
if (hex.length % 2) hex = "0" + hex;
|
|
96
|
+
var buf = Buffer.from(hex, "hex");
|
|
97
|
+
// Strip a single DER positive-sign 00 byte (present when the value's high bit is
|
|
98
|
+
// set) so the printed serial is the integer VALUE, matching OpenSSL -- not the
|
|
99
|
+
// encoding's leading octet.
|
|
100
|
+
if (buf.length > 1 && buf[0] === 0x00 && (buf[1] & 0x80)) buf = buf.subarray(1);
|
|
101
|
+
// Small non-negative serials render inline as decimal (0xhex), like OpenSSL;
|
|
102
|
+
// anything larger renders as a colon-hex block.
|
|
103
|
+
if (buf.length <= 6) {
|
|
104
|
+
var n = parseInt(buf.toString("hex") || "0", 16);
|
|
105
|
+
return "Serial Number: " + n + " (0x" + (buf.toString("hex").replace(/^0+/, "") || "0") + ")";
|
|
106
|
+
}
|
|
107
|
+
return "Serial Number:\n" + " ".repeat(indent) + _hexColon(buf, {});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
var CURVE_BITS = { "P-256": 256, "P-384": 384, "P-521": 521, "prime256v1": 256, "secp384r1": 384, "secp521r1": 521 };
|
|
111
|
+
var NIST_NAME = NAMES.NIST_CURVE;
|
|
112
|
+
// Every RSA-family key algorithm carries the same SPKI subjectPublicKey -- an
|
|
113
|
+
// RSAPublicKey SEQUENCE { modulus, publicExponent } (RFC 4055 sec. 1.2 for
|
|
114
|
+
// id-RSASSA-PSS / id-RSAES-OAEP) -- so all decode to modulus + exponent, not raw bytes.
|
|
115
|
+
var RSA_KEY_ALGS = { rsaEncryption: 1, rsassaPss: 1, rsaesOaep: 1 };
|
|
116
|
+
function _keyBlock(spki, pad) {
|
|
117
|
+
var algName = _algName(spki.algorithm);
|
|
118
|
+
var out = [pad + "Public Key Algorithm: " + algName];
|
|
119
|
+
var inner = pad + " ";
|
|
120
|
+
var pub = Buffer.isBuffer(spki.publicKey) ? spki.publicKey : (spki.publicKey && Buffer.isBuffer(spki.publicKey.bytes) ? spki.publicKey.bytes : null);
|
|
121
|
+
|
|
122
|
+
if (algName === "ecPublicKey" || algName === "id-ecPublicKey") {
|
|
123
|
+
var curveName = null;
|
|
124
|
+
try { curveName = oid.name(asn1.read.oid(asn1.decode(spki.algorithm.parameters))); } catch (_e) { /* unknown curve */ }
|
|
125
|
+
var bits = CURVE_BITS[curveName] || (pub ? ((pub.length - 1) / 2) * 8 : 0);
|
|
126
|
+
out.push(inner + "Public-Key: (" + bits + " bit)");
|
|
127
|
+
if (pub) { out.push(inner + "pub:"); out.push(_hexColon(pub, { wrap: 16, indent: (pad.length + 8) })); }
|
|
128
|
+
if (curveName) { out.push(inner + "ASN1 OID: " + curveName); if (NIST_NAME[curveName]) out.push(inner + "NIST CURVE: " + NIST_NAME[curveName]); }
|
|
129
|
+
return out.join("\n");
|
|
130
|
+
}
|
|
131
|
+
if (RSA_KEY_ALGS[algName]) {
|
|
132
|
+
try {
|
|
133
|
+
var rsa = asn1.decode(pub); // RSAPublicKey ::= SEQUENCE { modulus INTEGER, publicExponent INTEGER }
|
|
134
|
+
var modBig = asn1.read.integer(rsa.children[0]);
|
|
135
|
+
var expBig = asn1.read.integer(rsa.children[1]);
|
|
136
|
+
var modHex = modBig.toString(16); if (modHex.length % 2) modHex = "0" + modHex;
|
|
137
|
+
var modBuf = Buffer.from(modHex, "hex");
|
|
138
|
+
// Bit length is the value's, not the byte count's (a 0x7f.. modulus is 127-bit,
|
|
139
|
+
// not 128), and the DER sign-padding 00 is present only when the top bit is set.
|
|
140
|
+
out.push(inner + "Public-Key: (" + modBig.toString(2).length + " bit)");
|
|
141
|
+
out.push(inner + "Modulus:");
|
|
142
|
+
var modDisplay = (modBuf.length && (modBuf[0] & 0x80)) ? Buffer.concat([Buffer.from([0x00]), modBuf]) : modBuf;
|
|
143
|
+
out.push(_hexColon(modDisplay, { wrap: 16, indent: (pad.length + 8) }));
|
|
144
|
+
out.push(inner + "Exponent: " + expBig.toString(10) + " (0x" + expBig.toString(16) + ")");
|
|
145
|
+
return out.join("\n");
|
|
146
|
+
} catch (_e) { /* fall through to raw */ }
|
|
147
|
+
}
|
|
148
|
+
// EdDSA / ML-DSA / SLH-DSA / anything else: show the raw public-key bytes.
|
|
149
|
+
if (pub) { out.push(inner + "Public-Key: (" + (pub.length * 8) + " bit)"); out.push(inner + "pub:"); out.push(_hexColon(pub, { wrap: 16, indent: (pad.length + 8) })); }
|
|
150
|
+
return out.join("\n");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ---- extensions --------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
// Display-naming conventions from pki.C.NAMES (see constants.js); an entry absent
|
|
156
|
+
// from a table falls back to the registered name / OID.
|
|
157
|
+
var EXT_LABEL = NAMES.EXTENSION;
|
|
158
|
+
var KU_LABEL = NAMES.KEY_USAGE;
|
|
159
|
+
var EKU_LABEL = NAMES.EXT_KEY_USAGE;
|
|
160
|
+
var GN_KIND = NAMES.GENERAL_NAME;
|
|
161
|
+
|
|
162
|
+
// An iPAddress octet string -> a readable address, matching OpenSSL: 4 bytes as a
|
|
163
|
+
// dotted-quad, 16 as (non-compressed, uppercase) IPv6, and the name-constraints
|
|
164
|
+
// 8/32-byte address+mask forms as "addr/mask". Never emits a raw octet (a stray
|
|
165
|
+
// 0x0a would inject a newline into the report); an odd length falls back to hex.
|
|
166
|
+
function _ipString(buf) {
|
|
167
|
+
if (!Buffer.isBuffer(buf)) return "";
|
|
168
|
+
if (buf.length === 4) return buf[0] + "." + buf[1] + "." + buf[2] + "." + buf[3];
|
|
169
|
+
if (buf.length === 8) return _ipString(buf.subarray(0, 4)) + "/" + _ipString(buf.subarray(4));
|
|
170
|
+
if (buf.length === 16 || buf.length === 32) {
|
|
171
|
+
var groups = [];
|
|
172
|
+
for (var i = 0; i < 16; i += 2) groups.push((((buf[i] << 8) | buf[i + 1]) >>> 0).toString(16).toUpperCase());
|
|
173
|
+
var s = groups.join(":");
|
|
174
|
+
return buf.length === 32 ? s + "/" + _ipString(buf.subarray(16)) : s;
|
|
175
|
+
}
|
|
176
|
+
return _hexColon(buf, {});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Format a decoded GeneralName ({tagClass, tagNumber, value, bytes}) the way
|
|
180
|
+
// OpenSSL labels each choice. A directoryName carries a full DN (RFC 4514-escaped
|
|
181
|
+
// via _dnString); an iPAddress is a Buffer rendered as an address (never raw bytes);
|
|
182
|
+
// an otherName / unknown choice falls back to hex so a hostile value can never break
|
|
183
|
+
// the report's line structure. The string choices (DNS / URI / email) are
|
|
184
|
+
// control-byte-escaped -- which stops the severe case, a forged report line -- but
|
|
185
|
+
// their structural separators are left as-is, matching OpenSSL (a GeneralName has no
|
|
186
|
+
// RFC 4514-equivalent escaping profile, and escaping a legitimate comma in a URI
|
|
187
|
+
// would misrepresent it).
|
|
188
|
+
function _gn(g) {
|
|
189
|
+
if (!g || typeof g !== "object") return "";
|
|
190
|
+
var t = g.tagNumber;
|
|
191
|
+
if (t === 7 && Buffer.isBuffer(g.value)) return "IP Address:" + _ipString(g.value);
|
|
192
|
+
if (t === 4) {
|
|
193
|
+
var dn = (g.value && Array.isArray(g.value.rdns)) ? _dnString(g.value) : ((g.value && g.value.dn) || "");
|
|
194
|
+
return "DirName:" + dn;
|
|
195
|
+
}
|
|
196
|
+
if (t === 0) return "othername:" + (Buffer.isBuffer(g.bytes) ? _hexColon(g.bytes, {}) : "<unsupported>");
|
|
197
|
+
var kind = GN_KIND[t] || ("tag" + t);
|
|
198
|
+
var v = (typeof g.value === "string") ? _clean(g.value)
|
|
199
|
+
: Buffer.isBuffer(g.value) ? _hexColon(g.value, {})
|
|
200
|
+
: Buffer.isBuffer(g.bytes) ? _hexColon(g.bytes, {}) : "";
|
|
201
|
+
return kind + ":" + v;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Format a GeneralName still in its raw DER TLV (a CRL distribution point leaves
|
|
205
|
+
// its fullName entries undecoded) -- decode the context tag and render its content.
|
|
206
|
+
function _gnRaw(buf) {
|
|
207
|
+
if (!Buffer.isBuffer(buf)) return "";
|
|
208
|
+
try {
|
|
209
|
+
var node = asn1.decode(buf);
|
|
210
|
+
var t = node.tagNumber;
|
|
211
|
+
if (t === 1 || t === 2 || t === 6) return GN_KIND[t] + ":" + _clean(node.content.toString("latin1"));
|
|
212
|
+
if (t === 7) return "IP Address:" + _ipString(node.content);
|
|
213
|
+
return _hexColon(buf, {}); // dirName / otherName / registeredID -> best-effort hex
|
|
214
|
+
} catch (_e) { return _hexColon(buf, {}); }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Shared value renderers reused by more than one extension key.
|
|
218
|
+
function _renderAltName(decoded, inner) {
|
|
219
|
+
return inner + (decoded.names || []).map(_gn).join(", ");
|
|
220
|
+
}
|
|
221
|
+
function _renderCrlDp(decoded, inner) {
|
|
222
|
+
var dpLines = [];
|
|
223
|
+
(decoded || []).forEach(function (dp) {
|
|
224
|
+
var d = dp.distributionPoint, wrote = false;
|
|
225
|
+
if (d && d.kind === "fullName" && Array.isArray(d.names)) {
|
|
226
|
+
dpLines.push(inner + "Full Name:");
|
|
227
|
+
d.names.forEach(function (nm) { dpLines.push(inner + " " + (Buffer.isBuffer(nm) ? _gnRaw(nm) : _gn(nm))); });
|
|
228
|
+
wrote = true;
|
|
229
|
+
} else if (d && d.kind === "rdn") {
|
|
230
|
+
dpLines.push(inner + "Relative Name (to CRL issuer)");
|
|
231
|
+
wrote = true;
|
|
232
|
+
}
|
|
233
|
+
// The reasons BIT STRING scopes which revocation reasons this DP covers; dropping
|
|
234
|
+
// it would make a scoped revocation source look generally applicable.
|
|
235
|
+
if (dp.reasons && Buffer.isBuffer(dp.reasons.bytes)) {
|
|
236
|
+
var rf = [], rb = dp.reasons.bytes;
|
|
237
|
+
for (var bit = 1; bit < rb.length * 8; bit++) {
|
|
238
|
+
if ((rb[bit >> 3] & (0x80 >> (bit & 7))) && NAMES.REASON_FLAGS[bit]) rf.push(NAMES.REASON_FLAGS[bit]);
|
|
239
|
+
}
|
|
240
|
+
if (rf.length) { dpLines.push(inner + "Reasons: " + rf.join(", ")); wrote = true; }
|
|
241
|
+
}
|
|
242
|
+
// A DistributionPoint may carry only cRLIssuer (an indirect CRL, no
|
|
243
|
+
// distributionPoint) -- render the issuer GeneralNames rather than dropping them.
|
|
244
|
+
if (dp.cRLIssuer && Array.isArray(dp.cRLIssuer.names)) {
|
|
245
|
+
dpLines.push(inner + "CRL Issuer:");
|
|
246
|
+
dp.cRLIssuer.names.forEach(function (g) { dpLines.push(inner + " " + _gn(g)); });
|
|
247
|
+
wrote = true;
|
|
248
|
+
}
|
|
249
|
+
if (!wrote) dpLines.push(inner + "(distribution point)");
|
|
250
|
+
});
|
|
251
|
+
return dpLines.join("\n");
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Declarative extension-value renderer registry: extension name -> (decoded, inner) ->
|
|
255
|
+
// text. Data-driven dispatch (the schema family's "registry, not switch" shape) so a
|
|
256
|
+
// new extension is a row rather than another hand-coded branch, and the set an
|
|
257
|
+
// operator sees rendered is visible in one place. An extension with no row here
|
|
258
|
+
// hex-dumps its raw value (best-effort); each row's output is pinned by an
|
|
259
|
+
// inspect.test.js conformance vector.
|
|
260
|
+
var EXT_RENDERERS = {
|
|
261
|
+
keyUsage: function (decoded, inner) {
|
|
262
|
+
return inner + Object.keys(KU_LABEL).filter(function (k) { return decoded[k]; }).map(function (k) { return KU_LABEL[k]; }).join(", ");
|
|
263
|
+
},
|
|
264
|
+
extKeyUsage: function (decoded, inner) {
|
|
265
|
+
return inner + decoded.map(function (o) { var n = null; try { n = oid.name(o); } catch (_e) { /* unregistered EKU OID */ } return EKU_LABEL[n] || n || o; }).join(", ");
|
|
266
|
+
},
|
|
267
|
+
basicConstraints: function (decoded, inner) {
|
|
268
|
+
var s = "CA:" + (decoded.cA ? "TRUE" : "FALSE");
|
|
269
|
+
if (decoded.pathLenConstraint != null) s += ", pathlen:" + decoded.pathLenConstraint;
|
|
270
|
+
return inner + s;
|
|
271
|
+
},
|
|
272
|
+
subjectAltName: _renderAltName,
|
|
273
|
+
issuerAltName: _renderAltName,
|
|
274
|
+
certificatePolicies: function (decoded, inner) {
|
|
275
|
+
var lines = [];
|
|
276
|
+
decoded.forEach(function (p) {
|
|
277
|
+
lines.push(inner + "Policy: " + p.policyIdentifier);
|
|
278
|
+
if (!Buffer.isBuffer(p.qualifiersBytes)) return;
|
|
279
|
+
// Render each PolicyQualifierInfo { policyQualifierId, qualifier }: a printable
|
|
280
|
+
// qualifier (a CPS URI is an IA5String) shows as text, else a hex dump -- never
|
|
281
|
+
// dropped (which would make a qualified policy look unqualified).
|
|
282
|
+
try {
|
|
283
|
+
(asn1.decode(p.qualifiersBytes).children || []).forEach(function (pqi) {
|
|
284
|
+
var qid = asn1.read.oid(pqi.children[0]), q = pqi.children[1];
|
|
285
|
+
var label = null; try { label = oid.name(qid); } catch (_e) { /* unregistered qualifier */ }
|
|
286
|
+
var val = (q && !q.constructed && Buffer.isBuffer(q.content) && _printable(q.content))
|
|
287
|
+
? _clean(q.content.toString("latin1"))
|
|
288
|
+
: _hexColon(q && Buffer.isBuffer(q.bytes) ? q.bytes : Buffer.alloc(0), {});
|
|
289
|
+
lines.push(inner + " " + (label || qid) + ": " + val);
|
|
290
|
+
});
|
|
291
|
+
} catch (_e) {
|
|
292
|
+
lines.push(inner + " " + _hexColon(p.qualifiersBytes, {}));
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
return lines.join("\n");
|
|
296
|
+
},
|
|
297
|
+
cRLDistributionPoints: _renderCrlDp,
|
|
298
|
+
freshestCRL: _renderCrlDp,
|
|
299
|
+
nameConstraints: function (decoded, inner) {
|
|
300
|
+
var ncLines = [];
|
|
301
|
+
["permittedSubtrees:Permitted", "excludedSubtrees:Excluded"].forEach(function (pair) {
|
|
302
|
+
var key = pair.split(":")[0], label = pair.split(":")[1], arr = decoded[key];
|
|
303
|
+
if (!Array.isArray(arr) || !arr.length) return;
|
|
304
|
+
ncLines.push(inner + label + ":");
|
|
305
|
+
arr.forEach(function (st) { ncLines.push(inner + " " + _gn(st.base)); });
|
|
306
|
+
});
|
|
307
|
+
return ncLines.join("\n");
|
|
308
|
+
},
|
|
309
|
+
policyConstraints: function (decoded, inner) {
|
|
310
|
+
var pc = [];
|
|
311
|
+
if (decoded.requireExplicitPolicy != null) pc.push(inner + "Require Explicit Policy: " + decoded.requireExplicitPolicy);
|
|
312
|
+
if (decoded.inhibitPolicyMapping != null) pc.push(inner + "Inhibit Policy Mapping: " + decoded.inhibitPolicyMapping);
|
|
313
|
+
return pc.length ? pc.join("\n") : inner + "(empty)";
|
|
314
|
+
},
|
|
315
|
+
inhibitAnyPolicy: function (decoded, inner) {
|
|
316
|
+
return inner + "Inhibit Any Policy Skip Certs: " + decoded;
|
|
317
|
+
},
|
|
318
|
+
policyMappings: function (decoded, inner) {
|
|
319
|
+
return decoded.map(function (m) { return inner + m.issuerDomainPolicy + " -> " + m.subjectDomainPolicy; }).join("\n");
|
|
320
|
+
},
|
|
321
|
+
signedCertificateTimestampList: function (decoded, inner) {
|
|
322
|
+
var sct = [];
|
|
323
|
+
(decoded.scts || []).forEach(function (s) {
|
|
324
|
+
sct.push(inner + "Signed Certificate Timestamp:");
|
|
325
|
+
sct.push(inner + " Version: v" + ((typeof s.version === "number" ? s.version : 0) + 1));
|
|
326
|
+
if (s.logIdHex) sct.push(inner + " Log ID: " + String(s.logIdHex).toUpperCase());
|
|
327
|
+
if (s.timestamp != null) sct.push(inner + " Timestamp: " + String(s.timestamp));
|
|
328
|
+
});
|
|
329
|
+
var unk = (decoded.unknownScts || []).length;
|
|
330
|
+
if (unk) sct.push(inner + "(" + unk + " SCT(s) of an unrecognized version)");
|
|
331
|
+
return sct.length ? sct.join("\n") : inner + "(empty SCT list)";
|
|
332
|
+
},
|
|
333
|
+
precertificatePoison: function (decoded, inner) {
|
|
334
|
+
return inner + "Precertificate Poison (this is a precertificate, not a certificate)";
|
|
335
|
+
},
|
|
336
|
+
subjectKeyIdentifier: function (decoded, inner) {
|
|
337
|
+
return inner + _hexColon(Buffer.isBuffer(decoded) ? decoded : (decoded.bytes || Buffer.alloc(0)), { upper: true });
|
|
338
|
+
},
|
|
339
|
+
authorityKeyIdentifier: function (decoded, inner) {
|
|
340
|
+
// Any of the three fields may be present; the issuer+serial form carries no
|
|
341
|
+
// keyIdentifier, so render whichever the decoder populated rather than claiming
|
|
342
|
+
// "keyid:(none)" and dropping the certificate's real authority identifier.
|
|
343
|
+
var akiLines = [];
|
|
344
|
+
if (Buffer.isBuffer(decoded.keyIdentifier)) akiLines.push(inner + "keyid:" + _hexColon(decoded.keyIdentifier, { upper: true }));
|
|
345
|
+
if (decoded.authorityCertIssuer && Array.isArray(decoded.authorityCertIssuer.names)) {
|
|
346
|
+
decoded.authorityCertIssuer.names.forEach(function (g) { akiLines.push(inner + _gn(g)); });
|
|
347
|
+
}
|
|
348
|
+
if (decoded.authorityCertSerialNumber != null) {
|
|
349
|
+
var sn = (typeof decoded.authorityCertSerialNumber === "bigint"
|
|
350
|
+
? decoded.authorityCertSerialNumber : BigInt(decoded.authorityCertSerialNumber)).toString(16);
|
|
351
|
+
if (sn.length % 2) sn = "0" + sn;
|
|
352
|
+
akiLines.push(inner + "serial:0x" + sn.toUpperCase());
|
|
353
|
+
}
|
|
354
|
+
return akiLines.length ? akiLines.join("\n") : inner + "keyid:(none)";
|
|
355
|
+
},
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
function _renderExtValue(ext, decoded, inner) {
|
|
359
|
+
var fn = EXT_RENDERERS[ext.name];
|
|
360
|
+
return fn ? fn(decoded, inner) : null; // no registered renderer -> caller hex-dumps
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Fallback for an extension with no decoder or a decode failure. Best-effort,
|
|
364
|
+
// more useful than OpenSSL's raw-octet dump: a directly-printable value shows as a
|
|
365
|
+
// string; a DER-wrapped character string is decoded and shown; otherwise a hex
|
|
366
|
+
// dump. Never throws.
|
|
367
|
+
var _STRING_TAGS = { 12: 1, 19: 1, 22: 1, 20: 1, 26: 1, 27: 1, 30: 1 }; // UTF8/Printable/IA5/Teletex/Visible/General/BMP
|
|
368
|
+
// A value renders as text only when EVERY byte is a printable, non-control ASCII
|
|
369
|
+
// character. A control byte (a bare CR / LF / TAB, etc.) is rejected here so a
|
|
370
|
+
// hostile private-extension value cannot forge or overwrite report lines in a
|
|
371
|
+
// terminal or log -- such a value falls through to the hex dump instead.
|
|
372
|
+
function _printable(buf) {
|
|
373
|
+
return buf.length > 0 && buf.every(function (b) { return b >= 0x20 && b < 0x7f; });
|
|
374
|
+
}
|
|
375
|
+
function _fallback(value, inner) {
|
|
376
|
+
if (!Buffer.isBuffer(value) || value.length === 0) return inner + "(empty)";
|
|
377
|
+
if (_printable(value)) return inner + value.toString("latin1");
|
|
378
|
+
try {
|
|
379
|
+
var n = asn1.decode(value);
|
|
380
|
+
if (n.tagClass === "universal" && _STRING_TAGS[n.tagNumber]) {
|
|
381
|
+
var s = asn1.read.string(n);
|
|
382
|
+
if (_printable(Buffer.from(s, "utf8"))) return inner + s;
|
|
383
|
+
}
|
|
384
|
+
} catch (_e) { /* not DER / not a string -> hex */ }
|
|
385
|
+
return _hexColon(value, { wrap: 16, indent: inner.length });
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function _extension(ext, pad) {
|
|
389
|
+
var label = EXT_LABEL[ext.name] || ext.name || ext.oid;
|
|
390
|
+
var header = pad + label + ":" + (ext.critical ? " critical" : "");
|
|
391
|
+
var inner = pad + " ";
|
|
392
|
+
var decoder = EXT_DECODERS[ext.oid];
|
|
393
|
+
if (decoder) {
|
|
394
|
+
try {
|
|
395
|
+
var body = _renderExtValue(ext, decoder(ext.value), inner);
|
|
396
|
+
if (body != null) return header + "\n" + body;
|
|
397
|
+
} catch (_e) { /* fall through to the raw fallback */ }
|
|
398
|
+
}
|
|
399
|
+
return header + "\n" + _fallback(ext.value, inner);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// ---- input coercion ----------------------------------------------------------
|
|
403
|
+
|
|
404
|
+
// A genuine pki.schema.x509.parse result carries this whole shape. The fast path
|
|
405
|
+
// accepts a pre-parsed certificate to skip re-parsing, but a bare or partial object
|
|
406
|
+
// with only a tbsBytes property is NOT a certificate: without this check the renderer
|
|
407
|
+
// would dereference a missing field (c.validity.notBefore, ...) and throw a raw
|
|
408
|
+
// TypeError instead of the documented typed inspect/bad-input (the API's error contract).
|
|
409
|
+
function _looksParsed(o) {
|
|
410
|
+
return typeof o.version === "number" && typeof o.serialNumberHex === "string" &&
|
|
411
|
+
o.signatureAlgorithm && typeof o.signatureAlgorithm === "object" &&
|
|
412
|
+
o.issuer && typeof o.issuer === "object" && o.subject && typeof o.subject === "object" &&
|
|
413
|
+
o.validity && o.validity.notBefore != null && o.validity.notAfter != null &&
|
|
414
|
+
o.subjectPublicKeyInfo && typeof o.subjectPublicKeyInfo === "object" && Array.isArray(o.extensions);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function _parse(input) {
|
|
418
|
+
if (input && typeof input === "object" && !Buffer.isBuffer(input) && input.tbsBytes) {
|
|
419
|
+
if (!_looksParsed(input)) throw _err("inspect/bad-input", "input has a tbsBytes property but is not a complete pki.schema.x509.parse result");
|
|
420
|
+
return input; // a genuine already-parsed certificate
|
|
421
|
+
}
|
|
422
|
+
var der;
|
|
423
|
+
if (Buffer.isBuffer(input)) der = input;
|
|
424
|
+
else if (typeof input === "string") {
|
|
425
|
+
try { der = x509.pemDecode(input, "CERTIFICATE"); }
|
|
426
|
+
catch (e) { throw _err("inspect/bad-input", "input is not a PEM CERTIFICATE", e); }
|
|
427
|
+
} else {
|
|
428
|
+
throw _err("inspect/bad-input", "input must be a parsed certificate, a DER Buffer, or a PEM string");
|
|
429
|
+
}
|
|
430
|
+
try { return x509.parse(der); }
|
|
431
|
+
catch (e) { throw _err("inspect/bad-certificate", "input is not a well-formed X.509 certificate", e); }
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// ---- public: certificate -----------------------------------------------------
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* @primitive pki.inspect.certificate
|
|
438
|
+
* @signature pki.inspect.certificate(input) -> string
|
|
439
|
+
* @since 0.2.4
|
|
440
|
+
* @status experimental
|
|
441
|
+
* @spec RFC 5280
|
|
442
|
+
* @related pki.schema.x509.parse
|
|
443
|
+
*
|
|
444
|
+
* Render a certificate as a human-readable, OpenSSL-familiar text report. `input`
|
|
445
|
+
* is a PEM string, a DER Buffer, or a `pki.schema.x509.parse` result. A value that
|
|
446
|
+
* is none of those throws `inspect/bad-input`; a malformed certificate throws
|
|
447
|
+
* `inspect/bad-certificate`; but a malformed individual extension is rendered as a
|
|
448
|
+
* hex dump rather than failing the whole report. Pure -- no OpenSSL dependency.
|
|
449
|
+
*
|
|
450
|
+
* @example
|
|
451
|
+
* var cert = pki.schema.x509.parse(der);
|
|
452
|
+
* pki.inspect.certificate(cert).split("\n")[0]; // "Certificate:"
|
|
453
|
+
*/
|
|
454
|
+
function certificate(input) {
|
|
455
|
+
var c = _parse(input);
|
|
456
|
+
var L = [];
|
|
457
|
+
L.push("Certificate:");
|
|
458
|
+
L.push(" Data:");
|
|
459
|
+
L.push(" Version: " + c.version + " (0x" + (c.version - 1).toString(16) + ")");
|
|
460
|
+
L.push(" " + _serial(c, 12));
|
|
461
|
+
L.push(" Signature Algorithm: " + _algName(c.signatureAlgorithm));
|
|
462
|
+
L.push(" Issuer: " + _dnString(c.issuer));
|
|
463
|
+
L.push(" Validity");
|
|
464
|
+
L.push(" Not Before: " + _date(c.validity.notBefore));
|
|
465
|
+
L.push(" Not After : " + _date(c.validity.notAfter));
|
|
466
|
+
L.push(" Subject: " + _dnString(c.subject));
|
|
467
|
+
L.push(" Subject Public Key Info:");
|
|
468
|
+
L.push(_keyBlock(c.subjectPublicKeyInfo, " "));
|
|
469
|
+
if ((c.extensions || []).length) {
|
|
470
|
+
L.push(" X509v3 extensions:");
|
|
471
|
+
c.extensions.forEach(function (ext) { L.push(_extension(ext, " ")); });
|
|
472
|
+
}
|
|
473
|
+
L.push(" Signature Algorithm: " + _algName(c.signatureAlgorithm));
|
|
474
|
+
var sig = c.signatureValue && (c.signatureValue.bytes || c.signatureValue);
|
|
475
|
+
if (Buffer.isBuffer(sig)) { L.push(" Signature Value:"); L.push(_hexColon(sig, { wrap: 16, indent: 8 })); }
|
|
476
|
+
return L.join("\n") + "\n";
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
module.exports = {
|
|
480
|
+
certificate: certificate,
|
|
481
|
+
// The extension names certificate() renders to their decoded values (vs the raw
|
|
482
|
+
// hex fallback). The inspect test asserts this covers every extension the shared
|
|
483
|
+
// decoders decode, so a newly-decodable extension cannot silently hex-dump.
|
|
484
|
+
renderedExtensions: Object.keys(EXT_RENDERERS),
|
|
485
|
+
};
|
package/lib/schema-attrcert.js
CHANGED
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
*/
|
|
35
35
|
|
|
36
36
|
var asn1 = require("./asn1-der");
|
|
37
|
+
var constants = require("./constants");
|
|
37
38
|
var schema = require("./schema-engine");
|
|
38
39
|
var pkix = require("./schema-pkix");
|
|
39
40
|
var oid = require("./oid");
|
|
@@ -63,7 +64,8 @@ var GENERALIZED_TIME = pkix.generalizedTime(NS, { code: "attrcert/bad-time", mes
|
|
|
63
64
|
// digestedObjectType ::= ENUMERATED { publicKey(0), publicKeyCert(1),
|
|
64
65
|
// otherObjectTypes(2) } (RFC 5755 sec. 4.1) -- an undefined value is rejected; a
|
|
65
66
|
// non-ENUMERATED tag fails at the codec (asn1/*).
|
|
66
|
-
|
|
67
|
+
// objectDigestInfo digested-object-type names -- single source pki.C.NAMES.OBJECT_DIGEST_TYPE.
|
|
68
|
+
var ODT_NAMES = constants.NAMES.OBJECT_DIGEST_TYPE;
|
|
67
69
|
var DIGESTED_OBJECT_TYPE = schema.decode(function (n, ctx) {
|
|
68
70
|
var v = asn1.read.enumerated(n);
|
|
69
71
|
var k = v.toString();
|
package/lib/schema-ocsp.js
CHANGED
|
@@ -65,7 +65,8 @@ var OID_OCSP_NONCE = oid.byName("ocspNonce");
|
|
|
65
65
|
|
|
66
66
|
// OCSPResponseStatus ::= ENUMERATED -- value 4 is "not used" and >= 7 is undefined,
|
|
67
67
|
// so both are rejected (RFC 6960 sec. 4.2.1).
|
|
68
|
-
|
|
68
|
+
// OCSPResponseStatus value names -- single source pki.C.NAMES.OCSP_STATUS.
|
|
69
|
+
var STATUS_NAMES = constants.NAMES.OCSP_STATUS;
|
|
69
70
|
|
|
70
71
|
// CRLReason ::= ENUMERATED -- value 7 is "not used"; the rest are RFC 5280
|
|
71
72
|
// sec. 5.3.1. The legal set + names live once in pkix.CRL_REASON_NAMES (shared
|
package/lib/schema-pkix.js
CHANGED
|
@@ -150,20 +150,9 @@ function runParse(input, opts) {
|
|
|
150
150
|
}
|
|
151
151
|
|
|
152
152
|
// Distinguished-name attribute short labels (RFC 4514 sec. 3 + common use).
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
localityName: "L",
|
|
157
|
-
stateOrProvinceName: "ST",
|
|
158
|
-
streetAddress: "STREET",
|
|
159
|
-
organizationName: "O",
|
|
160
|
-
organizationalUnitName: "OU",
|
|
161
|
-
domainComponent: "DC",
|
|
162
|
-
surname: "SN",
|
|
163
|
-
givenName: "GN",
|
|
164
|
-
serialNumber: "SERIALNUMBER",
|
|
165
|
-
emailAddress: "emailAddress",
|
|
166
|
-
};
|
|
153
|
+
// RFC 4514 short names for the DN string representation -- the single source is
|
|
154
|
+
// pki.C.NAMES.DN_SHORT (shared with the human-readable renderer so the two can't drift).
|
|
155
|
+
var DN_SHORT = constants.NAMES.DN_SHORT;
|
|
167
156
|
|
|
168
157
|
// AlgorithmIdentifier ::= SEQUENCE { algorithm OBJECT IDENTIFIER, parameters ANY OPTIONAL }
|
|
169
158
|
// The error/OID namespace every format module walks its schema under:
|
|
@@ -286,12 +275,10 @@ function attrValueToString(ns) {
|
|
|
286
275
|
// '\#' / '\ ' -- without them the rendered dn is ambiguous (a literal '#0500'
|
|
287
276
|
// value would be indistinguishable from the hexstring form) and leading /
|
|
288
277
|
// trailing spaces would be silently significant.
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
return s;
|
|
294
|
-
}
|
|
278
|
+
// RFC 4514 sec. 2.4 attribute-value escaping is the single guard-name primitive
|
|
279
|
+
// (pki.inspect reuses the dn this produces rather than re-escaping the already-
|
|
280
|
+
// escaped value), so the display escaper can't diverge from a re-inline elsewhere.
|
|
281
|
+
var _escapeDnValue = guard.name.escapeDnValue;
|
|
295
282
|
|
|
296
283
|
// The dn RENDERING of a surfaced AttributeValue: the '#'-leading hex form (a
|
|
297
284
|
// non-string value) renders bare -- escaping its '#' would turn the RFC 4514
|
|
@@ -455,11 +442,9 @@ function rawNonEmptySequence(ns, opts) {
|
|
|
455
442
|
// The ONE canonical value->name table every consumer validates against: the
|
|
456
443
|
// CRL reasonCode decoder surfaces the numeric code, the OCSP RevokedInfo
|
|
457
444
|
// decoder the name; both draw the legal set from here so they cannot drift.
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
"8": "removeFromCRL", "9": "privilegeWithdrawn", "10": "aACompromise",
|
|
462
|
-
};
|
|
445
|
+
// CRLReason ENUMERATED value names -- the single source is pki.C.NAMES.CRL_REASON
|
|
446
|
+
// (shared with the OCSP revoked-status decoder, which surfaces the same numeric code).
|
|
447
|
+
var CRL_REASON_NAMES = constants.NAMES.CRL_REASON;
|
|
463
448
|
|
|
464
449
|
var GN_CONSTRUCTED = { 0: 1, 3: 1, 4: 1, 5: 1 };
|
|
465
450
|
var GN_IA5 = { 1: 1, 2: 1, 6: 1 };
|
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:8b4b818c-aa9b-48e0-bee9-d1588bcc6621",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-07-
|
|
8
|
+
"timestamp": "2026-07-12T18:33:27.056Z",
|
|
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.2.
|
|
22
|
+
"bom-ref": "@blamejs/pki@0.2.4",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "pki",
|
|
25
|
-
"version": "0.2.
|
|
25
|
+
"version": "0.2.4",
|
|
26
26
|
"scope": "required",
|
|
27
27
|
"author": "blamejs contributors",
|
|
28
28
|
"description": "Pure-JavaScript PKI toolkit that owns its stack — X.509, ASN.1/DER, CMS, PQC-first.",
|
|
29
|
-
"purl": "pkg:npm/%40blamejs/pki@0.2.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/pki@0.2.4",
|
|
30
30
|
"properties": [],
|
|
31
31
|
"externalReferences": [
|
|
32
32
|
{
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"components": [],
|
|
55
55
|
"dependencies": [
|
|
56
56
|
{
|
|
57
|
-
"ref": "@blamejs/pki@0.2.
|
|
57
|
+
"ref": "@blamejs/pki@0.2.4",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|