@blamejs/pki 0.1.6 → 0.1.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.
@@ -0,0 +1,320 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (c) blamejs contributors
3
+ "use strict";
4
+ //
5
+ // @internal — no operator-facing namespace. The documented surface is the
6
+ // parsers that compose these factories (pki.schema.x509, pki.schema.crl, …).
7
+ //
8
+ // Shared PKIX structure-schema factories (RFC 5280). Each is a namespace-
9
+ // parameterized FACTORY: given an error namespace `ns` ({ prefix, E, oid }) it
10
+ // returns an asn1-schema that walks the corresponding ASN.1 structure and emits
11
+ // the caller's own <prefix>/* error codes. x509.js, crl.js, and future CMS/CSR
12
+ // parsers compose these so AlgorithmIdentifier / Name / Extension are defined
13
+ // once, not re-derived per format. This module is internal infrastructure — the
14
+ // operator-facing surface is the parsers that consume it.
15
+
16
+ var asn1 = require("./asn1-der");
17
+ var constants = require("./constants");
18
+ var schema = require("./schema-engine");
19
+
20
+ var PEM_RE = /-----BEGIN ([A-Z0-9 ]+)-----([\s\S]*?)-----END \1-----/;
21
+
22
+ // ---- shared parse-entry ----------------------------------------------
23
+ // Input handling, PEM unwrapping (with the size cap), DER-decode wrapping, and
24
+ // the walk are defined ONCE here so no format can diverge on a guard. Each
25
+ // format supplies only its labels, error class, code prefix, and top schema.
26
+
27
+ // pemDecode(text, label, PemError): `label` (when truthy) is enforced, else the
28
+ // first block is taken. Applies the LIMITS.PEM_MAX_BYTES cap before scanning.
29
+ function pemDecode(text, label, PemError) {
30
+ if (Buffer.isBuffer(text)) text = text.toString("latin1");
31
+ if (typeof text !== "string") throw new PemError("pem/bad-input", "pemDecode expects a string or Buffer");
32
+ if (text.length > constants.LIMITS.PEM_MAX_BYTES) throw new PemError("pem/too-large", "PEM input exceeds size cap");
33
+ var m = PEM_RE.exec(text);
34
+ if (!m) throw new PemError("pem/no-block", "no PEM block found");
35
+ if (label && m[1] !== label) throw new PemError("pem/label-mismatch", "expected " + JSON.stringify(label) + " block, got " + JSON.stringify(m[1]));
36
+ var b64 = m[2].replace(/[\r\n\t ]+/g, "");
37
+ if (!/^[A-Za-z0-9+/]*={0,2}$/.test(b64)) throw new PemError("pem/bad-base64", "PEM body is not valid base64");
38
+ return Buffer.from(b64, "base64");
39
+ }
40
+
41
+ function pemEncode(der, label, PemError) {
42
+ if (typeof label !== "string" || label.length === 0) throw new PemError("pem/bad-label", "pemEncode requires a label");
43
+ var buf = Buffer.isBuffer(der) ? der : Buffer.from(der);
44
+ var b64 = buf.toString("base64").replace(/(.{64})/g, "$1\n").replace(/\n$/, "");
45
+ return "-----BEGIN " + label + "-----\n" + b64 + "\n-----END " + label + "-----\n";
46
+ }
47
+
48
+ // Coerce parse input to DER bytes. A PEM string OR a PEM Buffer (a .pem file
49
+ // read with fs.readFileSync) is unwrapped with opts.pemLabel; a DER Buffer or
50
+ // Uint8Array is taken as bytes. opts: { pemLabel, PemError, ErrorClass, prefix }.
51
+ function coerceToDer(input, opts) {
52
+ if (typeof input === "string") return pemDecode(input, opts.pemLabel, opts.PemError);
53
+ if (input instanceof Uint8Array && !Buffer.isBuffer(input)) input = Buffer.from(input);
54
+ if (Buffer.isBuffer(input)) {
55
+ return _isPemArmor(input) ? pemDecode(input, opts.pemLabel, opts.PemError) : input;
56
+ }
57
+ throw new opts.ErrorClass(opts.prefix + "/bad-input", "parse expects a DER Buffer or a PEM string");
58
+ }
59
+
60
+ // Does a Buffer carry PEM armor (a .pem read with fs.readFileSync) rather than
61
+ // raw DER? It does iff "-----BEGIN" appears and everything before it is TEXT
62
+ // (UTF-8 BOM / whitespace / RFC 7468 explanatory preamble). DER is binary — its
63
+ // leading tag+length bytes are non-printable — so a non-SEQUENCE DER (a bare
64
+ // SET / INTEGER) is NOT misrouted here; it decodes and fails closed structurally.
65
+ function _isPemArmor(buf) {
66
+ var head = buf.slice(0, 4096).toString("latin1");
67
+ var idx = head.indexOf("-----BEGIN");
68
+ if (idx === -1) return false;
69
+ for (var i = 0; i < idx; i++) {
70
+ var c = buf[i];
71
+ var textByte = (c >= 0x20 && c < 0x7f) || c === 0x09 || c === 0x0a || c === 0x0d ||
72
+ c === 0xef || c === 0xbb || c === 0xbf; // printable ASCII, tab/newlines, UTF-8 BOM
73
+ if (!textByte) return false;
74
+ }
75
+ return true;
76
+ }
77
+
78
+ // Decode the DER root, wrapping a codec fault in the caller's <prefix>/bad-der.
79
+ function decodeRoot(der, opts) {
80
+ try { return asn1.decode(der); }
81
+ catch (e) { throw new opts.ErrorClass(opts.prefix + "/bad-der", (opts.what || "input") + " DER did not decode: " + ((e && e.message) || String(e)), e); }
82
+ }
83
+
84
+ // The shared parse entry: coerce -> decode -> walk the top schema. A format's
85
+ // parse() is one call to this; the guard-parity bug class (a new format not
86
+ // mirroring an existing format's input handling) is structurally impossible.
87
+ function runParse(input, opts) {
88
+ return schema.walk(opts.topSchema, decodeRoot(coerceToDer(input, opts), opts), opts.ns).result;
89
+ }
90
+
91
+ // Distinguished-name attribute short labels (RFC 4514 §3 + common use).
92
+ var DN_SHORT = {
93
+ commonName: "CN",
94
+ countryName: "C",
95
+ localityName: "L",
96
+ stateOrProvinceName: "ST",
97
+ streetAddress: "STREET",
98
+ organizationName: "O",
99
+ organizationalUnitName: "OU",
100
+ domainComponent: "DC",
101
+ surname: "SN",
102
+ givenName: "GN",
103
+ serialNumber: "SERIALNUMBER",
104
+ emailAddress: "emailAddress",
105
+ };
106
+
107
+ // AlgorithmIdentifier ::= SEQUENCE { algorithm OBJECT IDENTIFIER, parameters ANY OPTIONAL }
108
+ function algorithmIdentifier(ns) {
109
+ return schema.seq([
110
+ schema.field("algorithm", schema.oidLeaf()),
111
+ schema.optional("parameters", schema.any(), { whenAny: true }),
112
+ ], {
113
+ assert: "sequence", arity: { min: 1 }, code: ns.prefix + "/bad-algorithm-identifier", what: "AlgorithmIdentifier",
114
+ build: function (m, ctx) {
115
+ var dotted = m.fields.algorithm.value;
116
+ return { oid: dotted, name: ctx.oid.name(dotted) || null, parameters: m.fields.parameters.present ? m.fields.parameters.node.bytes : null };
117
+ },
118
+ });
119
+ }
120
+
121
+ // attrValueToString(ns): the AttributeValue decode-leaf. A malformed KNOWN
122
+ // string type (invalid UTF-8, a non-IA5 byte, a PrintableString character
123
+ // outside its set, ...) surfaces as an asn1/bad-* content error and must fail
124
+ // closed — do NOT hex-encode it away, or the decoder's strict string validation
125
+ // is silently bypassed on the DN path. A value that is simply not a decodable
126
+ // primitive string is NOT malformed and stays representable: an ANY-typed
127
+ // non-string tag (asn1/expected-string) or a constructed universal type such as
128
+ // a SEQUENCE (asn1/expected-primitive) renders per RFC 4514 §2.4 as "#" plus the
129
+ // hex of its FULL DER encoding (node.bytes), round-tripping intact.
130
+ function attrValueToString(ns) {
131
+ return schema.decode(function (node) {
132
+ try { return asn1.read.string(node); }
133
+ catch (e) {
134
+ if (!e || (e.code !== "asn1/expected-string" && e.code !== "asn1/expected-primitive")) {
135
+ throw ns.E(ns.prefix + "/bad-atv", "malformed string in attribute value: " + ((e && e.message) || String(e)));
136
+ }
137
+ return "#" + node.bytes.toString("hex");
138
+ }
139
+ });
140
+ }
141
+
142
+ function _escapeDnValue(v) {
143
+ return v.replace(/([,+"\\<>;])/g, "\\$1");
144
+ }
145
+
146
+ // Name ::= RDNSequence ::= SEQUENCE OF RelativeDistinguishedName; RDN ::= SET OF
147
+ // AttributeTypeAndValue ::= SEQUENCE { type OID, value ANY }. The atv asserts
148
+ // bare-constructed (min 2) — matching the historical guard that never checked
149
+ // the SEQUENCE tag — and repeated RDN attribute types stay legal (no uniqueness).
150
+ function attributeTypeAndValue(ns) {
151
+ return schema.seq([
152
+ schema.field("type", schema.oidLeaf()),
153
+ schema.field("value", attrValueToString(ns)),
154
+ ], {
155
+ assert: "constructed", arity: { min: 2 }, code: ns.prefix + "/bad-atv", what: "AttributeTypeAndValue",
156
+ build: function (m, ctx) {
157
+ var typeOid = m.fields.type.value;
158
+ return { type: typeOid, name: ctx.oid.name(typeOid) || null, value: m.fields.value.value };
159
+ },
160
+ });
161
+ }
162
+ function relativeDistinguishedName(ns) {
163
+ // RelativeDistinguishedName ::= SET SIZE (1..MAX) — an empty SET {} is malformed.
164
+ return schema.setOf(attributeTypeAndValue(ns), { assert: "set", min: 1, code: ns.prefix + "/bad-rdn", what: "RelativeDistinguishedName" });
165
+ }
166
+ function name(ns) {
167
+ return schema.seqOf(relativeDistinguishedName(ns), {
168
+ assert: "sequence", code: ns.prefix + "/bad-name", what: "Name",
169
+ build: function (m) {
170
+ var rdns = [], parts = [];
171
+ m.items.forEach(function (rdnItem) {
172
+ var atvs = [], atvParts = [];
173
+ rdnItem.value.items.forEach(function (atvItem) {
174
+ var a = atvItem.value.result; // atvItem.value = the atv seq-match; .result = its build result
175
+ atvs.push(a);
176
+ var label = (a.name && DN_SHORT[a.name]) || a.name || a.type;
177
+ atvParts.push(label + "=" + _escapeDnValue(a.value));
178
+ });
179
+ rdns.push(atvs);
180
+ parts.push(atvParts.join("+"));
181
+ });
182
+ return { rdns: rdns, dn: parts.join(", ") };
183
+ },
184
+ });
185
+ }
186
+
187
+ // Extension ::= SEQUENCE { extnID OID, critical BOOLEAN DEFAULT FALSE,
188
+ // extnValue OCTET STRING }. `critical` is a universal BOOLEAN present-by-count
189
+ // (not context-tagged), so the per-extension decode handles the 2-vs-3-child
190
+ // shape directly; the seqOf centralizes the SEQUENCE / SIZE(1..MAX) assertion
191
+ // and the RFC 5280 §4.2 per-OID uniqueness.
192
+ function extension(ns) {
193
+ return schema.decode(function (ext) {
194
+ // Extension ::= SEQUENCE { extnID, critical DEFAULT FALSE, extnValue } — a
195
+ // UNIVERSAL SEQUENCE of exactly 2 (critical omitted) or 3 children. A
196
+ // context-tagged item (e.g. [5]{OID, OCTET STRING}) or a wrong child count
197
+ // is malformed; assert the tag, don't just count children (fail closed).
198
+ if (!ext.children || ext.tagClass !== "universal" || ext.tagNumber !== asn1.TAGS.SEQUENCE ||
199
+ ext.children.length < 2 || ext.children.length > 3) {
200
+ throw ns.E(ns.prefix + "/bad-extension", "Extension must be a SEQUENCE of {extnID, critical?, extnValue}");
201
+ }
202
+ var extnID = asn1.read.oid(ext.children[0]);
203
+ var critical = false, valueNode;
204
+ if (ext.children.length === 3) {
205
+ critical = asn1.read.boolean(ext.children[1]);
206
+ // critical is BOOLEAN DEFAULT FALSE — DER omits the field when false, so an
207
+ // explicitly-encoded FALSE is a non-canonical form; reject it fail-closed.
208
+ if (critical === false) throw ns.E(ns.prefix + "/bad-extension", "an explicit critical FALSE must be omitted (BOOLEAN DEFAULT FALSE)");
209
+ valueNode = ext.children[2];
210
+ } else {
211
+ valueNode = ext.children[1];
212
+ }
213
+ return { oid: extnID, name: ns.oid.name(extnID) || null, critical: critical, value: asn1.read.octetString(valueNode) };
214
+ });
215
+ }
216
+ function extensions(ns) {
217
+ return schema.seqOf(extension(ns), {
218
+ assert: "sequence", min: 1, code: ns.prefix + "/bad-extensions", what: "Extensions",
219
+ unique: function (item) { return item.value.oid; }, dupCode: ns.prefix + "/duplicate-extension",
220
+ build: function (m) { return m.items.map(function (it) { return it.value; }); },
221
+ });
222
+ }
223
+
224
+ // SubjectPublicKeyInfo ::= SEQUENCE { algorithm AlgorithmIdentifier,
225
+ // subjectPublicKey BIT STRING } (RFC 5280 §4.1.2.7, RFC 2986 §4.1). Asserted as a
226
+ // universal SEQUENCE — a context-tagged or SET-tagged constructed node carrying
227
+ // two well-formed children is NOT a SubjectPublicKeyInfo. Shared by the
228
+ // certificate and CSR parsers.
229
+ function spki(ns) {
230
+ return schema.seq([
231
+ schema.field("algorithm", algorithmIdentifier(ns)),
232
+ schema.field("subjectPublicKey", schema.bitString()),
233
+ ], {
234
+ assert: "sequence", arity: { exact: 2 }, code: ns.prefix + "/bad-spki", what: "SubjectPublicKeyInfo",
235
+ build: function (m) {
236
+ return {
237
+ algorithm: m.fields.algorithm.value.result,
238
+ publicKey: { unusedBits: m.fields.subjectPublicKey.value.unusedBits, bytes: m.fields.subjectPublicKey.value.bytes },
239
+ bytes: m.node.bytes,
240
+ };
241
+ },
242
+ });
243
+ }
244
+
245
+ // Certificate, CertificateList, and CertificationRequest share one outer shape:
246
+ // SEQUENCE { toBeSigned SEQUENCE, signatureAlgorithm, signatureValue }. This
247
+ // returns the first element (the to-be-signed info) when `root` is that
248
+ // SEQUENCE-of-exactly-3 whose first child is itself a constructed universal
249
+ // SEQUENCE, or null otherwise. Every format's `matches` detector shares this
250
+ // preamble, so the signed-envelope shape is recognized in one place and the three
251
+ // detectors cannot drift on it (the CRL detector historically omitted the
252
+ // tbs-is-universal check this recovers).
253
+ function signedEnvelopeTbs(root) {
254
+ if (!root || root.tagClass !== "universal" || root.tagNumber !== asn1.TAGS.SEQUENCE) return null;
255
+ if (!root.children || root.children.length !== 3) return null;
256
+ var tbs = root.children[0];
257
+ if (!tbs.children || tbs.tagClass !== "universal" || tbs.tagNumber !== asn1.TAGS.SEQUENCE) return null;
258
+ return tbs;
259
+ }
260
+
261
+ // Every format's `parse` is the shared runParse bound to that format's identity
262
+ // (PEM label, error class, error-code prefix, top-level schema). This returns the
263
+ // bound parser so a format declares its configuration once and never re-writes the
264
+ // coerce -> decode -> walk wrapper. `opts`: { pemLabel, PemError, ErrorClass,
265
+ // prefix, what, topSchema, ns }.
266
+ function makeParser(opts) {
267
+ return function (input) { return runParse(input, opts); };
268
+ }
269
+
270
+ // The X.509 SIGNED{ToBeSigned} macro (RFC 5280 §4.1.1.3): the outer
271
+ // SEQUENCE { toBeSigned, signatureAlgorithm AlgorithmIdentifier,
272
+ // signatureValue BIT STRING } shared by Certificate, CertificateList and
273
+ // CertificationRequest. `tbsSchema` parses the first element; the SEQUENCE-of-3
274
+ // shape, the arity, the signature extraction and the raw tbs / outer-signature
275
+ // bytes (for the cert/CRL outer==inner agreement check) are owned here once, and
276
+ // each format's `opts.build(envelope, ctx)` shapes its own object from the
277
+ // envelope. A CSR's build simply omits the agreement check — its CRI has no inner
278
+ // signature AlgorithmIdentifier — so the omission is structural, not a copy that
279
+ // forgot a guard.
280
+ function signedEnvelope(ns, tbsSchema, opts) {
281
+ return schema.seq([
282
+ schema.field("toBeSigned", tbsSchema),
283
+ schema.field("signatureAlgorithm", algorithmIdentifier(ns)),
284
+ schema.field("signatureValue", schema.bitString()),
285
+ ], {
286
+ assert: "sequence", arity: { exact: 3 }, code: opts.code, what: opts.what,
287
+ build: function (m, ctx) {
288
+ var tbsMatch = m.fields.toBeSigned.value;
289
+ var sigBits = m.fields.signatureValue.value;
290
+ return opts.build({
291
+ tbsMatch: tbsMatch, // raw seq-match: .fields.* / .result / .node
292
+ tbsBytes: tbsMatch.node.bytes, // the exact signed region
293
+ outerSignatureAlgorithmBytes: m.fields.signatureAlgorithm.node.bytes,
294
+ signatureAlgorithm: m.fields.signatureAlgorithm.value.result,
295
+ signatureValue: { unusedBits: sigBits.unusedBits, bytes: sigBits.bytes },
296
+ }, ctx);
297
+ },
298
+ });
299
+ }
300
+
301
+ module.exports = {
302
+ pemDecode: pemDecode,
303
+ pemEncode: pemEncode,
304
+ coerceToDer: coerceToDer,
305
+ decodeRoot: decodeRoot,
306
+ runParse: runParse,
307
+ DN_SHORT: DN_SHORT,
308
+ algorithmIdentifier: algorithmIdentifier,
309
+ spki: spki,
310
+ makeParser: makeParser,
311
+ signedEnvelopeTbs: signedEnvelopeTbs,
312
+ signedEnvelope: signedEnvelope,
313
+ attrValueToString: attrValueToString,
314
+ attributeTypeAndValue: attributeTypeAndValue,
315
+ relativeDistinguishedName: relativeDistinguishedName,
316
+ name: name,
317
+ extension: extension,
318
+ extensions: extensions,
319
+ };
320
+
@@ -0,0 +1,243 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (c) blamejs contributors
3
+ "use strict";
4
+ /**
5
+ * @module pki.schema.x509
6
+ * @nav Schema
7
+ * @title X.509
8
+ * @order 100
9
+ * @featured true
10
+ * @slug x509
11
+ *
12
+ * @intro
13
+ * X.509 certificate handling per RFC 5280. The seed surface is
14
+ * `parse` — turn a DER or PEM certificate into a structured,
15
+ * fully-decoded object: version, serial, signature algorithm, issuer
16
+ * and subject distinguished names, validity window (as real `Date`s),
17
+ * subject public-key info, and the extension list. The parser composes
18
+ * the strict DER codec and the OID registry, so every field is
19
+ * validated on the way in and every algorithm / attribute / extension
20
+ * is named where the registry knows it.
21
+ *
22
+ * The raw `tbsCertificate` bytes are returned alongside the parsed
23
+ * fields so a signature-verification layer can hash exactly the bytes
24
+ * that were signed rather than re-encoding and hoping for round-trip
25
+ * fidelity.
26
+ *
27
+ * @card
28
+ * Parse DER / PEM X.509 certificates into structured, validated fields
29
+ * with named algorithms, extensions, and real-`Date` validity windows.
30
+ */
31
+
32
+ var asn1 = require("./asn1-der");
33
+ var schema = require("./schema-engine");
34
+ var oid = require("./oid");
35
+ var frameworkError = require("./framework-error");
36
+ var pkix = require("./schema-pkix");
37
+
38
+ var CertificateError = frameworkError.CertificateError;
39
+ var PemError = frameworkError.PemError;
40
+
41
+ // ---- PEM -------------------------------------------------------------
42
+
43
+ /**
44
+ * @primitive pki.schema.x509.pemDecode
45
+ * @signature pki.schema.x509.pemDecode(text, label?) -> Buffer
46
+ * @since 0.1.7
47
+ * @status stable
48
+ * @spec RFC 7468, RFC 5280
49
+ * @related pki.schema.x509.pemEncode
50
+ *
51
+ * Extract the DER bytes from a PEM block. With `label` given (e.g.
52
+ * `"CERTIFICATE"`) the block type must match; without it, the first block
53
+ * is taken. Throws `PemError` on a missing / mismatched envelope or a
54
+ * non-base64 body.
55
+ *
56
+ * @example
57
+ * var der = pki.schema.x509.pemDecode(pemText, "CERTIFICATE");
58
+ */
59
+ function pemDecode(text, label) { return pkix.pemDecode(text, label, PemError); }
60
+
61
+ /**
62
+ * @primitive pki.schema.x509.pemEncode
63
+ * @signature pki.schema.x509.pemEncode(der, label) -> string
64
+ * @since 0.1.7
65
+ * @status stable
66
+ * @spec RFC 7468, RFC 5280
67
+ * @related pki.schema.x509.pemDecode
68
+ *
69
+ * Wrap DER bytes in a PEM envelope with 64-column base64 lines.
70
+ *
71
+ * @example
72
+ * var pem = pki.schema.x509.pemEncode(der, "CERTIFICATE");
73
+ */
74
+ function pemEncode(der, label) { return pkix.pemEncode(der, label, PemError); }
75
+
76
+ // ---- helpers ---------------------------------------------------------
77
+
78
+ // The x509 error namespace the schema engine walks under: prefix names the
79
+ // error family and E constructs the typed CertificateError. The shared PKIX
80
+ // sub-schemas (AlgorithmIdentifier, Name, Extension) live in lib/pkix-schema.js
81
+ // as ns-parameterized factories so crl.js / cms.js reuse them while emitting
82
+ // their own crl/*, cms/* codes; here they are instantiated under this NS.
83
+ var NS = { prefix: "x509", E: function (code, message) { return new CertificateError(code, message); }, oid: oid };
84
+
85
+ var ALGORITHM_IDENTIFIER = pkix.algorithmIdentifier(NS);
86
+ var NAME = pkix.name(NS);
87
+
88
+ // Validity ::= SEQUENCE { notBefore Time, notAfter Time }. The historical guard
89
+ // checked only children.length===2 (not the SEQUENCE tag) -> assert "constructed".
90
+ var VALIDITY = schema.seq([
91
+ schema.field("notBefore", schema.time(NS)),
92
+ schema.field("notAfter", schema.time(NS)),
93
+ ], {
94
+ assert: "constructed", arity: { exact: 2 }, code: "x509/bad-validity", what: "Validity",
95
+ build: function (m) { return { notBefore: m.fields.notBefore.value, notAfter: m.fields.notAfter.value }; },
96
+ });
97
+
98
+ // SubjectPublicKeyInfo — the shared pkix.spki factory under the x509 NS.
99
+ var SPKI = pkix.spki(NS);
100
+
101
+ var EXTENSIONS = pkix.extensions(NS);
102
+
103
+ // Version ::= INTEGER { v1(0), v2(1), v3(2) }, [0] EXPLICIT DEFAULT v1. Read as a
104
+ // BigInt so an out-of-range value can't coerce to a float; reject an explicitly-
105
+ // encoded v1 (DER forbids encoding the DEFAULT).
106
+ function readVersion(ns) {
107
+ return schema.decode(function (n) {
108
+ var v = asn1.read.integer(n);
109
+ if (v === 0n) throw ns.E(ns.prefix + "/bad-version", "DER forbids explicitly encoding the default version v1");
110
+ if (v === 1n) return 2;
111
+ if (v === 2n) return 3;
112
+ throw ns.E(ns.prefix + "/bad-version", "unsupported certificate version " + v.toString());
113
+ });
114
+ }
115
+
116
+ // TBSCertificate ::= SEQUENCE { version [0] EXPLICIT DEFAULT v1, serialNumber
117
+ // INTEGER, signature AlgorithmIdentifier, issuer Name, validity Validity,
118
+ // subject Name, subjectPublicKeyInfo SubjectPublicKeyInfo, issuerUniqueID [1]
119
+ // IMPLICIT OPTIONAL, subjectUniqueID [2] IMPLICIT OPTIONAL, extensions [3]
120
+ // EXPLICIT OPTIONAL }. The trailing fields are at-most-once, in increasing tag
121
+ // order (the engine enforces it); only extensions are surfaced.
122
+ var CERTIFICATE_TBS = schema.seq([
123
+ schema.optional("version", readVersion(NS), { tag: 0, explicit: true, emptyCode: "x509/bad-version", default: 1 }),
124
+ schema.field("serialNumber", schema.integerLeaf()),
125
+ schema.field("signature", ALGORITHM_IDENTIFIER),
126
+ schema.field("issuer", NAME),
127
+ schema.field("validity", VALIDITY),
128
+ schema.field("subject", NAME),
129
+ schema.field("subjectPublicKeyInfo", SPKI),
130
+ schema.trailing([
131
+ { tag: 1, name: "issuerUniqueID", schema: schema.any() },
132
+ { tag: 2, name: "subjectUniqueID", schema: schema.any() },
133
+ { tag: 3, name: "extensions", schema: EXTENSIONS, explicit: true, emptyCode: "x509/bad-extensions" },
134
+ ], { minTag: 1, maxTag: 3, unexpectedCode: "x509/bad-tbs", orderCode: "x509/bad-tbs" }),
135
+ ], { assert: "constructed", code: "x509/bad-tbs", what: "tbsCertificate" });
136
+
137
+ // Certificate ::= SEQUENCE { tbsCertificate, signatureAlgorithm, signatureValue
138
+ // BIT STRING }. The build runs the RFC 5280 cross-field checks (§4.1.1.2 sig-alg
139
+ // agreement, §4.1.2.4 non-empty issuer, §4.1.2.9 extensions-only-in-v3) after the
140
+ // structural walk, then assembles the public parse result. Raw-byte accessors
141
+ // (serialNumberHex, tbsBytes, sig-alg agreement) read off the match-tree nodes.
142
+ // Certificate ::= SEQUENCE { tbsCertificate, signatureAlgorithm, signatureValue }
143
+ // — the shared SIGNED envelope. The certificate-specific invariants (outer==inner
144
+ // signatureAlgorithm agreement, non-empty issuer, v3-only extensions) live in the
145
+ // build; the envelope owns the SEQUENCE-of-3 shape and signature extraction.
146
+ var CERTIFICATE = pkix.signedEnvelope(NS, CERTIFICATE_TBS, {
147
+ code: "x509/not-a-certificate", what: "Certificate",
148
+ build: function (e, ctx) {
149
+ var tbs = e.tbsMatch; // CERTIFICATE_TBS seq-match (no build)
150
+
151
+ // RFC 5280 §4.1.1.2 — outer signatureAlgorithm MUST equal tbsCertificate.signature.
152
+ if (!e.outerSignatureAlgorithmBytes.equals(tbs.fields.signature.node.bytes)) {
153
+ throw ctx.E("x509/bad-signature-algorithm", "signatureAlgorithm must match tbsCertificate.signature (RFC 5280 §4.1.1.2)");
154
+ }
155
+
156
+ var version = tbs.fields.version.value; // 1 (default) | 2 | 3
157
+ var issuer = tbs.fields.issuer.value.result;
158
+ // RFC 5280 §4.1.2.4 — issuer MUST be non-empty (an empty subject is permitted, §4.1.2.6).
159
+ if (!issuer.rdns.length) {
160
+ throw ctx.E("x509/bad-issuer", "issuer must be a non-empty distinguished name");
161
+ }
162
+
163
+ var extField = tbs.fields.extensions;
164
+ var hasExtensions = !!(extField && extField.present);
165
+ // RFC 5280 §4.1.2.9 — extensions appear only in a v3 certificate.
166
+ if (hasExtensions && version !== 3) {
167
+ throw ctx.E("x509/bad-version", "extensions are only permitted in a v3 certificate");
168
+ }
169
+
170
+ var serialNode = tbs.fields.serialNumber.node;
171
+ return {
172
+ version: version,
173
+ serialNumber: tbs.fields.serialNumber.value,
174
+ serialNumberHex: serialNode.content.toString("hex"),
175
+ signatureAlgorithm: e.signatureAlgorithm,
176
+ tbsSignatureAlgorithm: tbs.fields.signature.value.result,
177
+ issuer: issuer,
178
+ subject: tbs.fields.subject.value.result,
179
+ validity: tbs.fields.validity.value.result,
180
+ subjectPublicKeyInfo: tbs.fields.subjectPublicKeyInfo.value.result,
181
+ extensions: hasExtensions ? extField.value.result : [],
182
+ tbsBytes: e.tbsBytes,
183
+ signatureValue: e.signatureValue,
184
+ };
185
+ },
186
+ });
187
+
188
+ // ---- parse -----------------------------------------------------------
189
+
190
+ /**
191
+ * @primitive pki.schema.x509.parse
192
+ * @signature pki.schema.x509.parse(input) -> certificate
193
+ * @since 0.1.7
194
+ * @status stable
195
+ * @spec RFC 5280, X.509
196
+ * @defends malformed-certificate-parse (CWE-20)
197
+ * @related pki.asn1.decode, pki.oid.name
198
+ *
199
+ * Parse a DER `Buffer` or a PEM string/Buffer into a structured
200
+ * certificate: `{ version, serialNumber, serialNumberHex,
201
+ * signatureAlgorithm, issuer, subject, validity, subjectPublicKeyInfo,
202
+ * extensions, tbsBytes, signatureValue }`. Distinguished names come back
203
+ * both as a rendered `dn` string and as structured `rdns`; the validity
204
+ * window is real `Date`s; `tbsBytes` is the exact signed byte range for a
205
+ * downstream verifier.
206
+ *
207
+ * Throws `CertificateError` when the bytes are not a well-formed
208
+ * certificate and `Asn1Error` when the underlying DER is malformed.
209
+ *
210
+ * @example
211
+ * var cert = pki.schema.x509.parse(pemString);
212
+ * cert.subject.dn; // "CN=example.com, O=Example"
213
+ * cert.validity.notAfter; // Date
214
+ * cert.signatureAlgorithm.name; // "sha256WithRSAEncryption"
215
+ */
216
+ var parse = pkix.makeParser({ pemLabel: "CERTIFICATE", PemError: PemError, ErrorClass: CertificateError, prefix: "x509", what: "certificate", topSchema: CERTIFICATE, ns: NS });
217
+
218
+ // matches(root): does the decoded DER look like a Certificate? A CSR and a CRL
219
+ // share the outer SEQUENCE-of-3 envelope, so the discriminator is inside the
220
+ // tbs: a certificate has a Validity — a SEQUENCE of exactly two Time values — at
221
+ // position [serial, signature, issuer, VALIDITY] after the optional [0] version.
222
+ // A CSR's certificationRequestInfo has subjectPublicKeyInfo there (no Validity),
223
+ // and a CRL leads with a bare Time; neither matches. Used by the orchestrator.
224
+ function matches(root) {
225
+ var TAGS = asn1.TAGS;
226
+ var tbs = pkix.signedEnvelopeTbs(root);
227
+ if (!tbs) return false;
228
+ var kids = tbs.children;
229
+ var i = (kids[0] && kids[0].tagClass === "context" && kids[0].tagNumber === 0) ? 1 : 0; // optional [0] version
230
+ var validity = kids[i + 3]; // serial(i), signature(i+1), issuer(i+2), validity(i+3)
231
+ return !!validity && validity.tagClass === "universal" && validity.tagNumber === TAGS.SEQUENCE &&
232
+ !!validity.children && validity.children.length === 2 &&
233
+ validity.children.every(function (t) {
234
+ return t.tagClass === "universal" && (t.tagNumber === TAGS.UTC_TIME || t.tagNumber === TAGS.GENERALIZED_TIME);
235
+ });
236
+ }
237
+
238
+ module.exports = {
239
+ parse: parse,
240
+ pemDecode: pemDecode,
241
+ pemEncode: pemEncode,
242
+ matches: matches,
243
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/pki",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Pure-JavaScript PKI toolkit that owns its stack — X.509, ASN.1/DER, CMS, PQC-first.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
package/sbom.cdx.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.5",
5
- "serialNumber": "urn:uuid:8ff5f15e-e999-4db6-bf14-09273902a9c2",
5
+ "serialNumber": "urn:uuid:64eeff87-502c-43f1-a398-5fbe7fc7a614",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-07-04T22:46:29.855Z",
8
+ "timestamp": "2026-07-05T06:26:48.420Z",
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.1.6",
22
+ "bom-ref": "@blamejs/pki@0.1.8",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.1.6",
25
+ "version": "0.1.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.1.6",
29
+ "purl": "pkg:npm/%40blamejs/pki@0.1.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.1.6",
57
+ "ref": "@blamejs/pki@0.1.8",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]