/** * seedpass-js command surface. * * Design constraint (plan section 9.3): default output is reference-first — * entry ids, labels, and metadata, never secret values. Plaintext egress * exists only as the distinct `entry reveal` command; `use` delivers secrets * to sinks (clipboard, child-process env, stdin) without printing them. */ import { Command } from "commander"; import process from "node:process"; import { entryMetadata, parseRef, resolveEntry } from "./refs.js"; import { materializeSecret } from "./secrets.js"; import { clipboardSink, execSink, parseCommandSpec, stdinSink } from "./sinks.js"; import { capabilities } from "./capabilities.js"; import { openVault, saveVault, saveVaultHoldingLock, withVaultLock, atomicWrite, type OpenedVault, } from "./vaultFile.js"; import { importBackup, parseBackupWrapper, exportBackup, findDerivationCollisions, generateFingerprint, assertValidMnemonic, generateMnemonic, modifyEntry, archiveEntry, restoreEntry, addLink, removeLink, getLinks, generatePassword, Bip85, totpCodeAt, deriveTotpSecret, deriveNostrKeys, deriveKeyIndex, deriveIndexKeyBytes, RelayPool, publishSnapshot, fetchLatestSnapshot, fetchDeltasSince, decryptPayload, parseEncryptedFile, mergeIndexPayloads, newMergeReport, TOMBSTONE_RETENTION_CAP, parseVaultIndex, sha256Hex, type ModifyChanges, type PasswordPolicy, addPasswordEntry, addTotpDeterministic, addTotpImported, addKeyValueEntry, addDocumentEntry, addSeedEntry, addManagedAccountEntry, addNostrKeyEntry, addSshKeyEntry, addPgpKeyEntry, deriveSshKeyPair, derivePgpKey, sshPublicKeyOpenSsh, utf8, } from "@seedpass/core"; import { readFile, writeFile, mkdir } from "node:fs/promises"; import { existsSync, statSync } from "node:fs"; import { basename, dirname, extname, join } from "node:path"; import { homedir } from "node:os"; /** * Parse a numeric option, refusing anything that would poison the vault. * * `Number("not-a-number")` is NaN, which JSON.stringify writes as null; a * single bad --length once persisted an unreadable entry that failed schema * validation on every subsequent open. Validate before anything is stored. */ function parseIntOption(raw: string, name: string, opts: { min?: number; max?: number } = {}): number { const value = Number(raw); if (!Number.isFinite(value) || !Number.isInteger(value)) { throw new Error(`${name} must be a whole number (got ${JSON.stringify(raw)})`); } if (opts.min !== undefined && value < opts.min) { throw new Error(`${name} must be at least ${opts.min} (got ${value})`); } if (opts.max !== undefined && value > opts.max) { throw new Error(`${name} must be at most ${opts.max} (got ${value})`); } return value; } /** * Normalize a variadic --tags value. * * The option is space-separated (`--tags work dev`), but comma-separated is * what people type, and `--tags work,dev` silently stored the single tag * "work,dev" — no error, just a tag that never matches a search. Accept both * spellings, trim, and drop empties so `--tags "a, b"` behaves too. */ function normalizeTags(tags: string[]): string[] { const seen = new Set(); for (const raw of tags) { for (const part of raw.split(",")) { const tag = part.trim(); if (tag) seen.add(tag); } } return [...seen]; } /** Unix timestamps may be large but must still be real integers. */ function parseUnixTime(raw: string): number { return parseIntOption(raw, "--at", { min: 0 }); } /** * Read a secret without putting it on the command line. * * Arguments are visible in shell history, `ps` output, and any terminal or * session logging, so secret-bearing values should arrive on stdin. The * positional form still works for scripts that already depend on it, but it * warns. */ async function readSecretInput( positional: string | undefined, fromStdin: boolean | undefined, what: string, io: ProgramIo, ): Promise { if (fromStdin) { const chunks: Buffer[] = []; for await (const chunk of process.stdin) chunks.push(chunk as Buffer); // A trailing newline from `echo` or a heredoc is virtually never wanted. return Buffer.concat(chunks).toString("utf8").replace(/\r?\n$/, ""); } if (positional === undefined) { throw new Error(`${what} is required: pass it as an argument or use --stdin`); } io.err( `warning: ${what} was passed on the command line, where it can be captured by ` + `shell history and process listings. Prefer --stdin.`, ); return positional; } /** Expand a leading ~ the way Python's Path.expanduser does. */ 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 { loadConfig, saveConfig, mutateConfig, passwordPolicyFromConfig, } 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 { out(line: string): void; err(line: string): void; } const defaultIo: ProgramIo = { out: (line) => process.stdout.write(line + "\n"), err: (line) => process.stderr.write(line + "\n"), }; interface GlobalOpts { vault?: string; appDir?: string; fingerprint?: string; } async function currentFingerprint(app: AppDir, opts: GlobalOpts): Promise { if (opts.fingerprint) return opts.fingerprint; const data = await app.readFingerprints(); if (!data.last_used) throw new Error("no profile selected; run 'fingerprint add' first"); return data.last_used; } /** * Seed resolution order: SEEDPASS_MNEMONIC env -> session agent -> error. * The mnemonic never comes from argv. */ async function resolveMnemonic(app: AppDir, opts: GlobalOpts): Promise { const env = process.env["SEEDPASS_MNEMONIC"]; if (env) { // Catch a typo'd phrase at the boundary rather than deriving a silently // different vault from it. assertValidMnemonic(env, "SEEDPASS_MNEMONIC"); return env; } // A process that authenticates with a scoped token has declared itself a // constrained principal: it must never escalate to owner access by // pulling the mnemonic from the unlocked agent. if (process.env["SEEDPASS_TOKEN"]) { throw new Error( "token mode: this operation requires owner access (unset SEEDPASS_TOKEN " + "and unlock as the owner)", ); } try { const fp = await currentFingerprint(app, opts); const client = new AgentClient(agentSocketPath(app.root)); const held = await client.ownerMnemonic(fp); if (held) return held; } catch { // fall through to the error below } throw new Error( "vault is locked: set SEEDPASS_MNEMONIC, or run 'seedpass-js vault unlock' " + "with the session agent running ('seedpass-js agent start')", ); } function requireMnemonic(): string { const m = process.env["SEEDPASS_MNEMONIC"]; if (!m) { throw new Error( "SEEDPASS_MNEMONIC is not set. Export the parent seed mnemonic into the " + "environment of this process (never pass it on the command line).", ); } return m; } async function openFromOptions(opts: GlobalOpts): Promise { const app = new AppDir(resolveAppDir(opts.appDir)); if (opts.vault) { return openVault(opts.vault, await resolveMnemonic(app, opts)); } const fp = await currentFingerprint(app, opts); const path = join(app.profileDir(fp), INDEX_FILENAME); return openVault(path, await resolveMnemonic(app, opts)); } /** * The profile config's password policy, which entry `policy` blocks override. * * Parity with Python, which constructs its PasswordGenerator with * `policy=self.config_manager.get_password_policy()` and merges the entry's * block over that base. Deriving from the built-in defaults instead produces * a different password for every entry in a profile whose configured policy * is not the default — indistinguishable from data loss at the point of use. * * `--vault` names an index file rather than a profile, so the config is * looked for beside it; loadConfig returns defaults when none is there. */ async function basePolicyForOptions( opts: GlobalOpts, mnemonic: string, ): Promise { const app = new AppDir(resolveAppDir(opts.appDir)); const profileDir = opts.vault ? dirname(resolveHome(opts.vault)) : app.profileDir(await currentFingerprint(app, opts)); return passwordPolicyFromConfig(await loadConfig(profileDir, mnemonic)); } /** * Run a mutation under the vault lock, holding it for the whole * read-modify-write cycle. * * Locking only the write still loses changes: two commands can each read the * same index, mutate their copy, and write in turn. Everything that changes * the vault must go through here. */ async function mutateVault( opts: GlobalOpts, fn: (vault: OpenedVault) => Promise | T, ): Promise { const app = new AppDir(resolveAppDir(opts.appDir)); const path = opts.vault ?? join(app.profileDir(await currentFingerprint(app, opts)), INDEX_FILENAME); const mnemonic = await resolveMnemonic(app, opts); return withVaultLock(path, async () => { 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; }); } /** * Read/secret access that works in two modes: * - owner mode (mnemonic via env or agent): full local access * - token mode (SEEDPASS_TOKEN set, no mnemonic): every read and secret * goes through the agent, which enforces the token's scopes/kinds/label * constraints and writes the audit trail. The mnemonic never reaches * this process. */ interface ReadAccess { /** Redacted metadata rows, already filtered to what the caller may see. */ rows(): Promise>>; /** Resolve a ref/label/id to an entry id, using visible metadata only. */ resolveId(refOrQuery: string): Promise<{ id: string; ref: string; label: string; kind: string }>; mode: "owner" | "token"; /** Plaintext egress. In token mode this requires the reveal scope. */ reveal(id: string, timestamp?: number): Promise<{ value: string; descriptor: string }>; /** Deliver to a sink. In token mode the agent runs the sink itself. */ deliver( id: string, sink: "clipboard" | "exec" | "stdin", command: string[], timestamp?: number, ): Promise>; } function resolveFromRows( rows: Array>, refOrQuery: string, ): { id: string; ref: string; label: string; kind: string } { const asRow = (r: Record) => ({ id: String(r["id"]), ref: String(r["ref"]), label: String(r["label"] ?? ""), kind: String(r["kind"] ?? ""), }); const refId = parseRef(refOrQuery); const byId = (id: string) => rows.find((r) => String(r["id"]) === id); if (refId !== null) { const hit = byId(refId); if (!hit) throw new Error(`no entry for reference ${refOrQuery}`); return asRow(hit); } if (/^\d+$/.test(refOrQuery)) { const hit = byId(refOrQuery); if (hit) return asRow(hit); } const labelHits = rows.filter((r) => String(r["label"] ?? "") === refOrQuery); if (labelHits.length === 1) return asRow(labelHits[0]!); if (labelHits.length > 1) { throw new Error( `label "${refOrQuery}" is ambiguous (${labelHits.length} entries); use an id or sp:// reference`, ); } throw new Error(`no entry matches "${refOrQuery}"`); } async function openReadAccess(opts: GlobalOpts): Promise { const token = process.env["SEEDPASS_TOKEN"]; const app = new AppDir(resolveAppDir(opts.appDir)); if (token && !process.env["SEEDPASS_MNEMONIC"]) { const fp = await currentFingerprint(app, opts); const client = new AgentClient(agentSocketPath(app.root)); let cached: Array> | null = null; const rows = async () => { cached ??= await client.vaultEntries(fp, token); return cached; }; return { mode: "token", rows, resolveId: async (refOrQuery) => resolveFromRows(await rows(), refOrQuery), reveal: async (id, timestamp) => client.secret({ fingerprint: fp, id, token, ...(timestamp !== undefined && { timestamp }), }), deliver: async (id, sink, command, timestamp) => client.useSink({ fingerprint: fp, id, token, sink, command, ...(timestamp !== undefined && { timestamp }), }), }; } const vault = await openFromOptions(opts); const localRows = async () => Object.entries(vault.index.entries).map(([id, e]) => entryMetadata(id, e)); // Resolved on first use, not up front: reading the config costs another // BIP-39 seed derivation, and `entry list` and friends never need a policy. let basePolicy: PasswordPolicy | undefined; const localSecret = async (id: string, timestamp?: number) => { const entry = vault.index.entries[id]; if (!entry) throw new Error(`no entry ${id}`); basePolicy ??= await basePolicyForOptions(opts, vault.mnemonic); return materializeSecret(vault.index, id, entry, vault.mnemonic, { ...(timestamp !== undefined && { timestamp }), basePolicy, }); }; return { mode: "owner", rows: localRows, resolveId: async (refOrQuery) => { const hit = resolveEntry(vault.index, refOrQuery); return { id: hit.id, ref: hit.ref, label: hit.entry.label, kind: hit.entry.kind }; }, reveal: async (id, timestamp) => localSecret(id, timestamp), deliver: async (id, sink, command, timestamp) => { const secret = await localSecret(id, timestamp); const result = sink === "clipboard" ? await clipboardSink(secret.value) : sink === "exec" ? await execSink(secret.value, ...parseCommandSpec(command)) : await stdinSink(secret.value, ...parseCommandSpec(command)); return { descriptor: secret.descriptor, ...result }; }, }; } export function buildProgram(io: ProgramIo = defaultIo): Command { const program = new Command(); program .name("seedpass-js") .description("SeedPass (TypeScript port) — reference-first CLI") .option("--vault ", "encrypted vault index file (overrides profile)") .option("--app-dir ", "application directory (default ~/.seedpass)") .option("--fingerprint ", "profile fingerprint (default: last used)") .configureOutput({ writeOut: (s) => io.out(s.replace(/\n$/, "")), writeErr: (s) => io.err(s.replace(/\n$/, "")), }) .exitOverride(); // No subcommand: launch the interactive mode, matching Python, whose bare // `seedpass` opens its TUI. Without this the first thing a new user saw // was a help dump — the command they were told to run appeared to do // nothing. Piped or redirected, there is no terminal to drive, so the help // remains the sensible answer. program.action(async () => { const opts = program.opts() as GlobalOpts; if (!process.stdin.isTTY || !process.stdout.isTTY) { program.outputHelp(); process.exitCode = 1; return; } const code = await runTui({ ...(opts.appDir !== undefined && { appDir: opts.appDir }), ...(opts.fingerprint !== undefined && { fingerprint: opts.fingerprint }), }); if (code !== 0) process.exitCode = code; }); program .command("capabilities") .description("machine-readable capability map") .action(() => { io.out(JSON.stringify(capabilities(), null, 2)); }); const entry = program.command("entry").description("entry operations (reference-first)"); entry .command("list") .description("list entries as references + metadata (no secrets)") .action(async () => { const access = await openReadAccess(program.opts()); io.out(JSON.stringify(await access.rows(), null, 2)); }); entry .command("get ") .description("show one entry's reference + metadata (no secrets)") .action(async (refOrQuery: string) => { const access = await openReadAccess(program.opts()); const hit = await access.resolveId(refOrQuery); const row = (await access.rows()).find((r) => String(r["id"]) === hit.id); io.out(JSON.stringify(row, null, 2)); }); entry .command("search ") .description("substring search over labels/tags/notes (no secrets)") .action(async (text: string) => { const access = await openReadAccess(program.opts()); const needle = text.toLowerCase(); const rows = (await access.rows()).filter((r) => { const tags = Array.isArray(r["tags"]) ? (r["tags"] as string[]) : []; const hay = [String(r["label"] ?? ""), String(r["notes"] ?? ""), ...tags] .join("\n") .toLowerCase(); return hay.includes(needle); }); io.out(JSON.stringify(rows, null, 2)); }); // Provisioning (plan section 9.3): create entries, get back a reference // and metadata — never the secret the entry represents. Deterministic // artifacts are recoverable from the seed; nothing extra to back up. const add = entry.command("add").description("create entries (returns references, not secrets)"); const commonAddOptions = (cmd: T): T => { cmd .option("--notes ", "notes", "") .option("--tags ", "tags") .option("--archived", "create archived"); return cmd; }; /** Create an entry under the lock and print its reference + metadata. */ async function addEntry( create: (vault: OpenedVault) => string, ): Promise { const row = await mutateVault(program.opts(), (vault) => { const id = create(vault); return entryMetadata(id, vault.index.entries[id]!); }); io.out(JSON.stringify(row, null, 2)); } const commonOpts = (o: { notes?: string; tags?: string[]; archived?: boolean }) => ({ notes: o.notes ?? "", ...(o.tags !== undefined && { tags: normalizeTags(o.tags) }), ...(o.archived !== undefined && { archived: o.archived }), }); commonAddOptions( add .command("password