mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-10-27 07:52:03 +00:00
feat(cli): close the remaining command-surface gaps with the Python CLI
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) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
This commit is contained in:
1 parent
348245e14a
commit
46ff51f3d6
2 files changed
+311
-5
No files matched your search
@@ -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 <destFile>")
|
||||
.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 <refOrQuery>")
|
||||
.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 <host>", "server address", "127.0.0.1")
|
||||
.option("--port <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<string, number> = {};
|
||||
let archived = 0;
|
||||
for (const entry of Object.values(vault.index.entries)) {
|
||||
const e = entry as unknown as Record<string, unknown>;
|
||||
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 <file>", "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 <file> 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 <srcFile>")
|
||||
.description("restore a portable backup into the active vault")
|
||||
|
||||
@@ -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("<redacted>");
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user