@blamejs/pki 0.1.32 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/trust.js ADDED
@@ -0,0 +1,707 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (c) blamejs contributors
3
+ "use strict";
4
+ /**
5
+ * @module pki.trust
6
+ * @nav Path validation
7
+ * @title Trust-store ingestion
8
+ * @order 230
9
+ * @slug trust
10
+ *
11
+ * @intro
12
+ * Mozilla / CCADB root-program ingestion into constraint-carrying trust
13
+ * anchors. The bare root list (`tls.rootCertificates`) throws away exactly
14
+ * the metadata that decides WHICH roots may vouch for WHAT: the per-purpose
15
+ * trust bits (a root trusted for TLS is not thereby trusted for S/MIME) and
16
+ * the per-purpose distrust-after dates (a sunsetting root keeps validating
17
+ * already-issued certificates while certificates issued after the cutoff are
18
+ * rejected). `parseCertdata` reads the NSS `certdata.txt` object stream and
19
+ * `parseCcadbCsv` the CCADB CSV export into one identical `Anchor` shape, so
20
+ * enforcement downstream is source-agnostic; `anchor()` hands an entry to
21
+ * `pki.path.validate({ trustAnchor, checkPurpose })`.
22
+ *
23
+ * Everything is fail-closed and offline: the caller supplies the text (no
24
+ * network fetch); every malformed or oversized input throws a typed
25
+ * `trust/*` error before the offending allocation; a certificate object and
26
+ * its trust object are paired by byte-exact (CKA_ISSUER, CKA_SERIAL_NUMBER)
27
+ * -- never by adjacency -- and cross-checked against the parsed DER, so
28
+ * trust metadata can never attach to the wrong root; only
29
+ * `CKT_NSS_TRUSTED_DELEGATOR` grants a purpose (everything else, including
30
+ * an absent bit, is untrusted).
31
+ *
32
+ * @card
33
+ * `parseCertdata` / `parseCcadbCsv` -> constraint-carrying trust anchors
34
+ * (per-purpose trust bits + distrust-after dates); `anchor()` feeds
35
+ * `pki.path.validate`. Fail-closed, offline, source-agnostic.
36
+ */
37
+
38
+ var constants = require("./constants");
39
+ var errors = require("./framework-error");
40
+ var asn1 = require("./asn1-der");
41
+ var guard = require("./guard-all");
42
+ var x509 = require("./schema-x509");
43
+
44
+ var TrustError = errors.TrustError;
45
+ var LIMITS = constants.LIMITS;
46
+
47
+ function E(code, message, cause) { return new TrustError(code, message, cause); }
48
+
49
+ // The three NSS purposes an anchor carries; the keys double as the
50
+ // `opts.checkPurpose` vocabulary `pki.path.validate` consumes.
51
+ var PURPOSES = ["serverAuth", "emailProtection", "codeSigning"];
52
+
53
+ // CKA_TRUST_* attribute -> purpose key.
54
+ var PURPOSE_ATTRS = [
55
+ ["CKA_TRUST_SERVER_AUTH", "serverAuth"],
56
+ ["CKA_TRUST_EMAIL_PROTECTION", "emailProtection"],
57
+ ["CKA_TRUST_CODE_SIGNING", "codeSigning"],
58
+ ];
59
+
60
+ // The recognized CK_TRUST vocabulary. ONLY CKT_NSS_TRUSTED_DELEGATOR grants a
61
+ // purpose; the rest are recognized-but-untrusted (fail-closed: an absent or
62
+ // non-delegator bit never defaults to trusted). A token outside this set is a
63
+ // typed trust/bad-trust-value, never a silent false.
64
+ var CKT_RECOGNIZED = Object.create(null);
65
+ ["CKT_NSS_TRUSTED_DELEGATOR", "CKT_NSS_TRUSTED", "CKT_NSS_MUST_VERIFY_TRUST",
66
+ "CKT_NSS_TRUST_UNKNOWN", "CKT_NSS_NOT_TRUSTED", "CKT_NSS_VALID_DELEGATOR"]
67
+ .forEach(function (t) { CKT_RECOGNIZED[t] = true; });
68
+ var CKT_DELEGATOR = "CKT_NSS_TRUSTED_DELEGATOR";
69
+
70
+ // An attribute line: `CKA_<NAME> <type> [<value>]`. The value grammar is
71
+ // per-type (a quoted UTF8 string, a single token, or nothing for
72
+ // MULTILINE_OCTAL whose payload follows on its own lines).
73
+ var ATTR_RE = /^(CKA_[A-Z0-9_]+)(?:\s+(\S+)(?:\s+(.*))?)?$/;
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // certdata.txt lexer -- line-oriented, C.LIMITS-bounded, fail-closed
77
+ // ---------------------------------------------------------------------------
78
+
79
+ // Decode a MULTILINE_OCTAL body: whitespace-separated runs of `\ooo` escapes
80
+ // (exactly a backslash + three octal digits, \000..\377), terminated by a line
81
+ // that is exactly `END`. Every fault is refused before the byte lands: an
82
+ // escape past \377, a short or non-octal escape, a stray token, a blank line
83
+ // or EOF before END, or a blob past TRUST_MAX_OCTAL_BYTES.
84
+ function _readOctalBody(lines, idx) {
85
+ var out = [];
86
+ var cap = LIMITS.TRUST_MAX_OCTAL_BYTES;
87
+ for (var i = idx; i < lines.length; i++) {
88
+ var t = lines[i].trim();
89
+ if (t === "END") return { bytes: Buffer.from(out), next: i + 1 };
90
+ if (t === "") throw E("trust/bad-octal", "MULTILINE_OCTAL body interrupted by a blank line before END");
91
+ var chunks = t.split(/\s+/);
92
+ for (var c = 0; c < chunks.length; c++) {
93
+ var chunk = chunks[c];
94
+ for (var k = 0; k < chunk.length; k += 4) {
95
+ if (chunk.charCodeAt(k) !== 92 /* backslash */) {
96
+ throw E("trust/bad-octal", "MULTILINE_OCTAL expects \\ooo escapes, got " + JSON.stringify(chunk.slice(k, k + 4)));
97
+ }
98
+ var d = chunk.slice(k + 1, k + 4);
99
+ if (!/^[0-7]{3}$/.test(d)) {
100
+ throw E("trust/bad-octal", "a MULTILINE_OCTAL escape is a backslash + exactly three octal digits, got " + JSON.stringify("\\" + d));
101
+ }
102
+ if (d.charCodeAt(0) > 51 /* '3' */) {
103
+ throw E("trust/bad-octal", "octal escape \\" + d + " exceeds \\377 (one byte)");
104
+ }
105
+ if (out.length >= cap) throw E("trust/bad-block", "MULTILINE_OCTAL blob exceeds LIMITS.TRUST_MAX_OCTAL_BYTES");
106
+ out.push(parseInt(d, 8));
107
+ }
108
+ }
109
+ }
110
+ throw E("trust/bad-octal", "MULTILINE_OCTAL body reached end of input before END");
111
+ }
112
+
113
+ // Lex the object stream: a `#` line is a comment, the preamble ends at
114
+ // BEGINDATA, blank lines delimit object blocks, each block is typed
115
+ // `CKA_* <type> <value>` attribute lines. Returns [{ attrs }] where attrs maps
116
+ // the attribute name to { type, value | bytes }.
117
+ function _lexObjects(text) {
118
+ var lines = text.split(/\r\n|\n|\r/);
119
+ var n = lines.length;
120
+ var i = 0, sawBegin = false;
121
+ for (; i < n; i++) {
122
+ if (lines[i].trim() === "BEGINDATA") { sawBegin = true; i++; break; }
123
+ }
124
+ if (!sawBegin) throw E("trust/bad-input", "not a certdata stream: no BEGINDATA line");
125
+
126
+ var objects = [];
127
+ var cur = null;
128
+ while (i < n) {
129
+ var t = lines[i].trim();
130
+ if (t === "") {
131
+ if (cur) { objects.push(cur); cur = null; }
132
+ i++; continue;
133
+ }
134
+ if (t.charAt(0) === "#") { i++; continue; }
135
+ var m = ATTR_RE.exec(t);
136
+ if (!m) throw E("trust/bad-block", "unexpected line in the certdata object stream: " + JSON.stringify(t.slice(0, 64)));
137
+ var name = m[1], type = m[2];
138
+ var rawVal = m[3] !== undefined ? m[3].trim() : undefined;
139
+ if (!type) throw E("trust/bad-block", "attribute line is missing its type token: " + name);
140
+ if (!cur) {
141
+ // Refuse the block bomb BEFORE lexing another block's payloads.
142
+ if (objects.length >= LIMITS.TRUST_MAX_OBJECTS) throw E("trust/bad-block", "certdata object count exceeds LIMITS.TRUST_MAX_OBJECTS");
143
+ cur = { attrs: Object.create(null) };
144
+ }
145
+ if (cur.attrs[name]) throw E("trust/bad-block", "duplicate attribute " + name + " within one object block");
146
+
147
+ var entry;
148
+ if (type === "MULTILINE_OCTAL") {
149
+ if (rawVal !== undefined && rawVal !== "") throw E("trust/bad-block", name + " MULTILINE_OCTAL carries an unexpected inline value");
150
+ var r = _readOctalBody(lines, i + 1);
151
+ cur.attrs[name] = { type: type, bytes: r.bytes };
152
+ i = r.next;
153
+ continue;
154
+ }
155
+ if (type === "CK_BBOOL") {
156
+ if (rawVal !== "CK_TRUE" && rawVal !== "CK_FALSE") throw E("trust/bad-block", name + " CK_BBOOL must be CK_TRUE or CK_FALSE");
157
+ entry = { type: type, value: rawVal === "CK_TRUE" };
158
+ } else if (type === "UTF8") {
159
+ var q = rawVal !== undefined ? /^"([^"]*)"$/.exec(rawVal) : null;
160
+ if (!q) throw E("trust/bad-block", name + " UTF8 must carry one double-quoted value");
161
+ entry = { type: type, value: q[1] };
162
+ } else if (type === "CK_TRUST") {
163
+ if (rawVal === undefined || /\s/.test(rawVal)) throw E("trust/bad-block", name + " CK_TRUST must carry a single token value");
164
+ if (!CKT_RECOGNIZED[rawVal]) throw E("trust/bad-trust-value", "unrecognized CK_TRUST value " + JSON.stringify(rawVal) + " on " + name);
165
+ entry = { type: type, value: rawVal };
166
+ } else if (type === "CK_OBJECT_CLASS" || type === "CK_CERTIFICATE_TYPE") {
167
+ if (rawVal === undefined || /\s/.test(rawVal)) throw E("trust/bad-block", name + " " + type + " must carry a single token value");
168
+ entry = { type: type, value: rawVal };
169
+ } else {
170
+ // An unknown value TYPE cannot be lexed safely (its value grammar is
171
+ // unknown) -- fail closed rather than guess past it.
172
+ throw E("trust/bad-block", "unrecognized attribute type " + JSON.stringify(type) + " on " + name);
173
+ }
174
+ cur.attrs[name] = entry;
175
+ i++;
176
+ }
177
+ if (cur) objects.push(cur);
178
+ return objects;
179
+ }
180
+
181
+ // ---------------------------------------------------------------------------
182
+ // certdata semantics -- pairing, distrust-after, purpose bits
183
+ // ---------------------------------------------------------------------------
184
+
185
+ function _requireOctal(obj, name, what) {
186
+ var a = obj.attrs[name];
187
+ if (!a || a.type !== "MULTILINE_OCTAL") {
188
+ throw E("trust/bad-block", what + " object is missing its " + name + " MULTILINE_OCTAL attribute");
189
+ }
190
+ return a.bytes;
191
+ }
192
+
193
+ // The NSS distrust-after payload is the BARE ASCII of a YYMMDDHHMMSSZ UTCTime
194
+ // (13 bytes) or a YYYYMMDDHHMMSSZ GeneralizedTime (15 bytes) -- no DER tag or
195
+ // length. Synthesize the minimal TLV and route it through the strict asn1
196
+ // time reader so the Z-terminator / mandatory-seconds / component-rollover /
197
+ // RFC 5280 sec. 4.1.2.5.1 2050-pivot rules are enforced once, never re-derived.
198
+ function _strictTime(payload, code, label) {
199
+ var tlv;
200
+ if (payload.length === 13) tlv = Buffer.concat([Buffer.from([0x17, 0x0d]), payload]);
201
+ else if (payload.length === 15) tlv = Buffer.concat([Buffer.from([0x18, 0x0f]), payload]);
202
+ else throw E(code, label + " must be a 13-byte UTCTime or a 15-byte GeneralizedTime payload, got " + payload.length + " bytes");
203
+ try { return asn1.read.time(asn1.decode(tlv)); }
204
+ catch (e) { throw E(code, label + " does not decode as a strict time", e); }
205
+ }
206
+
207
+ // CKA_NSS_*_DISTRUST_AFTER: either `CK_BBOOL CK_FALSE` (no distrust-after in
208
+ // force) or a MULTILINE_OCTAL bare-ASCII time. Anything else fails closed.
209
+ function _distrustDate(obj, name) {
210
+ var a = obj.attrs[name];
211
+ if (!a) return null;
212
+ if (a.type === "CK_BBOOL") {
213
+ if (a.value === false) return null;
214
+ throw E("trust/bad-distrust-after", name + " CK_BBOOL may only be CK_FALSE");
215
+ }
216
+ if (a.type !== "MULTILINE_OCTAL") throw E("trust/bad-distrust-after", name + " must be CK_BBOOL CK_FALSE or MULTILINE_OCTAL");
217
+ return _strictTime(a.bytes, "trust/bad-distrust-after", name);
218
+ }
219
+
220
+ function _certEntry(obj) {
221
+ var der = _requireOctal(obj, "CKA_VALUE", "certificate");
222
+ var cert;
223
+ try { cert = x509.parse(der); }
224
+ catch (e) { throw E("trust/not-a-certificate", "CKA_VALUE does not parse as an X.509 certificate", e); }
225
+
226
+ var issuer = _requireOctal(obj, "CKA_ISSUER", "certificate");
227
+ var serial = _requireOctal(obj, "CKA_SERIAL_NUMBER", "certificate");
228
+
229
+ // Fail-closed cross-check (the pairing key vs the parsed DER): a block whose
230
+ // own identity octets disagree with its certificate would let trust metadata
231
+ // attach to the wrong root -- a trust-elevation bug, not a tolerable skew.
232
+ if (!issuer.equals(cert.issuer.bytes)) {
233
+ throw E("trust/pairing-mismatch", "CKA_ISSUER disagrees with the certificate's issuer DER");
234
+ }
235
+ var serialContentHex;
236
+ try {
237
+ var sn = asn1.decode(serial);
238
+ asn1.read.integer(sn); // strict INTEGER (tag + minimal content)
239
+ serialContentHex = sn.content.toString("hex");
240
+ } catch (e) {
241
+ throw E("trust/pairing-mismatch", "CKA_SERIAL_NUMBER is not a DER INTEGER", e);
242
+ }
243
+ if (serialContentHex !== cert.serialNumberHex) {
244
+ throw E("trust/pairing-mismatch", "CKA_SERIAL_NUMBER disagrees with the certificate's serial");
245
+ }
246
+ var subject = obj.attrs["CKA_SUBJECT"];
247
+ if (subject && (subject.type !== "MULTILINE_OCTAL" || !subject.bytes.equals(cert.subject.bytes))) {
248
+ throw E("trust/pairing-mismatch", "CKA_SUBJECT disagrees with the certificate's subject DER");
249
+ }
250
+
251
+ var label = obj.attrs["CKA_LABEL"];
252
+ if (label && label.type !== "UTF8") throw E("trust/bad-block", "CKA_LABEL must be UTF8");
253
+ var policy = obj.attrs["CKA_NSS_MOZILLA_CA_POLICY"];
254
+ if (policy && policy.type !== "CK_BBOOL") throw E("trust/bad-block", "CKA_NSS_MOZILLA_CA_POLICY must be CK_BBOOL");
255
+
256
+ var distrustAfter = {};
257
+ var server = _distrustDate(obj, "CKA_NSS_SERVER_DISTRUST_AFTER");
258
+ if (server) distrustAfter.serverAuth = server;
259
+ var email = _distrustDate(obj, "CKA_NSS_EMAIL_DISTRUST_AFTER");
260
+ if (email) distrustAfter.emailProtection = email;
261
+
262
+ return {
263
+ key: issuer.toString("hex") + "/" + serial.toString("hex"),
264
+ der: der,
265
+ cert: cert,
266
+ label: label ? label.value : null,
267
+ mozillaCaPolicy: !!(policy && policy.value === true),
268
+ distrustAfter: distrustAfter,
269
+ purposes: null, // attached by the paired CKO_NSS_TRUST object
270
+ };
271
+ }
272
+
273
+ function _trustEntry(obj) {
274
+ var issuer = _requireOctal(obj, "CKA_ISSUER", "trust");
275
+ var serial = _requireOctal(obj, "CKA_SERIAL_NUMBER", "trust");
276
+ var purposes = { serverAuth: false, emailProtection: false, codeSigning: false };
277
+ PURPOSE_ATTRS.forEach(function (pair) {
278
+ var a = obj.attrs[pair[0]];
279
+ if (!a) return; // an absent bit is untrusted (fail-closed)
280
+ if (a.type !== "CK_TRUST") throw E("trust/bad-block", pair[0] + " must be CK_TRUST");
281
+ purposes[pair[1]] = a.value === CKT_DELEGATOR;
282
+ });
283
+ return { key: issuer.toString("hex") + "/" + serial.toString("hex"), purposes: purposes };
284
+ }
285
+
286
+ // ---------------------------------------------------------------------------
287
+ // Anchor assembly + dedup (shared by both sources)
288
+ // ---------------------------------------------------------------------------
289
+
290
+ function _mkAnchor(cert, meta) {
291
+ var spki = cert.subjectPublicKeyInfo;
292
+ return {
293
+ name: cert.subject, // the object with .rdns (name chaining)
294
+ publicKey: spki.bytes, // the full SPKI SEQUENCE TLV
295
+ algorithm: spki.algorithm.oid, // the SPKI public-key algorithm OID
296
+ parameters: spki.algorithm.parameters, // e.g. an EC namedCurve (Buffer|null)
297
+ subjectDer: cert.subject.bytes,
298
+ distrustAfter: meta.distrustAfter,
299
+ purposes: meta.purposes,
300
+ label: meta.label,
301
+ mozillaCaPolicy: meta.mozillaCaPolicy,
302
+ };
303
+ }
304
+
305
+ function _datesEqual(x, y) {
306
+ var kx = Object.keys(x).sort(), ky = Object.keys(y).sort();
307
+ if (kx.join(",") !== ky.join(",")) return false;
308
+ return kx.every(function (k) { return x[k].getTime() === y[k].getTime(); });
309
+ }
310
+
311
+ function _purposesEqual(x, y) {
312
+ return PURPOSES.every(function (p) { return x[p] === y[p]; });
313
+ }
314
+
315
+ function _paramsEqual(x, y) {
316
+ if (x === null || y === null) return x === y;
317
+ return Buffer.isBuffer(x) && Buffer.isBuffer(y) && x.equals(y);
318
+ }
319
+
320
+ function _anchorsAgree(x, y) {
321
+ return x.label === y.label &&
322
+ x.mozillaCaPolicy === y.mozillaCaPolicy &&
323
+ x.algorithm === y.algorithm &&
324
+ _paramsEqual(x.parameters, y.parameters) &&
325
+ _purposesEqual(x.purposes, y.purposes) &&
326
+ _datesEqual(x.distrustAfter, y.distrustAfter);
327
+ }
328
+
329
+ // Dedup by (subjectDer, publicKey): the SAME root appearing twice (a
330
+ // duplicated block, or certdata + CCADB merged by the operator) collapses to
331
+ // one anchor; a DISTINCT root that merely shares a subject DN has a different
332
+ // key and survives. Two entries for one root that DISAGREE on trust metadata
333
+ // are ambiguous -- fail closed rather than silently pick one.
334
+ function _dedupAnchors(anchors) {
335
+ var seen = Object.create(null);
336
+ var out = [];
337
+ anchors.forEach(function (a) {
338
+ var key = a.subjectDer.toString("hex") + "/" + a.publicKey.toString("hex");
339
+ var prev = seen[key];
340
+ if (!prev) { seen[key] = a; out.push(a); return; }
341
+ if (!_anchorsAgree(prev, a)) {
342
+ throw E("trust/pairing-mismatch", "two entries for one root (same subject + key) disagree on trust metadata");
343
+ }
344
+ });
345
+ return out;
346
+ }
347
+
348
+ // ---------------------------------------------------------------------------
349
+ // Public surface
350
+ // ---------------------------------------------------------------------------
351
+
352
+ /**
353
+ * @primitive pki.trust.parseCertdata
354
+ * @signature pki.trust.parseCertdata(text) -> { anchors }
355
+ * @since 0.2.0
356
+ * @status experimental
357
+ * @spec RFC 5280 (NSS certdata.txt object stream)
358
+ * @defends trust-metadata-misattribution (CWE-345), trust-store-parser-DoS (CWE-770)
359
+ * @related pki.trust.parseCcadbCsv, pki.trust.anchor, pki.path.validate
360
+ *
361
+ * Parse the Mozilla/NSS `certdata.txt` root-store object stream into
362
+ * constraint-carrying trust anchors. Each CKO_CERTIFICATE object's
363
+ * MULTILINE_OCTAL `CKA_VALUE` is decoded and parsed as a DER certificate; the
364
+ * paired CKO_NSS_TRUST object -- joined by byte-exact CKA_ISSUER +
365
+ * CKA_SERIAL_NUMBER, never adjacency, and cross-checked against the parsed
366
+ * DER -- contributes the purpose trust bits (only CKT_NSS_TRUSTED_DELEGATOR
367
+ * grants a purpose); the per-purpose distrust-after dates ride in the
368
+ * certificate object as bare ASCII times routed through the strict DER time
369
+ * reader. Every anchor carries the exact `{ name, publicKey, algorithm,
370
+ * parameters }` shape `pki.path.validate` consumes plus `distrustAfter`,
371
+ * `purposes`, `subjectDer`, `label`, `mozillaCaPolicy`. A certificate with no
372
+ * trust object becomes an anchor trusted for nothing (never silently
373
+ * dropped); a trust object with no certificate grants nothing. Malformed
374
+ * octal, an oversized block, an unrecognized trust value, a mispaired or
375
+ * ambiguous-duplicate block, or an undecodable distrust-after time throws a
376
+ * typed `trust/*` error -- never a silently truncated or misattributed root.
377
+ *
378
+ * @example
379
+ * // Real input is the NSS certdata.txt read from disk; a one-root stream is
380
+ * // synthesized here from a DER certificate to show the object shape.
381
+ * var cert = pki.schema.x509.parse(der);
382
+ * var oct = function (buf) { return Array.prototype.map.call(buf, function (b) { return "\\" + ("000" + b.toString(8)).slice(-3); }).join(""); };
383
+ * var blk = function (n, v) { return n + " MULTILINE_OCTAL\n" + oct(v) + "\nEND\n"; };
384
+ * var store = pki.trust.parseCertdata("BEGINDATA\n\n" +
385
+ * "CKA_CLASS CK_OBJECT_CLASS CKO_CERTIFICATE\n" +
386
+ * blk("CKA_ISSUER", cert.issuer.bytes) +
387
+ * blk("CKA_SERIAL_NUMBER", pki.asn1.build.integer(cert.serialNumber)) +
388
+ * blk("CKA_VALUE", der));
389
+ * store.anchors[0].purposes.serverAuth; // -> false (no trust object: trusted for nothing)
390
+ */
391
+ function parseCertdata(text) {
392
+ text = guard.text.decode(text, LIMITS.TRUST_MAX_BYTES, TrustError, {
393
+ charset: "latin1", tooLarge: "trust/bad-block", badInput: "trust/bad-input", label: "certdata input",
394
+ });
395
+ var objects = _lexObjects(text);
396
+
397
+ var certs = [];
398
+ var byKey = Object.create(null);
399
+ var trusts = [];
400
+ objects.forEach(function (obj) {
401
+ var cls = obj.attrs["CKA_CLASS"];
402
+ if (!cls || cls.type !== "CK_OBJECT_CLASS") {
403
+ throw E("trust/bad-block", "object block is missing CKA_CLASS CK_OBJECT_CLASS");
404
+ }
405
+ if (cls.value === "CKO_CERTIFICATE") {
406
+ var e = _certEntry(obj);
407
+ var prev = byKey[e.key];
408
+ if (prev) {
409
+ // A byte-identical duplicated block collapses; two certificate
410
+ // objects claiming one (issuer, serial) with different content are a
411
+ // forged identity -- fail closed.
412
+ if (!prev.der.equals(e.der) || prev.label !== e.label ||
413
+ prev.mozillaCaPolicy !== e.mozillaCaPolicy ||
414
+ !_datesEqual(prev.distrustAfter, e.distrustAfter)) {
415
+ throw E("trust/pairing-mismatch", "two certificate objects share an (issuer, serial) but disagree");
416
+ }
417
+ return;
418
+ }
419
+ byKey[e.key] = e;
420
+ certs.push(e);
421
+ } else if (cls.value === "CKO_NSS_TRUST") {
422
+ trusts.push(_trustEntry(obj));
423
+ }
424
+ // Any other class (CKO_NSS_BUILTIN_ROOT_LIST, ...) is not a root: skipped.
425
+ });
426
+
427
+ trusts.forEach(function (t) {
428
+ var c = byKey[t.key];
429
+ if (!c) return; // a trust object with no certificate object grants nothing
430
+ if (c.purposes) {
431
+ if (!_purposesEqual(c.purposes, t.purposes)) {
432
+ throw E("trust/pairing-mismatch", "two trust objects for one certificate disagree on purpose bits");
433
+ }
434
+ return;
435
+ }
436
+ c.purposes = t.purposes;
437
+ });
438
+
439
+ return {
440
+ anchors: _dedupAnchors(certs.map(function (c) {
441
+ return _mkAnchor(c.cert, {
442
+ distrustAfter: c.distrustAfter,
443
+ purposes: c.purposes || { serverAuth: false, emailProtection: false, codeSigning: false },
444
+ label: c.label,
445
+ mozillaCaPolicy: c.mozillaCaPolicy,
446
+ });
447
+ })),
448
+ };
449
+ }
450
+
451
+ // ---------------------------------------------------------------------------
452
+ // CCADB CSV -- a small RFC 4180 sub-lexer + header-keyed column mapping
453
+ // ---------------------------------------------------------------------------
454
+
455
+ var CSV_REQUIRED = [
456
+ "Common Name or Certificate Name",
457
+ "Trust Bits",
458
+ "Distrust for TLS After Date",
459
+ "Distrust for S/MIME After Date",
460
+ "PEM Info",
461
+ ];
462
+
463
+ // RFC 4180 field lexer: quoted fields, embedded commas / newlines, `""`
464
+ // escapes. Strict: a quote may only open at the start of a field, nothing may
465
+ // follow a closing quote but a separator, and an unterminated quote at EOF is
466
+ // a framing violation. Row and field ceilings are refused as they are hit.
467
+ function _csvRows(text) {
468
+ var rows = [], row = [], field = "";
469
+ var inQuotes = false, quoted = false, closed = false;
470
+ var fieldCap = LIMITS.TRUST_MAX_CSV_FIELD_BYTES;
471
+
472
+ function appendChar(c) {
473
+ if (field.length >= fieldCap) throw E("trust/bad-csv", "a CSV field exceeds LIMITS.TRUST_MAX_CSV_FIELD_BYTES");
474
+ field += c;
475
+ }
476
+ function endField() { row.push(field); field = ""; quoted = false; closed = false; }
477
+ function endRow() {
478
+ endField();
479
+ if (rows.length >= LIMITS.TRUST_MAX_CSV_ROWS) throw E("trust/bad-csv", "CSV row count exceeds LIMITS.TRUST_MAX_CSV_ROWS");
480
+ rows.push(row); row = [];
481
+ }
482
+
483
+ var i = 0, n = text.length;
484
+ while (i < n) {
485
+ var ch = text.charAt(i);
486
+ if (inQuotes) {
487
+ if (ch === "\"") {
488
+ if (text.charAt(i + 1) === "\"") { appendChar("\""); i += 2; continue; }
489
+ inQuotes = false; closed = true; i++; continue;
490
+ }
491
+ appendChar(ch); i++; continue;
492
+ }
493
+ if (ch === "\"") {
494
+ if (field !== "" || quoted) throw E("trust/bad-csv", "a quote may only open at the start of a field (RFC 4180)");
495
+ inQuotes = true; quoted = true; i++; continue;
496
+ }
497
+ if (ch === ",") { endField(); i++; continue; }
498
+ if (ch === "\r" && text.charAt(i + 1) === "\n") { endRow(); i += 2; continue; }
499
+ if (ch === "\n" || ch === "\r") { endRow(); i++; continue; }
500
+ if (closed) throw E("trust/bad-csv", "content after a closing quote (RFC 4180)");
501
+ appendChar(ch); i++;
502
+ }
503
+ if (inQuotes) throw E("trust/bad-csv", "unterminated quoted field at end of input");
504
+ if (field !== "" || quoted || row.length > 0) endRow();
505
+ return rows;
506
+ }
507
+
508
+ // `Trust Bits` is set-valued (`Websites; Email`) and CCADB spells it in two
509
+ // vocabularies: the Mozilla-report labels (Websites / Email / Code) and the
510
+ // EKU-derived trust-bit names current exports carry (Server Authentication /
511
+ // Secure Email / Code Signing). Both map to the same purposes. The KNOWN CCADB
512
+ // purposes the anchor model does not track (Client Authentication, Document
513
+ // Signing, Time Stamping, OCSP Signing) are tolerated and grant NOTHING -- a
514
+ // purpose is only ever granted by a recognized delegator token, so ignoring an
515
+ // unmapped one fails closed. An empty cell grants nothing; a token outside the
516
+ // known universe is still surfaced (a silently-skipped NEW bit would hide
517
+ // vocabulary drift), never guessed.
518
+ var _TRUST_BIT_TOKENS = {
519
+ "websites": "serverAuth", "server authentication": "serverAuth",
520
+ "email": "emailProtection", "secure email": "emailProtection",
521
+ "code": "codeSigning", "code signing": "codeSigning",
522
+ "client authentication": null, "document signing": null,
523
+ "time stamping": null, "ocsp signing": null,
524
+ };
525
+ function _trustBits(cell) {
526
+ var purposes = { serverAuth: false, emailProtection: false, codeSigning: false };
527
+ String(cell).split(";").forEach(function (tok) {
528
+ var t = tok.trim();
529
+ if (t === "") return;
530
+ var l = t.toLowerCase();
531
+ if (!Object.prototype.hasOwnProperty.call(_TRUST_BIT_TOKENS, l)) {
532
+ throw E("trust/bad-csv", "unrecognized Trust Bits token " + JSON.stringify(t));
533
+ }
534
+ var p = _TRUST_BIT_TOKENS[l];
535
+ if (p) purposes[p] = true;
536
+ });
537
+ return purposes;
538
+ }
539
+
540
+ // The CCADB distrust columns are DATE-only. Expand Y-M-D to the end-of-day
541
+ // ...T23:59:59Z instant -- the same instant NSS encodes as `...235959Z` -- by
542
+ // synthesizing a GeneralizedTime and routing through the strict reader (which
543
+ // also rejects a rolled-over calendar date). Dotted, dashed, and slashed
544
+ // separators are accepted (the export format has drifted between them).
545
+ function _csvDate(cell, label) {
546
+ var s = String(cell).trim();
547
+ if (s === "") return null;
548
+ var m = /^(\d{4})[./-](\d{1,2})[./-](\d{1,2})$/.exec(s);
549
+ if (!m) throw E("trust/bad-csv", label + " must be a Y-M-D date, got " + JSON.stringify(s));
550
+ var pad = function (x) { return x.length === 1 ? "0" + x : x; };
551
+ var payload = Buffer.from(m[1] + pad(m[2]) + pad(m[3]) + "235959Z", "latin1");
552
+ try { return _strictTime(payload, "trust/bad-csv", label); }
553
+ catch (e) {
554
+ if (e && e.code === "trust/bad-csv") throw e;
555
+ throw E("trust/bad-csv", label + " is not a real calendar date: " + JSON.stringify(s), e);
556
+ }
557
+ }
558
+
559
+ // A CCADB `PEM Info` cell: the PEM certificate, commonly wrapped in one layer
560
+ // of single quotes (a spreadsheet-import guard) -- strip that layer, then
561
+ // parse strictly.
562
+ function _pemCell(cell) {
563
+ var s = String(cell).trim();
564
+ if (s.charAt(0) === "'") s = s.slice(1);
565
+ if (s.charAt(s.length - 1) === "'") s = s.slice(0, -1);
566
+ s = s.trim();
567
+ if (s === "") throw E("trust/bad-csv", "PEM Info cell is empty");
568
+ try { return x509.parse(s); }
569
+ catch (e) { throw E("trust/not-a-certificate", "PEM Info does not parse as an X.509 certificate", e); }
570
+ }
571
+
572
+ /**
573
+ * @primitive pki.trust.parseCcadbCsv
574
+ * @signature pki.trust.parseCcadbCsv(text) -> { anchors }
575
+ * @since 0.2.0
576
+ * @status experimental
577
+ * @spec RFC 4180, RFC 5280 (CCADB certificate-records CSV)
578
+ * @defends trust-metadata-misattribution (CWE-345), trust-store-parser-DoS (CWE-770)
579
+ * @related pki.trust.parseCertdata, pki.trust.anchor, pki.path.validate
580
+ *
581
+ * Parse a CCADB certificate-records CSV export into the SAME `Anchor` shape
582
+ * `parseCertdata` produces, so downstream enforcement is source-agnostic.
583
+ * Columns are located by header NAME -- never by position -- and unknown,
584
+ * reordered, or extra columns are tolerated; a MISSING required column
585
+ * (`Common Name or Certificate Name`, `Trust Bits`, `Distrust for TLS After
586
+ * Date`, `Distrust for S/MIME After Date`, `PEM Info`) fails closed with
587
+ * `trust/bad-csv`. Fields follow RFC 4180 quoting (embedded commas, newlines,
588
+ * doubled-quote escapes -- the PEM Info column depends on it). `Trust Bits`
589
+ * is set-valued (Websites -> serverAuth, Email -> emailProtection, Code ->
590
+ * codeSigning; absent -> untrusted). A DATE-only distrust cell expands to the
591
+ * end-of-day `...T23:59:59Z` instant, matching the NSS `...235959Z`
592
+ * encoding, through the same strict time reader.
593
+ *
594
+ * @example
595
+ * var csv = "Common Name or Certificate Name,Trust Bits," +
596
+ * "Distrust for TLS After Date,Distrust for S/MIME After Date,PEM Info\n" +
597
+ * 'Example Root,"Websites; Email",2027.06.01,,"' + pemText + '"';
598
+ * var store = pki.trust.parseCcadbCsv(csv);
599
+ * store.anchors[0].purposes.serverAuth; // -> true
600
+ * store.anchors[0].distrustAfter.serverAuth.toISOString(); // -> "2027-06-01T23:59:59.000Z"
601
+ */
602
+ function parseCcadbCsv(text) {
603
+ text = guard.text.decode(text, LIMITS.TRUST_MAX_BYTES, TrustError, {
604
+ charset: "utf-8", fatal: true,
605
+ tooLarge: "trust/bad-block", badDecode: "trust/bad-csv", badInput: "trust/bad-input",
606
+ label: "CCADB CSV input",
607
+ });
608
+ var rows = _csvRows(text);
609
+ if (rows.length === 0) throw E("trust/bad-csv", "CSV input carries no header row");
610
+
611
+ // trim() also strips a leading U+FEFF, so a BOM-prefixed export resolves
612
+ // its first header cell normally.
613
+ var header = rows[0].map(function (h) { return h.trim(); });
614
+ var col = Object.create(null);
615
+ header.forEach(function (h, idx) {
616
+ if (col[h] !== undefined) {
617
+ // Two columns with one REQUIRED name make the mapping ambiguous (which
618
+ // Trust Bits column binds?) -- fail closed rather than silently pick.
619
+ if (CSV_REQUIRED.indexOf(h) !== -1) throw E("trust/bad-csv", "duplicate required column " + JSON.stringify(h));
620
+ return;
621
+ }
622
+ col[h] = idx;
623
+ });
624
+ CSV_REQUIRED.forEach(function (r) {
625
+ if (col[r] === undefined) throw E("trust/bad-csv", "CSV is missing the required column " + JSON.stringify(r));
626
+ });
627
+
628
+ var anchors = [];
629
+ for (var i = 1; i < rows.length; i++) {
630
+ var row = rows[i];
631
+ if (row.length !== header.length) {
632
+ throw E("trust/bad-csv", "CSV row " + (i + 1) + " has " + row.length + " fields; the header has " + header.length);
633
+ }
634
+ var distrustAfter = {};
635
+ var tls = _csvDate(row[col["Distrust for TLS After Date"]], "Distrust for TLS After Date");
636
+ if (tls) distrustAfter.serverAuth = tls;
637
+ var smime = _csvDate(row[col["Distrust for S/MIME After Date"]], "Distrust for S/MIME After Date");
638
+ if (smime) distrustAfter.emailProtection = smime;
639
+ var label = row[col["Common Name or Certificate Name"]].trim();
640
+ anchors.push(_mkAnchor(_pemCell(row[col["PEM Info"]]), {
641
+ distrustAfter: distrustAfter,
642
+ purposes: _trustBits(row[col["Trust Bits"]]),
643
+ label: label === "" ? null : label,
644
+ mozillaCaPolicy: false, // certdata-only metadata
645
+ }));
646
+ }
647
+ return { anchors: _dedupAnchors(anchors) };
648
+ }
649
+
650
+ /**
651
+ * @primitive pki.trust.anchor
652
+ * @signature pki.trust.anchor(entry, opts?) -> trustAnchor
653
+ * @since 0.2.0
654
+ * @status experimental
655
+ * @spec RFC 5280 sec. 6.1.1 (NSS trust-bit semantics)
656
+ * @related pki.trust.parseCertdata, pki.trust.parseCcadbCsv, pki.path.validate
657
+ *
658
+ * Turn a parsed trust-store entry into the `trustAnchor` object
659
+ * `pki.path.validate` consumes: `{ name, publicKey, algorithm, parameters,
660
+ * distrustAfter, purposes }` -- a straight hand-off (validate reads
661
+ * `distrustAfter` as a per-purpose map and `purposes` as the delegator set,
662
+ * selected by its own `opts.checkPurpose`). With `opts.purpose` it
663
+ * fail-fasts: an entry that is not a trusted delegator for that purpose
664
+ * throws `trust/purpose-not-trusted` at build time, so an operator wiring a
665
+ * store catches the wrong root before a single validation runs (the
666
+ * authoritative gate stays inside `validate`).
667
+ *
668
+ * @opts
669
+ * purpose: string // "serverAuth" | "emailProtection" | "codeSigning" -- fail-fast purpose check
670
+ *
671
+ * @example
672
+ * var csv = "Common Name or Certificate Name,Trust Bits," +
673
+ * "Distrust for TLS After Date,Distrust for S/MIME After Date,PEM Info\n" +
674
+ * 'Example Root,Websites,,,"' + pemText + '"';
675
+ * var entry = pki.trust.parseCcadbCsv(csv).anchors[0];
676
+ * var anchor = pki.trust.anchor(entry, { purpose: "serverAuth" });
677
+ * await pki.path.validate([pki.schema.x509.parse(der)], { time: new Date(), trustAnchor: anchor, checkPurpose: "serverAuth" });
678
+ */
679
+ function anchor(entry, opts) {
680
+ if (!entry || typeof entry !== "object" || !Buffer.isBuffer(entry.publicKey) ||
681
+ typeof entry.algorithm !== "string" || !entry.name || !Array.isArray(entry.name.rdns)) {
682
+ throw E("trust/bad-input", "anchor expects a trust-store entry ({ name, publicKey, algorithm, ... })");
683
+ }
684
+ opts = opts || {};
685
+ if (opts.purpose !== undefined) {
686
+ if (PURPOSES.indexOf(opts.purpose) === -1) {
687
+ throw E("trust/bad-input", "anchor: opts.purpose must be one of " + PURPOSES.join(" | "));
688
+ }
689
+ if (!entry.purposes || entry.purposes[opts.purpose] !== true) {
690
+ throw E("trust/purpose-not-trusted", "this root is not a trusted delegator for " + opts.purpose);
691
+ }
692
+ }
693
+ return {
694
+ name: entry.name,
695
+ publicKey: entry.publicKey,
696
+ algorithm: entry.algorithm,
697
+ parameters: entry.parameters !== undefined ? entry.parameters : null,
698
+ distrustAfter: entry.distrustAfter || {},
699
+ purposes: entry.purposes || { serverAuth: false, emailProtection: false, codeSigning: false },
700
+ };
701
+ }
702
+
703
+ module.exports = {
704
+ parseCertdata: parseCertdata,
705
+ parseCcadbCsv: parseCcadbCsv,
706
+ anchor: anchor,
707
+ };