@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/CHANGELOG.md +6 -0
- package/NOTICE +12 -13
- package/README.md +3 -3
- package/lib/audit.js +69 -1
- package/lib/auth/oauth.js +57 -0
- package/lib/cli.js +15 -5
- package/lib/daemon.js +192 -2
- package/lib/guard-tenant-id.js +12 -1
- package/lib/mtls-ca.js +173 -18
- package/lib/mtls-engine-default.js +339 -314
- package/lib/outbox.js +43 -13
- package/lib/redact.js +39 -6
- package/lib/safe-json.js +9 -1
- package/lib/self-update.js +215 -24
- package/lib/vendor/MANIFEST.json +27 -30
- package/lib/vendor/blamejs-pki.cjs +28419 -0
- package/lib/webhook-dispatcher.js +55 -24
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
- package/lib/vendor/pki.cjs +0 -39718
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
|
-
|
|
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
|
-
//
|
|
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
|
};
|
package/lib/guard-tenant-id.js
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
19
|
-
*
|
|
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
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
//
|
|
517
|
-
//
|
|
518
|
-
//
|
|
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",
|