@blamejs/core 0.17.24 → 0.18.1

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/daemon.js CHANGED
@@ -72,6 +72,13 @@ var DEFAULT_STOP_TIMEOUT_MS = C.TIME.seconds(30);
72
72
  var DEFAULT_STOP_SIGNAL = "SIGTERM";
73
73
  var DEFAULT_POLL_MS = 100;
74
74
  var DEFAULT_LOG_FILE_MODE = 0o600;
75
+ // A detached child that exits within this window of spawn is treated as a boot
76
+ // death (spawn_failed audit); an abnormal exit after it is a normal run/crash,
77
+ // and a clean exit or an operator stop() is never a spawn failure.
78
+ var BOOT_DEATH_WINDOW_MS = C.TIME.seconds(5);
79
+ // setTimeout clamps a delay above this 32-bit ceiling to ~1ms, which would
80
+ // silently defeat the boot-window loop-hold — so the opt is refused above it.
81
+ var MAX_BOOT_DEATH_WINDOW_MS = 0x7FFFFFFF; // 2,147,483,647
75
82
  // Poll cadence for the Windows cooperative-stop sentinel (a synchronous
76
83
  // existsSync on this interval; see _installStopSentinelWatcher for why it is a
77
84
  // poll and not a filesystem watch). Runs for a foreground daemon's whole
@@ -90,6 +97,71 @@ function _safeAuditEmit(action, outcome, metadata) {
90
97
  var _isLivePid = pidProbe.isLivePid;
91
98
  var _readPidFile = pidProbe.readPidFile;
92
99
 
100
+ // An in-flight stop() writes a `<pidFile>.stopping` marker holding the pid it is
101
+ // stopping. The detached boot-death exit handler consults it so a stop()-induced
102
+ // exit within the boot window is not misread as a spawn failure — a FILESYSTEM
103
+ // marker (not an in-process flag) so it works whether stop() runs in the same
104
+ // process that called start() OR a different one (e.g. a `daemon stop` CLI). The
105
+ // marker carries the target pid so a stale marker from a crashed stopper can
106
+ // only ever suppress that same pid, never a later daemon reusing the pidfile.
107
+ function _stoppingMarkerPath(pidFile) { return pidFile + ".stopping"; }
108
+
109
+ // Reap the boot-dead child's OWN stale pidfile without a check-then-unlink race.
110
+ // A naive `if (read(pidFile) === childPid) unlink(pidFile)` can delete the WRONG
111
+ // file: a fast operator restart that rewrites pidFile with a NEW child's pid
112
+ // between the read and the unlink leaves the new daemon unmanageable via stop().
113
+ //
114
+ // A NON-DESTRUCTIVE ownership pre-check runs first: only when the sidecar still
115
+ // records THIS child's pid do we ATOMICALLY claim it (rename it to a per-pid path)
116
+ // so the confirming check and the removal act on the same exclusive file. A fast
117
+ // restart's replacement pidfile (a different pid) is thus never even temporarily
118
+ // hidden — we leave it untouched. Should a restart land in the tiny window between
119
+ // the pre-check and the claim, the post-claim re-check catches it (the claimed
120
+ // file's pid won't match) and restores the file. `readPid` defaults to the
121
+ // hardened reader and is injectable for tests. Returns whether the reaped sidecar
122
+ // was this child's.
123
+ function _reapOwnStalePidfile(pidFile, childPid, readPid) {
124
+ readPid = readPid || _readPidFile;
125
+ // Pre-check: don't claim (and momentarily hide) a pidfile that isn't ours.
126
+ var preOwned = false;
127
+ try { preOwned = String(readPid(pidFile)) === String(childPid); } catch (_pe) { preOwned = false; }
128
+ if (!preOwned) return false;
129
+ var claim = pidFile + ".reap-" + childPid;
130
+ try {
131
+ atomicFile.renameWithRetry(pidFile, claim); // retries a transient Windows AV/indexer lock
132
+ } catch (_e) {
133
+ return false; // pidFile vanished between the pre-check and the claim (stop() / a restart) — nothing to reap
134
+ }
135
+ // We now exclusively hold `claim`. Verify ownership against it (not pidFile) so
136
+ // the check and the removal act on the same file; a restart that rewrites
137
+ // pidFile after our rename creates a fresh, separate pidfile we never touch.
138
+ var mine = false;
139
+ try { mine = String(readPid(claim)) === String(childPid); } catch (_e2) { mine = false; }
140
+ if (mine) {
141
+ try { nodeFs.unlinkSync(claim); } catch (_e3) { /* best-effort reap — the sidecar is already off pidFile */ }
142
+ } else {
143
+ // Not ours (a fast restart's newer pidfile, or an unreadable one) — put it
144
+ // back so stop() still finds the running daemon's sidecar, but ONLY if nothing
145
+ // newer has since taken pidFile's place. linkSync is atomic and fails with
146
+ // EEXIST when a still-newer daemon already wrote pidFile during our inspection,
147
+ // so the restore never clobbers the newest pidfile with our older claimed one
148
+ // (a plain rename would). A NON-EEXIST failure means hard links aren't
149
+ // available on this filesystem (ENOTSUP on FAT / some network mounts), where
150
+ // nothing newer is present — fall back to a plain rename so the pidfile isn't
151
+ // lost entirely (it may clobber, but losing the running daemon's pidfile is
152
+ // worse). Either way, drop our claim afterward.
153
+ try {
154
+ nodeFs.linkSync(claim, pidFile);
155
+ } catch (linkErr) {
156
+ if (linkErr.code !== "EEXIST") {
157
+ try { atomicFile.renameWithRetry(claim, pidFile); } catch (_re) { /* best-effort restore */ }
158
+ }
159
+ }
160
+ try { nodeFs.unlinkSync(claim); } catch (_e5) { /* consumed by the rename fallback, or already gone */ }
161
+ }
162
+ return mine;
163
+ }
164
+
93
165
  function _validateStartOpts(opts) {
94
166
  validateOpts.shape(opts, {
95
167
  pidFile: { rule: "required-string", code: "daemon/bad-pid-file",
@@ -118,6 +190,15 @@ function _validateStartOpts(opts) {
118
190
  "daemon.start: opts.args requires opts.command");
119
191
  }
120
192
  },
193
+ bootDeathWindowMs: function (value) {
194
+ if (value !== undefined && (typeof value !== "number" || !isFinite(value) ||
195
+ value < 0 || value > MAX_BOOT_DEATH_WINDOW_MS)) {
196
+ throw new DaemonError("daemon/bad-boot-window",
197
+ "daemon.start: opts.bootDeathWindowMs must be a finite number of " +
198
+ "milliseconds in [0, " + MAX_BOOT_DEATH_WINDOW_MS + "] when present " +
199
+ "(a larger delay clamps setTimeout to ~1ms and defeats the boot window)");
200
+ }
201
+ },
121
202
  }, "daemon.start", DaemonError, "daemon/bad-opts");
122
203
  }
123
204
 
@@ -166,6 +247,7 @@ function _maybeReapStale(pidFile) {
166
247
  // Used both by detached-spawn (passed via stdio) and by foreground
167
248
  // redirect of the current process' stdout/stderr.
168
249
  function _openLogFd(logFile) {
250
+ /* c8 ignore next -- every caller gates on a truthy logFile string, so this guard never returns null */
169
251
  if (typeof logFile !== "string" || logFile.length === 0) return null;
170
252
  atomicFile.ensureDir(nodePath.dirname(logFile));
171
253
  // O_NOFOLLOW append: refuse (ELOOP) a symlink planted at the daemon log
@@ -182,6 +264,7 @@ function _openLogFd(logFile) {
182
264
  // pattern for foreground daemons that don't want to lose output when
183
265
  // detached from a terminal.
184
266
  function _redirectStdio(fd) {
267
+ /* c8 ignore next -- only ever called with the numeric fd from _openLogFd; the non-number guard is unreachable */
185
268
  if (typeof fd !== "number") return;
186
269
  function _writer(chunk, encOrCb, maybeCb) {
187
270
  var enc = typeof encOrCb === "string" ? encOrCb : "utf8";
@@ -247,6 +330,7 @@ function _installStopSentinelWatcher(pidFile, orchestrator) {
247
330
  timer = null;
248
331
  }
249
332
  function _maybeFire() {
333
+ /* c8 ignore next -- _stopPolling clears the interval on the first fire, so _maybeFire can't re-enter with fired=true */
250
334
  if (fired) return;
251
335
  // Synchronous existsSync on the main thread — deliberately not an async
252
336
  // stat, so detection never queues behind a saturated libuv threadpool.
@@ -310,6 +394,7 @@ function _installStopSentinelWatcher(pidFile, orchestrator) {
310
394
  * command: string, // executable for detached-fork mode
311
395
  * args: string[], // argv for the detached child
312
396
  * cwd: string, // cwd for the detached child
397
+ * bootDeathWindowMs: number, // detached: keep the parent loop alive this long after spawn to observe a boot death (an abnormal exit in the window is audited as a spawn failure + reaps the pidfile); default 5000, 0 opts out (fire-and-forget)
313
398
  *
314
399
  * @example
315
400
  * var handle = b.daemon.start({
@@ -378,6 +463,12 @@ function start(opts) {
378
463
  throw new DaemonError("daemon/spawn-failed",
379
464
  "daemon.start: spawn failed: " + ((e && e.message) || String(e)));
380
465
  }
466
+ // Boot-death window is measured from the moment the child was spawned. An
467
+ // abnormal exit within it is a boot failure; a later one is a normal
468
+ // run/crash. Operators tune it for a slow-booting child (default 5s).
469
+ var spawnedAt = Date.now();
470
+ var bootWindowMs = (typeof opts.bootDeathWindowMs === "number")
471
+ ? opts.bootDeathWindowMs : BOOT_DEATH_WINDOW_MS;
381
472
  // A bad command does NOT throw synchronously from spawn — child_process
382
473
  // reports it ASYNC via a 'error' event, with child.pid left undefined. The
383
474
  // sync try/catch above only covers spawn() itself, so without this the old
@@ -385,7 +476,18 @@ function start(opts) {
385
476
  // one-shot 'error' handler that reaps the sidecar + audits the failure, then
386
477
  // refuse to proceed for a child that never got a pid.
387
478
  child.on("error", function (err) {
388
- try { nodeFs.unlinkSync(pidFile); } catch (_e) { /* best-effort may not exist */ }
479
+ // Reap ONLY a pidfile this child actually wrote. A numeric pid means the
480
+ // sync path below wrote one, and the claim-then-verify reap removes only its
481
+ // own sidecar (never a fast restart's). A no-pid spawn failure (child.pid
482
+ // undefined) throws below BEFORE any pidfile write, so this late-firing
483
+ // callback must not touch pidFile at all — else a caller that catches that
484
+ // sync throw and retries with the same pidFile would have its replacement
485
+ // daemon's pidfile deleted. (A numeric-but-invalid pid never reaches here:
486
+ // child_process yields a positive int or undefined, and either way the
487
+ // claim-then-verify reap only removes a sidecar recording that exact pid.)
488
+ if (typeof child.pid === "number") {
489
+ _reapOwnStalePidfile(pidFile, child.pid);
490
+ }
389
491
  _safeAuditEmit("daemon.spawn_failed", "failure", {
390
492
  pidFile: pidFile,
391
493
  command: opts.command,
@@ -408,9 +510,76 @@ function start(opts) {
408
510
  // Write the child's PID via atomic temp+rename so a concurrent
409
511
  // observer never sees a half-written pidFile.
410
512
  atomicFile.ensureDir(nodePath.dirname(pidFile));
513
+ // Clear any STALE stop marker before claiming this pidfile: a stopper that was
514
+ // SIGKILLed mid-stop leaves `<pidFile>.stopping` behind, and if the OS later
515
+ // reuses that stopped pid for THIS fresh child, the stale marker would
516
+ // wrongly suppress a genuine boot-death audit. A fresh start means no stop is
517
+ // in flight, so the marker can only be stale.
518
+ try { nodeFs.unlinkSync(_stoppingMarkerPath(pidFile)); } catch (_sm) { /* best-effort — usually absent */ }
411
519
  var pidStr = String(child.pid) + "\n";
412
520
  atomicFile.writeSync(pidFile, pidStr, { fileMode: 0o600 });
413
- // Detach so the child survives parent exit.
521
+ // A detached child can spawn cleanly (valid pid above) yet DIE AT BOOT —
522
+ // exit before it ever serves. The synchronous success handle is already
523
+ // committed (detached mode returns immediately, so the sync return contract
524
+ // stands), so recover asynchronously: a one-shot 'exit' handler reaps the
525
+ // sidecar we just wrote — but ONLY when the pidFile still records THIS
526
+ // child's pid, so a fast operator restart that rewrote it is never
527
+ // clobbered — and audits the boot death so it leaves a trail instead of a
528
+ // silently-stranded pidfile that daemon.stop would misread as running.
529
+ // bootWatch (installed after this handler) holds the parent loop open through
530
+ // the boot window so a short-lived launcher can't exit before observing the
531
+ // death; the handler clears it the instant the child exits.
532
+ var bootWatch = null;
533
+ child.on("exit", function (code, signal) {
534
+ if (bootWatch) { clearTimeout(bootWatch); bootWatch = null; } // death observed — release the loop
535
+ // Was the sidecar still OURS at exit? Atomically claim-then-verify (see
536
+ // _reapOwnStalePidfile) so a fast operator restart that rewrote the pidfile
537
+ // can't make us delete the NEW daemon's sidecar, and so the boot-death
538
+ // signal survives a swallowed unlink failure. A stop() or a restart that
539
+ // rewrote/cleared it means someone else owns it now — not a boot death.
540
+ var wasOurs = _reapOwnStalePidfile(pidFile, child.pid);
541
+ // Only a BOOT DEATH is a spawn failure: an ABNORMAL exit (non-zero code or
542
+ // a terminating signal) SHORTLY after spawn, while the sidecar was still
543
+ // ours (nobody stop()'d it). A clean exit (code 0), a later run/crash, or
544
+ // an operator stop() is NOT a spawn failure — auditing those as one emits a
545
+ // contradictory failure alongside the daemon.stopped record.
546
+ var abnormal = (typeof code === "number" && code !== 0) || signal != null;
547
+ var withinBoot = (Date.now() - spawnedAt) <= bootWindowMs;
548
+ // stop() (this process OR another) sends SIGTERM but unlinks the pidfile
549
+ // only after observing the exit, so wasOurs is still true here. A
550
+ // `<pidFile>.stopping` marker holding THIS child's pid means an operator
551
+ // stop is in flight, so the exit is intentional — not a boot death that
552
+ // should emit spawn_failed right before daemon.stopped.
553
+ // Read the marker through the HARDENED pid-sidecar reader (1 KiB cap,
554
+ // refuse-symlink, positive-int parse, null on any failure) — the marker
555
+ // lives in the pidfile directory, so a raw readFileSync here would follow a
556
+ // planted symlink or buffer an unbounded file (CWE-59 / DoS), the exact
557
+ // threat _readPidFile hardens the pid read against. null (no/garbage marker)
558
+ // !== child.pid, so a missing marker correctly reads as "not being stopped".
559
+ var beingStopped = _readPidFile(_stoppingMarkerPath(pidFile)) === child.pid;
560
+ if (wasOurs && abnormal && withinBoot && !beingStopped) {
561
+ _safeAuditEmit("daemon.spawn_failed", "failure", {
562
+ pidFile: pidFile,
563
+ command: opts.command,
564
+ exitCode: code,
565
+ signal: signal || null,
566
+ });
567
+ }
568
+ });
569
+ // Keep the parent event loop alive through the boot-death window so the exit
570
+ // handler above can actually observe a child that dies at boot. A short-lived
571
+ // launcher (e.g. `blamejs daemon start`) would otherwise reach child.unref()
572
+ // and exit before the child dies — stranding the pidfile for a later stop()
573
+ // to misread, the very failure the handler exists to prevent. The timer is
574
+ // ref'd (holds the loop), is cleared the instant the child exits, and
575
+ // otherwise fires a no-op once the window elapses (boot succeeded → release
576
+ // the loop, which child.unref() no longer holds). bootDeathWindowMs:0 opts
577
+ // out entirely: no monitor, immediate exit (historical fire-and-forget).
578
+ if (bootWindowMs > 0) {
579
+ bootWatch = setTimeout(function () { bootWatch = null; }, bootWindowMs);
580
+ }
581
+ // Detach so a HEALTHY long-running child never holds the parent open past the
582
+ // boot window (bootWatch is the only remaining ref, and it self-clears).
414
583
  try { child.unref(); } catch (_u) { /* best-effort */ }
415
584
  if (typeof logFd === "number") {
416
585
  // Parent doesn't need its handle to the log; child inherited it.
@@ -446,6 +615,7 @@ function start(opts) {
446
615
  logFdForeground = _openLogFd(logFile);
447
616
  _redirectStdio(logFdForeground);
448
617
  } catch (e) {
618
+ /* c8 ignore next -- pidLock.release() swallows its own fs errors, so this guard never catches */
449
619
  try { lock.release(); } catch (_r) { /* best-effort */ }
450
620
  throw new DaemonError("daemon/log-open-failed",
451
621
  "daemon.start: failed to open logFile '" + logFile + "': " +
@@ -469,7 +639,9 @@ function start(opts) {
469
639
  {
470
640
  name: "pidLock-release",
471
641
  run: function () {
642
+ /* c8 ignore next -- close() delegates to _stopPolling, which self-catches, so stopWatcher.close never throws */
472
643
  if (stopWatcher) { try { stopWatcher.close(); } catch (_w) { /* best-effort */ } }
644
+ /* c8 ignore next -- pidLock.release() swallows its own fs errors, so this guard never catches */
473
645
  try { lock.release(); } catch (_e) { /* best-effort */ }
474
646
  if (logFdForeground !== null) {
475
647
  try { nodeFs.closeSync(logFdForeground); } catch (_c) { /* best-effort */ }
@@ -555,6 +727,23 @@ async function stop(opts) {
555
727
  return { stopped: false, pid: pid, reason: "stale" };
556
728
  }
557
729
 
730
+ // Publish a `<pidFile>.stopping` marker (holding the pid we are stopping) so the
731
+ // boot-death exit handler — in THIS process or the still-alive starter of a
732
+ // cross-process stop — treats the SIGTERM-induced exit as intentional, not a
733
+ // boot death. The finally removes it on every exit (return OR a kill-failed
734
+ // throw) so a later start() at the same path is never suppressed.
735
+ var stopMarker = _stoppingMarkerPath(pidFile);
736
+ try { atomicFile.writeSync(stopMarker, String(pid), { fileMode: 0o600 }); } catch (_w) { /* best-effort hint */ }
737
+ try {
738
+ return await _stopLivePid(pidFile, pid, signal, timeoutMs, pollMs, opts);
739
+ } finally {
740
+ try { nodeFs.unlinkSync(stopMarker); } catch (_u) { /* best-effort */ }
741
+ }
742
+ }
743
+
744
+ // Signal a confirmed-live pid and wait for exit, escalating SIGTERM -> SIGKILL.
745
+ // Extracted from stop() so the .stopping marker wraps every exit via try/finally.
746
+ async function _stopLivePid(pidFile, pid, signal, timeoutMs, pollMs, opts) {
558
747
  var t0 = Date.now();
559
748
 
560
749
  // Windows has no cooperative signal: process.kill(pid, "SIGTERM") maps to
@@ -733,4 +922,5 @@ module.exports = {
733
922
  DEFAULT_STOP_SIGNAL: DEFAULT_STOP_SIGNAL,
734
923
  DEFAULT_STOP_TIMEOUT_MS: DEFAULT_STOP_TIMEOUT_MS,
735
924
  _resetForTest: _resetForTest,
925
+ _reapOwnStalePidfile: _reapOwnStalePidfile,
736
926
  };
@@ -27,6 +27,7 @@
27
27
  var { defineClass } = require("./framework-error");
28
28
  var gateContract = require("./gate-contract");
29
29
  var codepointClass = require("./codepoint-class");
30
+ var pick = require("./pick");
30
31
 
31
32
  var GuardTenantIdError = defineClass("GuardTenantIdError", { alwaysPermanent: true });
32
33
 
@@ -78,10 +79,20 @@ function validate(tenantId, opts) {
78
79
  throw new GuardTenantIdError("tenant-id/oversize",
79
80
  "guardTenantId.validate: tenantId exceeds maxBytes=" + profile.maxBytes);
80
81
  }
81
- if (RESERVED[tenantId]) {
82
+ if (Object.prototype.hasOwnProperty.call(RESERVED, tenantId)) {
82
83
  throw new GuardTenantIdError("tenant-id/reserved",
83
84
  "guardTenantId.validate: tenantId '" + tenantId + "' is framework-reserved");
84
85
  }
86
+ // Refuse the prototype-pollution key names outright via the framework's single
87
+ // poisoned-key predicate (lib/pick.js). The own-property RESERVED check above
88
+ // (deliberately) will not match these as inherited keys, and a tenant id used to
89
+ // key a plain-object store must never be __proto__ / constructor / prototype (or
90
+ // an operator-registered dangerous name), which would pollute the prototype
91
+ // chain instead of isolating the tenant.
92
+ if (pick.isPoisonedKey(tenantId)) {
93
+ throw new GuardTenantIdError("tenant-id/reserved",
94
+ "guardTenantId.validate: tenantId '" + tenantId + "' is a prototype-pollution key name");
95
+ }
85
96
  if (tenantId.charAt(0) === ".") {
86
97
  throw new GuardTenantIdError("tenant-id/hidden",
87
98
  "guardTenantId.validate: tenantId cannot start with '.'");
package/lib/mtls-ca.js CHANGED
@@ -14,9 +14,9 @@
14
14
  * tagging, and atomic commit. Cert issuance (CA generation, client
15
15
  * cert signing, PKCS#12 packaging) delegates to a pluggable engine
16
16
  * so the operator chooses the X.509 toolchain. The default pure-JS
17
- * engine lives in `lib/mtls-engine-default.js` (backed by the
18
- * vendored @peculiar/x509 + pkijs bundle); operators with custom
19
- * requirements pass their own via `opts.engine`.
17
+ * engine lives in `lib/mtls-engine-default.js` (backed by the vendored
18
+ * zero-dep @blamejs/pki toolkit); operators with custom requirements
19
+ * pass their own via `opts.engine`.
20
20
  *
21
21
  * Files relative to `dataDir`: `ca.crt` (PEM cert, plaintext),
22
22
  * `ca.key` (PEM key, plaintext — refused under `caKeySealedMode:
@@ -69,10 +69,10 @@ var safeJson = require("./safe-json");
69
69
  var validateOpts = require("./validate-opts");
70
70
  var { FrameworkError } = require("./framework-error");
71
71
 
72
- // The default engine carries a 600+ KB vendored bundle (peculiar/x509 +
73
- // pkijs + reflect-metadata). Lazy-require it so operators wiring a
74
- // custom engine never pay the cost. The lazyRequire wrapper keeps the
75
- // require at top-of-file declaration shape — no indented inline calls.
72
+ // The default engine carries a vendored X.509 toolkit (@blamejs/pki).
73
+ // Lazy-require it so operators wiring a custom engine never pay the cost.
74
+ // The lazyRequire wrapper keeps the require at top-of-file declaration
75
+ // shape — no indented inline calls.
76
76
  var mtlsEngineDefault = lazyRequire(function () { return require("./mtls-engine-default"); });
77
77
 
78
78
  var caLog = boot("mtls-ca");
@@ -148,6 +148,7 @@ function parseGeneration(certPem) {
148
148
  if (typeof certPem !== "string" && !Buffer.isBuffer(certPem)) return 0;
149
149
  try {
150
150
  var cert = new nodeCrypto.X509Certificate(certPem);
151
+ /* c8 ignore next -- defensive: a successfully-parsed X.509 certificate always exposes a subject DN */
151
152
  var subj = cert.subject || "";
152
153
  var m = /OU=CAv(\d+)/.exec(subj);
153
154
  return m ? parseInt(m[1], 10) : 1;
@@ -180,6 +181,7 @@ function parseGeneration(certPem) {
180
181
  * caKeySealedMode: string, // "required" (default) | "disabled"
181
182
  * generation: number, // current CA generation for OU=CAv{N}
182
183
  * engine: object, // pluggable X.509 engine; default lib/mtls-engine-default
184
+ * 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
183
185
  *
184
186
  * @example
185
187
  * var fs = require("fs");
@@ -194,11 +196,43 @@ function parseGeneration(certPem) {
194
196
  * typeof ca.initCA;
195
197
  * // → "function"
196
198
  */
199
+ // Map an algorithm-pin label to the node KeyObject.asymmetricKeyType a stored CA
200
+ // key of that algorithm reports, so a pin can be checked against an on-disk CA.
201
+ // Returns null for a label this file can't map (a custom engine's own naming) —
202
+ // the check is then skipped and the engine owns the semantics.
203
+ // The OpenSSL curve name node reports for the framework's sole classical pin
204
+ // (ECDSA-P384-SHA384). A stored EC CA must report this curve to satisfy that pin.
205
+ var CLASSICAL_CA_CURVE = "secp384r1";
206
+
207
+ function _expectedKeyTypeForPin(label) {
208
+ var l = String(label).toLowerCase();
209
+ if (l.indexOf("ecdsa") !== -1) return "ec";
210
+ var m = l.match(/ml-dsa-(\d+)/);
211
+ return m ? ("ml-dsa-" + m[1]) : null;
212
+ }
213
+
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 algorithm — a 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).
221
+ function _labelForCaKeyType(caKeyPem) {
222
+ var type;
223
+ /* c8 ignore next -- the "" fallback is defensive: a parsed KeyObject always reports a non-empty asymmetricKeyType, so it is never reached */
224
+ try { type = String(nodeCrypto.createPrivateKey(caKeyPem).asymmetricKeyType || "").toLowerCase(); }
225
+ 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;
229
+ }
230
+
197
231
  function create(opts) {
198
232
  opts = opts || {};
199
233
  validateOpts(opts, [
200
234
  "dataDir", "paths", "vault",
201
- "caKeySealedMode", "generation", "engine", "revocationStore",
235
+ "caKeySealedMode", "generation", "engine", "revocationStore", "algorithm",
202
236
  ], "b.mtlsCa");
203
237
  validateOpts.requireNonEmptyString(opts.dataDir, "mtlsCa.create: opts.dataDir", MtlsCaError, "mtls-ca/no-datadir");
204
238
  // Auto-create the dataDir with restrictive perms (CA keys live here).
@@ -221,9 +255,29 @@ function create(opts) {
221
255
  var generation = typeof opts.generation === "number" && opts.generation >= 1
222
256
  ? Math.floor(opts.generation) : 1;
223
257
  // The default engine is lazy-loaded at top-of-file; resolve it only
224
- // when no custom engine was passed.
258
+ // when no custom engine was passed. Whether the bundled engine is in use
259
+ // gates the CA-following algorithm inference below: _labelForCaKeyType maps a
260
+ // key to the BUNDLED engine's label set, which is meaningless (or wrong) for a
261
+ // custom engine's own labels / key curves. A falsy engine (null / undefined)
262
+ // selects the bundled engine, so the flag must match the `opts.engine || ...`
263
+ // fallback exactly — an explicit engine: null is the bundled engine, not custom.
264
+ var usesDefaultEngine = !opts.engine;
225
265
  var engine = opts.engine || mtlsEngineDefault();
226
266
 
267
+ // Optional algorithm pin. When set, it is threaded into BOTH CA generation
268
+ // (initCA) and every leaf/PKCS#12 issuance so the whole chain shares one
269
+ // algorithm — the operator opt-in for a classical (ECDSA-P384-SHA384) CA when
270
+ // a peer predates the OpenSSL 3.5 that verifies the ML-DSA-87 default. The
271
+ // label set is the engine's to validate (a custom engine may define its own),
272
+ // so this is a config-time type guard only; an unknown label surfaces from the
273
+ // engine at issuance.
274
+ var caAlgorithm = opts.algorithm;
275
+ if (caAlgorithm !== undefined && (typeof caAlgorithm !== "string" || caAlgorithm.length === 0)) {
276
+ throw new MtlsCaError("mtls-ca/bad-algorithm",
277
+ "opts.algorithm must be a non-empty string label " +
278
+ "(e.g. \"ECDSA-P384-SHA384\") when set");
279
+ }
280
+
227
281
  function _requireVault(reason) {
228
282
  if (!vault || typeof vault.seal !== "function" || typeof vault.unseal !== "function") {
229
283
  throw new MtlsCaError("mtls-ca/no-vault",
@@ -332,8 +386,10 @@ function create(opts) {
332
386
  while (w < buf.length) {
333
387
  w += nodeFs.writeSync(fd, buf, w, buf.length - w, null);
334
388
  }
389
+ /* c8 ignore next -- best-effort: fsync on a freshly-opened, still-valid fd does not throw here */
335
390
  try { nodeFs.fsyncSync(fd); } catch (_fe) { /* fsync best-effort */ }
336
391
  } finally {
392
+ /* c8 ignore next -- best-effort: closeSync on the just-written fd does not throw here */
337
393
  try { nodeFs.closeSync(fd); } catch (_ce) { /* close best-effort */ }
338
394
  }
339
395
  }
@@ -353,8 +409,10 @@ function create(opts) {
353
409
  // so a genuinely-broken filesystem state surfaces in operator logs
354
410
  // rather than getting silently swallowed.
355
411
  try { if (nodeFs.existsSync(keyTmp)) nodeFs.unlinkSync(keyTmp); }
412
+ /* c8 ignore next -- best-effort cleanup: unlink of a tmp file we just created does not throw here */
356
413
  catch (cleanupErr) { caLog.debug("cleanup-failed", { op: "fs.unlinkSync", path: keyTmp, error: cleanupErr.message }); }
357
414
  try { if (nodeFs.existsSync(certTmp)) nodeFs.unlinkSync(certTmp); }
415
+ /* c8 ignore next -- best-effort cleanup: unlink of a tmp file we just created does not throw here */
358
416
  catch (cleanupErr) { caLog.debug("cleanup-failed", { op: "fs.unlinkSync", path: certTmp, error: cleanupErr.message }); }
359
417
  throw new MtlsCaError("mtls-ca/commit-failed",
360
418
  "atomic CA commit failed: " + ((e && e.message) || String(e)));
@@ -368,9 +426,54 @@ function create(opts) {
368
426
 
369
427
  async function initCA() {
370
428
  if (exists()) {
371
- return { caCertPem: loadCert().toString("utf8"), caKeyPem: loadKey().toString("utf8") };
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.");
455
+ }
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.");
466
+ }
467
+ }
468
+ return { caCertPem: existingCertPem, caKeyPem: existingKeyPem };
372
469
  }
373
- var fresh = await engine.generateCa({ generation: generation });
470
+ // Build the args conditionally so an `algorithm` key is present ONLY when the
471
+ // operator pinned one — a strict custom engine that validates its generateCa
472
+ // option shape would reject an own `algorithm: undefined` key on an unpinned
473
+ // first-time init (matching the conditional custom leaf-engine handling).
474
+ var caGenArgs = { generation: generation };
475
+ if (caAlgorithm !== undefined) caGenArgs.algorithm = caAlgorithm;
476
+ var fresh = await engine.generateCa(caGenArgs);
374
477
  if (!fresh || typeof fresh.caCertPem !== "string" || typeof fresh.caKeyPem !== "string") {
375
478
  throw new MtlsCaError("mtls-ca/bad-engine-output",
376
479
  "engine.generateCa must return { caCertPem, caKeyPem }");
@@ -402,10 +505,51 @@ function create(opts) {
402
505
  };
403
506
  }
404
507
 
508
+ // Build the engine call args for a leaf/PKCS#12 issuance. The leaf follows the
509
+ // CA's algorithm: the pin when set (initCA already verified it matches the
510
+ // stored CA), otherwise — for the BUNDLED engine only — the stored CA's own
511
+ // algorithm, so an unpinned upgrade over an existing classical CA keeps issuing
512
+ // classical leaves instead of the engine's ML-DSA process default. A custom
513
+ // engine gets no inferred algorithm (its label set / key curve is its own to
514
+ // resolve; the bundled ECDSA-P384-SHA384 label would break it). An explicit
515
+ // opts2.algorithm always wins.
516
+ 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
+ }
527
+ var args = Object.assign({}, opts2, { caCertPem: ca.caCertPem, caKeyPem: ca.caKeyPem });
528
+ if (leafAlg !== undefined) {
529
+ // The resolved CA algorithm (a pin verified against the stored CA, or the
530
+ // bundled engine's stored-CA inference) is AUTHORITATIVE and wins over a
531
+ // per-issuance opts.algorithm: silently honoring a conflicting one would let
532
+ // a classical ECDSA CA issue an ML-DSA leaf its legacy peers can't
533
+ // authenticate (and mis-select the P12 MAC tier). Refuse a conflict outright
534
+ // rather than issue a leaf that doesn't match the CA the operator pinned.
535
+ if (opts2.algorithm !== undefined && opts2.algorithm !== leafAlg) {
536
+ throw new MtlsCaError("mtls-ca/algorithm-conflict",
537
+ "generateClientCert/generateClientP12: opts.algorithm " + JSON.stringify(opts2.algorithm) +
538
+ " conflicts with the CA's algorithm " + JSON.stringify(leafAlg) +
539
+ " (the leaf must match the CA; rotate to a fresh CA to change algorithms)");
540
+ }
541
+ args.algorithm = leafAlg;
542
+ }
543
+ // When leafAlg is undefined (a custom engine), opts2.algorithm passes through
544
+ // for the engine to resolve; caCertPem/caKeyPem are forced last so opts2 can't
545
+ // shadow them.
546
+ return args;
547
+ }
548
+
405
549
  async function generateClientCert(opts2) {
406
550
  opts2 = opts2 || {};
407
551
  var ca = await initCA();
408
- var args = Object.assign({}, opts2, { caCertPem: ca.caCertPem, caKeyPem: ca.caKeyPem });
552
+ var args = _leafEngineArgs(ca, opts2);
409
553
  var result = await engine.signClientCert(args);
410
554
  if (!result || typeof result.cert !== "string" || typeof result.key !== "string") {
411
555
  throw new MtlsCaError("mtls-ca/bad-engine-output",
@@ -424,7 +568,10 @@ function create(opts) {
424
568
  "generateClientP12 requires opts.password (the PKCS#12 encryption password)");
425
569
  }
426
570
  var ca = await initCA();
427
- var args = Object.assign({}, opts2, { caCertPem: ca.caCertPem, caKeyPem: ca.caKeyPem });
571
+ // Leaf (and its P12 MAC tier) follows the CA's algorithm via the shared
572
+ // arg-builder — the pin when set, else the bundled engine's stored-CA
573
+ // inference, never a custom engine's inferred label or the process default.
574
+ var args = _leafEngineArgs(ca, opts2);
428
575
  var result = await engine.packageP12(args);
429
576
  if (!result || !Buffer.isBuffer(result.p12)) {
430
577
  throw new MtlsCaError("mtls-ca/bad-engine-output",
@@ -456,6 +603,7 @@ function create(opts) {
456
603
  { maxBytes: C.BYTES.mib(16) });
457
604
  return (json && Array.isArray(json.revocations)) ? json.revocations : [];
458
605
  } catch (e) {
606
+ /* c8 ignore next 2 -- defensive: safeJson.parse throws an Error with a message, so the String(e) fallback is unreachable */
459
607
  throw new MtlsCaError("mtls-ca/revocation-corrupt",
460
608
  "could not parse " + paths.revocations + ": " + ((e && e.message) || String(e)));
461
609
  }
@@ -512,10 +660,12 @@ function create(opts) {
512
660
  return stripped.toLowerCase();
513
661
  }
514
662
 
515
- // Map operator-friendly reason codes to RFC 5280 numeric codes used
516
- // by X.509 CRLs. Default "unspecified" (0) when omitted. removeFromCRL
517
- // uses hex 0x08 to express RFC 5280's reason code 8 the literal is a
518
- // protocol identifier, not a byte quantity.
663
+ // Map operator-friendly reason codes to RFC 5280 numeric codes used by X.509
664
+ // CRLs. Default "unspecified" (0) when omitted. removeFromCRL (code 8) is
665
+ // deliberately absent: it is a DELTA-CRL directive to UN-revoke a cert from the
666
+ // base CRL, not a revocation reason, and is invalid in a full CRL (all this CA
667
+ // issues) — the toolkit refuses it at sign time, so a persisted code-8 entry
668
+ // would poison every later generateCrl(). revoke() rejects it explicitly below.
519
669
  var CRL_REASON_BY_NAME = {
520
670
  "unspecified": 0,
521
671
  "keyCompromise": 1,
@@ -527,7 +677,6 @@ function create(opts) {
527
677
  "cessationOfOperation": 5,
528
678
  "cessation-of-operation": 5,
529
679
  "certificateHold": 6,
530
- "removeFromCRL": 0x08,
531
680
  "privilegeWithdrawn": 9,
532
681
  "aACompromise": 10,
533
682
  };
@@ -552,6 +701,12 @@ function create(opts) {
552
701
  "revoke requires a serial number or a fingerprint " +
553
702
  "(revoke(serial, opts) or revoke({ serial, fingerprint }))");
554
703
  }
704
+ if (reasonName === "removeFromCRL") {
705
+ throw new MtlsCaError("mtls-ca/bad-reason",
706
+ "revoke: 'removeFromCRL' (RFC 5280 code 8) is a delta-CRL un-revocation " +
707
+ "directive, not a revocation reason — this CA issues full CRLs only, and a " +
708
+ "persisted code-8 entry would make every generateCrl() fail");
709
+ }
555
710
  var reasonCode = CRL_REASON_BY_NAME[reasonName];
556
711
  if (reasonCode === undefined) {
557
712
  throw new MtlsCaError("mtls-ca/bad-reason",