/** * 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, refFor } 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, HIGH_RISK_KINDS, splitSecret, recoverSecret, isPartitionStub, emitEntryEvents, partitionStub, type Entry, buildSemanticRecords, searchSemanticRecords, semanticManifest, semanticStatus, isStaleSemanticIndex, type SemanticRecord, 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, rm } 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, DEFAULT_PBKDF2_ITERATIONS } 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 { factorConfigured, setFactor, tagForFactor, readPartition, writePartition, partitionPath, } from "./highRisk.js"; import { issueApproval, listApprovals, revokeApproval, VALID_APPROVAL_ACTIONS, } from "./approvals.js"; import { createJobProfile, currentPolicyStamp, listJobProfiles, revokeJobProfile, checkJobProfiles, } from "./jobProfiles.js"; import { recordRecoveryDrill, listRecoveryDrills, verifyRecoveryDrills, } from "./recoveryDrills.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); // Snapshot before mutating so the index0 differ can see what changed. // Structured-cloned, because `fn` mutates the index in place. const before = structuredClone(vault.index.entries) as Record; const result = await fn(vault); recordIndex0(vault, before, dirname(path)); 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}"`); } /** * Append index0 events for whatever a mutation changed. * * Best-effort: the atlas is derived state and can be rebuilt, so a failure * here must never fail a committed vault write. Silence in the ledger is a * lesser harm than refusing to save the user's entry. */ function recordIndex0( vault: OpenedVault, before: Record, fingerprintDir: string, ): void { try { const updated = emitEntryEvents(vault.index, { before, after: vault.index.entries as unknown as Record, fingerprintDir, now: Math.floor(Date.now() / 1000), }); (vault.index as unknown as Record)["_system"] = updated["_system"]; } catch { // Deliberately swallowed; see the note above. } } 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; /** * Replace a high-risk stub with its real record. * * A stub carries only kind/label/archived, so materializing a secret from * one would derive from the wrong fields — or, for an imported secret, * from nothing at all. The full record lives in the partition, and reaching * it needs the second factor to be live in the agent. */ const hydrate = async (id: string, entry: Entry): Promise => { const record = entry as unknown as Record; if (!isPartitionStub(record)) return entry; const client = new AgentClient(agentSocketPath(app.root)); const fp = await currentFingerprint(app, opts); const tag = await client.highRiskTag(fp); if (!tag) { throw new Error( `entry ${id} is in the high-risk partition and the partition is ` + `locked. Run 'agent high-risk unlock' with SEEDPASS_HIGH_RISK_FACTOR set.`, ); } const partition = await readPartition(app.profileDir(fp), tag); const full = partition[String(record["partition_ref"] ?? id)]; if (!full) { // The stub says a record exists and the partition disagrees. Say so // rather than deriving from the stub and handing back a wrong secret. throw new Error( `entry ${id} points at a high-risk record that is not in the ` + `partition file; the partition may be from a different factor.`, ); } return full as unknown as Entry; }; const localSecret = async (id: string, timestamp?: number) => { const stub = vault.index.entries[id]; if (!stub) throw new Error(`no entry ${id}`); const entry = await hydrate(id, stub); 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