@blamejs/core 0.18.2 → 0.18.4
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 +4 -0
- package/NOTICE +1 -1
- package/README.md +1 -1
- package/lib/db-collection.js +25 -1
- package/lib/middleware/require-mtls.js +78 -2
- package/lib/mtls-ca.js +2387 -110
- package/lib/mtls-engine-default.js +77 -0
- package/lib/session.js +21 -1
- package/lib/vendor/MANIFEST.json +12 -12
- package/lib/vendor/blamejs-pki.cjs +9020 -7179
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/mtls-ca.js
CHANGED
|
@@ -44,8 +44,9 @@
|
|
|
44
44
|
* -> { p12, certPem, issuedAt, expiresAt }
|
|
45
45
|
*
|
|
46
46
|
* The engine returns the cert PEM but does NOT compute a
|
|
47
|
-
* fingerprint — the framework hashes the
|
|
48
|
-
* `b.crypto.
|
|
47
|
+
* fingerprint — the framework hashes the certificate's DER via
|
|
48
|
+
* `b.crypto.hashCertFingerprint(certPem)` (the same value the
|
|
49
|
+
* require-mtls gate pins) so the SHA3-512 posture stays
|
|
49
50
|
* consistent across the stack. Operators who need the X.509-
|
|
50
51
|
* conventional SHA-256 fingerprint (browser cert-details panels,
|
|
51
52
|
* openssl interop) compute it separately from the cert PEM.
|
|
@@ -61,9 +62,10 @@ var atomicFile = require("./atomic-file");
|
|
|
61
62
|
var C = require("./constants");
|
|
62
63
|
var lazyRequire = require("./lazy-require");
|
|
63
64
|
// Lazy — the SHA3-512 fingerprint surfaced from issuance must match the one
|
|
64
|
-
// the require-mtls gate pins (b.crypto.
|
|
65
|
+
// the require-mtls gate pins (b.crypto.hashCertFingerprint of the cert DER).
|
|
65
66
|
var bCrypto = lazyRequire(function () { return require("./crypto"); });
|
|
66
67
|
var { boot } = require("./log");
|
|
68
|
+
var safeAsync = require("./safe-async");
|
|
67
69
|
var safeBuffer = require("./safe-buffer");
|
|
68
70
|
var safeJson = require("./safe-json");
|
|
69
71
|
var validateOpts = require("./validate-opts");
|
|
@@ -98,6 +100,27 @@ var DEFAULT_PATHS = {
|
|
|
98
100
|
// rotation, the CRL doesn't.
|
|
99
101
|
revocations: "revocations.json",
|
|
100
102
|
crl: "ca.crl",
|
|
103
|
+
// Superseded-CA snapshot for a re-enrollment grace window. `commit({
|
|
104
|
+
// retainPrevious: true })` copies the outgoing ca.crt here before the new
|
|
105
|
+
// one lands; `loadTrustBundle()` returns [current, ...retained] so live
|
|
106
|
+
// clients holding a cert from the old CA still verify while they re-enroll;
|
|
107
|
+
// `dropRetained()` ends the window.
|
|
108
|
+
caCertPrev: "ca.prev.crt",
|
|
109
|
+
// Issuance ledger — append-only JSON index of every leaf this CA has signed
|
|
110
|
+
// ({ serialNumber, fingerprint, generation, issuedAt }). `revokeGeneration(n)`
|
|
111
|
+
// reads it to revoke every cert issued under a CA generation < n.
|
|
112
|
+
issuance: "issuance.json",
|
|
113
|
+
// Revoked-generation watermark — the highest n passed to revokeGeneration().
|
|
114
|
+
// A leaf whose signing straddled a rotate()+revokeGeneration() is recorded in
|
|
115
|
+
// the ledger AFTER the sweep read it, so at record time issuance compares its
|
|
116
|
+
// generation against this watermark and revokes itself if the generation has
|
|
117
|
+
// already been swept — closing the issuance-vs-generation-revocation race.
|
|
118
|
+
revokedGeneration: "revoked-generation",
|
|
119
|
+
// Effective CUSTOM-engine algorithm label — durable shared metadata so a SECOND handle over the
|
|
120
|
+
// same dataDir issues under the CURRENT label after another handle's commit/rotate({ algorithm }).
|
|
121
|
+
// A custom label is not derivable from the stored cert (only the bundled engine's is), so without
|
|
122
|
+
// this a stale-pinned sibling handle would pass its old label to the new issuer and be rejected.
|
|
123
|
+
algorithm: "ca.algorithm",
|
|
101
124
|
};
|
|
102
125
|
|
|
103
126
|
var VALID_SEAL_MODES = { required: 1, disabled: 1 };
|
|
@@ -120,6 +143,10 @@ function _resolvePaths(dataDir, paths) {
|
|
|
120
143
|
caCert: _absoluteOrUnderDataDir(dataDir, p.caCert),
|
|
121
144
|
revocations: _absoluteOrUnderDataDir(dataDir, p.revocations),
|
|
122
145
|
crl: _absoluteOrUnderDataDir(dataDir, p.crl),
|
|
146
|
+
caCertPrev: _absoluteOrUnderDataDir(dataDir, p.caCertPrev),
|
|
147
|
+
issuance: _absoluteOrUnderDataDir(dataDir, p.issuance),
|
|
148
|
+
revokedGeneration: _absoluteOrUnderDataDir(dataDir, p.revokedGeneration),
|
|
149
|
+
algorithm: _absoluteOrUnderDataDir(dataDir, p.algorithm),
|
|
123
150
|
};
|
|
124
151
|
}
|
|
125
152
|
|
|
@@ -150,13 +177,36 @@ function parseGeneration(certPem) {
|
|
|
150
177
|
var cert = new nodeCrypto.X509Certificate(certPem);
|
|
151
178
|
/* c8 ignore next -- defensive: a successfully-parsed X.509 certificate always exposes a subject DN */
|
|
152
179
|
var subj = cert.subject || "";
|
|
153
|
-
|
|
180
|
+
// Anchor the OU=CAv{N} match to an RDN BOUNDARY (subject start, a newline RDN separator, or an
|
|
181
|
+
// unescaped comma / " + " attribute separator node emits between RDNs / inside a MULTI-VALUED
|
|
182
|
+
// RDN, e.g. "CN=x + OU=CAv7") so a CN or other attribute VALUE that literally contains the
|
|
183
|
+
// substring "OU=CAv<k>" is not misread as the generation, and the FIRST real OU RDN is taken.
|
|
184
|
+
// The comma AND plus boundaries use a `(?<!\\)` lookbehind so an ESCAPED "\," / "\+" — a literal
|
|
185
|
+
// comma / plus inside a value ("CN=foo\,OU=CAv9", "CN=foo\+OU=CAv9") — is NOT treated as a
|
|
186
|
+
// separator; \r/\n are structural line separators node never escapes. An embedded "OU=CAv" inside
|
|
187
|
+
// a value never sits at a boundary (it follows "<Type>=").
|
|
188
|
+
var m = /(?:^|[\r\n]|(?<!\\)[,+])\s*OU=CAv(\d+)/.exec(subj);
|
|
154
189
|
return m ? parseInt(m[1], 10) : 1;
|
|
155
190
|
} catch (_e) {
|
|
156
191
|
return 0;
|
|
157
192
|
}
|
|
158
193
|
}
|
|
159
194
|
|
|
195
|
+
// A rollback-manifest byte field, when PRESENT, must be a non-empty string of CANONICAL
|
|
196
|
+
// base64 decoding to a non-empty buffer. An empty ("") or malformed value would decode to
|
|
197
|
+
// an empty / garbage buffer that, written over the live CA key on the interrupted path,
|
|
198
|
+
// permanently destroys the CA. Absent (null / undefined) is allowed — the recovery code
|
|
199
|
+
// handles a missing field. Canonical round-trip (re-encode equals the input) rejects
|
|
200
|
+
// whitespace / non-canonical padding a lenient Buffer.from would otherwise accept.
|
|
201
|
+
function _validManifestB64Field(v) {
|
|
202
|
+
if (v === null || v === undefined) return true;
|
|
203
|
+
if (typeof v !== "string" || v.length === 0) return false;
|
|
204
|
+
// Buffer.from(<string>, "base64") never throws (invalid chars are dropped), so no
|
|
205
|
+
// try/catch is needed — an empty/garbage decode is caught by the length + round-trip.
|
|
206
|
+
var buf = Buffer.from(v, "base64");
|
|
207
|
+
return buf.length > 0 && buf.toString("base64") === v;
|
|
208
|
+
}
|
|
209
|
+
|
|
160
210
|
/**
|
|
161
211
|
* @primitive b.mtlsCa.create
|
|
162
212
|
* @signature b.mtlsCa.create(opts)
|
|
@@ -182,6 +232,21 @@ function parseGeneration(certPem) {
|
|
|
182
232
|
* generation: number, // current CA generation for OU=CAv{N}
|
|
183
233
|
* engine: object, // pluggable X.509 engine; default lib/mtls-engine-default
|
|
184
234
|
* algorithm: string, // pin CA + leaf key algorithm; default ML-DSA-87. Pass "ECDSA-P384-SHA384" for a classical CA when a peer predates OpenSSL 3.5
|
|
235
|
+
* issuanceStore: object, // bring-your-own { list(), add(entry) } for the issuance ledger revokeGeneration reads; default is a JSON file under dataDir
|
|
236
|
+
* revocationStore: object, // bring-your-own { list(), add(entry) } for the revocation registry; default is a JSON file under dataDir. For a CLUSTERED deployment (shared store, per-host dataDir) also expose { readGenerationWatermark(), bumpGenerationWatermark(n) } so the issuance-supersede watermark is shared across hosts
|
|
237
|
+
*
|
|
238
|
+
* The handle also supports a non-breaking CA algorithm migration: status()
|
|
239
|
+
* reports the stored CA's algorithm / keyType; rotate({ generation, algorithm })
|
|
240
|
+
* generates and atomically commits a new CA (returning { caCertPem,
|
|
241
|
+
* previousCaCertPem }) without the algorithm-mismatch initCA raises;
|
|
242
|
+
* commit({ retainPrevious:true }) + loadTrustBundle() + dropRetained() keep the
|
|
243
|
+
* superseded CA trusted during a re-enrollment grace window; canVerifyInTls(algorithm?)
|
|
244
|
+
* runs a loopback mTLS self-test proving node:tls verifies a given algorithm on
|
|
245
|
+
* this runtime (pass the prospective algorithm to pre-flight a migration before
|
|
246
|
+
* rotating to it); revokeGeneration(n) revokes every cert the issuance ledger
|
|
247
|
+
* recorded under a CA generation below n; and importIssuance(entries) backfills
|
|
248
|
+
* leaf identities the ledger lacks (a pre-upgrade dataDir or out-of-band certs)
|
|
249
|
+
* so revokeGeneration can sweep them.
|
|
185
250
|
*
|
|
186
251
|
* @example
|
|
187
252
|
* var fs = require("fs");
|
|
@@ -203,6 +268,16 @@ function parseGeneration(certPem) {
|
|
|
203
268
|
// The OpenSSL curve name node reports for the framework's sole classical pin
|
|
204
269
|
// (ECDSA-P384-SHA384). A stored EC CA must report this curve to satisfy that pin.
|
|
205
270
|
var CLASSICAL_CA_CURVE = "secp384r1";
|
|
271
|
+
// The require-mtls gate pins a leaf's SHA3-512 (FIPS 202) fingerprint: 64 bytes -> 128 hex
|
|
272
|
+
// characters. A fingerprint STORED for that gate (revoke({fingerprint}) / importIssuance) must be
|
|
273
|
+
// exactly this length, else a shorter but valid-hex value (a SHA-256 64-hex fingerprint, a truncated
|
|
274
|
+
// paste) is accepted yet the gate's 128-hex compare never matches — a silent fail-open.
|
|
275
|
+
var SHA3_512_HEX_LEN = 128;
|
|
276
|
+
// Both default file stores (issuance ledger + revocation registry) are READ with this maxBytes cap
|
|
277
|
+
// (fdSafeReadSync throws "too-large" over it). A write that pushes a file past the cap would succeed
|
|
278
|
+
// but then fail every later read, bricking future issuance/revocation — so add() must size-check the
|
|
279
|
+
// serialized output against this SAME value before writing (see _writeStoreCapped).
|
|
280
|
+
var STORE_READ_CAP = C.BYTES.mib(16);
|
|
206
281
|
|
|
207
282
|
function _expectedKeyTypeForPin(label) {
|
|
208
283
|
var l = String(label).toLowerCase();
|
|
@@ -211,28 +286,110 @@ function _expectedKeyTypeForPin(label) {
|
|
|
211
286
|
return m ? ("ml-dsa-" + m[1]) : null;
|
|
212
287
|
}
|
|
213
288
|
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
289
|
+
// Map a node asymmetricKeyType (from a key OR a cert public key) to the
|
|
290
|
+
// framework algorithm label. "ec" -> ECDSA-P384-SHA384 (the sole classical
|
|
291
|
+
// pin), "ml-dsa-N" -> ML-DSA-N. undefined for a type this file doesn't map
|
|
292
|
+
// (a custom engine's own naming) — the engine then owns the semantics.
|
|
293
|
+
function _labelForKeyType(type) {
|
|
294
|
+
/* c8 ignore next -- the "" fallback is defensive: callers pass a non-empty asymmetricKeyType */
|
|
295
|
+
var t = String(type || "").toLowerCase();
|
|
296
|
+
if (t === "ec") return "ECDSA-P384-SHA384";
|
|
297
|
+
if (/^ml-dsa-\d+$/.test(t)) return t.toUpperCase();
|
|
298
|
+
/* c8 ignore next -- the unrecognized-type fallback is reached only via _certAlgorithm's unmapped-type branch (an RSA/other custom-engine CA key, itself c8-ignored as a non-framework configuration); _labelForCaKeyType only ever passes ec/ml-dsa default-engine CA keys */
|
|
299
|
+
return undefined;
|
|
300
|
+
}
|
|
301
|
+
|
|
221
302
|
function _labelForCaKeyType(caKeyPem) {
|
|
222
303
|
var type;
|
|
223
304
|
/* c8 ignore next -- the "" fallback is defensive: a parsed KeyObject always reports a non-empty asymmetricKeyType, so it is never reached */
|
|
224
305
|
try { type = String(nodeCrypto.createPrivateKey(caKeyPem).asymmetricKeyType || "").toLowerCase(); }
|
|
225
306
|
catch (_e) { return undefined; }
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
307
|
+
return _labelForKeyType(type);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Derive { keyType, algorithm } from a CA CERT's public key — the shape
|
|
311
|
+
// status() exposes. Uses only the public key (no vault / private-key load),
|
|
312
|
+
// so it works regardless of caKeySealedMode. keyType is the raw node
|
|
313
|
+
// asymmetricKeyType ("ec" / "ml-dsa-87" / ...); algorithm is the mapped label
|
|
314
|
+
// (null for a type this file doesn't recognize, e.g. a custom engine's).
|
|
315
|
+
function _certAlgorithm(certPem) {
|
|
316
|
+
try {
|
|
317
|
+
var cert = new nodeCrypto.X509Certificate(certPem);
|
|
318
|
+
var pub = cert.publicKey;
|
|
319
|
+
/* c8 ignore next -- the "" fallback is defensive: a parsed public key always reports a non-empty asymmetricKeyType */
|
|
320
|
+
var type = String(pub.asymmetricKeyType || "").toLowerCase();
|
|
321
|
+
if (type === "ec") {
|
|
322
|
+
// The framework's sole classical label (ECDSA-P384-SHA384) is P-384 /
|
|
323
|
+
// secp384r1 signed with SHA-384. A custom engine may issue a P-256 / P-521
|
|
324
|
+
// EC CA, or a P-384 CA signed with SHA-256 — node still reports "ec", so
|
|
325
|
+
// require BOTH the curve AND the SHA-384 signature before labeling;
|
|
326
|
+
// otherwise return null (a wrong label misreports status() and feeds a bad
|
|
327
|
+
// label to a custom engine's canVerifyInTls()).
|
|
328
|
+
/* c8 ignore next 2 -- the :null fallback is defensive: a parsed EC key always reports a namedCurve */
|
|
329
|
+
var curve = pub.asymmetricKeyDetails && pub.asymmetricKeyDetails.namedCurve
|
|
330
|
+
? String(pub.asymmetricKeyDetails.namedCurve).toLowerCase() : null;
|
|
331
|
+
/* c8 ignore next -- the "" fallback is defensive: a parsed cert always reports a signatureAlgorithm */
|
|
332
|
+
var sigAlg = String(cert.signatureAlgorithm || "").toLowerCase();
|
|
333
|
+
var isP384Sha384 = curve === CLASSICAL_CA_CURVE && /sha-?384/.test(sigAlg);
|
|
334
|
+
return { keyType: type, algorithm: isP384Sha384 ? "ECDSA-P384-SHA384" : null };
|
|
335
|
+
}
|
|
336
|
+
/* c8 ignore next -- the ||null fallbacks are defensive: `type` is non-empty here, and an unmapped type (e.g. a custom RSA CA) is not a framework configuration */
|
|
337
|
+
return { keyType: type || null, algorithm: _labelForKeyType(type) || null };
|
|
338
|
+
} catch (_e) {
|
|
339
|
+
return { keyType: null, algorithm: null };
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Atomically write a default-store file, but REFUSE an over-cap write first. Both default file
|
|
344
|
+
// stores (issuance ledger + revocation registry) are read with STORE_READ_CAP as fdSafeReadSync's
|
|
345
|
+
// maxBytes; a write that pushes the file past that cap succeeds yet then fails every later read
|
|
346
|
+
// (too-large), silently disabling future issuance/revocation. Size-checking the serialized output
|
|
347
|
+
// here — against the SAME cap the read uses — turns that into an explicit refusal BEFORE the store
|
|
348
|
+
// mutates and (via _recordIssuance) before the signed credential is returned.
|
|
349
|
+
function _writeStoreCapped(path, serialized, writeOpts, fullCode, label) {
|
|
350
|
+
if (Buffer.byteLength(serialized, "utf8") > STORE_READ_CAP) {
|
|
351
|
+
throw new MtlsCaError(fullCode,
|
|
352
|
+
"the default " + label + " (" + path + ") would exceed its " + STORE_READ_CAP + "-byte read cap; the framework's " +
|
|
353
|
+
"own read of a larger file fails closed, disabling future issuance/revocation until the file is repaired — provide " +
|
|
354
|
+
"a bring-your-own store that can grow past this cap for a deployment this large");
|
|
355
|
+
}
|
|
356
|
+
atomicFile.writeSync(path, serialized, writeOpts);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Do two cert PEMs represent the SAME X.509 certificate? Compares parsed DER IDENTITY, so
|
|
360
|
+
// harmless PEM differences (CRLF vs LF line endings, line wrapping, a stripped trailing
|
|
361
|
+
// newline) between the stored cert and a recommitted one do not read as a new issuer — which
|
|
362
|
+
// would spuriously open the retained-root grace window and invalidate a still-valid CRL. Falls
|
|
363
|
+
// back to a raw-byte comparison only when a cert cannot be parsed (an opaque custom-engine
|
|
364
|
+
// cert), where the bytes are the only identity signal available.
|
|
365
|
+
function _sameCert(pemA, pemB) {
|
|
366
|
+
try {
|
|
367
|
+
return new nodeCrypto.X509Certificate(pemA).raw.equals(new nodeCrypto.X509Certificate(pemB).raw);
|
|
368
|
+
} catch (_e) {
|
|
369
|
+
return Buffer.from(pemA).equals(Buffer.from(pemB));
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Does this CA cert's public key correspond to this CA private key? A rotation
|
|
374
|
+
// renames the key and cert as two separate steps, so an issuer reading the pair
|
|
375
|
+
// mid-rotation can combine the old cert with the new key. initCA re-reads until
|
|
376
|
+
// this holds. Returns true for a cert/key node can't parse (a custom engine owns
|
|
377
|
+
// its own pairing; the two-file rename race is specific to the default store).
|
|
378
|
+
function _caPairConsistent(certPem, keyPem) {
|
|
379
|
+
try {
|
|
380
|
+
var certSpki = new nodeCrypto.X509Certificate(certPem).publicKey.export({ type: "spki", format: "der" });
|
|
381
|
+
var keySpki = nodeCrypto.createPublicKey(keyPem).export({ type: "spki", format: "der" });
|
|
382
|
+
return Buffer.from(certSpki).equals(Buffer.from(keySpki));
|
|
383
|
+
} catch (_e) {
|
|
384
|
+
return true;
|
|
385
|
+
}
|
|
229
386
|
}
|
|
230
387
|
|
|
231
388
|
function create(opts) {
|
|
232
389
|
opts = opts || {};
|
|
233
390
|
validateOpts(opts, [
|
|
234
391
|
"dataDir", "paths", "vault",
|
|
235
|
-
"caKeySealedMode", "generation", "engine", "revocationStore", "algorithm",
|
|
392
|
+
"caKeySealedMode", "generation", "engine", "revocationStore", "issuanceStore", "algorithm",
|
|
236
393
|
], "b.mtlsCa");
|
|
237
394
|
validateOpts.requireNonEmptyString(opts.dataDir, "mtlsCa.create: opts.dataDir", MtlsCaError, "mtls-ca/no-datadir");
|
|
238
395
|
// Auto-create the dataDir with restrictive perms (CA keys live here).
|
|
@@ -245,6 +402,16 @@ function create(opts) {
|
|
|
245
402
|
nodeFs.mkdirSync(opts.dataDir, { recursive: true, mode: 0o700 });
|
|
246
403
|
}
|
|
247
404
|
var paths = _resolvePaths(opts.dataDir, opts.paths);
|
|
405
|
+
// Ensure the parent directory of every managed path exists. atomicFile.lock()
|
|
406
|
+
// opens `<path>.lock` directly and does NOT create the parent, so a nested
|
|
407
|
+
// operator path (e.g. paths.revocations = "state/revocations.json") would make
|
|
408
|
+
// the first locked revoke()/issuance/rotation fail ENOENT before the store's
|
|
409
|
+
// own writeSync (which used to create it). Create the parents up front.
|
|
410
|
+
[paths.caKey, paths.caKeySealed, paths.caCert, paths.caCertPrev,
|
|
411
|
+
paths.revocations, paths.crl, paths.issuance, paths.revokedGeneration].forEach(function (p) {
|
|
412
|
+
var dir = nodePath.dirname(p);
|
|
413
|
+
if (!nodeFs.existsSync(dir)) nodeFs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
414
|
+
});
|
|
248
415
|
var vault = opts.vault || null;
|
|
249
416
|
var caKeySealedMode = (opts.caKeySealedMode || "required").toLowerCase();
|
|
250
417
|
if (!Object.prototype.hasOwnProperty.call(VALID_SEAL_MODES, caKeySealedMode)) {
|
|
@@ -300,15 +467,39 @@ function create(opts) {
|
|
|
300
467
|
generation: 0,
|
|
301
468
|
isLegacy: false,
|
|
302
469
|
current: generation,
|
|
470
|
+
algorithm: null,
|
|
471
|
+
keyType: null,
|
|
303
472
|
};
|
|
304
473
|
}
|
|
305
474
|
var pem = atomicFile.fdSafeReadSync(paths.caCert, { maxBytes: C.BYTES.mib(1) });
|
|
306
475
|
var gen = parseGeneration(pem);
|
|
476
|
+
// keyType is read from the stored cert's PUBLIC key, so a consumer deciding whether to migrate a
|
|
477
|
+
// classical CA no longer has to re-parse loadCert() with node:crypto to learn ECDSA-vs-ML-DSA.
|
|
478
|
+
var alg = _certAlgorithm(pem);
|
|
479
|
+
// The default engine's labels ARE the cert-derived ones, so report _certAlgorithm's inference. A
|
|
480
|
+
// CUSTOM engine may use its own label ("CUSTOM-P384") for a key type _certAlgorithm would misreport
|
|
481
|
+
// as the bundled "ECDSA-P384-SHA384" (or cannot classify at all → null), so report the durable
|
|
482
|
+
// PERSISTED label instead — the same source issuance/rotation/probing use — falling back to null
|
|
483
|
+
// (undeterminable) when unpinned rather than a misleading bundled guess. keyType stays cert-derived.
|
|
484
|
+
var _statusAlgorithm;
|
|
485
|
+
if (usesDefaultEngine) {
|
|
486
|
+
_statusAlgorithm = alg.algorithm;
|
|
487
|
+
} else {
|
|
488
|
+
var _persistedStatusLabel = _currentCustomLabel();
|
|
489
|
+
_statusAlgorithm = (_persistedStatusLabel !== undefined) ? _persistedStatusLabel : null;
|
|
490
|
+
}
|
|
307
491
|
return {
|
|
308
492
|
exists: true,
|
|
309
493
|
generation: gen,
|
|
310
|
-
|
|
494
|
+
// gen === 0 means the generation is UNDETERMINABLE (an opaque cert node:crypto
|
|
495
|
+
// cannot parse), NOT "older than current". Reporting isLegacy:true there would
|
|
496
|
+
// mislabel a current opaque-engine CA as legacy — an isLegacy-keyed upgrade flow
|
|
497
|
+
// would then rotate() it and hit mtls-ca/generation-undeterminable, contradicting
|
|
498
|
+
// status(). Only a DETERMINED generation below the create-time one is legacy.
|
|
499
|
+
isLegacy: gen >= 1 && gen < generation,
|
|
311
500
|
current: generation,
|
|
501
|
+
algorithm: _statusAlgorithm,
|
|
502
|
+
keyType: alg.keyType,
|
|
312
503
|
};
|
|
313
504
|
}
|
|
314
505
|
|
|
@@ -361,15 +552,183 @@ function create(opts) {
|
|
|
361
552
|
// vault-sealed before the on-disk write so plaintext PEM never touches
|
|
362
553
|
// the filesystem; when 'disabled', it goes to disk as PEM with the
|
|
363
554
|
// operator's audited reason on record.
|
|
364
|
-
|
|
555
|
+
// The commit body. MUST run under atomicFile.lock(paths.caCert): the journal
|
|
556
|
+
// write, key/cert renames, retained-root update, and journal delete are a single
|
|
557
|
+
// critical section — two unlocked commits over one dataDir would race the staged
|
|
558
|
+
// temp files and each other's renames, clobbering the CA. rotate() already holds
|
|
559
|
+
// the lock; the public commit() below acquires it.
|
|
560
|
+
function _commitLocked(opts2) {
|
|
561
|
+
/* c8 ignore next 4 -- defense in depth: the public commit() validates these synchronously before the lock, and rotate()/_freshCreateSerialized pass engine output already validated as { caKeyPem, caCertPem } strings, so _commitLocked never sees bad args */
|
|
365
562
|
if (!opts2 || typeof opts2.caKeyPem !== "string" || typeof opts2.caCertPem !== "string") {
|
|
366
563
|
throw new MtlsCaError("mtls-ca/bad-commit",
|
|
367
564
|
"commit requires opts.caKeyPem and opts.caCertPem (PEM strings)");
|
|
368
565
|
}
|
|
566
|
+
// retainPrevious drives a truthiness check below (outgoingCaCert). A non-boolean
|
|
567
|
+
// (e.g. the string "false" from config) is TRUTHY, so it would retain the outgoing
|
|
568
|
+
// root when the operator intended a hard cut. Reject a supplied non-boolean rather
|
|
569
|
+
// than silently misinterpreting it (rotate() validates its own raw value too).
|
|
570
|
+
if (opts2.retainPrevious !== undefined && typeof opts2.retainPrevious !== "boolean") {
|
|
571
|
+
throw new MtlsCaError("mtls-ca/bad-retain-previous",
|
|
572
|
+
"commit opts.retainPrevious must be a boolean when provided (got " +
|
|
573
|
+
JSON.stringify(opts2.retainPrevious) + ") — a non-boolean like the string \"false\" is truthy and " +
|
|
574
|
+
"would retain the outgoing root instead of hard-cutting it");
|
|
575
|
+
}
|
|
576
|
+
// Grace-window retention: capture the OUTGOING cert now (before the new one
|
|
577
|
+
// overwrites it), but do NOT touch the retained-root file until the commit
|
|
578
|
+
// below SUCCEEDS. If sealing / tmp-write / rename fails, the active CA is
|
|
579
|
+
// unchanged, so the retained root must stay intact — otherwise a client still
|
|
580
|
+
// using it is stranded by a rotation that never landed.
|
|
581
|
+
var currentCaCert = (opts2.retainPrevious && nodeFs.existsSync(paths.caCert))
|
|
582
|
+
? atomicFile.fdSafeReadSync(paths.caCert, { maxBytes: C.BYTES.mib(1) })
|
|
583
|
+
: null;
|
|
584
|
+
// Retain ONLY when the committed cert actually SUPERSEDES the current issuer. An
|
|
585
|
+
// idempotent recommit of the same cert supersedes nothing, so retaining it would open
|
|
586
|
+
// the single retained-root window (rejecting the NEXT real retained rotation with
|
|
587
|
+
// mtls-ca/retained-root-exists until the operator calls dropRetained()) for no benefit —
|
|
588
|
+
// loadTrustBundle() dedups the duplicate, but the ca.prev.crt FILE still opens the window.
|
|
589
|
+
var outgoingCaCert = (currentCaCert !== null && !_sameCert(currentCaCert.toString("utf8"), opts2.caCertPem))
|
|
590
|
+
? currentCaCert
|
|
591
|
+
: null;
|
|
592
|
+
// Capture the PRIOR retained root so a rollback can restore it if the final
|
|
593
|
+
// cert rename fails after we overwrote/removed ca.prev.crt — a failed rotation
|
|
594
|
+
// must not strand clients still enrolled under the previously-retained CA.
|
|
595
|
+
var priorPrevExisted = nodeFs.existsSync(paths.caCertPrev);
|
|
596
|
+
var priorPrev = null;
|
|
597
|
+
if (priorPrevExisted) {
|
|
598
|
+
try { priorPrev = atomicFile.fdSafeReadSync(paths.caCertPrev, { maxBytes: C.BYTES.mib(1) }); }
|
|
599
|
+
catch (_e) { priorPrev = null; }
|
|
600
|
+
}
|
|
601
|
+
// Single retained grace window at a time — enforced HERE so EVERY retention
|
|
602
|
+
// entry point is covered (rotate() AND the public commit(), which calls
|
|
603
|
+
// _commitLocked directly). ca.prev.crt holds one prior root; a second retained
|
|
604
|
+
// commit would overwrite it and strand clients still enrolled under the first
|
|
605
|
+
// retained generation. End the existing window explicitly (dropRetained(), or a
|
|
606
|
+
// retainPrevious:false commit that hard-cuts) before retaining again.
|
|
607
|
+
if (outgoingCaCert !== null && priorPrevExisted) {
|
|
608
|
+
throw new MtlsCaError("mtls-ca/retained-root-exists",
|
|
609
|
+
"a retained root from a prior rotation is still present at " + paths.caCertPrev + " — a second " +
|
|
610
|
+
"retained rotation would drop it and reject clients still enrolled under it. End the existing grace " +
|
|
611
|
+
"window with dropRetained(), or rotate({ retainPrevious: false }) to hard-cut, before rotating again");
|
|
612
|
+
}
|
|
613
|
+
// A commit while a grace window is open MUST state its retention intent. With
|
|
614
|
+
// retainPrevious OMITTED, outgoingCaCert is null (so the single-window guard above
|
|
615
|
+
// does not fire) AND the hard-cut branch below (retainPrevious === false) does not
|
|
616
|
+
// fire either — so the outgoing retained root is left untouched while the active
|
|
617
|
+
// cert is replaced, silently dropping trust for the just-superseded generation (its
|
|
618
|
+
// cert becomes neither the new current nor the retained root). Refuse an ambiguous
|
|
619
|
+
// commit: rotate()/first-init always pass a boolean, so this binds only the public
|
|
620
|
+
// commit() legacy form. End the window (dropRetained() / rotate({ retainPrevious:
|
|
621
|
+
// false })), or pass retainPrevious explicitly.
|
|
622
|
+
if (priorPrevExisted && typeof opts2.retainPrevious !== "boolean") {
|
|
623
|
+
throw new MtlsCaError("mtls-ca/retention-intent-required",
|
|
624
|
+
"a retained root from a prior rotation is present at " + paths.caCertPrev + " — a commit that omits " +
|
|
625
|
+
"retainPrevious would replace the active CA while leaving that root, dropping trust for the just-" +
|
|
626
|
+
"superseded generation. Pass retainPrevious explicitly (false to hard-cut), or dropRetained() first");
|
|
627
|
+
}
|
|
369
628
|
var sealed = caKeySealedMode === "required";
|
|
370
629
|
var keyDest = sealed ? paths.caKeySealed : paths.caKey;
|
|
371
|
-
|
|
372
|
-
|
|
630
|
+
// Random-token temp names (not fixed ".tmp"): an O_EXCL create through a fixed
|
|
631
|
+
// name would EEXIST against a crash residue OR a concurrent writer's staged
|
|
632
|
+
// file — a spurious commit-failed, or a cross-process clobber. A per-commit
|
|
633
|
+
// token makes both impossible (matches atomicFile.writeSync's tmp scheme).
|
|
634
|
+
var commitTok = bCrypto().generateToken(C.BYTES.bytes(8));
|
|
635
|
+
var keyTmp = keyDest + ".tmp-" + commitTok;
|
|
636
|
+
var certTmp = paths.caCert + ".tmp-" + commitTok;
|
|
637
|
+
// Capture the PRIOR key bytes so a failed cert publish can restore them too —
|
|
638
|
+
// the key rename runs before the cert rename, so without this a rotation that
|
|
639
|
+
// fails at the cert step would leave the new key beside the OLD cert (a
|
|
640
|
+
// permanently mismatched, unusable pair). Raw on-disk bytes (sealed or plain).
|
|
641
|
+
var priorKeyExisted = nodeFs.existsSync(keyDest);
|
|
642
|
+
var priorKey = null;
|
|
643
|
+
if (priorKeyExisted) {
|
|
644
|
+
try { priorKey = atomicFile.fdSafeReadSync(keyDest, { maxBytes: C.BYTES.mib(1) }); }
|
|
645
|
+
catch (_e) { priorKey = null; }
|
|
646
|
+
}
|
|
647
|
+
// The current cert BEFORE this rotation republishes it. Recorded in the journal
|
|
648
|
+
// so recovery (and loadTrustBundle) can tell an INTERRUPTED rotation — the live
|
|
649
|
+
// cert still equals this prior one, so the cert was never republished and the
|
|
650
|
+
// journal's saved retained root must be trusted / restored — from a COMPLETED
|
|
651
|
+
// one — the live cert differs, so the journal is spent and its old retained
|
|
652
|
+
// root must NOT be re-trusted (which would defeat a hard cutoff).
|
|
653
|
+
var priorCert = null;
|
|
654
|
+
if (nodeFs.existsSync(paths.caCert)) {
|
|
655
|
+
try { priorCert = atomicFile.fdSafeReadSync(paths.caCert, { maxBytes: C.BYTES.mib(1) }); }
|
|
656
|
+
catch (_e) { priorCert = null; }
|
|
657
|
+
}
|
|
658
|
+
// Capture the prior CUSTOM label so a REJECTED re-label restores it (the outer catch and an
|
|
659
|
+
// interrupted reconcile both roll it back — else a same-cert re-stamp whose label write succeeded
|
|
660
|
+
// but whose commit then failed would leave the new label active despite reporting failure).
|
|
661
|
+
// _readPersistedAlgorithm() fails closed on a real read error, aborting before any mutation.
|
|
662
|
+
var _priorPersistedLabel = !usesDefaultEngine ? _readPersistedAlgorithm() : undefined;
|
|
663
|
+
// Abort if ANY existing prior artifact could not be captured for the journal.
|
|
664
|
+
// The rollback journal must hold a complete snapshot of the pre-rotation state:
|
|
665
|
+
// - the KEY, or a failed publish strands the CA on a new-key/old-cert pair;
|
|
666
|
+
// - the CERT, or the interrupted-vs-completed comparison (live cert == prior
|
|
667
|
+
// cert) cannot run, so reconcile could delete a newly-established grace root
|
|
668
|
+
// or restore a hard-cut one;
|
|
669
|
+
// - the RETAINED ROOT, or a failed hard-cut rotation cannot restore it.
|
|
670
|
+
// A transient read fault on any of these must not silently produce a partial
|
|
671
|
+
// journal that later mis-reconciles — refuse to mutate the CA and let the
|
|
672
|
+
// operator resolve the fault and retry.
|
|
673
|
+
if (priorKeyExisted && priorKey === null) {
|
|
674
|
+
throw new MtlsCaError("mtls-ca/prior-key-unreadable",
|
|
675
|
+
"the existing CA key at " + keyDest + " could not be read to capture a rollback copy — refusing to " +
|
|
676
|
+
"overwrite it (a failed publish would otherwise strand the CA); resolve the read fault and retry");
|
|
677
|
+
}
|
|
678
|
+
if (nodeFs.existsSync(paths.caCert) && priorCert === null) {
|
|
679
|
+
throw new MtlsCaError("mtls-ca/prior-cert-unreadable",
|
|
680
|
+
"the existing CA certificate at " + paths.caCert + " could not be read to capture the rollback " +
|
|
681
|
+
"journal's prior-cert marker — refusing to rotate (a partial journal could mis-reconcile the " +
|
|
682
|
+
"retained root after a crash); resolve the read fault and retry");
|
|
683
|
+
}
|
|
684
|
+
if (priorPrevExisted && priorPrev === null) {
|
|
685
|
+
throw new MtlsCaError("mtls-ca/prior-retained-root-unreadable",
|
|
686
|
+
"the existing retained root at " + paths.caCertPrev + " could not be read to capture a rollback " +
|
|
687
|
+
"copy — refusing to rotate (a failed rotation could otherwise permanently lose it, stranding clients " +
|
|
688
|
+
"in the existing grace window); resolve the read fault and retry");
|
|
689
|
+
}
|
|
690
|
+
// A CERT-ONLY store (ca.crt present but NO key at the current mode's destination) is corrupt
|
|
691
|
+
// or half-published. Committing over it journals no prior key (the journal write below is
|
|
692
|
+
// gated on priorKeyExisted), so if the new key rename lands and the cert rename then fails
|
|
693
|
+
// (or the process exits between them), the catch has no prior key to restore AND leaves the
|
|
694
|
+
// new key in place — an old-cert/new-key pair with no journal that every later initCA() then
|
|
695
|
+
// rejects as ca-pair-inconsistent. Refuse before mutating; the operator restores the key (or
|
|
696
|
+
// removes ca.crt for a clean re-init). A key-only cold start (key present, cert absent) is
|
|
697
|
+
// the LEGITIMATE inverse and is handled via the journal's newCert discriminator.
|
|
698
|
+
if (nodeFs.existsSync(paths.caCert) && !priorKeyExisted) {
|
|
699
|
+
throw new MtlsCaError("mtls-ca/ca-pair-inconsistent",
|
|
700
|
+
"the stored CA certificate at " + paths.caCert + " has no matching private key at " + keyDest +
|
|
701
|
+
" (a corrupt or half-published CA state) — refusing to commit over it, which would leave an " +
|
|
702
|
+
"unrecoverable new-key/old-cert pair with no rollback journal; restore the key or remove " + paths.caCert +
|
|
703
|
+
" to re-initialize");
|
|
704
|
+
}
|
|
705
|
+
// Crash-recovery rollback journal. The CA key, current cert, and retained root
|
|
706
|
+
// (ca.prev.crt) are separate files, so the renames/writes below cannot be one
|
|
707
|
+
// atomic swap: if the process dies mid-publish, the in-memory catch rollback
|
|
708
|
+
// never runs and BOTH the prior key (already overwritten) and the prior
|
|
709
|
+
// retained root (already replaced or removed) are otherwise unrecoverable —
|
|
710
|
+
// stranding the CA (mtls-ca/ca-pair-inconsistent) AND dropping trust for
|
|
711
|
+
// clients still enrolled under the formerly-retained generation. Persist both
|
|
712
|
+
// prior artifacts durably (fsync'd) BEFORE mutating them; the journal's
|
|
713
|
+
// presence is the "rotation in progress" marker _reconcileCommitJournalLocked()
|
|
714
|
+
// rolls back from, and a clean commit removes it once the new state is durably
|
|
715
|
+
// consistent. Manifest: key = base64 prior key bytes; prevAction/prevData
|
|
716
|
+
// capture the prior ca.prev.crt (restore its bytes, delete a prev this rotation
|
|
717
|
+
// created, or leave an unreadable prior untouched — mirroring the catch).
|
|
718
|
+
var keyJournal = keyDest + ".rollback";
|
|
719
|
+
var keyJournalWritten = false;
|
|
720
|
+
// The stale CRL is invalidated by moving it ASIDE to a fixed rollback name BEFORE
|
|
721
|
+
// the cert publish, then deleting it once the new cert lands (see the CRL block
|
|
722
|
+
// below). Capturing crlExisted + crlRollback here (function scope) lets the catch
|
|
723
|
+
// restore it: the CA it rolled back to is still active, so its CRL is still valid
|
|
724
|
+
// and must keep being served. reconcile() drives the same fixed name.
|
|
725
|
+
var crlRollback = _crlRollbackPath();
|
|
726
|
+
var crlExisted = nodeFs.existsSync(paths.crl);
|
|
727
|
+
// Only invalidate the CRL when the committed cert actually CHANGES the issuer: an
|
|
728
|
+
// idempotent recommit of the same certificate leaves the CRL's issuer unchanged, so
|
|
729
|
+
// the valid CRL must keep being served rather than being moved aside and deleted.
|
|
730
|
+
var caCertChanged = priorCert === null || !_sameCert(priorCert.toString("utf8"), opts2.caCertPem);
|
|
731
|
+
var movingCrlAside = crlExisted && caCertChanged;
|
|
373
732
|
|
|
374
733
|
// CodeQL js/insecure-temporary-file defense — exclusive-create ("wx")
|
|
375
734
|
// refuses to write through a pre-existing path (symlink or regular
|
|
@@ -394,26 +753,307 @@ function create(opts) {
|
|
|
394
753
|
}
|
|
395
754
|
}
|
|
396
755
|
try {
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
756
|
+
// The NEW key in its on-disk form (sealed or plain) — written to the temp AND
|
|
757
|
+
// recorded in the journal so recovery can complete a rotation whose key rename
|
|
758
|
+
// was lost (a Windows/FUSE fsyncDir no-op) as byte-exactly as it can roll one
|
|
759
|
+
// back, without depending on node being able to parse the key.
|
|
760
|
+
var newKeyOnDisk = Buffer.from(sealed
|
|
761
|
+
? (_requireVault("sealed CA key commit"), vault.seal(opts2.caKeyPem))
|
|
762
|
+
: opts2.caKeyPem);
|
|
763
|
+
_writeExclusive(keyTmp, newKeyOnDisk, 0o600);
|
|
403
764
|
_writeExclusive(certTmp, opts2.caCertPem, 0o644);
|
|
765
|
+
// Persist a COMPLETE snapshot of the pre-rotation state AND the intended
|
|
766
|
+
// post-rotation state before mutating anything, so recovery can drive the CA
|
|
767
|
+
// to whichever state the rotation reached — byte-exact, engine-agnostic:
|
|
768
|
+
// key/cert — the prior key + prior cert (the interrupted discriminator);
|
|
769
|
+
// newKey — the new key, to finish a completed rotation whose key
|
|
770
|
+
// rename didn't stick;
|
|
771
|
+
// retainAfter — whether a COMPLETED rotation should retain the outgoing
|
|
772
|
+
// root (= the prior cert) or hard-cut it;
|
|
773
|
+
// newCert — the intended NEW cert, the completed-vs-interrupted
|
|
774
|
+
// discriminator when there is NO prior cert (a key-only cold
|
|
775
|
+
// start: ca.key present, ca.crt absent). Without it, reconcile
|
|
776
|
+
// could not classify a completed key-only init (manifest.cert
|
|
777
|
+
// is null) and would restore the orphaned prior key beside the
|
|
778
|
+
// newly published cert, leaving the CA an unusable pair;
|
|
779
|
+
// prevAction/prevData — how to roll the retained root BACK on an interrupted
|
|
780
|
+
// rotation ("restore" prior bytes / "delete" a prev this
|
|
781
|
+
// rotation created / "leave" an unreadable prior).
|
|
782
|
+
// customAlgorithm — the CUSTOM-engine effective label this commit publishes; the
|
|
783
|
+
// ca.algorithm write is done under this journal so a crash leaves it
|
|
784
|
+
// recoverable (a custom label is not cert-derivable).
|
|
785
|
+
// The CUSTOM-engine effective label to publish alongside the CA (the default engine derives its
|
|
786
|
+
// label from the cert, so it is never persisted). Prefer an explicit override — a
|
|
787
|
+
// commit/rotate({ algorithm }) or the preserved persisted label a bare custom rotate resolves —
|
|
788
|
+
// and otherwise fall back to the handle's own effective pin (caAlgorithm), so a pinned handle
|
|
789
|
+
// that bootstraps or migrates the CA via commit() WITHOUT redundantly repeating the label still
|
|
790
|
+
// publishes ca.algorithm (else a sibling adopts the CA under its own stale pin / engine default).
|
|
791
|
+
var _customCommitLabel = !usesDefaultEngine
|
|
792
|
+
? ((typeof opts2.algorithm === "string" && opts2.algorithm.length > 0) ? opts2.algorithm
|
|
793
|
+
: ((typeof caAlgorithm === "string" && caAlgorithm.length > 0) ? caAlgorithm : null))
|
|
794
|
+
: null;
|
|
795
|
+
if (priorKeyExisted && priorKey !== null) {
|
|
796
|
+
/* c8 ignore next -- the "leave" arm is dead: the prior-retained-root-unreadable check above throws when priorPrevExisted && priorPrev===null, so priorPrev!==null here */
|
|
797
|
+
var prevAction = !priorPrevExisted ? "delete" : (priorPrev !== null ? "restore" : "leave");
|
|
798
|
+
// The retained root a COMPLETED commit should leave in ca.prev.crt. Normally the outgoing
|
|
799
|
+
// (prior) cert when retaining, or removed on a hard cut — BUT an idempotent recommit that only
|
|
800
|
+
// REFORMATS the current cert (outgoingCaCert===null, same identity) with a grace window ALREADY
|
|
801
|
+
// open must PRESERVE the existing retained root (priorPrev), not delete it or replace it with
|
|
802
|
+
// the same-identity prior cert. Record the ACTUAL bytes: reconcile reads the byte-different
|
|
803
|
+
// reformatted live cert as COMPLETED and, without this, would derive retainAfter:false from
|
|
804
|
+
// outgoingCaCert===null and DELETE ca.prev.crt, stranding clients enrolled under it.
|
|
805
|
+
var retainAfterCert = (opts2.retainPrevious === false)
|
|
806
|
+
? null
|
|
807
|
+
: (outgoingCaCert !== null ? outgoingCaCert : priorPrev);
|
|
808
|
+
atomicFile.writeSync(keyJournal, JSON.stringify({
|
|
809
|
+
key: priorKey.toString("base64"),
|
|
810
|
+
newKey: newKeyOnDisk.toString("base64"),
|
|
811
|
+
cert: priorCert !== null ? priorCert.toString("base64") : null,
|
|
812
|
+
newCert: Buffer.from(opts2.caCertPem).toString("base64"),
|
|
813
|
+
retainAfter: retainAfterCert !== null,
|
|
814
|
+
retainAfterCert: retainAfterCert !== null ? retainAfterCert.toString("base64") : null,
|
|
815
|
+
crlMovedAside: movingCrlAside, // did THIS commit move a CRL aside (so reconcile may restore it)?
|
|
816
|
+
prevAction: prevAction,
|
|
817
|
+
prevData: prevAction === "restore" ? priorPrev.toString("base64") : null,
|
|
818
|
+
// The CUSTOM-engine effective label this commit publishes, so the ca.algorithm write below is
|
|
819
|
+
// crash-ATOMIC with the CA: a power loss between the CA publish and the label write leaves this
|
|
820
|
+
// journal, and a COMPLETED-commit reconcile restores the label from here — else ca.algorithm
|
|
821
|
+
// stays stale (the old label) against the new CA and every sibling issues under the wrong one.
|
|
822
|
+
customAlgorithm: _customCommitLabel,
|
|
823
|
+
// The label BEFORE this commit — an INTERRUPTED-commit reconcile restores it (mirrors the outer
|
|
824
|
+
// catch), so a re-label whose write landed but whose commit then rolled back is fully undone.
|
|
825
|
+
priorCustomAlgorithm: (_priorPersistedLabel !== undefined ? _priorPersistedLabel : null),
|
|
826
|
+
}), { fileMode: 0o600 });
|
|
827
|
+
keyJournalWritten = true;
|
|
828
|
+
}
|
|
829
|
+
// No journal was written (an INITIAL commit with no prior key, or an unreadable prior key), so
|
|
830
|
+
// nothing can reconcile the custom label from disk after a crash. Persist it BEFORE publishing
|
|
831
|
+
// the key/cert, so a failed label write aborts here — before any CA lands — rather than leaving
|
|
832
|
+
// a labelless CA a sibling would issue under its own stale pin. A commit WITH a journal persists
|
|
833
|
+
// the label at the commit point below and rolls it forward from the journal on failure instead.
|
|
834
|
+
if (!keyJournalWritten && _customCommitLabel !== null) _persistAlgorithm(_customCommitLabel);
|
|
404
835
|
atomicFile.renameWithRetry(keyTmp, keyDest);
|
|
405
|
-
|
|
836
|
+
// Make the KEY rename durable BEFORE publishing the cert. renameSync alone is
|
|
837
|
+
// not crash-durable and keyDest/caCert can have distinct operator-configured
|
|
838
|
+
// parents, so without this a power loss could persist the LATER cert rename
|
|
839
|
+
// while losing the key rename — leaving an old-key/new-cert pair the journal
|
|
840
|
+
// (which holds only the OLD key) cannot repair. Ordering the durability this
|
|
841
|
+
// way means at every crash point the on-disk pair is either consistent or
|
|
842
|
+
// recoverable from the OLD-key journal. fsyncDir is best-effort (Windows
|
|
843
|
+
// rejects directory fsync), matching atomicFile's own durability contract.
|
|
844
|
+
atomicFile.fsyncDir(nodePath.dirname(keyDest));
|
|
845
|
+
// Publish the retained root BEFORE the new current cert, so a concurrent
|
|
846
|
+
// loadTrustBundle() that observes the new ca.crt already sees the outgoing
|
|
847
|
+
// root in ca.prev.crt — closing the window where only the new root would be
|
|
848
|
+
// trusted. Both the retain write AND the retain:false removal are REQUIRED
|
|
849
|
+
// parts of the commit: a failure throws to the outer catch, which rolls the
|
|
850
|
+
// whole rotation back. Retaining but omitting the outgoing root breaks the
|
|
851
|
+
// no-outage migration (clients under the superseded CA are rejected); failing
|
|
852
|
+
// to remove the old root under retainPrevious:false silently keeps trusting a
|
|
853
|
+
// root the operator asked to hard-cut, admitting certs chained to it. Never
|
|
854
|
+
// publish a new CA whose trust bundle contradicts the requested retention.
|
|
855
|
+
if (outgoingCaCert !== null) {
|
|
856
|
+
// writeSync fsyncs its own file + directory, so the retain write is durable.
|
|
857
|
+
atomicFile.writeSync(paths.caCertPrev, outgoingCaCert, { fileMode: 0o644 });
|
|
858
|
+
} else if (opts2.retainPrevious === false && nodeFs.existsSync(paths.caCertPrev)) {
|
|
859
|
+
nodeFs.unlinkSync(paths.caCertPrev);
|
|
860
|
+
// Make the removal durable: ca.prev.crt may live in a different parent than
|
|
861
|
+
// ca.crt (whose fsync below would not cover it), so without this a power
|
|
862
|
+
// loss could resurrect the stale root after the rotation reported success,
|
|
863
|
+
// leaving loadTrustBundle() trusting a root the operator hard-cut.
|
|
864
|
+
atomicFile.fsyncDir(nodePath.dirname(paths.caCertPrev));
|
|
865
|
+
}
|
|
866
|
+
// Invalidate a persisted CRL as a REQUIRED part of the commit, but tie its fate
|
|
867
|
+
// ATOMICALLY to the cert publication: this commit republishes the CA cert, so a
|
|
868
|
+
// CRL persisted under the OLD cert becomes signed by a superseded issuer, yet if
|
|
869
|
+
// the publish fails (or a crash intervenes) the OLD CA stays active and its CRL
|
|
870
|
+
// is STILL VALID and must keep being served. So MOVE the CRL aside to a fixed
|
|
871
|
+
// rollback name here (before the cert rename, the point of no return) rather than
|
|
872
|
+
// deleting it: a failure to move it (e.g. paths.crl in a separately-configured
|
|
873
|
+
// read-only directory) throws to the outer catch, which rolls the whole commit
|
|
874
|
+
// back — the rename is a required precondition. Only AFTER the new cert lands is
|
|
875
|
+
// the moved-aside CRL truly stale (its issuer is superseded), so it is deleted
|
|
876
|
+
// then. A rollback (catch) or an interrupted-rotation reconcile renames it back;
|
|
877
|
+
// a completed-rotation reconcile deletes it. fsyncDir is best-effort (it swallows
|
|
878
|
+
// platform errors); only the rename can fail the commit.
|
|
879
|
+
if (movingCrlAside) {
|
|
880
|
+
// Clear an ORPHAN crl.rollback first: a prior CA-changing commit's best-effort delete of
|
|
881
|
+
// its moved-aside CRL may have failed, leaving one behind. On Windows renameSync cannot
|
|
882
|
+
// replace an existing destination, so without this the move-aside below would exhaust
|
|
883
|
+
// renameWithRetry and abort every later rotation until the orphan is removed by hand. A
|
|
884
|
+
// LEGITIMATE crl.rollback was already restored/deleted by the reconcile the caller ran
|
|
885
|
+
// before this commit, so any survivor here is inert (a superseded-issuer CRL).
|
|
886
|
+
if (nodeFs.existsSync(crlRollback)) {
|
|
887
|
+
try { nodeFs.unlinkSync(crlRollback); }
|
|
888
|
+
/* c8 ignore next -- best-effort: if this unlink fails the renameWithRetry below fails the commit closed, so it is not swallowed silently */
|
|
889
|
+
catch (_orphanErr) { caLog.debug("cleanup-failed", { op: "fs.unlinkSync", path: crlRollback, error: _orphanErr.message }); }
|
|
890
|
+
}
|
|
891
|
+
atomicFile.renameWithRetry(paths.crl, crlRollback);
|
|
892
|
+
atomicFile.fsyncDir(nodePath.dirname(paths.crl));
|
|
893
|
+
}
|
|
894
|
+
atomicFile.renameWithRetry(certTmp, paths.caCert); // publish the new current LAST
|
|
895
|
+
// Make the cert rename durable too (see the key-rename fsync note) before
|
|
896
|
+
// removing the recovery journal below — else a power loss could persist the
|
|
897
|
+
// journal deletion while losing the cert rename.
|
|
898
|
+
atomicFile.fsyncDir(nodePath.dirname(paths.caCert));
|
|
899
|
+
// The new cert is published — the moved-aside CRL is now signed by a superseded
|
|
900
|
+
// issuer, so delete it for good (best-effort: a leftover is an unserved orphan at
|
|
901
|
+
// the .rollback name, and a completed-rotation reconcile deletes it on next open).
|
|
902
|
+
// A consumer regenerates the CRL under the new CA via generateCrl().
|
|
903
|
+
if (movingCrlAside && nodeFs.existsSync(crlRollback)) {
|
|
904
|
+
try {
|
|
905
|
+
nodeFs.unlinkSync(crlRollback);
|
|
906
|
+
atomicFile.fsyncDir(nodePath.dirname(crlRollback));
|
|
907
|
+
caLog.info("invalidated stale CRL on CA change (regenerate with generateCrl)", { path: paths.crl });
|
|
908
|
+
}
|
|
909
|
+
/* c8 ignore next -- best-effort: unlink of the CRL we just moved aside does not throw here */
|
|
910
|
+
catch (_ce) { caLog.debug("cleanup-failed", { op: "fs.unlinkSync", path: crlRollback, error: _ce.message }); }
|
|
911
|
+
}
|
|
912
|
+
// Persist the CUSTOM-engine label alongside the now-durable CA, BEFORE deleting the journal
|
|
913
|
+
// (the commit point): a power loss between the CA publish and here leaves the journal, and a
|
|
914
|
+
// completed-commit reconcile restores ca.algorithm from manifest.customAlgorithm — so the label
|
|
915
|
+
// can never be left stale (old) against the new CA. Only the JOURNALED path lands here; a
|
|
916
|
+
// journal-less commit already persisted the label before publishing (above). Default-engine
|
|
917
|
+
// labels are cert-derived.
|
|
918
|
+
var _labelPersistDeferred = false;
|
|
919
|
+
if (keyJournalWritten && _customCommitLabel !== null) {
|
|
920
|
+
try {
|
|
921
|
+
_persistAlgorithm(_customCommitLabel);
|
|
922
|
+
} catch (_le) {
|
|
923
|
+
// The new key/cert/prev/CRL are already durably published and consistent here; only the
|
|
924
|
+
// ca.algorithm label file failed to write. For a CA-CHANGING commit the outer catch CANNOT
|
|
925
|
+
// un-publish the new cert (line 922's rollback restores the prior key + retained root only,
|
|
926
|
+
// leaving an old-key/new-cert pair, then deletes the journal that would heal it) — so DON'T
|
|
927
|
+
// abort: leave the journal and roll forward. The next reconcile reads live cert != journal.cert
|
|
928
|
+
// (COMPLETED) and restores the label from manifest.customAlgorithm. A SAME-cert re-stamp CAN
|
|
929
|
+
// roll back to a fully consistent prior state, so fail closed there (as the journal-delete
|
|
930
|
+
// path does) rather than report success with an unpersisted label.
|
|
931
|
+
if (!caCertChanged) throw _le;
|
|
932
|
+
_labelPersistDeferred = true;
|
|
933
|
+
caLog.debug("cleanup-failed", { op: "persist-algorithm", path: paths.algorithm, error: _le.message });
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
// The new key/cert pair is durably published and consistent on disk — the rollback journal has
|
|
937
|
+
// served its purpose; remove it (the commit point). Skip the delete when the label write failed
|
|
938
|
+
// above so the surviving journal reconciles the label forward on the next boot.
|
|
939
|
+
if (keyJournalWritten && !_labelPersistDeferred) {
|
|
940
|
+
try {
|
|
941
|
+
nodeFs.unlinkSync(keyJournal);
|
|
942
|
+
atomicFile.fsyncDir(nodePath.dirname(keyJournal)); // make the deletion durable
|
|
943
|
+
}
|
|
944
|
+
catch (_je) {
|
|
945
|
+
// A CERT-CHANGING commit self-heals a surviving journal: the next reconcile sees
|
|
946
|
+
// live cert != journal.cert (COMPLETED) and rolls forward, deleting it. But a
|
|
947
|
+
// SAME-CERT commit (caCertChanged false — e.g. a retainPrevious:false hard-cut of
|
|
948
|
+
// the grace window) CANNOT: reconcile reads live cert == journal.cert as INTERRUPTED
|
|
949
|
+
// and restores ca.prev.crt, AND the lock-free _journalRetainedRoot() re-adds it —
|
|
950
|
+
// resurrecting the very retained root the operator cut, while commit() reported
|
|
951
|
+
// success. So propagate the unlink failure there (fail closed, as reconcile's own
|
|
952
|
+
// journal delete does), so a cutoff cannot succeed with an authoritative journal
|
|
953
|
+
// still present. For a cert-changing commit the leftover self-heals, so keep the
|
|
954
|
+
// deletion best-effort (a spurious rollback of a published cert would be worse).
|
|
955
|
+
if (!caCertChanged) throw _je;
|
|
956
|
+
caLog.debug("cleanup-failed", { op: "fs.unlinkSync", path: keyJournal, error: _je.message });
|
|
957
|
+
}
|
|
958
|
+
}
|
|
406
959
|
} catch (e) {
|
|
407
960
|
// Best-effort cleanup of half-written tmp files; the original
|
|
408
961
|
// commit error is what we re-raise. Log cleanup failures at debug
|
|
409
962
|
// so a genuinely-broken filesystem state surfaces in operator logs
|
|
410
963
|
// rather than getting silently swallowed.
|
|
964
|
+
/* c8 ignore next -- defensive existence guard: the tmp file may or may not exist depending where the commit threw; both arms are best-effort cleanup */
|
|
411
965
|
try { if (nodeFs.existsSync(keyTmp)) nodeFs.unlinkSync(keyTmp); }
|
|
412
966
|
/* c8 ignore next -- best-effort cleanup: unlink of a tmp file we just created does not throw here */
|
|
413
967
|
catch (cleanupErr) { caLog.debug("cleanup-failed", { op: "fs.unlinkSync", path: keyTmp, error: cleanupErr.message }); }
|
|
968
|
+
/* c8 ignore next -- defensive existence guard: the tmp file may or may not exist depending where the commit threw; both arms are best-effort cleanup */
|
|
414
969
|
try { if (nodeFs.existsSync(certTmp)) nodeFs.unlinkSync(certTmp); }
|
|
415
970
|
/* c8 ignore next -- best-effort cleanup: unlink of a tmp file we just created does not throw here */
|
|
416
971
|
catch (cleanupErr) { caLog.debug("cleanup-failed", { op: "fs.unlinkSync", path: certTmp, error: cleanupErr.message }); }
|
|
972
|
+
// The rotation FAILED (the new cert was not published). Roll back the two
|
|
973
|
+
// artifacts the key rename + retained-root update already replaced, so the
|
|
974
|
+
// previously-active CA survives intact: restore the prior KEY (else the new
|
|
975
|
+
// key sits beside the old cert — a mismatched, unusable pair), and restore
|
|
976
|
+
// the prior retained root (or remove a prev created for this failed attempt).
|
|
977
|
+
var keyRolledBack = false;
|
|
978
|
+
try {
|
|
979
|
+
if (priorKeyExisted && priorKey !== null) {
|
|
980
|
+
atomicFile.writeSync(keyDest, priorKey, { fileMode: 0o600 });
|
|
981
|
+
}
|
|
982
|
+
keyRolledBack = true;
|
|
983
|
+
/* c8 ignore next 4 -- best-effort double-fault path: the key-restore writeSync does not throw in tests */
|
|
984
|
+
} catch (keyRbErr) {
|
|
985
|
+
caLog.error("ca-key-rollback-failed",
|
|
986
|
+
{ path: keyDest, error: (keyRbErr && keyRbErr.message) || String(keyRbErr) });
|
|
987
|
+
}
|
|
988
|
+
var prevRolledBack = false;
|
|
989
|
+
try {
|
|
990
|
+
if (priorPrevExisted && priorPrev !== null) {
|
|
991
|
+
atomicFile.writeSync(paths.caCertPrev, priorPrev, { fileMode: 0o644 });
|
|
992
|
+
} else if (!priorPrevExisted && nodeFs.existsSync(paths.caCertPrev)) {
|
|
993
|
+
// Remove the retained root THIS failed rotation created, and fsync its
|
|
994
|
+
// parent so the removal is durable: else a power loss could preserve the
|
|
995
|
+
// root creation while preserving the journal deletion, leaving a phantom
|
|
996
|
+
// ca.prev.crt that _commitLocked() reads as an existing grace window and
|
|
997
|
+
// rejects every later retained rotation (mtls-ca/retained-root-exists).
|
|
998
|
+
nodeFs.unlinkSync(paths.caCertPrev);
|
|
999
|
+
atomicFile.fsyncDir(nodePath.dirname(paths.caCertPrev));
|
|
1000
|
+
}
|
|
1001
|
+
prevRolledBack = true;
|
|
1002
|
+
/* c8 ignore next 4 -- best-effort double-fault path: the retained-root restore/unlink does not throw in tests */
|
|
1003
|
+
} catch (rbErr) {
|
|
1004
|
+
caLog.error("retained-root-rollback-failed",
|
|
1005
|
+
{ path: paths.caCertPrev, error: (rbErr && rbErr.message) || String(rbErr) });
|
|
1006
|
+
}
|
|
1007
|
+
// Restore the CRL THIS commit moved aside: the CA it rolled back to is still
|
|
1008
|
+
// active, so its CRL is still valid and must keep being served. Gate on crlExisted
|
|
1009
|
+
// (whether THIS commit moved a CRL aside) — a crl.rollback present when crlExisted
|
|
1010
|
+
// is false is an ORPHAN from a prior commit whose best-effort delete failed, signed
|
|
1011
|
+
// by an earlier issuer; restoring it would publish a stale-issuer CRL under the
|
|
1012
|
+
// still-active CA. A no-op when the move never ran or the CRL is already restored.
|
|
1013
|
+
var crlRolledBack = false;
|
|
1014
|
+
try {
|
|
1015
|
+
if (movingCrlAside && nodeFs.existsSync(crlRollback) && !nodeFs.existsSync(paths.crl)) {
|
|
1016
|
+
atomicFile.renameWithRetry(crlRollback, paths.crl);
|
|
1017
|
+
atomicFile.fsyncDir(nodePath.dirname(paths.crl));
|
|
1018
|
+
}
|
|
1019
|
+
crlRolledBack = true;
|
|
1020
|
+
/* c8 ignore next 4 -- best-effort double-fault path: the CRL restore rename does not throw in tests */
|
|
1021
|
+
} catch (crlRbErr) {
|
|
1022
|
+
caLog.error("crl-rollback-failed",
|
|
1023
|
+
{ path: paths.crl, error: (crlRbErr && crlRbErr.message) || String(crlRbErr) });
|
|
1024
|
+
}
|
|
1025
|
+
// Roll back a partially-applied CUSTOM re-label: this commit may have already written the new
|
|
1026
|
+
// ca.algorithm (a same-cert re-stamp whose label write succeeded but whose journal delete then
|
|
1027
|
+
// threw), so the rejected commit must not leave the new label active. Restore the prior label, or
|
|
1028
|
+
// remove ca.algorithm if there was none. Default engines never persist a label, so nothing to do.
|
|
1029
|
+
var labelRolledBack = false;
|
|
1030
|
+
try {
|
|
1031
|
+
if (!usesDefaultEngine && _customCommitLabel !== null) {
|
|
1032
|
+
if (_priorPersistedLabel !== undefined) {
|
|
1033
|
+
_persistAlgorithm(_priorPersistedLabel);
|
|
1034
|
+
} else if (nodeFs.existsSync(paths.algorithm)) {
|
|
1035
|
+
nodeFs.unlinkSync(paths.algorithm);
|
|
1036
|
+
atomicFile.fsyncDir(nodePath.dirname(paths.algorithm));
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
labelRolledBack = true;
|
|
1040
|
+
/* c8 ignore next 4 -- best-effort double-fault path: the label restore writeSync/unlink does not throw in tests */
|
|
1041
|
+
} catch (lblRbErr) {
|
|
1042
|
+
caLog.error("ca-label-rollback-failed",
|
|
1043
|
+
{ path: paths.algorithm, error: (lblRbErr && lblRbErr.message) || String(lblRbErr) });
|
|
1044
|
+
}
|
|
1045
|
+
// The in-memory rollback restored the live key, the retained root, the CRL, AND the label,
|
|
1046
|
+
// so the journal is spent — remove it. If ANY restore FAILED, keep the journal so
|
|
1047
|
+
// the next _reconcileCommitJournalLocked() completes the rollback: it holds the
|
|
1048
|
+
// prior key, the retained root (prevData), AND the prior label (priorCustomAlgorithm), and the
|
|
1049
|
+
// fixed-name crl.rollback lets reconcile finish the CRL restore too. Deleting it on a partial
|
|
1050
|
+
// rollback would permanently lose whichever the in-memory restore could not write (e.g. a hard-
|
|
1051
|
+
// cut rotation whose key rollback succeeds but whose retained-root restore fails).
|
|
1052
|
+
if (keyJournalWritten && keyRolledBack && prevRolledBack && crlRolledBack && labelRolledBack) {
|
|
1053
|
+
try { nodeFs.unlinkSync(keyJournal); }
|
|
1054
|
+
/* c8 ignore next -- best-effort: unlink of the journal we just wrote does not throw here */
|
|
1055
|
+
catch (_je) { caLog.debug("cleanup-failed", { op: "fs.unlinkSync", path: keyJournal, error: _je.message }); }
|
|
1056
|
+
}
|
|
417
1057
|
throw new MtlsCaError("mtls-ca/commit-failed",
|
|
418
1058
|
"atomic CA commit failed: " + ((e && e.message) || String(e)));
|
|
419
1059
|
}
|
|
@@ -424,48 +1064,581 @@ function create(opts) {
|
|
|
424
1064
|
};
|
|
425
1065
|
}
|
|
426
1066
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
1067
|
+
// Functionally verify a bundled-engine commit's material can perform BOTH engine operations —
|
|
1068
|
+
// sign a leaf AND sign a CRL — before publishing. node's X509Certificate exposes no KeyUsage
|
|
1069
|
+
// extension and the toolkit has no cert parser, so a CA cert missing keyCertSign or cRLSign passes
|
|
1070
|
+
// every static check yet the next generateClientCert()/generateCrl() fails (x509/bad-input /
|
|
1071
|
+
// crl/bad-input), disabling issuance or the revocation-export path after a "successful" commit. The
|
|
1072
|
+
// engine ops only sign and return PEM (no storage I/O), so this is a safe pre-publication probe.
|
|
1073
|
+
async function _assertCommittedCaUsable(caCertPem, caKeyPem) {
|
|
1074
|
+
/* c8 ignore start -- defensive: a certificate node reports as .ca=true carries a basicConstraints pathLen, which RFC 5280 sec. 4.2.1.9 requires be paired with keyUsage keyCertSign, so a conforming CA that reaches this point always signs leaves; this arm guards a non-conforming externally-built CA (pathLen without keyCertSign) and is not reachable with toolkit-built fixtures (the toolkit refuses to emit pathLen without keyCertSign) */
|
|
1075
|
+
try {
|
|
1076
|
+
await engine.signClientCert({ cn: "commit-usability-preflight", caCertPem: caCertPem, caKeyPem: caKeyPem });
|
|
1077
|
+
} catch (e) {
|
|
1078
|
+
throw new MtlsCaError("mtls-ca/ca-cannot-issue",
|
|
1079
|
+
"commit: the bundled engine cannot issue a leaf under the committed CA (its key usage likely omits " +
|
|
1080
|
+
"keyCertSign, or the material is otherwise unusable): " + ((e && e.message) || String(e)) +
|
|
1081
|
+
" — refusing to publish a CA that cannot sign certificates");
|
|
1082
|
+
}
|
|
1083
|
+
/* c8 ignore stop */
|
|
1084
|
+
var _now = Date.now();
|
|
1085
|
+
try {
|
|
1086
|
+
await engine.generateCrl({ caCertPem: caCertPem, caKeyPem: caKeyPem, revocations: [],
|
|
1087
|
+
thisUpdate: new Date(_now), nextUpdate: new Date(_now + C.TIME.days(1)) });
|
|
1088
|
+
} catch (e) {
|
|
1089
|
+
throw new MtlsCaError("mtls-ca/ca-cannot-sign-crl",
|
|
1090
|
+
"commit: the bundled engine cannot sign a CRL under the committed CA (its key usage likely omits cRLSign): " +
|
|
1091
|
+
/* c8 ignore next -- String(e) fallback unreachable: a thrown engine Error always has a .message */
|
|
1092
|
+
((e && e.message) || String(e)) + " — refusing to publish a CA that would disable the revocation-export path");
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
// Public commit — the LOCKED commit primitive (the migration docs direct
|
|
1097
|
+
// operators here, so it must be safe against a concurrent rotate/init over the
|
|
1098
|
+
// same dataDir). It acquires the rotation lock so its key/cert renames and
|
|
1099
|
+
// rollback-journal writes cannot interleave with another commit/rotation and
|
|
1100
|
+
// leave a mixed pair or a lost journal. rotate() and first-time creation call
|
|
1101
|
+
// _commitLocked directly (they already hold the lock; atomicFile.lock is
|
|
1102
|
+
// non-reentrant). Returns a PROMISE — await it.
|
|
1103
|
+
function commit(opts2) {
|
|
1104
|
+
// Validate the argument shape SYNCHRONOUSLY (a config-time typo), before taking
|
|
1105
|
+
// the lock, so a caller sees a synchronous TypeError-style throw for bad input;
|
|
1106
|
+
// the durable work (and its runtime aborts) happens under the lock.
|
|
1107
|
+
if (!opts2 || typeof opts2.caKeyPem !== "string" || typeof opts2.caCertPem !== "string") {
|
|
1108
|
+
throw new MtlsCaError("mtls-ca/bad-commit",
|
|
1109
|
+
"commit requires opts.caKeyPem and opts.caCertPem (PEM strings)");
|
|
1110
|
+
}
|
|
1111
|
+
// A supplied algorithm is the NEW effective label for a pinned CUSTOM-engine handle
|
|
1112
|
+
// migrating to a different-algorithm CA (the bundled label can't be inferred from a
|
|
1113
|
+
// custom cert). Validate its shape synchronously, matching rotate({ algorithm }).
|
|
1114
|
+
if (opts2.algorithm !== undefined && (typeof opts2.algorithm !== "string" || opts2.algorithm.length === 0)) {
|
|
1115
|
+
throw new MtlsCaError("mtls-ca/bad-algorithm",
|
|
1116
|
+
"commit: opts.algorithm must be a non-empty string label when set (the new effective algorithm for a " +
|
|
1117
|
+
"pinned custom-engine handle migrating to a different-algorithm CA)");
|
|
1118
|
+
}
|
|
1119
|
+
// The BUNDLED engine always emits parseable X.509 material, so require the same from a commit
|
|
1120
|
+
// to it — else a typo / garbage string ("garbage-cert") would slip past _caPairConsistent
|
|
1121
|
+
// (which returns "consistent" whenever parsing throws, the opaque-custom fallback) and publish
|
|
1122
|
+
// unusable material that bricks every later issuance. This parse requirement is DEFAULT-engine
|
|
1123
|
+
// only; a custom engine may legitimately commit opaque cert/key material.
|
|
1124
|
+
if (usesDefaultEngine) {
|
|
1125
|
+
var _commitCert = null, _commitKey = null;
|
|
1126
|
+
try { _commitCert = new nodeCrypto.X509Certificate(opts2.caCertPem); } catch (_ce) { _commitCert = null; }
|
|
1127
|
+
try { _commitKey = nodeCrypto.createPrivateKey(opts2.caKeyPem); } catch (_ke) { _commitKey = null; }
|
|
1128
|
+
if (_commitCert === null || _commitKey === null) {
|
|
1129
|
+
throw new MtlsCaError("mtls-ca/bad-commit",
|
|
1130
|
+
"commit: the bundled CA engine requires a parseable X.509 certificate and private key, but the supplied " +
|
|
1131
|
+
"caCertPem/caKeyPem did not parse — refusing to publish unusable material that would fail every subsequent " +
|
|
1132
|
+
"issuance; supply valid PEM (a custom engine may commit opaque material)");
|
|
1133
|
+
}
|
|
1134
|
+
// The bundled engine signs leaves WITH the committed CA, so the material must be a CA
|
|
1135
|
+
// CERTIFICATE (basicConstraints cA:true). A leaf / end-entity cert parses, classifies as a
|
|
1136
|
+
// supported algorithm, and pairs with its key, yet a non-CA issuer cannot sign — the next
|
|
1137
|
+
// generateClientCert() would fail x509/bad-input, commit() reporting success while bricking
|
|
1138
|
+
// issuance. Require X509Certificate.ca before publishing (custom engines own their issuance).
|
|
1139
|
+
if (_commitCert.ca !== true) {
|
|
1140
|
+
throw new MtlsCaError("mtls-ca/not-a-ca-certificate",
|
|
1141
|
+
"commit: the bundled CA engine requires a CA certificate (basicConstraints cA:true), but the supplied " +
|
|
1142
|
+
"caCertPem is not a CA (e.g. a leaf / end-entity certificate) — publishing it would succeed, but the next " +
|
|
1143
|
+
"generateClientCert() would fail because a non-CA issuer cannot sign leaves; supply the CA certificate.");
|
|
1144
|
+
}
|
|
1145
|
+
// A CA outside its validity window (expired or not-yet-valid) parses, is a CA, classifies,
|
|
1146
|
+
// pairs, and even signs a leaf — but every issued leaf chains to it, and a TLS peer rejects an
|
|
1147
|
+
// expired/not-yet-valid chain (CERT_HAS_EXPIRED), so a successful commit would make every new
|
|
1148
|
+
// credential unusable. Reject it before mutating storage.
|
|
1149
|
+
var _nowMs = Date.now();
|
|
1150
|
+
if (_commitCert.validFromDate.getTime() > _nowMs || _commitCert.validToDate.getTime() < _nowMs) {
|
|
1151
|
+
throw new MtlsCaError("mtls-ca/ca-outside-validity",
|
|
1152
|
+
"commit: the supplied CA certificate is outside its validity window (validFrom " + _commitCert.validFrom +
|
|
1153
|
+
" .. validTo " + _commitCert.validTo + ") — publishing it would succeed, but every issued leaf would chain to " +
|
|
1154
|
+
"an expired or not-yet-valid CA that a TLS peer rejects (CERT_HAS_EXPIRED); supply a currently-valid CA.");
|
|
1155
|
+
}
|
|
1156
|
+
// A parseable, MATCHING pair can still be an algorithm the bundled engine cannot drive (a
|
|
1157
|
+
// P-256 / P-521 EC CA, a P-384 cert on a non-SHA-384 digest, an ML-DSA parameter set outside
|
|
1158
|
+
// the engine's set). _caPairConsistent checks only pairing, so such a CA would publish, then
|
|
1159
|
+
// the next initCA() adopting it throws mtls-ca/algorithm-mismatch (the ECDSA-P384 pin requires
|
|
1160
|
+
// P-384) and every later issuance fails — commit() reporting success while bricking the CA.
|
|
1161
|
+
// Require the committed CA to classify as one of the bundled engine's SUPPORTED algorithms
|
|
1162
|
+
// before mutating storage; the set is read from engine.algorithmEnvelope() so it tracks the
|
|
1163
|
+
// engine rather than a drifting hardcoded list. (Custom engines skip this — they own issuance.)
|
|
1164
|
+
var _committedLabel = _certAlgorithm(opts2.caCertPem).algorithm;
|
|
1165
|
+
var _supportedLabels = engine.algorithmEnvelope().cert.priority.map(function (p) { return p.label; });
|
|
1166
|
+
if (_committedLabel === null || _supportedLabels.indexOf(_committedLabel) === -1) {
|
|
1167
|
+
throw new MtlsCaError("mtls-ca/unsupported-ca-algorithm",
|
|
1168
|
+
"commit: the bundled CA engine does not support the supplied CA's algorithm" +
|
|
1169
|
+
(_committedLabel ? " (" + _committedLabel + ")" : " (an EC curve/digest the engine does not issue, e.g. P-256)") +
|
|
1170
|
+
" — supported: " + _supportedLabels.join(", ") + ". Publishing it would succeed, but the next initCA() would " +
|
|
1171
|
+
"throw mtls-ca/algorithm-mismatch and leave issuance unavailable; commit a CA in a supported algorithm (a " +
|
|
1172
|
+
"custom engine may commit its own).");
|
|
1173
|
+
}
|
|
1174
|
+
// Normalize the committed key to the engine-decodable PKCS#8 form. createPrivateKey() also
|
|
1175
|
+
// parses the common OpenSSL SEC1 EC encoding (BEGIN EC PRIVATE KEY), which pairs and classifies,
|
|
1176
|
+
// but the bundled toolkit decodes only PKCS#8 — storing SEC1 verbatim would fail the next
|
|
1177
|
+
// generateClientCert()/generateCrl() (x509/bad-input). Idempotent for a PKCS#8 input.
|
|
1178
|
+
opts2 = Object.assign({}, opts2, { caKeyPem: _commitKey.export({ type: "pkcs8", format: "pem" }) });
|
|
1179
|
+
}
|
|
1180
|
+
// A PARSEABLE cert/key pair from DIFFERENT CA material (a caller supplying, say, a cert and a
|
|
1181
|
+
// key from two generations) would publish successfully but leave the next initCA() failing
|
|
1182
|
+
// ca-pair-inconsistent — an unusable CA that cannot issue certs or CRLs despite commit()
|
|
1183
|
+
// reporting success. Reject a mismatched pair up front (synchronously, before the lock).
|
|
1184
|
+
// A custom engine's opaque cert/key node cannot parse returns "consistent" here, so its own
|
|
1185
|
+
// pairing is preserved — the framework can only verify what it can parse.
|
|
1186
|
+
if (!_caPairConsistent(opts2.caCertPem, opts2.caKeyPem)) {
|
|
1187
|
+
throw new MtlsCaError("mtls-ca/ca-pair-inconsistent",
|
|
1188
|
+
"commit: the supplied caCertPem and caKeyPem are not a matching pair (the certificate's public key does " +
|
|
1189
|
+
"not correspond to the private key) — refusing to publish a mismatched CA the next initCA() would reject; " +
|
|
1190
|
+
"supply a certificate and key from the same CA");
|
|
1191
|
+
}
|
|
1192
|
+
return atomicFile.lock(paths.caCert, async function () {
|
|
1193
|
+
// Reconcile a leftover journal FIRST (as rotate() does): a crash that left a
|
|
1194
|
+
// new-key/old-cert state plus a journal would otherwise be overwritten by this
|
|
1195
|
+
// commit, which would record the ORPHANED new key as its prior key and, on a
|
|
1196
|
+
// failed publish, roll back to that orphan and delete the journal — losing the
|
|
1197
|
+
// actual matching old key. Safe here: the lock excludes any live commit.
|
|
1198
|
+
_reconcileCommitJournalLocked();
|
|
1199
|
+
// Functionally verify the bundled-engine material is fully usable (sign a leaf + a CRL) before
|
|
1200
|
+
// mutating storage — the KeyUsage extension is not statically readable, so a CA missing
|
|
1201
|
+
// keyCertSign/cRLSign would otherwise publish and disable issuance/revocation on the next call.
|
|
1202
|
+
if (usesDefaultEngine) { await _assertCommittedCaUsable(opts2.caCertPem, opts2.caKeyPem); }
|
|
1203
|
+
var result = _commitLocked(opts2);
|
|
1204
|
+
// Refresh the handle's effective algorithm pin to the committed CA's algorithm
|
|
1205
|
+
// (as rotate() does on rotate({ algorithm })). Without this, a handle created with
|
|
1206
|
+
// an algorithm pin that migrates to a different-algorithm CA via this documented
|
|
1207
|
+
// public commit() path keeps the stale pin, so the next initCA() (via
|
|
1208
|
+
// generateClientCert/P12) compares it against the new stored CA and throws
|
|
1209
|
+
// mtls-ca/algorithm-mismatch — unusable right after a successful commit. Only for
|
|
1210
|
+
// the DEFAULT engine: _certAlgorithm() yields BUNDLED labels, so refreshing a
|
|
1211
|
+
// CUSTOM engine's pin would REPLACE its own label (e.g. "CUSTOM-P384") with the
|
|
1212
|
+
// bundled one and the next issuance would pass a label the engine rejects — a
|
|
1213
|
+
// custom engine's pin is preserved (its parseable-key validation in initCA is
|
|
1214
|
+
// skipped for an unrecognized label anyway, so no spurious mismatch arises). Only
|
|
1215
|
+
// when pinned AND the committed algorithm is determinable (an unpinned handle
|
|
1216
|
+
// follows the stored CA already; an opaque cert yields null and leaves the pin).
|
|
1217
|
+
if (usesDefaultEngine && caAlgorithm !== undefined) {
|
|
1218
|
+
var committedAlg = _certAlgorithm(opts2.caCertPem).algorithm;
|
|
1219
|
+
if (committedAlg !== null && committedAlg !== undefined) caAlgorithm = committedAlg;
|
|
1220
|
+
} else if (!usesDefaultEngine && opts2.algorithm !== undefined) {
|
|
1221
|
+
// A CUSTOM engine's label can't be inferred from the committed cert, so a handle
|
|
1222
|
+
// migrating to a different-algorithm CA supplies the NEW effective label explicitly
|
|
1223
|
+
// (as rotate({ algorithm }) does). Apply it regardless of the handle's PRIOR pin state:
|
|
1224
|
+
// an UNPINNED custom handle (caAlgorithm === undefined) that commits an explicit label
|
|
1225
|
+
// must have it recorded too, else the next initCA() snapshot has no algorithm,
|
|
1226
|
+
// _leafEngineArgs omits it, and the engine selects its old default or rejects issuance
|
|
1227
|
+
// despite the successful commit. (A pinned handle likewise migrates off its stale pin.)
|
|
1228
|
+
caAlgorithm = opts2.algorithm;
|
|
1229
|
+
// The ca.algorithm FILE was already written crash-atomically inside _commitLocked (under the
|
|
1230
|
+
// rollback journal) from opts2.algorithm, so no separate persist here.
|
|
1231
|
+
}
|
|
1232
|
+
return result;
|
|
1233
|
+
});
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
// Reconcile a rotation that crashed mid-publish. commit() writes a durable copy
|
|
1237
|
+
// of the prior CA key (keyDest + ".rollback") before overwriting the live key,
|
|
1238
|
+
// and removes it only once the new key/cert pair is durably consistent. So a
|
|
1239
|
+
// lingering journal means a rotation died between the key rename and the cert
|
|
1240
|
+
// rename: the on-disk key is the NEW key but the cert is still the OLD one (an
|
|
1241
|
+
// unusable, otherwise-unrecoverable pair). Roll the live key back to the prior
|
|
1242
|
+
// copy so the previously-active CA (still able to issue leaves and CRLs during
|
|
1243
|
+
// the grace window) survives; if the pair is already consistent (the crash
|
|
1244
|
+
// landed after the cert rename, or the key was never overwritten), the journal
|
|
1245
|
+
// is simply spent and dropped.
|
|
1246
|
+
//
|
|
1247
|
+
// MUST hold the rotation lock (atomicFile.lock(paths.caCert)) across this call.
|
|
1248
|
+
// commit() runs UNDER that lock, so holding it here guarantees no rotation is
|
|
1249
|
+
// mid-publish — an inconsistent pair with a journal is then definitively a
|
|
1250
|
+
// CRASHED rotation, not the transient NEW-key/OLD-cert window a live commit
|
|
1251
|
+
// briefly shows. Reconciling lock-free would let a concurrent issuance clobber
|
|
1252
|
+
// an in-flight rotation's new key. Idempotent under the lock.
|
|
1253
|
+
function _reconcileCommitJournalLocked() {
|
|
1254
|
+
var keyDest = (caKeySealedMode === "required") ? paths.caKeySealed : paths.caKey;
|
|
1255
|
+
var keyJournal = keyDest + ".rollback";
|
|
1256
|
+
if (!nodeFs.existsSync(keyJournal)) return;
|
|
1257
|
+
var manifest;
|
|
1258
|
+
try {
|
|
1259
|
+
manifest = safeJson.parse(atomicFile.fdSafeReadSync(keyJournal, { maxBytes: C.BYTES.mib(2), encoding: "utf8" }),
|
|
1260
|
+
{ maxBytes: C.BYTES.mib(2) });
|
|
1261
|
+
} catch (_je) {
|
|
1262
|
+
// A rollback journal exists but cannot be read/parsed — the "rotation in
|
|
1263
|
+
// progress / crashed" marker, so we CANNOT tell whether the live key/cert pair
|
|
1264
|
+
// is mid-rotation. Continuing would let the caller (commit/rotate) overwrite the
|
|
1265
|
+
// ONLY durable copy of the prior key while snapshotting a possibly-orphaned live
|
|
1266
|
+
// key; a later failed publish could then restore the orphan and permanently lose
|
|
1267
|
+
// the matching key (and an opaque custom engine would issue from the mixed pair
|
|
1268
|
+
// node cannot verify). Fail closed: refuse to mutate until the fault is resolved.
|
|
1269
|
+
// Reconcile is idempotent, so the operator restores/removes the journal and
|
|
1270
|
+
// retries. A read-only trust read (_journalRetainedRoot) still tolerates it.
|
|
1271
|
+
throw new MtlsCaError("mtls-ca/rollback-journal-corrupt",
|
|
1272
|
+
"the CA rollback journal at " + keyJournal + " exists but could not be parsed (" +
|
|
1273
|
+
/* c8 ignore next -- String(_je) fallback unreachable: a thrown parse Error always has a .message */
|
|
1274
|
+
((_je && _je.message) || String(_je)) + ") — refusing to mutate the CA while an unresolved rotation " +
|
|
1275
|
+
"journal is present; restore or remove it, then retry");
|
|
1276
|
+
}
|
|
1277
|
+
if (!manifest || typeof manifest.key !== "string") {
|
|
1278
|
+
// Present, valid JSON, but not a rollback manifest (missing the prior-key field):
|
|
1279
|
+
// a truncated / externally-rewritten journal. Same hazard as an unparseable one —
|
|
1280
|
+
// fail closed rather than overwrite an unresolved rotation marker.
|
|
1281
|
+
throw new MtlsCaError("mtls-ca/rollback-journal-corrupt",
|
|
1282
|
+
"the CA rollback journal at " + keyJournal + " is present but is not a valid rollback manifest " +
|
|
1283
|
+
"(missing the prior-key field) — refusing to mutate the CA while an unresolved rotation journal is " +
|
|
1284
|
+
"present; restore or remove it, then retry");
|
|
1285
|
+
}
|
|
1286
|
+
// Every PRESENT byte field must be non-empty CANONICAL base64. A typeof-only guard
|
|
1287
|
+
// would accept an empty ("") or malformed key/newKey/cert/newCert/prevData that
|
|
1288
|
+
// decodes to an empty or garbage buffer and, written over the live CA key on the
|
|
1289
|
+
// interrupted path, permanently destroys the CA. Which field a given recovery path
|
|
1290
|
+
// USES is only known after the completed/interrupted branch below, so validate them
|
|
1291
|
+
// all up front and fail closed on any malformed one.
|
|
1292
|
+
if (![manifest.key, manifest.newKey, manifest.cert, manifest.newCert, manifest.prevData, manifest.retainAfterCert]
|
|
1293
|
+
.every(_validManifestB64Field)) {
|
|
1294
|
+
throw new MtlsCaError("mtls-ca/rollback-journal-corrupt",
|
|
1295
|
+
"the CA rollback journal at " + keyJournal + " has an empty or malformed base64 field — refusing to " +
|
|
1296
|
+
"recover the CA from a corrupt manifest (an empty key would overwrite and destroy the live CA); " +
|
|
1297
|
+
"restore or remove it, then retry");
|
|
1298
|
+
}
|
|
1299
|
+
var curCertBuf = nodeFs.existsSync(paths.caCert)
|
|
1300
|
+
? atomicFile.fdSafeReadSync(paths.caCert, { maxBytes: C.BYTES.mib(1) }) : null;
|
|
1301
|
+
var priorCertBuf = (typeof manifest.cert === "string") ? Buffer.from(manifest.cert, "base64") : null;
|
|
1302
|
+
// The rotation is COMPLETED (roll-forward) iff it republished the cert — the live
|
|
1303
|
+
// cert differs (BYTE-exact; a custom engine may emit non-UTF-8 cert bytes) from
|
|
1304
|
+
// the journal's recorded prior cert. Otherwise it is INTERRUPTED (roll-back): the
|
|
1305
|
+
// cert was never republished (a crash before the cert rename, or a partial catch
|
|
1306
|
+
// rollback). Both drive the CA to an authoritative on-disk state recorded in the
|
|
1307
|
+
// journal, byte-exact and engine-agnostic — no _caPairConsistent heuristic, which
|
|
1308
|
+
// is blind to custom-engine keys node cannot parse and would skip the key restore.
|
|
1309
|
+
var newCertBuf = (typeof manifest.newCert === "string") ? Buffer.from(manifest.newCert, "base64") : null;
|
|
1310
|
+
var completed;
|
|
1311
|
+
if (priorCertBuf !== null) {
|
|
1312
|
+
var certRepublished = curCertBuf !== null && !Buffer.from(curCertBuf).equals(priorCertBuf);
|
|
1313
|
+
// A commit that changed NEITHER the cert NOR the key bytes (a hard cut that only removed
|
|
1314
|
+
// ca.prev.crt, re-committing the byte-IDENTICAL current CA) can't be classified by the cert
|
|
1315
|
+
// alone — the live cert equals the journal's prior cert. It only matters for a HARD CUT
|
|
1316
|
+
// (retainAfter:false): a COMPLETED one removed ca.prev.crt, an INTERRUPTED one must restore it.
|
|
1317
|
+
// Tie-break on the prev state — an intended-removed prev already ABSENT means the removal
|
|
1318
|
+
// completed, so do not resurrect the root the operator hard-cut. Gate on the key being unchanged
|
|
1319
|
+
// too so a different-key interrupted rotation (whose live cert also equals its prior cert) is
|
|
1320
|
+
// untouched and still rolls back to its prior key.
|
|
1321
|
+
var certKeyUnchanged = !certRepublished && manifest.key === manifest.newKey;
|
|
1322
|
+
// Only when the hard cut was REMOVING an existing retained root (prevAction "restore"): a "delete"/
|
|
1323
|
+
// "leave" journal never had a readable prev, so an absent ca.prev.crt there is the normal state of
|
|
1324
|
+
// an INTERRUPTED rotation (crash before the cert publish), not evidence a removal completed.
|
|
1325
|
+
var hardCutRemovalDone = certKeyUnchanged && manifest.retainAfter === false &&
|
|
1326
|
+
manifest.prevAction === "restore" && !nodeFs.existsSync(paths.caCertPrev);
|
|
1327
|
+
completed = certRepublished || hardCutRemovalDone;
|
|
1328
|
+
} else {
|
|
1329
|
+
// No PRIOR cert (a key-only cold start: the retry's _commitLocked captured an
|
|
1330
|
+
// orphaned prior key with no cert). The "cert changed from prior" discriminator
|
|
1331
|
+
// cannot run, so classify by the intended NEW cert: completed iff the live cert IS
|
|
1332
|
+
// the one this commit meant to publish. Without this a completed key-only init
|
|
1333
|
+
// would be misread as interrupted and restore the orphaned key beside the new cert.
|
|
1334
|
+
completed = curCertBuf !== null && newCertBuf !== null && Buffer.from(curCertBuf).equals(newCertBuf);
|
|
1335
|
+
}
|
|
1336
|
+
var wantKeyBuf, wantPrevBuf; // wantPrevBuf: Buffer=write it, null=remove prev, undefined=leave untouched
|
|
1337
|
+
if (completed) {
|
|
1338
|
+
// Finish the rotation: the new key, and the retained root it intended (the
|
|
1339
|
+
// outgoing/prior cert if it retained, else removed). Closes a completed
|
|
1340
|
+
// rotation whose key rename or prev unlink didn't durably stick.
|
|
1341
|
+
wantKeyBuf = (typeof manifest.newKey === "string") ? Buffer.from(manifest.newKey, "base64") : null;
|
|
1342
|
+
// Roll forward to the ACTUAL intended retained root recorded at commit (retainAfterCert), not
|
|
1343
|
+
// the prior cert: an idempotent reformatted recommit keeps the EXISTING ca.prev.crt (priorPrev),
|
|
1344
|
+
// which differs from the prior cert. Fall back to priorCertBuf for a journal predating the field.
|
|
1345
|
+
wantPrevBuf = manifest.retainAfter
|
|
1346
|
+
? (typeof manifest.retainAfterCert === "string" ? Buffer.from(manifest.retainAfterCert, "base64") : priorCertBuf)
|
|
1347
|
+
: null;
|
|
1348
|
+
// Restore the CUSTOM-engine label the completed commit published, so a crash between the CA
|
|
1349
|
+
// publish and the ca.algorithm write cannot leave the label stale against the new CA. Idempotent
|
|
1350
|
+
// (a no-op when it already matches). Only on COMPLETED — an interrupted commit rolled the CA back
|
|
1351
|
+
// to the prior cert, whose label the un-overwritten ca.algorithm still correctly holds.
|
|
1352
|
+
if (typeof manifest.customAlgorithm === "string" && manifest.customAlgorithm.length > 0) {
|
|
1353
|
+
_persistAlgorithm(manifest.customAlgorithm);
|
|
1354
|
+
}
|
|
1355
|
+
} else {
|
|
1356
|
+
// Roll back to the prior key, and the prior retained root per prevAction
|
|
1357
|
+
// ("restore" bytes / "delete" a prev this rotation created / "leave" untouched).
|
|
1358
|
+
wantKeyBuf = Buffer.from(manifest.key, "base64");
|
|
1359
|
+
wantPrevBuf = (manifest.prevAction === "restore" && typeof manifest.prevData === "string")
|
|
1360
|
+
? Buffer.from(manifest.prevData, "base64")
|
|
1361
|
+
: (manifest.prevAction === "delete" ? null : undefined);
|
|
1362
|
+
// Undo a partially-applied re-label: the interrupted commit may have already written the new
|
|
1363
|
+
// ca.algorithm, so restore the prior label the journal captured (mirrors the outer catch's
|
|
1364
|
+
// in-memory rollback for a crash BETWEEN the catch's restore and the journal delete). A null
|
|
1365
|
+
// priorCustomAlgorithm means there was NO prior label (an unpinned CA re-labeled), so REMOVE the
|
|
1366
|
+
// rejected label — matching the catch's unlink arm — rather than leaving it active.
|
|
1367
|
+
if (typeof manifest.priorCustomAlgorithm === "string" && manifest.priorCustomAlgorithm.length > 0) {
|
|
1368
|
+
_persistAlgorithm(manifest.priorCustomAlgorithm);
|
|
1369
|
+
} else if (manifest.priorCustomAlgorithm === null && typeof manifest.customAlgorithm === "string" &&
|
|
1370
|
+
manifest.customAlgorithm.length > 0 && nodeFs.existsSync(paths.algorithm)) {
|
|
1371
|
+
nodeFs.unlinkSync(paths.algorithm);
|
|
1372
|
+
atomicFile.fsyncDir(nodePath.dirname(paths.algorithm));
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
// Drive the live key to the authoritative bytes (idempotent — a no-op when it
|
|
1376
|
+
// already matches). Byte comparison so it works for a custom engine too.
|
|
1377
|
+
if (wantKeyBuf !== null) {
|
|
1378
|
+
var curKeyRaw = nodeFs.existsSync(keyDest)
|
|
1379
|
+
? atomicFile.fdSafeReadSync(keyDest, { maxBytes: C.BYTES.mib(1) }) : null;
|
|
1380
|
+
if (curKeyRaw === null || !Buffer.from(curKeyRaw).equals(wantKeyBuf)) {
|
|
1381
|
+
atomicFile.writeSync(keyDest, wantKeyBuf, { fileMode: 0o600 });
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
// Drive the retained root to the authoritative state (write bytes / remove /
|
|
1385
|
+
// leave). Repairs a resurrected hard-cut root or a lost retained-root write.
|
|
1386
|
+
if (wantPrevBuf !== undefined) {
|
|
1387
|
+
var curPrev = nodeFs.existsSync(paths.caCertPrev)
|
|
1388
|
+
? atomicFile.fdSafeReadSync(paths.caCertPrev, { maxBytes: C.BYTES.mib(1) }) : null;
|
|
1389
|
+
if (wantPrevBuf === null) {
|
|
1390
|
+
if (curPrev !== null) { nodeFs.unlinkSync(paths.caCertPrev); atomicFile.fsyncDir(nodePath.dirname(paths.caCertPrev)); }
|
|
1391
|
+
} else if (curPrev === null || !Buffer.from(curPrev).equals(wantPrevBuf)) {
|
|
1392
|
+
atomicFile.writeSync(paths.caCertPrev, wantPrevBuf, { fileMode: 0o644 });
|
|
1393
|
+
atomicFile.fsyncDir(nodePath.dirname(paths.caCertPrev));
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
// Drive the moved-aside CRL to its authoritative state (BEFORE the journal delete,
|
|
1397
|
+
// so a failure keeps the journal for a retry — matching the key/prev drives). Only
|
|
1398
|
+
// when THIS journaled commit moved a CRL aside (manifest.crlMovedAside): a
|
|
1399
|
+
// crl.rollback present otherwise is an ORPHAN from a prior commit whose best-effort
|
|
1400
|
+
// delete failed, so restoring it would publish a stale-issuer CRL and deleting it
|
|
1401
|
+
// would touch a file this journal has no claim on — leave it (it is inert at the
|
|
1402
|
+
// .rollback name and a later move-aside overwrites it). When it IS ours: if this
|
|
1403
|
+
// rotation COMPLETED (the cert republished), that CRL is signed by the superseded
|
|
1404
|
+
// issuer — delete it; if it was INTERRUPTED (rolled back), the CA it reverts to is
|
|
1405
|
+
// still active, so its CRL is still valid — rename it back to the documented path.
|
|
1406
|
+
var crlRollback = _crlRollbackPath();
|
|
1407
|
+
if (manifest.crlMovedAside) {
|
|
1408
|
+
if (completed) {
|
|
1409
|
+
// A completed rotation superseded the CRL's issuer. Remove the moved-aside copy
|
|
1410
|
+
// if the move stuck, AND any live paths.crl a LOST move-aside (a best-effort
|
|
1411
|
+
// fsyncDir that did not persist the rename, while the later cert rename did) left
|
|
1412
|
+
// as the stale OLD-issuer CRL under the new CA — else it stays published until the
|
|
1413
|
+
// operator regenerates. Safe: a journal is still present, so no generateCrl() has
|
|
1414
|
+
// written a fresh CRL since the crash (it would have reconciled first).
|
|
1415
|
+
if (nodeFs.existsSync(crlRollback)) {
|
|
1416
|
+
nodeFs.unlinkSync(crlRollback);
|
|
1417
|
+
atomicFile.fsyncDir(nodePath.dirname(crlRollback));
|
|
455
1418
|
}
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
// classical pin is ECDSA-P384-SHA384 (secp384r1), so enforce the curve for
|
|
460
|
-
// it; a custom-engine label (unrecognized here) owns its own curve.
|
|
461
|
-
if (actualType === "ec" && /ecdsa-p384/i.test(String(caAlgorithm)) && actualCurve !== CLASSICAL_CA_CURVE) {
|
|
462
|
-
throw new MtlsCaError("mtls-ca/algorithm-mismatch",
|
|
463
|
-
"the CA at this dataDir uses EC curve " + actualCurve + ", but algorithm " +
|
|
464
|
-
JSON.stringify(caAlgorithm) + " requires P-384 (" + CLASSICAL_CA_CURVE + "). Rotate to a new " +
|
|
465
|
-
"CA (a fresh dataDir, or a higher generation) to change the curve.");
|
|
1419
|
+
if (nodeFs.existsSync(paths.crl)) {
|
|
1420
|
+
nodeFs.unlinkSync(paths.crl);
|
|
1421
|
+
atomicFile.fsyncDir(nodePath.dirname(paths.crl));
|
|
466
1422
|
}
|
|
1423
|
+
} else if (nodeFs.existsSync(crlRollback) && !nodeFs.existsSync(paths.crl)) {
|
|
1424
|
+
// Interrupted: the CA reverts to the one whose CRL is still valid — restore it.
|
|
1425
|
+
atomicFile.renameWithRetry(crlRollback, paths.crl);
|
|
1426
|
+
atomicFile.fsyncDir(nodePath.dirname(paths.crl));
|
|
467
1427
|
}
|
|
468
|
-
|
|
1428
|
+
}
|
|
1429
|
+
// Delete the journal durably, and PROPAGATE a failure — this is NOT best-effort.
|
|
1430
|
+
// A surviving journal would let _journalRetainedRoot()/loadTrustBundle() keep
|
|
1431
|
+
// treating it as authoritative and re-trust its saved root, undoing a completed
|
|
1432
|
+
// cutoff. Throwing here fails the caller closed (dropRetained/initCA/rotate),
|
|
1433
|
+
// so a cutoff never "completes" while its interrupted journal is still live; the
|
|
1434
|
+
// operator resolves the fault (e.g. a read-only journal dir) and retries — the
|
|
1435
|
+
// restore above is idempotent, so re-running reconcile is safe. The fsync (best-
|
|
1436
|
+
// effort per platform) makes the deletion durable across a power loss.
|
|
1437
|
+
nodeFs.unlinkSync(keyJournal);
|
|
1438
|
+
atomicFile.fsyncDir(nodePath.dirname(keyJournal));
|
|
1439
|
+
caLog.warn("recovered-interrupted-rotation",
|
|
1440
|
+
{ path: keyDest, detail: (completed ? "finished" : "rolled back") +
|
|
1441
|
+
" an interrupted rotation from the rollback journal (byte-exact)" });
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
function _commitJournalPath() {
|
|
1445
|
+
return ((caKeySealedMode === "required") ? paths.caKeySealed : paths.caKey) + ".rollback";
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
// Fixed name a CA-changing commit moves the stale CRL aside to before publishing the
|
|
1449
|
+
// new cert. A fixed (untokenized) name is safe because reconcile runs first on every
|
|
1450
|
+
// mutating open, so no stale crl.rollback survives into a later rotation; presence +
|
|
1451
|
+
// the completed/interrupted discriminator drive whether reconcile deletes it (stale)
|
|
1452
|
+
// or renames it back (still valid), so it needs no journal field.
|
|
1453
|
+
function _crlRollbackPath() {
|
|
1454
|
+
return paths.crl + ".rollback";
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
// Refuse to RETURN a stored CA that disagrees with the handle's algorithm pin: the
|
|
1458
|
+
// CA's own signature over every leaf is what a peer verifies, so issuing an ECDSA
|
|
1459
|
+
// leaf pinned for a legacy peer under a stored ML-DSA CA still yields an ML-DSA chain
|
|
1460
|
+
// that peer cannot verify. Shared by initCA()'s existing-CA path AND the fresh-init
|
|
1461
|
+
// adoption branch (a concurrent process may have created the CA under a different
|
|
1462
|
+
// recognized algorithm while this handle awaited generateCa) so every path that
|
|
1463
|
+
// adopts a stored CA enforces the pin identically. A custom engine may store a key
|
|
1464
|
+
// node cannot parse — the check is skipped then (the engine owns leaf issuance).
|
|
1465
|
+
function _assertPinMatchesStoredCa(certPem, keyPem) {
|
|
1466
|
+
if (caAlgorithm === undefined) return;
|
|
1467
|
+
var expectedType = _expectedKeyTypeForPin(caAlgorithm);
|
|
1468
|
+
var actualType = null;
|
|
1469
|
+
var actualCurve = null;
|
|
1470
|
+
try {
|
|
1471
|
+
var caKeyObj = nodeCrypto.createPrivateKey(keyPem);
|
|
1472
|
+
/* c8 ignore next -- the "" fallback is defensive: a parsed KeyObject always reports a non-empty asymmetricKeyType, so it is never reached */
|
|
1473
|
+
actualType = String(caKeyObj.asymmetricKeyType || "").toLowerCase();
|
|
1474
|
+
actualCurve = caKeyObj.asymmetricKeyDetails && caKeyObj.asymmetricKeyDetails.namedCurve
|
|
1475
|
+
? String(caKeyObj.asymmetricKeyDetails.namedCurve).toLowerCase() : null;
|
|
1476
|
+
} catch (_e) { actualType = null; }
|
|
1477
|
+
if (expectedType !== null && actualType && actualType !== expectedType) {
|
|
1478
|
+
throw new MtlsCaError("mtls-ca/algorithm-mismatch",
|
|
1479
|
+
"the CA at this dataDir was generated under " + actualType + ", but algorithm " +
|
|
1480
|
+
JSON.stringify(caAlgorithm) + " (" + expectedType + ") was requested. A leaf issued " +
|
|
1481
|
+
"under the pin would be signed by the mismatched CA and fail chain verification at a " +
|
|
1482
|
+
"peer. Rotate to a new CA (a fresh dataDir, or a higher generation) to change algorithms.");
|
|
1483
|
+
}
|
|
1484
|
+
// Every ECDSA label maps to the generic "ec" type, so the type check alone would
|
|
1485
|
+
// accept a P-256/P-521 stored CA under the ECDSA-P384 pin — leaving the operator
|
|
1486
|
+
// believing they hold P-384 posture. The framework's sole classical pin is
|
|
1487
|
+
// ECDSA-P384-SHA384 (secp384r1), so enforce the curve for it; a custom-engine label
|
|
1488
|
+
// (unrecognized here) owns its own curve.
|
|
1489
|
+
if (actualType === "ec" && /ecdsa-p384/i.test(String(caAlgorithm)) && actualCurve !== CLASSICAL_CA_CURVE) {
|
|
1490
|
+
throw new MtlsCaError("mtls-ca/algorithm-mismatch",
|
|
1491
|
+
"the CA at this dataDir uses EC curve " + actualCurve + ", but algorithm " +
|
|
1492
|
+
JSON.stringify(caAlgorithm) + " requires P-384 (" + CLASSICAL_CA_CURVE + "). Rotate to a new " +
|
|
1493
|
+
"CA (a fresh dataDir, or a higher generation) to change the curve.");
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
// Durably record the effective CUSTOM-engine algorithm label as shared metadata, so a SECOND handle
|
|
1498
|
+
// over the same dataDir picks up the CURRENT label after this handle's commit/rotate({ algorithm }).
|
|
1499
|
+
// Called under the rotation lock, alongside the CA publish. Default-engine labels are cert-derivable,
|
|
1500
|
+
// so they are never persisted or read here.
|
|
1501
|
+
function _persistAlgorithm(label) {
|
|
1502
|
+
atomicFile.writeSync(paths.algorithm, String(label), { fileMode: 0o600 });
|
|
1503
|
+
}
|
|
1504
|
+
// Read the persisted custom label a prior commit/rotate recorded (undefined when absent). fdSafeRead
|
|
1505
|
+
// caps the read; a missing/unreadable file just means no cross-handle label was recorded.
|
|
1506
|
+
function _readPersistedAlgorithm() {
|
|
1507
|
+
if (!nodeFs.existsSync(paths.algorithm)) return undefined;
|
|
1508
|
+
var s = "";
|
|
1509
|
+
try {
|
|
1510
|
+
s = atomicFile.fdSafeReadSync(paths.algorithm, { maxBytes: C.BYTES.kib(4) }).toString("utf8");
|
|
1511
|
+
} catch (_e) {
|
|
1512
|
+
// Only a genuine absent-file race — the label unlinked between the existsSync check and the open
|
|
1513
|
+
// — is "no label". Any OTHER read failure (permissions, an unreadable/temporarily-unmounted
|
|
1514
|
+
// algorithm path, an over-cap read) must NOT masquerade as missing: a stale create-time pin or a
|
|
1515
|
+
// bundled label a later probe infers would then be used against the CA. Fail closed so adoption,
|
|
1516
|
+
// rotation, and probing abort rather than silently downgrade the durable cross-handle label.
|
|
1517
|
+
if (_e && _e.code === "ENOENT") return undefined;
|
|
1518
|
+
throw _e;
|
|
1519
|
+
}
|
|
1520
|
+
return s.length > 0 ? s : undefined;
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
// The NEW label of a COMPLETED-commit journal whose ca.algorithm write was deferred (a CA-changing
|
|
1524
|
+
// commit whose label write failed but whose CA published — _labelPersistDeferred) or crash-stranded:
|
|
1525
|
+
// the live cert equals the journal's newCert, so the journal's customAlgorithm is the CA's actual
|
|
1526
|
+
// label even though the on-disk ca.algorithm file is still the old one. Read-only — reconcile applies
|
|
1527
|
+
// it durably; this reports what reconcile WOULD apply so the status/probe paths never read the stale
|
|
1528
|
+
// file. undefined when there is no such pending completed label.
|
|
1529
|
+
function _pendingCompletedJournalLabel() {
|
|
1530
|
+
/* c8 ignore next -- the sealed-mode key path is the same derivation reconcile()/_commitLocked() use; the read-only label consultation is exercised in the default (disabled) mode */
|
|
1531
|
+
var keyJournal = ((caKeySealedMode === "required") ? paths.caKeySealed : paths.caKey) + ".rollback";
|
|
1532
|
+
if (!nodeFs.existsSync(keyJournal)) return undefined;
|
|
1533
|
+
var manifest;
|
|
1534
|
+
try {
|
|
1535
|
+
manifest = safeJson.parse(atomicFile.fdSafeReadSync(keyJournal, { maxBytes: C.BYTES.mib(2), encoding: "utf8" }),
|
|
1536
|
+
{ maxBytes: C.BYTES.mib(2) });
|
|
1537
|
+
/* c8 ignore next -- defensive: a corrupt/truncated journal is treated as "no pending label"; reconcile validates and quarantines it, so the read-only status path just falls back to the file */
|
|
1538
|
+
} catch (_je) { return undefined; }
|
|
1539
|
+
/* c8 ignore start -- defensive: a journal RETAINED by a deferred label persist always carries a
|
|
1540
|
+
customAlgorithm (retention is driven by it) AND a prior cert string (the deferral is only ever on a
|
|
1541
|
+
CA-changing commit over an existing cert), and paths.caCert is present (a journal exists only after
|
|
1542
|
+
a commit published a cert) — this guards a malformed/legacy/key-only-cold-start journal */
|
|
1543
|
+
if (!manifest || typeof manifest.customAlgorithm !== "string" || manifest.customAlgorithm.length === 0 ||
|
|
1544
|
+
typeof manifest.cert !== "string" || !nodeFs.existsSync(paths.caCert)) {
|
|
1545
|
+
return undefined;
|
|
1546
|
+
}
|
|
1547
|
+
/* c8 ignore stop */
|
|
1548
|
+
var liveCert = atomicFile.fdSafeReadSync(paths.caCert, { maxBytes: C.BYTES.mib(1) }).toString("utf8");
|
|
1549
|
+
// COMPLETED as reconcile classifies it: the live cert is NOT the journal's PRIOR cert, so the
|
|
1550
|
+
// commit's new CA — and its customAlgorithm — is the published one, and a deferred label persist
|
|
1551
|
+
// (_labelPersistDeferred, only ever on a CA-changing commit) is what left the file stale. A same-cert
|
|
1552
|
+
// REJECTED re-stamp (live cert == the journal's prior cert) is INTERRUPTED — its rollback-restored
|
|
1553
|
+
// file label is authoritative, NOT this journal's rejected label — so report nothing.
|
|
1554
|
+
var priorCert = Buffer.from(manifest.cert, "base64").toString("utf8");
|
|
1555
|
+
return !_sameCert(liveCert, priorCert) ? manifest.customAlgorithm : undefined;
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
// The custom engine's CURRENT effective label for the read-only status/probe paths (which do not
|
|
1559
|
+
// reconcile): a pending completed-commit journal's newer label, else the persisted file.
|
|
1560
|
+
function _currentCustomLabel() {
|
|
1561
|
+
var pending = _pendingCompletedJournalLabel();
|
|
1562
|
+
return (pending !== undefined) ? pending : _readPersistedAlgorithm();
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
// Verify a read CA pair and return it as a snapshot: refuse a persistent mismatch, validate
|
|
1566
|
+
// the pin, and capture the pin WITH the pair (so _leafEngineArgs binds the leaf to the pin as
|
|
1567
|
+
// of this read). Shared by every adoption tail (initCA's existing-CA path AND
|
|
1568
|
+
// _freshCreateSerialized's under-lock adopt) so none can skip the pairing / pin check.
|
|
1569
|
+
function _verifiedCASnapshot(certPem, keyPem) {
|
|
1570
|
+
if (!_caPairConsistent(certPem, keyPem)) {
|
|
1571
|
+
throw new MtlsCaError("mtls-ca/ca-pair-inconsistent",
|
|
1572
|
+
"the stored CA certificate and private key did not become a matching pair after re-reading " +
|
|
1573
|
+
"(a rotation may still be publishing, or the store is corrupt) — retry issuance");
|
|
1574
|
+
}
|
|
1575
|
+
_assertPinMatchesStoredCa(certPem, keyPem);
|
|
1576
|
+
return { caCertPem: certPem, caKeyPem: keyPem, algorithm: caAlgorithm };
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
// Read the STORED CA as a CONSISTENT snapshot: re-read past a transient in-flight rotation
|
|
1580
|
+
// (old-cert paired with new-key while another handle renames the two in sequence), reconcile
|
|
1581
|
+
// a leftover journal UNDER the rotation lock, refuse a persistent mismatch, validate the pin,
|
|
1582
|
+
// and capture the pin WITH the snapshot. Shared by initCA()'s existing-CA path AND
|
|
1583
|
+
// _freshCreateSerialized()'s cold-start adopt — the latter used to read unlocked with no
|
|
1584
|
+
// consistency check and could hand a mismatched pair to a custom engine.
|
|
1585
|
+
async function _adoptExistingCASnapshot() {
|
|
1586
|
+
var existingCertPem = loadCert().toString("utf8");
|
|
1587
|
+
var existingKeyPem = loadKey().toString("utf8");
|
|
1588
|
+
var pairTries = 0;
|
|
1589
|
+
while (!_caPairConsistent(existingCertPem, existingKeyPem) && pairTries < 8) {
|
|
1590
|
+
pairTries += 1;
|
|
1591
|
+
await safeAsync.sleep(10);
|
|
1592
|
+
existingCertPem = loadCert().toString("utf8");
|
|
1593
|
+
existingKeyPem = loadKey().toString("utf8");
|
|
1594
|
+
}
|
|
1595
|
+
// A persistent mismatch (survived the re-read window), a leftover journal (a crash
|
|
1596
|
+
// between the cert rename and the journal delete), or a CUSTOM engine (whose cert/key
|
|
1597
|
+
// _caPairConsistent cannot verify, so only the lock proves the pair corresponds) needs
|
|
1598
|
+
// the rotation lock: reconcile, re-read the pair, AND build the verified snapshot inside
|
|
1599
|
+
// the SAME locked section. _verifiedCASnapshot samples the mutable caAlgorithm pin, so
|
|
1600
|
+
// building it after releasing the lock would let a concurrent same-handle rotate/commit
|
|
1601
|
+
// ({ algorithm: B }) publish B in the gap — pairing this A key/cert with label B, which a
|
|
1602
|
+
// custom signer rejects or mints an incompatible leaf under. (_freshCreateSerialized's
|
|
1603
|
+
// under-lock adopt tail builds its snapshot inside the lock for the same reason.)
|
|
1604
|
+
if (!_caPairConsistent(existingCertPem, existingKeyPem) ||
|
|
1605
|
+
nodeFs.existsSync(_commitJournalPath()) || !usesDefaultEngine) {
|
|
1606
|
+
var _snap;
|
|
1607
|
+
await atomicFile.lock(paths.caCert, function () {
|
|
1608
|
+
_reconcileCommitJournalLocked();
|
|
1609
|
+
existingCertPem = loadCert().toString("utf8");
|
|
1610
|
+
existingKeyPem = loadKey().toString("utf8");
|
|
1611
|
+
// For a CUSTOM engine, adopt the CURRENT effective label another handle may have persisted via
|
|
1612
|
+
// commit/rotate({ algorithm }): a custom label is not cert-derivable, so this handle's stale
|
|
1613
|
+
// create-time pin would otherwise be passed to the new issuer and rejected. Read it UNDER the
|
|
1614
|
+
// lock, atomically with the cert/key, so the snapshot's label matches the stored CA.
|
|
1615
|
+
if (!usesDefaultEngine) {
|
|
1616
|
+
var _persisted = _readPersistedAlgorithm();
|
|
1617
|
+
if (_persisted !== undefined) caAlgorithm = _persisted;
|
|
1618
|
+
}
|
|
1619
|
+
_snap = _verifiedCASnapshot(existingCertPem, existingKeyPem);
|
|
1620
|
+
});
|
|
1621
|
+
return _snap;
|
|
1622
|
+
}
|
|
1623
|
+
// No lock was needed (a default-engine, already-consistent pair with no journal): the
|
|
1624
|
+
// snapshot's captured algorithm is unused by default-engine issuance (_leafEngineArgs derives
|
|
1625
|
+
// the leaf label from the CA key, not the pin), so sampling caAlgorithm unlocked here is safe.
|
|
1626
|
+
return _verifiedCASnapshot(existingCertPem, existingKeyPem);
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
var _initChain = Promise.resolve();
|
|
1630
|
+
// Serialized first-time creation (see initCA's fresh path). Re-checks exists() at
|
|
1631
|
+
// the start (a prior chained init may have created it, avoiding a wasted keygen)
|
|
1632
|
+
// and again UNDER the rotation lock (a separate process may have created it while
|
|
1633
|
+
// we awaited generateCa) — adopting the committed CA instead of clobbering it.
|
|
1634
|
+
async function _freshCreateSerialized() {
|
|
1635
|
+
// A prior chained init (or a separate process) may have created the CA before this
|
|
1636
|
+
// keygen runs. Adopt it through the SAME consistent-snapshot path as initCA (re-read
|
|
1637
|
+
// past an in-flight rotation, reconcile under the lock) — reading it unlocked here could
|
|
1638
|
+
// return an old-cert/new-key pair mid-rotation and hand a mismatched pair to a custom
|
|
1639
|
+
// engine.
|
|
1640
|
+
if (exists()) {
|
|
1641
|
+
return _adoptExistingCASnapshot();
|
|
469
1642
|
}
|
|
470
1643
|
// Build the args conditionally so an `algorithm` key is present ONLY when the
|
|
471
1644
|
// operator pinned one — a strict custom engine that validates its generateCa
|
|
@@ -478,8 +1651,54 @@ function create(opts) {
|
|
|
478
1651
|
throw new MtlsCaError("mtls-ca/bad-engine-output",
|
|
479
1652
|
"engine.generateCa must return { caCertPem, caKeyPem }");
|
|
480
1653
|
}
|
|
481
|
-
|
|
482
|
-
|
|
1654
|
+
return atomicFile.lock(paths.caCert, function () {
|
|
1655
|
+
if (exists()) {
|
|
1656
|
+
// A separate process committed a CA under the shared dataDir while we awaited
|
|
1657
|
+
// generateCa. Adopt it rather than clobber it — but RECONCILE a leftover journal
|
|
1658
|
+
// first and verify the pair (that process may have crashed mid-rotation, leaving an
|
|
1659
|
+
// old-cert/new-key pair; an unpinned default-engine handle's _assertPinMatchesStoredCa
|
|
1660
|
+
// does no pairing check, so signing the mismatched snapshot would return a leaf that
|
|
1661
|
+
// does not chain to the stored root). Same discipline as initCA's existing-CA path.
|
|
1662
|
+
_reconcileCommitJournalLocked();
|
|
1663
|
+
var adoptedCert = loadCert().toString("utf8");
|
|
1664
|
+
var adoptedKey = loadKey().toString("utf8");
|
|
1665
|
+
// A concurrent create won the CA: for a custom engine, adopt its PERSISTED label (as
|
|
1666
|
+
// _adoptExistingCASnapshot does) rather than this handle's own create-time pin — else a
|
|
1667
|
+
// cold-start sibling with a different pin issues under the wrong label against the winner's CA.
|
|
1668
|
+
if (!usesDefaultEngine) {
|
|
1669
|
+
var _adoptedLabel = _readPersistedAlgorithm();
|
|
1670
|
+
if (_adoptedLabel !== undefined) caAlgorithm = _adoptedLabel;
|
|
1671
|
+
}
|
|
1672
|
+
return _verifiedCASnapshot(adoptedCert, adoptedKey);
|
|
1673
|
+
}
|
|
1674
|
+
// _commitLocked persists the create-time custom label (its _customCommitLabel falls back to this
|
|
1675
|
+
// handle's caAlgorithm) BEFORE publishing the CA: a fresh create writes no rollback journal, so
|
|
1676
|
+
// the label goes down first, and an unwritable algorithm path throws before the cert lands —
|
|
1677
|
+
// leaving no CA installed rather than a labelless CA a sibling would issue under its own stale
|
|
1678
|
+
// pin. Since the cert is published last, any handle that sees the CA also sees its label.
|
|
1679
|
+
_commitLocked(fresh);
|
|
1680
|
+
// Carry the create-time pin as the snapshot's algorithm (as the existing-CA and adopt
|
|
1681
|
+
// paths do) so an issuance that triggered this creation binds its leaf to the pin the
|
|
1682
|
+
// CA was made under, not a later rotate()-refreshed caAlgorithm.
|
|
1683
|
+
return Object.assign({}, fresh, { algorithm: caAlgorithm });
|
|
1684
|
+
});
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
async function initCA() {
|
|
1688
|
+
// A stored CA is adopted as a consistent snapshot (re-read past an in-flight rotation,
|
|
1689
|
+
// reconcile under the lock, refuse a persistent mismatch, validate + capture the pin).
|
|
1690
|
+
if (exists()) {
|
|
1691
|
+
return _adoptExistingCASnapshot();
|
|
1692
|
+
}
|
|
1693
|
+
// First-time creation. Serialize it (like rotation) so two concurrent cold-start
|
|
1694
|
+
// callers — the normal generateClientCert()-before-a-CA-exists path, same handle
|
|
1695
|
+
// or two processes over one dataDir — cannot each generate a CA and clobber one
|
|
1696
|
+
// another (the loser's just-issued leaf would chain to a CA that no longer
|
|
1697
|
+
// exists). _initChain serializes same-handle creation; the lock + double-check
|
|
1698
|
+
// handles cross-process.
|
|
1699
|
+
var next = _initChain.then(function () { return _freshCreateSerialized(); });
|
|
1700
|
+
_initChain = next.then(function () {}, function () {});
|
|
1701
|
+
return next;
|
|
483
1702
|
}
|
|
484
1703
|
|
|
485
1704
|
// Recover the issued certificate's identity from its PEM so issuance and
|
|
@@ -499,9 +1718,23 @@ function create(opts) {
|
|
|
499
1718
|
} catch (_e) {
|
|
500
1719
|
serialNumber = null;
|
|
501
1720
|
}
|
|
1721
|
+
// Hash the certificate's DER — exactly what the require-mtls gate pins
|
|
1722
|
+
// (b.crypto.hashCertFingerprint decodes the PEM envelope first). Hashing the
|
|
1723
|
+
// PEM TEXT instead (b.crypto.sha3Hash(certPem)) yields a value that never
|
|
1724
|
+
// matches the gate, so revoke()/revokeGeneration() by it could not be
|
|
1725
|
+
// enforced by a revocationSource-wired gate. A custom engine may return a
|
|
1726
|
+
// cert with no decodable PEM/DER envelope — that cert can't reach a standard
|
|
1727
|
+
// TLS gate either, so fall back to a stable hash of the returned bytes:
|
|
1728
|
+
// issuance always surfaces a revocable id and never crashes.
|
|
1729
|
+
var fingerprint;
|
|
1730
|
+
try {
|
|
1731
|
+
fingerprint = bCrypto().hashCertFingerprint(certPem).hex;
|
|
1732
|
+
} catch (_fpErr) {
|
|
1733
|
+
fingerprint = bCrypto().sha3Hash(certPem);
|
|
1734
|
+
}
|
|
502
1735
|
return {
|
|
503
1736
|
serialNumber: serialNumber,
|
|
504
|
-
fingerprint:
|
|
1737
|
+
fingerprint: fingerprint,
|
|
505
1738
|
};
|
|
506
1739
|
}
|
|
507
1740
|
|
|
@@ -514,16 +1747,24 @@ function create(opts) {
|
|
|
514
1747
|
// resolve; the bundled ECDSA-P384-SHA384 label would break it). An explicit
|
|
515
1748
|
// opts2.algorithm always wins.
|
|
516
1749
|
function _leafEngineArgs(ca, opts2) {
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
1750
|
+
// Derive the leaf algorithm from the SNAPSHOTTED CA, never from the mutable caAlgorithm
|
|
1751
|
+
// pin. A concurrent commit()/rotate() can refresh the pin between an issuance's initCA()
|
|
1752
|
+
// snapshot and this call (the pin is a closure variable both mutate); reading the pin
|
|
1753
|
+
// would then mint a leaf under the NEW algorithm but signed by the OLD snapshotted issuer
|
|
1754
|
+
// (e.g. an ML-DSA leaf under a retained ECDSA CA that the grace window's legacy peers
|
|
1755
|
+
// cannot authenticate). The snapshot is immutable, so binding the leaf to it guarantees
|
|
1756
|
+
// the algorithm matches the CA the leaf is actually signed under.
|
|
1757
|
+
//
|
|
1758
|
+
// For the DEFAULT engine the stored CA's own key type maps to the bundled label set (and
|
|
1759
|
+
// equals a valid pin, which initCA already validated against the stored CA — so this is a
|
|
1760
|
+
// no-op in the non-racing case). A CUSTOM engine resolves its OWN algorithm from its own
|
|
1761
|
+
// key: its pin passes through unchanged (injecting a bundled ECDSA-P384-SHA384 label would
|
|
1762
|
+
// break a P-256/P-521 or custom-labeled engine that validates its option shape). The custom
|
|
1763
|
+
// pin comes from the SNAPSHOT (ca.algorithm, captured by initCA atomically with the CA) —
|
|
1764
|
+
// NOT the mutable caAlgorithm closure, which a concurrent rotate({ algorithm }) can refresh
|
|
1765
|
+
// to a different label before this line runs, handing the engine a new label with the old
|
|
1766
|
+
// snapshotted CA (mint an incompatible leaf / reject issuance).
|
|
1767
|
+
var leafAlg = usesDefaultEngine ? _labelForCaKeyType(ca.caKeyPem) : ca.algorithm;
|
|
527
1768
|
var args = Object.assign({}, opts2, { caCertPem: ca.caCertPem, caKeyPem: ca.caKeyPem });
|
|
528
1769
|
if (leafAlg !== undefined) {
|
|
529
1770
|
// The resolved CA algorithm (a pin verified against the stored CA, or the
|
|
@@ -558,6 +1799,7 @@ function create(opts) {
|
|
|
558
1799
|
// Surface the issued serial + fingerprint so the caller can track/revoke
|
|
559
1800
|
// the cert by the same identifiers without re-parsing the PEM.
|
|
560
1801
|
var id = _certIdentity(result.cert);
|
|
1802
|
+
await _recordIssuance(ca.caCertPem, id);
|
|
561
1803
|
return Object.assign({}, result, { serialNumber: id.serialNumber, fingerprint: id.fingerprint });
|
|
562
1804
|
}
|
|
563
1805
|
|
|
@@ -577,11 +1819,24 @@ function create(opts) {
|
|
|
577
1819
|
throw new MtlsCaError("mtls-ca/bad-engine-output",
|
|
578
1820
|
"engine.packageP12 must return { p12: Buffer, certPem, issuedAt, expiresAt }");
|
|
579
1821
|
}
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
1822
|
+
// certPem is required engine output: it is the identity _recordIssuance writes to
|
|
1823
|
+
// the ledger. It must be a non-empty string, but it need NOT be node-parseable — a
|
|
1824
|
+
// custom engine may package a valid certificate this runtime cannot parse (e.g. a
|
|
1825
|
+
// newer post-quantum algorithm), exactly as generateClientCert() accepts. _certIdentity
|
|
1826
|
+
// still derives a stable fingerprint from the bytes, and parsing here could not prove
|
|
1827
|
+
// certPem is the certificate INSIDE the encrypted p12 anyway — that pairing is the
|
|
1828
|
+
// packageP12 engine's contract (the bundled engine builds both from one signing
|
|
1829
|
+
// operation so they always agree; the framework cannot re-verify an arbitrary
|
|
1830
|
+
// engine's encrypted, engine-defined bag). Requiring parseability only broke the
|
|
1831
|
+
// opaque-engine case without establishing the pairing guarantee.
|
|
1832
|
+
if (typeof result.certPem !== "string" || result.certPem.length === 0) {
|
|
1833
|
+
throw new MtlsCaError("mtls-ca/bad-engine-output",
|
|
1834
|
+
"engine.packageP12 must return a non-empty certPem so the archive is recorded in the issuance " +
|
|
1835
|
+
"ledger — an unrecorded P12 could not be revoked by revokeGeneration()");
|
|
583
1836
|
}
|
|
584
|
-
|
|
1837
|
+
var id12 = _certIdentity(result.certPem);
|
|
1838
|
+
await _recordIssuance(ca.caCertPem, id12);
|
|
1839
|
+
return Object.assign({}, result, { serialNumber: id12.serialNumber, fingerprint: id12.fingerprint });
|
|
585
1840
|
}
|
|
586
1841
|
|
|
587
1842
|
// ---- Revocation registry + CRL ----
|
|
@@ -599,8 +1854,8 @@ function create(opts) {
|
|
|
599
1854
|
// safeJson.parse caps depth + size + protects against
|
|
600
1855
|
// proto-pollution; a tampered or truncated file shouldn't be able to
|
|
601
1856
|
// corrupt the rotator process.
|
|
602
|
-
var json = safeJson.parse(atomicFile.fdSafeReadSync(paths.revocations, { maxBytes:
|
|
603
|
-
{ maxBytes:
|
|
1857
|
+
var json = safeJson.parse(atomicFile.fdSafeReadSync(paths.revocations, { maxBytes: STORE_READ_CAP, encoding: "utf8" }),
|
|
1858
|
+
{ maxBytes: STORE_READ_CAP });
|
|
604
1859
|
return (json && Array.isArray(json.revocations)) ? json.revocations : [];
|
|
605
1860
|
} catch (e) {
|
|
606
1861
|
/* c8 ignore next 2 -- defensive: safeJson.parse throws an Error with a message, so the String(e) fallback is unreachable */
|
|
@@ -613,14 +1868,286 @@ function create(opts) {
|
|
|
613
1868
|
add: function (entry) {
|
|
614
1869
|
var entries = _list();
|
|
615
1870
|
entries.push(entry);
|
|
616
|
-
|
|
617
|
-
JSON.stringify({ revocations: entries }, null, 2) + "\n", { mode: 0o600 }
|
|
1871
|
+
_writeStoreCapped(paths.revocations,
|
|
1872
|
+
JSON.stringify({ revocations: entries }, null, 2) + "\n", { mode: 0o600 },
|
|
1873
|
+
"mtls-ca/revocation-registry-full", "revocation registry");
|
|
1874
|
+
},
|
|
1875
|
+
// Cheap change signal for isRevoked()'s index — the append-only file's byte
|
|
1876
|
+
// length grows on every add (mtime disambiguates a same-size rewrite), so a
|
|
1877
|
+
// revocation written by ANOTHER handle / process over this file bumps it and
|
|
1878
|
+
// the index rebuilds on the next lookup. O(1) statSync, not an O(n) parse.
|
|
1879
|
+
version: function () {
|
|
1880
|
+
try { var st = nodeFs.statSync(paths.revocations); return st.size + ":" + st.mtimeMs; }
|
|
1881
|
+
catch (_e) { return "0:0"; }
|
|
618
1882
|
},
|
|
619
1883
|
};
|
|
620
1884
|
}
|
|
1885
|
+
var usesDefaultRevocationStore = !opts.revocationStore;
|
|
621
1886
|
var revocationStore = opts.revocationStore || _defaultFileStore();
|
|
622
1887
|
validateOpts.requireMethods(revocationStore, ["list", "add"],
|
|
623
1888
|
"opts.revocationStore", MtlsCaError, "mtls-ca/bad-revocation-store");
|
|
1889
|
+
// The clustered-watermark methods are all-or-nothing: providing only one would
|
|
1890
|
+
// SPLIT the watermark (one operation shared, the other on the local file), so a
|
|
1891
|
+
// revoked generation could still issue on another host — a fail-open. Refuse it.
|
|
1892
|
+
if ((typeof revocationStore.readGenerationWatermark === "function") !==
|
|
1893
|
+
(typeof revocationStore.bumpGenerationWatermark === "function")) {
|
|
1894
|
+
throw new MtlsCaError("mtls-ca/bad-revocation-store",
|
|
1895
|
+
"a revocationStore providing one of readGenerationWatermark() / bumpGenerationWatermark() must " +
|
|
1896
|
+
"provide BOTH — a split watermark would let a revoked generation still issue on another host");
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
// Revoked-generation watermark — the highest n passed to revokeGeneration(),
|
|
1900
|
+
// read by issuance to catch a leaf whose signing straddled a generation
|
|
1901
|
+
// revocation (see _recordIssuance). Stored in the LOCAL dataDir file by default,
|
|
1902
|
+
// which coordinates same-host processes. A CLUSTERED custom store (shared across
|
|
1903
|
+
// hosts, per-host dataDir) must instead expose { readGenerationWatermark(),
|
|
1904
|
+
// bumpGenerationWatermark(n) } so the watermark lives in the shared store; when
|
|
1905
|
+
// present those win. bumpGenerationWatermark(n) must be a monotonic max and own
|
|
1906
|
+
// its own atomicity.
|
|
1907
|
+
function _readRevokedWatermark() {
|
|
1908
|
+
if (typeof revocationStore.readGenerationWatermark === "function") {
|
|
1909
|
+
var v = revocationStore.readGenerationWatermark();
|
|
1910
|
+
// Fail CLOSED: a store that can't return a valid watermark must not let a
|
|
1911
|
+
// revoked generation slip through as 0. Only a genuine "never set" (0/absent)
|
|
1912
|
+
// is a valid zero.
|
|
1913
|
+
if (typeof v === "number" && isFinite(v) && v >= 0) return v;
|
|
1914
|
+
throw new MtlsCaError("mtls-ca/watermark-unreadable",
|
|
1915
|
+
"revocationStore.readGenerationWatermark() returned a non-numeric value — refusing issuance rather " +
|
|
1916
|
+
"than treating a revoked generation as unrevoked");
|
|
1917
|
+
}
|
|
1918
|
+
// ONLY an absent file is a real zero. A present-but-unreadable/malformed file
|
|
1919
|
+
// must ABORT issuance — reporting 0 would let a below-n generation issued
|
|
1920
|
+
// during the sweep pass the _recordIssuance() check (the race this closes).
|
|
1921
|
+
if (!nodeFs.existsSync(paths.revokedGeneration)) return 0;
|
|
1922
|
+
var raw;
|
|
1923
|
+
try {
|
|
1924
|
+
raw = atomicFile.fdSafeReadSync(paths.revokedGeneration, { maxBytes: 64, encoding: "utf8" });
|
|
1925
|
+
} catch (e) {
|
|
1926
|
+
throw new MtlsCaError("mtls-ca/watermark-unreadable",
|
|
1927
|
+
"the revoked-generation watermark (" + paths.revokedGeneration + ") exists but is unreadable (" +
|
|
1928
|
+
/* c8 ignore next -- String(e) fallback unreachable: a thrown fs Error always has a .message */
|
|
1929
|
+
((e && e.message) || String(e)) + ") — refusing issuance rather than treating it as unrevoked");
|
|
1930
|
+
}
|
|
1931
|
+
// Require the WHOLE trimmed content to be digits — parseInt would accept
|
|
1932
|
+
// "1junk"/"1.5" and take a lower prefix, letting a below-watermark generation
|
|
1933
|
+
// slip through. Any non-integer content fails closed.
|
|
1934
|
+
var trimmed = String(raw).trim();
|
|
1935
|
+
if (!/^\d+$/.test(trimmed)) {
|
|
1936
|
+
throw new MtlsCaError("mtls-ca/watermark-unreadable",
|
|
1937
|
+
"the revoked-generation watermark (" + paths.revokedGeneration + ") is malformed — refusing issuance");
|
|
1938
|
+
}
|
|
1939
|
+
var n = parseInt(trimmed, 10);
|
|
1940
|
+
return n;
|
|
1941
|
+
}
|
|
1942
|
+
// Returns a PROMISE: the shared-store bump owns its atomicity; the local-file
|
|
1943
|
+
// bump takes a cross-process lock on the watermark file for its read-modify-write.
|
|
1944
|
+
function _bumpRevokedWatermark(n) {
|
|
1945
|
+
if (typeof revocationStore.bumpGenerationWatermark === "function") {
|
|
1946
|
+
return Promise.resolve(revocationStore.bumpGenerationWatermark(n));
|
|
1947
|
+
}
|
|
1948
|
+
return atomicFile.lock(paths.revokedGeneration, function () {
|
|
1949
|
+
if (n > _readRevokedWatermark()) {
|
|
1950
|
+
atomicFile.writeSync(paths.revokedGeneration, String(n) + "\n", { mode: 0o600 });
|
|
1951
|
+
}
|
|
1952
|
+
});
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
// In-memory revocation index — a Set of every revoked serial + fingerprint —
|
|
1956
|
+
// so isRevoked() (called PER REQUEST by a revocationSource-wired require-mtls
|
|
1957
|
+
// gate) is O(1) with no filesystem read/JSON-parse on the event-loop hot path.
|
|
1958
|
+
// Built lazily from the store on first use, then kept in sync by revoke().
|
|
1959
|
+
// Reflects revocations made through THIS handle; the default file store is
|
|
1960
|
+
// single-process, so a store mutated out-of-band is not re-read here.
|
|
1961
|
+
var _revIndex = null;
|
|
1962
|
+
var _revSerialOnly = null;
|
|
1963
|
+
var _revIndexVersion = null;
|
|
1964
|
+
// Return a fresh-enough Set of revoked serials + fingerprints. When the store
|
|
1965
|
+
// exposes version(), the index is rebuilt only when that signal changes — so a
|
|
1966
|
+
// revocation written by another handle / process is picked up (cache coherence)
|
|
1967
|
+
// while an unchanged store costs one version() call + a Set lookup, not an O(n)
|
|
1968
|
+
// reparse. A store with no version() signal owns its own coherence, so it is
|
|
1969
|
+
// read fresh each call rather than risk serving a stale cached view.
|
|
1970
|
+
function _revIndexFor() {
|
|
1971
|
+
var hasVersion = typeof revocationStore.version === "function";
|
|
1972
|
+
var storeVersion = hasVersion ? revocationStore.version() : null;
|
|
1973
|
+
if (_revIndex === null || !hasVersion || storeVersion !== _revIndexVersion) {
|
|
1974
|
+
_revIndex = new Set();
|
|
1975
|
+
_revSerialOnly = new Set();
|
|
1976
|
+
revocationStore.list().forEach(function (r) {
|
|
1977
|
+
if (r && r.serialNumber) _revIndex.add(r.serialNumber);
|
|
1978
|
+
if (r && r.fingerprint) _revIndex.add(r.fingerprint);
|
|
1979
|
+
// A serial is unique only PER ISSUER, so it is a SOUND revocation key on its own only
|
|
1980
|
+
// when the entry carries nothing else (a bare revoke(serial)). A serial+fingerprint
|
|
1981
|
+
// entry (revoke({serial,fingerprint}) / revokeGeneration's fingerprint backfill) is
|
|
1982
|
+
// scoped to its SPECIFIC cert by the fingerprint; keying it by serial too would
|
|
1983
|
+
// false-deny a different generation's cert reusing that serial in the live multi-root
|
|
1984
|
+
// gate (a custom engine that restarts its serial counter on rotation). Index the
|
|
1985
|
+
// serial-only entries separately so isSerialRevoked() consults only those.
|
|
1986
|
+
if (r && r.serialNumber && r.fingerprint == null) _revSerialOnly.add(r.serialNumber);
|
|
1987
|
+
});
|
|
1988
|
+
_revIndexVersion = storeVersion;
|
|
1989
|
+
}
|
|
1990
|
+
return _revIndex;
|
|
1991
|
+
}
|
|
1992
|
+
// The serial-only revocation index (see _revIndexFor) — built alongside _revIndex so it is
|
|
1993
|
+
// coherent with the same version() signal.
|
|
1994
|
+
function _revSerialOnlyFor() { _revIndexFor(); return _revSerialOnly; }
|
|
1995
|
+
|
|
1996
|
+
// Issuance ledger — same bring-your-own-store contract as revocationStore
|
|
1997
|
+
// ({ list(), add(entry) }). Every generateClientCert/generateClientP12
|
|
1998
|
+
// appends { serialNumber, fingerprint, generation, issuedAt } so
|
|
1999
|
+
// revokeGeneration(n) can find the certs a superseded CA generation signed.
|
|
2000
|
+
function _defaultIssuanceStore() {
|
|
2001
|
+
function _list() {
|
|
2002
|
+
if (!nodeFs.existsSync(paths.issuance)) return [];
|
|
2003
|
+
var json;
|
|
2004
|
+
try {
|
|
2005
|
+
json = safeJson.parse(atomicFile.fdSafeReadSync(paths.issuance, { maxBytes: STORE_READ_CAP, encoding: "utf8" }),
|
|
2006
|
+
{ maxBytes: STORE_READ_CAP });
|
|
2007
|
+
} catch (e) {
|
|
2008
|
+
/* c8 ignore next 2 -- defensive: safeJson.parse throws an Error with a message, so the String(e) fallback is unreachable */
|
|
2009
|
+
throw new MtlsCaError("mtls-ca/issuance-corrupt",
|
|
2010
|
+
"could not parse " + paths.issuance + ": " + ((e && e.message) || String(e)));
|
|
2011
|
+
}
|
|
2012
|
+
// A PRESENT ledger MUST carry an `issued` array. Missing / non-array `issued`
|
|
2013
|
+
// (an accidental `{}`, a truncated or externally-rewritten file) is
|
|
2014
|
+
// corruption, not an empty ledger — silently treating it as [] would let the
|
|
2015
|
+
// next add() overwrite the file with only its own entry, dropping every prior
|
|
2016
|
+
// certificate from the SOLE index revokeGeneration() consults, so those certs
|
|
2017
|
+
// would survive a later generation revocation. Fail closed, as malformed JSON
|
|
2018
|
+
// does; the operator must restore or remove the file.
|
|
2019
|
+
if (!json || !Array.isArray(json.issued)) {
|
|
2020
|
+
throw new MtlsCaError("mtls-ca/issuance-corrupt",
|
|
2021
|
+
paths.issuance + " is present but has no `issued` array (ledger schema corruption) — " +
|
|
2022
|
+
"refusing to treat a corrupt issuance ledger as empty");
|
|
2023
|
+
}
|
|
2024
|
+
return json.issued;
|
|
2025
|
+
}
|
|
2026
|
+
return {
|
|
2027
|
+
list: _list,
|
|
2028
|
+
add: function (entry) {
|
|
2029
|
+
var entries = _list();
|
|
2030
|
+
entries.push(entry);
|
|
2031
|
+
_writeStoreCapped(paths.issuance,
|
|
2032
|
+
JSON.stringify({ issued: entries }, null, 2) + "\n", { mode: 0o600 },
|
|
2033
|
+
"mtls-ca/issuance-ledger-full", "issuance ledger");
|
|
2034
|
+
},
|
|
2035
|
+
// Cheap change signal, mirroring the revocation store's version(): every
|
|
2036
|
+
// _recordIssuance / importIssuance appends, growing the file (mtime
|
|
2037
|
+
// disambiguates a same-size rewrite), so generateCrl()'s issuer-scoping
|
|
2038
|
+
// snapshot can detect a backfill that lands while the engine signs and
|
|
2039
|
+
// skip publishing the now-stale CRL. O(1) statSync, not an O(n) parse.
|
|
2040
|
+
version: function () {
|
|
2041
|
+
try { var st = nodeFs.statSync(paths.issuance); return st.size + ":" + st.mtimeMs; }
|
|
2042
|
+
catch (_e) { return "0:0"; }
|
|
2043
|
+
},
|
|
2044
|
+
};
|
|
2045
|
+
}
|
|
2046
|
+
var usesDefaultIssuanceStore = !opts.issuanceStore;
|
|
2047
|
+
var issuanceStore = opts.issuanceStore || _defaultIssuanceStore();
|
|
2048
|
+
validateOpts.requireMethods(issuanceStore, ["list", "add"],
|
|
2049
|
+
"opts.issuanceStore", MtlsCaError, "mtls-ca/bad-issuance-store");
|
|
2050
|
+
// Clustered operation (a shared revocationStore with the watermark methods, but
|
|
2051
|
+
// per-host dataDirs) REQUIRES a shared issuanceStore too. revokeGeneration()
|
|
2052
|
+
// sweeps the issuance ledger to find the certs a superseded generation signed;
|
|
2053
|
+
// with the DEFAULT local-file ledger each host records only its own issuances,
|
|
2054
|
+
// so a cert fully issued on host B before host A calls revokeGeneration() is
|
|
2055
|
+
// absent from A's sweep and stays accepted by the shared live gate — a fail-open
|
|
2056
|
+
// the shared watermark can't close (it only supersedes FUTURE appends). Refuse
|
|
2057
|
+
// the split at construction rather than silently under-revoking in a cluster.
|
|
2058
|
+
if (typeof revocationStore.readGenerationWatermark === "function" && usesDefaultIssuanceStore) {
|
|
2059
|
+
throw new MtlsCaError("mtls-ca/bad-issuance-store",
|
|
2060
|
+
"a clustered revocationStore (readGenerationWatermark/bumpGenerationWatermark) requires a shared " +
|
|
2061
|
+
"issuanceStore as well — the default per-host ledger would let revokeGeneration() miss certificates " +
|
|
2062
|
+
"issued on another host, leaving them accepted by the shared revocation gate");
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
// Record an issued leaf in the ledger. Fail-closed: the ledger is the SOLE
|
|
2066
|
+
// index revokeGeneration() consults, so a cert absent from it can never be
|
|
2067
|
+
// revoked by generation and would stay accepted by fingerprint-based
|
|
2068
|
+
// enforcement. A write failure (disk full, the 16 MiB cap crossed, a custom
|
|
2069
|
+
// store throwing) therefore FAILS issuance rather than returning an untracked
|
|
2070
|
+
// credential — the caller must resolve the persistence fault and re-issue.
|
|
2071
|
+
async function _recordIssuance(caCertPem, id) {
|
|
2072
|
+
// parseGeneration() returns 0 when node:crypto cannot parse the CA cert (a custom
|
|
2073
|
+
// engine's opaque / post-quantum cert). 0 is NOT a real generation (they are >= 1),
|
|
2074
|
+
// so record it as UNDETERMINABLE (null), never 0: recording 0 would make
|
|
2075
|
+
// revokeGeneration(1) sweep these CURRENT-generation leaves (0 < 1) and, via the
|
|
2076
|
+
// bumped watermark, self-revoke every future issuance under the CA. A null-generation
|
|
2077
|
+
// entry is skipped by revokeGeneration()'s numeric sweep (it stays revocable by
|
|
2078
|
+
// serial/fingerprint); a custom engine that wants generation-based revocation must
|
|
2079
|
+
// embed a node-parseable generation (OU=CAv<n>) in its cert.
|
|
2080
|
+
var parsedGen = parseGeneration(caCertPem);
|
|
2081
|
+
var gen = parsedGen >= 1 ? parsedGen : null;
|
|
2082
|
+
var entry = {
|
|
2083
|
+
serialNumber: id.serialNumber,
|
|
2084
|
+
fingerprint: id.fingerprint,
|
|
2085
|
+
generation: gen,
|
|
2086
|
+
// The IDENTITY of the CA cert that signed this leaf (DER-based fingerprint, reformat-stable;
|
|
2087
|
+
// a PEM-hash fallback for an opaque custom cert). generateCrl() scopes its entries by this,
|
|
2088
|
+
// NOT by generation — commit() can replace a CA with a DIFFERENT cert at the SAME generation,
|
|
2089
|
+
// so generation equality is not issuer equality; a serial reused under the new issuer would
|
|
2090
|
+
// otherwise be false-revoked by the old cert's entry.
|
|
2091
|
+
caFingerprint: _certIdentity(caCertPem).fingerprint,
|
|
2092
|
+
issuedAt: Date.now(),
|
|
2093
|
+
};
|
|
2094
|
+
try {
|
|
2095
|
+
if (usesDefaultIssuanceStore) {
|
|
2096
|
+
// Serialize the ledger's read-modify-write across processes: two issuers
|
|
2097
|
+
// over the same dataDir must not both read the ledger, append locally,
|
|
2098
|
+
// and clobber each other's entry (a lost entry is invisible to
|
|
2099
|
+
// revokeGeneration(), so the cert would survive a generation revocation).
|
|
2100
|
+
// A custom store owns its own concurrency, so it is written directly.
|
|
2101
|
+
await atomicFile.lock(paths.issuance, function () { issuanceStore.add(entry); });
|
|
2102
|
+
} else {
|
|
2103
|
+
issuanceStore.add(entry);
|
|
2104
|
+
}
|
|
2105
|
+
} catch (e) {
|
|
2106
|
+
throw new MtlsCaError("mtls-ca/issuance-ledger-write-failed",
|
|
2107
|
+
"certificate " + id.serialNumber + " was signed but could not be recorded in the issuance " +
|
|
2108
|
+
/* c8 ignore next -- String(e) fallback unreachable: a thrown store Error always has a .message */
|
|
2109
|
+
"ledger (" + paths.issuance + "): " + ((e && e.message) || String(e)) +
|
|
2110
|
+
" — refusing to return an untracked credential revokeGeneration() could not later revoke");
|
|
2111
|
+
}
|
|
2112
|
+
// Issuance-vs-generation-revocation race: this leaf's signing may have
|
|
2113
|
+
// straddled a rotate()+revokeGeneration(gen'>gen), whose sweep read the
|
|
2114
|
+
// ledger BEFORE the append above. revokeGeneration bumps the watermark before
|
|
2115
|
+
// sweeping, so having recorded FIRST then reading it here guarantees the leaf
|
|
2116
|
+
// is caught by one side or the other. Applies to ALL stores — the watermark is
|
|
2117
|
+
// a separate file, so a custom store (list()/add() only) is covered too. If
|
|
2118
|
+
// this generation is already revoked, revoke the leaf and refuse it. An
|
|
2119
|
+
// undeterminable (null) generation is never below the watermark — an opaque
|
|
2120
|
+
// custom cert can't be classified as superseded, so it is not self-revoked here.
|
|
2121
|
+
if (typeof gen === "number" && gen < _readRevokedWatermark()) {
|
|
2122
|
+
/* c8 ignore next -- the ||null fallbacks are defensive API normalization: _certIdentity always yields a fingerprint, and a serial is present for every leaf a parseable-CA engine issues */
|
|
2123
|
+
await revoke({ serial: id.serialNumber || null, fingerprint: id.fingerprint || null, reason: "superseded" });
|
|
2124
|
+
throw new MtlsCaError("mtls-ca/issuance-superseded",
|
|
2125
|
+
"certificate for CA generation " + gen + " was issued while revokeGeneration() revoked that " +
|
|
2126
|
+
"generation (a concurrent rotation) — the certificate has been revoked; re-issue under the current generation");
|
|
2127
|
+
}
|
|
2128
|
+
// Issuance-vs-root-removal race: a hard-cut rotate({ retainPrevious:false }) or a
|
|
2129
|
+
// dropRetained() can remove the root this leaf was signed under while its signing
|
|
2130
|
+
// was in flight, leaving a leaf that chains to a root no longer in loadTrustBundle().
|
|
2131
|
+
// Having recorded the leaf FIRST, then checking membership here, a leaf whose
|
|
2132
|
+
// issuing root was dropped mid-flight is caught and revoked instead of returned
|
|
2133
|
+
// un-verifiable — covering both the hard cut and dropRetained without a watermark
|
|
2134
|
+
// bump that races the removal or wrongly supersedes on a failed rotation. (A root
|
|
2135
|
+
// removed AFTER this check is the operator's intended cut of that generation,
|
|
2136
|
+
// which cuts this leaf along with its cohort; a RETAINED rotation keeps the old
|
|
2137
|
+
// root in the bundle, so its straddling leaf still chains and is NOT revoked.)
|
|
2138
|
+
// Membership by cert IDENTITY (not exact PEM text): an idempotent commit() that
|
|
2139
|
+
// republished this same root with harmless PEM differences (CRLF, wrapping, a stripped
|
|
2140
|
+
// trailing newline) leaves the same root in the bundle under different bytes — a string
|
|
2141
|
+
// indexOf would miss it and falsely revoke a leaf that still chains.
|
|
2142
|
+
var _issuingRoots = await loadTrustBundle();
|
|
2143
|
+
if (!_issuingRoots.some(function (root) { return _sameCert(root, caCertPem); })) {
|
|
2144
|
+
/* c8 ignore next -- the ||null fallbacks are defensive API normalization: _certIdentity always yields a fingerprint, and a serial is present for every leaf a parseable-CA engine issues */
|
|
2145
|
+
await revoke({ serial: id.serialNumber || null, fingerprint: id.fingerprint || null, reason: "superseded" });
|
|
2146
|
+
throw new MtlsCaError("mtls-ca/issuance-superseded",
|
|
2147
|
+
"the CA root this certificate was signed under was removed (a concurrent hard-cut rotation or " +
|
|
2148
|
+
"dropRetained()) before issuance completed — the certificate has been revoked; re-issue under the current CA");
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
624
2151
|
|
|
625
2152
|
// A fingerprint is the SHA3-512 hex the require-mtls gate pins. Normalize it
|
|
626
2153
|
// like a serial (strip 0x / separators / whitespace, lowercase, hex-validate)
|
|
@@ -637,6 +2164,24 @@ function create(opts) {
|
|
|
637
2164
|
return stripped.toLowerCase();
|
|
638
2165
|
}
|
|
639
2166
|
|
|
2167
|
+
// Normalize a fingerprint that will be STORED for the require-mtls gate to compare against
|
|
2168
|
+
// (revoke({ fingerprint }) / importIssuance). Beyond _normalizeFingerprint's hex validation,
|
|
2169
|
+
// require the framework's SHA3-512 length: a SHA-256 (64-hex) or truncated value is valid hex, so
|
|
2170
|
+
// it would be accepted and "revoke" successfully, yet the gate pins the peer's 128-hex SHA3-512
|
|
2171
|
+
// fingerprint — the compare never matches and the certificate stays admitted (a silent fail-open).
|
|
2172
|
+
// Reject the wrong length at the write. isRevoked()/isSerialRevoked() keep the bare normalizer:
|
|
2173
|
+
// they also accept a SERIAL (shorter), matched against either key an entry carries.
|
|
2174
|
+
function _normalizeGateFingerprint(fp) {
|
|
2175
|
+
var norm = _normalizeFingerprint(fp);
|
|
2176
|
+
if (norm.length !== SHA3_512_HEX_LEN) {
|
|
2177
|
+
throw new MtlsCaError("mtls-ca/bad-fingerprint",
|
|
2178
|
+
"fingerprint must be the framework's SHA3-512 leaf fingerprint (" + SHA3_512_HEX_LEN + " hex characters — the " +
|
|
2179
|
+
"value the require-mtls gate pins), got " + norm.length + ": a SHA-256 (64-hex) or truncated fingerprint would " +
|
|
2180
|
+
"be stored but never match the gate, leaving the certificate admitted");
|
|
2181
|
+
}
|
|
2182
|
+
return norm;
|
|
2183
|
+
}
|
|
2184
|
+
|
|
640
2185
|
function _normalizeSerial(s) {
|
|
641
2186
|
if (!s || typeof s !== "string") {
|
|
642
2187
|
throw new MtlsCaError("mtls-ca/bad-serial",
|
|
@@ -681,6 +2226,46 @@ function create(opts) {
|
|
|
681
2226
|
"aACompromise": 10,
|
|
682
2227
|
};
|
|
683
2228
|
|
|
2229
|
+
// Unlocked registry read/dedupe/add. Callers hold the revocation lock (default
|
|
2230
|
+
// store) or own their own concurrency (custom store) before invoking this.
|
|
2231
|
+
function _revokeCore(serial, fingerprint, reasonName, reasonCode) {
|
|
2232
|
+
var existing = revocationStore.list().find(function (r) {
|
|
2233
|
+
var serialMatch = serial && r.serialNumber === serial;
|
|
2234
|
+
var fingerprintMatch = fingerprint && r.fingerprint === fingerprint;
|
|
2235
|
+
if (!serialMatch && !fingerprintMatch) return false;
|
|
2236
|
+
// A TRUE duplicate already carries every identifier this call supplies AND is no MORE
|
|
2237
|
+
// specific in a way that matters to enforcement. A serial-only entry matched by a
|
|
2238
|
+
// revoke({ serial, fingerprint }) call (revokeGeneration's shape) is NOT a duplicate —
|
|
2239
|
+
// its fingerprint is missing, so the require-mtls gate (fingerprint-keyed) would still
|
|
2240
|
+
// admit the cert; fall through and record the fingerprint-bearing entry. Conversely, a
|
|
2241
|
+
// SERIAL-ONLY revoke(serial) must only dedup against an existing SERIAL-ONLY entry: a
|
|
2242
|
+
// pre-existing serial+FINGERPRINT entry (e.g. an OLD generation's cert with a since-reused
|
|
2243
|
+
// serial) is a DIFFERENT cert, and treating it as a duplicate would drop the serial-only
|
|
2244
|
+
// entry so isSerialRevoked() stays false and the gate admits the current cert.
|
|
2245
|
+
var coversSerial = !serial || r.serialNumber === serial;
|
|
2246
|
+
var coversFingerprint = fingerprint ? (r.fingerprint === fingerprint) : (r.fingerprint == null);
|
|
2247
|
+
return coversSerial && coversFingerprint;
|
|
2248
|
+
});
|
|
2249
|
+
if (existing) {
|
|
2250
|
+
// Idempotent — repeated revoke() of the same serial/fingerprint doesn't
|
|
2251
|
+
// shift the revokedAt timestamp.
|
|
2252
|
+
return existing;
|
|
2253
|
+
}
|
|
2254
|
+
var entry = {
|
|
2255
|
+
serialNumber: serial,
|
|
2256
|
+
fingerprint: fingerprint,
|
|
2257
|
+
reason: reasonName,
|
|
2258
|
+
reasonCode: reasonCode,
|
|
2259
|
+
revokedAt: Date.now(),
|
|
2260
|
+
};
|
|
2261
|
+
revocationStore.add(entry);
|
|
2262
|
+
return entry;
|
|
2263
|
+
}
|
|
2264
|
+
|
|
2265
|
+
// revoke() validates its input SYNCHRONOUSLY (entry-point tier: a bad serial /
|
|
2266
|
+
// fingerprint / reason throws before any work) but returns a PROMISE for the
|
|
2267
|
+
// result, because the default store's read/dedupe/add runs under a cross-process
|
|
2268
|
+
// lock. Await the returned promise for the recorded entry.
|
|
684
2269
|
function revoke(idOrOpts, opts3) {
|
|
685
2270
|
// Accept either revoke(serialString, { reason, fingerprint }) — the
|
|
686
2271
|
// backward-compatible serial-keyed form — or revoke({ serial?,
|
|
@@ -695,7 +2280,7 @@ function create(opts) {
|
|
|
695
2280
|
|
|
696
2281
|
var serial = (serialIn !== undefined && serialIn !== null) ? _normalizeSerial(serialIn) : null;
|
|
697
2282
|
var fingerprint = (fingerprintIn !== undefined && fingerprintIn !== null)
|
|
698
|
-
?
|
|
2283
|
+
? _normalizeGateFingerprint(fingerprintIn) : null;
|
|
699
2284
|
if (!serial && !fingerprint) {
|
|
700
2285
|
throw new MtlsCaError("mtls-ca/no-revocation-key",
|
|
701
2286
|
"revoke requires a serial number or a fingerprint " +
|
|
@@ -713,23 +2298,16 @@ function create(opts) {
|
|
|
713
2298
|
"revoke: unknown reason '" + reasonName + "' (valid: " +
|
|
714
2299
|
Object.keys(CRL_REASON_BY_NAME).join(", ") + ")");
|
|
715
2300
|
}
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
//
|
|
721
|
-
|
|
722
|
-
|
|
2301
|
+
if (usesDefaultRevocationStore) {
|
|
2302
|
+
// Serialize the registry read/dedupe/add across processes (same rationale
|
|
2303
|
+
// as the issuance ledger) so a concurrent revoke() / revokeGeneration() in
|
|
2304
|
+
// another process cannot read the same file, append locally, and clobber
|
|
2305
|
+
// this entry — a lost revocation would let the live gate admit the cert.
|
|
2306
|
+
return atomicFile.lock(paths.revocations, function () {
|
|
2307
|
+
return _revokeCore(serial, fingerprint, reasonName, reasonCode);
|
|
2308
|
+
});
|
|
723
2309
|
}
|
|
724
|
-
|
|
725
|
-
serialNumber: serial,
|
|
726
|
-
fingerprint: fingerprint,
|
|
727
|
-
reason: reasonName,
|
|
728
|
-
reasonCode: reasonCode,
|
|
729
|
-
revokedAt: Date.now(),
|
|
730
|
-
};
|
|
731
|
-
revocationStore.add(entry);
|
|
732
|
-
return entry;
|
|
2310
|
+
return Promise.resolve(_revokeCore(serial, fingerprint, reasonName, reasonCode));
|
|
733
2311
|
}
|
|
734
2312
|
|
|
735
2313
|
function isRevoked(serialOrFingerprint) {
|
|
@@ -740,9 +2318,19 @@ function create(opts) {
|
|
|
740
2318
|
"isRevoked requires a serial number or a fingerprint (hex string)");
|
|
741
2319
|
}
|
|
742
2320
|
var norm = _normalizeFingerprint(serialOrFingerprint);
|
|
743
|
-
return
|
|
744
|
-
|
|
745
|
-
|
|
2321
|
+
return _revIndexFor().has(norm);
|
|
2322
|
+
}
|
|
2323
|
+
|
|
2324
|
+
// Is this serial revoked by a SERIAL-ONLY entry (a bare revoke(serial), fingerprint:null)?
|
|
2325
|
+
// Used by the require-mtls live gate for a serial fallback: a serial+fingerprint revocation
|
|
2326
|
+
// is already matched by its fingerprint there, and a serial is unique only per issuer, so
|
|
2327
|
+
// matching it globally would false-deny a different generation's cert reusing the serial.
|
|
2328
|
+
function isSerialRevoked(serial) {
|
|
2329
|
+
if (!serial || typeof serial !== "string") {
|
|
2330
|
+
throw new MtlsCaError("mtls-ca/bad-revocation-key",
|
|
2331
|
+
"isSerialRevoked requires a serial number (hex string)");
|
|
2332
|
+
}
|
|
2333
|
+
return _revSerialOnlyFor().has(_normalizeFingerprint(serial));
|
|
746
2334
|
}
|
|
747
2335
|
|
|
748
2336
|
function getRevocations() {
|
|
@@ -761,8 +2349,29 @@ function create(opts) {
|
|
|
761
2349
|
"configured engine does not implement generateCrl(); use the " +
|
|
762
2350
|
"framework's bundled CA engine, which supports it");
|
|
763
2351
|
}
|
|
2352
|
+
// persist gates a `!== false` truthiness check below (default: persist). A supplied
|
|
2353
|
+
// non-boolean (e.g. the string "false" from config) is not the literal false and
|
|
2354
|
+
// would persist when the operator meant return-only. Reject it, matching commit()/
|
|
2355
|
+
// rotate()'s retainPrevious validation (config-time input throws on a typo).
|
|
2356
|
+
if (opts3.persist !== undefined && typeof opts3.persist !== "boolean") {
|
|
2357
|
+
throw new MtlsCaError("mtls-ca/bad-persist",
|
|
2358
|
+
"generateCrl: opts.persist must be a boolean when set (got " + JSON.stringify(opts3.persist) +
|
|
2359
|
+
") — a non-boolean like the string \"false\" is not the literal false and would still persist the CRL");
|
|
2360
|
+
}
|
|
764
2361
|
var ca = await initCA();
|
|
765
|
-
|
|
2362
|
+
// Snapshot the revocation registry together with the default store's version() so the
|
|
2363
|
+
// persist below can detect a revoke()/revokeGeneration() that COMPLETES while we await
|
|
2364
|
+
// engine.generateCrl(): publishing a CRL signed over the older snapshot would drop a
|
|
2365
|
+
// revocation that already returned success, leaving CRL-based clients accepting the
|
|
2366
|
+
// revoked certificate until the next regeneration. For a custom store the framework does
|
|
2367
|
+
// not own the write lock, so there is no version signal to compare (operator's concern).
|
|
2368
|
+
var allRevocations, revSnapshotVersion;
|
|
2369
|
+
var _snapshotRevocations = function () {
|
|
2370
|
+
allRevocations = revocationStore.list();
|
|
2371
|
+
revSnapshotVersion = (typeof revocationStore.version === "function") ? revocationStore.version() : null;
|
|
2372
|
+
};
|
|
2373
|
+
if (usesDefaultRevocationStore) { await atomicFile.lock(paths.revocations, _snapshotRevocations); }
|
|
2374
|
+
else { _snapshotRevocations(); }
|
|
766
2375
|
// A standard X.509 CRL (RFC 5280 §5.1) is keyed by certificate serial
|
|
767
2376
|
// number. revoke({ fingerprint }) — a first-class revocation mode, and the
|
|
768
2377
|
// value the require-mtls gate pins on — stores no serial (serialNumber is
|
|
@@ -773,10 +2382,102 @@ function create(opts) {
|
|
|
773
2382
|
// CRL (a fail-open for those certs' published revocation). Fingerprint-only
|
|
774
2383
|
// revocations stay enforced through isRevoked()/the mTLS gate, which is
|
|
775
2384
|
// fingerprint-aware; the count that could not be represented is surfaced.
|
|
2385
|
+
// Dedup by serial: one certificate can carry two registry entries (a
|
|
2386
|
+
// serial-only revocation plus a later serial+fingerprint one added when
|
|
2387
|
+
// revokeGeneration backfills the fingerprint), but a CRL must list each
|
|
2388
|
+
// serial once.
|
|
2389
|
+
// Scope the CRL to the CURRENT ISSUER IDENTITY. A CRL is signed by ONE CA, and X.509 serials
|
|
2390
|
+
// are unique only per issuer, so a revocation whose cert was issued by a DIFFERENT CA must NOT
|
|
2391
|
+
// be published here — under a custom engine that reuses a serial, that CA's revoked serial
|
|
2392
|
+
// would false-revoke the unrelated current certificate reusing it. Scope by the ISSUING CA's
|
|
2393
|
+
// identity (the ledger's caFingerprint), NOT by generation: commit() can replace a CA with a
|
|
2394
|
+
// different cert at the SAME generation, so generation equality is not issuer equality.
|
|
2395
|
+
// Resolve each entry's issuer from the ledger — by leaf FINGERPRINT (unique) preferentially,
|
|
2396
|
+
// else by serial when it maps to a single issuer. An entry whose issuer cannot be determined
|
|
2397
|
+
// (an out-of-band serial, or an undeterminable current identity) stays in, best-effort.
|
|
2398
|
+
// Snapshot the issuance ledger (the issuer-scoping source) together with its version(), the
|
|
2399
|
+
// same way the revocation registry is snapshotted above: an importIssuance() that backfills a
|
|
2400
|
+
// revoked serial's issuer can COMPLETE while we await engine.generateCrl(), and the persist
|
|
2401
|
+
// below re-checks this version so a CRL built from the older ledger view — which would still
|
|
2402
|
+
// list an old-issuer serial a serial-reusing custom engine reassigned to a current cert — is
|
|
2403
|
+
// not published. For a custom store the framework owns no lock, so there is no version signal
|
|
2404
|
+
// (operator's concern), matching the revocation-store handling.
|
|
2405
|
+
var _issuanceEntries, issuanceSnapshotVersion;
|
|
2406
|
+
var _snapshotIssuance = function () {
|
|
2407
|
+
_issuanceEntries = issuanceStore.list();
|
|
2408
|
+
issuanceSnapshotVersion = (typeof issuanceStore.version === "function") ? issuanceStore.version() : null;
|
|
2409
|
+
};
|
|
2410
|
+
if (usesDefaultIssuanceStore) { await atomicFile.lock(paths.issuance, _snapshotIssuance); }
|
|
2411
|
+
else { _snapshotIssuance(); }
|
|
2412
|
+
var currentCaId = _certIdentity(ca.caCertPem).fingerprint;
|
|
2413
|
+
var _caIdByFingerprint = new Map();
|
|
2414
|
+
var _caIdsBySerial = new Map();
|
|
2415
|
+
_issuanceEntries.forEach(function (e) {
|
|
2416
|
+
if (!e || e.caFingerprint == null) return;
|
|
2417
|
+
if (e.fingerprint != null) _caIdByFingerprint.set(e.fingerprint, e.caFingerprint);
|
|
2418
|
+
if (e.serialNumber != null) {
|
|
2419
|
+
if (!_caIdsBySerial.has(e.serialNumber)) _caIdsBySerial.set(e.serialNumber, new Set());
|
|
2420
|
+
_caIdsBySerial.get(e.serialNumber).add(e.caFingerprint);
|
|
2421
|
+
}
|
|
2422
|
+
});
|
|
2423
|
+
var _entryCaIdentity = function (r) {
|
|
2424
|
+
if (r.fingerprint != null && _caIdByFingerprint.has(r.fingerprint)) return _caIdByFingerprint.get(r.fingerprint);
|
|
2425
|
+
var ids = _caIdsBySerial.get(r.serialNumber);
|
|
2426
|
+
if (ids && ids.size === 1) return ids.values().next().value;
|
|
2427
|
+
return null;
|
|
2428
|
+
};
|
|
2429
|
+
var seenSerials = new Set();
|
|
776
2430
|
var revocations = allRevocations.filter(function (r) {
|
|
777
|
-
|
|
2431
|
+
if (!(r && r.serialNumber != null)) return false;
|
|
2432
|
+
if (currentCaId != null) {
|
|
2433
|
+
var ei = _entryCaIdentity(r);
|
|
2434
|
+
if (ei != null && ei !== currentCaId) return false; // issued by a different CA — not this CRL
|
|
2435
|
+
}
|
|
2436
|
+
if (seenSerials.has(r.serialNumber)) return false;
|
|
2437
|
+
seenSerials.add(r.serialNumber);
|
|
2438
|
+
return true;
|
|
778
2439
|
});
|
|
779
|
-
|
|
2440
|
+
// The CRL's content IS its issuer-scoped, deduped SERIAL set. Compute a stable signature of it
|
|
2441
|
+
// (and a re-computer from arbitrary fresh store views) so the persist below can re-check the CRL's
|
|
2442
|
+
// ACTUAL content — not each store's coarse version() — and skip a re-sign only when the content
|
|
2443
|
+
// genuinely changed. A normal generateClientCert() appends an unrelated fresh serial to the ledger
|
|
2444
|
+
// (and a fingerprint-only revoke() adds no serial), advancing a version() but leaving this set
|
|
2445
|
+
// unchanged; only a new serial revocation OR an importIssuance issuer-backfill of a revoked serial
|
|
2446
|
+
// alters it. _scopedCrlSerials mirrors the filter above (issuer maps + issuer-scope + dedup).
|
|
2447
|
+
var _scopedCrlSerials = function (revList, issList) {
|
|
2448
|
+
var byFp = new Map(), bySerial = new Map();
|
|
2449
|
+
issList.forEach(function (e) {
|
|
2450
|
+
if (!e || e.caFingerprint == null) return;
|
|
2451
|
+
if (e.fingerprint != null) byFp.set(e.fingerprint, e.caFingerprint);
|
|
2452
|
+
if (e.serialNumber != null) {
|
|
2453
|
+
if (!bySerial.has(e.serialNumber)) bySerial.set(e.serialNumber, new Set());
|
|
2454
|
+
bySerial.get(e.serialNumber).add(e.caFingerprint);
|
|
2455
|
+
}
|
|
2456
|
+
});
|
|
2457
|
+
var resolve = function (r) {
|
|
2458
|
+
if (r.fingerprint != null && byFp.has(r.fingerprint)) return byFp.get(r.fingerprint);
|
|
2459
|
+
var ids = bySerial.get(r.serialNumber);
|
|
2460
|
+
if (ids && ids.size === 1) return ids.values().next().value;
|
|
2461
|
+
return null;
|
|
2462
|
+
};
|
|
2463
|
+
var seen = {}, out = [];
|
|
2464
|
+
revList.forEach(function (r) {
|
|
2465
|
+
if (!(r && r.serialNumber != null)) return;
|
|
2466
|
+
if (currentCaId != null) { var ei = resolve(r); if (ei != null && ei !== currentCaId) return; }
|
|
2467
|
+
if (seen[r.serialNumber]) return;
|
|
2468
|
+
seen[r.serialNumber] = 1; out.push(r.serialNumber);
|
|
2469
|
+
});
|
|
2470
|
+
return out.sort().join(",");
|
|
2471
|
+
};
|
|
2472
|
+
var signedCrlSerials = revocations.map(function (r) { return r.serialNumber; }).slice().sort().join(",");
|
|
2473
|
+
// Count ONLY entries that genuinely lack a serial (fingerprint-only, thus
|
|
2474
|
+
// unrepresentable in an X.509 CRL). Deriving this from allRevocations.length
|
|
2475
|
+
// - revocations.length would wrongly fold in the serial DUPLICATES the dedup
|
|
2476
|
+
// above dropped, over-reporting the CRL as incomplete when those serials are
|
|
2477
|
+
// in fact published.
|
|
2478
|
+
var fingerprintOnlyOmitted = allRevocations.filter(function (r) {
|
|
2479
|
+
return r && r.serialNumber == null;
|
|
2480
|
+
}).length;
|
|
780
2481
|
var nowMs = Date.now();
|
|
781
2482
|
var thisUpdate = opts3.thisUpdate || new Date(nowMs);
|
|
782
2483
|
var nextUpdate = opts3.nextUpdate ||
|
|
@@ -792,27 +2493,603 @@ function create(opts) {
|
|
|
792
2493
|
throw new MtlsCaError("mtls-ca/bad-engine-output",
|
|
793
2494
|
"engine.generateCrl must return a non-empty PEM string");
|
|
794
2495
|
}
|
|
2496
|
+
var persisted = false;
|
|
795
2497
|
if (opts3.persist !== false) {
|
|
796
|
-
|
|
2498
|
+
// The CA may have ROTATED while we awaited engine.generateCrl() — the signed
|
|
2499
|
+
// CRL is then for the SUPERSEDED CA, and persisting it would recreate the
|
|
2500
|
+
// stale-issuer artifact a rotation just invalidated. Under the rotation lock
|
|
2501
|
+
// (so no rotation is in flight), re-check that the CA we signed under is still
|
|
2502
|
+
// current; persist only then. If it rotated, skip — the caller regenerates
|
|
2503
|
+
// under the new CA (persisted=false signals it).
|
|
2504
|
+
await atomicFile.lock(paths.caCert, function () {
|
|
2505
|
+
// Compare by cert IDENTITY, not exact PEM text: an idempotent commit() that republished
|
|
2506
|
+
// the SAME cert with harmless PEM reformatting during signing must not read as a rotation
|
|
2507
|
+
// and skip the persist.
|
|
2508
|
+
if (!(nodeFs.existsSync(paths.caCert) &&
|
|
2509
|
+
_sameCert(atomicFile.fdSafeReadSync(paths.caCert, { maxBytes: C.BYTES.mib(1) }).toString("utf8"), ca.caCertPem))) {
|
|
2510
|
+
return; // CA rotated during signing — this CRL is for the superseded issuer
|
|
2511
|
+
}
|
|
2512
|
+
var _writeCrl = function () {
|
|
2513
|
+
atomicFile.writeSync(paths.crl, crlPem, { fileMode: 0o644 });
|
|
2514
|
+
persisted = true;
|
|
2515
|
+
};
|
|
2516
|
+
// The just-signed CRL depends on BOTH the revocation snapshot AND the issuance-ledger
|
|
2517
|
+
// snapshot that resolved issuer-scoping (which serials are in/out). A revoke()/
|
|
2518
|
+
// revokeGeneration() OR an importIssuance() issuer-backfill may have COMPLETED while we
|
|
2519
|
+
// signed, making the CRL stale. Recompute the scoped serial set from BOTH fresh lists in ONE
|
|
2520
|
+
// coherent view with BOTH leaf locks held, so the check and the paths.crl write are atomic
|
|
2521
|
+
// with every store write. Checking each store against the OTHER's stale snapshot misses a
|
|
2522
|
+
// scope change only the combined fresh view reveals: a revoke by a NEW fingerprint plus an
|
|
2523
|
+
// importIssuance mapping that fingerprint to the current issuer each look scope-neutral alone,
|
|
2524
|
+
// but together move a serial into the CRL. Global lock order is caCert < revocations <
|
|
2525
|
+
// issuance — each is only ever a leaf lock elsewhere (revokeGeneration reads the ledger
|
|
2526
|
+
// unlocked; _recordIssuance releases the issuance lock before taking caCert), so this nesting
|
|
2527
|
+
// has no inverse and cannot deadlock. The version fast-path stays: if neither default store
|
|
2528
|
+
// advanced, nothing changed. If the scope moved, skip — persisted=false tells the caller to
|
|
2529
|
+
// regenerate.
|
|
2530
|
+
var _persistIfScopeUnchanged = function () {
|
|
2531
|
+
var revFresh = usesDefaultRevocationStore ? revocationStore.list() : allRevocations;
|
|
2532
|
+
var issFresh = usesDefaultIssuanceStore ? issuanceStore.list() : _issuanceEntries;
|
|
2533
|
+
var revUnchanged = !usesDefaultRevocationStore || revocationStore.version() === revSnapshotVersion;
|
|
2534
|
+
var issUnchanged = !usesDefaultIssuanceStore || issuanceStore.version() === issuanceSnapshotVersion;
|
|
2535
|
+
if ((revUnchanged && issUnchanged) ||
|
|
2536
|
+
_scopedCrlSerials(revFresh, issFresh) === signedCrlSerials) { _writeCrl(); }
|
|
2537
|
+
};
|
|
2538
|
+
var _underIssuanceLock = function () {
|
|
2539
|
+
if (usesDefaultIssuanceStore) {
|
|
2540
|
+
return atomicFile.lock(paths.issuance, _persistIfScopeUnchanged);
|
|
2541
|
+
}
|
|
2542
|
+
return _persistIfScopeUnchanged();
|
|
2543
|
+
};
|
|
2544
|
+
if (usesDefaultRevocationStore) {
|
|
2545
|
+
return atomicFile.lock(paths.revocations, _underIssuanceLock);
|
|
2546
|
+
}
|
|
2547
|
+
return _underIssuanceLock();
|
|
2548
|
+
});
|
|
797
2549
|
}
|
|
798
2550
|
return { crlPem: crlPem, thisUpdate: thisUpdate, nextUpdate: nextUpdate,
|
|
799
2551
|
entryCount: revocations.length,
|
|
800
2552
|
fingerprintOnlyOmitted: fingerprintOnlyOmitted,
|
|
2553
|
+
persisted: persisted,
|
|
801
2554
|
path: paths.crl };
|
|
802
2555
|
}
|
|
803
2556
|
|
|
2557
|
+
// ---- Algorithm migration (issue #532) ----
|
|
2558
|
+
|
|
2559
|
+
// Serialize rotations on this handle. Two concurrent rotate() calls must not
|
|
2560
|
+
// both read the same current generation, both mint the next one, and clobber
|
|
2561
|
+
// each other's CA + retained root (the second commit would overwrite the first
|
|
2562
|
+
// and snapshot a short-lived intermediate as ca.prev.crt, dropping the original
|
|
2563
|
+
// root from loadTrustBundle()). Each call waits for the prior to settle, THEN
|
|
2564
|
+
// re-reads state inside _rotateImpl, so generations advance monotonically.
|
|
2565
|
+
var _rotateChain = Promise.resolve();
|
|
2566
|
+
function rotate(rotateOpts) {
|
|
2567
|
+
var next = _rotateChain.then(function () { return _rotateImpl(rotateOpts); },
|
|
2568
|
+
function () { return _rotateImpl(rotateOpts); });
|
|
2569
|
+
// Keep the chain alive past a rejection so one failed rotation doesn't wedge
|
|
2570
|
+
// every later one; the caller still awaits `next` for the real outcome.
|
|
2571
|
+
_rotateChain = next.then(function () {}, function () {});
|
|
2572
|
+
return next;
|
|
2573
|
+
}
|
|
2574
|
+
|
|
2575
|
+
async function _rotateImpl(rotateOpts) {
|
|
2576
|
+
rotateOpts = rotateOpts || {};
|
|
2577
|
+
// A defined algorithm must be a non-empty label (matching create({ algorithm })).
|
|
2578
|
+
// An empty string would be treated as a pin here yet as "no pin" by the engine
|
|
2579
|
+
// (selecting the process default) and as "omitted" by canVerifyInTls(), letting
|
|
2580
|
+
// a pre-flight pass for the stored algorithm while rotation activates the default.
|
|
2581
|
+
if (rotateOpts.algorithm !== undefined &&
|
|
2582
|
+
(typeof rotateOpts.algorithm !== "string" || rotateOpts.algorithm.length === 0)) {
|
|
2583
|
+
throw new MtlsCaError("mtls-ca/bad-algorithm",
|
|
2584
|
+
"rotate: algorithm must be a non-empty string label when set (e.g. \"ECDSA-P384-SHA384\")");
|
|
2585
|
+
}
|
|
2586
|
+
// retainPrevious is coerced by `!== false` below, so a non-boolean (e.g. the string
|
|
2587
|
+
// "false" from config) would enable retention for every value except the literal
|
|
2588
|
+
// false — keeping a superseded CA trusted when the operator intended a hard cut.
|
|
2589
|
+
// Reject a supplied non-boolean (matching the public commit() validation).
|
|
2590
|
+
if (rotateOpts.retainPrevious !== undefined && typeof rotateOpts.retainPrevious !== "boolean") {
|
|
2591
|
+
throw new MtlsCaError("mtls-ca/bad-retain-previous",
|
|
2592
|
+
"rotate: retainPrevious must be a boolean when set (got " + JSON.stringify(rotateOpts.retainPrevious) +
|
|
2593
|
+
") — a non-boolean like the string \"false\" is not the literal false and would retain the outgoing root");
|
|
2594
|
+
}
|
|
2595
|
+
var st = status();
|
|
2596
|
+
var previousCaCertPem = st.exists ? loadCert().toString("utf8") : null;
|
|
2597
|
+
var curGen = st.exists ? st.generation : 0;
|
|
2598
|
+
// Snapshot the persisted CUSTOM label too: a concurrent commit({ algorithm }) can re-label the
|
|
2599
|
+
// byte-identical current cert/key without changing the generation or cert identity, which the
|
|
2600
|
+
// cert+generation compare-and-swap below would miss — letting this rotation overwrite the newer
|
|
2601
|
+
// effective-label migration instead of conflicting. A default engine derives its label from the
|
|
2602
|
+
// cert, so a cert-unchanged commit cannot move it; only custom labels need the extra guard.
|
|
2603
|
+
var previousPersistedLabel = !usesDefaultEngine ? _readPersistedAlgorithm() : undefined;
|
|
2604
|
+
// A stored CA whose generation is UNDETERMINABLE (status().generation === 0, since
|
|
2605
|
+
// real generations are >= 1) cannot be rotated: a default rotation would mint
|
|
2606
|
+
// generation 1 even when the active CA was already 1 or higher, and an explicit
|
|
2607
|
+
// lower/equal generation would be accepted below (curGen 0) — either mis-assigns the
|
|
2608
|
+
// revocation cohort and violates the documented strictly-increasing invariant. The
|
|
2609
|
+
// certificate failed to parse to a generation on this runtime; that has several
|
|
2610
|
+
// causes (see the message), so the diagnostic names them all rather than assuming a
|
|
2611
|
+
// custom engine.
|
|
2612
|
+
if (st.exists && curGen === 0) {
|
|
2613
|
+
throw new MtlsCaError("mtls-ca/generation-undeterminable",
|
|
2614
|
+
"the stored CA's generation cannot be determined — its certificate did not parse to a generation on " +
|
|
2615
|
+
"this runtime, so rotation cannot compute or validate a strictly-increasing generation. Causes: a " +
|
|
2616
|
+
"custom-engine certificate node:crypto cannot classify; the bundled certificate on a runtime that " +
|
|
2617
|
+
"cannot parse its algorithm (e.g. an ML-DSA CA on a Node/OpenSSL build without ML-DSA support); or a " +
|
|
2618
|
+
"corrupt/truncated ca.crt. Restore a valid ca.crt from backup, run on a runtime that parses the " +
|
|
2619
|
+
"certificate's algorithm, or (custom engine) use one whose certificate encodes a parseable generation " +
|
|
2620
|
+
"(OU=CAv<n>); a fresh dataDir resets generations only for a genuinely new CA.");
|
|
2621
|
+
}
|
|
2622
|
+
// Validate the ORIGINAL value before any normalization: Math.floor would
|
|
2623
|
+
// silently accept 1.9 / 2.9 as generation 1 / 2, committing the CA under a
|
|
2624
|
+
// different generation than requested and mis-assigning its revocation cohort.
|
|
2625
|
+
if (rotateOpts.generation !== undefined && rotateOpts.generation !== null &&
|
|
2626
|
+
(typeof rotateOpts.generation !== "number" || !Number.isInteger(rotateOpts.generation))) {
|
|
2627
|
+
throw new MtlsCaError("mtls-ca/bad-generation",
|
|
2628
|
+
"rotate: generation must be a positive integer, got " + JSON.stringify(rotateOpts.generation));
|
|
2629
|
+
}
|
|
2630
|
+
var newGen = (rotateOpts.generation !== undefined && rotateOpts.generation !== null)
|
|
2631
|
+
? rotateOpts.generation : curGen + 1;
|
|
2632
|
+
/* c8 ignore next 4 -- defensive: newGen is a validated integer >= 1 (rotateOpts.generation validated above, else curGen+1), so this never throws */
|
|
2633
|
+
if (typeof newGen !== "number" || !isFinite(newGen) || newGen < 1) {
|
|
2634
|
+
throw new MtlsCaError("mtls-ca/bad-generation",
|
|
2635
|
+
"rotate: generation must be a positive integer, got " + JSON.stringify(rotateOpts.generation));
|
|
2636
|
+
}
|
|
2637
|
+
if (st.exists && newGen <= curGen) {
|
|
2638
|
+
throw new MtlsCaError("mtls-ca/bad-generation",
|
|
2639
|
+
"rotate: generation " + newGen + " must be greater than the current CA generation " +
|
|
2640
|
+
curGen + " — a rotation moves forward (use a fresh dataDir to reset generations)");
|
|
2641
|
+
}
|
|
2642
|
+
// The pin threads into generateCa exactly as create({ algorithm }) / initCA
|
|
2643
|
+
// do; a per-call rotate({ algorithm }) overrides the create-time pin so an
|
|
2644
|
+
// operator can flip a stored classical CA to the ML-DSA default (or back)
|
|
2645
|
+
// WITHOUT the mtls-ca/algorithm-mismatch initCA raises — rotation is the
|
|
2646
|
+
// sanctioned path to change a CA's algorithm.
|
|
2647
|
+
var genArgs = { generation: newGen };
|
|
2648
|
+
var pin = rotateOpts.algorithm !== undefined ? rotateOpts.algorithm : caAlgorithm;
|
|
2649
|
+
// An UNPINNED rotation on a CUSTOM engine must PRESERVE the stored CA's PERSISTED label (the
|
|
2650
|
+
// authoritative current label) over this handle's possibly-stale create-time pin — else a bare
|
|
2651
|
+
// rotate({ generation }) on a stale-pinned sibling silently reverts a completed migration and
|
|
2652
|
+
// diverges ca.algorithm from the stored CA. Mirrors the default engine's cert-derived
|
|
2653
|
+
// preservation below (a custom label is not cert-derivable, so read the shared metadata).
|
|
2654
|
+
if (rotateOpts.algorithm === undefined && !usesDefaultEngine) {
|
|
2655
|
+
var _persistedPin = _readPersistedAlgorithm();
|
|
2656
|
+
if (_persistedPin !== undefined) pin = _persistedPin;
|
|
2657
|
+
}
|
|
2658
|
+
if (pin === undefined && usesDefaultEngine && previousCaCertPem !== null) {
|
|
2659
|
+
// An UNPINNED rotation (no rotate({algorithm}) and no create-time pin) over an
|
|
2660
|
+
// existing CA must PRESERVE the stored algorithm, not silently adopt the engine
|
|
2661
|
+
// default (ML-DSA-87). Otherwise a bare rotate({generation}) to advance a cohort
|
|
2662
|
+
// would flip a classical ECDSA CA to ML-DSA and reject legacy peers — mirroring
|
|
2663
|
+
// the stored-CA inference _leafEngineArgs does for unpinned leaf issuance.
|
|
2664
|
+
// Changing algorithm stays explicit via rotate({algorithm}).
|
|
2665
|
+
/* c8 ignore next -- the ||undefined fallback is unreachable: a default-engine CA cert always classifies to a non-null algorithm (ML-DSA / ECDSA-P384) */
|
|
2666
|
+
pin = _certAlgorithm(previousCaCertPem).algorithm || undefined;
|
|
2667
|
+
}
|
|
2668
|
+
if (pin !== undefined) genArgs.algorithm = pin;
|
|
2669
|
+
var fresh = await engine.generateCa(genArgs);
|
|
2670
|
+
if (!fresh || typeof fresh.caCertPem !== "string" || typeof fresh.caKeyPem !== "string") {
|
|
2671
|
+
throw new MtlsCaError("mtls-ca/bad-engine-output",
|
|
2672
|
+
"engine.generateCa must return { caCertPem, caKeyPem }");
|
|
2673
|
+
}
|
|
2674
|
+
// retainPrevious defaults ON for a rotation (the grace window is the point);
|
|
2675
|
+
// pass retainPrevious:false to overwrite without retaining the old CA.
|
|
2676
|
+
var retain = rotateOpts.retainPrevious !== false && previousCaCertPem !== null;
|
|
2677
|
+
// Cross-process compare-and-swap. _rotateChain serializes rotations on THIS
|
|
2678
|
+
// handle, but a separate handle over the same dataDir (or another process)
|
|
2679
|
+
// owns a different chain and could have committed a new generation while we
|
|
2680
|
+
// awaited generateCa. Hold the dataDir rotation lock, re-read the on-disk
|
|
2681
|
+
// generation UNDER it, and refuse if it moved — so the revalidation and the
|
|
2682
|
+
// commit are atomic and the loser cannot overwrite the winner's CA or
|
|
2683
|
+
// snapshot its transient intermediate as ca.prev.crt (dropping the root
|
|
2684
|
+
// clients still trust). The caller retries against the current generation.
|
|
2685
|
+
await atomicFile.lock(paths.caCert, function () {
|
|
2686
|
+
// Heal a prior rotation that crashed mid-publish BEFORE re-reading the
|
|
2687
|
+
// generation — otherwise this rotation would snapshot a new-key/old-cert
|
|
2688
|
+
// state and journal the orphaned new key, permanently losing the
|
|
2689
|
+
// recoverable prior. Safe here: the lock excludes any live commit.
|
|
2690
|
+
_reconcileCommitJournalLocked();
|
|
2691
|
+
var nowSt = status();
|
|
2692
|
+
var nowGen = nowSt.exists ? nowSt.generation : 0;
|
|
2693
|
+
// Compare cert IDENTITY, not only the generation number: a public commit()
|
|
2694
|
+
// could have replaced the CA with a DIFFERENT cert at the SAME generation
|
|
2695
|
+
// while we awaited generateCa(), which a gen-only check would miss — letting
|
|
2696
|
+
// this older rotation overwrite that later commit. Refuse if the current cert
|
|
2697
|
+
// is not the one we snapshotted before generating. Compare by DER identity (via
|
|
2698
|
+
// _sameCert) so a concurrent idempotent commit() that merely REFORMATTED the same
|
|
2699
|
+
// cert (CRLF, wrapping, a stripped trailing newline) does not spuriously abort an
|
|
2700
|
+
// expensive rotation; the null checks stay (either side is null before a first CA).
|
|
2701
|
+
var nowCert = nowSt.exists ? loadCert().toString("utf8") : null;
|
|
2702
|
+
var nowCertChanged = (nowCert === null || previousCaCertPem === null)
|
|
2703
|
+
? nowCert !== previousCaCertPem
|
|
2704
|
+
: !_sameCert(nowCert, previousCaCertPem);
|
|
2705
|
+
// A concurrent commit may have re-labelled a byte-identical CA (custom engine): the cert and
|
|
2706
|
+
// generation are unchanged, but the effective label moved, so proceeding would overwrite that
|
|
2707
|
+
// migration. Compare the persisted label snapshot too.
|
|
2708
|
+
var nowLabelChanged = !usesDefaultEngine && _readPersistedAlgorithm() !== previousPersistedLabel;
|
|
2709
|
+
if (nowGen !== curGen || nowCertChanged || nowLabelChanged) {
|
|
2710
|
+
throw new MtlsCaError("mtls-ca/rotation-conflict",
|
|
2711
|
+
"the CA changed (generation " + curGen + " -> " + nowGen + ", a same-generation replacement, or a " +
|
|
2712
|
+
"concurrent algorithm-label migration) during rotation — a concurrent rotate/commit on another handle " +
|
|
2713
|
+
"or process. Retry against the current CA");
|
|
2714
|
+
}
|
|
2715
|
+
// The single-retained-window invariant (refuse a second retained rotation
|
|
2716
|
+
// while a root is still retained) is enforced inside _commitLocked, so every
|
|
2717
|
+
// retention entry point — rotate() and the public commit() — is covered.
|
|
2718
|
+
_commitLocked({ caKeyPem: fresh.caKeyPem, caCertPem: fresh.caCertPem, retainPrevious: retain,
|
|
2719
|
+
// The effective CUSTOM label the new CA is minted under (explicit, else the preserved persisted
|
|
2720
|
+
// label) — _commitLocked persists it crash-atomically under the rollback journal.
|
|
2721
|
+
algorithm: (rotateOpts.algorithm !== undefined ? rotateOpts.algorithm : pin) });
|
|
2722
|
+
// Persist the effective algorithm on the handle WITHIN the same locked section as
|
|
2723
|
+
// its commit. Without this pin update, a handle created with an algorithm pin that
|
|
2724
|
+
// then rotate({ algorithm })s to a different one keeps the stale closed-over pin,
|
|
2725
|
+
// so the next initCA() (via generateClientCert / generateClientP12) compares it
|
|
2726
|
+
// against the new stored CA and throws mtls-ca/algorithm-mismatch. It MUST run
|
|
2727
|
+
// under the lock: a public commit() that acquires the lock the instant this
|
|
2728
|
+
// rotation releases it could publish a different CA and set the pin to match,
|
|
2729
|
+
// only for an unlocked assignment here to overwrite the pin afterwards — leaving
|
|
2730
|
+
// the stored CA and the pin disagreeing (the same mismatch on the next issuance).
|
|
2731
|
+
// Update the handle's in-memory pin. The ca.algorithm FILE was already written crash-atomically
|
|
2732
|
+
// inside _commitLocked (under the rollback journal) from the effective label passed below, so no
|
|
2733
|
+
// separate persist here — a crash cannot leave the file and the CA disagreeing.
|
|
2734
|
+
if (rotateOpts.algorithm !== undefined) {
|
|
2735
|
+
caAlgorithm = rotateOpts.algorithm;
|
|
2736
|
+
} else if (!usesDefaultEngine && pin !== undefined) {
|
|
2737
|
+
// An UNPINNED custom rotate minted the new CA under `pin` (the preserved persisted label).
|
|
2738
|
+
caAlgorithm = pin;
|
|
2739
|
+
}
|
|
2740
|
+
});
|
|
2741
|
+
// A hard cut (retainPrevious:false) removes the old root, so a leaf whose signing
|
|
2742
|
+
// straddled this rotation would chain to a root now gone from the trust bundle.
|
|
2743
|
+
// That is handled where the removal races the issuance — _recordIssuance re-checks
|
|
2744
|
+
// trust-bundle membership of its issuing root after recording (covering both this
|
|
2745
|
+
// hard cut and dropRetained), rather than a post-commit watermark bump that would
|
|
2746
|
+
// both race the removal and wrongly supersede a generation on a FAILED rotation.
|
|
2747
|
+
// A persisted CRL signed by the superseded CA is invalidated inside _commitLocked
|
|
2748
|
+
// (under the lock, covering rotate() and the public commit() alike) — regenerate
|
|
2749
|
+
// it under the new CA with generateCrl().
|
|
2750
|
+
caLog.info("rotated CA", { generation: newGen, retainedPrevious: retain });
|
|
2751
|
+
return {
|
|
2752
|
+
caCertPem: fresh.caCertPem,
|
|
2753
|
+
previousCaCertPem: previousCaCertPem,
|
|
2754
|
+
generation: newGen,
|
|
2755
|
+
// The documented migration algorithm callers persist. For the BUNDLED engine the fresh
|
|
2756
|
+
// cert is authoritative (the engine chose the key type), so infer from it. A CUSTOM
|
|
2757
|
+
// engine's own label is NOT inferable — _certAlgorithm() only understands bundled labels
|
|
2758
|
+
// and would misreport a custom P-384 CA as ECDSA-P384-SHA384 (or null for other custom
|
|
2759
|
+
// certs), recording the wrong migration algorithm — so report the effective label: the
|
|
2760
|
+
// pin carried into this rotation (rotate({algorithm}) else the create-time pin), null when
|
|
2761
|
+
// a custom label is genuinely unknown.
|
|
2762
|
+
algorithm: usesDefaultEngine
|
|
2763
|
+
? _certAlgorithm(fresh.caCertPem).algorithm
|
|
2764
|
+
: (pin !== undefined ? pin : null),
|
|
2765
|
+
};
|
|
2766
|
+
}
|
|
2767
|
+
|
|
2768
|
+
function _readCurrentCert() {
|
|
2769
|
+
return nodeFs.existsSync(paths.caCert) ? loadCert().toString("utf8") : null;
|
|
2770
|
+
}
|
|
2771
|
+
function _readRetainedRoot() {
|
|
2772
|
+
// Read the retained root without a lock, tolerating a concurrent removal: a
|
|
2773
|
+
// dropRetained() / rotate({ retainPrevious:false }) in another process can
|
|
2774
|
+
// unlink ca.prev.crt between this existsSync and the read, so an ENOENT here
|
|
2775
|
+
// just means the grace window ended.
|
|
2776
|
+
if (!nodeFs.existsSync(paths.caCertPrev)) return null;
|
|
2777
|
+
try {
|
|
2778
|
+
return atomicFile.fdSafeReadSync(paths.caCertPrev, { maxBytes: C.BYTES.mib(1) }).toString("utf8");
|
|
2779
|
+
} catch (e) {
|
|
2780
|
+
/* c8 ignore next 2 -- concurrent-removal race path: the retained-root read rarely throws in tests (ENOENT -> null; any other error re-throws) */
|
|
2781
|
+
if (!e || e.code !== "ENOENT") throw e;
|
|
2782
|
+
return null;
|
|
2783
|
+
}
|
|
2784
|
+
}
|
|
2785
|
+
// The retained root a crashed rotation saved ONLY in its rollback journal (no
|
|
2786
|
+
// initCA()/rotate() has reconciled it back to ca.prev.crt yet). Returned so a
|
|
2787
|
+
// restart that calls only loadTrustBundle() still trusts clients enrolled under
|
|
2788
|
+
// the formerly-retained generation. Best-effort: an unreadable journal is left
|
|
2789
|
+
// for the locked reconcile to handle.
|
|
2790
|
+
function _journalRetainedRoot() {
|
|
2791
|
+
var keyJournal = ((caKeySealedMode === "required") ? paths.caKeySealed : paths.caKey) + ".rollback";
|
|
2792
|
+
if (!nodeFs.existsSync(keyJournal)) return null;
|
|
2793
|
+
try {
|
|
2794
|
+
var m = safeJson.parse(atomicFile.fdSafeReadSync(keyJournal, { maxBytes: C.BYTES.mib(2), encoding: "utf8" }),
|
|
2795
|
+
{ maxBytes: C.BYTES.mib(2) });
|
|
2796
|
+
// Validate the base64 byte fields canonically (matching the locked reconcile): a
|
|
2797
|
+
// malformed prevData would otherwise leniently decode to a garbage NON-empty string
|
|
2798
|
+
// that this read path returns into loadTrustBundle() BEFORE any reconcile runs, so
|
|
2799
|
+
// an operator feeding that bundle to node:tls `ca:` gets a SecureContext failure (a
|
|
2800
|
+
// DoS of the mTLS gate). A malformed field means the journal is corrupt — leave it
|
|
2801
|
+
// for the locked reconcile (which fails closed) rather than trusting garbage.
|
|
2802
|
+
if (m && m.prevAction === "restore" && m.retainAfter !== false &&
|
|
2803
|
+
_validManifestB64Field(m.prevData) && typeof m.prevData === "string" &&
|
|
2804
|
+
_validManifestB64Field(m.cert) && typeof m.cert === "string") {
|
|
2805
|
+
// Only trust the journal's retained root when it represents an INTERRUPTED
|
|
2806
|
+
// RETENTION rotation: the live cert still equals the prior cert the journal recorded,
|
|
2807
|
+
// so the rotation never republished and the old root is still the operative
|
|
2808
|
+
// one. A SPENT journal (rotation COMPLETED — but its delete failed) has a different
|
|
2809
|
+
// live cert. A HARD CUT (retainAfter:false) is excluded even when byte-identical: a
|
|
2810
|
+
// COMPLETED byte-identical hard cut has the SAME live cert as the interrupted one, so the
|
|
2811
|
+
// cert compare can't distinguish them — re-trusting here would resurrect the very root the
|
|
2812
|
+
// operator hard-cut (the reconcile path's hardCutRemovalDone tie-break handles this; this
|
|
2813
|
+
// lock-free read must fail closed the same way). An INTERRUPTED hard cut loses nothing:
|
|
2814
|
+
// ca.prev.crt is still present and _trustRoots() reads it directly.
|
|
2815
|
+
// Byte comparison (a custom engine may emit non-UTF-8 cert bytes).
|
|
2816
|
+
var priorCertBuf = Buffer.from(m.cert, "base64");
|
|
2817
|
+
var curBuf = nodeFs.existsSync(paths.caCert)
|
|
2818
|
+
? atomicFile.fdSafeReadSync(paths.caCert, { maxBytes: C.BYTES.mib(1) }) : null;
|
|
2819
|
+
if (curBuf !== null && Buffer.from(curBuf).equals(priorCertBuf)) {
|
|
2820
|
+
return Buffer.from(m.prevData, "base64").toString("utf8");
|
|
2821
|
+
}
|
|
2822
|
+
}
|
|
2823
|
+
} catch (_e) { /* unreadable journal — the locked reconcile handles it */ }
|
|
2824
|
+
return null;
|
|
2825
|
+
}
|
|
2826
|
+
// Lock-free trust-root snapshot. The double-read makes the snapshot internally
|
|
2827
|
+
// consistent; the PUBLIC loadTrustBundle() wraps this in the rotation lock so a
|
|
2828
|
+
// completed dropRetained()/rotation cannot precede delivery of a stale bundle.
|
|
2829
|
+
// _recordIssuance's root-drop check uses the LOCKED loadTrustBundle(), not this,
|
|
2830
|
+
// so a hard-cut/dropRetained that completes is seen and the leaf self-revokes.
|
|
2831
|
+
function _trustRoots() {
|
|
2832
|
+
// A retained rotation publishes ca.prev.crt = old THEN ca.crt = new as two
|
|
2833
|
+
// steps, so a naive read can interleave: read the OLD current, then read the
|
|
2834
|
+
// just-written ca.prev.crt (also old), returning [old, old] and OMITTING the
|
|
2835
|
+
// new active root — a TLS context reloaded from that rejects newly-enrolled
|
|
2836
|
+
// clients until another reload. Read a STABLE snapshot: re-read the current
|
|
2837
|
+
// cert after the retained one and retry if it changed mid-read (a rotation
|
|
2838
|
+
// published between the reads). Bounded — a rotation completes in microseconds;
|
|
2839
|
+
// sustained churn still returns the last current snapshot rather than looping.
|
|
2840
|
+
var cur = null;
|
|
2841
|
+
var bundle = null;
|
|
2842
|
+
for (var attempt = 0; attempt < 8 && bundle === null; attempt += 1) {
|
|
2843
|
+
cur = _readCurrentCert();
|
|
2844
|
+
var prev = _readRetainedRoot();
|
|
2845
|
+
// Accept only a snapshot where BOTH the current cert AND the retained root
|
|
2846
|
+
// are unchanged across the read. Re-checking only `cur` would let a
|
|
2847
|
+
// dropRetained() that unlinks ca.prev.crt between the prev read and here slip
|
|
2848
|
+
// through — returning a root the operator just cut. Re-reading prev too means
|
|
2849
|
+
// that removal is seen as prev-changed and retried (next pass reads prev=null).
|
|
2850
|
+
if (cur === _readCurrentCert() && prev === _readRetainedRoot()) {
|
|
2851
|
+
bundle = [];
|
|
2852
|
+
if (cur) bundle.push(cur);
|
|
2853
|
+
if (prev && prev !== cur) bundle.push(prev); // dedup — never return [old, old]
|
|
2854
|
+
}
|
|
2855
|
+
}
|
|
2856
|
+
/* c8 ignore next -- retry-exhausted fallback: the 8-attempt stable-snapshot loop sets bundle on the first pass (a rotation completes in microseconds), so bundle===null is unreachable */
|
|
2857
|
+
if (bundle === null) bundle = cur ? [cur] : [];
|
|
2858
|
+
// Include a retained root held ONLY in an unreconciled rollback journal (a
|
|
2859
|
+
// crash left it there before any initCA()/rotate() reconciled) so a restart
|
|
2860
|
+
// that loads trust without first reconciling does not drop that cohort.
|
|
2861
|
+
var journalRoot = _journalRetainedRoot();
|
|
2862
|
+
if (journalRoot && bundle.indexOf(journalRoot) === -1) bundle.push(journalRoot);
|
|
2863
|
+
return bundle;
|
|
2864
|
+
}
|
|
2865
|
+
// Public trust bundle. Returns a PROMISE: it takes the rotation lock so the read
|
|
2866
|
+
// is serialized with dropRetained()/rotation — a cutoff that has COMPLETED (held
|
|
2867
|
+
// then released the lock) cannot be preceded by delivery of a bundle that still
|
|
2868
|
+
// trusts the cut root, closing the residual window a lock-free read leaves after
|
|
2869
|
+
// its last comparison. Under the lock no rotation/removal is in flight, so the
|
|
2870
|
+
// snapshot is both consistent and current. Await it.
|
|
2871
|
+
function loadTrustBundle() {
|
|
2872
|
+
return atomicFile.lock(paths.caCert, function () { return _trustRoots(); });
|
|
2873
|
+
}
|
|
2874
|
+
|
|
2875
|
+
// Ends the retained-root grace window. Returns a PROMISE: it takes the rotation
|
|
2876
|
+
// lock (paths.caCert) so it cannot unlink ca.prev.crt in the middle of a
|
|
2877
|
+
// concurrent retained rotation (which writes prev, then renames the new cert) —
|
|
2878
|
+
// that interleaving would leave the rotation with no retained root, stranding
|
|
2879
|
+
// clients on the outgoing CA. Await it.
|
|
2880
|
+
function dropRetained() {
|
|
2881
|
+
return atomicFile.lock(paths.caCert, function () {
|
|
2882
|
+
// Reconcile an interrupted rotation FIRST. A crashed hard-cut rotation can
|
|
2883
|
+
// remove ca.prev.crt yet leave a journal whose recorded root loadTrustBundle()
|
|
2884
|
+
// still trusts (its prior cert matches the live cert). Without reconciling,
|
|
2885
|
+
// dropRetained() would see no live retained file, remove nothing, and the
|
|
2886
|
+
// journal would keep serving the "dropped" root — so the window never ends.
|
|
2887
|
+
// Under this lock, reconcile restores that root (or drops a spent journal);
|
|
2888
|
+
// the removal below then actually ends the grace window.
|
|
2889
|
+
_reconcileCommitJournalLocked();
|
|
2890
|
+
var had = nodeFs.existsSync(paths.caCertPrev);
|
|
2891
|
+
if (had) {
|
|
2892
|
+
nodeFs.unlinkSync(paths.caCertPrev);
|
|
2893
|
+
// Durable removal — see the commit-path note; ca.prev.crt's parent may
|
|
2894
|
+
// differ from ca.crt's, so a power loss must not resurrect the dropped root.
|
|
2895
|
+
atomicFile.fsyncDir(nodePath.dirname(paths.caCertPrev));
|
|
2896
|
+
}
|
|
2897
|
+
return { dropped: had };
|
|
2898
|
+
});
|
|
2899
|
+
}
|
|
2900
|
+
|
|
2901
|
+
// Backfill leaf identities the issuance ledger does not have — certificates
|
|
2902
|
+
// issued by a PRE-#532 release (whose runs never recorded issuance) or issued
|
|
2903
|
+
// out of band. revokeGeneration(n) can only sweep what the ledger records, so an
|
|
2904
|
+
// upgraded dataDir's older cohort must be imported first: each entry is
|
|
2905
|
+
// { fingerprint, generation, serialNumber?, caCert? }. `generation` is the ISSUING
|
|
2906
|
+
// CA's generation (the OU=CAvN tag lives on the CA cert's subject, NOT the leaf), so
|
|
2907
|
+
// derive it from the ISSUER cert — parseGeneration(<issuing CA cert>), or the
|
|
2908
|
+
// known cohort number — never parseGeneration(<leaf>), which would fall back to 1
|
|
2909
|
+
// and mislabel a gen-2+ leaf (revokeGeneration could then revoke a current cert).
|
|
2910
|
+
// `caCert` (the issuing CA certificate PEM, optional) records the same issuer identity
|
|
2911
|
+
// normal issuance stores so generateCrl() can issuer-scope the entry. Returns
|
|
2912
|
+
// { imported }. SYNC-throws on bad input.
|
|
2913
|
+
function importIssuance(entries) {
|
|
2914
|
+
if (!Array.isArray(entries)) {
|
|
2915
|
+
throw new MtlsCaError("mtls-ca/bad-import",
|
|
2916
|
+
"importIssuance requires an array of { fingerprint, generation, serialNumber? } entries");
|
|
2917
|
+
}
|
|
2918
|
+
var normalized = entries.map(function (e) {
|
|
2919
|
+
if (!e || typeof e !== "object") {
|
|
2920
|
+
throw new MtlsCaError("mtls-ca/bad-import", "each importIssuance entry must be an object");
|
|
2921
|
+
}
|
|
2922
|
+
if (typeof e.generation !== "number" || !Number.isInteger(e.generation) || e.generation < 1) {
|
|
2923
|
+
throw new MtlsCaError("mtls-ca/bad-import", "importIssuance entry.generation must be a positive integer");
|
|
2924
|
+
}
|
|
2925
|
+
var fp = (e.fingerprint !== undefined && e.fingerprint !== null) ? _normalizeGateFingerprint(e.fingerprint) : null;
|
|
2926
|
+
var serial = (e.serialNumber !== undefined && e.serialNumber !== null) ? _normalizeSerial(e.serialNumber) : null;
|
|
2927
|
+
// A fingerprint is MANDATORY: importIssuance exists so revokeGeneration() can sweep the entry,
|
|
2928
|
+
// and a serial is unique only per issuer. A serial-only entry would be swept into a
|
|
2929
|
+
// fingerprint-null revocation that isSerialRevoked() matches GLOBALLY, false-revoking a current
|
|
2930
|
+
// certificate that reuses the serial under a rotated / serial-reusing custom CA (recording
|
|
2931
|
+
// caCert does not help — the live serial lookup does not consult it). The gate pins the
|
|
2932
|
+
// globally-unique SHA3-512 fingerprint, so require it; serialNumber stays optional (recorded
|
|
2933
|
+
// for the CRL alongside the fingerprint).
|
|
2934
|
+
if (!fp) {
|
|
2935
|
+
throw new MtlsCaError("mtls-ca/bad-import",
|
|
2936
|
+
"importIssuance entry requires a fingerprint (the globally-unique SHA3-512 identity the require-mtls gate " +
|
|
2937
|
+
"pins) — a serial number is unique only per issuer, so a serial-only entry would be generation-revoked into a " +
|
|
2938
|
+
"fingerprint-null revocation that false-revokes an unrelated current certificate reusing the serial; supply the " +
|
|
2939
|
+
"certificate's fingerprint (serialNumber may accompany it for the CRL)");
|
|
2940
|
+
}
|
|
2941
|
+
// Record the ISSUING CA's identity (as normal issuance does via caFingerprint) so
|
|
2942
|
+
// generateCrl() can issuer-scope this backfilled entry: without it, an imported old-CA
|
|
2943
|
+
// revocation whose serial a current CA reuses is left in the current CRL, false-revoking the
|
|
2944
|
+
// unrelated current cert. Accept the issuing CA cert PEM (caCert) and derive the same
|
|
2945
|
+
// DER-based identity; absent it the entry stays issuer-unknown (best-effort included).
|
|
2946
|
+
if (e.caCert !== undefined && e.caCert !== null && typeof e.caCert !== "string") {
|
|
2947
|
+
throw new MtlsCaError("mtls-ca/bad-import", "importIssuance entry.caCert must be a PEM string (the issuing CA certificate) when set");
|
|
2948
|
+
}
|
|
2949
|
+
var caFp = (e.caCert !== undefined && e.caCert !== null) ? _certIdentity(e.caCert).fingerprint : null;
|
|
2950
|
+
return { serialNumber: serial, fingerprint: fp, generation: e.generation, caFingerprint: caFp, issuedAt: e.issuedAt || Date.now() };
|
|
2951
|
+
});
|
|
2952
|
+
var add = function () { normalized.forEach(function (n) { issuanceStore.add(n); }); };
|
|
2953
|
+
var run = usesDefaultIssuanceStore ? atomicFile.lock(paths.issuance, add) : Promise.resolve(add());
|
|
2954
|
+
return run.then(function () {
|
|
2955
|
+
// Read the watermark AFTER the append (matching _recordIssuance's ordering,
|
|
2956
|
+
// fail-closed on a malformed value): a concurrent revokeGeneration that
|
|
2957
|
+
// bumps the watermark and finishes its sweep before our append lands would,
|
|
2958
|
+
// with a pre-read stale value, be missed by BOTH the sweep and this check —
|
|
2959
|
+
// reading here guarantees one side catches the entry. An imported leaf whose
|
|
2960
|
+
// generation is already revoked is revoked here.
|
|
2961
|
+
var wm = _readRevokedWatermark();
|
|
2962
|
+
var superseded = normalized.filter(function (n) { return n.generation < wm; });
|
|
2963
|
+
return Promise.all(superseded.map(function (n) {
|
|
2964
|
+
// n.fingerprint is always present — importIssuance requires it — so no ||null fallback here.
|
|
2965
|
+
return revoke({ serial: n.serialNumber || null, fingerprint: n.fingerprint, reason: "superseded" });
|
|
2966
|
+
})).then(function () { return { imported: normalized.length, revoked: superseded.length }; });
|
|
2967
|
+
});
|
|
2968
|
+
}
|
|
2969
|
+
|
|
2970
|
+
// Revoke every cert the issuance ledger recorded under a CA generation < n.
|
|
2971
|
+
// (Pre-#532 / out-of-band certs are unindexed until importIssuance() backfills
|
|
2972
|
+
// them — see above.) Enforcement is fingerprint-keyed through the revocation
|
|
2973
|
+
// registry —
|
|
2974
|
+
// isRevoked() and a require-mtls gate wired with `revocationSource: caHandle`
|
|
2975
|
+
// deny these certs regardless of which CA generation issued them. A standard
|
|
2976
|
+
// X.509 CRL cannot: generateCrl() signs with the CURRENT CA, which a peer will
|
|
2977
|
+
// not accept as revoking a cert issued by a superseded generation. For a CRL-
|
|
2978
|
+
// consuming deployment, publish generateCrl() for a generation while it is
|
|
2979
|
+
// still current (before rotate() supersedes its signing key); the registry
|
|
2980
|
+
// path above needs no such ordering.
|
|
2981
|
+
// Like revoke(): SYNC-throws on bad input, returns a PROMISE for { revoked }.
|
|
2982
|
+
function revokeGeneration(n, opts3) {
|
|
2983
|
+
if (typeof n !== "number" || !isFinite(n) || n < 1 || Math.floor(n) !== n) {
|
|
2984
|
+
throw new MtlsCaError("mtls-ca/bad-generation",
|
|
2985
|
+
"revokeGeneration: n must be a positive integer (revokes every cert issued under a CA generation < n)");
|
|
2986
|
+
}
|
|
2987
|
+
opts3 = opts3 || {};
|
|
2988
|
+
var reason = opts3.reason || "superseded";
|
|
2989
|
+
var reasonCode = CRL_REASON_BY_NAME[reason];
|
|
2990
|
+
if (reasonCode === undefined) {
|
|
2991
|
+
throw new MtlsCaError("mtls-ca/bad-reason",
|
|
2992
|
+
"revokeGeneration: unknown reason '" + reason + "' (valid: " +
|
|
2993
|
+
Object.keys(CRL_REASON_BY_NAME).join(", ") + ")");
|
|
2994
|
+
}
|
|
2995
|
+
var sweep = function () {
|
|
2996
|
+
// Uses _revokeCore directly — for the default store we already hold the
|
|
2997
|
+
// revocation lock here, so calling revoke() would re-enter it.
|
|
2998
|
+
var before = revocationStore.list().length;
|
|
2999
|
+
issuanceStore.list().forEach(function (e) {
|
|
3000
|
+
if (e && typeof e.generation === "number" && e.generation < n && (e.serialNumber || e.fingerprint)) {
|
|
3001
|
+
_revokeCore(e.serialNumber || null, e.fingerprint || null, reason, reasonCode);
|
|
3002
|
+
}
|
|
3003
|
+
});
|
|
3004
|
+
return { revoked: revocationStore.list().length - before };
|
|
3005
|
+
};
|
|
3006
|
+
// Bump the watermark (atomic for ALL stores — a shared custom store's
|
|
3007
|
+
// bumpGenerationWatermark, else a locked local-file RMW) BEFORE sweeping, so
|
|
3008
|
+
// an in-flight issuance that records after the sweep-read still self-revokes
|
|
3009
|
+
// (see _recordIssuance).
|
|
3010
|
+
return _bumpRevokedWatermark(n).then(function () {
|
|
3011
|
+
return usesDefaultRevocationStore ? atomicFile.lock(paths.revocations, sweep) : Promise.resolve(sweep());
|
|
3012
|
+
});
|
|
3013
|
+
}
|
|
3014
|
+
|
|
3015
|
+
// CA-handle convenience over the engine probe: can node:tls VERIFY a chain
|
|
3016
|
+
// under a given algorithm on this runtime? Pass the PROSPECTIVE algorithm to
|
|
3017
|
+
// pre-flight a migration — canVerifyInTls("ML-DSA-87") before
|
|
3018
|
+
// rotate({ algorithm: "ML-DSA-87" }) probes the TARGET, not the current CA, so
|
|
3019
|
+
// an ECDSA-stored handle does not falsely pass when the runtime cannot verify
|
|
3020
|
+
// the ML-DSA chain it is about to activate. With no argument it probes the
|
|
3021
|
+
// stored CA's algorithm (or the create-time pin / engine default when none is
|
|
3022
|
+
// stored yet). Delegates to engine.canVerifyInTls(label).
|
|
3023
|
+
async function canVerifyInTls(algorithm) {
|
|
3024
|
+
if (typeof engine.canVerifyInTls !== "function") {
|
|
3025
|
+
throw new MtlsCaError("mtls-ca/no-tls-probe",
|
|
3026
|
+
"the configured engine does not implement canVerifyInTls(label)");
|
|
3027
|
+
}
|
|
3028
|
+
// Validate a SUPPLIED argument before any fallback (same as create()/rotate()): an
|
|
3029
|
+
// empty string or non-string explicit target must be REFUSED, not silently treated
|
|
3030
|
+
// as "omitted" and answered against the stored/default algorithm — that would let a
|
|
3031
|
+
// migration pre-flight return true without ever testing the requested target. Only
|
|
3032
|
+
// an OMITTED argument (undefined) falls back to the stored CA / create-time pin.
|
|
3033
|
+
if (algorithm !== undefined && (typeof algorithm !== "string" || algorithm.length === 0)) {
|
|
3034
|
+
throw new MtlsCaError("mtls-ca/bad-algorithm",
|
|
3035
|
+
"canVerifyInTls(algorithm) requires a non-empty string algorithm label when provided " +
|
|
3036
|
+
"(e.g. \"ML-DSA-87\"); omit the argument to probe the stored CA");
|
|
3037
|
+
}
|
|
3038
|
+
var st = status();
|
|
3039
|
+
// For a CUSTOM engine, prefer the durable stored label over status()'s inferred label:
|
|
3040
|
+
// status() infers a BUNDLED label (e.g. ML-DSA-87) from the cert's key type, but a custom
|
|
3041
|
+
// engine may use its own label for that key type, which only its stored metadata carries —
|
|
3042
|
+
// passing the bundled label could make the engine reject or misinterpret the probe. The
|
|
3043
|
+
// stored label lives in ca.algorithm (the durable shared file), which a SIBLING handle's
|
|
3044
|
+
// migration updates, so read it here rather than trusting this handle's possibly-stale
|
|
3045
|
+
// caAlgorithm closure — as the issuance, cold-start adopt, and rotate paths do. The default
|
|
3046
|
+
// engine's inferred label matches its own label set, so status() wins there.
|
|
3047
|
+
var _effectiveCustomLabel = caAlgorithm;
|
|
3048
|
+
if (!usesDefaultEngine) {
|
|
3049
|
+
var _persistedLabel = _currentCustomLabel();
|
|
3050
|
+
if (_persistedLabel !== undefined) _effectiveCustomLabel = _persistedLabel;
|
|
3051
|
+
}
|
|
3052
|
+
var label = (typeof algorithm === "string" && algorithm.length > 0)
|
|
3053
|
+
? algorithm
|
|
3054
|
+
: (usesDefaultEngine ? (st.algorithm || caAlgorithm) : (_effectiveCustomLabel || st.algorithm));
|
|
3055
|
+
// Refuse an undeterminable label ONLY when a CA is STORED whose algorithm this
|
|
3056
|
+
// runtime cannot classify (status().algorithm === null — e.g. a P-256 custom
|
|
3057
|
+
// engine — with no create-time pin): passing undefined to the engine would then
|
|
3058
|
+
// let one that reads an omitted label as "current default" probe a DIFFERENT
|
|
3059
|
+
// algorithm than the stored CA, reporting on the wrong chain. With NO CA stored
|
|
3060
|
+
// yet, an omitted label is unambiguous — the engine resolves its default, which
|
|
3061
|
+
// is exactly the intended pre-flight probe on a fresh deployment — so pass it
|
|
3062
|
+
// through rather than forcing the operator to name a label they may not know.
|
|
3063
|
+
if ((typeof label !== "string" || label.length === 0) && st.exists) {
|
|
3064
|
+
throw new MtlsCaError("mtls-ca/algorithm-undeterminable",
|
|
3065
|
+
"canVerifyInTls() cannot derive the stored CA's algorithm (a custom-engine CA this runtime does " +
|
|
3066
|
+
"not classify, with no create-time pin) — pass the algorithm explicitly, e.g. canVerifyInTls(\"ML-DSA-87\")");
|
|
3067
|
+
}
|
|
3068
|
+
return engine.canVerifyInTls(label);
|
|
3069
|
+
}
|
|
3070
|
+
|
|
804
3071
|
return {
|
|
805
3072
|
exists: exists,
|
|
806
3073
|
keyExists: keyExists,
|
|
807
3074
|
status: status,
|
|
808
3075
|
loadKey: loadKey,
|
|
809
3076
|
loadCert: loadCert,
|
|
3077
|
+
loadTrustBundle: loadTrustBundle,
|
|
810
3078
|
commit: commit,
|
|
811
3079
|
initCA: initCA,
|
|
3080
|
+
rotate: rotate,
|
|
3081
|
+
dropRetained: dropRetained,
|
|
3082
|
+
canVerifyInTls: canVerifyInTls,
|
|
3083
|
+
revokeGeneration: revokeGeneration,
|
|
3084
|
+
importIssuance: importIssuance,
|
|
812
3085
|
generateClientCert: generateClientCert,
|
|
813
3086
|
generateClientP12: generateClientP12,
|
|
814
3087
|
revoke: revoke,
|
|
815
3088
|
isRevoked: isRevoked,
|
|
3089
|
+
// isRevoked already matches a serial OR fingerprint; this alias signals to a
|
|
3090
|
+
// require-mtls gate that this source supports serial-number lookups (so it may
|
|
3091
|
+
// check the peer cert's serial), without changing isRevoked's contract.
|
|
3092
|
+
isSerialRevoked: isSerialRevoked,
|
|
816
3093
|
getRevocations: getRevocations,
|
|
817
3094
|
generateCrl: generateCrl,
|
|
818
3095
|
paths: paths,
|