From 46ff51f3d64a2e7a85deae90688b4312ab4f15fa Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN Date: Wed, 19 Aug 2026 09:27:39 -0400 Subject: [PATCH] feat(cli): close the remaining command-surface gaps with the Python CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six commands existed in Python (and several in the TS TUI or API) but had no TypeScript CLI equivalent: vault stats — counts by kind, archived, relays; asserts no secret reaches stdout vault change-password — re-wraps the parent seed, honouring the profile's configured kdf_iterations the way the TUI does. Passwords come from the environment, never argv: a password on the command line is in the process list and the shell history. vault reveal-parent-seed — same egress rule as `fingerprint create`: this is the one secret that unlocks everything, so it refuses to print into a pipe (a log, a transcript, an agent's context) and writes a 0600 file it will not clobber. entry export-totp — every 2FA secret, written through atomicWrite for a genuine 0600 inode. Secrets and otpauth URIs, not codes: a code expires in seconds and cannot seed an authenticator, so an export of codes looks correct and is useless. Same bug that the API tests caught. config toggle-secret-mode — `config set` could already do this, but only if config toggle-offline the caller knew the key and that the value is a JSON boolean. These read and flip. api stop — POSTs /shutdown; token from SEEDPASS_API_TOKEN rather than argv, like every other credential. `api` becomes a group with `start` as the default subcommand, so `api` alone keeps working. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN --- js/packages/cli/src/program.ts | 217 +++++++++++++++++++++++++- js/packages/cli/test/profiles.test.ts | 99 +++++++++++- 2 files changed, 311 insertions(+), 5 deletions(-) diff --git a/js/packages/cli/src/program.ts b/js/packages/cli/src/program.ts index 9506b253..b4afa853 100644 --- a/js/packages/cli/src/program.ts +++ b/js/packages/cli/src/program.ts @@ -154,7 +154,7 @@ async function readSecretInput( function resolveHome(p: string): string { return p.startsWith("~") ? join(homedir(), p.slice(1)) : p; } -import { AppDir, resolveAppDir, INDEX_FILENAME, BACKUP_EXTENSION, defaultBackupFilename } from "./appDir.js"; +import { AppDir, resolveAppDir, INDEX_FILENAME, BACKUP_EXTENSION, defaultBackupFilename, DEFAULT_PBKDF2_ITERATIONS } from "./appDir.js"; import { loadConfig, saveConfig, @@ -915,6 +915,50 @@ export function buildProgram(io: ProgramIo = defaultIo): Command { io.out(JSON.stringify(rows, null, 2)); }); + entry + .command("export-totp ") + .description( + "PLAINTEXT EGRESS: write every TOTP secret to a 0600 file (secrets, " + + "not codes — the form an authenticator can be seeded from)", + ) + .option("--overwrite", "replace an existing file") + .action(async (destFile: string, cmdOpts: { overwrite?: boolean }) => { + const vault = await openFromOptions(program.opts()); + const rows = Object.entries(vault.index.entries) + .filter(([, e]) => e.kind === "totp" && !e.archived) + .map(([, e]) => { + const totp = e as { + secret?: string; index?: number; period: number; digits: number; label: string; + }; + // The SECRET, not a code: a code expires in seconds and cannot seed + // an authenticator. Imported entries carry theirs; deterministic + // ones re-derive it. + const secret = totp.secret ?? deriveTotpSecret(vault.mnemonic, totp.index ?? 0); + return { + label: totp.label, + secret, + period: totp.period, + digits: totp.digits, + uri: + `otpauth://totp/${encodeURIComponent(totp.label)}?secret=${secret}` + + `&issuer=SeedPass&period=${totp.period}&digits=${totp.digits}`, + }; + }); + const resolved = resolveHome(destFile); + if (existsSync(resolved) && !cmdOpts.overwrite) { + throw new Error(`${resolved} already exists; pass --overwrite to replace it`); + } + // Fresh 0600 inode via atomicWrite rather than writeFile's mode, which + // applies only at creation and would leave an existing 0644 file as it + // found it. This file is every 2FA secret in the vault. + await atomicWrite(resolved, utf8(JSON.stringify({ entries: rows }, null, 2))); + io.out(JSON.stringify({ exported: resolved, count: rows.length })); + io.err( + "That file contains the 2FA secrets themselves, not codes. Move it " + + "somewhere safe or delete it.", + ); + }); + entry .command("reveal ") .description("PLAINTEXT EGRESS: print the secret to stdout") @@ -1145,6 +1189,34 @@ export function buildProgram(io: ProgramIo = defaultIo): Command { io.out(JSON.stringify({ [key]: parsed })); }); + /** + * Convenience toggles, matching Python's `config toggle-*`. + * + * `config set` can do this, but only if the caller knows the key name and + * that the value is a JSON boolean. These read the current value and flip + * it, which is what a person at a prompt actually wants. + */ + for (const [name, key, label] of [ + ["toggle-secret-mode", "secret_mode_enabled", "Secret Mode"], + ["toggle-offline", "offline_mode", "Offline Mode"], + ] as const) { + config + .command(name) + .description(`flip ${label} for the active profile`) + .action(async () => { + const opts = program.opts() as GlobalOpts; + const app = new AppDir(resolveAppDir(opts.appDir)); + const fp = await currentFingerprint(app, opts); + const mnemonic = await resolveMnemonic(app, opts); + const next = await mutateConfig(app.profileDir(fp), mnemonic, (cfg) => { + const value = !cfg[key]; + cfg[key] = value; + return value; + }); + io.out(JSON.stringify({ [key]: next })); + }); + } + const nostr = program.command("nostr").description("Nostr relay sync"); async function nostrContext(opts: GlobalOpts) { @@ -1523,8 +1595,10 @@ export function buildProgram(io: ProgramIo = defaultIo): Command { io.out(JSON.stringify({ agent: "stopped" })); }); - program - .command("api") + const apiCmd = program.command("api").description("HTTP API server"); + + apiCmd + .command("start", { isDefault: true }) .description( "serve the HTTP API on loopback (holds an unlocked seed; prints a " + "bearer token once)", @@ -1569,6 +1643,32 @@ export function buildProgram(io: ProgramIo = defaultIo): Command { }, ); + apiCmd + .command("stop") + .description("ask a running API server to shut down") + .option("--host ", "server address", "127.0.0.1") + .option("--port ", "server port", "8765") + .action(async (o: { host: string; port: string }) => { + // The token is only ever printed once, at startup, so the caller has to + // supply it. Read from the environment rather than argv, like every + // other credential here. + const token = process.env["SEEDPASS_API_TOKEN"]; + if (!token) { + throw new Error( + "set SEEDPASS_API_TOKEN to the token the server printed when it started", + ); + } + const port = parseIntOption(o.port, "--port", { min: 1, max: 65535 }); + const res = await fetch(`http://${o.host}:${port}/api/v1/shutdown`, { + method: "POST", + headers: { authorization: `Bearer ${token}` }, + }); + if (!res.ok) { + throw new Error(`server refused the shutdown request: HTTP ${res.status}`); + } + io.out(JSON.stringify({ status: "shutting down", host: o.host, port })); + }); + const util = program.command("util").description("utility commands"); util @@ -1730,6 +1830,117 @@ export function buildProgram(io: ProgramIo = defaultIo): Command { io.out(JSON.stringify({ locked })); }); + vaultCmd + .command("stats") + .description("summarize the active profile (counts only, no secrets)") + .action(async () => { + const opts = program.opts() as GlobalOpts; + const app = new AppDir(resolveAppDir(opts.appDir)); + const vault = await openFromOptions(opts); + const byKind: Record = {}; + let archived = 0; + for (const entry of Object.values(vault.index.entries)) { + const e = entry as unknown as Record; + const kind = String(e["kind"] ?? e["type"] ?? "unknown"); + byKind[kind] = (byKind[kind] ?? 0) + 1; + if (e["archived"] === true) archived++; + } + // Config is best-effort: --vault can name an index with no profile + // beside it, and a stats command should not fail for want of settings. + let relays = 0; + let fingerprint: string | null = null; + try { + fingerprint = await currentFingerprint(app, opts); + const cfg = await loadConfig(app.profileDir(fingerprint), vault.mnemonic); + relays = Array.isArray(cfg["relays"]) ? (cfg["relays"] as unknown[]).length : 0; + } catch { + // no profile context; counts below are still accurate + } + io.out( + JSON.stringify( + { + fingerprint, + schema_version: vault.index.schema_version, + total_entries: Object.keys(vault.index.entries).length, + archived_entries: archived, + by_kind: byKind, + relays_configured: relays, + }, + null, + 2, + ), + ); + }); + + vaultCmd + .command("change-password") + .description( + "re-wrap the parent seed under a new master password (reads " + + "SEEDPASS_PASSWORD and SEEDPASS_NEW_PASSWORD)", + ) + .action(async () => { + const opts = program.opts() as GlobalOpts; + const app = new AppDir(resolveAppDir(opts.appDir)); + const fp = await currentFingerprint(app, opts); + // Never from argv: a password on the command line is in the process + // list and the shell history. + const current = process.env["SEEDPASS_PASSWORD"]; + const next = process.env["SEEDPASS_NEW_PASSWORD"]; + if (!current || !next) { + throw new Error( + "set SEEDPASS_PASSWORD and SEEDPASS_NEW_PASSWORD in the environment " + + "(never pass a password on the command line)", + ); + } + // Re-wrap at the profile's configured strength, so the kdf_iterations + // setting is honoured here the way the TUI honours it. + let iterations = DEFAULT_PBKDF2_ITERATIONS; + try { + const mnemonic = await app.decryptParentSeed(fp, current); + const cfg = await loadConfig(app.profileDir(fp), mnemonic); + iterations = Number(cfg["kdf_iterations"] ?? DEFAULT_PBKDF2_ITERATIONS); + } catch { + throw new Error("current password is incorrect"); + } + await app.changePassword(fp, current, next, iterations); + io.out(JSON.stringify({ status: "ok", fingerprint: fp, kdf_iterations: iterations })); + }); + + vaultCmd + .command("reveal-parent-seed") + .description("PLAINTEXT EGRESS: the master seed phrase for this profile") + .option("--out ", "write to a 0600 file instead of printing") + .option("--show", "print to stdout even when it is not a terminal") + .action(async (o: { out?: string; show?: boolean }) => { + if (o.show && o.out) throw new Error("--show and --out are mutually exclusive"); + const opts = program.opts() as GlobalOpts; + const app = new AppDir(resolveAppDir(opts.appDir)); + const mnemonic = await resolveMnemonic(app, opts); + // Same egress rule as `fingerprint create`: this is the one secret that + // unlocks everything, so it never lands in a pipe by default. + const toTty = !o.out && !o.show && Boolean(process.stdout.isTTY); + if (!o.out && !o.show && !toTty) { + throw new Error( + "refusing to print the parent seed into a pipe (a log, a transcript, " + + "an AI agent's context). Pass --out for a 0600 file, or " + + "--show if you really want it on stdout.", + ); + } + if (o.out) { + try { + await writeFile(o.out, mnemonic + "\n", { encoding: "utf8", mode: 0o600, flag: "wx" }); + } catch (e) { + if ((e as NodeJS.ErrnoException).code === "EEXIST") { + throw new Error(`${o.out} already exists; choose another path.`); + } + throw e; + } + io.err(`Parent seed written to ${o.out} (0600). Move it offline.`); + } else { + io.out(mnemonic); + } + }); + vaultCmd .command("import ") .description("restore a portable backup into the active vault") diff --git a/js/packages/cli/test/profiles.test.ts b/js/packages/cli/test/profiles.test.ts index fb35b691..0992cf9e 100644 --- a/js/packages/cli/test/profiles.test.ts +++ b/js/packages/cli/test/profiles.test.ts @@ -5,7 +5,7 @@ */ import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { mkdtemp, readFile, writeFile, stat } from "node:fs/promises"; import { existsSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -19,7 +19,10 @@ import { encryptV3, deriveIndexKeyBytes, } from "@seedpass/core"; -import { buildProgram, AgentDaemon, agentSocketPath, type ProgramIo } from "../src/index.js"; +import { buildProgram, AgentDaemon, agentSocketPath, AppDir, type ProgramIo } from "../src/index.js"; + +/** Owner env: seed in the environment, as the other suites use it. */ +const asOwnerEnv = () => ({ SEEDPASS_MNEMONIC: MNEMONIC }); const MNEMONIC = mnemonics["abandon12"]!; const FINGERPRINT = generateFingerprint(MNEMONIC); @@ -206,3 +209,95 @@ describe("config", () => { expect(redacted.pin_hash).toBe(""); }); }); + +describe("commands that close the gap with the Python CLI", () => { + it("vault stats counts entries without disclosing any", async () => { + await run(asOwnerEnv(), "entry", "add", "password", "stats-a", "--length", "16"); + await run(asOwnerEnv(), "entry", "add", "key-value", "stats-b", "K", "stats-secret-value"); + const r = await run(asOwnerEnv(), "vault", "stats"); + const stats = JSON.parse(r.stdout); + expect(stats.fingerprint).toBe(FINGERPRINT); + expect(stats.total_entries).toBeGreaterThanOrEqual(2); + expect(stats.by_kind.password).toBeGreaterThanOrEqual(1); + // A stats command has no business emitting a secret. + expect(r.stdout).not.toContain("stats-secret-value"); + }); + + it("vault change-password re-wraps the seed and the old password stops working", async () => { + const NEW = "changed-master-password"; + const r = await run( + { SEEDPASS_MNEMONIC: undefined, SEEDPASS_PASSWORD: PASSWORD, SEEDPASS_NEW_PASSWORD: NEW }, + "vault", "change-password", + ); + expect(JSON.parse(r.stdout).status).toBe("ok"); + + const app = new AppDir(appDir); + // The new password opens it... + await expect(app.decryptParentSeed(FINGERPRINT, NEW)).resolves.toBe(MNEMONIC); + // ...and the old one does not. + await expect(app.decryptParentSeed(FINGERPRINT, PASSWORD)).rejects.toThrow(); + + // Put it back so the rest of the file's expectations still hold. + await app.changePassword(FINGERPRINT, NEW, PASSWORD); + }); + + it("vault change-password refuses a wrong current password", async () => { + const r = await run( + { SEEDPASS_MNEMONIC: undefined, SEEDPASS_PASSWORD: "wrong", SEEDPASS_NEW_PASSWORD: "x" }, + "vault", "change-password", + ); + expect(String((r.error as Error).message)).toContain("incorrect"); + }); + + it("vault reveal-parent-seed refuses to drop the seed into a pipe", async () => { + // stdout is not a TTY under the test runner, which is exactly the case + // the guard exists for: a log, a transcript, an agent's context. + const r = await run(asOwnerEnv(), "vault", "reveal-parent-seed"); + expect(String((r.error as Error).message)).toContain("refusing to print the parent seed"); + expect(r.stdout).not.toContain("abandon"); + }); + + it("vault reveal-parent-seed writes a 0600 file and will not clobber one", async () => { + const dir = await mkdtemp(join(tmpdir(), "seedpass-seedout-")); + const out = join(dir, "seed.txt"); + const r = await run(asOwnerEnv(), "vault", "reveal-parent-seed", "--out", out); + expect(r.error).toBeUndefined(); + expect((await readFile(out, "utf8")).trim()).toBe(MNEMONIC); + expect((await stat(out)).mode & 0o777).toBe(0o600); + + const second = await run(asOwnerEnv(), "vault", "reveal-parent-seed", "--out", out); + expect(String((second.error as Error).message)).toContain("already exists"); + }); + + it("entry export-totp writes secrets, not codes, at 0600", async () => { + await run(asOwnerEnv(), "entry", "add", "totp", "export-me"); + const dir = await mkdtemp(join(tmpdir(), "seedpass-totpout-")); + const out = join(dir, "totp.json"); + const r = await run(asOwnerEnv(), "entry", "export-totp", out); + expect(JSON.parse(r.stdout).count).toBeGreaterThanOrEqual(1); + expect((await stat(out)).mode & 0o777).toBe(0o600); + + const exported = JSON.parse(await readFile(out, "utf8")); + const row = exported.entries.find((e: { label: string }) => e.label === "export-me"); + // base32 secret, not a six-digit code — an authenticator cannot be seeded + // from a code, so an export of codes would look right and be useless. + expect(row.secret).toMatch(/^[A-Z2-7]+$/); + expect(row.secret).not.toMatch(/^\d{6}$/); + expect(row.uri).toContain("otpauth://totp/"); + + // And it refuses to overwrite without being told to. + expect(String(((await run(asOwnerEnv(), "entry", "export-totp", out)).error as Error).message)) + .toContain("--overwrite"); + }); + + it("config toggles flip the stored value both ways", async () => { + const first = JSON.parse((await run(asOwnerEnv(), "config", "toggle-secret-mode")).stdout); + expect(first.secret_mode_enabled).toBe(true); + const second = JSON.parse((await run(asOwnerEnv(), "config", "toggle-secret-mode")).stdout); + expect(second.secret_mode_enabled).toBe(false); + + const offline = JSON.parse((await run(asOwnerEnv(), "config", "toggle-offline")).stdout); + expect(offline.offline_mode).toBe(true); + await run(asOwnerEnv(), "config", "toggle-offline"); + }); +});