@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/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 cert via
48
- * `b.crypto.sha3Hash(certPem)` so the SHA3-512 posture stays
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.sha3Hash(certPem)).
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
- var m = /OU=CAv(\d+)/.exec(subj);
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
- // The inverse of _expectedKeyTypeForPin: map a stored CA key's node
215
- // asymmetricKeyType to the algorithm label leaves should be issued under, so an
216
- // UNPINNED deployment (e.g. an upgrade that never set opts.algorithm) issues
217
- // leaves under the CA's OWN algorithma coherent chain the CA's existing peers
218
- // can verify — instead of the engine's newer process default. Returns undefined
219
- // for a key node cannot parse or a type this file doesn't map (the engine then
220
- // applies its own default).
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
- if (type === "ec") return "ECDSA-P384-SHA384";
227
- if (/^ml-dsa-\d+$/.test(type)) return type.toUpperCase();
228
- return undefined;
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
- isLegacy: gen < generation,
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
- function commit(opts2) {
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
- var keyTmp = keyDest + ".tmp";
372
- var certTmp = paths.caCert + ".tmp";
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
- if (sealed) {
398
- _requireVault("sealed CA key commit");
399
- _writeExclusive(keyTmp, vault.seal(opts2.caKeyPem), 0o600);
400
- } else {
401
- _writeExclusive(keyTmp, opts2.caKeyPem, 0o600);
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
- atomicFile.renameWithRetry(certTmp, paths.caCert);
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
- async function initCA() {
428
- if (exists()) {
429
- var existingCertPem = loadCert().toString("utf8");
430
- var existingKeyPem = loadKey().toString("utf8");
431
- // A stored CA is returned as-is (initCA never silently rotates). But an
432
- // algorithm pin that DISAGREES with the stored CA cannot be honored: the
433
- // CA's own signature over every leaf is what a peer verifies, so issuing an
434
- // ECDSA leaf pinned for a legacy peer under a stored ML-DSA CA still yields
435
- // an ML-DSA-signed chain that peer cannot verify. Refuse the mismatch and
436
- // tell the operator to rotate, rather than issue an unusable credential.
437
- if (caAlgorithm !== undefined) {
438
- var expectedType = _expectedKeyTypeForPin(caAlgorithm);
439
- var actualType = null;
440
- var actualCurve = null;
441
- // A custom engine may store a key node cannot parse — skip the check then.
442
- try {
443
- var caKeyObj = nodeCrypto.createPrivateKey(existingKeyPem);
444
- /* c8 ignore next -- the "" fallback is defensive: a parsed KeyObject always reports a non-empty asymmetricKeyType, so it is never reached */
445
- actualType = String(caKeyObj.asymmetricKeyType || "").toLowerCase();
446
- actualCurve = caKeyObj.asymmetricKeyDetails && caKeyObj.asymmetricKeyDetails.namedCurve
447
- ? String(caKeyObj.asymmetricKeyDetails.namedCurve).toLowerCase() : null;
448
- } catch (_e) { actualType = null; }
449
- if (expectedType !== null && actualType && actualType !== expectedType) {
450
- throw new MtlsCaError("mtls-ca/algorithm-mismatch",
451
- "the CA at this dataDir was generated under " + actualType + ", but algorithm " +
452
- JSON.stringify(caAlgorithm) + " (" + expectedType + ") was requested. A leaf issued " +
453
- "under the pin would be signed by the mismatched CA and fail chain verification at a " +
454
- "peer. Rotate to a new CA (a fresh dataDir, or a higher generation) to change algorithms.");
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
- // Every ECDSA label maps to the generic "ec" type, so the type check alone
457
- // would accept a P-256/P-521 stored CA under the ECDSA-P384 pin — leaving
458
- // the operator believing they hold P-384 posture. The framework's sole
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
- return { caCertPem: existingCertPem, caKeyPem: existingKeyPem };
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
- commit(fresh);
482
- return fresh;
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: bCrypto().sha3Hash(certPem),
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
- var leafAlg = caAlgorithm;
518
- if (leafAlg === undefined) {
519
- // The stored CA's own key type maps to the BUNDLED engine's label set, so
520
- // the inference is USED only for that engine a custom engine resolves its
521
- // own algorithm from its own key (injecting the bundled ECDSA-P384-SHA384
522
- // label would break a P-256/P-521 or custom-labeled engine that validates
523
- // its option shape).
524
- var caKeyLabel = _labelForCaKeyType(ca.caKeyPem);
525
- if (usesDefaultEngine) leafAlg = caKeyLabel;
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
- if (typeof result.certPem === "string") {
581
- var id12 = _certIdentity(result.certPem);
582
- return Object.assign({}, result, { serialNumber: id12.serialNumber, fingerprint: id12.fingerprint });
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
- return result;
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: C.BYTES.mib(16), encoding: "utf8" }),
603
- { maxBytes: C.BYTES.mib(16) });
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
- atomicFile.writeSync(paths.revocations,
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
- ? _normalizeFingerprint(fingerprintIn) : null;
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
- var existing = revocationStore.list().find(function (r) {
717
- return (serial && r.serialNumber === serial) || (fingerprint && r.fingerprint === fingerprint);
718
- });
719
- if (existing) {
720
- // Idempotentrepeated revoke() of the same serial/fingerprint doesn't
721
- // shift the revokedAt timestamp.
722
- return existing;
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
- var entry = {
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 revocationStore.list().some(function (r) {
744
- return r.serialNumber === norm || r.fingerprint === norm;
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
- var allRevocations = revocationStore.list();
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
- return r && r.serialNumber != null;
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
- var fingerprintOnlyOmitted = allRevocations.length - revocations.length;
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
- atomicFile.writeSync(paths.crl, crlPem, { mode: 0o644 });
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,