@phnx-labs/agents-cli 1.20.87 → 1.20.89
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 +323 -0
- package/README.md +12 -4
- package/dist/bin/agents +0 -0
- package/dist/commands/commands.js +7 -7
- package/dist/commands/doctor.d.ts +0 -19
- package/dist/commands/doctor.js +219 -305
- package/dist/commands/exec.js +7 -19
- package/dist/commands/factory.js +26 -2
- package/dist/commands/funnel.js +16 -1
- package/dist/commands/inspect.js +3 -5
- package/dist/commands/menubar.js +117 -34
- package/dist/commands/routines.js +25 -3
- package/dist/commands/secrets-rotate-passphrase.d.ts +17 -0
- package/dist/commands/secrets-rotate-passphrase.js +96 -0
- package/dist/commands/secrets.js +2 -0
- package/dist/commands/sessions.d.ts +7 -1
- package/dist/commands/sessions.js +40 -12
- package/dist/commands/ssh.js +3 -3
- package/dist/commands/usage.d.ts +3 -2
- package/dist/commands/usage.js +2 -9
- package/dist/commands/webhook.js +7 -2
- package/dist/lib/agents.d.ts +31 -1
- package/dist/lib/agents.js +55 -0
- package/dist/lib/command-skills.d.ts +10 -0
- package/dist/lib/command-skills.js +14 -0
- package/dist/lib/commands.js +28 -2
- package/dist/lib/daemon.d.ts +29 -0
- package/dist/lib/daemon.js +75 -6
- package/dist/lib/devices/doctor-findings.d.ts +167 -0
- package/dist/lib/devices/doctor-findings.js +893 -0
- package/dist/lib/devices/fleet-divergence.d.ts +22 -0
- package/dist/lib/devices/fleet-divergence.js +34 -10
- package/dist/lib/devices/fleet-inventory.d.ts +17 -6
- package/dist/lib/devices/fleet-inventory.js +56 -8
- package/dist/lib/events.d.ts +1 -1
- package/dist/lib/exec.d.ts +14 -3
- package/dist/lib/exec.js +41 -8
- package/dist/lib/factory/snapshot.d.ts +78 -0
- package/dist/lib/factory/snapshot.js +209 -0
- package/dist/lib/fs-atomic.d.ts +14 -1
- package/dist/lib/fs-atomic.js +35 -3
- package/dist/lib/funnel.d.ts +1 -0
- package/dist/lib/funnel.js +8 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +2 -2
- package/dist/lib/menubar/install-menubar.d.ts +53 -2
- package/dist/lib/menubar/install-menubar.js +183 -28
- package/dist/lib/platform/process.d.ts +2 -0
- package/dist/lib/platform/process.js +5 -3
- package/dist/lib/project-resources.js +34 -20
- package/dist/lib/resources.d.ts +8 -0
- package/dist/lib/resources.js +34 -1
- package/dist/lib/routines-placement.d.ts +2 -1
- package/dist/lib/routines-placement.js +8 -4
- package/dist/lib/routines.d.ts +57 -1
- package/dist/lib/routines.js +74 -1
- package/dist/lib/runner.d.ts +16 -1
- package/dist/lib/runner.js +58 -16
- package/dist/lib/sandbox.d.ts +2 -0
- package/dist/lib/sandbox.js +38 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/bundles.js +9 -34
- package/dist/lib/secrets/filestore.d.ts +152 -34
- package/dist/lib/secrets/filestore.js +676 -123
- package/dist/lib/secrets/rc-hygiene.d.ts +0 -6
- package/dist/lib/secrets/rc-hygiene.js +0 -24
- package/dist/lib/session/active.d.ts +6 -6
- package/dist/lib/session/active.js +6 -6
- package/dist/lib/session/discover.d.ts +5 -0
- package/dist/lib/session/discover.js +137 -1
- package/dist/lib/session/parse.d.ts +2 -0
- package/dist/lib/session/parse.js +76 -37
- package/dist/lib/session/remote-active.d.ts +4 -1
- package/dist/lib/session/remote-active.js +8 -2
- package/dist/lib/session/sync/agents.js +0 -0
- package/dist/lib/session/types.d.ts +1 -1
- package/dist/lib/session/types.js +1 -1
- package/dist/lib/session/viewing-in.d.ts +31 -0
- package/dist/lib/session/viewing-in.js +47 -0
- package/dist/lib/staleness/detectors/commands.js +14 -5
- package/dist/lib/staleness/types.d.ts +2 -0
- package/dist/lib/staleness/writers/commands.js +13 -7
- package/dist/lib/state.d.ts +17 -0
- package/dist/lib/state.js +30 -2
- package/dist/lib/triggers/handlers.d.ts +95 -0
- package/dist/lib/triggers/handlers.js +384 -0
- package/dist/lib/triggers/webhook.d.ts +10 -2
- package/dist/lib/triggers/webhook.js +65 -11
- package/dist/lib/usage.d.ts +72 -1
- package/dist/lib/usage.js +21 -27
- package/dist/lib/versions.js +30 -13
- package/package.json +1 -1
|
@@ -3,29 +3,29 @@
|
|
|
3
3
|
*
|
|
4
4
|
* An AES-256-GCM encrypted-file store under `~/.agents/.cache/secrets/`. The
|
|
5
5
|
* encryption key is scrypt-derived from a passphrase read from
|
|
6
|
-
* `AGENTS_SECRETS_PASSPHRASE` (preferred)
|
|
7
|
-
*
|
|
6
|
+
* `AGENTS_SECRETS_PASSPHRASE` (preferred) or a machine-local key the store
|
|
7
|
+
* auto-provisions on first use. One `<item>.enc` JSON file per item, mode 0600.
|
|
8
8
|
*
|
|
9
|
-
* Two callers:
|
|
9
|
+
* Two callers, one policy: the store silently auto-provisions a stable
|
|
10
|
+
* machine-local key (a 0600 file under `~/.agents/.secrets-key/`) on EVERY
|
|
11
|
+
* platform, so it works out of the box with no passphrase to set or remember and
|
|
12
|
+
* never pops a prompt or Touch ID sheet.
|
|
10
13
|
* - Linux (src/lib/secrets/linux.ts): the headless fallback when the default
|
|
11
|
-
* Secret Service collection is locked.
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* AGENTS_SECRETS_PASSPHRASE (or TTY) — never the silent machine-local
|
|
17
|
-
* auto-provision — so a remote box holds ciphertext only.
|
|
14
|
+
* Secret Service collection is locked.
|
|
15
|
+
* - macOS/Windows file-backed bundles (src/lib/secrets/bundles.ts): an explicit,
|
|
16
|
+
* opt-in non-biometry backend for headless/remote runs.
|
|
17
|
+
* Set AGENTS_SECRETS_PASSPHRASE to opt into a key held off disk instead (e.g. to
|
|
18
|
+
* share one bundle's ciphertext across boxes under a common key).
|
|
18
19
|
*
|
|
19
20
|
* The item-name scheme is shared with the keychain backend so a file-backed
|
|
20
21
|
* item and its keychain twin carry identical names:
|
|
21
22
|
* `agents-cli.bundles.<name>` and `agents-cli.secrets.<bundle>.<key>`.
|
|
22
23
|
*/
|
|
23
|
-
import { execSync, spawnSync } from 'child_process';
|
|
24
24
|
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'crypto';
|
|
25
25
|
import * as fs from 'fs';
|
|
26
26
|
import * as os from 'os';
|
|
27
27
|
import * as path from 'path';
|
|
28
|
-
import {
|
|
28
|
+
import { withFileLock, ensureLockTarget } from '../fs-atomic.js';
|
|
29
29
|
// ---------- file store location ----------
|
|
30
30
|
let fileDirOverride = null;
|
|
31
31
|
let passphraseDirOverride = null;
|
|
@@ -37,89 +37,38 @@ export function fileDir() {
|
|
|
37
37
|
function ensureFileDir() {
|
|
38
38
|
fs.mkdirSync(fileDir(), { recursive: true, mode: 0o700 });
|
|
39
39
|
}
|
|
40
|
-
// ----------
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
stdio: ['inherit', 'pipe', 'inherit'],
|
|
61
|
-
});
|
|
62
|
-
if (res.error || res.status !== 0) {
|
|
63
|
-
throw new Error('Could not prompt for a passphrase on Windows. Set AGENTS_SECRETS_PASSPHRASE ' +
|
|
64
|
-
'to decrypt the file-backed secret store.');
|
|
65
|
-
}
|
|
66
|
-
return (res.stdout?.toString() ?? '').replace(/\r?\n$/, '');
|
|
67
|
-
}
|
|
68
|
-
/**
|
|
69
|
-
* Turn off terminal echo on the controlling TTY, or throw — fail CLOSED. If echo
|
|
70
|
-
* cannot be disabled (`stty` missing, no controlling terminal) we must NOT fall
|
|
71
|
-
* through and read the passphrase anyway: that echoes the secret to the screen
|
|
72
|
-
* and into scrollback (RUSH-1764). Refuse and point the user at the environment
|
|
73
|
-
* variable instead. `run` performs the echo-disable and throws iff it fails.
|
|
74
|
-
* Exported so the fail-closed contract has direct test coverage.
|
|
75
|
-
*/
|
|
76
|
-
export function disableTtyEchoOrThrow(run) {
|
|
77
|
-
try {
|
|
78
|
-
run();
|
|
79
|
-
}
|
|
80
|
-
catch {
|
|
81
|
-
throw new Error('Refusing to prompt for AGENTS_SECRETS_PASSPHRASE: terminal echo could not be ' +
|
|
82
|
-
'disabled (stty unavailable or no controlling TTY), so the passphrase would be ' +
|
|
83
|
-
'shown in cleartext. Set AGENTS_SECRETS_PASSPHRASE in the environment instead.');
|
|
84
|
-
}
|
|
40
|
+
// ---------- cross-process store lock (RUSH-1975) ----------
|
|
41
|
+
// Every mutation of the store (a `secrets set`/`delete`) and every rotation runs
|
|
42
|
+
// under one exclusive lock, so a write can never land in the store dir between a
|
|
43
|
+
// rotation's store-swap and key-swap renames (which would forge a MIXED store —
|
|
44
|
+
// items sealed under two keys at once — with no crash involved) and two rotations
|
|
45
|
+
// can never run concurrently. The macOS-only broker-unlock guard (`agentStatus`)
|
|
46
|
+
// does nothing on the Linux headless targets this command exists for, so this is
|
|
47
|
+
// the real serialization. The lock target is a SIBLING of the store dir, never a
|
|
48
|
+
// file inside it — so it is never copied through a rotation nor swept as a
|
|
49
|
+
// `.rotate-*` artifact, and it stays put across a mid-swap store-dir rename.
|
|
50
|
+
//
|
|
51
|
+
// A rotation's critical section is fully synchronous and scrypt-bound, so on a real
|
|
52
|
+
// store it runs well past the lock's stale window. It calls `withFileLock`'s
|
|
53
|
+
// `heartbeat()` through the re-encrypt and staging loops to keep the lockfile mtime
|
|
54
|
+
// fresh — otherwise a peer would see the live holder as crashed, break the lock, and
|
|
55
|
+
// interleave a write into the swap (a live lock-steal, no crash needed).
|
|
56
|
+
let lockAcquireTimeoutMsOverride = null;
|
|
57
|
+
let lockStaleMsOverride = null;
|
|
58
|
+
function fileStoreLockPath() {
|
|
59
|
+
return `${fileDir()}.lock`;
|
|
85
60
|
}
|
|
86
|
-
function
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
echoDisabled = true;
|
|
96
|
-
let pass = '';
|
|
97
|
-
const buf = Buffer.alloc(1);
|
|
98
|
-
while (true) {
|
|
99
|
-
const n = fs.readSync(fd, buf, 0, 1, null);
|
|
100
|
-
if (n === 0)
|
|
101
|
-
break;
|
|
102
|
-
const ch = buf.toString('utf8', 0, n);
|
|
103
|
-
if (ch === '\n' || ch === '\r')
|
|
104
|
-
break;
|
|
105
|
-
pass += ch;
|
|
106
|
-
}
|
|
107
|
-
return pass;
|
|
108
|
-
}
|
|
109
|
-
finally {
|
|
110
|
-
if (echoDisabled) {
|
|
111
|
-
try {
|
|
112
|
-
execSync('stty echo < /dev/tty', { stdio: 'ignore' });
|
|
113
|
-
}
|
|
114
|
-
catch { /* best effort */ }
|
|
115
|
-
}
|
|
116
|
-
try {
|
|
117
|
-
fs.writeSync(fd, '\n');
|
|
118
|
-
}
|
|
119
|
-
catch { /* best effort */ }
|
|
120
|
-
fs.closeSync(fd);
|
|
121
|
-
}
|
|
61
|
+
function withStoreLock(fn) {
|
|
62
|
+
const lock = fileStoreLockPath();
|
|
63
|
+
ensureLockTarget(lock);
|
|
64
|
+
const opts = {};
|
|
65
|
+
if (lockAcquireTimeoutMsOverride != null)
|
|
66
|
+
opts.acquireTimeoutMs = lockAcquireTimeoutMsOverride;
|
|
67
|
+
if (lockStaleMsOverride != null)
|
|
68
|
+
opts.staleMs = lockStaleMsOverride;
|
|
69
|
+
return withFileLock(lock, fn, opts);
|
|
122
70
|
}
|
|
71
|
+
// ---------- passphrase ----------
|
|
123
72
|
/**
|
|
124
73
|
* Directory for the auto-provisioned machine-local passphrase. Kept outside
|
|
125
74
|
* `fileDir()` so a scan of the encrypted store never co-locates key + ciphertext.
|
|
@@ -196,15 +145,13 @@ function provisionMachinePassphrase() {
|
|
|
196
145
|
* Resolve the passphrase for the encrypted file store.
|
|
197
146
|
*
|
|
198
147
|
* Order: AGENTS_SECRETS_PASSPHRASE > previously-provisioned machine-local key >
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
* on-disk key — the caller (bundles.ts) guards this before we get here.
|
|
148
|
+
* a freshly auto-provisioned machine-local key. It NEVER prompts and NEVER
|
|
149
|
+
* hard-fails — the file store must work on every platform (macOS included)
|
|
150
|
+
* without the user setting, typing, or remembering a passphrase. Provisioning
|
|
151
|
+
* writes a 0600 key file (encryption-at-rest, same posture as an SSH key); set
|
|
152
|
+
* AGENTS_SECRETS_PASSPHRASE to opt into an off-disk key.
|
|
205
153
|
*/
|
|
206
|
-
export function getPassphrase(
|
|
207
|
-
const allowAutoProvision = opts.allowAutoProvision ?? true;
|
|
154
|
+
export function getPassphrase() {
|
|
208
155
|
if (cachedPassphrase !== null)
|
|
209
156
|
return cachedPassphrase;
|
|
210
157
|
const env = process.env.AGENTS_SECRETS_PASSPHRASE;
|
|
@@ -213,26 +160,18 @@ export function getPassphrase(opts = {}) {
|
|
|
213
160
|
return env;
|
|
214
161
|
}
|
|
215
162
|
// A previously-provisioned machine-local passphrase is this machine's stable
|
|
216
|
-
// file-store key — prefer it
|
|
217
|
-
// always agree (a TTY run won't re-prompt once the file exists).
|
|
163
|
+
// file-store key — prefer it so interactive and headless runs always agree.
|
|
218
164
|
const onDisk = readMachinePassphrase();
|
|
219
165
|
if (onDisk) {
|
|
220
166
|
cachedPassphrase = onDisk;
|
|
221
167
|
return onDisk;
|
|
222
168
|
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
if (process.stdin.isTTY) {
|
|
230
|
-
const p = readPassphraseFromTty();
|
|
231
|
-
if (!p)
|
|
232
|
-
throw new Error('No passphrase entered.');
|
|
233
|
-
cachedPassphrase = p;
|
|
234
|
-
return p;
|
|
235
|
-
}
|
|
169
|
+
// No env passphrase and no machine-local key yet: silently provision a stable
|
|
170
|
+
// machine-local key (a 0600 file) on EVERY platform, macOS included. This is
|
|
171
|
+
// encryption-at-rest with an on-disk key — the same posture as an SSH private
|
|
172
|
+
// key — so the file store "just works" without the user ever setting, typing,
|
|
173
|
+
// or remembering a passphrase, and never pops a prompt or a Touch ID sheet.
|
|
174
|
+
// Set AGENTS_SECRETS_PASSPHRASE to opt into an off-disk key instead.
|
|
236
175
|
cachedPassphrase = provisionMachinePassphrase();
|
|
237
176
|
return cachedPassphrase;
|
|
238
177
|
}
|
|
@@ -274,7 +213,7 @@ function fileFor(item) {
|
|
|
274
213
|
function fileHas(item) {
|
|
275
214
|
return fs.existsSync(fileFor(item));
|
|
276
215
|
}
|
|
277
|
-
function fileGet(item
|
|
216
|
+
function fileGet(item) {
|
|
278
217
|
const fp = fileFor(item);
|
|
279
218
|
if (!fs.existsSync(fp)) {
|
|
280
219
|
throw new Error(`Secret '${item}' not found in encrypted store.`);
|
|
@@ -288,23 +227,34 @@ function fileGet(item, opts = {}) {
|
|
|
288
227
|
throw new Error(`Encrypted secret file ${fp} is corrupt (not valid JSON).`);
|
|
289
228
|
}
|
|
290
229
|
try {
|
|
291
|
-
return decryptForFallback(parsed, getPassphrase(
|
|
230
|
+
return decryptForFallback(parsed, getPassphrase());
|
|
292
231
|
}
|
|
293
232
|
catch {
|
|
294
233
|
throw new Error(`Failed to decrypt '${item}'. Wrong AGENTS_SECRETS_PASSPHRASE or tampered file.`);
|
|
295
234
|
}
|
|
296
235
|
}
|
|
297
|
-
function fileSet(item, value
|
|
236
|
+
function fileSet(item, value) {
|
|
298
237
|
ensureFileDir();
|
|
299
|
-
|
|
300
|
-
|
|
238
|
+
// Under the store lock: a write must not interleave with a rotation's swap.
|
|
239
|
+
// `getPassphrase()` takes no options since the auto-provision requirement was
|
|
240
|
+
// dropped (#1658) — provisioning is now unconditional.
|
|
241
|
+
withStoreLock(() => {
|
|
242
|
+
const enc = encryptForFallback(value, getPassphrase());
|
|
243
|
+
fs.writeFileSync(fileFor(item), JSON.stringify(enc), { mode: 0o600 });
|
|
244
|
+
});
|
|
301
245
|
}
|
|
302
246
|
function fileDelete(item) {
|
|
303
247
|
const fp = fileFor(item);
|
|
304
248
|
if (!fs.existsSync(fp))
|
|
305
249
|
return true; // idempotent, matches secret-tool clear
|
|
306
|
-
|
|
307
|
-
|
|
250
|
+
return withStoreLock(() => {
|
|
251
|
+
// Re-check under the lock — a rotation may have swapped the dir since the
|
|
252
|
+
// pre-lock existence probe above.
|
|
253
|
+
if (!fs.existsSync(fp))
|
|
254
|
+
return true;
|
|
255
|
+
fs.unlinkSync(fp);
|
|
256
|
+
return true;
|
|
257
|
+
});
|
|
308
258
|
}
|
|
309
259
|
function fileList(prefix) {
|
|
310
260
|
const dir = fileDir();
|
|
@@ -346,6 +296,593 @@ export const fileBackend = {
|
|
|
346
296
|
export function resolvePassphraseDir() {
|
|
347
297
|
return passphraseDir();
|
|
348
298
|
}
|
|
299
|
+
// ---------- passphrase rotation (RUSH-1975) ----------
|
|
300
|
+
/**
|
|
301
|
+
* Path of the machine-local passphrase file that currently holds the file-store
|
|
302
|
+
* key, or null if none is provisioned. Prefers the canonical #479 location and
|
|
303
|
+
* falls back to the legacy co-located path, mirroring `readMachinePassphrase`.
|
|
304
|
+
*/
|
|
305
|
+
export function machinePassphraseSourcePath() {
|
|
306
|
+
for (const fp of [passphraseFilePath(), legacyPassphraseFilePath()]) {
|
|
307
|
+
try {
|
|
308
|
+
if (fs.readFileSync(fp, 'utf8').trim().length > 0)
|
|
309
|
+
return fp;
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
// try next location
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Resolve the key path for a rotation that crashed mid-key-swap and left the live
|
|
319
|
+
* key file absent (Window B: `keyPath` moved to `<key>.rotate-oldkey`, the new key
|
|
320
|
+
* not yet landed). `machinePassphraseSourcePath` returns null in that state because
|
|
321
|
+
* neither canonical nor legacy file has content, so recovery would never run. If a
|
|
322
|
+
* rotation artifact (`.rotate-new` / `.rotate-oldkey`) exists for a canonical key
|
|
323
|
+
* path, that path is the interrupted rotation's target — return it so recovery can
|
|
324
|
+
* finish forward. Null when no such artifact is present.
|
|
325
|
+
*/
|
|
326
|
+
function resolveInterruptedKeyPath() {
|
|
327
|
+
for (const fp of [passphraseFilePath(), legacyPassphraseFilePath()]) {
|
|
328
|
+
if (fs.existsSync(`${fp}.rotate-new`) || fs.existsSync(`${fp}.rotate-oldkey`))
|
|
329
|
+
return fp;
|
|
330
|
+
}
|
|
331
|
+
// Co-located layout: the key lives inside the store dir and travels with it in a
|
|
332
|
+
// single rename, so no `.rotate-new`/`.rotate-oldkey` key artifacts are ever
|
|
333
|
+
// written. A crash in that rename leaves the store dir absent with its old store +
|
|
334
|
+
// co-located key sitting in the `<dir>.rotate-old-*` backup, and the canonical/
|
|
335
|
+
// legacy key files both gone — so the checks above return null and recovery would
|
|
336
|
+
// never run. If a backup holding a co-located `.passphrase` is present, the
|
|
337
|
+
// interrupted rotation's key target is that legacy co-located path; return it so
|
|
338
|
+
// recovery restores the backup (old store + old key) and heals the store.
|
|
339
|
+
const dir = fileDir();
|
|
340
|
+
if (!fs.existsSync(dir)) {
|
|
341
|
+
const parent = path.dirname(dir);
|
|
342
|
+
const base = path.basename(dir);
|
|
343
|
+
let entries;
|
|
344
|
+
try {
|
|
345
|
+
entries = fs.readdirSync(parent);
|
|
346
|
+
}
|
|
347
|
+
catch {
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
const bak = entries.find((e) => e.startsWith(`${base}.rotate-old-`));
|
|
351
|
+
if (bak && fs.existsSync(path.join(parent, bak, '.passphrase'))) {
|
|
352
|
+
return legacyPassphraseFilePath();
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
/** Flush a file's data to disk (durability before the atomic swap). */
|
|
358
|
+
function writeFileFsync(fp, data, mode) {
|
|
359
|
+
const fd = fs.openSync(fp, 'w', mode);
|
|
360
|
+
try {
|
|
361
|
+
// Narrow to one of fs.writeSync's overloads: the string form encodes as UTF-8,
|
|
362
|
+
// the Buffer form writes raw bytes verbatim (binary-safe copy-through).
|
|
363
|
+
if (typeof data === 'string')
|
|
364
|
+
fs.writeSync(fd, data);
|
|
365
|
+
else
|
|
366
|
+
fs.writeSync(fd, data);
|
|
367
|
+
fs.fsyncSync(fd);
|
|
368
|
+
}
|
|
369
|
+
finally {
|
|
370
|
+
fs.closeSync(fd);
|
|
371
|
+
}
|
|
372
|
+
// A freshly-created file needs its mode set explicitly — the open() mode is
|
|
373
|
+
// masked by the process umask, so 0600 is not guaranteed by the flag alone.
|
|
374
|
+
try {
|
|
375
|
+
fs.chmodSync(fp, mode);
|
|
376
|
+
}
|
|
377
|
+
catch { /* best effort on platforms without chmod */ }
|
|
378
|
+
}
|
|
379
|
+
/** fsync a directory so a rename/create in it is durable. Best-effort: some
|
|
380
|
+
* filesystems reject O_RDONLY fsync on a directory. */
|
|
381
|
+
function fsyncDir(dir) {
|
|
382
|
+
let fd = null;
|
|
383
|
+
try {
|
|
384
|
+
fd = fs.openSync(dir, 'r');
|
|
385
|
+
fs.fsyncSync(fd);
|
|
386
|
+
}
|
|
387
|
+
catch {
|
|
388
|
+
// filesystem doesn't support directory fsync — the rename is still ordered
|
|
389
|
+
}
|
|
390
|
+
finally {
|
|
391
|
+
if (fd !== null)
|
|
392
|
+
fs.closeSync(fd);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
/** True if `enc` decrypts (auth-tag verifies) under `keyVal`. */
|
|
396
|
+
function opensUnder(enc, keyVal) {
|
|
397
|
+
try {
|
|
398
|
+
decryptForFallback(enc, keyVal);
|
|
399
|
+
return true;
|
|
400
|
+
}
|
|
401
|
+
catch {
|
|
402
|
+
return false;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Classify how the `.enc` items in `dir` relate to `keyVal`, so recovery can tell
|
|
407
|
+
* a single-key store (safe to sweep) apart from a MIXED store (live data sealed
|
|
408
|
+
* under two keys at once — unsafe). `candidateKeys` is every key a mid-rotation
|
|
409
|
+
* crash could have left on disk: the live key file, `<key>.rotate-new` (the
|
|
410
|
+
* incoming key), and `<key>.rotate-oldkey` (the retired key). An item that opens
|
|
411
|
+
* under NONE of them is a genuine orphan — sealed under a third key and carried
|
|
412
|
+
* through a rotation verbatim — and is ignored: it neither proves nor disproves
|
|
413
|
+
* consistency. Among the remaining, non-orphan items:
|
|
414
|
+
* 'all' — every one opens under `keyVal` → `keyVal` is the store's one key
|
|
415
|
+
* 'some' — at least one opens under `keyVal` AND at least one does not → MIXED
|
|
416
|
+
* 'none' — none open under `keyVal`
|
|
417
|
+
*
|
|
418
|
+
* Only 'all' is safe to sweep against. The original "any one item opens" heuristic
|
|
419
|
+
* returned true for a mixed store — so a single stray item sealed under the live
|
|
420
|
+
* key (an interstitial `secrets set` after a mid-swap crash) made recovery sweep
|
|
421
|
+
* `<key>.rotate-new`, the only surviving copy of the key the *other* items need,
|
|
422
|
+
* destroying every one of them silently (RUSH-1975).
|
|
423
|
+
*/
|
|
424
|
+
function classifyStore(dir, keyVal, candidateKeys) {
|
|
425
|
+
if (keyVal == null)
|
|
426
|
+
return 'none';
|
|
427
|
+
let names;
|
|
428
|
+
try {
|
|
429
|
+
names = fs.readdirSync(dir).filter((f) => f.endsWith('.enc'));
|
|
430
|
+
}
|
|
431
|
+
catch {
|
|
432
|
+
return 'none';
|
|
433
|
+
}
|
|
434
|
+
const otherKeys = candidateKeys.filter((k) => k != null && k !== keyVal);
|
|
435
|
+
let nonOrphan = 0;
|
|
436
|
+
let openUnderKey = 0;
|
|
437
|
+
for (const name of names) {
|
|
438
|
+
let enc;
|
|
439
|
+
try {
|
|
440
|
+
enc = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8'));
|
|
441
|
+
}
|
|
442
|
+
catch {
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
const opensKey = opensUnder(enc, keyVal);
|
|
446
|
+
// Ignore genuine orphans (open under no candidate key) — a third-party cache
|
|
447
|
+
// carried through verbatim is not evidence of a mixed store.
|
|
448
|
+
if (!opensKey && !otherKeys.some((k) => opensUnder(enc, k)))
|
|
449
|
+
continue;
|
|
450
|
+
nonOrphan++;
|
|
451
|
+
if (opensKey)
|
|
452
|
+
openUnderKey++;
|
|
453
|
+
}
|
|
454
|
+
if (openUnderKey === 0)
|
|
455
|
+
return 'none';
|
|
456
|
+
return openUnderKey === nonOrphan ? 'all' : 'some';
|
|
457
|
+
}
|
|
458
|
+
/** True if `dir` holds at least one `.enc` item. */
|
|
459
|
+
function storeHasEnc(dir) {
|
|
460
|
+
try {
|
|
461
|
+
return fs.readdirSync(dir).some((f) => f.endsWith('.enc'));
|
|
462
|
+
}
|
|
463
|
+
catch {
|
|
464
|
+
return false;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* `.enc` basenames present in the `<dir>.rotate-old-*` backup but ABSENT from the
|
|
469
|
+
* live `dir` — ciphertext that lives ONLY in the backup. A genuine post-swap store
|
|
470
|
+
* is always a superset of the pre-swap store (rotation re-keys every item and copies
|
|
471
|
+
* orphans/non-.enc files through verbatim — nothing is dropped), so this set is empty
|
|
472
|
+
* for any real completed/interrupted rotation. It is non-empty only when the live
|
|
473
|
+
* `dir` is NOT the post-swap store: an interstitial `secrets set` recreated the store
|
|
474
|
+
* dir after a crash in the move-aside window left it absent (RUSH-1975). Sweeping the
|
|
475
|
+
* backup then destroys those items — so recovery refuses instead.
|
|
476
|
+
*/
|
|
477
|
+
function backupOnlyEnc(bak, dir) {
|
|
478
|
+
let bakNames;
|
|
479
|
+
try {
|
|
480
|
+
bakNames = fs.readdirSync(bak).filter((f) => f.endsWith('.enc'));
|
|
481
|
+
}
|
|
482
|
+
catch {
|
|
483
|
+
return [];
|
|
484
|
+
}
|
|
485
|
+
let dirNames;
|
|
486
|
+
try {
|
|
487
|
+
dirNames = new Set(fs.readdirSync(dir).filter((f) => f.endsWith('.enc')));
|
|
488
|
+
}
|
|
489
|
+
catch {
|
|
490
|
+
dirNames = new Set();
|
|
491
|
+
}
|
|
492
|
+
return bakNames.filter((n) => !dirNames.has(n));
|
|
493
|
+
}
|
|
494
|
+
/** Read a key file's trimmed contents, or null if absent/empty. */
|
|
495
|
+
function readKeyFile(fp) {
|
|
496
|
+
try {
|
|
497
|
+
const v = fs.readFileSync(fp, 'utf8').trim();
|
|
498
|
+
return v.length > 0 ? v : null;
|
|
499
|
+
}
|
|
500
|
+
catch {
|
|
501
|
+
return null;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Recover from a rotation that was interrupted mid-swap on a prior run, so the
|
|
506
|
+
* store is always left in a single, self-consistent, readable state.
|
|
507
|
+
*
|
|
508
|
+
* Recovery is CONTENT-aware, not presence-aware, and it classifies the WHOLE
|
|
509
|
+
* store, not just one item. The mere existence of the store dir and the key file
|
|
510
|
+
* does not prove they match (RUSH-1975 data-loss window): on the non-co-located
|
|
511
|
+
* key path the swap is four renames, and a crash after the store swap
|
|
512
|
+
* (`stageDir`->`dir`) but before the key swap (`keyTmp`->`keyPath`) finishes
|
|
513
|
+
* leaves a NEW-key store next to the OLD key file, both present. A presence check
|
|
514
|
+
* would see "both here" and wrongly sweep the only copies of the old ciphertext
|
|
515
|
+
* (`<dir>.rotate-old-*`) and the new key (`<key>.rotate-new`), permanently
|
|
516
|
+
* orphaning every secret. So we probe the actual ciphertext with `classifyStore`,
|
|
517
|
+
* which distinguishes a store that opens fully under one key ('all') from one that
|
|
518
|
+
* is MIXED — some items under the live key, others under the incoming key ('some',
|
|
519
|
+
* e.g. after a mid-swap crash contaminated by a later `secrets set`):
|
|
520
|
+
*
|
|
521
|
+
* 1. The live key opens EVERY non-orphan item ('all') → rotation complete and
|
|
522
|
+
* consistent (or never interrupted); sweeping the `.rotate-*` artifacts is safe —
|
|
523
|
+
* unless a `<dir>.rotate-old-*` backup still holds `.enc` items absent from the
|
|
524
|
+
* live dir. That means the live dir is not the post-swap store but a fresh dir an
|
|
525
|
+
* interstitial `secrets set` created after a crash in the move-aside window left
|
|
526
|
+
* the store dir absent, so the backup is the only copy of those items → REFUSE.
|
|
527
|
+
* 2. Else, if `<key>.rotate-new` opens every non-orphan item ('all'), the crash
|
|
528
|
+
* landed after the store swap but before the key swap finished → finish the
|
|
529
|
+
* rotation forward by installing `.rotate-new` as the live key, then sweep.
|
|
530
|
+
* 3. Else, if neither key opens any item, roll back: restore the
|
|
531
|
+
* `<dir>.rotate-old-*` backup over `dir` and `<key>.rotate-oldkey` over the key
|
|
532
|
+
* file — but only once the backup is proven to open fully under the old key.
|
|
533
|
+
* 4. If a key opens SOME but not all items ('some'), the store is MIXED — an
|
|
534
|
+
* interrupted rotation contaminated by a later write, with live data under two
|
|
535
|
+
* keys at once. Sweeping would delete the only copy of one of those keys, so we
|
|
536
|
+
* REFUSE: throw an actionable error and preserve every recovery artifact for
|
|
537
|
+
* out-of-band repair. Likewise, if neither forward nor rollback can be proven,
|
|
538
|
+
* leave every artifact in place — a leftover temp dir is recoverable, deleting
|
|
539
|
+
* the only copy of a key or ciphertext is not.
|
|
540
|
+
*
|
|
541
|
+
* A phase-marker / journal file was considered and deliberately skipped: the
|
|
542
|
+
* AES-256-GCM auth tag already makes the decrypt probe an authoritative,
|
|
543
|
+
* self-validating record of which key matches the store. A separate marker would
|
|
544
|
+
* be a second source of truth that can disagree with reality — its own write has
|
|
545
|
+
* crash windows, and a stale marker misleads — so it would weaken, not strengthen,
|
|
546
|
+
* this guarantee. Idempotent; a no-op when no rotation artifacts are present.
|
|
547
|
+
* Callers run this under the store lock (see `withStoreLock`).
|
|
548
|
+
*/
|
|
549
|
+
/**
|
|
550
|
+
* Whether a previous rotation left artifacts on disk — i.e. whether
|
|
551
|
+
* {@link recoverInterruptedRotation} would do any work. Read-only, so `--dry-run`
|
|
552
|
+
* can report a pending recovery without performing (and thus writing) one.
|
|
553
|
+
*/
|
|
554
|
+
export function hasInterruptedRotationArtifacts(keyPath) {
|
|
555
|
+
const dir = fileDir();
|
|
556
|
+
const parent = path.dirname(dir);
|
|
557
|
+
const base = path.basename(dir);
|
|
558
|
+
let entries;
|
|
559
|
+
try {
|
|
560
|
+
entries = fs.readdirSync(parent);
|
|
561
|
+
}
|
|
562
|
+
catch {
|
|
563
|
+
return false;
|
|
564
|
+
}
|
|
565
|
+
return entries.some((e) => e.startsWith(`${base}.rotate-`))
|
|
566
|
+
|| fs.existsSync(`${keyPath}.rotate-new`)
|
|
567
|
+
|| fs.existsSync(`${keyPath}.rotate-oldkey`);
|
|
568
|
+
}
|
|
569
|
+
function recoverInterruptedRotation(keyPath) {
|
|
570
|
+
const dir = fileDir();
|
|
571
|
+
const parent = path.dirname(dir);
|
|
572
|
+
const base = path.basename(dir);
|
|
573
|
+
let entries;
|
|
574
|
+
try {
|
|
575
|
+
entries = fs.readdirSync(parent);
|
|
576
|
+
}
|
|
577
|
+
catch {
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
const keyNew = `${keyPath}.rotate-new`;
|
|
581
|
+
const keyOld = `${keyPath}.rotate-oldkey`;
|
|
582
|
+
const bakName = entries.find((e) => e.startsWith(`${base}.rotate-old-`));
|
|
583
|
+
let bakDir = bakName ? path.join(parent, bakName) : null;
|
|
584
|
+
// Nothing rotation-related on disk -> no interrupted rotation to recover.
|
|
585
|
+
if (!hasInterruptedRotationArtifacts(keyPath))
|
|
586
|
+
return;
|
|
587
|
+
// If the live store dir vanished mid-swap (crash between the two store renames,
|
|
588
|
+
// before the new store landed), restore the old store from its backup — the key
|
|
589
|
+
// was not touched yet, so old store + old key is a consistent state.
|
|
590
|
+
if (!fs.existsSync(dir) && bakDir && fs.existsSync(bakDir)) {
|
|
591
|
+
fs.renameSync(bakDir, dir);
|
|
592
|
+
bakDir = null; // consumed
|
|
593
|
+
}
|
|
594
|
+
const sweep = () => {
|
|
595
|
+
for (const e of fs.readdirSync(parent)) {
|
|
596
|
+
if (e.startsWith(`${base}.rotate-`)) {
|
|
597
|
+
try {
|
|
598
|
+
fs.rmSync(path.join(parent, e), { recursive: true, force: true });
|
|
599
|
+
}
|
|
600
|
+
catch { /* best effort */ }
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
for (const suffix of ['.rotate-new', '.rotate-oldkey']) {
|
|
604
|
+
try {
|
|
605
|
+
fs.rmSync(`${keyPath}${suffix}`, { force: true });
|
|
606
|
+
}
|
|
607
|
+
catch { /* best effort */ }
|
|
608
|
+
}
|
|
609
|
+
};
|
|
610
|
+
// An empty or unreadable store holds no ciphertext at risk. Only sweep once the
|
|
611
|
+
// live key file is present again; never delete recovery artifacts for a store we
|
|
612
|
+
// cannot probe.
|
|
613
|
+
if (!storeHasEnc(dir)) {
|
|
614
|
+
if (fs.existsSync(keyPath))
|
|
615
|
+
sweep();
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
const liveKey = readKeyFile(keyPath);
|
|
619
|
+
const newKey = readKeyFile(keyNew);
|
|
620
|
+
const oldKey = readKeyFile(keyOld);
|
|
621
|
+
const candidates = [liveKey, newKey, oldKey];
|
|
622
|
+
// A MIXED store cannot be swept safely: live data is sealed under two keys at
|
|
623
|
+
// once, so deleting either `.rotate-new` or the old-ciphertext backup destroys
|
|
624
|
+
// one class permanently. Refuse loudly and keep every artifact for out-of-band
|
|
625
|
+
// repair — never report success over a store we would be corrupting.
|
|
626
|
+
const refuseMixed = (label) => {
|
|
627
|
+
throw new Error(`Interrupted secrets rotation left a MIXED store at ${dir}: ${label}. This ` +
|
|
628
|
+
`happens when a \`secrets set\` landed between a crashed rotation and this ` +
|
|
629
|
+
`recovery. Refusing to sweep — every recovery artifact is preserved. Recover ` +
|
|
630
|
+
`out of band: for each ${dir}/*.enc, decrypt it under whichever of ${keyPath}` +
|
|
631
|
+
(fs.existsSync(keyNew) ? `, ${keyNew}` : '') +
|
|
632
|
+
(fs.existsSync(keyOld) ? `, ${keyOld}` : '') +
|
|
633
|
+
` opens it, re-seal all items under one key, then re-run \`rotate-passphrase\`.`);
|
|
634
|
+
};
|
|
635
|
+
// 1. Live key opens the WHOLE store -> rotation complete/consistent. Sweep safe,
|
|
636
|
+
// UNLESS a `<dir>.rotate-old-*` backup still holds items absent from the live
|
|
637
|
+
// dir: then the live dir is not the post-swap store but a fresh dir an
|
|
638
|
+
// interstitial `secrets set` created after a crash in the move-aside window
|
|
639
|
+
// (store dir absent), and the backup is the ONLY copy of those items. Sweeping
|
|
640
|
+
// would destroy them, so refuse. Opens only some items -> MIXED, refuse.
|
|
641
|
+
const liveMatch = classifyStore(dir, liveKey, candidates);
|
|
642
|
+
if (liveMatch === 'all') {
|
|
643
|
+
const orphaned = bakDir && fs.existsSync(bakDir) ? backupOnlyEnc(bakDir, dir) : [];
|
|
644
|
+
if (orphaned.length > 0) {
|
|
645
|
+
const shown = orphaned.slice(0, 3).join(', ') + (orphaned.length > 3 ? ', …' : '');
|
|
646
|
+
throw new Error(`Interrupted secrets rotation left a MIXED (split) store: the live key opens ` +
|
|
647
|
+
`${dir}, but its backup ${bakDir} holds ${orphaned.length} item(s) absent from ` +
|
|
648
|
+
`the live store (${shown}) — so the live dir is not the whole store. This ` +
|
|
649
|
+
`happens when a \`secrets set\` recreated the store dir after a crash left it ` +
|
|
650
|
+
`absent. Refusing to sweep — every recovery artifact is preserved. Recover out ` +
|
|
651
|
+
`of band: merge ${bakDir}/*.enc into ${dir} (both open under ${keyPath}), then ` +
|
|
652
|
+
`re-run \`rotate-passphrase\`.`);
|
|
653
|
+
}
|
|
654
|
+
sweep();
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
if (liveMatch === 'some')
|
|
658
|
+
refuseMixed('some items open under the live key and others do not');
|
|
659
|
+
// 2. `.rotate-new` opens the whole store, the live key none of it -> the crash
|
|
660
|
+
// landed after the store swap, before the key swap finished. Finish the
|
|
661
|
+
// rotation forward by installing the new key, then sweep. Opens only some ->
|
|
662
|
+
// MIXED, refuse.
|
|
663
|
+
const newMatch = classifyStore(dir, newKey, candidates);
|
|
664
|
+
if (newMatch === 'all') {
|
|
665
|
+
try {
|
|
666
|
+
fs.rmSync(keyPath, { force: true });
|
|
667
|
+
}
|
|
668
|
+
catch { /* may be absent mid-key-swap */ }
|
|
669
|
+
fs.renameSync(keyNew, keyPath);
|
|
670
|
+
fsyncDir(path.dirname(keyPath));
|
|
671
|
+
sweep();
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
if (newMatch === 'some')
|
|
675
|
+
refuseMixed('some items open under the incoming (.rotate-new) key and others do not');
|
|
676
|
+
// 3. Neither key opens the live store -> roll back to the pre-rotation state, but
|
|
677
|
+
// only once the backup store is proven to open FULLY under the old key (or the
|
|
678
|
+
// live key, when `.rotate-oldkey` was not written yet — Window A).
|
|
679
|
+
const rollbackKey = bakDir && classifyStore(bakDir, oldKey, candidates) === 'all'
|
|
680
|
+
? oldKey
|
|
681
|
+
: (bakDir && classifyStore(bakDir, liveKey, candidates) === 'all' ? liveKey : null);
|
|
682
|
+
if (bakDir && fs.existsSync(bakDir) && rollbackKey != null) {
|
|
683
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
684
|
+
fs.renameSync(bakDir, dir);
|
|
685
|
+
fsyncDir(path.dirname(dir));
|
|
686
|
+
if (rollbackKey === oldKey && fs.existsSync(keyOld)) {
|
|
687
|
+
try {
|
|
688
|
+
fs.rmSync(keyPath, { force: true });
|
|
689
|
+
}
|
|
690
|
+
catch { /* may be absent */ }
|
|
691
|
+
fs.renameSync(keyOld, keyPath);
|
|
692
|
+
fsyncDir(path.dirname(keyPath));
|
|
693
|
+
}
|
|
694
|
+
sweep();
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
// 4. Neither forward nor rollback is provable -> leave every artifact untouched
|
|
698
|
+
// for out-of-band recovery. Do NOT sweep: that is the data-loss bug.
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* Rotate under the exclusive store lock, so no `secrets set`/`delete` and no
|
|
702
|
+
* second rotation can interleave with the swap (see `withStoreLock`). The whole
|
|
703
|
+
* run — recovery, verify, swap — holds the lock; it is released on return or throw.
|
|
704
|
+
*/
|
|
705
|
+
export function rotatePassphrase(opts = {}) {
|
|
706
|
+
return withStoreLock((heartbeat) => rotatePassphraseLocked(opts, heartbeat));
|
|
707
|
+
}
|
|
708
|
+
function rotatePassphraseLocked(opts, heartbeat = () => { }) {
|
|
709
|
+
const dryRun = opts.dryRun ?? false;
|
|
710
|
+
// Resolve the key path. If the live key file is absent because a prior rotation
|
|
711
|
+
// crashed mid-key-swap, fall back to the interrupted rotation's target so
|
|
712
|
+
// recovery below can still run and heal the store (RUSH-1975 Window B).
|
|
713
|
+
const keyPath = machinePassphraseSourcePath() ?? resolveInterruptedKeyPath();
|
|
714
|
+
if (!keyPath) {
|
|
715
|
+
throw new Error('No machine-local passphrase to rotate. `rotate-passphrase` re-keys the ' +
|
|
716
|
+
'file store\'s auto-provisioned key; none is provisioned on this machine.');
|
|
717
|
+
}
|
|
718
|
+
// Recovery runs even under --dry-run, deliberately: healing an interrupted
|
|
719
|
+
// rotation is how a crashed store becomes readable again WITHOUT re-keying it,
|
|
720
|
+
// and gating it would leave such a store recoverable only via a full rotation.
|
|
721
|
+
// It is the one thing a dry run writes, so the report says so and the CLI
|
|
722
|
+
// prints it instead of claiming "nothing written".
|
|
723
|
+
const recoveredInterruptedRotation = hasInterruptedRotationArtifacts(keyPath);
|
|
724
|
+
recoverInterruptedRotation(keyPath);
|
|
725
|
+
const oldPass = fs.readFileSync(keyPath, 'utf8').trim();
|
|
726
|
+
if (!oldPass)
|
|
727
|
+
throw new Error(`Machine-local passphrase file ${keyPath} is empty.`);
|
|
728
|
+
const newPass = opts.newPassphrase ?? randomBytes(32).toString('base64');
|
|
729
|
+
if (newPass === oldPass)
|
|
730
|
+
throw new Error('New passphrase equals the current one — refusing a no-op rotation.');
|
|
731
|
+
const dir = fileDir();
|
|
732
|
+
let names;
|
|
733
|
+
try {
|
|
734
|
+
names = fs.readdirSync(dir).filter((f) => f.endsWith('.enc'));
|
|
735
|
+
}
|
|
736
|
+
catch {
|
|
737
|
+
names = [];
|
|
738
|
+
}
|
|
739
|
+
if (names.length === 0) {
|
|
740
|
+
throw new Error(`No encrypted items in ${dir} — nothing to rotate.`);
|
|
741
|
+
}
|
|
742
|
+
// Phase 1 — decrypt-all, re-encrypt, re-verify in memory. Nothing on disk is
|
|
743
|
+
// touched here, so any throw leaves the live store and key file untouched.
|
|
744
|
+
const staged = [];
|
|
745
|
+
const skipped = [];
|
|
746
|
+
for (const name of names) {
|
|
747
|
+
// Keep the store lock fresh across the scrypt-bound loop: each item runs the
|
|
748
|
+
// KDF three times (decrypt, re-encrypt, verify), so on a real store this loop
|
|
749
|
+
// outlives the lock's stale window — without this a peer could break the lock
|
|
750
|
+
// as "stale" mid-run and interleave a write (see `withFileLock`'s heartbeat).
|
|
751
|
+
heartbeat();
|
|
752
|
+
const raw = fs.readFileSync(path.join(dir, name), 'utf8');
|
|
753
|
+
let parsed;
|
|
754
|
+
try {
|
|
755
|
+
parsed = JSON.parse(raw);
|
|
756
|
+
}
|
|
757
|
+
catch {
|
|
758
|
+
skipped.push(`${name} (not valid EncFile JSON)`);
|
|
759
|
+
continue;
|
|
760
|
+
}
|
|
761
|
+
let plain;
|
|
762
|
+
try {
|
|
763
|
+
plain = decryptForFallback(parsed, oldPass);
|
|
764
|
+
}
|
|
765
|
+
catch {
|
|
766
|
+
skipped.push(`${name} (does not decrypt under the current key — orphan)`);
|
|
767
|
+
continue;
|
|
768
|
+
}
|
|
769
|
+
let reEnc = encryptForFallback(plain, newPass);
|
|
770
|
+
if (opts.tamperStaged)
|
|
771
|
+
reEnc = { ...reEnc, ciphertext: `00${reEnc.ciphertext.slice(2)}` };
|
|
772
|
+
let check;
|
|
773
|
+
try {
|
|
774
|
+
check = decryptForFallback(reEnc, newPass);
|
|
775
|
+
}
|
|
776
|
+
catch {
|
|
777
|
+
throw new Error(`Re-encryption of ${name} failed to verify under the new key — aborted, nothing written.`);
|
|
778
|
+
}
|
|
779
|
+
if (check !== plain)
|
|
780
|
+
throw new Error(`Round-trip mismatch on ${name} — aborted, nothing written.`);
|
|
781
|
+
staged.push({ name, enc: JSON.stringify(reEnc) });
|
|
782
|
+
}
|
|
783
|
+
if (staged.length === 0) {
|
|
784
|
+
throw new Error('No item decrypted under the current machine-local key — aborted, nothing written.');
|
|
785
|
+
}
|
|
786
|
+
const report = {
|
|
787
|
+
dryRun,
|
|
788
|
+
committed: false,
|
|
789
|
+
bundleCount: staged.length,
|
|
790
|
+
skipped,
|
|
791
|
+
roundTripOk: true,
|
|
792
|
+
keyFilePath: keyPath,
|
|
793
|
+
recoveredInterruptedRotation,
|
|
794
|
+
};
|
|
795
|
+
if (dryRun)
|
|
796
|
+
return report;
|
|
797
|
+
// Phase 2 — stage the complete replacement store in a sibling temp dir, fsync,
|
|
798
|
+
// then swap. Orphans and any non-.enc files are copied through verbatim so the
|
|
799
|
+
// swapped dir is a complete superset of the old one (nothing is dropped).
|
|
800
|
+
const keyColocated = path.dirname(keyPath) === dir;
|
|
801
|
+
const rand = randomBytes(6).toString('hex');
|
|
802
|
+
const stageDir = `${dir}.rotate-${rand}`;
|
|
803
|
+
fs.rmSync(stageDir, { recursive: true, force: true });
|
|
804
|
+
fs.mkdirSync(stageDir, { recursive: true, mode: 0o700 });
|
|
805
|
+
const stagedNames = new Set(staged.map((s) => s.name));
|
|
806
|
+
for (const { name, enc } of staged) {
|
|
807
|
+
heartbeat(); // each write is an fsync; keep the lock fresh across the batch
|
|
808
|
+
writeFileFsync(path.join(stageDir, name), enc, 0o600);
|
|
809
|
+
}
|
|
810
|
+
for (const entry of fs.readdirSync(dir)) {
|
|
811
|
+
if (stagedNames.has(entry))
|
|
812
|
+
continue;
|
|
813
|
+
if (keyColocated && entry === path.basename(keyPath))
|
|
814
|
+
continue; // rewritten below, not copied
|
|
815
|
+
const src = path.join(dir, entry);
|
|
816
|
+
if (!fs.statSync(src).isFile())
|
|
817
|
+
continue;
|
|
818
|
+
heartbeat();
|
|
819
|
+
// Copy through as raw bytes — reading as 'utf8' would decode any non-UTF-8
|
|
820
|
+
// byte to U+FFFD and silently corrupt the file on the way through the swap.
|
|
821
|
+
writeFileFsync(path.join(stageDir, entry), fs.readFileSync(src), 0o600);
|
|
822
|
+
}
|
|
823
|
+
// A co-located legacy key travels with the store: write the new value into the
|
|
824
|
+
// staged dir so a single directory swap commits both ciphertext and key.
|
|
825
|
+
if (keyColocated)
|
|
826
|
+
writeFileFsync(path.join(stageDir, path.basename(keyPath)), newPass, 0o600);
|
|
827
|
+
fsyncDir(stageDir);
|
|
828
|
+
// Test seam: simulate a crash after staging but before the swap. The live store
|
|
829
|
+
// and key file are still untouched at this point.
|
|
830
|
+
opts.onStagedBeforeCommit?.();
|
|
831
|
+
// For a non-co-located key, stage the new key beside the old one first so the
|
|
832
|
+
// swap is two quick renames with no I/O between them.
|
|
833
|
+
const keyTmp = `${keyPath}.rotate-new`;
|
|
834
|
+
if (!keyColocated) {
|
|
835
|
+
writeFileFsync(keyTmp, newPass, 0o600);
|
|
836
|
+
fsyncDir(path.dirname(keyPath));
|
|
837
|
+
}
|
|
838
|
+
// Swap. Move the live store aside, then the staged store into place. The gap
|
|
839
|
+
// between these two renames is the only crash window that leaves the store dir
|
|
840
|
+
// absent; recoverInterruptedRotation restores it from the backup on next run.
|
|
841
|
+
const bakDir = `${dir}.rotate-old-${rand}`;
|
|
842
|
+
fs.renameSync(dir, bakDir);
|
|
843
|
+
// Test seam: crash after the live store is moved aside, before the staged store
|
|
844
|
+
// lands (the store dir is absent). For a co-located key this is the ONLY swap
|
|
845
|
+
// window — the single rename carries both ciphertext and key.
|
|
846
|
+
opts.onStoreMovedAsideBeforeSwap?.();
|
|
847
|
+
fs.renameSync(stageDir, dir);
|
|
848
|
+
fsyncDir(path.dirname(dir));
|
|
849
|
+
const keyBak = `${keyPath}.rotate-oldkey`;
|
|
850
|
+
if (!keyColocated) {
|
|
851
|
+
// Test seam: crash after the store swap, before the key swap begins (Window A).
|
|
852
|
+
// Also receives the lock heartbeat so a test can prove a long hold stays fresh.
|
|
853
|
+
opts.onStoreSwappedBeforeKeySwap?.(heartbeat);
|
|
854
|
+
fs.renameSync(keyPath, keyBak);
|
|
855
|
+
// Test seam: crash after the old key is moved aside, before the new key lands (Window B).
|
|
856
|
+
opts.onKeyBackedUpBeforeNewKey?.();
|
|
857
|
+
fs.renameSync(keyTmp, keyPath);
|
|
858
|
+
fsyncDir(path.dirname(keyPath));
|
|
859
|
+
}
|
|
860
|
+
// Verify a real read out of the now-live store under the new key. On failure,
|
|
861
|
+
// roll the store (and key) back to the backup — the old passphrase still works.
|
|
862
|
+
try {
|
|
863
|
+
const probe = JSON.parse(fs.readFileSync(path.join(dir, staged[0].name), 'utf8'));
|
|
864
|
+
decryptForFallback(probe, newPass);
|
|
865
|
+
}
|
|
866
|
+
catch (err) {
|
|
867
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
868
|
+
fs.renameSync(bakDir, dir);
|
|
869
|
+
if (!keyColocated && fs.existsSync(keyBak)) {
|
|
870
|
+
try {
|
|
871
|
+
fs.rmSync(keyPath, { force: true });
|
|
872
|
+
}
|
|
873
|
+
catch { /* may not exist */ }
|
|
874
|
+
fs.renameSync(keyBak, keyPath);
|
|
875
|
+
}
|
|
876
|
+
throw new Error(`Post-swap verification failed; rolled back to the old key. (${err.message})`);
|
|
877
|
+
}
|
|
878
|
+
// Committed. Drop the old ciphertext and old key — both hold the retired key.
|
|
879
|
+
fs.rmSync(bakDir, { recursive: true, force: true });
|
|
880
|
+
if (!keyColocated)
|
|
881
|
+
fs.rmSync(keyBak, { force: true });
|
|
882
|
+
cachedPassphrase = newPass;
|
|
883
|
+
report.committed = true;
|
|
884
|
+
return report;
|
|
885
|
+
}
|
|
349
886
|
/** Test-only: reset module state (file dir + cached passphrase). */
|
|
350
887
|
export function _resetFileStoreForTest(opts = {}) {
|
|
351
888
|
fileDirOverride = opts.fileDir ?? null;
|
|
@@ -361,4 +898,20 @@ export function _resetFileStoreForTest(opts = {}) {
|
|
|
361
898
|
}
|
|
362
899
|
cachedPassphrase = opts.passphrase ?? null;
|
|
363
900
|
warnedAutoPassphrase = false;
|
|
901
|
+
lockAcquireTimeoutMsOverride = null;
|
|
902
|
+
lockStaleMsOverride = null;
|
|
903
|
+
}
|
|
904
|
+
/** Test-only: shorten the store-lock acquire timeout so a contended-lock assertion
|
|
905
|
+
* fails fast instead of waiting out the 30s production budget. */
|
|
906
|
+
export function _setFileStoreLockTimeoutForTest(ms) {
|
|
907
|
+
lockAcquireTimeoutMsOverride = ms;
|
|
908
|
+
}
|
|
909
|
+
/** Test-only: shrink the store-lock stale window so a heartbeat/steal assertion runs
|
|
910
|
+
* in milliseconds instead of the 5s production window. */
|
|
911
|
+
export function _setFileStoreLockStaleMsForTest(ms) {
|
|
912
|
+
lockStaleMsOverride = ms;
|
|
913
|
+
}
|
|
914
|
+
/** Test-only: the cross-process store-lock target (sibling of the store dir). */
|
|
915
|
+
export function _fileStoreLockPathForTest() {
|
|
916
|
+
return fileStoreLockPath();
|
|
364
917
|
}
|