mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-10-27 07:52:03 +00:00
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:
1 parent
063fae191f
commit
4b01ac62e0
8 files changed
+591
-16
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.");
|
||||
|
||||
@@ -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<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");
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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<string> {
|
||||
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<string, unknown>)["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();
|
||||
|
||||
@@ -90,6 +90,7 @@ export {
|
||||
export {
|
||||
importBackup,
|
||||
exportBackup,
|
||||
parseBackupWrapper,
|
||||
portableBackupSchema,
|
||||
BackupImportError,
|
||||
PORTABLE_FORMAT_VERSION,
|
||||
|
||||
@@ -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<Record<string, unknown>> {
|
||||
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");
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user