Files
SeedPass/js/packages/cli/src/program.ts
T
thePR0M3TH3ANandClaude Opus 5 c6cb85e840 feat(vault): port index0/atlas, and keep it current on every mutation
index0 is the per-vault activity ledger under `_system.index0`: an append-only
event stream per writer, daily checkpoints rolling those events up, and
derived canonical views (children_of, counts_by_kind, recent_activity).

It was the last piece carried through verbatim rather than computed — which
was safe for round-tripping but meant a TypeScript client never UPDATED it. A
vault written by this port went stale the moment it was touched, and a Python
client reading it afterwards saw an activity log missing everything the TS
client had done. Worse, `nostr restore` kept the local block verbatim, so a
restore silently discarded the remote's history.

Hash parity is the entire contract here: event ids, integrity hashes,
checkpoint summaries and view hashes are SHA-256 over Python's canonical JSON,
and the merge picks winners by comparing those hashes — one byte of encoding
disagreement means the two implementations permanently disagree about which
record wins. Everything routes through the existing canonicalJson (Python's
ensure_ascii escaping, code-point key ordering, CPython number formatting)
rather than JSON.stringify, and the tests compare whole structures via
canonical JSON rather than toEqual, so they pass only if the bytes that get
hashed are identical. Verified against Python fixtures covering the append
chain, compaction, manifest metadata, views and a two-replica merge —
including non-ASCII labels and tags, a non-numeric entry id, two writers
across two days, and a managed-account scope path.

Two Python quirks are replicated deliberately rather than "fixed", because
diverging would change which events exist rather than merely how they hash:
`str(None)` is "None" (so a JSON null in a required field is a non-empty
string there), and a stored integrity_hash is preserved even when it
disagrees — this normalizes, it does not verify, and silently rewriting a
mismatched hash would hide the tampering the hash exists to reveal.

Events are emitted by DIFFING the index at the single mutation funnel each
surface goes through, rather than from each call site as Python does. That is
deliberate: a call-site emitter is something a new code path can forget, and a
silently incomplete ledger is worse than an absent one because it reads as
"nothing happened". The event vocabulary matches Python's, so a stream from
either implementation reads the same. Emission is best-effort — the atlas is
derived state, and it must never fail a committed vault write.

mergeIndexPayloads now defaults to merging index0 with the same deterministic
rules; "preserve-current" and "reject" remain for callers that want them, and
`nostr restore` no longer discards the remote's history.

Also made gen_high_risk deterministic. Fernet embeds a random IV and the
current time, so that fixture regenerated differently every run and would have
failed the fixture-drift CI job each time — it now pins both, with a note that
production must never do this.

Verified end to end: the TS binary performed four mutations, and Python read
the resulting atlas, normalized it without dropping a record, and compacted it
into checkpoints and views.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
2026-08-19 11:13:15 -04:00

2700 lines
104 KiB
TypeScript

/**
* 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<string>();
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<string> {
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<string> {
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<string> {
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<OpenedVault> {
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<PasswordPolicy> {
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<T>(
opts: GlobalOpts,
fn: (vault: OpenedVault) => Promise<T> | T,
): Promise<T> {
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<string, unknown>;
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<Array<Record<string, unknown>>>;
/** 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<Record<string, unknown>>;
}
function resolveFromRows(
rows: Array<Record<string, unknown>>,
refOrQuery: string,
): { id: string; ref: string; label: string; kind: string } {
const asRow = (r: Record<string, unknown>) => ({
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<string, unknown>,
fingerprintDir: string,
): void {
try {
const updated = emitEntryEvents(vault.index, {
before,
after: vault.index.entries as unknown as Record<string, unknown>,
fingerprintDir,
now: Math.floor(Date.now() / 1000),
});
(vault.index as unknown as Record<string, unknown>)["_system"] = updated["_system"];
} catch {
// Deliberately swallowed; see the note above.
}
}
async function openReadAccess(opts: GlobalOpts): Promise<ReadAccess> {
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<Record<string, unknown>> | 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<Entry> => {
const record = entry as unknown as Record<string, unknown>;
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 <file>", "encrypted vault index file (overrides profile)")
.option("--app-dir <dir>", "application directory (default ~/.seedpass)")
.option("--fingerprint <fp>", "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 <refOrQuery>")
.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 <text>")
.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 = <T extends Command>(cmd: T): T => {
cmd
.option("--notes <text>", "notes", "")
.option("--tags <tag...>", "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<void> {
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 <label>")
.description("deterministic password entry (derives on demand; value never shown here)")
.option("--length <n>", "password length", "16")
.option("--username <name>", "username")
.option("--url <url>", "site URL"),
).action(
async (
label: string,
o: { length: string; username?: string; url?: string; notes?: string; tags?: string[]; archived?: boolean },
) => {
await addEntry((vault) => addPasswordEntry(vault.index, label, parseIntOption(o.length, "--length", { min: 8, max: 128 }), {
...commonOpts(o),
...(o.username !== undefined && { username: o.username }),
...(o.url !== undefined && { url: o.url }),
}));
},
);
commonAddOptions(
add
.command("totp <label>")
.description("TOTP entry: deterministic by default, or --secret to import")
.option("--secret <b32>", "import an existing base32 secret (prefer --secret-stdin)")
.option("--secret-stdin", "read the base32 secret from stdin")
.option("--period <s>", "period seconds", "30")
.option("--digits <n>", "code digits", "6"),
).action(
async (
label: string,
o: {
secret?: string;
secretStdin?: boolean;
period: string;
digits: string;
notes?: string;
tags?: string[];
archived?: boolean;
},
) => {
const opts = {
...commonOpts(o),
period: parseIntOption(o.period, "--period", { min: 1 }),
digits: parseIntOption(o.digits, "--digits", { min: 6, max: 10 }),
};
const imported =
o.secret !== undefined || o.secretStdin
? await readSecretInput(o.secret, o.secretStdin, "TOTP secret", io)
: null;
await addEntry((vault) =>
imported !== null
? addTotpImported(vault.index, label, imported, opts)
: addTotpDeterministic(vault.index, label, vault.mnemonic, opts),
);
},
);
commonAddOptions(
add
.command("key-value <label> <key> [value]")
.description("store an arbitrary secret value (prefer --stdin over an argument)")
.option("--stdin", "read the value from stdin instead of an argument"),
).action(
async (
label: string,
key: string,
value: string | undefined,
o: { stdin?: boolean; notes?: string; tags?: string[]; archived?: boolean },
) => {
const secret = await readSecretInput(value, o.stdin, "value", io);
await addEntry((vault) => addKeyValueEntry(vault.index, label, key, secret, commonOpts(o)));
},
);
commonAddOptions(
add
.command("document <label> <content>")
.description("store a text document")
.option("--file-type <ext>", "file type", "txt"),
).action(
async (
label: string,
content: string,
o: { fileType: string; notes?: string; tags?: string[]; archived?: boolean },
) => {
await addEntry((vault) => addDocumentEntry(vault.index, label, content, {
...commonOpts(o),
fileType: o.fileType,
}));
},
);
commonAddOptions(
add
.command("seed <label>")
.description("derived seed phrase entry (12/18/24 words)")
.option("--words <n>", "word count", "24"),
).action(
async (label: string, o: { words: string; notes?: string; tags?: string[]; archived?: boolean }) => {
const words = parseIntOption(o.words, "--words");
if (words !== 12 && words !== 18 && words !== 24) {
throw new Error("--words must be 12, 18 or 24");
}
await addEntry((vault) =>
addSeedEntry(vault.index, label, {
...commonOpts(o),
wordCount: words as 12 | 18 | 24,
}),
);
},
);
commonAddOptions(
add.command("managed-account <label>").description("BIP-85 managed account (12-word child seed)"),
).action(async (label: string, o: { notes?: string; tags?: string[]; archived?: boolean }) => {
await addEntry((vault) =>
addManagedAccountEntry(vault.index, label, vault.mnemonic, commonOpts(o)),
);
});
commonAddOptions(
add.command("nostr <label>").description("derived Nostr key entry"),
).action(async (label: string, o: { notes?: string; tags?: string[]; archived?: boolean }) => {
await addEntry((vault) => addNostrKeyEntry(vault.index, label, commonOpts(o)));
});
commonAddOptions(
add.command("ssh <label>").description("derived Ed25519 SSH key entry"),
).action(async (label: string, o: { notes?: string; tags?: string[]; archived?: boolean }) => {
await addEntry((vault) => addSshKeyEntry(vault.index, label, commonOpts(o)));
});
commonAddOptions(
add
.command("pgp <label>")
.description("derived Ed25519 PGP key entry")
.option("--user-id <uid>", "PGP user id", ""),
).action(
async (
label: string,
o: { userId: string; notes?: string; tags?: string[]; archived?: boolean },
) => {
await addEntry((vault) => addPgpKeyEntry(vault.index, label, {
...commonOpts(o),
userId: o.userId,
}));
},
);
entry
.command("import-document <file>")
.description("import a local text file as a document entry")
.option("--label <text>", "title override (default: the file's stem)")
.option("--notes <text>", "entry notes", "")
.option("--tags <tag...>", "tags")
.action(
async (
file: string,
o: { label?: string; notes: string; tags?: string[] },
) => {
const path = resolveHome(file);
// Documents are stored as text, matching Python's read_text.
const content = await readFile(path, "utf8");
const base = basename(path);
const ext = extname(base).replace(/^\./, "").toLowerCase();
await addEntry((vault) =>
addDocumentEntry(vault.index, o.label || base.replace(/\.[^.]*$/, ""), content, {
fileType: ext || "txt",
notes: o.notes,
...(o.tags !== undefined && { tags: normalizeTags(o.tags) }),
}),
);
},
);
entry
.command("export-document <refOrQuery>")
.description("write a document entry to a file (plaintext egress)")
.option("--out <path>", "output file or directory (default: cwd)")
.option("--overwrite", "replace an existing file")
.action(async (refOrQuery: string, o: { out?: string; overwrite?: boolean }) => {
const vault = await openFromOptions(program.opts());
const hit = resolveEntry(vault.index, refOrQuery);
if (hit.entry.kind !== "document") throw new Error(`${hit.ref} is not a document entry`);
const fileType = (hit.entry.file_type || "txt").trim().toLowerCase() || "txt";
// Same sanitization as Python: non-portable characters collapse to "_".
const stem =
hit.entry.label.trim().replace(/[^A-Za-z0-9._-]+/g, "_").replace(/^[._]+|[._]+$/g, "") ||
`document_${hit.id}`;
let dest: string;
if (o.out === undefined) {
dest = join(process.cwd(), `${stem}.${fileType}`);
} else {
const raw = resolveHome(o.out);
const isDir = existsSync(raw) && statSync(raw).isDirectory();
dest = isDir || !extname(raw) ? join(raw, `${stem}.${fileType}`) : raw;
}
await mkdir(dirname(dest), { recursive: true });
if (existsSync(dest) && !o.overwrite) {
throw new Error(`File already exists: ${dest} (use --overwrite)`);
}
// atomicWrite creates a fresh 0600 inode and renames it into place, so
// a pre-existing world-readable file (or a symlink pointing somewhere
// else) cannot end up holding this plaintext.
await atomicWrite(dest, utf8(hit.entry.content));
io.out(JSON.stringify({ exported: dest, ref: hit.ref, bytes: hit.entry.content.length }));
});
entry
.command("pgp-public <refOrQuery>")
.description("print a PGP entry's public key block (not secret material)")
.action(async (refOrQuery: string) => {
const vault = await openFromOptions(program.opts());
const hit = resolveEntry(vault.index, refOrQuery);
if (hit.entry.kind !== "pgp") throw new Error(`${hit.ref} is not a pgp entry`);
const key = derivePgpKey(vault.mnemonic, hit.entry.index, {
userId: hit.entry.user_id,
keyType: hit.entry.key_type,
});
io.out(JSON.stringify({ fingerprint: key.fingerprint }));
io.out(key.publicKeyArmored.trimEnd());
});
entry
.command("ssh-public <refOrQuery>")
.description("print an SSH entry's public key (not secret material)")
.option("--format <fmt>", "pem or openssh", "openssh")
.option("--comment <text>", "comment for the openssh format", "")
.action(async (refOrQuery: string, o: { format: string; comment: string }) => {
const vault = await openFromOptions(program.opts());
const hit = resolveEntry(vault.index, refOrQuery);
if (hit.entry.kind !== "ssh") throw new Error(`${hit.ref} is not an ssh entry`);
const pair = deriveSshKeyPair(vault.mnemonic, hit.entry.index);
io.out(
o.format === "pem"
? pair.publicKeyPem.trimEnd()
: sshPublicKeyOpenSsh(pair.publicKey, o.comment || hit.entry.label),
);
});
entry
.command("modify <refOrQuery>")
.description("update entry fields (kind-checked); prints the new metadata")
.option("--label <text>")
.option("--username <text>")
.option("--url <text>")
.option("--notes <text>")
.option("--tags <tag...>")
.option("--period <n>")
.option("--digits <n>")
.option("--key <text>")
.option("--value <text>", "new secret value (prefer --value-stdin)")
.option("--value-stdin", "read the new value from stdin")
.option("--content <text>", "new document content")
.option("--file-type <ext>")
.action(
async (
refOrQuery: string,
o: {
label?: string; username?: string; url?: string; notes?: string;
tags?: string[]; period?: string; digits?: string; key?: string;
value?: string; valueStdin?: boolean; content?: string; fileType?: string;
},
) => {
const changes: ModifyChanges = {
...(o.label !== undefined && { label: o.label }),
...(o.username !== undefined && { username: o.username }),
...(o.url !== undefined && { url: o.url }),
...(o.notes !== undefined && { notes: o.notes }),
...(o.tags !== undefined && { tags: normalizeTags(o.tags) }),
...(o.period !== undefined && { period: parseIntOption(o.period, "--period", { min: 1 }) }),
...(o.digits !== undefined && { digits: parseIntOption(o.digits, "--digits", { min: 6, max: 10 }) }),
...(o.key !== undefined && { key: o.key }),
...((o.value !== undefined || o.valueStdin) && {
value: await readSecretInput(o.value, o.valueStdin, "value", io),
}),
...(o.content !== undefined && { content: o.content }),
...(o.fileType !== undefined && { file_type: o.fileType }),
};
if (Object.keys(changes).length === 0) throw new Error("no changes given");
const row = await mutateVault(program.opts(), (vault) => {
const hit = resolveEntry(vault.index, refOrQuery);
modifyEntry(vault.index, hit.id, changes);
return entryMetadata(hit.id, vault.index.entries[hit.id]!);
});
io.out(JSON.stringify(row, null, 2));
},
);
entry
.command("archive <refOrQuery>")
.description("archive an entry")
.action(async (refOrQuery: string) => {
const ref = await mutateVault(program.opts(), (vault) => {
const hit = resolveEntry(vault.index, refOrQuery);
archiveEntry(vault.index, hit.id);
return hit.ref;
});
io.out(JSON.stringify({ ref, archived: true }));
});
entry
.command("unarchive <refOrQuery>")
.description("restore an archived entry")
.action(async (refOrQuery: string) => {
const ref = await mutateVault(program.opts(), (vault) => {
const hit = resolveEntry(vault.index, refOrQuery);
restoreEntry(vault.index, hit.id);
return hit.ref;
});
io.out(JSON.stringify({ ref, archived: false }));
});
entry
.command("links <refOrQuery>")
.description("list an entry's links with resolved targets")
.action(async (refOrQuery: string) => {
const vault = await openFromOptions(program.opts());
const hit = resolveEntry(vault.index, refOrQuery);
io.out(JSON.stringify(getLinks(vault.index, hit.id), null, 2));
});
entry
.command("link-add <refOrQuery> <target>")
.description("link an entry to another")
.option("--relation <name>", "relation type", "related_to")
.option("--note <text>", "link note", "")
.action(async (refOrQuery: string, target: string, o: { relation: string; note: string }) => {
const result = await mutateVault(program.opts(), (vault) => {
const hit = resolveEntry(vault.index, refOrQuery);
const targetHit = resolveEntry(vault.index, target);
const links = addLink(vault.index, hit.id, Number(targetHit.id), {
relation: o.relation,
note: o.note,
});
return { ref: hit.ref, links };
});
io.out(JSON.stringify(result, null, 2));
});
entry
.command("link-remove <refOrQuery> <target>")
.description("remove links to a target entry")
.option("--relation <name>", "only remove this relation")
.action(async (refOrQuery: string, target: string, o: { relation?: string }) => {
const result = await mutateVault(program.opts(), (vault) => {
const hit = resolveEntry(vault.index, refOrQuery);
const targetHit = resolveEntry(vault.index, target);
const links = removeLink(vault.index, hit.id, Number(targetHit.id), {
...(o.relation !== undefined && { relation: o.relation }),
});
return { ref: hit.ref, links };
});
io.out(JSON.stringify(result, null, 2));
});
entry
.command("totp-codes")
.description("PLAINTEXT EGRESS: current codes for all active TOTP entries")
.option("--at <timestamp>", "unix time to compute codes at")
.action(async (cmdOpts: { at?: string }) => {
const vault = await openFromOptions(program.opts());
const ts = cmdOpts.at !== undefined ? parseUnixTime(cmdOpts.at) : Math.floor(Date.now() / 1000);
const rows = Object.entries(vault.index.entries)
.filter(([, e]) => e.kind === "totp" && !e.archived)
.map(([id, e]) => {
const totp = e as { secret?: string; index?: number; period: number; digits: number; label: string };
const secret = totp.secret ?? deriveTotpSecret(vault.mnemonic, totp.index ?? 0);
return {
ref: `sp://entry/${id}`,
label: totp.label,
code: totpCodeAt(secret, ts, totp.period, totp.digits),
period: totp.period,
seconds_remaining: totp.period - (ts % totp.period),
};
});
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")
.option("--at <timestamp>", "TOTP: unix time to compute the code at")
.action(async (refOrQuery: string, cmdOpts: { at?: string }) => {
const access = await openReadAccess(program.opts());
const hit = await access.resolveId(refOrQuery);
const secret = await access.reveal(
hit.id,
cmdOpts.at !== undefined ? parseUnixTime(cmdOpts.at) : undefined,
);
io.out(secret.value);
});
program
.command("use <refOrQuery>")
.description("deliver a secret to a sink without printing it")
.option("--clipboard", "copy to the system clipboard")
.option(
"--exec <cmd...>",
'run a command with SEEDPASS_SECRET in its env (quote it to include flags: --exec "cmd -f")',
)
.option(
"--stdin-to <cmd...>",
'pipe the secret to a command\'s stdin (quote it to include flags: --stdin-to "wc -c")',
)
.option("--at <timestamp>", "TOTP: unix time to compute the code at")
.action(
async (
refOrQuery: string,
cmdOpts: { clipboard?: boolean; exec?: string[]; stdinTo?: string[]; at?: string },
) => {
const chosen = [cmdOpts.clipboard, cmdOpts.exec, cmdOpts.stdinTo].filter(
(v) => v !== undefined,
).length;
if (chosen !== 1) {
throw new Error("choose exactly one sink: --clipboard, --exec, or --stdin-to");
}
const access = await openReadAccess(program.opts());
const hit = await access.resolveId(refOrQuery);
const sink = cmdOpts.clipboard ? "clipboard" : cmdOpts.exec ? "exec" : "stdin";
const command = cmdOpts.clipboard ? [] : (cmdOpts.exec ?? cmdOpts.stdinTo!);
// In token mode the agent runs the sink: the secret is never sent
// back to this process.
const result = await access.deliver(
hit.id,
sink,
command,
cmdOpts.at !== undefined ? parseUnixTime(cmdOpts.at) : undefined,
);
io.out(
JSON.stringify({
delivered: result["descriptor"],
ref: hit.ref,
mode: access.mode,
...Object.fromEntries(
Object.entries(result).filter(([k]) => k !== "descriptor"),
),
}),
);
// Adopt the sink child's exit status as our own. Delivering a secret
// to a command that then failed is not a success: `seedpass-js use
// db-pass --exec ./deploy.sh && echo deployed` printed "deployed"
// after a failed deploy, because we only reported the child's code in
// JSON and always exited 0 ourselves.
const childExit = result["exitCode"];
if (typeof childExit === "number" && childExit !== 0) {
process.exitCode = childExit > 0 ? childExit : 1;
}
},
);
const fingerprint = program.command("fingerprint").description("profile management");
fingerprint
.command("list")
.description("list profiles")
.action(async () => {
const app = new AppDir(resolveAppDir((program.opts() as GlobalOpts).appDir));
const data = await app.readFingerprints();
io.out(
JSON.stringify(
data.fingerprints.map((fp) => ({
fingerprint: fp,
name: data.names[fp] ?? null,
current: fp === data.last_used,
})),
null,
2,
),
);
});
fingerprint
.command("add")
.description("create a profile from SEEDPASS_MNEMONIC + SEEDPASS_PASSWORD")
.option("--name <name>", "human-readable profile name")
.action(async (o: { name?: string }) => {
const app = new AppDir(resolveAppDir((program.opts() as GlobalOpts).appDir));
const mnemonic = requireMnemonic();
const password = process.env["SEEDPASS_PASSWORD"];
if (!password) throw new Error("SEEDPASS_PASSWORD is not set");
const fp = await app.createProfile(mnemonic, password, o.name);
io.out(JSON.stringify({ fingerprint: fp, name: o.name ?? null }));
});
fingerprint
.command("create")
.description("generate a NEW master seed and create a profile from it")
.option("--name <name>", "human-readable profile name")
.option("--words <n>", "seed length: 12 or 24", "12")
.option("--show", "print the new seed phrase to stdout")
.option("--out <file>", "write the new seed phrase to a 0600 file instead of printing")
.action(async (o: { name?: string; words: string; show?: boolean; out?: string }) => {
const app = new AppDir(resolveAppDir((program.opts() as GlobalOpts).appDir));
const password = process.env["SEEDPASS_PASSWORD"];
if (!password) throw new Error("SEEDPASS_PASSWORD is not set");
const words = parseIntOption(o.words, "--words", { min: 12, max: 24 });
if (words !== 12 && words !== 24) throw new Error("--words must be 12 or 24");
if (o.show && o.out) throw new Error("--show and --out are mutually exclusive");
// Decide how the phrase reaches the human BEFORE generating anything.
// This is the only secret in SeedPass with no other copy: creating the
// profile first and failing to deliver the phrase afterwards would
// leave a vault that nobody — including its owner — can ever reopen.
const toTty = !o.out && !o.show && Boolean(process.stdout.isTTY);
if (!o.out && !o.show && !toTty) {
throw new Error(
"refusing to generate a seed with nowhere safe to put it: stdout is not a " +
"terminal, so the phrase would be captured by whatever is reading this " +
"pipe (a log, a transcript, an AI agent's context). Pass --out <file> to " +
"write it to a 0600 file, or --show if you really want it on stdout.",
);
}
const mnemonic = generateMnemonic(words);
// Deliver first, create second. If the write fails we abort having
// created nothing, rather than orphaning an unrecoverable profile.
if (o.out) {
// "wx" so an existing file is never clobbered, and 0600 from the
// moment it exists rather than chmod'd afterwards.
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, and overwriting it would destroy whatever seed ` +
`is in it. Choose another path.`,
);
}
throw e;
}
} else {
io.out(mnemonic);
}
const fp = await app.createProfile(mnemonic, password, o.name);
io.err(
"Write this seed phrase down and store it offline. It is the ONLY way to " +
"recover this vault: SeedPass derives every secret from it and keeps no " +
"copy you can read without it.",
);
io.out(
JSON.stringify({
fingerprint: fp,
name: o.name ?? null,
words,
seed_written_to: o.out ?? null,
}),
);
});
fingerprint
.command("switch <fp>")
.description("set the active profile")
.action(async (fp: string) => {
const app = new AppDir(resolveAppDir((program.opts() as GlobalOpts).appDir));
await app.switchProfile(fp);
io.out(JSON.stringify({ current: fp }));
});
fingerprint
.command("remove <fp>")
.description("DESTRUCTIVE: delete a profile directory")
.option("--yes", "confirm deletion")
.action(async (fp: string, o: { yes?: boolean }) => {
if (!o.yes) throw new Error("refusing to delete without --yes");
const app = new AppDir(resolveAppDir((program.opts() as GlobalOpts).appDir));
await app.removeProfile(fp);
io.out(JSON.stringify({ removed: fp }));
});
const config = program.command("config").description("per-profile configuration");
config
.command("get [key]")
.description("read config (whole object or one key); secret hashes redacted")
.action(async (key: string | undefined) => {
const opts = program.opts() as GlobalOpts;
const app = new AppDir(resolveAppDir(opts.appDir));
const fp = await currentFingerprint(app, opts);
const cfg = await loadConfig(app.profileDir(fp), await resolveMnemonic(app, opts));
for (const k of ["pin_hash", "password_hash"]) {
if (cfg[k]) cfg[k] = "<redacted>";
}
io.out(JSON.stringify(key !== undefined ? { [key]: cfg[key] } : cfg, null, 2));
});
config
.command("set <key> <value>")
.description("set a config value (JSON-parsed when possible)")
.action(async (key: string, value: string) => {
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);
let parsed: unknown = value;
try {
parsed = JSON.parse(value);
} catch {
// keep as string
}
await mutateConfig(app.profileDir(fp), mnemonic, (cfg) => {
cfg[key] = parsed;
});
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) {
const app = new AppDir(resolveAppDir(opts.appDir));
const fp = await currentFingerprint(app, opts);
const mnemonic = await resolveMnemonic(app, opts);
const cfg = await loadConfig(app.profileDir(fp), mnemonic);
const relays = cfg["relays"] as string[];
const keys = deriveNostrKeys(Bip85.fromMnemonic(mnemonic), 0);
return { app, fp, mnemonic, cfg, relays, keys };
}
nostr
.command("get-pubkey")
.description("display the active profile's npub")
.action(async () => {
const { keys } = await nostrContext(program.opts());
io.out(keys.npub);
});
nostr
.command("list-relays")
.description("show configured relays")
.action(async () => {
const { relays } = await nostrContext(program.opts());
io.out(JSON.stringify(relays, null, 2));
});
nostr
.command("add-relay <url>")
.description("add a relay URL")
.action(async (url: string) => {
if (!/^wss?:\/\//.test(url)) throw new Error("relay URL must start with ws:// or wss://");
const { app, fp, mnemonic } = await nostrContext(program.opts());
const relays = await mutateConfig(app.profileDir(fp), mnemonic, (cfg) => {
const list = Array.isArray(cfg["relays"]) ? (cfg["relays"] as string[]) : [];
if (!list.includes(url)) list.push(url);
cfg["relays"] = list;
return list;
});
io.out(JSON.stringify({ relays }));
});
nostr
.command("remove-relay <index>")
.description("remove a relay by 1-based index")
.action(async (indexStr: string) => {
const { app, fp, mnemonic } = await nostrContext(program.opts());
const i = parseIntOption(indexStr, "index", { min: 1 }) - 1;
const relays = await mutateConfig(app.profileDir(fp), mnemonic, (cfg) => {
const list = Array.isArray(cfg["relays"]) ? (cfg["relays"] as string[]) : [];
if (i >= list.length) throw new Error(`index out of range 1..${list.length}`);
if (list.length === 1) throw new Error("at least one relay must remain");
list.splice(i, 1);
cfg["relays"] = list;
return list;
});
io.out(JSON.stringify({ relays }));
});
nostr
.command("sync")
.description("publish the local vault as a snapshot to the configured relays")
.option("--chunk-limit <bytes>", "max chunk size", "50000")
.action(async (o: { chunkLimit: string }) => {
const opts = program.opts() as GlobalOpts;
const { app, fp, mnemonic, relays, keys } = await nostrContext(opts);
const vaultPath = opts.vault ?? join(app.profileDir(fp), INDEX_FILENAME);
const encrypted = new Uint8Array(await readFile(vaultPath));
const pool = new RelayPool(relays);
try {
const published = await publishSnapshot(
pool,
keys.privateKeyHex,
deriveKeyIndex(mnemonic),
encrypted,
{ limit: parseIntOption(o.chunkLimit, "--chunk-limit", { min: 64 }) },
);
io.out(
JSON.stringify(
{
manifest_id: published.manifestId,
manifest_event_id: published.manifestEventId,
chunk_event_ids: published.chunkEventIds,
relays,
},
null,
2,
),
);
} finally {
await pool.close();
}
});
nostr
.command("restore")
.description("fetch the latest snapshot + deltas and merge them into the local vault")
.option(
"--replace",
"DESTRUCTIVE: discard local state instead of merging remote into it",
)
.option("--yes", "confirm --replace")
.action(async (o: { replace?: boolean; yes?: boolean }) => {
const opts = program.opts() as GlobalOpts;
const { app, fp, mnemonic, relays, keys } = await nostrContext(opts);
if (o.replace && !o.yes) {
throw new Error("--replace discards local entries; pass --yes to confirm");
}
const pool = new RelayPool(relays);
try {
const fetched = await fetchLatestSnapshot(pool, keys.privateKeyHex);
if (!fetched) throw new Error("no snapshot found on the configured relays");
const indexKey = deriveIndexKeyBytes(mnemonic);
const snapshotPayload = parseEncryptedFile(fetched.encrypted).ciphertext;
let remote = JSON.parse(
new TextDecoder().decode(await decryptPayload(indexKey, snapshotPayload)),
) as Record<string, unknown>;
let deltaCount = 0;
// Collects what the merges below resolve silently, so the summary can
// report it. Declared here to cover delta replay as well as the final
// local merge.
const report = newMergeReport();
if (fetched.manifest.delta_since) {
// Bind deltas to this manifest so a relay cannot replay one from
// another snapshot lineage into the restore.
const deltas = await fetchDeltasSince(
pool,
keys.privateKeyHex,
fetched.manifest.delta_since,
fetched.manifestEvent.id,
);
for (const delta of deltas) {
const incoming = JSON.parse(
new TextDecoder().decode(
await decryptPayload(indexKey, parseEncryptedFile(delta).ciphertext),
),
);
// Same collector as the final merge: a delta can just as easily
// carry an entry that displaces one the snapshot created.
//
// index0 now merges properly. It used to be preserve-current
// because the atlas could not be recomputed here, which meant a
// delta's activity history was discarded on the way in.
remote = mergeIndexPayloads(remote, incoming, sha256Hex(delta).slice(0, 16), {
report,
});
deltaCount++;
}
}
const vaultPath = opts.vault ?? join(app.profileDir(fp), INDEX_FILENAME);
const localExists = existsSync(vaultPath);
let local: Record<string, unknown> | null = null;
if (localExists) {
try {
local = (await openVault(vaultPath, mnemonic)).index as unknown as Record<
string,
unknown
>;
} catch {
local = null; // unreadable local vault: treat the restore as recovery
}
}
// Default is a merge, not a replacement: a restore must not silently
// discard entries created locally since the snapshot was published.
let final: Record<string, unknown>;
let mode: string;
if (o.replace || local === null) {
final = remote;
mode = local === null ? "restored" : "replaced";
} else {
// index0 merges both sides now. It used to keep the local block
// verbatim, because the atlas could not be merged here — which
// meant a restore silently discarded the remote's activity history.
final = mergeIndexPayloads(local, remote, "nostr-restore", { report });
mode = "merged";
}
// Keep a copy of whatever we are about to overwrite.
let backupPath: string | null = null;
if (localExists) {
backupPath = `${vaultPath}.pre-restore-${Math.floor(Date.now() / 1000)}`;
await atomicWrite(backupPath, new Uint8Array(await readFile(vaultPath)));
}
const index = parseVaultIndex(final);
await saveVault({ index, mnemonic, path: vaultPath });
// A merge is one of the few ways a vault can come to hold two
// different-kind entries at the same BIP-85 app-32 index, which
// derive the same key. Neither side can create that alone, so it is
// worth naming when it appears.
const collisions = findDerivationCollisions(index);
io.out(
JSON.stringify({
mode,
vault: vaultPath,
entry_count: Object.keys(index.entries).length,
deltas_applied: deltaCount,
local_backup: backupPath,
// A merge resolves same-id conflicts deterministically and
// silently. Deterministic is the design; silent is the problem —
// an entry the user created is simply gone afterwards.
...(report.conflicts.length > 0 && { merge_conflicts: report.conflicts }),
...(report.tombstonesEvicted > 0 && {
tombstones_evicted: report.tombstonesEvicted,
}),
...(collisions.length > 0 && {
derivation_collisions: collisions.map((c) => ({
index: c.index,
severity: c.severity,
entries: c.entries,
})),
}),
}),
);
for (const c of report.conflicts) {
io.err(
`warning: both sides created a different entry at id ${c.id}; kept ` +
`${c.kept.kind} '${c.kept.label}', discarded ${c.discarded.kind} ` +
`'${c.discarded.label}'. The discarded entry is not recoverable ` +
`from this vault — the pre-restore copy is at ${backupPath ?? "(none)"}.`,
);
}
if (report.tombstonesEvicted > 0) {
io.err(
`warning: ${report.tombstonesEvicted} tombstone(s) dropped at the ` +
`${TOMBSTONE_RETENTION_CAP}-deletion retention cap. Deletions older ` +
`than that are forgotten, so merging an old replica or snapshot can ` +
`bring those entries back.`,
);
}
for (const c of collisions) io.err(`warning: ${c.message}`);
} finally {
await pool.close();
}
});
const agent = program.command("agent").description("session agent (holds unlocked seeds)");
agent
.command("start")
.description("run the session agent in the foreground")
.option("--ttl <seconds>", "default unlock TTL", String(DEFAULT_TTL_SECONDS))
.action(async (o: { ttl: string }) => {
const app = new AppDir(resolveAppDir((program.opts() as GlobalOpts).appDir));
const daemon = new AgentDaemon(agentSocketPath(app.root), parseIntOption(o.ttl, "--ttl", { min: 1 }), app.root);
await daemon.start();
io.out(JSON.stringify({ agent: "running", socket: agentSocketPath(app.root) }));
await new Promise(() => {}); // run until killed
});
agent
.command("token-issue")
.description("issue a scoped bearer token (printed exactly once)")
.option("--name <name>", "token name", "agent")
.option("--scope <scope...>", "read/use/reveal (repeatable)", ["read"])
.option("--kind <kind...>", "restrict to entry kinds (repeatable)")
.option(
"--label-regex <re>",
"restrict to labels the pattern MATCHES ANYWHERE (like Python's " +
"re.search): 'prod' also matches 'not-prod-db'. Anchor it — '^prod$' " +
"— for an exact match",
".*",
)
.option("--ttl <seconds>", "token lifetime", "300")
.option("--uses <n>", "max secret deliveries", "1")
.option(
"--exec-allowlist <cmd...>",
"restrict `use` to these commands (without it, a use-scoped holder can " +
"read the secret). Matches the command WORD only — the holder still " +
"chooses the arguments, so pick binaries that stay safe with any argv",
)
.action(
async (o: {
name: string; scope: string[]; kind?: string[]; labelRegex: string;
ttl: string; uses: string; execAllowlist?: string[];
}) => {
const opts = program.opts() as GlobalOpts;
const app = new AppDir(resolveAppDir(opts.appDir));
const fp = await currentFingerprint(app, opts);
const client = new AgentClient(agentSocketPath(app.root));
const issued = await client.tokenIssue({
fingerprint: fp,
name: o.name,
scopes: o.scope as ("read" | "use" | "reveal")[],
...(o.kind !== undefined && { kinds: o.kind }),
labelRegex: o.labelRegex,
ttl: parseIntOption(o.ttl, "--ttl", { min: 1 }),
uses: parseIntOption(o.uses, "--uses", { min: 1 }),
...(o.execAllowlist !== undefined && { execAllowlist: o.execAllowlist }),
});
io.out(
JSON.stringify(
{ token: issued.token, note: "shown once — store it now", record: issued.record },
null,
2,
),
);
},
);
agent
.command("token-list")
.description("list issued tokens (no secrets)")
.action(async () => {
const opts = program.opts() as GlobalOpts;
const app = new AppDir(resolveAppDir(opts.appDir));
const fp = await currentFingerprint(app, opts);
const client = new AgentClient(agentSocketPath(app.root));
io.out(JSON.stringify(await client.tokenList(fp), null, 2));
});
agent
.command("token-revoke <tokenId>")
.description("revoke a token immediately")
.action(async (tokenId: string) => {
const app = new AppDir(resolveAppDir((program.opts() as GlobalOpts).appDir));
const client = new AgentClient(agentSocketPath(app.root));
await client.tokenRevoke(tokenId);
io.out(JSON.stringify({ revoked: tokenId }));
});
agent
.command("audit-verify")
.description("verify the profile's HMAC-chained audit log (owner only)")
.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 path = join(app.profileDir(fp), "audit.log");
const records = await AuditLog.verify(path, deriveKeyIndex(mnemonic));
io.out(JSON.stringify({ verified: true, records: records.length, path }));
});
agent
.command("audit-tail")
.description("show the last audit records after verifying the chain (owner only)")
.option("-n <count>", "records to show", "10")
.action(async (o: { n: string }) => {
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 records = await AuditLog.verify(
join(app.profileDir(fp), "audit.log"),
deriveKeyIndex(mnemonic),
);
io.out(JSON.stringify(records.slice(-parseIntOption(o.n, "-n", { min: 1 })), null, 2));
});
agent
.command("status")
.description("show unlocked profiles and expiry times")
.action(async () => {
const app = new AppDir(resolveAppDir((program.opts() as GlobalOpts).appDir));
const client = new AgentClient(agentSocketPath(app.root));
io.out(JSON.stringify(await client.status(), null, 2));
});
agent
.command("stop")
.description("shut down the session agent (wipes held seeds)")
.action(async () => {
const app = new AppDir(resolveAppDir((program.opts() as GlobalOpts).appDir));
const client = new AgentClient(agentSocketPath(app.root));
// Must authenticate, and must not claim success it did not get: the
// user is being told their seeds were wiped.
await client.shutdown();
io.out(JSON.stringify({ agent: "stopped" }));
});
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)",
)
.option("--host <host>", "bind address", "127.0.0.1")
.option("--port <port>", "bind port", "8765")
.option(
"--allow-remote",
"permit a non-loopback bind (requires a TLS-terminating proxy in front)",
)
.option(
"--cors-origin <origin...>",
"allow these browser origins (repeatable; none by default)",
)
.option("--locked", "start locked; unlock via POST /api/v1/vault/unlock")
.action(
async (o: {
host: string;
port: string;
allowRemote?: boolean;
corsOrigin?: string[];
locked?: boolean;
}) => {
const opts = program.opts() as GlobalOpts;
const app = new AppDir(resolveAppDir(opts.appDir));
const fingerprint = await currentFingerprint(app, opts);
// Starting unlocked is the common case for a local automation
// server, but --locked lets an operator hand the port to something
// before the password exists anywhere.
const mnemonic = o.locked ? null : await resolveMnemonic(app, opts);
const { serveApi } = await import("./api/serve.js");
await serveApi({
app,
fingerprint,
mnemonic,
host: o.host,
port: parseIntOption(o.port, "--port", { min: 1, max: 65535 }),
...(o.allowRemote !== undefined && { allowRemote: o.allowRemote }),
...(o.corsOrigin !== undefined && { corsOrigins: o.corsOrigin }),
io,
});
},
);
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 semantic = program
.command("semantic")
.description("local retrieval index over entry METADATA (never secrets)");
/**
* Records live in a plaintext file beside the vault, so the index holds
* only what is safe to leave unencrypted: labels, notes, tags, usernames,
* URLs, key names. Never a value. See buildSemanticRecords.
*/
async function semanticPaths(opts: GlobalOpts) {
const app = new AppDir(resolveAppDir(opts.appDir));
const fp = await currentFingerprint(app, opts);
const dir = join(app.profileDir(fp), "semantic_index");
return { dir, records: join(dir, "records.json"), manifest: join(dir, "manifest.json") };
}
async function readManifest(path: string): Promise<Record<string, unknown>> {
if (!existsSync(path)) return {};
try {
return JSON.parse(await readFile(path, "utf8")) as Record<string, unknown>;
} catch {
// A corrupt manifest reads as "not built"; the fix is a rebuild, which
// is what status is meant to prompt.
return {};
}
}
/**
* Delete an index written before secrets stopped being indexed.
*
* Such a file holds stored secrets in the clear, and fixing the writer does
* not rewrite files already on disk. Deleting is safe — the index is a
* derived cache, rebuilt from the vault in milliseconds — and leaving it is
* leaving plaintext secrets next to an encrypted vault.
*/
async function purgeStaleSemantic(paths: {
records: string;
manifest: string;
}): Promise<boolean> {
if (!isStaleSemanticIndex(await readManifest(paths.manifest))) return false;
for (const path of [paths.records, paths.manifest]) {
if (existsSync(path)) await rm(path, { force: true });
}
io.err(
"Removed a semantic index built by an older version: it stored entry " +
"secrets in plaintext. Run 'semantic build' to search again.",
);
return true;
}
async function readRecords(path: string): Promise<SemanticRecord[]> {
if (!existsSync(path)) return [];
try {
const data = JSON.parse(await readFile(path, "utf8"));
return Array.isArray(data) ? (data as SemanticRecord[]) : [];
} catch {
return [];
}
}
semantic
.command("build")
.description("(re)build the index from the current vault")
.action(async () => {
const opts = program.opts() as GlobalOpts;
const vault = await openFromOptions(opts);
const paths = await semanticPaths(opts);
const entries = Object.entries(vault.index.entries).map(([id, e]) => ({
...(e as object),
id: Number(id),
})) as Array<Record<string, unknown>>;
const records = buildSemanticRecords(entries);
await mkdir(paths.dir, { recursive: true });
// atomicWrite gives a fresh 0600 inode. Python created these at the
// process umask, which on a default install is 0664 — group and world
// readable, for a file sitting next to an encrypted vault.
await atomicWrite(
paths.records,
utf8(JSON.stringify(records, null, 2)),
);
await atomicWrite(
paths.manifest,
utf8(
JSON.stringify(
semanticManifest({
enabled: true,
built: true,
recordCount: records.length,
updatedAt: Date.now() / 1000,
}),
null,
2,
),
),
);
io.out(JSON.stringify({ built: records.length, index: paths.dir }));
});
semantic
.command("status")
.description("show whether the index exists and how current it is")
.action(async () => {
const opts = program.opts() as GlobalOpts;
const paths = await semanticPaths(opts);
await purgeStaleSemantic(paths);
const manifest = await readManifest(paths.manifest);
const records = await readRecords(paths.records);
io.out(JSON.stringify(semanticStatus(manifest, records.length), null, 2));
});
semantic
.command("search <query>")
.description("rank entries by metadata overlap (returns references, no secrets)")
.option("-k, --limit <n>", "maximum hits", "10")
.option("--kind <kind>", "restrict to one entry kind")
.action(async (query: string, o: { limit: string; kind?: string }) => {
const opts = program.opts() as GlobalOpts;
const paths = await semanticPaths(opts);
// Reading a stale index would serve results derived from the secrets it
// should never have held.
await purgeStaleSemantic(paths);
const records = await readRecords(paths.records);
if (records.length === 0) {
throw new Error("no semantic index; run 'semantic build' first");
}
const hits = searchSemanticRecords(records, query, {
k: parseIntOption(o.limit, "--limit", { min: 1 }),
...(o.kind !== undefined && { kind: o.kind }),
});
io.out(
JSON.stringify(
hits.map((h) => ({ ...h, ref: refFor(String(h.entry_id)) })),
null,
2,
),
);
});
// ------------------------------------------------- high-risk partition
/**
* The factor never comes from argv — it is the second factor for exactly
* the secrets deemed to need one, and argv is in the process list and the
* shell history.
*/
function requireFactor(): string {
const factor = process.env["SEEDPASS_HIGH_RISK_FACTOR"];
if (!factor) {
throw new Error(
"set SEEDPASS_HIGH_RISK_FACTOR in the environment (never pass the " +
"high-risk factor on the command line)",
);
}
return factor;
}
const highRisk = agent
.command("high-risk")
.description("second-factor partition for ssh/pgp/seed/nostr entries");
highRisk
.command("factor-set")
.description("configure the high-risk factor (reads SEEDPASS_HIGH_RISK_FACTOR)")
.action(async () => {
const opts = program.opts() as GlobalOpts;
const app = new AppDir(resolveAppDir(opts.appDir));
if (factorConfigured(app.root)) {
// Replacing the factor mints a NEW partition key, which would leave
// any existing partition file undecryptable. Losing a partition is
// losing secrets, so this refuses rather than doing it quietly.
throw new Error(
"a high-risk factor is already configured. Replacing it generates a " +
"new partition key and would make any existing partition " +
"permanently unreadable; migrate the entries back out first.",
);
}
await setFactor(app.root, requireFactor());
io.out(JSON.stringify({ status: "ok", configured: true }));
});
highRisk
.command("status")
.description("whether the factor is configured and the partition unlocked")
.action(async () => {
const opts = program.opts() as GlobalOpts;
const app = new AppDir(resolveAppDir(opts.appDir));
const fp = await currentFingerprint(app, opts);
const client = new AgentClient(agentSocketPath(app.root));
let session = { unlocked: false, expires_at: null as number | null };
try {
session = await client.highRiskStatus(fp);
} catch {
// No agent running means nothing is unlocked, which is the honest
// answer rather than an error.
}
io.out(
JSON.stringify({
fingerprint: fp,
configured: factorConfigured(app.root),
unlocked: session.unlocked,
expires_at: session.expires_at,
partition_exists: existsSync(partitionPath(app.profileDir(fp))),
}),
);
});
highRisk
.command("unlock")
.description("unlock the partition for a bounded window (reads SEEDPASS_HIGH_RISK_FACTOR)")
.option("--ttl <seconds>", "unlock lifetime", "300")
.action(async (o: { ttl: string }) => {
const opts = program.opts() as GlobalOpts;
const app = new AppDir(resolveAppDir(opts.appDir));
const fp = await currentFingerprint(app, opts);
// Unwrapping happens HERE, not in the daemon: a wrong factor never
// reaches the process holding seeds, and the KDF cost stays out of it.
const tag = await tagForFactor(app.root, requireFactor());
const client = new AgentClient(agentSocketPath(app.root));
const expiresAt = await client.highRiskUnlock(
fp,
tag,
parseIntOption(o.ttl, "--ttl", { min: 1 }),
);
io.out(JSON.stringify({ status: "unlocked", fingerprint: fp, expires_at: expiresAt }));
});
highRisk
.command("lock")
.description("drop the high-risk unlock immediately")
.action(async () => {
const opts = program.opts() as GlobalOpts;
const app = new AppDir(resolveAppDir(opts.appDir));
const fp = await currentFingerprint(app, opts);
const client = new AgentClient(agentSocketPath(app.root));
io.out(JSON.stringify({ status: "ok", locked: await client.highRiskLock(fp) }));
});
highRisk
.command("migrate")
.description("move high-risk entries out of the vault index into the partition")
.option(
"--kind <kind...>",
`entry kinds to move (default: ${HIGH_RISK_KINDS.join(", ")})`,
)
.action(async (o: { kind?: string[] }) => {
const opts = program.opts() as GlobalOpts;
const app = new AppDir(resolveAppDir(opts.appDir));
const fp = await currentFingerprint(app, opts);
const tag = await tagForFactor(app.root, requireFactor());
const kinds = new Set(o.kind ?? [...HIGH_RISK_KINDS]);
const profileDir = app.profileDir(fp);
const moved: string[] = [];
await mutateVault(opts, async (vault) => {
const partition = await readPartition(profileDir, tag);
for (const [id, raw] of Object.entries(vault.index.entries)) {
const entry = raw as unknown as Record<string, unknown>;
if (isPartitionStub(entry)) continue; // already moved
const kind = String(entry["kind"] ?? entry["type"] ?? "").toLowerCase();
if (!kinds.has(kind)) continue;
partition[id] = entry;
(vault.index.entries as unknown as Record<string, unknown>)[id] =
partitionStub(id, entry, kind, Date.now() / 1000);
moved.push(id);
}
// Write the partition BEFORE the index. If the order were reversed a
// crash between them would leave stubs pointing at entries that were
// never stored — the vault would have lost the secrets outright.
if (moved.length > 0) await writePartition(profileDir, tag, partition);
});
io.out(
JSON.stringify({
moved_count: moved.length,
moved_indexes: moved.sort((a, b) => Number(a) - Number(b)),
partition_file: partitionPath(profileDir),
}),
);
});
// ------------------------------------------------------- approval gates
const approval = agent
.command("approval")
.description("one-shot authorizations for dangerous actions");
approval
.command("issue")
.description("issue an approval for a named action")
.requiredOption("--action <action>", VALID_APPROVAL_ACTIONS.join("|"))
.option("--resource <resource>", "restrict to one resource", "*")
.option("--ttl <seconds>", "lifetime", "300")
.option("--uses <n>", "how many times it may be consumed", "1")
.option("--issued-by <who>", "who authorized it, for the record", "manual")
.action(async (o: {
action: string; resource: string; ttl: string; uses: string; issuedBy: string;
}) => {
const opts = program.opts() as GlobalOpts;
const app = new AppDir(resolveAppDir(opts.appDir));
const record = await issueApproval(app.root, {
action: o.action,
resource: o.resource,
issuedBy: o.issuedBy,
ttlSeconds: parseIntOption(o.ttl, "--ttl", { min: 1 }),
uses: parseIntOption(o.uses, "--uses", { min: 1 }),
});
io.out(JSON.stringify(record, null, 2));
});
approval
.command("list")
.description("list approvals")
.option("--include-revoked", "also show revoked ones")
.action(async (o: { includeRevoked?: boolean }) => {
const opts = program.opts() as GlobalOpts;
const app = new AppDir(resolveAppDir(opts.appDir));
io.out(
JSON.stringify(
await listApprovals(app.root, {
...(o.includeRevoked !== undefined && { includeRevoked: o.includeRevoked }),
}),
null,
2,
),
);
});
approval
.command("revoke <approvalId>")
.description("revoke an approval immediately")
.action(async (approvalId: string) => {
const opts = program.opts() as GlobalOpts;
const app = new AppDir(resolveAppDir(opts.appDir));
const revoked = await revokeApproval(app.root, approvalId);
if (!revoked) throw new Error(`no active approval ${approvalId}`);
io.out(JSON.stringify({ status: "ok", revoked: approvalId }));
});
// ------------------------------------------------------- recovery split
const recovery = agent
.command("recovery")
.description("threshold split of a secret, and backup drills");
recovery
.command("split")
.description(
"split SEEDPASS_RECOVERY_SECRET into shares (any --threshold of which " +
"recover it); shares are printed once and never stored",
)
.requiredOption("--total <n>", "how many shares to produce")
.requiredOption("--threshold <n>", "how many are needed to recover")
.option("--label <label>", "label recorded in each share", "default")
.action(async (o: { total: string; threshold: string; label: string }) => {
// Never from argv: the secret being split is typically a parent seed.
const secret = process.env["SEEDPASS_RECOVERY_SECRET"];
if (!secret) {
throw new Error(
"set SEEDPASS_RECOVERY_SECRET in the environment (never pass the " +
"secret to split on the command line)",
);
}
const shares = splitSecret(secret, {
totalShares: parseIntOption(o.total, "--total", { min: 2, max: 32 }),
threshold: parseIntOption(o.threshold, "--threshold", { min: 2 }),
label: o.label,
});
io.out(JSON.stringify({ shares }, null, 2));
io.err(
`Distribute these ${shares.length} shares separately. Any ` +
`${o.threshold} of them reconstruct the secret; fewer reveal ` +
`nothing. SeedPass keeps no copy.`,
);
});
recovery
.command("recover")
.description("reconstruct a secret from shares (reads SEEDPASS_RECOVERY_SHARES, newline separated)")
.action(async () => {
const raw = process.env["SEEDPASS_RECOVERY_SHARES"];
if (!raw) {
throw new Error(
"set SEEDPASS_RECOVERY_SHARES to the shares, one per line (never " +
"pass them on the command line)",
);
}
const shares = raw.split("\n").map((s) => s.trim()).filter(Boolean);
// The recovered value is the secret itself, so it obeys the same egress
// rule as `vault reveal-parent-seed`.
if (!process.stdout.isTTY) {
throw new Error(
"refusing to print a recovered secret into a pipe (a log, a " +
"transcript, an agent's context); run this on a terminal",
);
}
io.out(recoverSecret(shares));
});
recovery
.command("drill <backupPath>")
.description("record that a backup was checked; the log is HMAC-chained")
.option("--max-age-days <n>", "warn if the backup is older than this")
.option("--simulated", "mark the drill as a rehearsal")
.action(async (backupPath: string, o: { maxAgeDays?: string; simulated?: boolean }) => {
const opts = program.opts() as GlobalOpts;
const app = new AppDir(resolveAppDir(opts.appDir));
const fp = await currentFingerprint(app, opts);
const record = await recordRecoveryDrill(app.root, {
fingerprint: fp,
backupPath: resolveHome(backupPath),
simulated: Boolean(o.simulated),
...(o.maxAgeDays !== undefined && {
expectedMaxAgeDays: parseIntOption(o.maxAgeDays, "--max-age-days", { min: 0 }),
}),
});
io.out(JSON.stringify(record, null, 2));
if (record.status !== "ok") {
io.err(
record.backup_exists
? `warning: that backup is ${record.backup_age_days} days old.`
: `warning: no backup found at ${record.backup_path}.`,
);
}
});
recovery
.command("drill-list")
.description("show recorded drills")
.option("-n <count>", "how many", "20")
.action(async (o: { n: string }) => {
const opts = program.opts() as GlobalOpts;
const app = new AppDir(resolveAppDir(opts.appDir));
io.out(
JSON.stringify(
await listRecoveryDrills(app.root, {
limit: parseIntOption(o.n, "-n", { min: 1 }),
}),
null,
2,
),
);
});
recovery
.command("drill-verify")
.description("verify the drill log's HMAC chain")
.action(async () => {
const opts = program.opts() as GlobalOpts;
const app = new AppDir(resolveAppDir(opts.appDir));
const result = await verifyRecoveryDrills(app.root);
io.out(JSON.stringify(result, null, 2));
// A broken chain means the history was edited; a script needs to know.
if (!result.valid) process.exitCode = 1;
});
// --------------------------------------------------------- job profiles
const jobs = agent
.command("job-profile")
.description("stored descriptions of unattended retrievals");
jobs
.command("create <jobId>")
.description("create a job profile")
.requiredOption("--query <query>", "entry reference or search text")
.option("--auth-broker <broker>", "env|keyring|command", "env")
.option("--broker-service <name>", "keyring service", "seedpass")
.option("--broker-account <name>", "keyring account", "")
.option("--broker-command <cmd>", "command that prints the credential")
.option("--policy-binding <name>", "policy this job runs under", "default")
.option("--schedule <spec>", "when it is expected to run", "")
.option("--description <text>", "what it is for", "")
.option("--host-binding <host>", "host it is expected to run on", "")
.option("--lease-only", "restrict to lease delivery")
.option("--lease-ttl <seconds>", "lease lifetime", "0")
.option("--lease-uses <n>", "lease uses", "0")
.option("--reveal", "permit plaintext reveal")
.action(async (jobId: string, o: Record<string, string | boolean | undefined>) => {
const opts = program.opts() as GlobalOpts;
const app = new AppDir(resolveAppDir(opts.appDir));
const fp = await currentFingerprint(app, opts);
const record = await createJobProfile(app.root, {
jobId,
fingerprint: fp,
query: String(o["query"]),
authBroker: String(o["authBroker"]),
brokerService: String(o["brokerService"]),
brokerAccount: String(o["brokerAccount"]),
brokerCommand: o["brokerCommand"] === undefined ? null : String(o["brokerCommand"]),
policyBinding: String(o["policyBinding"]),
// The stamp binds the profile to the policy in force now, so a later
// policy change shows up as a mismatch rather than passing silently.
policyStamp: await currentPolicyStamp(app.root),
schedule: String(o["schedule"]),
description: String(o["description"]),
hostBinding: String(o["hostBinding"]),
leaseOnly: Boolean(o["leaseOnly"]),
leaseTtl: parseIntOption(String(o["leaseTtl"]), "--lease-ttl", { min: 0 }),
leaseUses: parseIntOption(String(o["leaseUses"]), "--lease-uses", { min: 0 }),
reveal: Boolean(o["reveal"]),
});
io.out(JSON.stringify(record, null, 2));
});
jobs
.command("list")
.description("list job profiles")
.option("--include-revoked", "also show revoked ones")
.action(async (o: { includeRevoked?: boolean }) => {
const opts = program.opts() as GlobalOpts;
const app = new AppDir(resolveAppDir(opts.appDir));
io.out(
JSON.stringify(
await listJobProfiles(app.root, {
...(o.includeRevoked !== undefined && { includeRevoked: o.includeRevoked }),
}),
null,
2,
),
);
});
jobs
.command("revoke <jobId>")
.description("revoke a job profile")
.action(async (jobId: string) => {
const opts = program.opts() as GlobalOpts;
const app = new AppDir(resolveAppDir(opts.appDir));
if (!(await revokeJobProfile(app.root, jobId))) {
throw new Error(`no active job profile ${jobId}`);
}
io.out(JSON.stringify({ status: "ok", revoked: jobId }));
});
jobs
.command("check")
.description("report profiles whose policy has changed since they were created")
.action(async () => {
const opts = program.opts() as GlobalOpts;
const app = new AppDir(resolveAppDir(opts.appDir));
const checks = await checkJobProfiles(app.root, await currentPolicyStamp(app.root));
io.out(JSON.stringify(checks, null, 2));
// A stale binding means the job may be running under rules that no
// longer apply — worth a non-zero exit for a scheduled check.
if (checks.some((c) => !c.policy_current)) process.exitCode = 1;
});
const util = program.command("util").description("utility commands");
util
.command("check-derivation")
.description(
"report entries that share a BIP-85 derivation coordinate (ssh/pgp at " +
"the same index are the same key)",
)
.action(async () => {
const vault = await openFromOptions(program.opts());
const collisions = findDerivationCollisions(vault.index);
io.out(
JSON.stringify(
{
checked: Object.keys(vault.index.entries).length,
collisions: collisions.map((c) => ({
index: c.index,
severity: c.severity,
entries: c.entries,
message: c.message,
})),
},
null,
2,
),
);
// Exit status is the machine-readable answer for a script that only
// wants to know whether the vault is clean.
if (collisions.length > 0) process.exitCode = 1;
});
util
.command("generate-password")
// Deliberately profile-less: this derives from SEEDPASS_MNEMONIC and the
// flags alone, never from a profile's configured policy. Unlike
// `entry reveal`, there is no entry and no profile to read a base policy
// from, so the flags ARE the policy — an unflagged run gives the built-in
// defaults, not whatever the active profile happens to be configured for.
.description(
"derive a password from the flags alone, ignoring any profile's " +
"configured policy (Python parity: index 0, gen v1 by default)",
)
.option("--length <n>", "password length", "24")
.option("--index <n>", "derivation index", "0")
.option("--gen-version <v>", "generation version (1 or 2)", "1")
.option("--no-special", "exclude special characters")
.option("--allowed-special-chars <set>")
.option("--special-mode <mode>")
.option("--exclude-ambiguous")
.option("--min-uppercase <n>")
.option("--min-lowercase <n>")
.option("--min-digits <n>")
.option("--min-special <n>")
.action(
(o: {
length: string; index: string; genVersion: string; special: boolean;
allowedSpecialChars?: string; specialMode?: string; excludeAmbiguous?: boolean;
minUppercase?: string; minLowercase?: string; minDigits?: string; minSpecial?: string;
}) => {
const policy: PasswordPolicy = {
...(o.special === false && { includeSpecialChars: false }),
...(o.allowedSpecialChars !== undefined && { allowedSpecialChars: o.allowedSpecialChars }),
...(o.specialMode !== undefined && { specialMode: o.specialMode }),
...(o.excludeAmbiguous !== undefined && { excludeAmbiguous: o.excludeAmbiguous }),
...(o.minUppercase !== undefined && {
minUppercase: parseIntOption(o.minUppercase, "--min-uppercase", { min: 0 }),
}),
...(o.minLowercase !== undefined && {
minLowercase: parseIntOption(o.minLowercase, "--min-lowercase", { min: 0 }),
}),
...(o.minDigits !== undefined && {
minDigits: parseIntOption(o.minDigits, "--min-digits", { min: 0 }),
}),
...(o.minSpecial !== undefined && {
minSpecial: parseIntOption(o.minSpecial, "--min-special", { min: 0 }),
}),
};
const bip85 = Bip85.fromMnemonic(requireMnemonic());
io.out(
generatePassword(bip85, {
length: parseIntOption(o.length, "--length", { min: 8, max: 128 }),
index: parseIntOption(o.index, "--index", { min: 0 }),
genVersion: parseIntOption(o.genVersion, "--gen-version", { min: 1, max: 2 }),
policy,
}),
);
},
);
const vaultCmd = program.command("vault").description("vault import/export");
vaultCmd
.command("export <destFile>")
.description(
"write a portable backup (encrypted by default); give a directory to " +
`get a default seedpass-<fingerprint>-<date>${BACKUP_EXTENSION} name`,
)
.option("--plaintext", "HIGH RISK: export without encryption")
.option("--overwrite", "replace an existing file")
.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<string, unknown>, {
mnemonic: vault.mnemonic,
fingerprint,
encrypt: !o.plaintext,
});
if (existsSync(destFile) && !o.overwrite) {
throw new Error(`File already exists: ${destFile} (use --overwrite)`);
}
await atomicWrite(destFile, utf8(JSON.stringify(wrapper, null, 2) + "\n"));
io.out(
JSON.stringify({
exported: destFile,
encrypted: !o.plaintext,
checksum: wrapper.checksum,
}),
);
});
vaultCmd
.command("unlock")
.description("decrypt the parent seed with SEEDPASS_PASSWORD and hand it to the agent")
.option("--ttl <seconds>", "how long the agent holds the seed")
.action(async (o: { ttl?: string }) => {
const opts = program.opts() as GlobalOpts;
const app = new AppDir(resolveAppDir(opts.appDir));
const fp = await currentFingerprint(app, opts);
const password = process.env["SEEDPASS_PASSWORD"];
if (!password) throw new Error("SEEDPASS_PASSWORD is not set");
const mnemonic = await app.decryptParentSeed(fp, password);
const client = new AgentClient(agentSocketPath(app.root));
const expiresAt = await client.put(
fp,
mnemonic,
o.ttl !== undefined ? parseIntOption(o.ttl, "--ttl", { min: 1 }) : undefined,
);
io.out(JSON.stringify({ unlocked: fp, expires_at: expiresAt }));
});
vaultCmd
.command("lock")
.description("drop the seed from the session agent")
.option("--all", "lock every profile")
.action(async (o: { all?: boolean }) => {
const opts = program.opts() as GlobalOpts;
const app = new AppDir(resolveAppDir(opts.appDir));
const client = new AgentClient(agentSocketPath(app.root));
const locked = o.all
? await client.lock()
: await client.lock(await currentFingerprint(app, opts));
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")
.option("--inspect", "verify and summarize without writing")
.option("--yes", "confirm replacing a vault that already has entries")
.option(
"--allow-fingerprint-mismatch",
"import a backup belonging to a DIFFERENT seed (every derived secret " +
"will change)",
)
.action(
async (
srcFile: string,
o: { inspect?: boolean; yes?: boolean; allowFingerprintMismatch?: boolean },
) => {
const opts = program.opts() as GlobalOpts;
const app = new AppDir(resolveAppDir(opts.appDir));
const raw = await readFile(srcFile);
// Read the envelope rather than pattern-matching the file text: the
// payload is attacker-influenced in the sense that it holds arbitrary
// user data, and deciding whether a seed is needed by regex over the
// whole file is a fragile way to ask a structural question.
const wrapper = parseBackupWrapper(new Uint8Array(raw));
const isEncrypted = wrapper.encryption_mode !== "none";
const mnemonic = isEncrypted ? await resolveMnemonic(app, opts) : undefined;
const parsed = await importBackup(new Uint8Array(raw), {
...(mnemonic !== undefined && { mnemonic }),
});
const index = parseVaultIndex(parsed);
const entryCount = Object.keys(index.entries).length;
if (o.inspect) {
io.out(
JSON.stringify({
inspected: srcFile,
schema_version: index.schema_version,
entry_count: entryCount,
encryption_mode: wrapper.encryption_mode,
fingerprint: wrapper.fingerprint,
derivation_collisions: findDerivationCollisions(index).map((c) => ({
index: c.index,
severity: c.severity,
entries: c.entries,
})),
written: false,
}),
);
return;
}
const targetMnemonic = mnemonic ?? (await resolveMnemonic(app, opts));
// A seed-only backup is bound to its seed implicitly — the wrong seed
// cannot decrypt it. A plaintext backup has no such binding: it will
// import cleanly into any profile, and then every derived entry
// (passwords, SSH, PGP, seeds) silently produces a DIFFERENT secret
// than the original, because the secret comes from the target profile's
// seed and not from the backup. Nothing fails; the user just gets the
// wrong passwords. The wrapper records which seed it came from, so
// check it rather than trusting the operator to have noticed.
const targetFingerprint = generateFingerprint(targetMnemonic);
if (wrapper.fingerprint !== targetFingerprint && !o.allowFingerprintMismatch) {
throw new Error(
`backup belongs to profile ${wrapper.fingerprint}, but the target ` +
`is ${targetFingerprint}. Importing it would re-derive every ` +
`secret from the target's seed, silently producing different ` +
`passwords than the ones in the backup. Import into the matching ` +
`profile, or pass --allow-fingerprint-mismatch if that is really ` +
`what you want.`,
);
}
// Resolve the profile only when the target actually depends on it: an
// explicit --vault path needs no profile, and demanding one anyway made
// `vault import --vault x` fail on machines with no ~/.seedpass — an
// environment coupling that local runs masked for months because this
// machine has one. (The CI-only parity failure was exactly this.)
const vaultPath =
opts.vault ?? join(app.profileDir(await currentFingerprint(app, opts)), INDEX_FILENAME);
if (existsSync(vaultPath) && !o.yes) {
const current = await openVault(vaultPath, targetMnemonic);
if (Object.keys(current.index.entries).length > 0) {
throw new Error(
"refusing to replace a non-empty vault without --yes " +
"(use --inspect to check the backup first)",
);
}
}
await saveVault({ index, mnemonic: targetMnemonic, path: vaultPath });
const collisions = findDerivationCollisions(index);
io.out(
JSON.stringify({
imported: srcFile,
into: vaultPath,
schema_version: index.schema_version,
entry_count: entryCount,
...(collisions.length > 0 && {
derivation_collisions: collisions.map((c) => ({
index: c.index,
severity: c.severity,
entries: c.entries,
})),
}),
}),
);
for (const c of collisions) io.err(`warning: ${c.message}`);
},
);
return program;
}