@blamejs/pki 0.1.6 → 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 +18 -0
- package/README.md +11 -8
- package/bin/pki.js +2 -1
- package/index.js +8 -7
- package/lib/framework-error.js +10 -0
- package/lib/oid.js +7 -2
- package/lib/schema-all.js +126 -0
- package/lib/schema-crl.js +259 -0
- package/lib/{asn1-schema.js → schema-engine.js} +19 -13
- package/lib/schema-pkix.js +239 -0
- package/lib/{x509.js → schema-x509.js} +49 -182
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,24 @@ 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
|
+
|
|
7
25
|
## v0.1.6 — 2026-07-04
|
|
8
26
|
|
|
9
27
|
A declarative ASN.1 structure-schema engine; the X.509 parser is rebuilt on it.
|
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
|
|
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.
|
|
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
|
-
*
|
|
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
|
|
@@ -25,10 +26,9 @@
|
|
|
25
26
|
var constants = require("./lib/constants");
|
|
26
27
|
var errors = require("./lib/framework-error");
|
|
27
28
|
var asn1 = require("./lib/asn1-der");
|
|
28
|
-
var asn1Schema = require("./lib/asn1-schema");
|
|
29
29
|
var oid = require("./lib/oid");
|
|
30
30
|
var webcrypto = require("./lib/webcrypto");
|
|
31
|
-
var
|
|
31
|
+
var schema = require("./lib/schema-all");
|
|
32
32
|
|
|
33
33
|
module.exports = {
|
|
34
34
|
version: constants.version,
|
|
@@ -36,11 +36,12 @@ module.exports = {
|
|
|
36
36
|
C: constants,
|
|
37
37
|
constants: constants,
|
|
38
38
|
errors: errors,
|
|
39
|
-
// `asn1
|
|
40
|
-
|
|
41
|
-
asn1: Object.assign({}, asn1, { schema: asn1Schema }),
|
|
39
|
+
// `asn1` is the strict DER codec (decode/encode/build/read/TAGS).
|
|
40
|
+
asn1: asn1,
|
|
42
41
|
oid: oid,
|
|
43
|
-
|
|
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,
|
|
44
45
|
// A ready W3C Crypto instance (globalThis.crypto shape) + the classes
|
|
45
46
|
// for constructing more. PQC-first, classical-capable, zero-dep.
|
|
46
47
|
webcrypto: webcrypto.webcrypto,
|
package/lib/framework-error.js
CHANGED
|
@@ -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,
|
|
58
|
-
|
|
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
|
+
};
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
// Copyright (c) blamejs contributors
|
|
3
3
|
"use strict";
|
|
4
4
|
/**
|
|
5
|
-
* @module pki.
|
|
6
|
-
* @nav
|
|
5
|
+
* @module pki.schema.engine
|
|
6
|
+
* @nav Schema
|
|
7
7
|
* @title Schema engine
|
|
8
8
|
* @order 60
|
|
9
9
|
*
|
|
@@ -109,14 +109,20 @@ function time(ns) {
|
|
|
109
109
|
function field(name, schema) { return { fkind: "required", name: name, schema: schema }; }
|
|
110
110
|
function optional(name, schema, opts) {
|
|
111
111
|
opts = opts || {};
|
|
112
|
-
// How the optional field is recognized at its position
|
|
113
|
-
// [tag] (the certificate version [0] shape).
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
112
|
+
// How the optional field is recognized at its position:
|
|
113
|
+
// - default: a context [tag] (the certificate version [0] shape).
|
|
114
|
+
// - whenUniversal: the next element iff its UNIVERSAL tag is in the set —
|
|
115
|
+
// the CRL TBSCertList shape (bare INTEGER version, Time nextUpdate,
|
|
116
|
+
// SEQUENCE revokedCertificates), disambiguated by tag, not a context [n].
|
|
117
|
+
// - whenAny: the next element whatever its tag — an OPTIONAL ANY like
|
|
118
|
+
// AlgorithmIdentifier.parameters.
|
|
119
|
+
// The recognizer lets _walkSeq CONSUME the element so a closed sequence can
|
|
120
|
+
// reject whatever is left over (without it, a trailing ANY looks unconsumed).
|
|
117
121
|
var match = opts.whenAny
|
|
118
122
|
? function () { return true; }
|
|
119
|
-
:
|
|
123
|
+
: opts.whenUniversal
|
|
124
|
+
? function (n) { return n.tagClass === "universal" && opts.whenUniversal.indexOf(n.tagNumber) !== -1; }
|
|
125
|
+
: function (n) { return n.tagClass === "context" && n.tagNumber === opts.tag; };
|
|
120
126
|
return { fkind: "optional", name: name, schema: schema, tag: opts.tag, match: match,
|
|
121
127
|
explicit: !!opts.explicit, emptyCode: opts.emptyCode, hasDefault: ("default" in opts), def: opts.default };
|
|
122
128
|
}
|
|
@@ -167,12 +173,12 @@ function setOfUnique(item, keyFn, opts) {
|
|
|
167
173
|
// ---- the walk engine -------------------------------------------------
|
|
168
174
|
|
|
169
175
|
/**
|
|
170
|
-
* @primitive pki.
|
|
171
|
-
* @signature pki.
|
|
172
|
-
* @since 0.1.
|
|
176
|
+
* @primitive pki.schema.engine.walk
|
|
177
|
+
* @signature pki.schema.engine.walk(schema, node, ctx) -> value
|
|
178
|
+
* @since 0.1.7
|
|
173
179
|
* @status experimental
|
|
174
180
|
* @spec X.690, X.680
|
|
175
|
-
* @related pki.asn1.decode, pki.x509.parse
|
|
181
|
+
* @related pki.asn1.decode, pki.schema.x509.parse
|
|
176
182
|
*
|
|
177
183
|
* Interpret a declarative schema against a decoded DER node, enforcing the
|
|
178
184
|
* schema's structural rules (shape assertion, arity, optional / context-tagged
|
|
@@ -188,7 +194,7 @@ function setOfUnique(item, keyFn, opts) {
|
|
|
188
194
|
* `octetString` / `bitString` / `any` / `decode` / `time`).
|
|
189
195
|
*
|
|
190
196
|
* @example
|
|
191
|
-
* var S = pki.
|
|
197
|
+
* var S = pki.schema.engine;
|
|
192
198
|
* var ALGID = S.seq([S.field("algorithm", S.oidLeaf())],
|
|
193
199
|
* { assert: "sequence", arity: { min: 1 }, code: "app/bad-alg" });
|
|
194
200
|
* S.walk(ALGID, pki.asn1.decode(der), { prefix: "app", E: MyError, oid: pki.oid });
|
|
@@ -0,0 +1,239 @@
|
|
|
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
|
+
module.exports = {
|
|
225
|
+
pemDecode: pemDecode,
|
|
226
|
+
pemEncode: pemEncode,
|
|
227
|
+
coerceToDer: coerceToDer,
|
|
228
|
+
decodeRoot: decodeRoot,
|
|
229
|
+
runParse: runParse,
|
|
230
|
+
DN_SHORT: DN_SHORT,
|
|
231
|
+
algorithmIdentifier: algorithmIdentifier,
|
|
232
|
+
attrValueToString: attrValueToString,
|
|
233
|
+
attributeTypeAndValue: attributeTypeAndValue,
|
|
234
|
+
relativeDistinguishedName: relativeDistinguishedName,
|
|
235
|
+
name: name,
|
|
236
|
+
extension: extension,
|
|
237
|
+
extensions: extensions,
|
|
238
|
+
};
|
|
239
|
+
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
// Copyright (c) blamejs contributors
|
|
3
3
|
"use strict";
|
|
4
4
|
/**
|
|
5
|
-
* @module pki.x509
|
|
6
|
-
* @nav
|
|
5
|
+
* @module pki.schema.x509
|
|
6
|
+
* @nav Schema
|
|
7
7
|
* @title X.509
|
|
8
8
|
* @order 100
|
|
9
9
|
* @featured true
|
|
@@ -29,42 +29,24 @@
|
|
|
29
29
|
* with named algorithms, extensions, and real-`Date` validity windows.
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
|
-
var constants = require("./constants");
|
|
33
32
|
var asn1 = require("./asn1-der");
|
|
34
|
-
var schema = require("./
|
|
33
|
+
var schema = require("./schema-engine");
|
|
35
34
|
var oid = require("./oid");
|
|
36
35
|
var frameworkError = require("./framework-error");
|
|
36
|
+
var pkix = require("./schema-pkix");
|
|
37
37
|
|
|
38
38
|
var CertificateError = frameworkError.CertificateError;
|
|
39
39
|
var PemError = frameworkError.PemError;
|
|
40
40
|
|
|
41
|
-
// Distinguished-name attribute short labels (RFC 4514 §3 + common use).
|
|
42
|
-
var DN_SHORT = {
|
|
43
|
-
commonName: "CN",
|
|
44
|
-
countryName: "C",
|
|
45
|
-
localityName: "L",
|
|
46
|
-
stateOrProvinceName: "ST",
|
|
47
|
-
streetAddress: "STREET",
|
|
48
|
-
organizationName: "O",
|
|
49
|
-
organizationalUnitName: "OU",
|
|
50
|
-
domainComponent: "DC",
|
|
51
|
-
surname: "SN",
|
|
52
|
-
givenName: "GN",
|
|
53
|
-
serialNumber: "SERIALNUMBER",
|
|
54
|
-
emailAddress: "emailAddress",
|
|
55
|
-
};
|
|
56
|
-
|
|
57
41
|
// ---- PEM -------------------------------------------------------------
|
|
58
42
|
|
|
59
|
-
var PEM_RE = /-----BEGIN ([A-Z0-9 ]+)-----([\s\S]*?)-----END \1-----/;
|
|
60
|
-
|
|
61
43
|
/**
|
|
62
|
-
* @primitive pki.x509.pemDecode
|
|
63
|
-
* @signature pki.x509.pemDecode(text, label?) -> Buffer
|
|
64
|
-
* @since 0.1.
|
|
44
|
+
* @primitive pki.schema.x509.pemDecode
|
|
45
|
+
* @signature pki.schema.x509.pemDecode(text, label?) -> Buffer
|
|
46
|
+
* @since 0.1.7
|
|
65
47
|
* @status stable
|
|
66
48
|
* @spec RFC 7468, RFC 5280
|
|
67
|
-
* @related pki.x509.pemEncode
|
|
49
|
+
* @related pki.schema.x509.pemEncode
|
|
68
50
|
*
|
|
69
51
|
* Extract the DER bytes from a PEM block. With `label` given (e.g.
|
|
70
52
|
* `"CERTIFICATE"`) the block type must match; without it, the first block
|
|
@@ -72,133 +54,36 @@ var PEM_RE = /-----BEGIN ([A-Z0-9 ]+)-----([\s\S]*?)-----END \1-----/;
|
|
|
72
54
|
* non-base64 body.
|
|
73
55
|
*
|
|
74
56
|
* @example
|
|
75
|
-
* var der = pki.x509.pemDecode(pemText, "CERTIFICATE");
|
|
57
|
+
* var der = pki.schema.x509.pemDecode(pemText, "CERTIFICATE");
|
|
76
58
|
*/
|
|
77
|
-
function pemDecode(text, label) {
|
|
78
|
-
if (Buffer.isBuffer(text)) text = text.toString("latin1");
|
|
79
|
-
if (typeof text !== "string") throw new PemError("pem/bad-input", "pemDecode expects a string or Buffer");
|
|
80
|
-
if (text.length > constants.LIMITS.PEM_MAX_BYTES) throw new PemError("pem/too-large", "PEM input exceeds size cap");
|
|
81
|
-
var m = PEM_RE.exec(text);
|
|
82
|
-
if (!m) throw new PemError("pem/no-block", "no PEM block found");
|
|
83
|
-
if (label && m[1] !== label) throw new PemError("pem/label-mismatch", "expected " + JSON.stringify(label) + " block, got " + JSON.stringify(m[1]));
|
|
84
|
-
var b64 = m[2].replace(/[\r\n\t ]+/g, "");
|
|
85
|
-
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(b64)) throw new PemError("pem/bad-base64", "PEM body is not valid base64");
|
|
86
|
-
return Buffer.from(b64, "base64");
|
|
87
|
-
}
|
|
59
|
+
function pemDecode(text, label) { return pkix.pemDecode(text, label, PemError); }
|
|
88
60
|
|
|
89
61
|
/**
|
|
90
|
-
* @primitive pki.x509.pemEncode
|
|
91
|
-
* @signature pki.x509.pemEncode(der, label) -> string
|
|
92
|
-
* @since 0.1.
|
|
62
|
+
* @primitive pki.schema.x509.pemEncode
|
|
63
|
+
* @signature pki.schema.x509.pemEncode(der, label) -> string
|
|
64
|
+
* @since 0.1.7
|
|
93
65
|
* @status stable
|
|
94
66
|
* @spec RFC 7468, RFC 5280
|
|
95
|
-
* @related pki.x509.pemDecode
|
|
67
|
+
* @related pki.schema.x509.pemDecode
|
|
96
68
|
*
|
|
97
69
|
* Wrap DER bytes in a PEM envelope with 64-column base64 lines.
|
|
98
70
|
*
|
|
99
71
|
* @example
|
|
100
|
-
* var pem = pki.x509.pemEncode(der, "CERTIFICATE");
|
|
72
|
+
* var pem = pki.schema.x509.pemEncode(der, "CERTIFICATE");
|
|
101
73
|
*/
|
|
102
|
-
function pemEncode(der, label) {
|
|
103
|
-
if (typeof label !== "string" || label.length === 0) throw new PemError("pem/bad-label", "pemEncode requires a label");
|
|
104
|
-
var buf = Buffer.isBuffer(der) ? der : Buffer.from(der);
|
|
105
|
-
var b64 = buf.toString("base64").replace(/(.{64})/g, "$1\n").replace(/\n$/, "");
|
|
106
|
-
return "-----BEGIN " + label + "-----\n" + b64 + "\n-----END " + label + "-----\n";
|
|
107
|
-
}
|
|
74
|
+
function pemEncode(der, label) { return pkix.pemEncode(der, label, PemError); }
|
|
108
75
|
|
|
109
76
|
// ---- helpers ---------------------------------------------------------
|
|
110
77
|
|
|
111
78
|
// The x509 error namespace the schema engine walks under: prefix names the
|
|
112
79
|
// error family and E constructs the typed CertificateError. The shared PKIX
|
|
113
|
-
// sub-schemas
|
|
114
|
-
//
|
|
115
|
-
//
|
|
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.
|
|
116
83
|
var NS = { prefix: "x509", E: function (code, message) { return new CertificateError(code, message); }, oid: oid };
|
|
117
84
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
return schema.seq([
|
|
121
|
-
schema.field("algorithm", schema.oidLeaf()),
|
|
122
|
-
schema.optional("parameters", schema.any(), { whenAny: true }),
|
|
123
|
-
], {
|
|
124
|
-
assert: "sequence", arity: { min: 1 }, code: ns.prefix + "/bad-algorithm-identifier", what: "AlgorithmIdentifier",
|
|
125
|
-
build: function (m, ctx) {
|
|
126
|
-
var dotted = m.fields.algorithm.value;
|
|
127
|
-
return { oid: dotted, name: ctx.oid.name(dotted) || null, parameters: m.fields.parameters.present ? m.fields.parameters.node.bytes : null };
|
|
128
|
-
},
|
|
129
|
-
});
|
|
130
|
-
}
|
|
131
|
-
var ALGORITHM_IDENTIFIER = algorithmIdentifier(NS);
|
|
132
|
-
|
|
133
|
-
function _algId(node) { return schema.walk(ALGORITHM_IDENTIFIER, node, NS).result; }
|
|
134
|
-
|
|
135
|
-
// attrValueToString(ns): the AttributeValue decode-leaf. A malformed KNOWN
|
|
136
|
-
// string type (invalid UTF-8, a non-IA5 byte, a PrintableString character
|
|
137
|
-
// outside its set, ...) surfaces as an asn1/bad-* content error and must fail
|
|
138
|
-
// the certificate closed — do NOT hex-encode it away, or the decoder's strict
|
|
139
|
-
// string validation is silently bypassed on the DN path. A value that is simply
|
|
140
|
-
// not a decodable primitive string is NOT malformed and stays representable: an
|
|
141
|
-
// ANY-typed non-string tag (asn1/expected-string) or a constructed universal
|
|
142
|
-
// type such as a SEQUENCE (asn1/expected-primitive) renders per RFC 4514 §2.4 as
|
|
143
|
-
// "#" plus the hex of its FULL DER encoding (node.bytes), round-tripping intact.
|
|
144
|
-
function attrValueToString(ns) {
|
|
145
|
-
return schema.decode(function (node) {
|
|
146
|
-
try { return asn1.read.string(node); }
|
|
147
|
-
catch (e) {
|
|
148
|
-
if (!e || (e.code !== "asn1/expected-string" && e.code !== "asn1/expected-primitive")) {
|
|
149
|
-
throw ns.E(ns.prefix + "/bad-atv", "malformed string in attribute value: " + ((e && e.message) || String(e)));
|
|
150
|
-
}
|
|
151
|
-
return "#" + node.bytes.toString("hex");
|
|
152
|
-
}
|
|
153
|
-
});
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
function _escapeDnValue(v) {
|
|
157
|
-
return v.replace(/([,+"\\<>;])/g, "\\$1");
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// Name ::= RDNSequence ::= SEQUENCE OF RelativeDistinguishedName; RDN ::= SET OF
|
|
161
|
-
// AttributeTypeAndValue ::= SEQUENCE { type OID, value ANY }. The atv asserts
|
|
162
|
-
// bare-constructed (min 2) — matching the historical guard that never checked
|
|
163
|
-
// the SEQUENCE tag — and repeated RDN attribute types stay legal (no uniqueness).
|
|
164
|
-
function attributeTypeAndValue(ns) {
|
|
165
|
-
return schema.seq([
|
|
166
|
-
schema.field("type", schema.oidLeaf()),
|
|
167
|
-
schema.field("value", attrValueToString(ns)),
|
|
168
|
-
], {
|
|
169
|
-
assert: "constructed", arity: { min: 2 }, code: ns.prefix + "/bad-atv", what: "AttributeTypeAndValue",
|
|
170
|
-
build: function (m, ctx) {
|
|
171
|
-
var typeOid = m.fields.type.value;
|
|
172
|
-
return { type: typeOid, name: ctx.oid.name(typeOid) || null, value: m.fields.value.value };
|
|
173
|
-
},
|
|
174
|
-
});
|
|
175
|
-
}
|
|
176
|
-
function relativeDistinguishedName(ns) {
|
|
177
|
-
return schema.setOf(attributeTypeAndValue(ns), { assert: "set", code: ns.prefix + "/bad-rdn", what: "RelativeDistinguishedName" });
|
|
178
|
-
}
|
|
179
|
-
function name(ns) {
|
|
180
|
-
return schema.seqOf(relativeDistinguishedName(ns), {
|
|
181
|
-
assert: "sequence", code: ns.prefix + "/bad-name", what: "Name",
|
|
182
|
-
build: function (m) {
|
|
183
|
-
var rdns = [], parts = [];
|
|
184
|
-
m.items.forEach(function (rdnItem) {
|
|
185
|
-
var atvs = [], atvParts = [];
|
|
186
|
-
rdnItem.value.items.forEach(function (atvItem) {
|
|
187
|
-
var a = atvItem.value.result; // atvItem.value = the atv seq-match; .value = its build result
|
|
188
|
-
atvs.push(a);
|
|
189
|
-
var label = (a.name && DN_SHORT[a.name]) || a.name || a.type;
|
|
190
|
-
atvParts.push(label + "=" + _escapeDnValue(a.value));
|
|
191
|
-
});
|
|
192
|
-
rdns.push(atvs);
|
|
193
|
-
parts.push(atvParts.join("+"));
|
|
194
|
-
});
|
|
195
|
-
return { rdns: rdns, dn: parts.join(", ") };
|
|
196
|
-
},
|
|
197
|
-
});
|
|
198
|
-
}
|
|
199
|
-
var NAME = name(NS);
|
|
200
|
-
|
|
201
|
-
function _parseName(node) { return schema.walk(NAME, node, NS).result; }
|
|
85
|
+
var ALGORITHM_IDENTIFIER = pkix.algorithmIdentifier(NS);
|
|
86
|
+
var NAME = pkix.name(NS);
|
|
202
87
|
|
|
203
88
|
// Validity ::= SEQUENCE { notBefore Time, notAfter Time }. The historical guard
|
|
204
89
|
// checked only children.length===2 (not the SEQUENCE tag) -> assert "constructed".
|
|
@@ -225,33 +110,7 @@ var SPKI = schema.seq([
|
|
|
225
110
|
},
|
|
226
111
|
});
|
|
227
112
|
|
|
228
|
-
|
|
229
|
-
// extnValue OCTET STRING }. `critical` is a universal BOOLEAN present-by-count
|
|
230
|
-
// (not context-tagged), so the per-extension decode handles the 2-vs-3-child
|
|
231
|
-
// shape directly; the seqOf centralizes the SEQUENCE / SIZE(1..MAX) assertion
|
|
232
|
-
// and the RFC 5280 §4.2 per-OID uniqueness.
|
|
233
|
-
function extension(ns) {
|
|
234
|
-
return schema.decode(function (ext) {
|
|
235
|
-
if (!ext.children || ext.children.length < 2) {
|
|
236
|
-
throw ns.E(ns.prefix + "/bad-extension", "Extension must be a SEQUENCE");
|
|
237
|
-
}
|
|
238
|
-
var extnID = asn1.read.oid(ext.children[0]);
|
|
239
|
-
var critical = false, valueNode;
|
|
240
|
-
if (ext.children.length === 3) { critical = asn1.read.boolean(ext.children[1]); valueNode = ext.children[2]; }
|
|
241
|
-
else { valueNode = ext.children[1]; }
|
|
242
|
-
return { oid: extnID, name: ns.oid.name(extnID) || null, critical: critical, value: asn1.read.octetString(valueNode) };
|
|
243
|
-
});
|
|
244
|
-
}
|
|
245
|
-
function extensions(ns) {
|
|
246
|
-
return schema.seqOf(extension(ns), {
|
|
247
|
-
assert: "sequence", min: 1, code: ns.prefix + "/bad-extensions", what: "Extensions",
|
|
248
|
-
unique: function (item) { return item.value.oid; }, dupCode: ns.prefix + "/duplicate-extension",
|
|
249
|
-
build: function (m) { return m.items.map(function (it) { return it.value; }); },
|
|
250
|
-
});
|
|
251
|
-
}
|
|
252
|
-
var EXTENSIONS = extensions(NS);
|
|
253
|
-
|
|
254
|
-
function _parseExtensions(seqNode) { return schema.walk(EXTENSIONS, seqNode, NS).result; }
|
|
113
|
+
var EXTENSIONS = pkix.extensions(NS);
|
|
255
114
|
|
|
256
115
|
// Version ::= INTEGER { v1(0), v2(1), v3(2) }, [0] EXPLICIT DEFAULT v1. Read as a
|
|
257
116
|
// BigInt so an out-of-range value can't coerce to a float; reject an explicitly-
|
|
@@ -342,9 +201,9 @@ var CERTIFICATE = schema.seq([
|
|
|
342
201
|
// ---- parse -----------------------------------------------------------
|
|
343
202
|
|
|
344
203
|
/**
|
|
345
|
-
* @primitive pki.x509.parse
|
|
346
|
-
* @signature pki.x509.parse(input) -> certificate
|
|
347
|
-
* @since 0.1.
|
|
204
|
+
* @primitive pki.schema.x509.parse
|
|
205
|
+
* @signature pki.schema.x509.parse(input) -> certificate
|
|
206
|
+
* @since 0.1.7
|
|
348
207
|
* @status stable
|
|
349
208
|
* @spec RFC 5280, X.509
|
|
350
209
|
* @defends malformed-certificate-parse (CWE-20)
|
|
@@ -362,32 +221,40 @@ var CERTIFICATE = schema.seq([
|
|
|
362
221
|
* certificate and `Asn1Error` when the underlying DER is malformed.
|
|
363
222
|
*
|
|
364
223
|
* @example
|
|
365
|
-
* var cert = pki.x509.parse(pemString);
|
|
224
|
+
* var cert = pki.schema.x509.parse(pemString);
|
|
366
225
|
* cert.subject.dn; // "CN=example.com, O=Example"
|
|
367
226
|
* cert.validity.notAfter; // Date
|
|
368
227
|
* cert.signatureAlgorithm.name; // "sha256WithRSAEncryption"
|
|
369
228
|
*/
|
|
370
229
|
function parse(input) {
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
der = pemDecode(input, "CERTIFICATE");
|
|
374
|
-
} else if (Buffer.isBuffer(input) || input instanceof Uint8Array) {
|
|
375
|
-
der = Buffer.isBuffer(input) ? input : Buffer.from(input);
|
|
376
|
-
} else {
|
|
377
|
-
throw new CertificateError("x509/bad-input", "parse expects a DER Buffer or a PEM string");
|
|
378
|
-
}
|
|
230
|
+
return pkix.runParse(input, { pemLabel: "CERTIFICATE", PemError: PemError, ErrorClass: CertificateError, prefix: "x509", what: "certificate", topSchema: CERTIFICATE, ns: NS });
|
|
231
|
+
}
|
|
379
232
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
233
|
+
// matches(root): does the decoded DER look like a Certificate? A CSR and a CRL
|
|
234
|
+
// share the outer SEQUENCE-of-3 envelope, so the discriminator is inside the
|
|
235
|
+
// tbs: a certificate has a Validity — a SEQUENCE of exactly two Time values — at
|
|
236
|
+
// position [serial, signature, issuer, VALIDITY] after the optional [0] version.
|
|
237
|
+
// A CSR's certificationRequestInfo has subjectPublicKeyInfo there (no Validity),
|
|
238
|
+
// and a CRL leads with a bare Time; neither matches. Used by the orchestrator.
|
|
239
|
+
function matches(root) {
|
|
240
|
+
var TAGS = asn1.TAGS;
|
|
241
|
+
if (!root || root.tagClass !== "universal" || root.tagNumber !== TAGS.SEQUENCE) return false;
|
|
242
|
+
if (!root.children || root.children.length !== 3) return false;
|
|
243
|
+
var tbs = root.children[0];
|
|
244
|
+
if (!tbs.children || tbs.tagClass !== "universal" || tbs.tagNumber !== TAGS.SEQUENCE) return false;
|
|
245
|
+
var kids = tbs.children;
|
|
246
|
+
var i = (kids[0] && kids[0].tagClass === "context" && kids[0].tagNumber === 0) ? 1 : 0; // optional [0] version
|
|
247
|
+
var validity = kids[i + 3]; // serial(i), signature(i+1), issuer(i+2), validity(i+3)
|
|
248
|
+
return !!validity && validity.tagClass === "universal" && validity.tagNumber === TAGS.SEQUENCE &&
|
|
249
|
+
!!validity.children && validity.children.length === 2 &&
|
|
250
|
+
validity.children.every(function (t) {
|
|
251
|
+
return t.tagClass === "universal" && (t.tagNumber === TAGS.UTC_TIME || t.tagNumber === TAGS.GENERALIZED_TIME);
|
|
252
|
+
});
|
|
387
253
|
}
|
|
388
254
|
|
|
389
255
|
module.exports = {
|
|
390
256
|
parse: parse,
|
|
391
257
|
pemDecode: pemDecode,
|
|
392
258
|
pemEncode: pemEncode,
|
|
259
|
+
matches: matches,
|
|
393
260
|
};
|
package/package.json
CHANGED
package/sbom.cdx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.5",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:a50e8ea1-a357-4e08-b473-56bcf5e2659f",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-07-
|
|
8
|
+
"timestamp": "2026-07-05T03:52:05.348Z",
|
|
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.
|
|
22
|
+
"bom-ref": "@blamejs/pki@0.1.7",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "pki",
|
|
25
|
-
"version": "0.1.
|
|
25
|
+
"version": "0.1.7",
|
|
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.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/pki@0.1.7",
|
|
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.
|
|
57
|
+
"ref": "@blamejs/pki@0.1.7",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|