@phnx-labs/agents-cli 1.22.84 → 1.22.86
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 +96 -0
- package/README.md +80 -53
- package/dist/bootstrap.d.ts +7 -6
- package/dist/bootstrap.js +20 -27
- package/dist/cli/command-registry.js +5 -1
- package/dist/commands/accounts.d.ts +6 -13
- package/dist/commands/accounts.js +330 -143
- package/dist/commands/alias.js +7 -3
- package/dist/commands/apply.js +1 -1
- package/dist/commands/auth-mint.d.ts +7 -3
- package/dist/commands/auth-mint.js +21 -9
- package/dist/commands/auth.js +2 -3
- package/dist/commands/browser.js +10 -10
- package/dist/commands/daemon.d.ts +7 -3
- package/dist/commands/daemon.js +40 -25
- package/dist/commands/doctor.js +28 -2
- package/dist/commands/exec.js +87 -39
- package/dist/commands/fleet-capture.js +21 -2
- package/dist/commands/harness-wizard.js +2 -2
- package/dist/commands/lease.js +7 -12
- package/dist/commands/profiles.d.ts +2 -2
- package/dist/commands/profiles.js +20 -13
- package/dist/commands/repo.js +3 -3
- package/dist/commands/run-account-picker.d.ts +3 -3
- package/dist/commands/run-account-picker.js +29 -30
- package/dist/commands/secrets-passthrough.d.ts +20 -0
- package/dist/commands/secrets-passthrough.js +43 -0
- package/dist/commands/setup-accounts.d.ts +3 -2
- package/dist/commands/setup-accounts.js +4 -4
- package/dist/commands/setup-secrets.d.ts +16 -21
- package/dist/commands/setup-secrets.js +43 -224
- package/dist/commands/ssh.js +2 -2
- package/dist/commands/sync.js +1 -1
- package/dist/commands/update.d.ts +10 -0
- package/dist/commands/update.js +43 -40
- package/dist/commands/versions.d.ts +11 -11
- package/dist/commands/versions.js +131 -83
- package/dist/commands/view.d.ts +1 -9
- package/dist/commands/view.js +41 -55
- package/dist/commands/webhook.js +5 -5
- package/dist/commands/workflows.js +2 -1
- package/dist/index.d.ts +12 -12
- package/dist/index.js +13 -42
- package/dist/lib/account-capabilities.d.ts +1 -1
- package/dist/lib/account-capabilities.js +1 -1
- package/dist/lib/account-catalog.d.ts +121 -6
- package/dist/lib/account-catalog.js +421 -7
- package/dist/lib/account-registry.d.ts +8 -30
- package/dist/lib/account-registry.js +36 -79
- package/dist/lib/account-schema.d.ts +1 -1
- package/dist/lib/account-schema.js +1 -1
- package/dist/lib/accounting/account-pool-collect.js +16 -4
- package/dist/lib/accounting/rotate.d.ts +20 -7
- package/dist/lib/accounting/rotate.js +89 -13
- package/dist/lib/accounting/usage.js +11 -16
- package/dist/lib/accounts/add.d.ts +138 -0
- package/dist/lib/accounts/add.js +651 -0
- package/dist/lib/accounts/migrate.d.ts +117 -0
- package/dist/lib/accounts/migrate.js +536 -0
- package/dist/lib/accounts/slots.d.ts +13 -0
- package/dist/lib/accounts/slots.js +100 -0
- package/dist/lib/agent-spec/agents.d.ts +16 -0
- package/dist/lib/agent-spec/agents.js +38 -4
- package/dist/lib/app-bundle-install.js +5 -4
- package/dist/lib/auth-health.d.ts +3 -0
- package/dist/lib/auth-health.js +11 -0
- package/dist/lib/auth-mint.d.ts +52 -16
- package/dist/lib/auth-mint.js +117 -39
- package/dist/lib/browser/chrome.d.ts +1 -1
- package/dist/lib/browser/chrome.js +5 -5
- package/dist/lib/byok-usage.js +3 -3
- package/dist/lib/claude-account-token.d.ts +47 -3
- package/dist/lib/claude-account-token.js +172 -50
- package/dist/lib/cloud/antigravity.js +4 -4
- package/dist/lib/cloud/cursor.js +4 -4
- package/dist/lib/crabbox/cli.d.ts +1 -1
- package/dist/lib/crabbox/cli.js +6 -6
- package/dist/lib/crabbox/runtimes.d.ts +3 -3
- package/dist/lib/crabbox/runtimes.js +5 -5
- package/dist/lib/daemon/account-state-daemon-service.d.ts +37 -1
- package/dist/lib/daemon/account-state-daemon-service.js +199 -3
- package/dist/lib/daemon/auth-sync-service.js +32 -1
- package/dist/lib/daemon/daemon.d.ts +6 -16
- package/dist/lib/daemon/daemon.js +52 -97
- package/dist/lib/daemon/daemon.test-fixture.d.ts +7 -9
- package/dist/lib/daemon/daemon.test-fixture.js +22 -40
- package/dist/lib/daemon/harness-update-service.d.ts +1 -1
- package/dist/lib/daemon/harness-update-service.js +1 -1
- package/dist/lib/daemon/runner.d.ts +15 -3
- package/dist/lib/daemon/runner.js +49 -24
- package/dist/lib/daemon-health.d.ts +1 -2
- package/dist/lib/daemon-health.js +7 -6
- package/dist/lib/daemon-services.d.ts +1 -1
- package/dist/lib/daemon-services.js +1 -11
- package/dist/lib/daemon-webhooks.d.ts +8 -7
- package/dist/lib/daemon-webhooks.js +10 -9
- package/dist/lib/device-config.js +1 -1
- package/dist/lib/devices/doctor-findings.d.ts +1 -1
- package/dist/lib/devices/harness-inventory.d.ts +39 -0
- package/dist/lib/devices/harness-inventory.js +126 -4
- package/dist/lib/doctor-diff.js +2 -1
- package/dist/lib/exec-account-home.d.ts +38 -0
- package/dist/lib/exec-account-home.js +164 -0
- package/dist/lib/exec.d.ts +30 -0
- package/dist/lib/exec.js +101 -31
- package/dist/lib/fleet/apply.d.ts +2 -2
- package/dist/lib/fleet/apply.js +4 -4
- package/dist/lib/fleet/auth-sync.js +4 -3
- package/dist/lib/fleet-shared-repo-sync.js +6 -9
- package/dist/lib/harness/adapter.d.ts +7 -7
- package/dist/lib/harness/adapter.js +10 -6
- package/dist/lib/harness/adapters/grok.js +8 -3
- package/dist/lib/harness/adapters/muse.js +1 -1
- package/dist/lib/harness/adapters/opencode.js +12 -3
- package/dist/lib/harness-auth-capabilities.d.ts +61 -0
- package/dist/lib/harness-auth-capabilities.js +52 -0
- package/dist/lib/helper-versions.d.ts +6 -4
- package/dist/lib/helper-versions.js +5 -4
- package/dist/lib/hosts/credential-transport.d.ts +10 -0
- package/dist/lib/hosts/credential-transport.js +37 -0
- package/dist/lib/hosts/dispatch.d.ts +7 -0
- package/dist/lib/hosts/dispatch.js +13 -6
- package/dist/lib/identity/client.d.ts +3 -3
- package/dist/lib/identity/client.js +3 -3
- package/dist/lib/installations/index.d.ts +1 -1
- package/dist/lib/installations/index.js +1 -1
- package/dist/lib/installations/migrate.js +9 -0
- package/dist/lib/installations/resolve.js +1 -1
- package/dist/lib/installations/shims.d.ts +21 -4
- package/dist/lib/installations/shims.js +86 -5
- package/dist/lib/installations/store.d.ts +43 -0
- package/dist/lib/installations/store.js +75 -7
- package/dist/lib/installations/versions.js +1 -1
- package/dist/lib/menubar/install-menubar.d.ts +3 -3
- package/dist/lib/menubar/install-menubar.js +3 -3
- package/dist/lib/native-accounts.d.ts +35 -0
- package/dist/lib/native-accounts.js +41 -0
- package/dist/lib/net-close.d.ts +11 -0
- package/dist/lib/net-close.js +25 -0
- package/dist/lib/openclaw-keychain.js +27 -1
- package/dist/lib/profiles.js +22 -9
- package/dist/lib/project-resources.js +1 -1
- package/dist/lib/reserved-stores.d.ts +53 -0
- package/dist/lib/reserved-stores.js +120 -0
- package/dist/lib/secrets-client.d.ts +199 -0
- package/dist/lib/secrets-client.js +718 -0
- package/dist/lib/secrets-policy.d.ts +235 -0
- package/dist/lib/secrets-policy.js +505 -0
- package/dist/lib/secrets-types.d.ts +188 -0
- package/dist/lib/secrets-types.js +24 -0
- package/dist/lib/self-heal/checks/shims.js +5 -3
- package/dist/lib/service-manifest.js +3 -4
- package/dist/lib/session/db.d.ts +18 -0
- package/dist/lib/session/db.js +47 -14
- package/dist/lib/session/sync/config.js +2 -2
- package/dist/lib/sha256-asset.d.ts +11 -16
- package/dist/lib/sha256-asset.js +11 -16
- package/dist/lib/share/config.js +14 -5
- package/dist/lib/signin-badge.d.ts +26 -0
- package/dist/lib/signin-badge.js +42 -0
- package/dist/lib/staleness/detectors/workflows.js +3 -105
- package/dist/lib/staleness/writers/workflows.js +2 -1
- package/dist/lib/state.d.ts +7 -4
- package/dist/lib/state.js +18 -7
- package/dist/lib/sync-umbrella.js +2 -2
- package/dist/lib/teams/agents.js +1 -1
- package/dist/lib/types.d.ts +74 -33
- package/dist/lib/view-types.d.ts +6 -3
- package/dist/lib/workflows-registry.d.ts +71 -0
- package/dist/lib/workflows-registry.js +280 -0
- package/dist/lib/workflows.d.ts +21 -21
- package/dist/lib/workflows.js +12 -327
- package/package.json +2 -3
- package/scripts/postinstall.js +35 -30
- package/dist/commands/secrets-import.d.ts +0 -18
- package/dist/commands/secrets-import.js +0 -74
- package/dist/commands/secrets-migrate.d.ts +0 -25
- package/dist/commands/secrets-migrate.js +0 -334
- package/dist/commands/secrets-rotate-passphrase.d.ts +0 -17
- package/dist/commands/secrets-rotate-passphrase.js +0 -96
- package/dist/commands/secrets-sync.d.ts +0 -11
- package/dist/commands/secrets-sync.js +0 -153
- package/dist/commands/secrets-vault.d.ts +0 -10
- package/dist/commands/secrets-vault.js +0 -130
- package/dist/commands/secrets.d.ts +0 -212
- package/dist/commands/secrets.js +0 -3030
- package/dist/lib/accounts/connect.d.ts +0 -176
- package/dist/lib/accounts/connect.js +0 -453
- package/dist/lib/daemon/keychain-reap-service.d.ts +0 -17
- package/dist/lib/daemon/keychain-reap-service.js +0 -32
- package/dist/lib/daemon/secrets-broker-service.d.ts +0 -21
- package/dist/lib/daemon/secrets-broker-service.js +0 -51
- package/dist/lib/secrets/agent.d.ts +0 -358
- package/dist/lib/secrets/agent.js +0 -1291
- package/dist/lib/secrets/audit.d.ts +0 -46
- package/dist/lib/secrets/audit.js +0 -101
- package/dist/lib/secrets/bundles.d.ts +0 -290
- package/dist/lib/secrets/bundles.js +0 -1547
- package/dist/lib/secrets/download-keychain.d.ts +0 -47
- package/dist/lib/secrets/download-keychain.js +0 -70
- package/dist/lib/secrets/drivers/rush.d.ts +0 -14
- package/dist/lib/secrets/drivers/rush.js +0 -90
- package/dist/lib/secrets/fallback.d.ts +0 -48
- package/dist/lib/secrets/fallback.js +0 -48
- package/dist/lib/secrets/filestore.d.ts +0 -222
- package/dist/lib/secrets/filestore.js +0 -1099
- package/dist/lib/secrets/headless.d.ts +0 -43
- package/dist/lib/secrets/headless.js +0 -56
- package/dist/lib/secrets/icloud-import.d.ts +0 -79
- package/dist/lib/secrets/icloud-import.js +0 -206
- package/dist/lib/secrets/index.d.ts +0 -451
- package/dist/lib/secrets/index.js +0 -1568
- package/dist/lib/secrets/install-helper.d.ts +0 -72
- package/dist/lib/secrets/install-helper.js +0 -245
- package/dist/lib/secrets/lease.d.ts +0 -25
- package/dist/lib/secrets/lease.js +0 -44
- package/dist/lib/secrets/linux.d.ts +0 -77
- package/dist/lib/secrets/linux.js +0 -393
- package/dist/lib/secrets/list-filter.d.ts +0 -109
- package/dist/lib/secrets/list-filter.js +0 -261
- package/dist/lib/secrets/mcp.d.ts +0 -93
- package/dist/lib/secrets/mcp.js +0 -211
- package/dist/lib/secrets/profiles.d.ts +0 -10
- package/dist/lib/secrets/profiles.js +0 -13
- package/dist/lib/secrets/push.d.ts +0 -133
- package/dist/lib/secrets/push.js +0 -273
- package/dist/lib/secrets/rc-hygiene.d.ts +0 -63
- package/dist/lib/secrets/rc-hygiene.js +0 -143
- package/dist/lib/secrets/read-backoff.d.ts +0 -27
- package/dist/lib/secrets/read-backoff.js +0 -64
- package/dist/lib/secrets/reaper.d.ts +0 -90
- package/dist/lib/secrets/reaper.js +0 -243
- package/dist/lib/secrets/remote.d.ts +0 -152
- package/dist/lib/secrets/remote.js +0 -344
- package/dist/lib/secrets/reserved-sync.d.ts +0 -66
- package/dist/lib/secrets/reserved-sync.js +0 -147
- package/dist/lib/secrets/scope.d.ts +0 -26
- package/dist/lib/secrets/scope.js +0 -29
- package/dist/lib/secrets/session-store.d.ts +0 -107
- package/dist/lib/secrets/session-store.js +0 -342
- package/dist/lib/secrets/sync-backend.d.ts +0 -48
- package/dist/lib/secrets/sync-backend.js +0 -13
- package/dist/lib/secrets/sync-commands.d.ts +0 -21
- package/dist/lib/secrets/sync-commands.js +0 -21
- package/dist/lib/secrets/sync.d.ts +0 -48
- package/dist/lib/secrets/sync.js +0 -237
- package/dist/lib/secrets/unlock-hints.d.ts +0 -27
- package/dist/lib/secrets/unlock-hints.js +0 -36
- package/dist/lib/secrets/usage-db.d.ts +0 -46
- package/dist/lib/secrets/usage-db.js +0 -96
- package/dist/lib/secrets/vault-age-helper.d.ts +0 -1
- package/dist/lib/secrets/vault-age-helper.js +0 -34
- package/dist/lib/secrets/vault.d.ts +0 -49
- package/dist/lib/secrets/vault.js +0 -399
- package/dist/lib/secrets/windows.d.ts +0 -81
- package/dist/lib/secrets/windows.js +0 -556
- package/scripts/install-helper.js +0 -97
- /package/dist/lib/{secrets/sync-passphrase.d.ts → sync-passphrase.d.ts} +0 -0
- /package/dist/lib/{secrets/sync-passphrase.js → sync-passphrase.js} +0 -0
|
@@ -1,1099 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Passphrase-encrypted file store for secrets — platform-neutral.
|
|
3
|
-
*
|
|
4
|
-
* An AES-256-GCM encrypted-file store under `~/.agents/.cache/secrets/`. The
|
|
5
|
-
* encryption key is scrypt-derived from a passphrase read from
|
|
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
|
-
*
|
|
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.
|
|
13
|
-
* - Linux (src/lib/secrets/linux.ts): the headless fallback when the default
|
|
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).
|
|
19
|
-
*
|
|
20
|
-
* The item-name scheme is shared with the keychain backend so a file-backed
|
|
21
|
-
* item and its keychain twin carry identical names:
|
|
22
|
-
* `agents-cli.bundles.<name>` and `agents-cli.secrets.<bundle>.<key>`.
|
|
23
|
-
*/
|
|
24
|
-
import { createCipheriv, createDecipheriv, createHash, randomBytes, scryptSync } from 'crypto';
|
|
25
|
-
import * as fs from 'fs';
|
|
26
|
-
import * as os from 'os';
|
|
27
|
-
import * as path from 'path';
|
|
28
|
-
import { withFileLock, ensureLockTarget } from '../fs-atomic.js';
|
|
29
|
-
// ---------- file store location ----------
|
|
30
|
-
let fileDirOverride = null;
|
|
31
|
-
let passphraseDirOverride = null;
|
|
32
|
-
let cachedPassphrase = null;
|
|
33
|
-
let warnedAutoPassphrase = false;
|
|
34
|
-
export function fileDir() {
|
|
35
|
-
return fileDirOverride ?? path.join(os.homedir(), '.agents', '.cache', 'secrets');
|
|
36
|
-
}
|
|
37
|
-
function ensureFileDir() {
|
|
38
|
-
fs.mkdirSync(fileDir(), { recursive: true, mode: 0o700 });
|
|
39
|
-
}
|
|
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`;
|
|
60
|
-
}
|
|
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);
|
|
70
|
-
}
|
|
71
|
-
// ---------- passphrase ----------
|
|
72
|
-
/**
|
|
73
|
-
* Directory for the auto-provisioned machine-local passphrase. Kept outside
|
|
74
|
-
* `fileDir()` so a scan of the encrypted store never co-locates key + ciphertext.
|
|
75
|
-
*/
|
|
76
|
-
function passphraseDir() {
|
|
77
|
-
return passphraseDirOverride ?? path.join(os.homedir(), '.agents', '.secrets-key');
|
|
78
|
-
}
|
|
79
|
-
function ensurePassphraseDir() {
|
|
80
|
-
fs.mkdirSync(passphraseDir(), { recursive: true, mode: 0o700 });
|
|
81
|
-
}
|
|
82
|
-
/** Path of the auto-provisioned machine-local passphrase (not an `.enc` item). */
|
|
83
|
-
function passphraseFilePath() {
|
|
84
|
-
return path.join(passphraseDir(), 'passphrase');
|
|
85
|
-
}
|
|
86
|
-
/** Legacy co-located path — read-only for machines provisioned before #479. */
|
|
87
|
-
function legacyPassphraseFilePath() {
|
|
88
|
-
return path.join(fileDir(), '.passphrase');
|
|
89
|
-
}
|
|
90
|
-
/** True if a machine-local passphrase has already been provisioned. */
|
|
91
|
-
export function machinePassphraseExists() {
|
|
92
|
-
return readMachinePassphrase() !== null;
|
|
93
|
-
}
|
|
94
|
-
function readMachinePassphrase() {
|
|
95
|
-
for (const fp of [passphraseFilePath(), legacyPassphraseFilePath()]) {
|
|
96
|
-
try {
|
|
97
|
-
const p = fs.readFileSync(fp, 'utf8').trim();
|
|
98
|
-
if (p.length > 0)
|
|
99
|
-
return p;
|
|
100
|
-
}
|
|
101
|
-
catch {
|
|
102
|
-
// try next location
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
return null;
|
|
106
|
-
}
|
|
107
|
-
/**
|
|
108
|
-
* Provision (or read back) a stable machine-local passphrase for the encrypted
|
|
109
|
-
* file store, so `agents secrets` works out of the box on a headless box where
|
|
110
|
-
* the keyring is locked and no AGENTS_SECRETS_PASSPHRASE is set.
|
|
111
|
-
*
|
|
112
|
-
* Security model: this is encryption-at-rest with the key held in a 0600 file —
|
|
113
|
-
* the same posture as an SSH private key. It is NOT equivalent to the common
|
|
114
|
-
* "export AGENTS_SECRETS_PASSPHRASE=… in ~/.zshenv (chmod 600)" workaround, and
|
|
115
|
-
* is strictly safer: this file is read by the one process that needs it, while
|
|
116
|
-
* a shell-rc export is inherited by every process the login shell spawns and is
|
|
117
|
-
* readable from /proc/<pid>/environ by any same-user process (RUSH-1968; see
|
|
118
|
-
* rc-hygiene.ts). The keyring (key in a daemon's locked memory) is stronger
|
|
119
|
-
* still but is unavailable without a graphical/unlocked session.
|
|
120
|
-
*/
|
|
121
|
-
function provisionMachinePassphrase() {
|
|
122
|
-
const existing = readMachinePassphrase();
|
|
123
|
-
if (existing)
|
|
124
|
-
return existing;
|
|
125
|
-
ensurePassphraseDir();
|
|
126
|
-
const generated = randomBytes(32).toString('base64');
|
|
127
|
-
const fp = passphraseFilePath();
|
|
128
|
-
try {
|
|
129
|
-
// wx: fail if a concurrent process created it first (then we read theirs).
|
|
130
|
-
fs.writeFileSync(fp, generated, { mode: 0o600, flag: 'wx' });
|
|
131
|
-
}
|
|
132
|
-
catch {
|
|
133
|
-
const raced = readMachinePassphrase();
|
|
134
|
-
if (raced)
|
|
135
|
-
return raced;
|
|
136
|
-
throw new Error(`Failed to provision machine-local passphrase at ${fp}.`);
|
|
137
|
-
}
|
|
138
|
-
if (!warnedAutoPassphrase) {
|
|
139
|
-
warnedAutoPassphrase = true;
|
|
140
|
-
process.stderr.write(`[agents] keyring locked and no AGENTS_SECRETS_PASSPHRASE set; provisioned a ` +
|
|
141
|
-
`machine-local passphrase at ${fp} (mode 0600). Set AGENTS_SECRETS_PASSPHRASE ` +
|
|
142
|
-
`for a key held off disk.\n`);
|
|
143
|
-
}
|
|
144
|
-
return generated;
|
|
145
|
-
}
|
|
146
|
-
/**
|
|
147
|
-
* Resolve the passphrase for the encrypted file store.
|
|
148
|
-
*
|
|
149
|
-
* Order: AGENTS_SECRETS_PASSPHRASE > previously-provisioned machine-local key >
|
|
150
|
-
* legacy co-located key > a freshly auto-provisioned machine-local key. It NEVER
|
|
151
|
-
* prompts, and never fails for WANT of a passphrase — the file store must work on
|
|
152
|
-
* every platform (macOS included) without the user setting, typing, or
|
|
153
|
-
* remembering one. (It can still throw if provisioning cannot write the key file
|
|
154
|
-
* at all; that is a disk/permissions failure, not a missing passphrase.) Provisioning
|
|
155
|
-
* writes a 0600 key file (encryption-at-rest, same posture as an SSH key); set
|
|
156
|
-
* AGENTS_SECRETS_PASSPHRASE to opt into an off-disk key.
|
|
157
|
-
*/
|
|
158
|
-
export function getPassphrase() {
|
|
159
|
-
if (cachedPassphrase !== null)
|
|
160
|
-
return cachedPassphrase;
|
|
161
|
-
const env = process.env.AGENTS_SECRETS_PASSPHRASE;
|
|
162
|
-
if (env && env.length > 0) {
|
|
163
|
-
cachedPassphrase = env;
|
|
164
|
-
return env;
|
|
165
|
-
}
|
|
166
|
-
// A previously-provisioned machine-local passphrase is this machine's stable
|
|
167
|
-
// file-store key — prefer it so interactive and headless runs always agree.
|
|
168
|
-
const onDisk = readMachinePassphrase();
|
|
169
|
-
if (onDisk) {
|
|
170
|
-
cachedPassphrase = onDisk;
|
|
171
|
-
return onDisk;
|
|
172
|
-
}
|
|
173
|
-
// No env passphrase and no machine-local key yet: silently provision a stable
|
|
174
|
-
// machine-local key (a 0600 file) on EVERY platform, macOS included. This is
|
|
175
|
-
// encryption-at-rest with an on-disk key — the same posture as an SSH private
|
|
176
|
-
// key — so the file store "just works" without the user ever setting, typing,
|
|
177
|
-
// or remembering a passphrase, and never pops a prompt or a Touch ID sheet.
|
|
178
|
-
// Set AGENTS_SECRETS_PASSPHRASE to opt into an off-disk key instead.
|
|
179
|
-
cachedPassphrase = provisionMachinePassphrase();
|
|
180
|
-
return cachedPassphrase;
|
|
181
|
-
}
|
|
182
|
-
function deriveKey(passphrase, salt) {
|
|
183
|
-
return scryptSync(passphrase, salt, 32);
|
|
184
|
-
}
|
|
185
|
-
/** Encrypt plaintext under a passphrase using AES-256-GCM with a random
|
|
186
|
-
* scrypt salt and a random 96-bit IV. Exported for tests. */
|
|
187
|
-
export function encryptForFallback(plaintext, passphrase) {
|
|
188
|
-
const salt = randomBytes(16);
|
|
189
|
-
const iv = randomBytes(12);
|
|
190
|
-
const key = deriveKey(passphrase, salt);
|
|
191
|
-
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
|
192
|
-
const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
|
193
|
-
return {
|
|
194
|
-
salt: salt.toString('hex'),
|
|
195
|
-
iv: iv.toString('hex'),
|
|
196
|
-
authTag: cipher.getAuthTag().toString('hex'),
|
|
197
|
-
ciphertext: ciphertext.toString('hex'),
|
|
198
|
-
};
|
|
199
|
-
}
|
|
200
|
-
/** Decrypt an EncFile under a passphrase. Throws on wrong key or tampered
|
|
201
|
-
* ciphertext (auth-tag mismatch). Exported for tests. */
|
|
202
|
-
export function decryptForFallback(enc, passphrase) {
|
|
203
|
-
const salt = Buffer.from(enc.salt, 'hex');
|
|
204
|
-
const iv = Buffer.from(enc.iv, 'hex');
|
|
205
|
-
const authTag = Buffer.from(enc.authTag, 'hex');
|
|
206
|
-
const ciphertext = Buffer.from(enc.ciphertext, 'hex');
|
|
207
|
-
const key = deriveKey(passphrase, salt);
|
|
208
|
-
const decipher = createDecipheriv('aes-256-gcm', key, iv);
|
|
209
|
-
decipher.setAuthTag(authTag);
|
|
210
|
-
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
211
|
-
return plaintext.toString('utf8');
|
|
212
|
-
}
|
|
213
|
-
// ---------- bundle-metadata plaintext cache (PHNX-3585) ----------
|
|
214
|
-
//
|
|
215
|
-
// Enumerating file-backed bundles — `agents secrets list`, and the
|
|
216
|
-
// account-rotation `readAccountRegistry` on the `agents run` hot path — decrypts
|
|
217
|
-
// EVERY bundle's metadata item, and each decrypt runs a fresh scrypt KDF (~12ms)
|
|
218
|
-
// because every `.enc` file carries its own random salt. On a box with dozens of
|
|
219
|
-
// bundles that is hundreds of ms of pure KDF before the harness even starts (the
|
|
220
|
-
// ~0.55s New-Claude boot cost in PHNX-3585).
|
|
221
|
-
//
|
|
222
|
-
// Bundle METADATA is non-secret by contract (secrets/bundles.ts `writeBundle`:
|
|
223
|
-
// "Metadata is non-sensitive by contract", stored no-ACL so `secrets list`
|
|
224
|
-
// enumerates without Touch ID) — the vars hold `keychain:`/`env:`/literal REFS,
|
|
225
|
-
// never the secret bytes, which live in separate `agents-cli.secrets.<bundle>.*`
|
|
226
|
-
// value items. So its decrypted JSON is safe to cache in cleartext, at the SAME
|
|
227
|
-
// 0600 protection as the ciphertext beside it.
|
|
228
|
-
//
|
|
229
|
-
// The cache keys each entry on the `.enc` file's (mtimeMs,size) plus a
|
|
230
|
-
// passphrase fingerprint, so ANY bundle write (new mtime/size) or passphrase
|
|
231
|
-
// change (new fingerprint; rotation also re-writes every file) misses and
|
|
232
|
-
// re-derives — no staleness, self-healing on any miss. It is scoped to the
|
|
233
|
-
// metadata namespace ONLY (`agents-cli.bundles.`); secret VALUE items are never
|
|
234
|
-
// read through it, so no secret is ever written to the cache.
|
|
235
|
-
const META_ITEM_PREFIX = 'agents-cli.bundles.';
|
|
236
|
-
function isMetaItem(item) {
|
|
237
|
-
return item.startsWith(META_ITEM_PREFIX);
|
|
238
|
-
}
|
|
239
|
-
let metaCache = null;
|
|
240
|
-
let metaCacheDirty = false;
|
|
241
|
-
let metaCacheFlushRegistered = false;
|
|
242
|
-
function metaCachePath() {
|
|
243
|
-
// A SIBLING of the store dir, never inside it — the same rule the store lock
|
|
244
|
-
// above follows, so a rotation's store-dir rename / `.rotate-*` sweep never
|
|
245
|
-
// moves or reaps it. Under `~/.agents/.cache/`, so it is machine-local and
|
|
246
|
-
// never enters `agents repo push/pull`.
|
|
247
|
-
return `${fileDir()}.meta-cache.json`;
|
|
248
|
-
}
|
|
249
|
-
function passphraseFingerprint() {
|
|
250
|
-
// Non-reversible tag, never the passphrase itself — so a passphrase change
|
|
251
|
-
// invalidates the cache without the key ever landing on disk in the clear.
|
|
252
|
-
return createHash('sha256').update(getPassphrase()).digest('hex').slice(0, 16);
|
|
253
|
-
}
|
|
254
|
-
function loadMetaCache() {
|
|
255
|
-
if (metaCache)
|
|
256
|
-
return metaCache;
|
|
257
|
-
const keyFp = passphraseFingerprint();
|
|
258
|
-
try {
|
|
259
|
-
const doc = JSON.parse(fs.readFileSync(metaCachePath(), 'utf8'));
|
|
260
|
-
if (doc && doc.v === 1 && doc.keyFp === keyFp && doc.entries && typeof doc.entries === 'object') {
|
|
261
|
-
metaCache = doc;
|
|
262
|
-
return metaCache;
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
catch {
|
|
266
|
-
/* absent or corrupt — rebuild from scratch */
|
|
267
|
-
}
|
|
268
|
-
metaCache = { v: 1, keyFp, entries: {} };
|
|
269
|
-
return metaCache;
|
|
270
|
-
}
|
|
271
|
-
/** Decrypted metadata for `item` if the cache holds a fresh entry, else null. */
|
|
272
|
-
function metaCacheGet(item, fp) {
|
|
273
|
-
let st;
|
|
274
|
-
try {
|
|
275
|
-
st = fs.statSync(fp);
|
|
276
|
-
}
|
|
277
|
-
catch {
|
|
278
|
-
return null;
|
|
279
|
-
}
|
|
280
|
-
const e = loadMetaCache().entries[item];
|
|
281
|
-
return e && e.mtimeMs === st.mtimeMs && e.size === st.size ? e.plain : null;
|
|
282
|
-
}
|
|
283
|
-
/** Record freshly-decrypted metadata for `item`, flushed once at process exit.
|
|
284
|
-
* `snapshot` is the pre-readFileSync stat — the identity of the bytes just
|
|
285
|
-
* decrypted. Skip the put if a post-decrypt stat does not match: a concurrent
|
|
286
|
-
* writer during scrypt must not pin that plaintext to the new file identity. */
|
|
287
|
-
function metaCachePut(item, fp, plain, snapshot) {
|
|
288
|
-
try {
|
|
289
|
-
const now = fs.statSync(fp);
|
|
290
|
-
if (now.mtimeMs !== snapshot.mtimeMs || now.size !== snapshot.size)
|
|
291
|
-
return;
|
|
292
|
-
}
|
|
293
|
-
catch {
|
|
294
|
-
return;
|
|
295
|
-
}
|
|
296
|
-
loadMetaCache().entries[item] = { mtimeMs: snapshot.mtimeMs, size: snapshot.size, plain };
|
|
297
|
-
metaCacheDirty = true;
|
|
298
|
-
if (!metaCacheFlushRegistered) {
|
|
299
|
-
metaCacheFlushRegistered = true;
|
|
300
|
-
process.on('exit', flushMetaCache);
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
/** Drop a cached entry the moment its file is rewritten or removed, so a
|
|
304
|
-
* same-process write→read never returns pre-write metadata (mtime/size alone
|
|
305
|
-
* can collide within a millisecond on an unchanged-length rewrite). */
|
|
306
|
-
function metaCacheEvict(item) {
|
|
307
|
-
if (!isMetaItem(item))
|
|
308
|
-
return;
|
|
309
|
-
const cache = metaCache;
|
|
310
|
-
if (cache && cache.entries[item]) {
|
|
311
|
-
delete cache.entries[item];
|
|
312
|
-
metaCacheDirty = true;
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
function flushMetaCache() {
|
|
316
|
-
if (!metaCacheDirty || !metaCache)
|
|
317
|
-
return;
|
|
318
|
-
metaCacheDirty = false;
|
|
319
|
-
// Prune entries whose `.enc` file is gone so churn can't grow the cache without
|
|
320
|
-
// bound; a surviving stale entry would re-validate against mtime/size anyway.
|
|
321
|
-
for (const item of Object.keys(metaCache.entries)) {
|
|
322
|
-
if (!fs.existsSync(fileFor(item)))
|
|
323
|
-
delete metaCache.entries[item];
|
|
324
|
-
}
|
|
325
|
-
try {
|
|
326
|
-
ensureFileDir();
|
|
327
|
-
const tmp = `${metaCachePath()}.${process.pid}.tmp`;
|
|
328
|
-
fs.writeFileSync(tmp, JSON.stringify(metaCache), { mode: 0o600 });
|
|
329
|
-
fs.renameSync(tmp, metaCachePath());
|
|
330
|
-
}
|
|
331
|
-
catch {
|
|
332
|
-
/* the cache is advisory — a write failure just re-derives next run */
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
// ---------- file backend ----------
|
|
336
|
-
function fileFor(item) {
|
|
337
|
-
return path.join(fileDir(), `${item}.enc`);
|
|
338
|
-
}
|
|
339
|
-
/** Absolute path of one encrypted file-store item. No read or decrypt occurs. */
|
|
340
|
-
export function fileStoreItemPath(item) {
|
|
341
|
-
return fileFor(item);
|
|
342
|
-
}
|
|
343
|
-
function fileHas(item) {
|
|
344
|
-
return fs.existsSync(fileFor(item));
|
|
345
|
-
}
|
|
346
|
-
function fileGet(item) {
|
|
347
|
-
const fp = fileFor(item);
|
|
348
|
-
if (!fs.existsSync(fp)) {
|
|
349
|
-
throw new Error(`Secret '${item}' not found in encrypted store.`);
|
|
350
|
-
}
|
|
351
|
-
// Non-secret metadata short-circuits the scrypt decrypt when the file is
|
|
352
|
-
// unchanged (see the cache block above). Secret VALUE items skip the cache
|
|
353
|
-
// entirely, so they always decrypt fresh and never touch cleartext storage.
|
|
354
|
-
const metaItem = isMetaItem(item);
|
|
355
|
-
if (metaItem) {
|
|
356
|
-
const hit = metaCacheGet(item, fp);
|
|
357
|
-
if (hit !== null)
|
|
358
|
-
return hit;
|
|
359
|
-
}
|
|
360
|
-
// Identity of the bytes we are about to decrypt. A post-decrypt stat would
|
|
361
|
-
// attribute this plaintext to a concurrent writer's new file.
|
|
362
|
-
let snapshot;
|
|
363
|
-
if (metaItem) {
|
|
364
|
-
try {
|
|
365
|
-
snapshot = fs.statSync(fp);
|
|
366
|
-
}
|
|
367
|
-
catch {
|
|
368
|
-
snapshot = undefined;
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
const raw = fs.readFileSync(fp, 'utf8');
|
|
372
|
-
let parsed;
|
|
373
|
-
try {
|
|
374
|
-
parsed = JSON.parse(raw);
|
|
375
|
-
}
|
|
376
|
-
catch {
|
|
377
|
-
throw new Error(`Encrypted secret file ${fp} is corrupt (not valid JSON).`);
|
|
378
|
-
}
|
|
379
|
-
let plain;
|
|
380
|
-
try {
|
|
381
|
-
plain = decryptForFallback(parsed, getPassphrase());
|
|
382
|
-
}
|
|
383
|
-
catch {
|
|
384
|
-
throw new Error(`Failed to decrypt '${item}'. Wrong AGENTS_SECRETS_PASSPHRASE or tampered file.`);
|
|
385
|
-
}
|
|
386
|
-
if (metaItem && snapshot)
|
|
387
|
-
metaCachePut(item, fp, plain, snapshot);
|
|
388
|
-
return plain;
|
|
389
|
-
}
|
|
390
|
-
function fileGetBatch(items) {
|
|
391
|
-
const out = new Map();
|
|
392
|
-
for (const item of items) {
|
|
393
|
-
// A missing file is an absent item. Any error reading an existing file,
|
|
394
|
-
// especially an AES-GCM authentication failure, is a broken store and must
|
|
395
|
-
// stop the caller rather than silently produce an incomplete environment.
|
|
396
|
-
if (!fileHas(item))
|
|
397
|
-
continue;
|
|
398
|
-
out.set(item, fileGet(item));
|
|
399
|
-
}
|
|
400
|
-
return out;
|
|
401
|
-
}
|
|
402
|
-
function fileSet(item, value) {
|
|
403
|
-
ensureFileDir();
|
|
404
|
-
// Under the store lock: a write must not interleave with a rotation's swap.
|
|
405
|
-
// `getPassphrase()` takes no options since the auto-provision requirement was
|
|
406
|
-
// dropped (#1658) — provisioning is now unconditional.
|
|
407
|
-
withStoreLock(() => {
|
|
408
|
-
const enc = encryptForFallback(value, getPassphrase());
|
|
409
|
-
fs.writeFileSync(fileFor(item), JSON.stringify(enc), { mode: 0o600 });
|
|
410
|
-
});
|
|
411
|
-
metaCacheEvict(item);
|
|
412
|
-
}
|
|
413
|
-
function fileDelete(item) {
|
|
414
|
-
const fp = fileFor(item);
|
|
415
|
-
if (!fs.existsSync(fp))
|
|
416
|
-
return true; // idempotent, matches secret-tool clear
|
|
417
|
-
const deleted = withStoreLock(() => {
|
|
418
|
-
// Re-check under the lock — a rotation may have swapped the dir since the
|
|
419
|
-
// pre-lock existence probe above.
|
|
420
|
-
if (!fs.existsSync(fp))
|
|
421
|
-
return true;
|
|
422
|
-
fs.unlinkSync(fp);
|
|
423
|
-
return true;
|
|
424
|
-
});
|
|
425
|
-
metaCacheEvict(item);
|
|
426
|
-
return deleted;
|
|
427
|
-
}
|
|
428
|
-
function fileList(prefix) {
|
|
429
|
-
const dir = fileDir();
|
|
430
|
-
if (!fs.existsSync(dir))
|
|
431
|
-
return [];
|
|
432
|
-
return fs.readdirSync(dir)
|
|
433
|
-
.filter((f) => f.endsWith('.enc'))
|
|
434
|
-
.map((f) => f.slice(0, -'.enc'.length))
|
|
435
|
-
.filter((name) => name.startsWith(prefix));
|
|
436
|
-
}
|
|
437
|
-
/** True if the fallback dir has any committed encrypted items. */
|
|
438
|
-
export function fileStoreHasItems() {
|
|
439
|
-
try {
|
|
440
|
-
return fs.readdirSync(fileDir()).some((e) => e.endsWith('.enc'));
|
|
441
|
-
}
|
|
442
|
-
catch {
|
|
443
|
-
return false;
|
|
444
|
-
}
|
|
445
|
-
}
|
|
446
|
-
/** Low-level file-store ops, exported so callers (linux fallback, macOS
|
|
447
|
-
* file-backed bundles) can opt into or out of passphrase auto-provision. */
|
|
448
|
-
export const fileStore = {
|
|
449
|
-
has: fileHas,
|
|
450
|
-
get: fileGet,
|
|
451
|
-
getBatch: fileGetBatch,
|
|
452
|
-
set: fileSet,
|
|
453
|
-
delete: fileDelete,
|
|
454
|
-
list: fileList,
|
|
455
|
-
};
|
|
456
|
-
/** File-only KeychainBackend (exported for tests; the Linux backend uses these
|
|
457
|
-
* ops with auto-provision allowed). */
|
|
458
|
-
export const fileBackend = {
|
|
459
|
-
has: fileHas,
|
|
460
|
-
get: (item) => fileGet(item),
|
|
461
|
-
set: (item, value) => fileSet(item, value),
|
|
462
|
-
delete: fileDelete,
|
|
463
|
-
list: fileList,
|
|
464
|
-
};
|
|
465
|
-
/** Resolved passphrase directory (exported for integration tests). */
|
|
466
|
-
export function resolvePassphraseDir() {
|
|
467
|
-
return passphraseDir();
|
|
468
|
-
}
|
|
469
|
-
// ---------- passphrase rotation (RUSH-1975) ----------
|
|
470
|
-
/**
|
|
471
|
-
* Path of the machine-local passphrase file that currently holds the file-store
|
|
472
|
-
* key, or null if none is provisioned. Prefers the canonical #479 location and
|
|
473
|
-
* falls back to the legacy co-located path, mirroring `readMachinePassphrase`.
|
|
474
|
-
*/
|
|
475
|
-
export function machinePassphraseSourcePath() {
|
|
476
|
-
for (const fp of [passphraseFilePath(), legacyPassphraseFilePath()]) {
|
|
477
|
-
try {
|
|
478
|
-
if (fs.readFileSync(fp, 'utf8').trim().length > 0)
|
|
479
|
-
return fp;
|
|
480
|
-
}
|
|
481
|
-
catch {
|
|
482
|
-
// try next location
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
return null;
|
|
486
|
-
}
|
|
487
|
-
/**
|
|
488
|
-
* Resolve the key path for a rotation that crashed mid-key-swap and left the live
|
|
489
|
-
* key file absent (Window B: `keyPath` moved to `<key>.rotate-oldkey`, the new key
|
|
490
|
-
* not yet landed). `machinePassphraseSourcePath` returns null in that state because
|
|
491
|
-
* neither canonical nor legacy file has content, so recovery would never run. If a
|
|
492
|
-
* rotation artifact (`.rotate-new` / `.rotate-oldkey`) exists for a canonical key
|
|
493
|
-
* path, that path is the interrupted rotation's target — return it so recovery can
|
|
494
|
-
* finish forward. Null when no such artifact is present.
|
|
495
|
-
*/
|
|
496
|
-
function resolveInterruptedKeyPath() {
|
|
497
|
-
for (const fp of [passphraseFilePath(), legacyPassphraseFilePath()]) {
|
|
498
|
-
if (fs.existsSync(`${fp}.rotate-new`) || fs.existsSync(`${fp}.rotate-oldkey`))
|
|
499
|
-
return fp;
|
|
500
|
-
}
|
|
501
|
-
// Co-located layout: the key lives inside the store dir and travels with it in a
|
|
502
|
-
// single rename, so no `.rotate-new`/`.rotate-oldkey` key artifacts are ever
|
|
503
|
-
// written. A crash in that rename leaves the store dir absent with its old store +
|
|
504
|
-
// co-located key sitting in the `<dir>.rotate-old-*` backup, and the canonical/
|
|
505
|
-
// legacy key files both gone — so the checks above return null and recovery would
|
|
506
|
-
// never run. If a backup holding a co-located `.passphrase` is present, the
|
|
507
|
-
// interrupted rotation's key target is that legacy co-located path; return it so
|
|
508
|
-
// recovery restores the backup (old store + old key) and heals the store.
|
|
509
|
-
const dir = fileDir();
|
|
510
|
-
if (!fs.existsSync(dir)) {
|
|
511
|
-
const parent = path.dirname(dir);
|
|
512
|
-
const base = path.basename(dir);
|
|
513
|
-
let entries;
|
|
514
|
-
try {
|
|
515
|
-
entries = fs.readdirSync(parent);
|
|
516
|
-
}
|
|
517
|
-
catch {
|
|
518
|
-
return null;
|
|
519
|
-
}
|
|
520
|
-
const bak = entries.find((e) => e.startsWith(`${base}.rotate-old-`));
|
|
521
|
-
if (bak && fs.existsSync(path.join(parent, bak, '.passphrase'))) {
|
|
522
|
-
return legacyPassphraseFilePath();
|
|
523
|
-
}
|
|
524
|
-
}
|
|
525
|
-
return null;
|
|
526
|
-
}
|
|
527
|
-
/** Flush a file's data to disk (durability before the atomic swap). */
|
|
528
|
-
function writeFileFsync(fp, data, mode) {
|
|
529
|
-
const fd = fs.openSync(fp, 'w', mode);
|
|
530
|
-
try {
|
|
531
|
-
// Narrow to one of fs.writeSync's overloads: the string form encodes as UTF-8,
|
|
532
|
-
// the Buffer form writes raw bytes verbatim (binary-safe copy-through).
|
|
533
|
-
if (typeof data === 'string')
|
|
534
|
-
fs.writeSync(fd, data);
|
|
535
|
-
else
|
|
536
|
-
fs.writeSync(fd, data);
|
|
537
|
-
fs.fsyncSync(fd);
|
|
538
|
-
}
|
|
539
|
-
finally {
|
|
540
|
-
fs.closeSync(fd);
|
|
541
|
-
}
|
|
542
|
-
// A freshly-created file needs its mode set explicitly — the open() mode is
|
|
543
|
-
// masked by the process umask, so 0600 is not guaranteed by the flag alone.
|
|
544
|
-
try {
|
|
545
|
-
fs.chmodSync(fp, mode);
|
|
546
|
-
}
|
|
547
|
-
catch { /* best effort on platforms without chmod */ }
|
|
548
|
-
}
|
|
549
|
-
/** fsync a directory so a rename/create in it is durable. Best-effort: some
|
|
550
|
-
* filesystems reject O_RDONLY fsync on a directory. */
|
|
551
|
-
function fsyncDir(dir) {
|
|
552
|
-
let fd = null;
|
|
553
|
-
try {
|
|
554
|
-
fd = fs.openSync(dir, 'r');
|
|
555
|
-
fs.fsyncSync(fd);
|
|
556
|
-
}
|
|
557
|
-
catch {
|
|
558
|
-
// filesystem doesn't support directory fsync — the rename is still ordered
|
|
559
|
-
}
|
|
560
|
-
finally {
|
|
561
|
-
if (fd !== null)
|
|
562
|
-
fs.closeSync(fd);
|
|
563
|
-
}
|
|
564
|
-
}
|
|
565
|
-
/** True if `enc` decrypts (auth-tag verifies) under `keyVal`. */
|
|
566
|
-
function opensUnder(enc, keyVal) {
|
|
567
|
-
try {
|
|
568
|
-
decryptForFallback(enc, keyVal);
|
|
569
|
-
return true;
|
|
570
|
-
}
|
|
571
|
-
catch {
|
|
572
|
-
return false;
|
|
573
|
-
}
|
|
574
|
-
}
|
|
575
|
-
/**
|
|
576
|
-
* Classify how the `.enc` items in `dir` relate to `keyVal`, so recovery can tell
|
|
577
|
-
* a single-key store (safe to sweep) apart from a MIXED store (live data sealed
|
|
578
|
-
* under two keys at once — unsafe). `candidateKeys` is every key a mid-rotation
|
|
579
|
-
* crash could have left on disk: the live key file, `<key>.rotate-new` (the
|
|
580
|
-
* incoming key), and `<key>.rotate-oldkey` (the retired key). An item that opens
|
|
581
|
-
* under NONE of them is a genuine orphan — sealed under a third key and carried
|
|
582
|
-
* through a rotation verbatim — and is ignored: it neither proves nor disproves
|
|
583
|
-
* consistency. Among the remaining, non-orphan items:
|
|
584
|
-
* 'all' — every one opens under `keyVal` → `keyVal` is the store's one key
|
|
585
|
-
* 'some' — at least one opens under `keyVal` AND at least one does not → MIXED
|
|
586
|
-
* 'none' — none open under `keyVal`
|
|
587
|
-
*
|
|
588
|
-
* Only 'all' is safe to sweep against. The original "any one item opens" heuristic
|
|
589
|
-
* returned true for a mixed store — so a single stray item sealed under the live
|
|
590
|
-
* key (an interstitial `secrets set` after a mid-swap crash) made recovery sweep
|
|
591
|
-
* `<key>.rotate-new`, the only surviving copy of the key the *other* items need,
|
|
592
|
-
* destroying every one of them silently (RUSH-1975).
|
|
593
|
-
*/
|
|
594
|
-
function classifyStore(dir, keyVal, candidateKeys) {
|
|
595
|
-
if (keyVal == null)
|
|
596
|
-
return 'none';
|
|
597
|
-
let names;
|
|
598
|
-
try {
|
|
599
|
-
names = fs.readdirSync(dir).filter((f) => f.endsWith('.enc'));
|
|
600
|
-
}
|
|
601
|
-
catch {
|
|
602
|
-
return 'none';
|
|
603
|
-
}
|
|
604
|
-
const otherKeys = candidateKeys.filter((k) => k != null && k !== keyVal);
|
|
605
|
-
let nonOrphan = 0;
|
|
606
|
-
let openUnderKey = 0;
|
|
607
|
-
for (const name of names) {
|
|
608
|
-
let enc;
|
|
609
|
-
try {
|
|
610
|
-
enc = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8'));
|
|
611
|
-
}
|
|
612
|
-
catch {
|
|
613
|
-
continue;
|
|
614
|
-
}
|
|
615
|
-
const opensKey = opensUnder(enc, keyVal);
|
|
616
|
-
// Ignore genuine orphans (open under no candidate key) — a third-party cache
|
|
617
|
-
// carried through verbatim is not evidence of a mixed store.
|
|
618
|
-
if (!opensKey && !otherKeys.some((k) => opensUnder(enc, k)))
|
|
619
|
-
continue;
|
|
620
|
-
nonOrphan++;
|
|
621
|
-
if (opensKey)
|
|
622
|
-
openUnderKey++;
|
|
623
|
-
}
|
|
624
|
-
if (openUnderKey === 0)
|
|
625
|
-
return 'none';
|
|
626
|
-
return openUnderKey === nonOrphan ? 'all' : 'some';
|
|
627
|
-
}
|
|
628
|
-
/** True if `dir` holds at least one `.enc` item. */
|
|
629
|
-
function storeHasEnc(dir) {
|
|
630
|
-
try {
|
|
631
|
-
return fs.readdirSync(dir).some((f) => f.endsWith('.enc'));
|
|
632
|
-
}
|
|
633
|
-
catch {
|
|
634
|
-
return false;
|
|
635
|
-
}
|
|
636
|
-
}
|
|
637
|
-
/**
|
|
638
|
-
* `.enc` basenames present in the `<dir>.rotate-old-*` backup but ABSENT from the
|
|
639
|
-
* live `dir` — ciphertext that lives ONLY in the backup. A genuine post-swap store
|
|
640
|
-
* is always a superset of the pre-swap store (rotation re-keys every item and copies
|
|
641
|
-
* orphans/non-.enc files through verbatim — nothing is dropped), so this set is empty
|
|
642
|
-
* for any real completed/interrupted rotation. It is non-empty only when the live
|
|
643
|
-
* `dir` is NOT the post-swap store: an interstitial `secrets set` recreated the store
|
|
644
|
-
* dir after a crash in the move-aside window left it absent (RUSH-1975). Sweeping the
|
|
645
|
-
* backup then destroys those items — so recovery refuses instead.
|
|
646
|
-
*/
|
|
647
|
-
function backupOnlyEnc(bak, dir) {
|
|
648
|
-
let bakNames;
|
|
649
|
-
try {
|
|
650
|
-
bakNames = fs.readdirSync(bak).filter((f) => f.endsWith('.enc'));
|
|
651
|
-
}
|
|
652
|
-
catch {
|
|
653
|
-
return [];
|
|
654
|
-
}
|
|
655
|
-
let dirNames;
|
|
656
|
-
try {
|
|
657
|
-
dirNames = new Set(fs.readdirSync(dir).filter((f) => f.endsWith('.enc')));
|
|
658
|
-
}
|
|
659
|
-
catch {
|
|
660
|
-
dirNames = new Set();
|
|
661
|
-
}
|
|
662
|
-
return bakNames.filter((n) => !dirNames.has(n));
|
|
663
|
-
}
|
|
664
|
-
/** Read a key file's trimmed contents, or null if absent/empty. */
|
|
665
|
-
function readKeyFile(fp) {
|
|
666
|
-
try {
|
|
667
|
-
const v = fs.readFileSync(fp, 'utf8').trim();
|
|
668
|
-
return v.length > 0 ? v : null;
|
|
669
|
-
}
|
|
670
|
-
catch {
|
|
671
|
-
return null;
|
|
672
|
-
}
|
|
673
|
-
}
|
|
674
|
-
/**
|
|
675
|
-
* Recover from a rotation that was interrupted mid-swap on a prior run, so the
|
|
676
|
-
* store is always left in a single, self-consistent, readable state.
|
|
677
|
-
*
|
|
678
|
-
* Recovery is CONTENT-aware, not presence-aware, and it classifies the WHOLE
|
|
679
|
-
* store, not just one item. The mere existence of the store dir and the key file
|
|
680
|
-
* does not prove they match (RUSH-1975 data-loss window): on the non-co-located
|
|
681
|
-
* key path the swap is four renames, and a crash after the store swap
|
|
682
|
-
* (`stageDir`->`dir`) but before the key swap (`keyTmp`->`keyPath`) finishes
|
|
683
|
-
* leaves a NEW-key store next to the OLD key file, both present. A presence check
|
|
684
|
-
* would see "both here" and wrongly sweep the only copies of the old ciphertext
|
|
685
|
-
* (`<dir>.rotate-old-*`) and the new key (`<key>.rotate-new`), permanently
|
|
686
|
-
* orphaning every secret. So we probe the actual ciphertext with `classifyStore`,
|
|
687
|
-
* which distinguishes a store that opens fully under one key ('all') from one that
|
|
688
|
-
* is MIXED — some items under the live key, others under the incoming key ('some',
|
|
689
|
-
* e.g. after a mid-swap crash contaminated by a later `secrets set`):
|
|
690
|
-
*
|
|
691
|
-
* 1. The live key opens EVERY non-orphan item ('all') → rotation complete and
|
|
692
|
-
* consistent (or never interrupted); sweeping the `.rotate-*` artifacts is safe —
|
|
693
|
-
* unless a `<dir>.rotate-old-*` backup still holds `.enc` items absent from the
|
|
694
|
-
* live dir. That means the live dir is not the post-swap store but a fresh dir an
|
|
695
|
-
* interstitial `secrets set` created after a crash in the move-aside window left
|
|
696
|
-
* the store dir absent, so the backup is the only copy of those items → REFUSE.
|
|
697
|
-
* 2. Else, if `<key>.rotate-new` opens every non-orphan item ('all'), the crash
|
|
698
|
-
* landed after the store swap but before the key swap finished → finish the
|
|
699
|
-
* rotation forward by installing `.rotate-new` as the live key, then sweep.
|
|
700
|
-
* 3. Else, if neither key opens any item, roll back: restore the
|
|
701
|
-
* `<dir>.rotate-old-*` backup over `dir` and `<key>.rotate-oldkey` over the key
|
|
702
|
-
* file — but only once the backup is proven to open fully under the old key.
|
|
703
|
-
* 4. If a key opens SOME but not all items ('some'), the store is MIXED — an
|
|
704
|
-
* interrupted rotation contaminated by a later write, with live data under two
|
|
705
|
-
* keys at once. Sweeping would delete the only copy of one of those keys, so we
|
|
706
|
-
* REFUSE: throw an actionable error and preserve every recovery artifact for
|
|
707
|
-
* out-of-band repair. Likewise, if neither forward nor rollback can be proven,
|
|
708
|
-
* leave every artifact in place — a leftover temp dir is recoverable, deleting
|
|
709
|
-
* the only copy of a key or ciphertext is not.
|
|
710
|
-
*
|
|
711
|
-
* A phase-marker / journal file was considered and deliberately skipped: the
|
|
712
|
-
* AES-256-GCM auth tag already makes the decrypt probe an authoritative,
|
|
713
|
-
* self-validating record of which key matches the store. A separate marker would
|
|
714
|
-
* be a second source of truth that can disagree with reality — its own write has
|
|
715
|
-
* crash windows, and a stale marker misleads — so it would weaken, not strengthen,
|
|
716
|
-
* this guarantee. Idempotent; a no-op when no rotation artifacts are present.
|
|
717
|
-
* Callers run this under the store lock (see `withStoreLock`).
|
|
718
|
-
*/
|
|
719
|
-
/**
|
|
720
|
-
* Whether a previous rotation left artifacts on disk — i.e. whether
|
|
721
|
-
* {@link recoverInterruptedRotation} would do any work. Read-only, so `--dry-run`
|
|
722
|
-
* can report a pending recovery without performing (and thus writing) one.
|
|
723
|
-
*/
|
|
724
|
-
export function hasInterruptedRotationArtifacts(keyPath) {
|
|
725
|
-
const dir = fileDir();
|
|
726
|
-
const parent = path.dirname(dir);
|
|
727
|
-
const base = path.basename(dir);
|
|
728
|
-
let entries;
|
|
729
|
-
try {
|
|
730
|
-
entries = fs.readdirSync(parent);
|
|
731
|
-
}
|
|
732
|
-
catch {
|
|
733
|
-
return false;
|
|
734
|
-
}
|
|
735
|
-
return entries.some((e) => e.startsWith(`${base}.rotate-`))
|
|
736
|
-
|| fs.existsSync(`${keyPath}.rotate-new`)
|
|
737
|
-
|| fs.existsSync(`${keyPath}.rotate-oldkey`);
|
|
738
|
-
}
|
|
739
|
-
function recoverInterruptedRotation(keyPath) {
|
|
740
|
-
const dir = fileDir();
|
|
741
|
-
const parent = path.dirname(dir);
|
|
742
|
-
const base = path.basename(dir);
|
|
743
|
-
let entries;
|
|
744
|
-
try {
|
|
745
|
-
entries = fs.readdirSync(parent);
|
|
746
|
-
}
|
|
747
|
-
catch {
|
|
748
|
-
return;
|
|
749
|
-
}
|
|
750
|
-
const keyNew = `${keyPath}.rotate-new`;
|
|
751
|
-
const keyOld = `${keyPath}.rotate-oldkey`;
|
|
752
|
-
const bakName = entries.find((e) => e.startsWith(`${base}.rotate-old-`));
|
|
753
|
-
let bakDir = bakName ? path.join(parent, bakName) : null;
|
|
754
|
-
// Nothing rotation-related on disk -> no interrupted rotation to recover.
|
|
755
|
-
if (!hasInterruptedRotationArtifacts(keyPath))
|
|
756
|
-
return;
|
|
757
|
-
// If the live store dir vanished mid-swap (crash between the two store renames,
|
|
758
|
-
// before the new store landed), restore the old store from its backup — the key
|
|
759
|
-
// was not touched yet, so old store + old key is a consistent state.
|
|
760
|
-
if (!fs.existsSync(dir) && bakDir && fs.existsSync(bakDir)) {
|
|
761
|
-
fs.renameSync(bakDir, dir);
|
|
762
|
-
bakDir = null; // consumed
|
|
763
|
-
}
|
|
764
|
-
const sweep = () => {
|
|
765
|
-
for (const e of fs.readdirSync(parent)) {
|
|
766
|
-
if (e.startsWith(`${base}.rotate-`)) {
|
|
767
|
-
try {
|
|
768
|
-
fs.rmSync(path.join(parent, e), { recursive: true, force: true });
|
|
769
|
-
}
|
|
770
|
-
catch { /* best effort */ }
|
|
771
|
-
}
|
|
772
|
-
}
|
|
773
|
-
for (const suffix of ['.rotate-new', '.rotate-oldkey']) {
|
|
774
|
-
try {
|
|
775
|
-
fs.rmSync(`${keyPath}${suffix}`, { force: true });
|
|
776
|
-
}
|
|
777
|
-
catch { /* best effort */ }
|
|
778
|
-
}
|
|
779
|
-
};
|
|
780
|
-
// An empty or unreadable store holds no ciphertext at risk. Only sweep once the
|
|
781
|
-
// live key file is present again; never delete recovery artifacts for a store we
|
|
782
|
-
// cannot probe.
|
|
783
|
-
if (!storeHasEnc(dir)) {
|
|
784
|
-
if (fs.existsSync(keyPath))
|
|
785
|
-
sweep();
|
|
786
|
-
return;
|
|
787
|
-
}
|
|
788
|
-
const liveKey = readKeyFile(keyPath);
|
|
789
|
-
const newKey = readKeyFile(keyNew);
|
|
790
|
-
const oldKey = readKeyFile(keyOld);
|
|
791
|
-
const candidates = [liveKey, newKey, oldKey];
|
|
792
|
-
// A MIXED store cannot be swept safely: live data is sealed under two keys at
|
|
793
|
-
// once, so deleting either `.rotate-new` or the old-ciphertext backup destroys
|
|
794
|
-
// one class permanently. Refuse loudly and keep every artifact for out-of-band
|
|
795
|
-
// repair — never report success over a store we would be corrupting.
|
|
796
|
-
const refuseMixed = (label) => {
|
|
797
|
-
throw new Error(`Interrupted secrets rotation left a MIXED store at ${dir}: ${label}. This ` +
|
|
798
|
-
`happens when a \`secrets set\` landed between a crashed rotation and this ` +
|
|
799
|
-
`recovery. Refusing to sweep — every recovery artifact is preserved. Recover ` +
|
|
800
|
-
`out of band: for each ${dir}/*.enc, decrypt it under whichever of ${keyPath}` +
|
|
801
|
-
(fs.existsSync(keyNew) ? `, ${keyNew}` : '') +
|
|
802
|
-
(fs.existsSync(keyOld) ? `, ${keyOld}` : '') +
|
|
803
|
-
` opens it, re-seal all items under one key, then re-run \`rotate-passphrase\`.`);
|
|
804
|
-
};
|
|
805
|
-
// 1. Live key opens the WHOLE store -> rotation complete/consistent. Sweep safe,
|
|
806
|
-
// UNLESS a `<dir>.rotate-old-*` backup still holds items absent from the live
|
|
807
|
-
// dir: then the live dir is not the post-swap store but a fresh dir an
|
|
808
|
-
// interstitial `secrets set` created after a crash in the move-aside window
|
|
809
|
-
// (store dir absent), and the backup is the ONLY copy of those items. Sweeping
|
|
810
|
-
// would destroy them, so refuse. Opens only some items -> MIXED, refuse.
|
|
811
|
-
const liveMatch = classifyStore(dir, liveKey, candidates);
|
|
812
|
-
if (liveMatch === 'all') {
|
|
813
|
-
const orphaned = bakDir && fs.existsSync(bakDir) ? backupOnlyEnc(bakDir, dir) : [];
|
|
814
|
-
if (orphaned.length > 0) {
|
|
815
|
-
const shown = orphaned.slice(0, 3).join(', ') + (orphaned.length > 3 ? ', …' : '');
|
|
816
|
-
throw new Error(`Interrupted secrets rotation left a MIXED (split) store: the live key opens ` +
|
|
817
|
-
`${dir}, but its backup ${bakDir} holds ${orphaned.length} item(s) absent from ` +
|
|
818
|
-
`the live store (${shown}) — so the live dir is not the whole store. This ` +
|
|
819
|
-
`happens when a \`secrets set\` recreated the store dir after a crash left it ` +
|
|
820
|
-
`absent. Refusing to sweep — every recovery artifact is preserved. Recover out ` +
|
|
821
|
-
`of band: merge ${bakDir}/*.enc into ${dir} (both open under ${keyPath}), then ` +
|
|
822
|
-
`re-run \`rotate-passphrase\`.`);
|
|
823
|
-
}
|
|
824
|
-
sweep();
|
|
825
|
-
return;
|
|
826
|
-
}
|
|
827
|
-
if (liveMatch === 'some')
|
|
828
|
-
refuseMixed('some items open under the live key and others do not');
|
|
829
|
-
// 2. `.rotate-new` opens the whole store, the live key none of it -> the crash
|
|
830
|
-
// landed after the store swap, before the key swap finished. Finish the
|
|
831
|
-
// rotation forward by installing the new key, then sweep. Opens only some ->
|
|
832
|
-
// MIXED, refuse.
|
|
833
|
-
const newMatch = classifyStore(dir, newKey, candidates);
|
|
834
|
-
if (newMatch === 'all') {
|
|
835
|
-
try {
|
|
836
|
-
fs.rmSync(keyPath, { force: true });
|
|
837
|
-
}
|
|
838
|
-
catch { /* may be absent mid-key-swap */ }
|
|
839
|
-
fs.renameSync(keyNew, keyPath);
|
|
840
|
-
fsyncDir(path.dirname(keyPath));
|
|
841
|
-
sweep();
|
|
842
|
-
return;
|
|
843
|
-
}
|
|
844
|
-
if (newMatch === 'some')
|
|
845
|
-
refuseMixed('some items open under the incoming (.rotate-new) key and others do not');
|
|
846
|
-
// 3. Neither key opens the live store -> roll back to the pre-rotation state, but
|
|
847
|
-
// only once the backup store is proven to open FULLY under the old key (or the
|
|
848
|
-
// live key, when `.rotate-oldkey` was not written yet — Window A).
|
|
849
|
-
const rollbackKey = bakDir && classifyStore(bakDir, oldKey, candidates) === 'all'
|
|
850
|
-
? oldKey
|
|
851
|
-
: (bakDir && classifyStore(bakDir, liveKey, candidates) === 'all' ? liveKey : null);
|
|
852
|
-
if (bakDir && fs.existsSync(bakDir) && rollbackKey != null) {
|
|
853
|
-
fs.rmSync(dir, { recursive: true, force: true });
|
|
854
|
-
fs.renameSync(bakDir, dir);
|
|
855
|
-
fsyncDir(path.dirname(dir));
|
|
856
|
-
if (rollbackKey === oldKey && fs.existsSync(keyOld)) {
|
|
857
|
-
try {
|
|
858
|
-
fs.rmSync(keyPath, { force: true });
|
|
859
|
-
}
|
|
860
|
-
catch { /* may be absent */ }
|
|
861
|
-
fs.renameSync(keyOld, keyPath);
|
|
862
|
-
fsyncDir(path.dirname(keyPath));
|
|
863
|
-
}
|
|
864
|
-
sweep();
|
|
865
|
-
return;
|
|
866
|
-
}
|
|
867
|
-
// 4. Neither forward nor rollback is provable -> leave every artifact untouched
|
|
868
|
-
// for out-of-band recovery. Do NOT sweep: that is the data-loss bug.
|
|
869
|
-
}
|
|
870
|
-
/**
|
|
871
|
-
* Rotate under the exclusive store lock, so no `secrets set`/`delete` and no
|
|
872
|
-
* second rotation can interleave with the swap (see `withStoreLock`). The whole
|
|
873
|
-
* run — recovery, verify, swap — holds the lock; it is released on return or throw.
|
|
874
|
-
*/
|
|
875
|
-
export function rotatePassphrase(opts = {}) {
|
|
876
|
-
return withStoreLock((heartbeat) => rotatePassphraseLocked(opts, heartbeat));
|
|
877
|
-
}
|
|
878
|
-
function rotatePassphraseLocked(opts, heartbeat = () => { }) {
|
|
879
|
-
const dryRun = opts.dryRun ?? false;
|
|
880
|
-
// Resolve the key path. If the live key file is absent because a prior rotation
|
|
881
|
-
// crashed mid-key-swap, fall back to the interrupted rotation's target so
|
|
882
|
-
// recovery below can still run and heal the store (RUSH-1975 Window B).
|
|
883
|
-
const keyPath = machinePassphraseSourcePath() ?? resolveInterruptedKeyPath();
|
|
884
|
-
if (!keyPath) {
|
|
885
|
-
throw new Error('No machine-local passphrase to rotate. `rotate-passphrase` re-keys the ' +
|
|
886
|
-
'file store\'s auto-provisioned key; none is provisioned on this machine.');
|
|
887
|
-
}
|
|
888
|
-
// Recovery runs even under --dry-run, deliberately: healing an interrupted
|
|
889
|
-
// rotation is how a crashed store becomes readable again WITHOUT re-keying it,
|
|
890
|
-
// and gating it would leave such a store recoverable only via a full rotation.
|
|
891
|
-
// It is the one thing a dry run writes, so the report says so and the CLI
|
|
892
|
-
// prints it instead of claiming "nothing written".
|
|
893
|
-
const recoveredInterruptedRotation = hasInterruptedRotationArtifacts(keyPath);
|
|
894
|
-
recoverInterruptedRotation(keyPath);
|
|
895
|
-
const oldPass = fs.readFileSync(keyPath, 'utf8').trim();
|
|
896
|
-
if (!oldPass)
|
|
897
|
-
throw new Error(`Machine-local passphrase file ${keyPath} is empty.`);
|
|
898
|
-
const newPass = opts.newPassphrase ?? randomBytes(32).toString('base64');
|
|
899
|
-
if (newPass === oldPass)
|
|
900
|
-
throw new Error('New passphrase equals the current one — refusing a no-op rotation.');
|
|
901
|
-
const dir = fileDir();
|
|
902
|
-
let names;
|
|
903
|
-
try {
|
|
904
|
-
names = fs.readdirSync(dir).filter((f) => f.endsWith('.enc'));
|
|
905
|
-
}
|
|
906
|
-
catch {
|
|
907
|
-
names = [];
|
|
908
|
-
}
|
|
909
|
-
if (names.length === 0) {
|
|
910
|
-
throw new Error(`No encrypted items in ${dir} — nothing to rotate.`);
|
|
911
|
-
}
|
|
912
|
-
// Phase 1 — decrypt-all, re-encrypt, re-verify in memory. Nothing on disk is
|
|
913
|
-
// touched here, so any throw leaves the live store and key file untouched.
|
|
914
|
-
const staged = [];
|
|
915
|
-
const skipped = [];
|
|
916
|
-
for (const name of names) {
|
|
917
|
-
// Keep the store lock fresh across the scrypt-bound loop: each item runs the
|
|
918
|
-
// KDF three times (decrypt, re-encrypt, verify), so on a real store this loop
|
|
919
|
-
// outlives the lock's stale window — without this a peer could break the lock
|
|
920
|
-
// as "stale" mid-run and interleave a write (see `withFileLock`'s heartbeat).
|
|
921
|
-
heartbeat();
|
|
922
|
-
const raw = fs.readFileSync(path.join(dir, name), 'utf8');
|
|
923
|
-
let parsed;
|
|
924
|
-
try {
|
|
925
|
-
parsed = JSON.parse(raw);
|
|
926
|
-
}
|
|
927
|
-
catch {
|
|
928
|
-
skipped.push(`${name} (not valid EncFile JSON)`);
|
|
929
|
-
continue;
|
|
930
|
-
}
|
|
931
|
-
let plain;
|
|
932
|
-
try {
|
|
933
|
-
plain = decryptForFallback(parsed, oldPass);
|
|
934
|
-
}
|
|
935
|
-
catch {
|
|
936
|
-
skipped.push(`${name} (does not decrypt under the current key — orphan)`);
|
|
937
|
-
continue;
|
|
938
|
-
}
|
|
939
|
-
let reEnc = encryptForFallback(plain, newPass);
|
|
940
|
-
if (opts.tamperStaged)
|
|
941
|
-
reEnc = { ...reEnc, ciphertext: `00${reEnc.ciphertext.slice(2)}` };
|
|
942
|
-
let check;
|
|
943
|
-
try {
|
|
944
|
-
check = decryptForFallback(reEnc, newPass);
|
|
945
|
-
}
|
|
946
|
-
catch {
|
|
947
|
-
throw new Error(`Re-encryption of ${name} failed to verify under the new key — aborted, nothing written.`);
|
|
948
|
-
}
|
|
949
|
-
if (check !== plain)
|
|
950
|
-
throw new Error(`Round-trip mismatch on ${name} — aborted, nothing written.`);
|
|
951
|
-
staged.push({ name, enc: JSON.stringify(reEnc) });
|
|
952
|
-
}
|
|
953
|
-
if (staged.length === 0) {
|
|
954
|
-
throw new Error('No item decrypted under the current machine-local key — aborted, nothing written.');
|
|
955
|
-
}
|
|
956
|
-
const report = {
|
|
957
|
-
dryRun,
|
|
958
|
-
committed: false,
|
|
959
|
-
bundleCount: staged.length,
|
|
960
|
-
skipped,
|
|
961
|
-
roundTripOk: true,
|
|
962
|
-
keyFilePath: keyPath,
|
|
963
|
-
recoveredInterruptedRotation,
|
|
964
|
-
};
|
|
965
|
-
if (dryRun)
|
|
966
|
-
return report;
|
|
967
|
-
// Phase 2 — stage the complete replacement store in a sibling temp dir, fsync,
|
|
968
|
-
// then swap. Orphans and any non-.enc files are copied through verbatim so the
|
|
969
|
-
// swapped dir is a complete superset of the old one (nothing is dropped).
|
|
970
|
-
const keyColocated = path.dirname(keyPath) === dir;
|
|
971
|
-
const rand = randomBytes(6).toString('hex');
|
|
972
|
-
const stageDir = `${dir}.rotate-${rand}`;
|
|
973
|
-
fs.rmSync(stageDir, { recursive: true, force: true });
|
|
974
|
-
fs.mkdirSync(stageDir, { recursive: true, mode: 0o700 });
|
|
975
|
-
const stagedNames = new Set(staged.map((s) => s.name));
|
|
976
|
-
for (const { name, enc } of staged) {
|
|
977
|
-
heartbeat(); // each write is an fsync; keep the lock fresh across the batch
|
|
978
|
-
writeFileFsync(path.join(stageDir, name), enc, 0o600);
|
|
979
|
-
}
|
|
980
|
-
for (const entry of fs.readdirSync(dir)) {
|
|
981
|
-
if (stagedNames.has(entry))
|
|
982
|
-
continue;
|
|
983
|
-
if (keyColocated && entry === path.basename(keyPath))
|
|
984
|
-
continue; // rewritten below, not copied
|
|
985
|
-
const src = path.join(dir, entry);
|
|
986
|
-
if (!fs.statSync(src).isFile())
|
|
987
|
-
continue;
|
|
988
|
-
heartbeat();
|
|
989
|
-
// Copy through as raw bytes — reading as 'utf8' would decode any non-UTF-8
|
|
990
|
-
// byte to U+FFFD and silently corrupt the file on the way through the swap.
|
|
991
|
-
writeFileFsync(path.join(stageDir, entry), fs.readFileSync(src), 0o600);
|
|
992
|
-
}
|
|
993
|
-
// A co-located legacy key travels with the store: write the new value into the
|
|
994
|
-
// staged dir so a single directory swap commits both ciphertext and key.
|
|
995
|
-
if (keyColocated)
|
|
996
|
-
writeFileFsync(path.join(stageDir, path.basename(keyPath)), newPass, 0o600);
|
|
997
|
-
fsyncDir(stageDir);
|
|
998
|
-
// Test seam: simulate a crash after staging but before the swap. The live store
|
|
999
|
-
// and key file are still untouched at this point.
|
|
1000
|
-
opts.onStagedBeforeCommit?.();
|
|
1001
|
-
// For a non-co-located key, stage the new key beside the old one first so the
|
|
1002
|
-
// swap is two quick renames with no I/O between them.
|
|
1003
|
-
const keyTmp = `${keyPath}.rotate-new`;
|
|
1004
|
-
if (!keyColocated) {
|
|
1005
|
-
writeFileFsync(keyTmp, newPass, 0o600);
|
|
1006
|
-
fsyncDir(path.dirname(keyPath));
|
|
1007
|
-
}
|
|
1008
|
-
// Swap. Move the live store aside, then the staged store into place. The gap
|
|
1009
|
-
// between these two renames is the only crash window that leaves the store dir
|
|
1010
|
-
// absent; recoverInterruptedRotation restores it from the backup on next run.
|
|
1011
|
-
const bakDir = `${dir}.rotate-old-${rand}`;
|
|
1012
|
-
fs.renameSync(dir, bakDir);
|
|
1013
|
-
// Test seam: crash after the live store is moved aside, before the staged store
|
|
1014
|
-
// lands (the store dir is absent). For a co-located key this is the ONLY swap
|
|
1015
|
-
// window — the single rename carries both ciphertext and key.
|
|
1016
|
-
opts.onStoreMovedAsideBeforeSwap?.();
|
|
1017
|
-
fs.renameSync(stageDir, dir);
|
|
1018
|
-
fsyncDir(path.dirname(dir));
|
|
1019
|
-
const keyBak = `${keyPath}.rotate-oldkey`;
|
|
1020
|
-
if (!keyColocated) {
|
|
1021
|
-
// Test seam: crash after the store swap, before the key swap begins (Window A).
|
|
1022
|
-
// Also receives the lock heartbeat so a test can prove a long hold stays fresh.
|
|
1023
|
-
opts.onStoreSwappedBeforeKeySwap?.(heartbeat);
|
|
1024
|
-
fs.renameSync(keyPath, keyBak);
|
|
1025
|
-
// Test seam: crash after the old key is moved aside, before the new key lands (Window B).
|
|
1026
|
-
opts.onKeyBackedUpBeforeNewKey?.();
|
|
1027
|
-
fs.renameSync(keyTmp, keyPath);
|
|
1028
|
-
fsyncDir(path.dirname(keyPath));
|
|
1029
|
-
}
|
|
1030
|
-
// Verify a real read out of the now-live store under the new key. On failure,
|
|
1031
|
-
// roll the store (and key) back to the backup — the old passphrase still works.
|
|
1032
|
-
try {
|
|
1033
|
-
const probe = JSON.parse(fs.readFileSync(path.join(dir, staged[0].name), 'utf8'));
|
|
1034
|
-
decryptForFallback(probe, newPass);
|
|
1035
|
-
}
|
|
1036
|
-
catch (err) {
|
|
1037
|
-
fs.rmSync(dir, { recursive: true, force: true });
|
|
1038
|
-
fs.renameSync(bakDir, dir);
|
|
1039
|
-
if (!keyColocated && fs.existsSync(keyBak)) {
|
|
1040
|
-
try {
|
|
1041
|
-
fs.rmSync(keyPath, { force: true });
|
|
1042
|
-
}
|
|
1043
|
-
catch { /* may not exist */ }
|
|
1044
|
-
fs.renameSync(keyBak, keyPath);
|
|
1045
|
-
}
|
|
1046
|
-
throw new Error(`Post-swap verification failed; rolled back to the old key. (${err.message})`);
|
|
1047
|
-
}
|
|
1048
|
-
// Committed. Drop the old ciphertext and old key — both hold the retired key.
|
|
1049
|
-
fs.rmSync(bakDir, { recursive: true, force: true });
|
|
1050
|
-
if (!keyColocated)
|
|
1051
|
-
fs.rmSync(keyBak, { force: true });
|
|
1052
|
-
cachedPassphrase = newPass;
|
|
1053
|
-
report.committed = true;
|
|
1054
|
-
return report;
|
|
1055
|
-
}
|
|
1056
|
-
/** Test-only: reset module state (file dir + cached passphrase). */
|
|
1057
|
-
export function _resetFileStoreForTest(opts = {}) {
|
|
1058
|
-
fileDirOverride = opts.fileDir ?? null;
|
|
1059
|
-
if (opts.passphraseDir !== undefined) {
|
|
1060
|
-
passphraseDirOverride = opts.passphraseDir;
|
|
1061
|
-
}
|
|
1062
|
-
else if (opts.fileDir) {
|
|
1063
|
-
// Hermetic sibling when only the store dir is overridden (linux.test.ts).
|
|
1064
|
-
passphraseDirOverride = path.resolve(opts.fileDir, '..', `${path.basename(opts.fileDir)}-key`);
|
|
1065
|
-
}
|
|
1066
|
-
else {
|
|
1067
|
-
passphraseDirOverride = null;
|
|
1068
|
-
}
|
|
1069
|
-
cachedPassphrase = opts.passphrase ?? null;
|
|
1070
|
-
warnedAutoPassphrase = false;
|
|
1071
|
-
lockAcquireTimeoutMsOverride = null;
|
|
1072
|
-
lockStaleMsOverride = null;
|
|
1073
|
-
// Drop the in-memory metadata cache so a test that repoints the store dir or
|
|
1074
|
-
// passphrase never reads another fixture's cached plaintext (the on-disk cache
|
|
1075
|
-
// is per-dir and keyed by the passphrase fingerprint, so it self-invalidates
|
|
1076
|
-
// too, but the process-level map must be cleared explicitly).
|
|
1077
|
-
metaCache = null;
|
|
1078
|
-
metaCacheDirty = false;
|
|
1079
|
-
}
|
|
1080
|
-
/** Test-only: flush the in-memory metadata cache to disk now. Production flushes
|
|
1081
|
-
* once at process exit; a test needs the on-disk copy mid-run to simulate the
|
|
1082
|
-
* next `agents` process reading it. */
|
|
1083
|
-
export function _flushMetaCacheForTest() {
|
|
1084
|
-
flushMetaCache();
|
|
1085
|
-
}
|
|
1086
|
-
/** Test-only: shorten the store-lock acquire timeout so a contended-lock assertion
|
|
1087
|
-
* fails fast instead of waiting out the 30s production budget. */
|
|
1088
|
-
export function _setFileStoreLockTimeoutForTest(ms) {
|
|
1089
|
-
lockAcquireTimeoutMsOverride = ms;
|
|
1090
|
-
}
|
|
1091
|
-
/** Test-only: shrink the store-lock stale window so a heartbeat/steal assertion runs
|
|
1092
|
-
* in milliseconds instead of the 5s production window. */
|
|
1093
|
-
export function _setFileStoreLockStaleMsForTest(ms) {
|
|
1094
|
-
lockStaleMsOverride = ms;
|
|
1095
|
-
}
|
|
1096
|
-
/** Test-only: the cross-process store-lock target (sibling of the store dir). */
|
|
1097
|
-
export function _fileStoreLockPathForTest() {
|
|
1098
|
-
return fileStoreLockPath();
|
|
1099
|
-
}
|