@blamejs/pki 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,36 @@ 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.1.7 — 2026-07-05
8
+
9
+ A unified pki.schema family: the structure-schema engine, the X.509 parser, a new CRL parser, and a detect-and-route orchestrator.
10
+
11
+ ### Added
12
+
13
+ - pki.schema.crl.parse — an X.509 CRL (CertificateList) parser per RFC 5280 §5. It turns a DER Buffer or an 'X509 CRL' PEM string into a structured object: version, issuer distinguished name, thisUpdate / nextUpdate as real Dates, the ordered list of revoked certificates (serial number + hex + revocation date + entry extensions), and the CRL extensions — with the cRLNumber, reasonCode, and invalidityDate values decoded and the raw tbsCertList bytes returned for signature verification. It composes the same schema engine and shared PKIX sub-schemas (AlgorithmIdentifier, Name, Extension) as the certificate parser, so the CertificateList inherits the identical fail-closed structural rules (bounds-checked positional reads, the signature-algorithm agreement, non-empty issuer, extension uniqueness, the v2-only version rule).
14
+ - pki.schema.parse — a detect-and-route entry point: hand it DER or PEM and it identifies which registered PKI format the bytes encode (certificate vs CRL) and routes to that member's parser. pki.schema.all() enumerates the registered formats.
15
+
16
+ ### Changed
17
+
18
+ - The schema engine and the per-format parsers are reorganized under one pki.schema namespace. pki.x509.parse is now pki.schema.x509.parse (and .pemDecode / .pemEncode likewise), and the structure-schema engine pki.asn1.schema is now pki.schema.engine. pki.asn1 remains the strict DER codec (decode / encode / build / read / TAGS). This is a breaking rename with no compatibility shim; see MIGRATING. The schema engine also gained a universal-tag optional-field recognizer, which the CRL's bare version / nextUpdate / revokedCertificates fields require.
19
+
20
+ ### Migration
21
+
22
+ - Replace pki.x509.parse(...) with pki.schema.x509.parse(...); pki.x509.pemDecode / pemEncode become pki.schema.x509.pemDecode / pemEncode.
23
+ - Replace pki.asn1.schema (the structure-schema engine) with pki.schema.engine. pki.asn1 is unchanged for the DER codec (pki.asn1.decode / encode / build / read / TAGS).
24
+
25
+ ## v0.1.6 — 2026-07-04
26
+
27
+ A declarative ASN.1 structure-schema engine; the X.509 parser is rebuilt on it.
28
+
29
+ ### Added
30
+
31
+ - pki.asn1.schema — a declarative ASN.1 structure-schema engine. A schema is plain data built from combinators (seq / field / optional / explicit / trailing / seqOf / setOf / setOfUnique / choice, plus the value leaves oidLeaf / integerLeaf / boolean / octetString / bitString / any / decode / time); pki.asn1.schema.walk(schema, node, ctx) interprets it against a decoded DER node under an error namespace, enforcing the structural rules — shape assertion, bounds-checked positional reads, optional / context-tagged fields in strictly increasing tag order, SET-OF uniqueness, and fail-closed typed errors — in one place. This is the shared base the certificate parser is built on and the forthcoming CRL / CMS parsers compose, so a new format is declared as data rather than hand-written.
32
+
33
+ ### Changed
34
+
35
+ - pki.x509.parse is now built on the schema engine: the Certificate, tbsCertificate, and every sub-structure (AlgorithmIdentifier, Name, Validity, SubjectPublicKeyInfo, Extensions) are declared as schemas and walked. Every valid certificate parses to the same result as before, and every malformed certificate is still rejected — the full existing test suite passes unchanged. The certificate's structural rules (positional bounds, the trailing-field grammar, extension uniqueness, the signature-algorithm agreement) now live in one auditable place instead of a hand-written decoder, and the format is structurally incapable of the positional-read and duplicate-field bug classes. The parser now validates the full certificate structure before applying cross-field checks, so a certificate carrying more than one defect at once may be rejected with a different (still fail-closed) error than a prior release reported.
36
+
7
37
  ## v0.1.5 — 2026-07-04
8
38
 
9
39
  Container healthcheck honors WIKI_PORT; release-tooling supply-chain hardening.
package/README.md CHANGED
@@ -63,7 +63,7 @@ var pki = require("@blamejs/pki");
63
63
 
64
64
  ### Parse an X.509 certificate
65
65
 
66
- `pki.x509.parse` accepts a DER `Buffer` or a PEM string/Buffer and returns a
66
+ `pki.schema.x509.parse` accepts a DER `Buffer` or a PEM string/Buffer and returns a
67
67
  fully-decoded, validated certificate — distinguished names rendered and
68
68
  structured, the validity window as real `Date`s, algorithms and extensions
69
69
  named through the OID registry, and the exact signed `tbsBytes` for a downstream
@@ -74,7 +74,7 @@ var pki = require("@blamejs/pki");
74
74
  var fs = require("node:fs");
75
75
 
76
76
  var pem = fs.readFileSync("cert.pem", "utf8");
77
- var cert = pki.x509.parse(pem);
77
+ var cert = pki.schema.x509.parse(pem);
78
78
 
79
79
  cert.subject.dn; // "CN=example.com, O=Example Org, C=US"
80
80
  cert.issuer.dn; // "CN=example.com, O=Example Org, C=US"
@@ -94,7 +94,7 @@ Malformed bytes throw a typed error rather than returning a half-parsed object:
94
94
 
95
95
  ```js
96
96
  try {
97
- pki.x509.parse(Buffer.from([0x30, 0x03, 0x02, 0x01, 0x00]));
97
+ pki.schema.x509.parse(Buffer.from([0x30, 0x03, 0x02, 0x01, 0x00]));
98
98
  } catch (e) {
99
99
  e.constructor.name; // "CertificateError"
100
100
  e.code; // e.g. "x509/not-a-certificate" — stable domain/reason string
@@ -104,8 +104,8 @@ try {
104
104
  ### Convert PEM ↔ DER
105
105
 
106
106
  ```js
107
- var der = pki.x509.pemDecode(pem, "CERTIFICATE"); // Buffer of DER bytes
108
- var out = pki.x509.pemEncode(der, "CERTIFICATE"); // 64-column PEM string
107
+ var der = pki.schema.x509.pemDecode(pem, "CERTIFICATE"); // Buffer of DER bytes
108
+ var out = pki.schema.x509.pemEncode(der, "CERTIFICATE"); // 64-column PEM string
109
109
  ```
110
110
 
111
111
  ### Decode and build ASN.1 / DER directly
@@ -180,7 +180,7 @@ var ok = await subtle.verify({ name: "ML-DSA-65" }, kp.publicKey, sig, data); /
180
180
  // same call shape, and every key it exports is OpenSSL/NSS-interoperable.
181
181
  ```
182
182
 
183
- ## What ships today (v0.1.0)
183
+ ## What ships today
184
184
 
185
185
  The core codec and certificate-reading surface are here and stable. Everything
186
186
  is callable today; nothing below is a stub.
@@ -190,9 +190,12 @@ is callable today; nothing below is a stub.
190
190
  | `pki.asn1` | Strict, bounded DER codec — `decode` (zero-copy node tree), `encode`, `build.*` canonical-DER value builders, `read.*` typed readers, `TAGS`, OID-content encode/decode |
191
191
  | `pki.oid` | Two-way OID ↔ name registry — `name`, `byName`, `register`, `toArcs`/`fromArcs`, `toDER`/`fromDER`; seeded with RFC 5280 + NIST PQC arcs |
192
192
  | `pki.webcrypto` | A W3C WebCrypto (`SubtleCrypto`) engine over `node:crypto` — `sign`/`verify`/`encrypt`/`decrypt`/`deriveBits`/`digest`/`generateKey`/`importKey`/`exportKey` across RSA, ECDSA, ECDH, Ed25519/Ed448, AES, HMAC, HKDF, PBKDF2, SHA — **and** post-quantum ML-DSA-44/65/87 and SLH-DSA signatures, plus ML-KEM-512/768/1024 key generation (KEM encapsulation on the roadmap). Zero-dependency, OpenSSL-interoperable |
193
- | `pki.x509` | Parse DER / PEM certificates into structured, validated fields `parse`, `pemDecode`, `pemEncode` |
193
+ | `pki.schema` | The schema family — `parse` detects which PKI format DER / PEM encodes and routes to the right parser, `all` enumerates the registered formats, and the engine + per-format members are grouped here |
194
+ | `pki.schema.x509` | Parse DER / PEM certificates into structured, validated fields — `parse`, `pemDecode`, `pemEncode` |
195
+ | `pki.schema.crl` | Parse DER / PEM X.509 CRLs per RFC 5280 §5 — revoked serials with real-`Date` revocation times, named + partly-decoded extensions, fail-closed — `parse`, `pemDecode` |
196
+ | `pki.schema.engine` | The declarative ASN.1 structure-schema engine every format parser composes — `walk` plus the schema combinators |
194
197
  | `pki.C` / `pki.constants` | Version-stable constants — functional scale helpers (`C.TIME.*`, `C.BYTES.*`), codec `LIMITS`, `version` |
195
- | `pki.errors` | The `PkiError` taxonomy — `defineClass` plus `ConstantsError` / `Asn1Error` / `OidError` / `PemError` / `CertificateError`, each carrying a stable `code` in `domain/reason` form |
198
+ | `pki.errors` | The `PkiError` taxonomy — `defineClass` plus `ConstantsError` / `Asn1Error` / `OidError` / `PemError` / `CertificateError` / `CrlError`, each carrying a stable `code` in `domain/reason` form |
196
199
  | `pki` CLI | `pki version`, `pki oid <dotted\|name>`, `pki parse <cert>` |
197
200
 
198
201
  ### CLI
package/bin/pki.js CHANGED
@@ -44,7 +44,7 @@ function cmdParse(file) {
44
44
  var bytes;
45
45
  try { bytes = fs.readFileSync(file); } catch (e) { return fail("cannot read " + file + ": " + e.message); }
46
46
  var cert;
47
- try { cert = pki.x509.parse(bytes); } catch (e) { return fail(e.code + ": " + e.message); }
47
+ try { cert = pki.schema.x509.parse(bytes); } catch (e) { return fail(e.code + ": " + e.message); }
48
48
  var view = {
49
49
  version: cert.version,
50
50
  serialNumber: cert.serialNumberHex,
@@ -74,3 +74,4 @@ function main(argv) {
74
74
  }
75
75
 
76
76
  main(process.argv.slice(2));
77
+
package/index.js CHANGED
@@ -13,7 +13,8 @@
13
13
  * (PkiError taxonomy), asn1 (strict DER codec), oid
14
14
  * (OID ↔ name registry), webcrypto (W3C SubtleCrypto
15
15
  * engine over node:crypto — PQC-first, classical-capable)
16
- * Certificates: x509 (parse DER / PEM certificates)
16
+ * Schema: schema (the structure-schema engine + per-format parsers:
17
+ * schema.engine, schema.x509, schema.parse detect-and-route)
17
18
  *
18
19
  * The surface grows per ROADMAP.md — CMS, OCSP, CRL, CSR, PKCS#8/#12,
19
20
  * timestamping, path validation, and the post-quantum algorithm set are
@@ -27,7 +28,7 @@ var errors = require("./lib/framework-error");
27
28
  var asn1 = require("./lib/asn1-der");
28
29
  var oid = require("./lib/oid");
29
30
  var webcrypto = require("./lib/webcrypto");
30
- var x509 = require("./lib/x509");
31
+ var schema = require("./lib/schema-all");
31
32
 
32
33
  module.exports = {
33
34
  version: constants.version,
@@ -35,9 +36,12 @@ module.exports = {
35
36
  C: constants,
36
37
  constants: constants,
37
38
  errors: errors,
39
+ // `asn1` is the strict DER codec (decode/encode/build/read/TAGS).
38
40
  asn1: asn1,
39
41
  oid: oid,
40
- x509: x509,
42
+ // `schema` is the family: the L2 structure-schema engine (schema.engine) and
43
+ // the per-format parsers (schema.x509, …) with detect-and-route schema.parse.
44
+ schema: schema,
41
45
  // A ready W3C Crypto instance (globalThis.crypto shape) + the classes
42
46
  // for constructing more. PQC-first, classical-capable, zero-dep.
43
47
  webcrypto: webcrypto.webcrypto,
@@ -121,6 +121,14 @@ var PemError = defineClass("PemError");
121
121
  // version).
122
122
  var CertificateError = defineClass("CertificateError", { withCause: true });
123
123
 
124
+ // CrlError — a byte sequence that is not a well-formed X.509 CRL
125
+ // (CertificateList / TBSCertList — RFC 5280 §5).
126
+ var CrlError = defineClass("CrlError", { withCause: true });
127
+
128
+ // SchemaError — the schema-family orchestrator's own errors (input that matches
129
+ // no registered format / does not decode). (code, message) shape like the rest.
130
+ var SchemaError = defineClass("SchemaError", { withCause: true });
131
+
124
132
  module.exports = {
125
133
  PkiError: PkiError,
126
134
  defineClass: defineClass,
@@ -129,4 +137,6 @@ module.exports = {
129
137
  OidError: OidError,
130
138
  PemError: PemError,
131
139
  CertificateError: CertificateError,
140
+ CrlError: CrlError,
141
+ SchemaError: SchemaError,
132
142
  };
package/lib/oid.js CHANGED
@@ -54,8 +54,13 @@ var FAMILIES = {
54
54
  // RFC 5280 certificate extensions.
55
55
  certExtension: { base: [2, 5, 29], of: {
56
56
  subjectKeyIdentifier: 14, keyUsage: 15, subjectAltName: 17, issuerAltName: 18,
57
- basicConstraints: 19, nameConstraints: 30, cRLDistributionPoints: 31,
58
- certificatePolicies: 32, authorityKeyIdentifier: 35, extKeyUsage: 37 } },
57
+ basicConstraints: 19,
58
+ // CRL + CRL-entry extensions (RFC 5280 §5.2, §5.3)
59
+ cRLNumber: 20, reasonCode: 21, invalidityDate: 24, deltaCRLIndicator: 27,
60
+ issuingDistributionPoint: 28, certificateIssuer: 29,
61
+ nameConstraints: 30, cRLDistributionPoints: 31,
62
+ certificatePolicies: 32, authorityKeyIdentifier: 35, extKeyUsage: 37,
63
+ freshestCRL: 46 } },
59
64
 
60
65
  // PKIX private extensions (authorityInfoAccess et al).
61
66
  pkixAccess: { base: [1, 3, 6, 1, 5, 5, 7, 1], of: { authorityInfoAccess: 1 } },
@@ -0,0 +1,126 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (c) blamejs contributors
3
+ "use strict";
4
+ /**
5
+ * @module pki.schema
6
+ * @nav Schema
7
+ * @title Schema
8
+ * @order 10
9
+ * @featured true
10
+ * @slug schema
11
+ *
12
+ * @intro
13
+ * The schema family: a declarative ASN.1 structure-schema engine and the
14
+ * per-format parsers built on it. Every format (X.509 certificates, CRLs,
15
+ * and — as they land — CMS / CSR / PKCS#8) is a member that COMPOSES the
16
+ * shared engine and the shared PKIX sub-schemas (AlgorithmIdentifier, Name,
17
+ * Extension), so a structural rule — bounds-checked positional reads,
18
+ * optional / tagged field ordering, SET-OF uniqueness, fail-closed typed
19
+ * errors — is defined once in the engine and no format can reintroduce the
20
+ * class of bug it prevents.
21
+ *
22
+ * `parse` is the orchestrator: hand it DER (or PEM) and it detects which
23
+ * format the bytes encode and routes to that member's parser. Each member is
24
+ * also reachable directly (`pki.schema.x509.parse`, `pki.schema.crl.parse`),
25
+ * and `all()` enumerates the registered formats.
26
+ *
27
+ * @card
28
+ * One declarative schema engine; every PKI format (X.509, CRL, …) is a
29
+ * member composed on it. Detect-and-parse DER, or call a format directly.
30
+ */
31
+
32
+ var engine = require("./schema-engine");
33
+ var pkix = require("./schema-pkix");
34
+ var x509 = require("./schema-x509");
35
+ var crl = require("./schema-crl");
36
+ var frameworkError = require("./framework-error");
37
+
38
+ var SchemaError = frameworkError.SchemaError;
39
+ var PemError = frameworkError.PemError;
40
+
41
+ // The shared parse-entry opts for the orchestrator: label-agnostic PEM unwrap
42
+ // (any block type routes), the schema/* error family. Detection needs DER, so
43
+ // the coerced DER is what gets routed to the matched format.
44
+ var ENTRY = { pemLabel: null, PemError: PemError, ErrorClass: SchemaError, prefix: "schema", what: "input" };
45
+
46
+ // REGISTRY — each format is declarative data: a name, a `detect(root)` that
47
+ // recognizes the decoded DER root as this format's outer structure, and the
48
+ // member's own `parse`. Adding a format is a table entry, not new dispatch
49
+ // logic. Order matters: the most specific detector wins, so a new member is
50
+ // inserted ahead of any more-permissive one.
51
+ var FORMATS = [
52
+ {
53
+ // CertificateList ::= SEQUENCE { tbsCertList, signatureAlgorithm,
54
+ // signatureValue } — the same outer shape as a certificate, distinguished
55
+ // by its tbsCertList (a bare Time at the certificate's Validity position).
56
+ // Checked first because its detector is the specific one.
57
+ name: "crl",
58
+ module: crl,
59
+ detect: crl.matches,
60
+ parse: function (input) { return crl.parse(input); },
61
+ },
62
+ {
63
+ name: "x509",
64
+ module: x509,
65
+ // Certificate — identified by a Validity (SEQUENCE of two Times) inside the
66
+ // tbs, so a CSR / other 3-element signed envelope is NOT misclassified as a
67
+ // certificate (it falls through to schema/unknown-format).
68
+ detect: x509.matches,
69
+ parse: function (input) { return x509.parse(input); },
70
+ },
71
+ ];
72
+
73
+ /**
74
+ * @primitive pki.schema.all
75
+ * @signature pki.schema.all() -> string[]
76
+ * @since 0.1.7
77
+ * @status experimental
78
+ * @spec RFC 5280
79
+ * @related pki.schema.parse
80
+ *
81
+ * The names of every registered format, in detection order.
82
+ *
83
+ * @example
84
+ * pki.schema.all(); // → ["crl", "x509"]
85
+ */
86
+ function all() { return FORMATS.map(function (f) { return f.name; }); }
87
+
88
+ /**
89
+ * @primitive pki.schema.parse
90
+ * @signature pki.schema.parse(input) -> parsed
91
+ * @since 0.1.7
92
+ * @status experimental
93
+ * @spec RFC 5280
94
+ * @related pki.schema.x509, pki.schema.all
95
+ *
96
+ * Detect which PKI format `input` (a DER `Buffer` or a PEM string) encodes and
97
+ * route to that format's parser, returning the same structured object the
98
+ * format's own `parse` returns. Throws `SchemaError("schema/unknown-format")` when
99
+ * the bytes match no registered format; the underlying decode / structural
100
+ * errors of the matched format propagate unchanged.
101
+ *
102
+ * @example
103
+ * var parsed = pki.schema.parse(der); // cert → the pki.schema.x509 shape
104
+ */
105
+ function parse(input) {
106
+ // Coerce + decode via the shared parse-entry, then route the COERCED DER (a
107
+ // PEM string/Buffer is already unwrapped, so the matched format parses DER
108
+ // directly and never re-treats the armor as DER).
109
+ var der = pkix.coerceToDer(input, ENTRY);
110
+ var root = pkix.decodeRoot(der, ENTRY);
111
+ for (var i = 0; i < FORMATS.length; i++) {
112
+ if (FORMATS[i].detect(root)) return FORMATS[i].parse(der);
113
+ }
114
+ throw new SchemaError("schema/unknown-format", "input does not match any registered PKI format (" + all().join(", ") + ")");
115
+ }
116
+
117
+ // Curated public surface: each format exposes only its operator primitives. The
118
+ // `matches` detector is internal dispatch infrastructure (used by FORMATS above),
119
+ // not an operator API, so it is NOT re-exported here.
120
+ module.exports = {
121
+ engine: engine,
122
+ x509: { parse: x509.parse, pemDecode: x509.pemDecode, pemEncode: x509.pemEncode },
123
+ crl: { parse: crl.parse, pemDecode: crl.pemDecode },
124
+ all: all,
125
+ parse: parse,
126
+ };
@@ -0,0 +1,259 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (c) blamejs contributors
3
+ "use strict";
4
+ /**
5
+ * @module pki.schema.crl
6
+ * @nav Schema
7
+ * @title CRL
8
+ * @order 120
9
+ * @slug crl
10
+ *
11
+ * @intro
12
+ * X.509 Certificate Revocation List handling per RFC 5280 §5. `parse` turns a
13
+ * DER or PEM CRL into a structured, fully-decoded object: version, issuer
14
+ * distinguished name, this/next update as real `Date`s, the ordered list of
15
+ * revoked certificates (serial + revocation date + entry extensions), and the
16
+ * CRL extensions. It composes the same schema engine and shared PKIX
17
+ * sub-schemas (AlgorithmIdentifier, Name, Extension) the certificate parser
18
+ * uses, so the CertificateList inherits the identical fail-closed structural
19
+ * rules, and the raw `tbsCertList` bytes are returned for signature checking.
20
+ *
21
+ * @card
22
+ * Parse DER / PEM X.509 CRLs into structured, validated fields — revoked
23
+ * serials with real-`Date` revocation times, named extensions, fail-closed.
24
+ */
25
+
26
+ var asn1 = require("./asn1-der");
27
+ var schema = require("./schema-engine");
28
+ var pkix = require("./schema-pkix");
29
+ var oid = require("./oid");
30
+ var frameworkError = require("./framework-error");
31
+
32
+ var CrlError = frameworkError.CrlError;
33
+ var PemError = frameworkError.PemError;
34
+ var TAGS = asn1.TAGS;
35
+
36
+
37
+ // CRLReason ::= ENUMERATED (RFC 5280 §5.3.1) — value 7 is unused/reserved.
38
+ var CRL_REASONS = [0n, 1n, 2n, 3n, 4n, 5n, 6n, 8n, 9n, 10n];
39
+
40
+ // Extension-value decoding is keyed off the STABLE dotted OID (resolved once at
41
+ // load from the canonical name), not the mutable display name — a caller's
42
+ // pki.oid.register() display override must not change parse behaviour.
43
+ var OID_CRL_NUMBER = oid.byName("cRLNumber");
44
+ var OID_REASON_CODE = oid.byName("reasonCode");
45
+ var OID_INVALIDITY_DATE = oid.byName("invalidityDate");
46
+
47
+ // The crl error namespace the schema engine walks under. Shared PKIX sub-schemas
48
+ // are instantiated here under crl/* so a structural fault reports a crl code.
49
+ var NS = { prefix: "crl", E: function (code, message, cause) { return new CrlError(code, message, cause); }, oid: oid };
50
+
51
+ var ALGORITHM_IDENTIFIER = pkix.algorithmIdentifier(NS);
52
+ var NAME = pkix.name(NS);
53
+ var EXTENSIONS = pkix.extensions(NS);
54
+ var TIME = schema.time(NS);
55
+
56
+ // CRL Version ::= INTEGER { v1(0), v2(1) } — a BARE INTEGER (not [0] EXPLICIT).
57
+ // A CRL is at most v2; reject an explicit v1 (DER forbids the default) and any
58
+ // value >= 2. Do NOT reuse the certificate readVersion (it maps 2 -> v3).
59
+ var CRL_VERSION = schema.decode(function (n) {
60
+ var v = asn1.read.integer(n);
61
+ if (v === 1n) return 2;
62
+ if (v === 0n) throw NS.E("crl/bad-version", "DER forbids explicitly encoding the default version v1");
63
+ throw NS.E("crl/bad-version", "unsupported CRL version " + v.toString() + " (a CRL is at most v2)");
64
+ });
65
+
66
+ // The three cheap, high-value CRL extension values are decoded from their raw
67
+ // extnValue octets (RFC 5280 §5.2/§5.3); GeneralNames-based extensions
68
+ // (issuingDistributionPoint, certificateIssuer, authorityKeyIdentifier, …) stay
69
+ // raw with their bytes reachable. A malformed decoded value fails closed.
70
+ function decodeExt(ext) {
71
+ var value = ext.value;
72
+ try {
73
+ if (ext.oid === OID_CRL_NUMBER) { // cRLNumber ::= INTEGER (0..MAX)
74
+ value = asn1.read.integer(asn1.decode(ext.value));
75
+ if (value < 0n) throw new Error("cRLNumber must be non-negative (INTEGER 0..MAX)");
76
+ } else if (ext.oid === OID_REASON_CODE) { // reasonCode ::= ENUMERATED (CRLReason)
77
+ var rn = asn1.decode(ext.value);
78
+ if (rn.tagClass !== "universal" || rn.tagNumber !== TAGS.ENUMERATED) {
79
+ throw new Error("reasonCode must be an ENUMERATED");
80
+ }
81
+ var reason = asn1.read.integer(rn);
82
+ if (CRL_REASONS.indexOf(reason) === -1) throw new Error("undefined CRLReason " + reason.toString());
83
+ value = Number(reason);
84
+ } else if (ext.oid === OID_INVALIDITY_DATE) { // invalidityDate ::= GeneralizedTime
85
+ var n = asn1.decode(ext.value);
86
+ if (n.tagClass !== "universal" || n.tagNumber !== TAGS.GENERALIZED_TIME) {
87
+ throw new Error("invalidityDate must be a GeneralizedTime");
88
+ }
89
+ value = asn1.read.time(n);
90
+ }
91
+ } catch (e) {
92
+ throw NS.E("crl/bad-extension-value", "malformed " + (ext.name || ext.oid) + " extension value: " + ((e && e.message) || String(e)), e);
93
+ }
94
+ return { oid: ext.oid, name: ext.name, critical: ext.critical, value: value };
95
+ }
96
+
97
+ // RevokedCertificate ::= SEQUENCE { userCertificate CertificateSerialNumber,
98
+ // revocationDate Time, crlEntryExtensions Extensions OPTIONAL }
99
+ var REVOKED_ENTRY = schema.seq([
100
+ schema.field("userCertificate", schema.integerLeaf()),
101
+ schema.field("revocationDate", TIME),
102
+ schema.optional("crlEntryExtensions", EXTENSIONS, { whenUniversal: [TAGS.SEQUENCE] }),
103
+ ], {
104
+ assert: "sequence", arity: { min: 2 }, code: "crl/bad-revoked-entry", what: "RevokedCertificate",
105
+ build: function (m) {
106
+ return {
107
+ serialNumber: m.fields.userCertificate.value,
108
+ // Raw INTEGER content bytes (not a reserialized BigInt) so the hex matches
109
+ // the X.509 parser's serialNumberHex and preserves DER sign padding
110
+ // (a positive 0x80 serial encodes as content 00 80).
111
+ serialNumberHex: m.fields.userCertificate.node.content.toString("hex"),
112
+ revocationDate: m.fields.revocationDate.value,
113
+ crlEntryExtensions: m.fields.crlEntryExtensions.present ? m.fields.crlEntryExtensions.value.result.map(decodeExt) : [],
114
+ };
115
+ },
116
+ });
117
+
118
+ var REVOKED_LIST = schema.seqOf(REVOKED_ENTRY, {
119
+ assert: "sequence", min: 1, code: "crl/bad-revoked-certificates", what: "revokedCertificates",
120
+ build: function (m) { return m.items.map(function (it) { return it.value.result; }); },
121
+ });
122
+
123
+ // TBSCertList ::= SEQUENCE { version Version OPTIONAL, signature AlgorithmIdentifier,
124
+ // issuer Name, thisUpdate Time, nextUpdate Time OPTIONAL,
125
+ // revokedCertificates SEQUENCE OF SEQUENCE {...} OPTIONAL,
126
+ // crlExtensions [0] EXPLICIT Extensions OPTIONAL }.
127
+ // The three OPTIONAL universal-tagged fields (version=INTEGER, nextUpdate=Time,
128
+ // revokedCertificates=SEQUENCE) are disambiguated by their universal tag;
129
+ // crlExtensions is modeled as a trailing [0]..[0] so a stray non-[0] trailing
130
+ // context tag is REJECTED (crl/bad-tbs), not silently ignored.
131
+ var TBS_CERTLIST = schema.seq([
132
+ schema.optional("version", CRL_VERSION, { whenUniversal: [TAGS.INTEGER] }),
133
+ schema.field("signature", ALGORITHM_IDENTIFIER),
134
+ schema.field("issuer", NAME),
135
+ schema.field("thisUpdate", TIME),
136
+ schema.optional("nextUpdate", TIME, { whenUniversal: [TAGS.UTC_TIME, TAGS.GENERALIZED_TIME] }),
137
+ schema.optional("revokedCertificates", REVOKED_LIST, { whenUniversal: [TAGS.SEQUENCE] }),
138
+ schema.trailing([{ tag: 0, name: "crlExtensions", schema: EXTENSIONS, explicit: true, emptyCode: "crl/bad-extensions" }],
139
+ { minTag: 0, maxTag: 0, unexpectedCode: "crl/bad-tbs", orderCode: "crl/bad-tbs" }),
140
+ ], {
141
+ assert: "sequence", code: "crl/bad-tbs", what: "tbsCertList",
142
+ build: function (m) {
143
+ return {
144
+ version: m.fields.version.present ? m.fields.version.value : 1,
145
+ issuer: m.fields.issuer.value.result, // Name is a seqOf → field.value is the match; .result is the {rdns, dn} build
146
+ thisUpdate: m.fields.thisUpdate.value,
147
+ nextUpdate: m.fields.nextUpdate.present ? m.fields.nextUpdate.value : null,
148
+ revokedCertificates: m.fields.revokedCertificates.present ? m.fields.revokedCertificates.value.result : [],
149
+ crlExtensions: m.fields.crlExtensions.present ? m.fields.crlExtensions.value.result.map(decodeExt) : [],
150
+ crlExtensionsPresent: m.fields.crlExtensions.present,
151
+ };
152
+ },
153
+ });
154
+
155
+ // CertificateList ::= SEQUENCE { tbsCertList, signatureAlgorithm, signatureValue }
156
+ var CERTIFICATE_LIST = schema.seq([
157
+ schema.field("tbsCertList", TBS_CERTLIST),
158
+ schema.field("signatureAlgorithm", ALGORITHM_IDENTIFIER),
159
+ schema.field("signatureValue", schema.bitString()),
160
+ ], {
161
+ assert: "sequence", arity: { exact: 3 }, code: "crl/not-a-crl", what: "CertificateList",
162
+ build: function (m) {
163
+ var tbsMatch = m.fields.tbsCertList.value;
164
+ var tbs = tbsMatch.result;
165
+ // RFC 5280 §5.1.1.2 — the outer signatureAlgorithm MUST equal tbsCertList.signature.
166
+ if (!m.fields.signatureAlgorithm.node.bytes.equals(tbsMatch.fields.signature.node.bytes)) {
167
+ throw NS.E("crl/bad-signature-algorithm", "signatureAlgorithm must match tbsCertList.signature (RFC 5280 §5.1.1.2)");
168
+ }
169
+ // RFC 5280 §5.1.2.3 — the issuer MUST be a non-empty distinguished name.
170
+ if (!tbs.issuer.rdns.length) {
171
+ throw NS.E("crl/bad-issuer", "issuer must be a non-empty distinguished name");
172
+ }
173
+ // RFC 5280 §5.1.2.1 — crlExtensions / crlEntryExtensions appear only in a v2 CRL.
174
+ var hasExtensions = tbs.crlExtensionsPresent ||
175
+ tbs.revokedCertificates.some(function (r) { return r.crlEntryExtensions.length > 0; });
176
+ if (hasExtensions && tbs.version !== 2) {
177
+ throw NS.E("crl/bad-version", "crlExtensions / crlEntryExtensions are only permitted in a v2 CRL");
178
+ }
179
+ return {
180
+ version: tbs.version,
181
+ issuer: tbs.issuer,
182
+ thisUpdate: tbs.thisUpdate,
183
+ nextUpdate: tbs.nextUpdate,
184
+ revokedCertificates: tbs.revokedCertificates,
185
+ crlExtensions: tbs.crlExtensions,
186
+ tbsBytes: tbsMatch.node.bytes,
187
+ signatureAlgorithm: m.fields.signatureAlgorithm.value.result,
188
+ signatureValue: { unusedBits: m.fields.signatureValue.value.unusedBits, bytes: m.fields.signatureValue.value.bytes },
189
+ };
190
+ },
191
+ });
192
+
193
+ /**
194
+ * @primitive pki.schema.crl.parse
195
+ * @signature pki.schema.crl.parse(input) -> crl
196
+ * @since 0.1.7
197
+ * @status experimental
198
+ * @spec RFC 5280
199
+ * @related pki.schema.x509.parse, pki.schema.parse
200
+ *
201
+ * Parse a DER `Buffer` or a PEM (`X509 CRL`) string into a structured CRL:
202
+ * `{ version, issuer, thisUpdate, nextUpdate, revokedCertificates,
203
+ * crlExtensions, tbsBytes, signatureAlgorithm, signatureValue }`. Every field is
204
+ * validated on the way in; a malformed CertificateList / TBSCertList throws a
205
+ * typed `CrlError` (`crl/*`) and a leaf-level codec fault surfaces as `asn1/*`.
206
+ *
207
+ * @example
208
+ * var crl = pki.schema.crl.parse(der);
209
+ * crl.revokedCertificates[0].serialNumberHex; // → "0a3f…"
210
+ */
211
+ function parse(input) {
212
+ return pkix.runParse(input, { pemLabel: "X509 CRL", PemError: PemError, ErrorClass: CrlError, prefix: "crl", what: "CRL", topSchema: CERTIFICATE_LIST, ns: NS });
213
+ }
214
+
215
+ /**
216
+ * @primitive pki.schema.crl.pemDecode
217
+ * @signature pki.schema.crl.pemDecode(text, label?) -> Buffer
218
+ * @since 0.1.7
219
+ * @status experimental
220
+ * @spec RFC 7468, RFC 5280
221
+ * @related pki.schema.crl.parse
222
+ *
223
+ * Extract the DER bytes from a PEM CRL block (default label `X509 CRL`). Throws
224
+ * `PemError` on a missing / mismatched envelope or a non-base64 body.
225
+ *
226
+ * @example
227
+ * var der = pki.schema.crl.pemDecode(pemText);
228
+ */
229
+ function pemDecode(text, label) { return pkix.pemDecode(text, label || "X509 CRL", PemError); }
230
+
231
+ // Certificate and CertificateList share the outer SEQUENCE-of-3 shape; a CRL is
232
+ // distinguished by its tbsCertList — the first tbs element is a bare INTEGER
233
+ // (version) or an AlgorithmIdentifier (signature) SEQUENCE, and crucially the
234
+ // field at the certificate's Validity position is a bare Time (thisUpdate). The
235
+ // orchestrator uses `matches` to route; a cert's tbs leads with a [0] EXPLICIT
236
+ // version or an INTEGER serial FOLLOWED by an AlgorithmIdentifier and a Name,
237
+ // then a Validity SEQUENCE, never a bare Time at that depth.
238
+ function matches(root) {
239
+ if (!root || root.tagClass !== "universal" || root.tagNumber !== TAGS.SEQUENCE) return false;
240
+ if (!root.children || root.children.length !== 3) return false;
241
+ var tbs = root.children[0];
242
+ if (!tbs.children || tbs.tagNumber !== TAGS.SEQUENCE) return false;
243
+ // A certificate's tbs leads with [0] EXPLICIT version — a CRL never does.
244
+ if (tbs.children[0] && tbs.children[0].tagClass === "context") return false;
245
+ // Walk to the thisUpdate / validity position: skip an optional bare INTEGER
246
+ // version, then signature (SEQUENCE) + issuer (SEQUENCE); the next element is
247
+ // thisUpdate (Time) for a CRL, Validity (SEQUENCE) for a certificate.
248
+ var i = 0;
249
+ if (tbs.children[i] && tbs.children[i].tagClass === "universal" && tbs.children[i].tagNumber === TAGS.INTEGER) i++;
250
+ i += 2; // signature + issuer
251
+ var pos = tbs.children[i];
252
+ return !!pos && pos.tagClass === "universal" && (pos.tagNumber === TAGS.UTC_TIME || pos.tagNumber === TAGS.GENERALIZED_TIME);
253
+ }
254
+
255
+ module.exports = {
256
+ parse: parse,
257
+ pemDecode: pemDecode,
258
+ matches: matches,
259
+ };