@blamejs/pki 0.2.2 → 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/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/oid.js CHANGED
@@ -131,6 +131,20 @@ var FAMILIES = {
131
131
  signedCertificateTimestampList: 2, precertificatePoison: 3,
132
132
  precertificateSigningCert: 4, ocspSignedCertificateTimestampList: 5 } },
133
133
 
134
+ // Fulcio (Sigstore) X.509 certificate-extension arc. `.1.1`-`.1.6` are the
135
+ // DEPRECATED members whose values are RAW UTF-8 strings (no DER wrapping);
136
+ // `.1.7` is the OtherName SAN type; `.1.8` onward are DER-encoded ASN.1
137
+ // UTF8String -- the decode MUST honor the raw-vs-DER split by member.
138
+ fulcio: { base: [1, 3, 6, 1, 4, 1, 57264, 1], of: {
139
+ issuerLegacy: 1, githubWorkflowTrigger: 2, githubWorkflowSha: 3,
140
+ githubWorkflowName: 4, githubWorkflowRepository: 5, githubWorkflowRef: 6,
141
+ otherName: 7, issuer: 8, buildSignerURI: 9, buildSignerDigest: 10,
142
+ runnerEnvironment: 11, sourceRepositoryURI: 12, sourceRepositoryDigest: 13,
143
+ sourceRepositoryRef: 14, sourceRepositoryIdentifier: 15,
144
+ sourceRepositoryOwnerURI: 16, sourceRepositoryOwnerIdentifier: 17,
145
+ buildConfigURI: 18, buildConfigDigest: 19, buildTrigger: 20,
146
+ runInvocationURI: 21, sourceRepositoryVisibilityAtSigning: 22 } },
147
+
134
148
  // PKCS#1 RSA public-key + RSASSA signature algorithms.
135
149
  rsa: { base: [1, 2, 840, 113549, 1, 1], of: {
136
150
  rsaEncryption: 1, rsaesOaep: 7, mgf1: 8, rsassaPss: 10,
@@ -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
- var ODT_NAMES = { "0": "publicKey", "1": "publicKeyCert", "2": "otherObjectTypes" };
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();
@@ -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
- var STATUS_NAMES = { "0": "successful", "1": "malformedRequest", "2": "internalError", "3": "tryLater", "5": "sigRequired", "6": "unauthorized" };
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
@@ -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
- var DN_SHORT = {
154
- commonName: "CN",
155
- countryName: "C",
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
- function _escapeDnValue(v) {
290
- var s = v.replace(/([,+"\\<>;])/g, "\\$1").split("\u0000").join("\\00");
291
- if (s.length && s.charAt(s.length - 1) === " ") s = s.slice(0, -1) + "\\ ";
292
- if (s.charAt(0) === "#" || s.charAt(0) === " ") s = "\\" + s;
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
- var CRL_REASON_NAMES = {
459
- "0": "unspecified", "1": "keyCompromise", "2": "cACompromise", "3": "affiliationChanged",
460
- "4": "superseded", "5": "cessationOfOperation", "6": "certificateHold",
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 };