diff --git a/README.md b/README.md index 6eead88d..623532ff 100644 --- a/README.md +++ b/README.md @@ -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" diff --git a/TODO.md b/TODO.md index 8759405e..08827ce2 100644 --- a/TODO.md +++ b/TODO.md @@ -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 ` and the TUI database export generate + `seedpass--.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. diff --git a/docs/typescript_migration_guide.md b/docs/typescript_migration_guide.md index 32eab888..d66d470b 100644 --- a/docs/typescript_migration_guide.md +++ b/docs/typescript_migration_guide.md @@ -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) diff --git a/js/packages/cli/src/appDir.ts b/js/packages/cli/src/appDir.ts index e6714757..4e888ed8 100644 --- a/js/packages/cli/src/appDir.ts +++ b/js/packages/cli/src/appDir.ts @@ -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; diff --git a/js/packages/cli/src/program.ts b/js/packages/cli/src/program.ts index 4830b3c7..587084e5 100644 --- a/js/packages/cli/src/program.ts +++ b/js/packages/cli/src/program.ts @@ -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 ") - .description("write a portable backup (encrypted by default)") + .description( + "write a portable backup (encrypted by default); give a directory to " + + `get a default seedpass--${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, { mnemonic: vault.mnemonic, - fingerprint: generateFingerprint(vault.mnemonic), + fingerprint, encrypt: !o.plaintext, }); if (existsSync(destFile) && !o.overwrite) { diff --git a/js/packages/cli/src/tui/menus.ts b/js/packages/cli/src/tui/menus.ts index 2490386e..ed96dc53 100644 --- a/js/packages/cli/src/tui/menus.ts +++ b/js/packages/cli/src/tui/menus.ts @@ -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 { async function exportDatabase(s: Session): Promise { 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, { mnemonic: s.vault.mnemonic, fingerprint: s.fingerprint, diff --git a/js/packages/cli/test/cli.test.ts b/js/packages/cli/test/cli.test.ts index 3457b182..3bf605f4 100644 --- a/js/packages/cli/test/cli.test.ts +++ b/js/packages/cli/test/cli.test.ts @@ -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--.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); + }); +}); diff --git a/js/packages/cli/test/tuiMenus.test.ts b/js/packages/cli/test/tuiMenus.test.ts index 85221012..5d2f3c3c 100644 --- a/js/packages/cli/test/tuiMenus.test.ts +++ b/js/packages/cli/test/tuiMenus.test.ts @@ -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`); + }); +});