Files
SeedPass/js/packages/cli/test/backups.test.ts
T
thePR0M3TH3ANandClaude Opus 5 4b01ac62e0 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
2026-08-18 21:49:26 -04:00

187 lines
7.3 KiB
TypeScript

/**
* Rolling index backups (parity with Python's BackupManager).
*
* The setting these serve was previously inert: the TUI accepted an
* "additional backup location", replied that backups would be written there,
* and no code path ever wrote one. That is worse than having no setting at
* all — it is false assurance in the one feature a user leans on precisely
* when everything else has gone wrong. These tests exist to keep it real.
*
* Filenames must match Python's byte for byte, because the two
* implementations share a profile directory and either may be asked to
* restore from the other's snapshots.
*/
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { mkdtemp, mkdir, readdir, readFile, writeFile, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import process from "node:process";
import { mnemonics } from "@seedpass/test-vectors";
import {
generateFingerprint,
deriveIndexKeyBytes,
encryptV3,
utf8,
addPasswordEntry,
type VaultIndex,
} from "@seedpass/core";
import { buildProgram, type ProgramIo } from "../src/index.js";
import { AppDir, INDEX_FILENAME } from "../src/appDir.js";
import { createIndexBackup, backupFilename, BACKUP_DIR_NAME } from "../src/backups.js";
const MNEMONIC = mnemonics["abandon12"]!;
const FINGERPRINT = generateFingerprint(MNEMONIC);
let appDir: string;
let profileDir: string;
let indexPath: string;
async function run(...argv: string[]): Promise<string> {
const out: string[] = [];
const io: ProgramIo = { out: (l: string) => out.push(l), err: () => {} };
await buildProgram(io).parseAsync(["node", "seedpass-js", "--app-dir", appDir, ...argv]);
return out.join("\n");
}
beforeEach(async () => {
appDir = await mkdtemp(join(tmpdir(), "seedpass-backups-"));
const app = new AppDir(appDir);
await app.mutateFingerprints((data) => {
data.fingerprints.push(FINGERPRINT);
data.names[FINGERPRINT] = "main";
data.last_used = FINGERPRINT;
});
profileDir = app.profileDir(FINGERPRINT);
await mkdir(profileDir, { recursive: true });
const index = { schema_version: 4, entries: {} } as VaultIndex;
addPasswordEntry(index, "seed.example", 16, {});
indexPath = join(profileDir, INDEX_FILENAME);
await writeFile(
indexPath,
await encryptV3(deriveIndexKeyBytes(MNEMONIC), utf8(JSON.stringify(index))),
);
process.env["SEEDPASS_MNEMONIC"] = MNEMONIC;
});
afterEach(() => {
delete process.env["SEEDPASS_MNEMONIC"];
});
async function backupNames(): Promise<string[]> {
try {
return (await readdir(join(profileDir, BACKUP_DIR_NAME))).sort();
} catch {
return [];
}
}
describe("createIndexBackup", () => {
it("writes a Python-named snapshot at 0600 and copies the ciphertext verbatim", async () => {
const result = await createIndexBackup({ indexPath, config: {}, now: 1111 });
expect(result.written).toBe(join(profileDir, BACKUP_DIR_NAME, backupFilename(1111)));
// Exactly Python's BACKUP_FILENAME_TEMPLATE.
expect(backupFilename(1111)).toBe("entries_db_backup_1111.json.enc");
const mode = (await stat(result.written!)).mode & 0o777;
expect(mode).toBe(0o600);
// A copy of the encrypted file — the backup path never decrypts anything.
expect(await readFile(result.written!)).toEqual(await readFile(indexPath));
});
it("mirrors to additional_backup_path, prefixed by fingerprint as Python does", async () => {
const extra = await mkdtemp(join(tmpdir(), "seedpass-extra-"));
const result = await createIndexBackup({
indexPath,
config: { additional_backup_path: extra },
now: 2222,
});
const expected = join(extra, `${FINGERPRINT}_${backupFilename(2222)}`);
expect(result.mirrored).toBe(expected);
expect((await stat(expected)).mode & 0o777).toBe(0o600);
expect(await readFile(expected)).toEqual(await readFile(indexPath));
});
it("creates the additional location if it does not exist yet", async () => {
const extra = join(await mkdtemp(join(tmpdir(), "seedpass-extra-")), "nested", "dir");
const result = await createIndexBackup({
indexPath,
config: { additional_backup_path: extra },
now: 3333,
});
expect(result.mirrored).toContain("nested");
expect(result.mirrorError).toBeUndefined();
});
it("reports a failed mirror instead of failing the backup", async () => {
// A path whose parent is a FILE cannot be made into a directory.
const blocker = join(profileDir, "not-a-dir");
await writeFile(blocker, "x");
const result = await createIndexBackup({
indexPath,
config: { additional_backup_path: join(blocker, "sub") },
now: 4444,
});
// The primary snapshot still landed; only the mirror failed, and it said so.
expect(result.written).not.toBeNull();
expect(result.mirrorError).toBeTruthy();
expect(result.mirrored).toBeUndefined();
});
it("honours backup_interval, and does not throttle when it is 0", async () => {
await createIndexBackup({ indexPath, config: { backup_interval: 600 }, now: 5000 });
// Inside the window: skipped.
const throttled = await createIndexBackup({
indexPath, config: { backup_interval: 600 }, now: 5100,
});
expect(throttled.written).toBeNull();
expect(throttled.skipped).toBe("throttled");
// Past the window: written.
const later = await createIndexBackup({
indexPath, config: { backup_interval: 600 }, now: 5700,
});
expect(later.written).not.toBeNull();
// Default config has no interval, so every write snapshots.
const always = await createIndexBackup({ indexPath, config: {}, now: 5701 });
expect(always.written).not.toBeNull();
});
it("skips quietly when there is no index to copy", async () => {
const result = await createIndexBackup({
indexPath: join(profileDir, "missing.enc"), config: {}, now: 6000,
});
expect(result.written).toBeNull();
expect(result.skipped).toBe("no-index");
});
});
describe("the CLI snapshots the vault on every mutation", () => {
it("writes a backup when an entry is added", async () => {
expect(await backupNames()).toHaveLength(0);
await run("entry", "add", "password", "added.example", "--length", "16");
const names = await backupNames();
expect(names).toHaveLength(1);
expect(names[0]).toMatch(/^entries_db_backup_\d+\.json\.enc$/);
});
it("mirrors CLI mutations to the configured additional location", async () => {
const extra = await mkdtemp(join(tmpdir(), "seedpass-cli-extra-"));
await run("config", "set", "additional_backup_path", extra);
await run("entry", "add", "password", "mirrored.example", "--length", "16");
const mirrored = await readdir(extra);
expect(mirrored).toHaveLength(1);
expect(mirrored[0]).toMatch(new RegExp(`^${FINGERPRINT}_entries_db_backup_\\d+\\.json\\.enc$`));
});
it("does not let an unwritable additional location fail the mutation", async () => {
const blocker = join(profileDir, "blocker-file");
await writeFile(blocker, "x");
await run("config", "set", "additional_backup_path", join(blocker, "sub"));
// The entry must still be added, and the command must not throw.
await run("entry", "add", "password", "survives.example", "--length", "16");
const rows = JSON.parse(await run("entry", "list")) as { label: string }[];
expect(rows.map((r) => r.label)).toContain("survives.example");
});
});