feat(cli): .seedpass backup naming (phase 1 -- convention only, zero format change)

A portable backup is an encrypted, checksummed, cross-impl-verified file;
its only sin was landing on disk as an anonymous .json. Exports now
self-identify: point `vault export` (or the TUI database export) at a
directory and the file is named seedpass-<fingerprint>-<date>.seedpass --
tied to the profile it restores and sortable by date. The TUI also appends
.seedpass to an extensionless destination, since interactive input has no
script-compatibility concerns.

The zero-risk boundary, kept deliberately: an explicit file path is used
exactly as typed -- a script that exports to backup.json still gets
backup.json -- and imports sniff content, never extensions, so any old
export restores unchanged. No byte of the format moved, which is what makes
this safe to ship ahead of the review gate while format_version 2 (magic
header) and the OS icon association stay queued behind it.

Tests pin all three behaviours in both surfaces: directory target gets the
generated name, extensionless TUI input gets the suffix, explicit paths pass
through untouched. 158 CLI tests pass.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Tkj4rzKBA3uhfgk7kqjsb8
This commit is contained in:
thePR0M3TH3ANandClaude Opus 5 committed 2026-08-18 13:48:36 -04:00
1 parent 7748d6b5e9
commit 0482bc3dfd
8 files changed
+109 -16

No files matched your search

+1 -1
View File
@@ -325,7 +325,7 @@ You can then launch SeedPass and create a backup:
seedpass
# Export your index
seedpass vault export --file "~/seedpass_backup.json"
seedpass vault export --file "~/seedpass_backup.seedpass"
# Later you can restore it
seedpass vault import --file "~/seedpass_backup.json"
+9 -6
View File
@@ -293,9 +293,12 @@ before and after each was confirmed. Ordered by severity.
field tolerance check in both implementations, retention/mirror
policy, and the same metadata-leakage analysis as relays (a pubkey's
blob list is public).
- [ ] **`.seedpass` file extension for exports/backups** — phase 1: default
export filenames to `.seedpass`, accept any extension on import (no
byte-format change, cross-impl safe). Phase 2 (optional, versioned):
portable-backup format_version 2 with a magic header for robust file
detection. Plus OS file association + icon (XDG MIME on Linux via
install.sh, registry on Windows, UTI if a mac bundle ever exists).
- [x] **(phase 1 done 2026-08-18)** **`.seedpass` file extension** — CLI
`vault export <dir>` and the TUI database export generate
`seedpass-<fingerprint>-<date>.seedpass`; extensionless TUI input gets
the suffix; explicit paths are used exactly as typed (scripts
unaffected); imports never cared about extensions. Zero byte-format
change. Remaining (deliberately later): phase 2 format_version 2 with
a magic header (versioned, both implementations), and OS file
association + icon from `logo/svg/` (XDG MIME via install.sh, registry
via install.ps1) — installer work, after #34/#36.
+1 -1
View File
@@ -28,7 +28,7 @@ Take a backup you can restore without either implementation:
```bash
# A portable, encrypted export (readable by both implementations)
seedpass vault export --file ~/seedpass-pre-migration.json
seedpass vault export --file ~/seedpass-pre-migration.seedpass
# Plus a copy of the raw profile directory
cp -a ~/.seedpass ~/.seedpass.backup-$(date +%Y%m%d)
+16
View File
@@ -46,6 +46,22 @@ export const INDEX_FILENAME = "seedpass_entries_db.json.enc";
export const CONFIG_FILENAME = "seedpass_config.json.enc";
export const PARENT_SEED_FILENAME = "parent_seed.enc";
/** Canonical extension for portable backups. The bytes are unchanged — this
* is a naming convention (TODO records an optional future format_version 2
* with a magic header; that is a separate, versioned change). */
export const BACKUP_EXTENSION = ".seedpass";
/**
* Default filename for a portable backup: self-identifying on a disk full of
* files, sortable by date, and tied to the profile it restores.
*/
export function defaultBackupFilename(fingerprint: string, when = new Date()): string {
const y = when.getFullYear();
const m = String(when.getMonth() + 1).padStart(2, "0");
const d = String(when.getDate()).padStart(2, "0");
return `seedpass-${fingerprint}-${y}${m}${d}${BACKUP_EXTENSION}`;
}
export interface FingerprintsFile {
fingerprints: string[];
last_used: string | null;
+15 -4
View File
@@ -150,7 +150,7 @@ async function readSecretInput(
function resolveHome(p: string): string {
return p.startsWith("~") ? join(homedir(), p.slice(1)) : p;
}
import { AppDir, resolveAppDir, INDEX_FILENAME } from "./appDir.js";
import { AppDir, resolveAppDir, INDEX_FILENAME, BACKUP_EXTENSION, defaultBackupFilename } from "./appDir.js";
import { loadConfig, saveConfig, mutateConfig } from "./configFile.js";
import { AgentClient, AgentDaemon, agentSocketPath, DEFAULT_TTL_SECONDS } from "./agent.js";
import { AuditLog } from "./audit.js";
@@ -1478,14 +1478,25 @@ export function buildProgram(io: ProgramIo = defaultIo): Command {
vaultCmd
.command("export <destFile>")
.description("write a portable backup (encrypted by default)")
.description(
"write a portable backup (encrypted by default); give a directory to " +
`get a default seedpass-<fingerprint>-<date>${BACKUP_EXTENSION} name`,
)
.option("--plaintext", "HIGH RISK: export without encryption")
.option("--overwrite", "replace an existing file")
.action(async (destFile: string, o: { plaintext?: boolean; overwrite?: boolean }) => {
.action(async (rawDest: string, o: { plaintext?: boolean; overwrite?: boolean }) => {
const vault = await openFromOptions(program.opts());
const fingerprint = generateFingerprint(vault.mnemonic);
// An explicit file path is used exactly as typed — scripts depend on
// that. Only a directory target gets the generated name.
const resolved = resolveHome(rawDest);
const destFile =
existsSync(resolved) && statSync(resolved).isDirectory()
? join(resolved, defaultBackupFilename(fingerprint))
: resolved;
const wrapper = await exportBackup(vault.index as Record<string, unknown>, {
mnemonic: vault.mnemonic,
fingerprint: generateFingerprint(vault.mnemonic),
fingerprint,
encrypt: !o.plaintext,
});
if (existsSync(destFile) && !o.overwrite) {
+14 -4
View File
@@ -15,7 +15,7 @@
import process from "node:process";
import { join, basename } from "node:path";
import { readFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { existsSync, statSync } from "node:fs";
import {
addPasswordEntry,
addTotpDeterministic,
@@ -41,7 +41,7 @@ import {
type Entry,
type VaultIndex,
} from "@seedpass/core";
import { AppDir, INDEX_FILENAME, DEFAULT_PBKDF2_ITERATIONS } from "../appDir.js";
import { AppDir, INDEX_FILENAME, DEFAULT_PBKDF2_ITERATIONS, BACKUP_EXTENSION, defaultBackupFilename } from "../appDir.js";
import { atomicWrite, openVault, saveVaultHoldingLock, withVaultLock, type OpenedVault } from "../vaultFile.js";
import { entryMetadata, refFor } from "../refs.js";
import { materializeSecret } from "../secrets.js";
@@ -1405,8 +1405,18 @@ async function backupParentSeed(s: Session): Promise<void> {
async function exportDatabase(s: Session): Promise<void> {
title(s, "Main Menu > Settings > Export database");
const dest = await s.ui.ask("Destination file: ");
if (!dest) return;
const suggested = defaultBackupFilename(s.fingerprint);
const raw = await s.ui.ask(`Destination file or directory (e.g. ${suggested}): `);
if (!raw) return;
// Interactive convenience, not applied to CLI scripts: a directory gets
// the generated name inside it, and a bare name with no extension gets
// the .seedpass suffix so backups self-identify on disk.
let dest = raw;
if (existsSync(dest) && statSync(dest).isDirectory()) {
dest = join(dest, suggested);
} else if (!basename(dest).includes(".")) {
dest = `${dest}${BACKUP_EXTENSION}`;
}
const payload = await exportBackup(s.vault.index as unknown as Record<string, unknown>, {
mnemonic: s.vault.mnemonic,
fingerprint: s.fingerprint,
+28
View File
@@ -528,3 +528,31 @@ describe("unknown entry kinds at the CLI surface", () => {
expect(rows.find((row) => row["kind"] === "bitlogin_org")).toBeDefined();
});
});
describe(".seedpass export naming", () => {
it("a directory target gets the generated seedpass-<fp>-<date>.seedpass name", async () => {
const dir = await mkdtemp(join(tmpdir(), "seedpass-name-"));
const r = await run("--vault", vaultPath, "vault", "export", dir);
expect(r.error).toBeUndefined();
const dest = String(JSON.parse(r.stdout).exported);
expect(dest.startsWith(dir)).toBe(true);
expect(dest).toMatch(/seedpass-[0-9A-F]{16}-\d{8}\.seedpass$/);
// And the file restores like any other backup: same wrapper shape.
const wrapper = JSON.parse(await readFile(dest, "utf8"));
expect(wrapper.format_version).toBe(1);
});
it("an explicit file path is used exactly as typed (scripts depend on it)", async () => {
const dir = await mkdtemp(join(tmpdir(), "seedpass-name-"));
const exact = join(dir, "my-backup.json");
const r = await run("--vault", vaultPath, "vault", "export", exact);
expect(String(JSON.parse(r.stdout).exported)).toBe(exact);
});
it("an extensionless explicit path is also used exactly as typed", async () => {
const dir = await mkdtemp(join(tmpdir(), "seedpass-name-"));
const bare = join(dir, "backup");
const r = await run("--vault", vaultPath, "vault", "export", bare);
expect(String(JSON.parse(r.stdout).exported)).toBe(bare);
});
});
+25
View File
@@ -551,3 +551,28 @@ describe("secret-bearing exports get a fresh 0600 file", () => {
expect(await readFile(dest, "utf8")).toBe("precious");
});
});
describe(".seedpass export naming in the TUI", () => {
it("appends .seedpass to an extensionless destination", async () => {
const dest = join(appDir, "my-backup");
const ui = await run("7", "7", dest, "", "", "");
expect(ui.text).toContain(`Exported to ${dest}.seedpass`);
const wrapper = JSON.parse(await readFile(`${dest}.seedpass`, "utf8"));
expect(wrapper.format_version).toBe(1);
});
it("a directory destination gets the generated self-identifying name", async () => {
const ui = await run("7", "7", appDir, "", "", "");
const match = ui.text.match(/Exported to (.*seedpass-[0-9A-F]{16}-\d{8}\.seedpass)/);
expect(match).not.toBeNull();
const wrapper = JSON.parse(await readFile(match![1]!, "utf8"));
expect(wrapper.format_version).toBe(1);
});
it("an explicit extension is respected as typed", async () => {
const dest = join(appDir, "explicit.json");
const ui = await run("7", "7", dest, "", "", "");
expect(ui.text).toContain(`Exported to ${dest}`);
expect(ui.text).not.toContain(`${dest}.seedpass`);
});
});