fix(cli): real rolling backups, and imports bound to their profile (L-4, L-8)

Two findings from the 2026-08-18 audit, entangled in the same files.

L-4 — the additional backup location was inert. The TUI accepted a path and
answered "Additional backups will be written to X"; nothing ever wrote one.
That is worse than an absent setting: it is false assurance in the feature a
user leans on precisely when everything else has gone wrong. The cause was
larger than the setting — Python's BackupManager is not ported at all, so
`backup_interval` was dead for the same reason.

New backups.ts ports the write side: after every committed mutation, the
encrypted index is copied to <profile>/backups/entries_db_backup_<ts>.json.enc
at 0600, and mirrored to `additional_backup_path` as <fingerprint>_<name>.
Filenames match Python's byte for byte so the two implementations share one
backup directory. Hooked after the save in both mutation funnels, matching
Python's create_backup()-after-_save_index() order, so a snapshot is the
post-mutation state.

Two deliberate deviations, both documented in the source:
  - Throttling reads the newest snapshot on disk rather than Python's
    in-memory _last_backup_time. Every CLI command is its own process, so an
    in-memory counter would make backup_interval inert for CLI use — the same
    bug in a new place.
  - A failed mirror is reported rather than swallowed. Best-effort must not
    fail a committed vault write, but silence would restore the false
    assurance this change exists to remove.

Quick Unlock stays in the menu for item-order parity but now says it is not
implemented instead of storing a preference nothing reads. In Python the flag
never changed unlocking either — it writes an audit entry and raises a
security finding.

L-8 — plaintext backup import was not bound to a profile. A seed-only backup
is implicitly seed-bound: the wrong seed cannot decrypt it. A plaintext one
imports into any profile and is then re-derived from the TARGET seed, so every
password, SSH key, PGP key and seed silently comes out different from the one
the backup was taken to preserve. Nothing errors anywhere along the way.

Both the CLI and the TUI now compare the wrapper's fingerprint to the target
profile and require an explicit override (--allow-fingerprint-mismatch, or a
confirmation in the TUI). `vault import --inspect` reports the backup's
fingerprint and encryption mode so the mismatch is visible before committing.
The CLI also stops deciding encryption mode by regex over the whole file: new
parseBackupWrapper reads the envelope, which is the structural question being
asked.

Tests: backups.test.ts (9 cases: filename parity, 0600, verbatim ciphertext,
mirror naming, mirror-failure reporting, interval throttling, CLI end-to-end)
plus foreign-import cases in cli.test.ts and tuiMenus.test.ts using a genuine
second profile rather than a doctored file. All mutation-verified: removing
each hook or guard turns the matching tests red.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
This commit is contained in:
thePR0M3TH3ANandClaude Opus 5 committed 2026-08-18 21:49:26 -04:00
1 parent 063fae191f
commit 4b01ac62e0
8 files changed
+591 -16

No files matched your search

+143
View File
@@ -0,0 +1,143 @@
/**
* Rolling index backups, parity with Python's BackupManager.
*
* Every vault mutation leaves a timestamped copy of the encrypted index in
* `<profile>/backups/`, and — when the profile is configured for it — mirrors
* that copy to a second location. The filenames match Python's exactly, so
* the two implementations share one backup directory and either can restore
* from the other's snapshots.
*
* These are copies of the already-encrypted file. Nothing here decrypts
* anything, and the seed is never needed to make a backup.
*/
import { copyFile, mkdir, chmod, readdir, stat } from "node:fs/promises";
import { existsSync } from "node:fs";
import { basename, dirname, join } from "node:path";
import { homedir } from "node:os";
/** Matches Python's BackupManager.BACKUP_FILENAME_TEMPLATE. */
export const BACKUP_DIR_NAME = "backups";
const BACKUP_PREFIX = "entries_db_backup_";
const BACKUP_SUFFIX = ".json.enc";
export function backupFilename(timestamp: number): string {
return `${BACKUP_PREFIX}${timestamp}${BACKUP_SUFFIX}`;
}
/** Expand a leading ~ the way Python's Path.expanduser does. */
function expandHome(p: string): string {
return p.startsWith("~") ? join(homedir(), p.slice(1)) : p;
}
/**
* The unix time of the most recent snapshot in `backupDir`, or 0 if there is
* none.
*
* Python throttles with an in-memory `_last_backup_time`, which only works
* for as long as one BackupManager lives. The TUI is long-lived, but every
* CLI command is its own process, so an in-memory counter there would mean
* `backup_interval` is silently ignored for CLI use — the same class of
* inert setting this code exists to remove. Reading the interval from what is
* actually on disk gives one answer for both, and matches what a user means
* by "at most every N seconds".
*/
async function lastBackupTime(backupDir: string): Promise<number> {
if (!existsSync(backupDir)) return 0;
let newest = 0;
for (const name of await readdir(backupDir)) {
if (!name.startsWith(BACKUP_PREFIX) || !name.endsWith(BACKUP_SUFFIX)) continue;
const stamp = Number(name.slice(BACKUP_PREFIX.length, -BACKUP_SUFFIX.length));
if (Number.isFinite(stamp) && stamp > newest) newest = stamp;
}
return newest;
}
export interface BackupResult {
/** Path of the snapshot written, or null when throttled or skipped. */
written: string | null;
/** Path of the mirrored copy, when `additional_backup_path` is set. */
mirrored?: string;
/** Why no snapshot was written, for callers that surface it. */
skipped?: "no-index" | "throttled";
/**
* The mirror failed. A backup is best-effort: a full or unwritable second
* location must never turn a successful vault write into a failed command,
* but it must not be silent either, or the setting is false assurance
* again — which is the whole point of this module.
*/
mirrorError?: string;
}
/**
* Snapshot the encrypted index after a successful write.
*
* Called after the vault is saved, matching Python, where `create_backup()`
* runs after `_save_index()` — so a snapshot is the post-mutation state and
* the directory reads as a history rather than an undo stack.
*/
export async function createIndexBackup(options: {
/** Path of the encrypted index that was just written. */
indexPath: string;
/** Profile config; reads `backup_interval` and `additional_backup_path`. */
config: Record<string, unknown>;
/** Injectable for tests; defaults to now. */
now?: number;
}): Promise<BackupResult> {
const { indexPath, config } = options;
if (!existsSync(indexPath)) return { written: null, skipped: "no-index" };
const profileDir = dirname(indexPath);
const backupDir = join(profileDir, BACKUP_DIR_NAME);
const now = options.now ?? Math.floor(Date.now() / 1000);
const interval = Number(config["backup_interval"] ?? 0);
if (Number.isFinite(interval) && interval > 0) {
const last = await lastBackupTime(backupDir);
if (last > 0 && now - last < interval) return { written: null, skipped: "throttled" };
}
await mkdir(backupDir, { recursive: true });
const name = backupFilename(now);
const dest = join(backupDir, name);
await copyFile(indexPath, dest);
// copyFile preserves neither mode nor the absence of one reliably across
// platforms, and the source may predate the 0600 convention. Set it
// explicitly: this file is the whole vault.
await chmod(dest, 0o600);
const result: BackupResult = { written: dest };
const extra = String(config["additional_backup_path"] ?? "").trim();
if (!extra) return result;
try {
const destDir = expandHome(extra);
await mkdir(destDir, { recursive: true });
// Python prefixes with the profile directory's name (the fingerprint) so
// several profiles can share one additional location without colliding.
const mirrored = join(destDir, `${basename(profileDir)}_${name}`);
await copyFile(dest, mirrored);
await chmod(mirrored, 0o600);
result.mirrored = mirrored;
} catch (e) {
result.mirrorError = (e as Error).message;
}
return result;
}
/** Snapshots in a profile's backup directory, newest first. */
export async function listIndexBackups(profileDir: string): Promise<string[]> {
const backupDir = join(profileDir, BACKUP_DIR_NAME);
if (!existsSync(backupDir)) return [];
const names = (await readdir(backupDir)).filter(
(n) => n.startsWith(BACKUP_PREFIX) && n.endsWith(BACKUP_SUFFIX),
);
const withTimes = await Promise.all(
names.map(async (n) => ({
path: join(backupDir, n),
mtime: (await stat(join(backupDir, n))).mtimeMs,
})),
);
return withTimes.sort((a, b) => b.mtime - a.mtime).map((r) => r.path);
}
+53 -5
View File
@@ -23,6 +23,7 @@ import {
} from "./vaultFile.js";
import {
importBackup,
parseBackupWrapper,
exportBackup,
generateFingerprint,
assertValidMnemonic,
@@ -159,6 +160,7 @@ import {
} from "./configFile.js";
import { AgentClient, AgentDaemon, agentSocketPath, DEFAULT_TTL_SECONDS } from "./agent.js";
import { AuditLog } from "./audit.js";
import { createIndexBackup } from "./backups.js";
import { runTui } from "./tui/app.js";
export interface ProgramIo {
@@ -283,6 +285,17 @@ async function mutateVault<T>(
const vault = await openVault(path, mnemonic);
const result = await fn(vault);
await saveVaultHoldingLock(vault);
// Rolling snapshot, after the write and still under the lock, matching
// Python's create_backup() call order. Best-effort by design: a failed
// backup must not make a committed vault write look like it failed.
try {
const config = await loadConfig(dirname(path), mnemonic);
await createIndexBackup({ indexPath: path, config });
} catch {
// Deliberately swallowed -- see BackupResult.mirrorError for the one
// failure that is worth surfacing; the rest are not worth failing a
// successful mutation over.
}
return result;
});
}
@@ -1592,13 +1605,25 @@ export function buildProgram(io: ProgramIo = defaultIo): Command {
.description("restore a portable backup into the active vault")
.option("--inspect", "verify and summarize without writing")
.option("--yes", "confirm replacing a vault that already has entries")
.action(async (srcFile: string, o: { inspect?: boolean; yes?: boolean }) => {
.option(
"--allow-fingerprint-mismatch",
"import a backup belonging to a DIFFERENT seed (every derived secret " +
"will change)",
)
.action(
async (
srcFile: string,
o: { inspect?: boolean; yes?: boolean; allowFingerprintMismatch?: boolean },
) => {
const opts = program.opts() as GlobalOpts;
const app = new AppDir(resolveAppDir(opts.appDir));
const raw = await readFile(srcFile);
// Encrypted backups need the seed; plaintext ones do not, so only
// resolve when required (and use the agent, not just the env).
const isEncrypted = !/"encryption_mode"\s*:\s*"none"/.test(raw.toString("utf8"));
// Read the envelope rather than pattern-matching the file text: the
// payload is attacker-influenced in the sense that it holds arbitrary
// user data, and deciding whether a seed is needed by regex over the
// whole file is a fragile way to ask a structural question.
const wrapper = parseBackupWrapper(new Uint8Array(raw));
const isEncrypted = wrapper.encryption_mode !== "none";
const mnemonic = isEncrypted ? await resolveMnemonic(app, opts) : undefined;
const parsed = await importBackup(new Uint8Array(raw), {
...(mnemonic !== undefined && { mnemonic }),
@@ -1612,6 +1637,8 @@ export function buildProgram(io: ProgramIo = defaultIo): Command {
inspected: srcFile,
schema_version: index.schema_version,
entry_count: entryCount,
encryption_mode: wrapper.encryption_mode,
fingerprint: wrapper.fingerprint,
written: false,
}),
);
@@ -1619,6 +1646,26 @@ export function buildProgram(io: ProgramIo = defaultIo): Command {
}
const targetMnemonic = mnemonic ?? (await resolveMnemonic(app, opts));
// A seed-only backup is bound to its seed implicitly — the wrong seed
// cannot decrypt it. A plaintext backup has no such binding: it will
// import cleanly into any profile, and then every derived entry
// (passwords, SSH, PGP, seeds) silently produces a DIFFERENT secret
// than the original, because the secret comes from the target profile's
// seed and not from the backup. Nothing fails; the user just gets the
// wrong passwords. The wrapper records which seed it came from, so
// check it rather than trusting the operator to have noticed.
const targetFingerprint = generateFingerprint(targetMnemonic);
if (wrapper.fingerprint !== targetFingerprint && !o.allowFingerprintMismatch) {
throw new Error(
`backup belongs to profile ${wrapper.fingerprint}, but the target ` +
`is ${targetFingerprint}. Importing it would re-derive every ` +
`secret from the target's seed, silently producing different ` +
`passwords than the ones in the backup. Import into the matching ` +
`profile, or pass --allow-fingerprint-mismatch if that is really ` +
`what you want.`,
);
}
// Resolve the profile only when the target actually depends on it: an
// explicit --vault path needs no profile, and demanding one anyway made
// `vault import --vault x` fail on machines with no ~/.seedpass — an
@@ -1644,7 +1691,8 @@ export function buildProgram(io: ProgramIo = defaultIo): Command {
entry_count: entryCount,
}),
);
});
},
);
return program;
}
+38 -1
View File
@@ -36,6 +36,7 @@ import {
generateMnemonic,
exportBackup,
importBackup,
parseBackupWrapper,
totpCodeAt,
utf8,
type Entry,
@@ -46,6 +47,7 @@ import { atomicWrite, openVault, saveVaultHoldingLock, withVaultLock, type Opene
import { entryMetadata, refFor } from "../refs.js";
import { materializeSecret, type MaterializedSecret } from "../secrets.js";
import { clipboardSink } from "../sinks.js";
import { createIndexBackup, type BackupResult } from "../backups.js";
import {
loadConfig,
mutateConfig,
@@ -161,12 +163,24 @@ function isArchived(entry: Entry): boolean {
/** Apply a change under the vault lock, re-reading first. */
async function mutate(s: Session, fn: (vault: OpenedVault) => void): Promise<void> {
const { path, mnemonic } = s.vault;
let backup: BackupResult | undefined;
await withVaultLock(path, async () => {
const fresh = await openVault(path, mnemonic);
fn(fresh);
await saveVaultHoldingLock(fresh);
s.vault = fresh;
try {
backup = await createIndexBackup({ indexPath: path, config: s.config });
} catch {
// Best-effort: never turn a committed write into a visible failure.
}
});
// The one backup failure worth interrupting for. A configured second
// location that silently stops working is exactly the false assurance the
// setting is supposed to provide protection against.
if (backup?.mirrorError) {
warn(s.ui, `Additional backup location failed: ${backup.mirrorError}`);
}
}
async function reload(s: Session): Promise<void> {
@@ -1034,7 +1048,15 @@ async function settingsMenu(s: Session): Promise<void> {
await toggleConfig(s, "offline_mode", "Offline Mode");
break;
case "17":
await toggleConfig(s, "quick_unlock_enabled", "Quick Unlock");
// Kept in the menu for parity with Python's item list, but it does
// not toggle anything here. In Python the flag never changed how
// unlocking works either -- it writes an audit entry and raises a
// security finding. A switch that silently stores a preference
// nothing reads is worse than one that says so.
warn(s.ui, "Quick Unlock is not implemented in this build.");
s.ui.say("Unlocking always requires the master password. Use 'vault unlock'");
s.ui.say("with a TTL if you want a window where the seed stays available.");
await pause(s.ui);
break;
case "18":
warn(s.ui, "The semantic index is not part of this build.");
@@ -1466,6 +1488,19 @@ async function importDatabase(s: Session): Promise<void> {
await pause(s.ui);
return;
}
// Check which profile the backup came from before importing it. A
// seed-only backup is bound to its seed implicitly -- the wrong seed cannot
// decrypt it -- but a plaintext one imports into any profile and then
// re-derives every password, SSH key, PGP key and seed from THIS profile's
// seed, silently producing different secrets than the backup held. Nothing
// errors; the user simply gets wrong passwords.
const wrapper = parseBackupWrapper(raw);
if (wrapper.fingerprint !== s.fingerprint) {
warn(s.ui, `This backup belongs to profile ${wrapper.fingerprint}, not ${s.fingerprint}.`);
warn(s.ui, "Its entries would be re-derived from this profile's seed, so every");
warn(s.ui, "password, key and seed would come out DIFFERENT from the backup.");
if (!(await confirm(s.ui, "Import anyway?"))) return;
}
const imported = (await importBackup(raw, { mnemonic: s.vault.mnemonic })) as {
entries?: Record<string, unknown>;
schema_version?: number;
@@ -1507,6 +1542,8 @@ async function exportTotpCodes(s: Session): Promise<void> {
async function setBackupLocation(s: Session): Promise<void> {
title(s, "Main Menu > Settings > Additional backup location");
s.ui.say(`Current: ${String(s.config["additional_backup_path"] || "(none)")}`);
s.ui.say("Every change to the vault is snapshotted to the profile's backups/");
s.ui.say("directory. Set a path here to mirror each snapshot to a second place.");
const path = await s.ui.ask("New path (blank to clear): ");
await saveSetting(s, "additional_backup_path", path);
ok(s.ui, path ? `Additional backups will be written to ${path}` : "Additional backup cleared.");