@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 +30 -0
- package/README.md +11 -8
- package/bin/pki.js +2 -1
- package/index.js +7 -3
- 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/schema-engine.js +346 -0
- package/lib/schema-pkix.js +239 -0
- package/lib/schema-x509.js +260 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
- package/lib/x509.js +0 -411
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (c) blamejs contributors
|
|
3
|
+
"use strict";
|
|
4
|
+
/**
|
|
5
|
+
* @module pki.schema.engine
|
|
6
|
+
* @nav Schema
|
|
7
|
+
* @title Schema engine
|
|
8
|
+
* @order 60
|
|
9
|
+
*
|
|
10
|
+
* @intro
|
|
11
|
+
* L2 of the ASN.1 stack — a declarative structure-schema engine. A schema is
|
|
12
|
+
* plain data (`{kind, ...}` descriptors built by the combinators here) and
|
|
13
|
+
* `walk(schema, node, ctx)` interprets it against a decoded DER node. The
|
|
14
|
+
* engine is where every cross-cutting structural rule lives ONCE: the shape
|
|
15
|
+
* assertion (SEQUENCE / SET / bare-constructed), bounds-checked positional
|
|
16
|
+
* reads, optional and context-tagged fields in strictly-increasing tag order,
|
|
17
|
+
* SET-OF uniqueness, and fail-closed typed errors. A format module declares a
|
|
18
|
+
* schema and calls `walk` under an error namespace `ctx = { E, prefix, oid }`;
|
|
19
|
+
* it never hand-rolls `children[idx++]`, so the positional-read and
|
|
20
|
+
* duplicate-field bug classes are structurally retired. This is the shared
|
|
21
|
+
* base the certificate parser (and, later, CRL / CMS) composes.
|
|
22
|
+
*
|
|
23
|
+
* @card
|
|
24
|
+
* Declarative ASN.1 structure schemas + one walk engine — the shared base the
|
|
25
|
+
* certificate / CRL / CMS parsers compose instead of hand-writing.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
var asn1 = require("./asn1-der.js");
|
|
29
|
+
|
|
30
|
+
var TAGS = asn1.TAGS;
|
|
31
|
+
|
|
32
|
+
// ---- context ---------------------------------------------------------
|
|
33
|
+
// A walk receives ctx = { E, prefix, oid } where E(code, message) constructs
|
|
34
|
+
// (does not throw) the format's typed error, prefix names the error family
|
|
35
|
+
// ("x509", "crl", ...) and oid is the OID registry (for name resolution in a
|
|
36
|
+
// build fn). _fail throws so a schema node reads as a single expression.
|
|
37
|
+
|
|
38
|
+
function _fail(ctx, code, message) {
|
|
39
|
+
throw ctx.E(code, message);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ---- shape assertions ------------------------------------------------
|
|
43
|
+
// A schema's `assert` mode is a real behaviour-preservation control: some
|
|
44
|
+
// hand-written guards checked the universal SEQUENCE tag (algorithmIdentifier,
|
|
45
|
+
// Name), others checked only that the node had children (Validity, SPKI, the
|
|
46
|
+
// tbs body, an AttributeTypeAndValue). Collapsing them to one "is a SEQUENCE"
|
|
47
|
+
// check would silently change behaviour on SET-wrapped input, so each is kept.
|
|
48
|
+
|
|
49
|
+
function _assertShape(schema, node, ctx) {
|
|
50
|
+
var mode = schema.assert || "sequence";
|
|
51
|
+
if (mode === "sequence") {
|
|
52
|
+
if (node.tagClass !== "universal" || node.tagNumber !== TAGS.SEQUENCE || !node.children) {
|
|
53
|
+
_fail(ctx, schema.code, (schema.what || "value") + " must be a SEQUENCE");
|
|
54
|
+
}
|
|
55
|
+
} else if (mode === "set") {
|
|
56
|
+
if (node.tagClass !== "universal" || node.tagNumber !== TAGS.SET || !node.children) {
|
|
57
|
+
_fail(ctx, schema.code, (schema.what || "value") + " must be a SET");
|
|
58
|
+
}
|
|
59
|
+
} else if (mode === "constructed") {
|
|
60
|
+
// Bare constructed: the historical guards only required decoded children,
|
|
61
|
+
// never a specific universal tag.
|
|
62
|
+
if (!node.children) {
|
|
63
|
+
_fail(ctx, schema.code, (schema.what || "value") + " must be a constructed value");
|
|
64
|
+
}
|
|
65
|
+
} else {
|
|
66
|
+
_fail(ctx, schema.code, "unknown assert mode " + JSON.stringify(mode));
|
|
67
|
+
}
|
|
68
|
+
return node.children;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function _assertArity(schema, kids, ctx) {
|
|
72
|
+
var a = schema.arity;
|
|
73
|
+
if (!a) return;
|
|
74
|
+
if (a.exact != null && kids.length !== a.exact) {
|
|
75
|
+
_fail(ctx, schema.code, (schema.what || "value") + " must have exactly " + a.exact + " elements");
|
|
76
|
+
}
|
|
77
|
+
if (a.min != null && kids.length < a.min) {
|
|
78
|
+
_fail(ctx, schema.code, (schema.what || "value") + " must have at least " + a.min + " elements");
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---- leaf / value combinators ---------------------------------------
|
|
83
|
+
// Leaves read a primitive off a node via the L1 codec; the codec's own
|
|
84
|
+
// asn1/* errors propagate unchanged (the frozen oracle expects those codes on
|
|
85
|
+
// the OID / INTEGER / string reads). `decode(fn)` hands the whole node to fn,
|
|
86
|
+
// which owns its try/catch and code mapping. `any()` yields the node itself.
|
|
87
|
+
|
|
88
|
+
function oidLeaf() { return { kind: "leaf", read: asn1.read.oid }; }
|
|
89
|
+
function integerLeaf() { return { kind: "leaf", read: asn1.read.integer }; }
|
|
90
|
+
function boolean() { return { kind: "leaf", read: asn1.read.boolean }; }
|
|
91
|
+
function octetString() { return { kind: "leaf", read: asn1.read.octetString }; }
|
|
92
|
+
function bitString() { return { kind: "leaf", read: function (n) { var b = asn1.read.bitString(n); return { unusedBits: b.unusedBits, bytes: b.bytes }; } }; }
|
|
93
|
+
function any() { return { kind: "any" }; }
|
|
94
|
+
function decode(fn) { return { kind: "decode", fn: fn }; }
|
|
95
|
+
|
|
96
|
+
// time(ns): a UTCTime / GeneralizedTime value, asserting the tag before the
|
|
97
|
+
// codec reads it (mirrors _parseValidityTime's x509/bad-time guard).
|
|
98
|
+
function time(ns) {
|
|
99
|
+
return decode(function (n, ctx) {
|
|
100
|
+
if (n.tagClass !== "universal" || (n.tagNumber !== TAGS.UTC_TIME && n.tagNumber !== TAGS.GENERALIZED_TIME)) {
|
|
101
|
+
_fail(ctx, ns.prefix + "/bad-time", "time must be UTCTime or GeneralizedTime");
|
|
102
|
+
}
|
|
103
|
+
return asn1.read.time(n);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---- field descriptors (members of a seq) ---------------------------
|
|
108
|
+
|
|
109
|
+
function field(name, schema) { return { fkind: "required", name: name, schema: schema }; }
|
|
110
|
+
function optional(name, schema, opts) {
|
|
111
|
+
opts = opts || {};
|
|
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).
|
|
121
|
+
var match = opts.whenAny
|
|
122
|
+
? function () { return true; }
|
|
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; };
|
|
126
|
+
return { fkind: "optional", name: name, schema: schema, tag: opts.tag, match: match,
|
|
127
|
+
explicit: !!opts.explicit, emptyCode: opts.emptyCode, hasDefault: ("default" in opts), def: opts.default };
|
|
128
|
+
}
|
|
129
|
+
// trailing: the [minTag..maxTag] optional context fields, each at most once in
|
|
130
|
+
// strictly-increasing tag order (the tbs issuerUniqueID[1]/subjectUniqueID[2]/
|
|
131
|
+
// extensions[3] block). members: [{ tag, name, schema, explicit? }].
|
|
132
|
+
function trailing(members, opts) {
|
|
133
|
+
opts = opts || {};
|
|
134
|
+
return { fkind: "trailing", members: members, minTag: opts.minTag, maxTag: opts.maxTag,
|
|
135
|
+
unexpectedCode: opts.unexpectedCode, orderCode: opts.orderCode };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---- structural combinators -----------------------------------------
|
|
139
|
+
|
|
140
|
+
function seq(fields, opts) {
|
|
141
|
+
opts = opts || {};
|
|
142
|
+
return { kind: "seq", fields: fields, assert: opts.assert || "sequence", arity: opts.arity,
|
|
143
|
+
code: opts.code, what: opts.what, build: opts.build, checks: opts.checks || [] };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// A NON-optional EXPLICIT context wrapper (CMS ContentInfo; the tbs [3] uses it
|
|
147
|
+
// inside `trailing`). Asserts context class + tag, constructed, >=1 child, then
|
|
148
|
+
// walks the inner schema on children[0].
|
|
149
|
+
function explicit(tag, schema, opts) {
|
|
150
|
+
opts = opts || {};
|
|
151
|
+
return { kind: "explicit", tag: tag, schema: schema, emptyCode: opts.emptyCode, code: opts.code, what: opts.what };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function choice(alts, opts) {
|
|
155
|
+
opts = opts || {};
|
|
156
|
+
return { kind: "choice", alts: alts, code: opts.code, what: opts.what };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function seqOf(item, opts) {
|
|
160
|
+
opts = opts || {};
|
|
161
|
+
return { kind: "repeat", item: item, assert: opts.assert || "sequence", code: opts.code, what: opts.what,
|
|
162
|
+
min: opts.min, unique: opts.unique, dupCode: opts.dupCode, build: opts.build };
|
|
163
|
+
}
|
|
164
|
+
function setOf(item, opts) {
|
|
165
|
+
opts = opts || {};
|
|
166
|
+
return { kind: "repeat", item: item, assert: opts.assert || "set", code: opts.code, what: opts.what,
|
|
167
|
+
min: opts.min, unique: opts.unique, dupCode: opts.dupCode, build: opts.build };
|
|
168
|
+
}
|
|
169
|
+
function setOfUnique(item, keyFn, opts) {
|
|
170
|
+
return setOf(item, Object.assign({ unique: keyFn }, opts || {}));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ---- the walk engine -------------------------------------------------
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* @primitive pki.schema.engine.walk
|
|
177
|
+
* @signature pki.schema.engine.walk(schema, node, ctx) -> value
|
|
178
|
+
* @since 0.1.7
|
|
179
|
+
* @status experimental
|
|
180
|
+
* @spec X.690, X.680
|
|
181
|
+
* @related pki.asn1.decode, pki.schema.x509.parse
|
|
182
|
+
*
|
|
183
|
+
* Interpret a declarative schema against a decoded DER node, enforcing the
|
|
184
|
+
* schema's structural rules (shape assertion, arity, optional / context-tagged
|
|
185
|
+
* fields in increasing tag order, SET-OF uniqueness) and returning the built
|
|
186
|
+
* value — or the match tree (`{ node, fields | items }`, with the build output
|
|
187
|
+
* on `.result`) for a structure with no build fn. `ctx = { E, prefix, oid }`
|
|
188
|
+
* supplies the typed-error constructor, the error-code family prefix, and the
|
|
189
|
+
* OID registry a build fn resolves names through.
|
|
190
|
+
*
|
|
191
|
+
* The schema is assembled from the combinators this module exports — structural
|
|
192
|
+
* (`seq` / `field` / `optional` / `explicit` / `trailing` / `seqOf` / `setOf` /
|
|
193
|
+
* `setOfUnique` / `choice`) and value (`oidLeaf` / `integerLeaf` / `boolean` /
|
|
194
|
+
* `octetString` / `bitString` / `any` / `decode` / `time`).
|
|
195
|
+
*
|
|
196
|
+
* @example
|
|
197
|
+
* var S = pki.schema.engine;
|
|
198
|
+
* var ALGID = S.seq([S.field("algorithm", S.oidLeaf())],
|
|
199
|
+
* { assert: "sequence", arity: { min: 1 }, code: "app/bad-alg" });
|
|
200
|
+
* S.walk(ALGID, pki.asn1.decode(der), { prefix: "app", E: MyError, oid: pki.oid });
|
|
201
|
+
*/
|
|
202
|
+
function walk(schema, node, ctx) {
|
|
203
|
+
switch (schema.kind) {
|
|
204
|
+
case "leaf": return schema.read(node);
|
|
205
|
+
case "any": return node;
|
|
206
|
+
case "decode": return schema.fn(node, ctx);
|
|
207
|
+
case "seq": return _walkSeq(schema, node, ctx);
|
|
208
|
+
case "explicit": return _walkExplicit(schema, node, ctx);
|
|
209
|
+
case "repeat": return _walkRepeat(schema, node, ctx);
|
|
210
|
+
case "choice": return _walkChoice(schema, node, ctx);
|
|
211
|
+
default: _fail(ctx, (ctx.prefix || "schema") + "/bad-schema", "unknown schema kind " + JSON.stringify(schema.kind));
|
|
212
|
+
}
|
|
213
|
+
return undefined;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// An EXPLICIT [tag] wrapper carries EXACTLY ONE inner value; 0 children is
|
|
217
|
+
// empty and 2+ would silently drop all but the first (the same drop-extra
|
|
218
|
+
// fail-open the seq guard closes). Assert exactly one, return it.
|
|
219
|
+
function _explicitInner(node, tag, ctx, code) {
|
|
220
|
+
if (!node.children || node.children.length !== 1) {
|
|
221
|
+
_fail(ctx, code, "EXPLICIT [" + tag + "] must wrap exactly one value");
|
|
222
|
+
}
|
|
223
|
+
return node.children[0];
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function _walkExplicit(schema, node, ctx) {
|
|
227
|
+
if (node.tagClass !== "context" || node.tagNumber !== schema.tag) {
|
|
228
|
+
_fail(ctx, schema.emptyCode || schema.code, "expected an EXPLICIT [" + schema.tag + "] wrapper");
|
|
229
|
+
}
|
|
230
|
+
return walk(schema.schema, _explicitInner(node, schema.tag, ctx, schema.emptyCode || schema.code), ctx);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function _walkChoice(schema, node, ctx) {
|
|
234
|
+
for (var i = 0; i < schema.alts.length; i++) {
|
|
235
|
+
var w = schema.alts[i].when;
|
|
236
|
+
if (node.tagClass === w.tagClass && node.tagNumber === w.tagNumber) {
|
|
237
|
+
return walk(schema.alts[i].schema, node, ctx);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
_fail(ctx, schema.code, "no CHOICE alternative matched " + node.tagClass + "/" + node.tagNumber);
|
|
241
|
+
return undefined;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function _walkRepeat(schema, node, ctx) {
|
|
245
|
+
var kids = _assertShape(schema, node, ctx);
|
|
246
|
+
if (schema.min != null && kids.length < schema.min) {
|
|
247
|
+
_fail(ctx, schema.code, (schema.what || "value") + " must contain at least " + schema.min + " element(s)");
|
|
248
|
+
}
|
|
249
|
+
var items = [];
|
|
250
|
+
var seen = schema.unique ? new Set() : null;
|
|
251
|
+
for (var i = 0; i < kids.length; i++) {
|
|
252
|
+
// The item wrapper is the SAME shape unique() and build() see: { node,
|
|
253
|
+
// value: <walk result> }. keyFn reads off item.value just like build does.
|
|
254
|
+
var item = { node: kids[i], value: walk(schema.item, kids[i], ctx) };
|
|
255
|
+
if (seen) {
|
|
256
|
+
var key = schema.unique(item);
|
|
257
|
+
if (seen.has(key)) _fail(ctx, schema.dupCode || schema.code, "duplicate element " + key);
|
|
258
|
+
seen.add(key);
|
|
259
|
+
}
|
|
260
|
+
items.push(item);
|
|
261
|
+
}
|
|
262
|
+
var match = { kind: "repeat", node: node, items: items };
|
|
263
|
+
if (schema.build) match.result = schema.build(match, ctx);
|
|
264
|
+
return match;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function _walkSeq(schema, node, ctx) {
|
|
268
|
+
var kids = _assertShape(schema, node, ctx);
|
|
269
|
+
_assertArity(schema, kids, ctx);
|
|
270
|
+
|
|
271
|
+
var fields = {};
|
|
272
|
+
var idx = 0;
|
|
273
|
+
for (var f = 0; f < schema.fields.length; f++) {
|
|
274
|
+
var fld = schema.fields[f];
|
|
275
|
+
if (fld.fkind === "required") {
|
|
276
|
+
if (idx >= kids.length) _fail(ctx, schema.code, "missing required field " + JSON.stringify(fld.name));
|
|
277
|
+
var child = kids[idx++];
|
|
278
|
+
fields[fld.name] = { node: child, value: walk(fld.schema, child, ctx) };
|
|
279
|
+
} else if (fld.fkind === "optional") {
|
|
280
|
+
var next = idx < kids.length ? kids[idx] : null;
|
|
281
|
+
if (next && fld.match(next)) {
|
|
282
|
+
idx++;
|
|
283
|
+
var inner = next;
|
|
284
|
+
if (fld.explicit) {
|
|
285
|
+
inner = _explicitInner(next, fld.tag, ctx, fld.emptyCode || schema.code);
|
|
286
|
+
}
|
|
287
|
+
fields[fld.name] = { node: next, present: true, value: walk(fld.schema, inner, ctx) };
|
|
288
|
+
} else {
|
|
289
|
+
fields[fld.name] = { present: false, value: fld.hasDefault ? fld.def : undefined };
|
|
290
|
+
}
|
|
291
|
+
} else if (fld.fkind === "trailing") {
|
|
292
|
+
_consumeTrailing(fld, kids, idx, fields, ctx);
|
|
293
|
+
idx = kids.length;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Every child must be consumed by a field. A leftover element (no trailing
|
|
298
|
+
// field ran to absorb it) is malformed — dropping it silently would let a
|
|
299
|
+
// closed sequence of optional fields accept a duplicate/extra element.
|
|
300
|
+
if (idx < kids.length) {
|
|
301
|
+
_fail(ctx, schema.code, (schema.what || "value") + " has an unexpected element after its last field");
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
var match = { kind: "seq", node: node, fields: fields };
|
|
305
|
+
for (var c = 0; c < schema.checks.length; c++) schema.checks[c](match, ctx);
|
|
306
|
+
if (schema.build) match.result = schema.build(match, ctx);
|
|
307
|
+
return match;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function _consumeTrailing(fld, kids, start, fields, ctx) {
|
|
311
|
+
var byTag = {};
|
|
312
|
+
for (var m = 0; m < fld.members.length; m++) byTag[fld.members[m].tag] = fld.members[m];
|
|
313
|
+
// The monotonic-order sentinel must start below the lowest accepted tag.
|
|
314
|
+
// Context tag numbers are non-negative, so -1 is below any member tag when
|
|
315
|
+
// minTag is absent — otherwise a trailing block whose first member is [0]
|
|
316
|
+
// would reject that field (0 <= last==0) as repeated/out-of-order.
|
|
317
|
+
var last = fld.minTag != null ? fld.minTag - 1 : -1;
|
|
318
|
+
for (var i = start; i < kids.length; i++) {
|
|
319
|
+
var t = kids[i];
|
|
320
|
+
if (t.tagClass !== "context" || (fld.minTag != null && t.tagNumber < fld.minTag) || (fld.maxTag != null && t.tagNumber > fld.maxTag) || !byTag[t.tagNumber]) {
|
|
321
|
+
_fail(ctx, fld.unexpectedCode, "unexpected trailing field [" + (t.tagClass === "context" ? t.tagNumber : t.tagClass) + "]");
|
|
322
|
+
}
|
|
323
|
+
if (t.tagNumber <= last) _fail(ctx, fld.orderCode, "trailing field [" + t.tagNumber + "] is repeated or out of order");
|
|
324
|
+
last = t.tagNumber;
|
|
325
|
+
var member = byTag[t.tagNumber];
|
|
326
|
+
var inner = t;
|
|
327
|
+
if (member.explicit) {
|
|
328
|
+
inner = _explicitInner(t, t.tagNumber, ctx, member.emptyCode);
|
|
329
|
+
}
|
|
330
|
+
fields[member.name] = { node: t, present: true, value: walk(member.schema, inner, ctx) };
|
|
331
|
+
}
|
|
332
|
+
for (var n = 0; n < fld.members.length; n++) {
|
|
333
|
+
if (!fields[fld.members[n].name]) fields[fld.members[n].name] = { present: false, value: undefined };
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
module.exports = {
|
|
338
|
+
// structural
|
|
339
|
+
seq: seq, field: field, optional: optional, explicit: explicit, trailing: trailing,
|
|
340
|
+
seqOf: seqOf, setOf: setOf, setOfUnique: setOfUnique, choice: choice,
|
|
341
|
+
// leaves
|
|
342
|
+
oidLeaf: oidLeaf, integerLeaf: integerLeaf, boolean: boolean, octetString: octetString,
|
|
343
|
+
bitString: bitString, any: any, decode: decode, time: time,
|
|
344
|
+
// engine
|
|
345
|
+
walk: walk,
|
|
346
|
+
};
|
|
@@ -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
|
+
|