diff --git a/js/packages/cli/src/backups.ts b/js/packages/cli/src/backups.ts new file mode 100644 index 00000000..7c8125a9 --- /dev/null +++ b/js/packages/cli/src/backups.ts @@ -0,0 +1,143 @@ +/** + * Rolling index backups, parity with Python's BackupManager. + * + * Every vault mutation leaves a timestamped copy of the encrypted index in + * `/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 { + 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; + /** Injectable for tests; defaults to now. */ + now?: number; +}): Promise { + 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 { + 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); +} diff --git a/js/packages/cli/src/program.ts b/js/packages/cli/src/program.ts index f33b3114..6c4e08b3 100644 --- a/js/packages/cli/src/program.ts +++ b/js/packages/cli/src/program.ts @@ -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( 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; } diff --git a/js/packages/cli/src/tui/menus.ts b/js/packages/cli/src/tui/menus.ts index 8ad1f42e..d972be03 100644 --- a/js/packages/cli/src/tui/menus.ts +++ b/js/packages/cli/src/tui/menus.ts @@ -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 { 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 { @@ -1034,7 +1048,15 @@ async function settingsMenu(s: Session): Promise { 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 { 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; schema_version?: number; @@ -1507,6 +1542,8 @@ async function exportTotpCodes(s: Session): Promise { async function setBackupLocation(s: Session): Promise { 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."); diff --git a/js/packages/cli/test/backups.test.ts b/js/packages/cli/test/backups.test.ts new file mode 100644 index 00000000..f637f876 --- /dev/null +++ b/js/packages/cli/test/backups.test.ts @@ -0,0 +1,186 @@ +/** + * 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 { + 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 { + 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"); + }); +}); diff --git a/js/packages/cli/test/cli.test.ts b/js/packages/cli/test/cli.test.ts index 656218c4..2b5c5dad 100644 --- a/js/packages/cli/test/cli.test.ts +++ b/js/packages/cli/test/cli.test.ts @@ -19,7 +19,7 @@ import { totpCases, mnemonics, } from "@seedpass/test-vectors"; -import { deriveIndexKeyBytes, encryptV3, utf8 } from "@seedpass/core"; +import { deriveIndexKeyBytes, encryptV3, generateFingerprint, utf8 } from "@seedpass/core"; import { buildProgram, type ProgramIo } from "../src/index.js"; const MNEMONIC = mnemonics["abandon12"]!; @@ -391,6 +391,68 @@ describe("vault export/import", () => { const forced = await run("--vault", target, "vault", "import", backup, "--yes"); expect(JSON.parse(forced.stdout).entry_count).toBe(10); }); + + /** + * A plaintext backup has no cryptographic binding to the seed it came from + * — the wrong profile can read it perfectly well. Importing one re-derives + * every password, SSH key, PGP key and seed from the TARGET profile's seed, + * so the user ends up with a vault full of entries whose secrets differ + * from the ones the backup was taken to preserve. Nothing errors on the way + * through, which is what makes it worth refusing. + */ + it("refuses a plaintext backup from a different seed unless told otherwise", async () => { + const dir = await mkdtemp(join(tmpdir(), "seedpass-foreign-")); + const foreignMnemonic = mnemonics["legal12"]!; + const saved = process.env["SEEDPASS_MNEMONIC"]; + + // A genuine backup of a genuinely different profile. + const foreignVault = join(dir, "foreign.enc"); + await writeFile( + foreignVault, + await encryptV3( + deriveIndexKeyBytes(foreignMnemonic), + utf8(JSON.stringify({ schema_version: 4, entries: {} })), + ), + ); + const backup = join(dir, "foreign-backup.json"); + process.env["SEEDPASS_MNEMONIC"] = foreignMnemonic; + const exported = await run( + "--vault", foreignVault, "vault", "export", backup, "--plaintext", + ); + expect(JSON.parse(exported.stdout).encrypted).toBe(false); + process.env["SEEDPASS_MNEMONIC"] = saved!; + + const target = join(dir, "target.enc"); + await writeFile( + target, + await encryptV3( + deriveIndexKeyBytes(MNEMONIC), + utf8(JSON.stringify({ schema_version: 4, entries: {} })), + ), + ); + + const refused = await run("--vault", target, "vault", "import", backup); + const message = String((refused.error as Error).message); + expect(message).toContain("belongs to profile"); + expect(message).toContain("--allow-fingerprint-mismatch"); + + // Still possible on purpose — the point is that it cannot happen by + // accident, not that it is forbidden. + const allowed = await run( + "--vault", target, "vault", "import", backup, "--allow-fingerprint-mismatch", + ); + expect(allowed.error).toBeUndefined(); + expect(JSON.parse(allowed.stdout).entry_count).toBe(0); + }); + + it("reports the backup's own fingerprint and mode when inspecting", async () => { + const dir = await mkdtemp(join(tmpdir(), "seedpass-inspect-")); + const dest = join(dir, "backup.json"); + await run("--vault", vaultPath, "vault", "export", dest); + const summary = JSON.parse((await run("vault", "import", dest, "--inspect")).stdout); + expect(summary.encryption_mode).toBe("seed-only"); + expect(summary.fingerprint).toBe(generateFingerprint(MNEMONIC)); + }); }); describe("document import/export", () => { diff --git a/js/packages/cli/test/tuiMenus.test.ts b/js/packages/cli/test/tuiMenus.test.ts index 5d2f3c3c..c1a7c84c 100644 --- a/js/packages/cli/test/tuiMenus.test.ts +++ b/js/packages/cli/test/tuiMenus.test.ts @@ -10,7 +10,7 @@ */ import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { mkdtemp, writeFile, mkdir, readFile } from "node:fs/promises"; +import { mkdtemp, writeFile, mkdir, readFile, readdir } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import process from "node:process"; @@ -23,6 +23,7 @@ import { addPasswordEntry, addKeyValueEntry, addTotpDeterministic, + exportBackup, type VaultIndex, } from "@seedpass/core"; import { runTui } from "../src/tui/app.js"; @@ -118,6 +119,88 @@ afterEach(() => { delete process.env["SEEDPASS_MNEMONIC"]; }); +/** + * Import guard: a plaintext backup carries no cryptographic binding to the + * seed it was taken from, so importing one into the wrong profile succeeds + * and then re-derives every secret from the WRONG seed. The TUI has to say so + * before it happens, because afterwards nothing looks broken. + */ +describe("importing a backup from another profile", () => { + const FOREIGN = mnemonics["legal12"]!; + + async function writeForeignBackup(): Promise { + const wrapper = await exportBackup( + { schema_version: 4, entries: {} }, + { mnemonic: FOREIGN, fingerprint: generateFingerprint(FOREIGN), encrypt: false }, + ); + const path = join(appDir, "foreign-backup.json"); + await writeFile(path, JSON.stringify(wrapper)); + return path; + } + + it("warns which profile the backup belongs to and abandons on refusal", async () => { + const path = await writeForeignBackup(); + const ui = await run("7", "8", path, "n", "", ""); + expect(ui.text).toContain(`belongs to profile ${generateFingerprint(FOREIGN)}`); + expect(ui.text).toContain("DIFFERENT"); + + // The vault is untouched: all four seeded entries still there. + const vault = await openVault( + join(new AppDir(appDir).profileDir(FINGERPRINT), INDEX_FILENAME), + MNEMONIC, + ); + expect(Object.keys(vault.index.entries)).toHaveLength(4); + }); + + it("still allows it when the user confirms twice", async () => { + const path = await writeForeignBackup(); + // "y" to the foreign-profile warning, then "y" to the replace-vault + // confirmation the import already had. + const ui = await run("7", "8", path, "y", "y", "", ""); + expect(ui.text).toContain("Imported 0 entries"); + }); +}); + +describe("additional backup location", () => { + it("actually writes a copy there, having said it would", async () => { + const extra = await mkdtemp(join(tmpdir(), "seedpass-tui-extra-")); + // Settings > Set additional backup location > path, then add an entry so + // there is a mutation to snapshot. + const ui = await run( + "7", "10", extra, "", "", + "1", "1", "backup-me.example", "", "", "", "", "", "", "", + ); + expect(ui.text).toContain(`Additional backups will be written to ${extra}`); + + const mirrored = await readdir(extra); + expect(mirrored).toHaveLength(1); + expect(mirrored[0]).toMatch( + new RegExp(`^${FINGERPRINT}_entries_db_backup_\\d+\\.json\\.enc$`), + ); + }); + + it("tells the user when the configured location stops working", async () => { + const app = new AppDir(appDir); + const blocker = join(app.profileDir(FINGERPRINT), "blocker-file"); + await writeFile(blocker, "x"); + const ui = await run( + "7", "10", join(blocker, "sub"), "", "", + "1", "1", "still-added.example", "", "", "", "", "", "", "", + ); + expect(ui.text).toContain("Additional backup location failed"); + + // The mutation itself still committed. + const vault = await openVault( + join(app.profileDir(FINGERPRINT), INDEX_FILENAME), + MNEMONIC, + ); + const labels = Object.values(vault.index.entries).map( + (e) => (e as unknown as Record)["label"], + ); + expect(labels).toContain("still-added.example"); + }); +}); + describe("main menu", () => { it("offers Python's eight items, in Python's order", async () => { const ui = await run(); diff --git a/js/packages/core/src/index.ts b/js/packages/core/src/index.ts index 38f7c590..464263a7 100644 --- a/js/packages/core/src/index.ts +++ b/js/packages/core/src/index.ts @@ -90,6 +90,7 @@ export { export { importBackup, exportBackup, + parseBackupWrapper, portableBackupSchema, BackupImportError, PORTABLE_FORMAT_VERSION, diff --git a/js/packages/core/src/vault/portableBackup.ts b/js/packages/core/src/vault/portableBackup.ts index 78104cd5..532bc8a4 100644 --- a/js/packages/core/src/vault/portableBackup.ts +++ b/js/packages/core/src/vault/portableBackup.ts @@ -39,19 +39,34 @@ export class BackupImportError extends Error { } } +/** + * Parse and validate a backup wrapper without decrypting or verifying it. + * + * Callers need the wrapper's metadata — `encryption_mode` to know whether a + * seed is required, `fingerprint` to check the backup belongs to the profile + * being imported into — *before* they can decide how to import. Reading those + * by pattern-matching the raw file text works until a payload happens to + * contain the same bytes; this parses the envelope instead. + * + * Structural validation only. A wrapper that parses here can still fail + * importBackup on checksum, format version, or decryption. + */ +export function parseBackupWrapper(wrapperJson: string | Uint8Array): PortableBackup { + const text = + typeof wrapperJson === "string" ? wrapperJson : new TextDecoder().decode(wrapperJson); + try { + return portableBackupSchema.parse(JSON.parse(text)); + } catch (e) { + throw new BackupImportError(`invalid backup wrapper: ${String(e)}`); + } +} + /** Import a portable backup wrapper, returning the verified index payload. */ export async function importBackup( wrapperJson: string | Uint8Array, options: { mnemonic?: string } = {}, ): Promise> { - const text = - typeof wrapperJson === "string" ? wrapperJson : new TextDecoder().decode(wrapperJson); - let wrapper: PortableBackup; - try { - wrapper = portableBackupSchema.parse(JSON.parse(text)); - } catch (e) { - throw new BackupImportError(`invalid backup wrapper: ${String(e)}`); - } + const wrapper = parseBackupWrapper(wrapperJson); if (wrapper.format_version !== PORTABLE_FORMAT_VERSION) { throw new BackupImportError("Unsupported backup format"); }