From 841d0984b32948d9879bbd9a3ab3e0f69fc61500 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 14 Aug 2026 14:15:51 -0400 Subject: [PATCH] TS port slice 8: profiles, config, and the session agent - Python-compatible ~/.seedpass layout: fingerprints.json (same shape, indent=4), per-profile dirs with parent_seed.enc (kdf/ct wrapper; unlock honors argon2id metadata and falls back across pbkdf2 iteration counts), index-key-encrypted seedpass_config.json.enc with ConfigManager defaults. - CLI: fingerprint list/add/switch/remove (remove requires --yes), config get/set (JSON-parsed values, pin/password hashes redacted on read), profile-aware vault resolution (--vault override, else active profile). - Session agent (the chosen unlock model): unix-socket daemon holding unlocked seeds in memory with TTL, 0600 socket; agent start/status/stop, vault unlock (password -> parent seed -> agent, never argv) and vault lock. Seed resolution order: SEEDPASS_MNEMONIC env, then agent. This daemon is the designed enforcement point for the section-9.3 lease/token layer. - Tests cover the full no-env-mnemonic lifecycle: locked error -> unlock with password only -> provision/reveal through the agent -> status -> lock -> locked again. 148 core + 29 CLI tests green. Co-Authored-By: Claude Fable 5 --- docs/typescript_port_compatibility_matrix.md | 6 +- js/packages/cli/package.json | 1 + js/packages/cli/src/agent.ts | 204 ++++++++++++++++++ js/packages/cli/src/appDir.ts | 163 ++++++++++++++ js/packages/cli/src/capabilities.ts | 12 +- js/packages/cli/src/configFile.ts | 64 ++++++ js/packages/cli/src/index.ts | 3 + js/packages/cli/src/program.ts | 210 ++++++++++++++++++- js/packages/cli/test/profiles.test.ts | 154 ++++++++++++++ js/pnpm-lock.yaml | 3 + 10 files changed, 812 insertions(+), 8 deletions(-) create mode 100644 js/packages/cli/src/agent.ts create mode 100644 js/packages/cli/src/appDir.ts create mode 100644 js/packages/cli/src/configFile.ts create mode 100644 js/packages/cli/test/profiles.test.ts diff --git a/docs/typescript_port_compatibility_matrix.md b/docs/typescript_port_compatibility_matrix.md index ad7a8ae7..196faa65 100644 --- a/docs/typescript_port_compatibility_matrix.md +++ b/docs/typescript_port_compatibility_matrix.md @@ -40,13 +40,15 @@ until a later milestone. | Entry creation (provision) | P0 | green | `entries_index.json` | TS `add*Entry` ops rebuild the Python `EntryManager` fixture index byte-for-byte (pinned clock): field shapes, id allocation, independent TOTP derivation-index allocation, ISO timestamp format. Not ported: index0 event emission. | | Entry modify/archive/links | P0 | green | `entry_mods.json` | TS replays a Python `EntryManager` op sequence (modify with kind-checked field matrix + policy merge, archive/restore, link add/remove with normalization and dedupe) byte-for-byte, including timestamp touching. | | Entry secret retrieval | P0 | green | `entry_secrets.json` | Reveal values match Python retrieval for password (v2), TOTP-at-time, nostr entry nsec (BIP-85 app 39 — NOT the sync client's 1237), seed and managed-account mnemonics. | -| CLI (Milestone 5, agent-blind MVP) | P1 | partial | — | `packages/cli` seedpass-js: capabilities, entry list/get/search (reference-first, `sp://entry/`, secrets replaced by `has_*` flags), `entry add password/totp/key-value/document/seed/managed-account/nostr` (provision-blind: returns refs, never the created secret), `entry reveal` as the only plaintext egress, `use --clipboard/--exec/--stdin-to` sinks (env-var injection, never argv/stdout), vault export/import. Tests assert secrets never appear in default or provisioning output. Also: modify, archive/unarchive, links, totp-codes, util generate-password. Not yet: profiles/config, relay sync, leases/tokens. | +| CLI (Milestone 5, agent-blind MVP) | P1 | partial | — | `packages/cli` seedpass-js: capabilities, entry list/get/search (reference-first, `sp://entry/`, secrets replaced by `has_*` flags), `entry add password/totp/key-value/document/seed/managed-account/nostr` (provision-blind: returns refs, never the created secret), `entry reveal` as the only plaintext egress, `use --clipboard/--exec/--stdin-to` sinks (env-var injection, never argv/stdout), vault export/import. Tests assert secrets never appear in default or provisioning output. Also: modify, archive/unarchive, links, totp-codes, util generate-password. Not yet: relay sync, leases/tokens. | +| Profiles + config (Python `~/.seedpass` layout) | P1 | green* | — | `fingerprint list/add/switch/remove`, `config get/set` over the Python directory layout (fingerprints.json, parent_seed.enc kdf/ct wrapper, index-key-encrypted config with ConfigManager defaults). *Layout-compatible and tested end-to-end in TS; opening a real Python-created profile is verified indirectly via the parent-seed wrapper fixture — direct cross-open test TODO. | +| Session agent (vault unlock/lock) | new (TS-only) | green | — | ssh-agent-style unix-socket daemon holding seeds with TTL (0600 socket, in-memory only); seed resolution env -> agent; full lifecycle tested with no mnemonic in the environment. Designed enforcement point for the section-9.3 lease/token layer. Python has no equivalent (its lock is in-process TUI state). | ## Environment coverage | Environment | Status | |---|---| -| Node 22 (vitest) | green — 148/148 core + 22 CLI | +| Node 22 (vitest) | green — 148/148 core + 29 CLI | | jsdom browser-like env | green — 148/148 core | | Real Chromium/Firefox (vitest browser mode) | todo — lands with Milestone 6 web app CI | diff --git a/js/packages/cli/package.json b/js/packages/cli/package.json index 7c8c879a..55f2db43 100644 --- a/js/packages/cli/package.json +++ b/js/packages/cli/package.json @@ -14,6 +14,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@scure/base": "^2.3.0", "@seedpass/core": "workspace:*", "commander": "^14.0.2" }, diff --git a/js/packages/cli/src/agent.ts b/js/packages/cli/src/agent.ts new file mode 100644 index 00000000..c1cf1dc8 --- /dev/null +++ b/js/packages/cli/src/agent.ts @@ -0,0 +1,204 @@ +/** + * Session agent: a small daemon that holds unlocked parent seeds in memory + * with a TTL, so the CLI can be used across invocations without exporting + * the mnemonic into every process environment (ssh-agent model). + * + * Transport: unix domain socket (0600), one JSON object per line. + * {op:"put", fingerprint, mnemonic, ttl?} -> {ok, expires_at} + * {op:"get", fingerprint} -> {ok, mnemonic?} + * {op:"lock", fingerprint?} -> {ok, locked: n} + * {op:"status"} -> {ok, profiles: [...]} + * {op:"shutdown"} -> {ok} + * + * This process is the designed enforcement point for the plan-section-9.3 + * lease/token layer: scoped grants and one-time leases attach here, not in + * the stateless CLI. + */ + +import { createServer, createConnection, type Socket } from "node:net"; +import { chmod, rm } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import process from "node:process"; + +export const DEFAULT_TTL_SECONDS = 900; + +export function agentSocketPath(appDir: string): string { + return process.env["SEEDPASS_AGENT_SOCK"] ?? join(appDir, "agent.sock"); +} + +interface Held { + mnemonic: string; + expiresAt: number; +} + +export interface AgentStatusProfile { + fingerprint: string; + expires_at: number; +} + +export class AgentDaemon { + private held = new Map(); + private server = createServer((socket) => this.serve(socket)); + + constructor( + private readonly socketPath: string, + private readonly defaultTtl = DEFAULT_TTL_SECONDS, + ) {} + + async start(): Promise { + if (existsSync(this.socketPath)) { + // A live agent refuses to be replaced; a stale socket file is cleaned. + const alive = await AgentClient.ping(this.socketPath); + if (alive) throw new Error(`agent already running at ${this.socketPath}`); + await rm(this.socketPath, { force: true }); + } + await new Promise((resolve, reject) => { + this.server.once("error", reject); + this.server.listen(this.socketPath, () => resolve()); + }); + await chmod(this.socketPath, 0o600); + } + + async stop(): Promise { + this.held.clear(); + await new Promise((resolve) => this.server.close(() => resolve())); + await rm(this.socketPath, { force: true }); + } + + private expire(): void { + const now = Date.now() / 1000; + for (const [fp, held] of this.held) { + if (held.expiresAt <= now) this.held.delete(fp); + } + } + + private handle(msg: Record): Record { + this.expire(); + switch (msg["op"]) { + case "put": { + const fingerprint = String(msg["fingerprint"] ?? ""); + const mnemonic = String(msg["mnemonic"] ?? ""); + if (!fingerprint || !mnemonic) return { ok: false, error: "missing fields" }; + const ttl = Number(msg["ttl"] ?? this.defaultTtl); + const expiresAt = Math.floor(Date.now() / 1000 + ttl); + this.held.set(fingerprint, { mnemonic, expiresAt }); + return { ok: true, expires_at: expiresAt }; + } + case "get": { + const held = this.held.get(String(msg["fingerprint"] ?? "")); + return held ? { ok: true, mnemonic: held.mnemonic } : { ok: false, error: "locked" }; + } + case "lock": { + const fp = msg["fingerprint"]; + if (fp === undefined) { + const n = this.held.size; + this.held.clear(); + return { ok: true, locked: n }; + } + const had = this.held.delete(String(fp)); + return { ok: true, locked: had ? 1 : 0 }; + } + case "status": { + const profiles: AgentStatusProfile[] = [...this.held.entries()].map( + ([fingerprint, h]) => ({ fingerprint, expires_at: h.expiresAt }), + ); + return { ok: true, profiles }; + } + case "ping": + return { ok: true, pong: true }; + case "shutdown": + setImmediate(() => void this.stop().then(() => process.exit(0))); + return { ok: true }; + default: + return { ok: false, error: `unknown op ${String(msg["op"])}` }; + } + } + + private serve(socket: Socket): void { + let buffer = ""; + socket.on("data", (chunk) => { + buffer += chunk.toString("utf8"); + let nl; + while ((nl = buffer.indexOf("\n")) >= 0) { + const line = buffer.slice(0, nl); + buffer = buffer.slice(nl + 1); + let reply: Record; + try { + reply = this.handle(JSON.parse(line) as Record); + } catch (e) { + reply = { ok: false, error: String(e) }; + } + socket.write(JSON.stringify(reply) + "\n"); + } + }); + socket.on("error", () => socket.destroy()); + } +} + +export class AgentClient { + constructor(private readonly socketPath: string) {} + + static async ping(socketPath: string): Promise { + try { + await new AgentClient(socketPath).request({ op: "ping" }); + return true; + } catch { + return false; + } + } + + async request(msg: Record): Promise> { + return new Promise((resolve, reject) => { + const socket = createConnection(this.socketPath); + const timer = setTimeout(() => { + socket.destroy(); + reject(new Error("agent request timed out")); + }, 2000); + let buffer = ""; + socket.on("connect", () => socket.write(JSON.stringify(msg) + "\n")); + socket.on("data", (chunk) => { + buffer += chunk.toString("utf8"); + const nl = buffer.indexOf("\n"); + if (nl >= 0) { + clearTimeout(timer); + socket.end(); + try { + resolve(JSON.parse(buffer.slice(0, nl)) as Record); + } catch (e) { + reject(e instanceof Error ? e : new Error(String(e))); + } + } + }); + socket.on("error", (e) => { + clearTimeout(timer); + reject(e); + }); + }); + } + + async put(fingerprint: string, mnemonic: string, ttl?: number): Promise { + const r = await this.request({ op: "put", fingerprint, mnemonic, ...(ttl && { ttl }) }); + if (!r["ok"]) throw new Error(String(r["error"])); + return Number(r["expires_at"]); + } + + async get(fingerprint: string): Promise { + try { + const r = await this.request({ op: "get", fingerprint }); + return r["ok"] ? String(r["mnemonic"]) : null; + } catch { + return null; + } + } + + async lock(fingerprint?: string): Promise { + const r = await this.request({ op: "lock", ...(fingerprint && { fingerprint }) }); + return Number(r["locked"] ?? 0); + } + + async status(): Promise { + const r = await this.request({ op: "status" }); + return (r["profiles"] ?? []) as AgentStatusProfile[]; + } +} diff --git a/js/packages/cli/src/appDir.ts b/js/packages/cli/src/appDir.ts new file mode 100644 index 00000000..5a5ed7ec --- /dev/null +++ b/js/packages/cli/src/appDir.ts @@ -0,0 +1,163 @@ +/** + * Application directory and profile (fingerprint) management, compatible + * with the Python layout so seedpass-js can open an existing ~/.seedpass: + * + * ~/.seedpass/fingerprints.json {fingerprints, last_used, names} + * ~/.seedpass//parent_seed.enc kdf/ct wrapper, password key + * ~/.seedpass//seedpass_entries_db.json.enc index key + * ~/.seedpass//seedpass_config.json.enc index key + */ + +import { mkdir, readFile, writeFile, rm } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import process from "node:process"; +import { base64url } from "@scure/base"; +import { + generateFingerprint, + deriveKeyFromPassword, + deriveKeyFromPasswordArgon2, + deriveIndexKeyBytes, + decryptPayload, + encryptV3, + parseEncryptedFile, + utf8, + type KdfConfig, +} from "@seedpass/core"; + +export const INDEX_FILENAME = "seedpass_entries_db.json.enc"; +export const CONFIG_FILENAME = "seedpass_config.json.enc"; +export const PARENT_SEED_FILENAME = "parent_seed.enc"; + +export interface FingerprintsFile { + fingerprints: string[]; + last_used: string | null; + names: Record; +} + +export function resolveAppDir(override?: string): string { + return override ?? process.env["SEEDPASS_APP_DIR"] ?? join(homedir(), ".seedpass"); +} + +export class AppDir { + constructor(public readonly root: string) {} + + private get fingerprintsPath(): string { + return join(this.root, "fingerprints.json"); + } + + profileDir(fingerprint: string): string { + return join(this.root, fingerprint); + } + + async readFingerprints(): Promise { + try { + const raw = JSON.parse(await readFile(this.fingerprintsPath, "utf8")) as Partial; + return { + fingerprints: raw.fingerprints ?? [], + last_used: raw.last_used ?? null, + names: raw.names ?? {}, + }; + } catch { + return { fingerprints: [], last_used: null, names: {} }; + } + } + + async writeFingerprints(data: FingerprintsFile): Promise { + await mkdir(this.root, { recursive: true }); + // indent=4 matches the Python writer + await writeFile(this.fingerprintsPath, JSON.stringify(data, null, 4), { mode: 0o600 }); + } + + /** Create a profile from a mnemonic; returns its fingerprint. */ + async createProfile(mnemonic: string, password: string, name?: string): Promise { + const fingerprint = generateFingerprint(mnemonic); + const data = await this.readFingerprints(); + if (data.fingerprints.includes(fingerprint)) { + throw new Error(`profile ${fingerprint} already exists`); + } + const dir = this.profileDir(fingerprint); + await mkdir(dir, { recursive: true, mode: 0o700 }); + + // parent_seed.enc: kdf/ct wrapper around a V3 blob under the password key + const seedKey = base64url.decode(deriveKeyFromPassword(password, fingerprint)); + const ct = await encryptV3(seedKey, utf8(mnemonic)); + const kdf: KdfConfig = { + name: "pbkdf2-sha256", + version: 1, + params: { iterations: 100000 }, + salt_b64: "", + }; + const wrapper = JSON.stringify({ + kdf, + ct: Buffer.from(ct).toString("base64"), + }); + await writeFile(join(dir, PARENT_SEED_FILENAME), wrapper, { mode: 0o600 }); + + // empty entries index under the index key + const indexKey = deriveIndexKeyBytes(mnemonic); + const emptyIndex = { schema_version: 4, entries: {} }; + await writeFile( + join(dir, INDEX_FILENAME), + await encryptV3(indexKey, utf8(JSON.stringify(emptyIndex))), + { mode: 0o600 }, + ); + + data.fingerprints.push(fingerprint); + data.last_used = fingerprint; + if (name) data.names[fingerprint] = name; + await this.writeFingerprints(data); + return fingerprint; + } + + async removeProfile(fingerprint: string): Promise { + const data = await this.readFingerprints(); + if (!data.fingerprints.includes(fingerprint)) { + throw new Error(`no profile ${fingerprint}`); + } + await rm(this.profileDir(fingerprint), { recursive: true, force: true }); + data.fingerprints = data.fingerprints.filter((f) => f !== fingerprint); + delete data.names[fingerprint]; + if (data.last_used === fingerprint) { + data.last_used = data.fingerprints[0] ?? null; + } + await this.writeFingerprints(data); + } + + async switchProfile(fingerprint: string): Promise { + const data = await this.readFingerprints(); + if (!data.fingerprints.includes(fingerprint)) { + throw new Error(`no profile ${fingerprint}`); + } + data.last_used = fingerprint; + await this.writeFingerprints(data); + } + + /** Decrypt a profile's parent seed with its master password. */ + async decryptParentSeed(fingerprint: string, password: string): Promise { + const path = join(this.profileDir(fingerprint), PARENT_SEED_FILENAME); + if (!existsSync(path)) throw new Error(`no parent seed file for ${fingerprint}`); + const blob = new Uint8Array(await readFile(path)); + const { kdf, ciphertext } = parseEncryptedFile(blob); + + const candidates: Uint8Array[] = []; + if (kdf.name === "argon2id" && kdf.salt_b64) { + candidates.push(base64url.decode(deriveKeyFromPasswordArgon2(password, kdf))); + } + const iterations = Number((kdf.params as { iterations?: number }).iterations ?? 0); + for (const iters of [...(iterations ? [iterations] : []), 100_000, 50_000]) { + candidates.push(base64url.decode(deriveKeyFromPassword(password, fingerprint, iters))); + } + let lastError: unknown; + for (const key of candidates) { + try { + const plain = await decryptPayload(key, ciphertext); + return new TextDecoder().decode(plain).trim(); + } catch (e) { + lastError = e; + } + } + throw new Error(`could not decrypt parent seed (wrong password?): ${String(lastError)}`); + } +} diff --git a/js/packages/cli/src/capabilities.ts b/js/packages/cli/src/capabilities.ts index c71c4b45..b1df516f 100644 --- a/js/packages/cli/src/capabilities.ts +++ b/js/packages/cli/src/capabilities.ts @@ -25,13 +25,21 @@ export function capabilities(): Record { ], sinks: ["clipboard", "exec-env", "stdin-to"], exec_env_var: "SEEDPASS_SECRET", + session_agent: { + transport: "unix-socket", + commands: ["agent start/status/stop", "vault unlock/lock"], + seed_resolution_order: ["SEEDPASS_MNEMONIC env", "session agent"], + }, + }, + profiles: { + layout: "python-compatible ~/.seedpass", + commands: ["fingerprint list/add/switch/remove", "config get/set"], }, not_yet_ported: [ "ssh/pgp key material", "relay sync commands", - "profile and config management", "index0/atlas", - "leases and scoped tokens (design: plan section 9.3)", + "leases and scoped tokens (design: plan section 9.3; enforcement point: session agent)", ], }; } diff --git a/js/packages/cli/src/configFile.ts b/js/packages/cli/src/configFile.ts new file mode 100644 index 00000000..0535bd22 --- /dev/null +++ b/js/packages/cli/src/configFile.ts @@ -0,0 +1,64 @@ +/** + * Per-profile encrypted configuration, parity with ConfigManager + * (seedpass_config.json.enc, encrypted under the index key). + */ + +import { readFile, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { decryptPayload, deriveIndexKeyBytes, encryptV3, utf8 } from "@seedpass/core"; +import { CONFIG_FILENAME } from "./appDir.js"; + +export const DEFAULT_RELAYS = [ + "wss://relay.snort.social", + "wss://nostr.oxtr.dev", + "wss://relay.primal.net", +]; + +/** Defaults mirror ConfigManager.load_config. */ +export function defaultConfig(): Record { + return { + relays: [...DEFAULT_RELAYS], + offline_mode: false, + online_mode_notice_seen: false, + pin_hash: "", + password_hash: "", + inactivity_timeout: 900, + kdf_iterations: 200000, + kdf_mode: "pbkdf2", + argon2_time_cost: 2, + additional_backup_path: "", + backup_interval: 0, + secret_mode_enabled: false, + clipboard_clear_delay: 45, + quick_unlock_enabled: false, + nostr_max_retries: 2, + nostr_retry_delay: 1.0, + min_uppercase: 2, + min_lowercase: 2, + min_digits: 2, + min_special: 2, + }; +} + +export async function loadConfig( + profileDir: string, + mnemonic: string, +): Promise> { + const path = join(profileDir, CONFIG_FILENAME); + if (!existsSync(path)) return defaultConfig(); + const key = deriveIndexKeyBytes(mnemonic); + const blob = new Uint8Array(await readFile(path)); + const plain = await decryptPayload(key, blob); + return { ...defaultConfig(), ...(JSON.parse(new TextDecoder().decode(plain)) as object) }; +} + +export async function saveConfig( + profileDir: string, + mnemonic: string, + config: Record, +): Promise { + const key = deriveIndexKeyBytes(mnemonic); + const payload = await encryptV3(key, utf8(JSON.stringify(config))); + await writeFile(join(profileDir, CONFIG_FILENAME), payload, { mode: 0o600 }); +} diff --git a/js/packages/cli/src/index.ts b/js/packages/cli/src/index.ts index 41f840f4..d9131703 100644 --- a/js/packages/cli/src/index.ts +++ b/js/packages/cli/src/index.ts @@ -4,3 +4,6 @@ export { resolveEntry, entryMetadata, refFor, parseRef, REF_PREFIX } from "./ref export { materializeSecret } from "./secrets.js"; export { execSink, stdinSink, clipboardSink, EXEC_ENV_VAR } from "./sinks.js"; export { capabilities } from "./capabilities.js"; +export { AppDir, resolveAppDir, INDEX_FILENAME, CONFIG_FILENAME } from "./appDir.js"; +export { loadConfig, saveConfig, defaultConfig, DEFAULT_RELAYS } from "./configFile.js"; +export { AgentDaemon, AgentClient, agentSocketPath } from "./agent.js"; diff --git a/js/packages/cli/src/program.ts b/js/packages/cli/src/program.ts index 1bf032c5..3d2c7003 100644 --- a/js/packages/cli/src/program.ts +++ b/js/packages/cli/src/program.ts @@ -40,6 +40,10 @@ import { addNostrKeyEntry, } from "@seedpass/core"; import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { AppDir, resolveAppDir, INDEX_FILENAME } from "./appDir.js"; +import { loadConfig, saveConfig } from "./configFile.js"; +import { AgentClient, AgentDaemon, agentSocketPath, DEFAULT_TTL_SECONDS } from "./agent.js"; export interface ProgramIo { out(line: string): void; @@ -51,6 +55,40 @@ const defaultIo: ProgramIo = { err: (line) => process.stderr.write(line + "\n"), }; +interface GlobalOpts { + vault?: string; + appDir?: string; + fingerprint?: string; +} + +async function currentFingerprint(app: AppDir, opts: GlobalOpts): Promise { + if (opts.fingerprint) return opts.fingerprint; + const data = await app.readFingerprints(); + if (!data.last_used) throw new Error("no profile selected; run 'fingerprint add' first"); + return data.last_used; +} + +/** + * Seed resolution order: SEEDPASS_MNEMONIC env -> session agent -> error. + * The mnemonic never comes from argv. + */ +async function resolveMnemonic(app: AppDir, opts: GlobalOpts): Promise { + const env = process.env["SEEDPASS_MNEMONIC"]; + if (env) return env; + try { + const fp = await currentFingerprint(app, opts); + const client = new AgentClient(agentSocketPath(app.root)); + const held = await client.get(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) { @@ -62,9 +100,14 @@ function requireMnemonic(): string { return m; } -async function openFromOptions(opts: { vault?: string }): Promise { - if (!opts.vault) throw new Error("--vault is required"); - return openVault(opts.vault, requireMnemonic()); +async function openFromOptions(opts: GlobalOpts): Promise { + const app = new AppDir(resolveAppDir(opts.appDir)); + if (opts.vault) { + return openVault(opts.vault, await resolveMnemonic(app, opts)); + } + const fp = await currentFingerprint(app, opts); + const path = join(app.profileDir(fp), INDEX_FILENAME); + return openVault(path, await resolveMnemonic(app, opts)); } export function buildProgram(io: ProgramIo = defaultIo): Command { @@ -72,7 +115,9 @@ export function buildProgram(io: ProgramIo = defaultIo): Command { program .name("seedpass-js") .description("SeedPass (TypeScript port) — reference-first CLI") - .option("--vault ", "encrypted vault index file") + .option("--vault ", "encrypted vault index file (overrides profile)") + .option("--app-dir ", "application directory (default ~/.seedpass)") + .option("--fingerprint ", "profile fingerprint (default: last used)") .configureOutput({ writeOut: (s) => io.out(s.replace(/\n$/, "")), writeErr: (s) => io.err(s.replace(/\n$/, "")), @@ -456,6 +501,129 @@ export function buildProgram(io: ProgramIo = defaultIo): Command { }, ); + 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 ", "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("switch ") + .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 ") + .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] = ""; + } + io.out(JSON.stringify(key !== undefined ? { [key]: cfg[key] } : cfg, null, 2)); + }); + + config + .command("set ") + .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); + const cfg = await loadConfig(app.profileDir(fp), mnemonic); + let parsed: unknown = value; + try { + parsed = JSON.parse(value); + } catch { + // keep as string + } + cfg[key] = parsed; + await saveConfig(app.profileDir(fp), mnemonic, cfg); + io.out(JSON.stringify({ [key]: parsed })); + }); + + const agent = program.command("agent").description("session agent (holds unlocked seeds)"); + + agent + .command("start") + .description("run the session agent in the foreground") + .option("--ttl ", "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), Number(o.ttl)); + await daemon.start(); + io.out(JSON.stringify({ agent: "running", socket: agentSocketPath(app.root) })); + await new Promise(() => {}); // run until killed + }); + + 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)); + await client.request({ op: "shutdown" }); + io.out(JSON.stringify({ agent: "stopped" })); + }); + const util = program.command("util").description("utility commands"); util @@ -523,6 +691,40 @@ export function buildProgram(io: ProgramIo = defaultIo): Command { ); }); + vaultCmd + .command("unlock") + .description("decrypt the parent seed with SEEDPASS_PASSWORD and hand it to the agent") + .option("--ttl ", "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 ? Number(o.ttl) : 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("import ") .description("read a portable backup and print its reference summary") diff --git a/js/packages/cli/test/profiles.test.ts b/js/packages/cli/test/profiles.test.ts new file mode 100644 index 00000000..898606f2 --- /dev/null +++ b/js/packages/cli/test/profiles.test.ts @@ -0,0 +1,154 @@ +/** + * Profile, config, and session-agent tests: the full unlock lifecycle with + * no SEEDPASS_MNEMONIC in the environment — the agent is the only seed + * source, exactly how an agent-driven workflow would run. + */ + +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { mkdtemp, readFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import process from "node:process"; +import { mnemonics } from "@seedpass/test-vectors"; +import { generateFingerprint } from "@seedpass/core"; +import { buildProgram, AgentDaemon, agentSocketPath, type ProgramIo } from "../src/index.js"; + +const MNEMONIC = mnemonics["abandon12"]!; +const FINGERPRINT = generateFingerprint(MNEMONIC); +const PASSWORD = "profile-test-password"; + +let appDir: string; +let daemon: AgentDaemon; + +interface RunResult { + stdout: string; + error?: unknown; +} + +async function run(env: Record, ...argv: string[]): Promise { + const saved: Record = {}; + for (const [k, v] of Object.entries(env)) { + saved[k] = process.env[k]; + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + const out: string[] = []; + const io: ProgramIo = { out: (l: string) => out.push(l), err: () => {} }; + let error: unknown; + try { + await buildProgram(io).parseAsync(["node", "seedpass-js", "--app-dir", appDir, ...argv]); + } catch (e) { + error = e; + } finally { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + } + return { stdout: out.join("\n"), error }; +} + +beforeAll(async () => { + appDir = await mkdtemp(join(tmpdir(), "seedpass-appdir-")); + process.env["SEEDPASS_AGENT_SOCK"] = join(appDir, "agent.sock"); + daemon = new AgentDaemon(agentSocketPath(appDir), 900); + await daemon.start(); +}); + +afterAll(async () => { + await daemon.stop(); + delete process.env["SEEDPASS_AGENT_SOCK"]; +}); + +describe("profile lifecycle", () => { + it("creates a Python-layout profile from env secrets", async () => { + const r = await run( + { SEEDPASS_MNEMONIC: MNEMONIC, SEEDPASS_PASSWORD: PASSWORD }, + "fingerprint", "add", "--name", "main", + ); + expect(JSON.parse(r.stdout).fingerprint).toBe(FINGERPRINT); + expect(existsSync(join(appDir, FINGERPRINT, "parent_seed.enc"))).toBe(true); + expect(existsSync(join(appDir, FINGERPRINT, "seedpass_entries_db.json.enc"))).toBe(true); + const meta = JSON.parse(await readFile(join(appDir, "fingerprints.json"), "utf8")); + expect(meta).toEqual({ + fingerprints: [FINGERPRINT], + last_used: FINGERPRINT, + names: { [FINGERPRINT]: "main" }, + }); + }); + + it("lists and switches profiles", async () => { + const r = await run({}, "fingerprint", "list"); + expect(JSON.parse(r.stdout)).toEqual([ + { fingerprint: FINGERPRINT, name: "main", current: true }, + ]); + }); +}); + +describe("agent unlock lifecycle (no mnemonic in env)", () => { + it("locked vault refuses access with a helpful error", async () => { + const r = await run({ SEEDPASS_MNEMONIC: undefined }, "entry", "list"); + expect(String((r.error as Error).message)).toContain("vault is locked"); + }); + + it("vault unlock hands the seed to the agent using only the password", async () => { + const r = await run( + { SEEDPASS_MNEMONIC: undefined, SEEDPASS_PASSWORD: PASSWORD }, + "vault", "unlock", "--ttl", "60", + ); + const row = JSON.parse(r.stdout); + expect(row.unlocked).toBe(FINGERPRINT); + expect(row.expires_at).toBeGreaterThan(Date.now() / 1000); + }); + + it("profile vault opens via the agent, no env mnemonic, no --vault", async () => { + const list = await run({ SEEDPASS_MNEMONIC: undefined }, "entry", "list"); + expect(JSON.parse(list.stdout)).toEqual([]); + + const add = await run( + { SEEDPASS_MNEMONIC: undefined }, + "entry", "add", "key-value", "agent-made", "k", "agent-value", + ); + expect(JSON.parse(add.stdout).ref).toBe("sp://entry/0"); + expect(add.stdout).not.toContain("agent-value"); + + const reveal = await run( + { SEEDPASS_MNEMONIC: undefined }, + "entry", "reveal", "agent-made", + ); + expect(reveal.stdout).toBe("agent-value"); + }); + + it("agent status shows the unlocked profile; lock drops it", async () => { + const status = await run({}, "agent", "status"); + expect(JSON.parse(status.stdout).map((p: { fingerprint: string }) => p.fingerprint)).toEqual([ + FINGERPRINT, + ]); + + const lock = await run({ SEEDPASS_MNEMONIC: undefined }, "vault", "lock"); + expect(JSON.parse(lock.stdout).locked).toBe(1); + + const denied = await run({ SEEDPASS_MNEMONIC: undefined }, "entry", "list"); + expect(String((denied.error as Error).message)).toContain("vault is locked"); + }); +}); + +describe("config", () => { + it("returns defaults, persists sets, and redacts hashes", async () => { + const env = { SEEDPASS_MNEMONIC: MNEMONIC }; + const defaults = JSON.parse((await run(env, "config", "get")).stdout); + expect(defaults.relays).toHaveLength(3); + expect(defaults.kdf_iterations).toBe(200000); + + await run(env, "config", "set", "clipboard_clear_delay", "10"); + await run(env, "config", "set", "relays", '["wss://relay.example"]'); + const after = JSON.parse((await run(env, "config", "get")).stdout); + expect(after.clipboard_clear_delay).toBe(10); + expect(after.relays).toEqual(["wss://relay.example"]); + + await run(env, "config", "set", "pin_hash", "supersecret"); + const redacted = JSON.parse((await run(env, "config", "get", "pin_hash")).stdout); + expect(redacted.pin_hash).toBe(""); + }); +}); diff --git a/js/pnpm-lock.yaml b/js/pnpm-lock.yaml index 225a1274..c592532c 100644 --- a/js/pnpm-lock.yaml +++ b/js/pnpm-lock.yaml @@ -10,6 +10,9 @@ importers: packages/cli: dependencies: + '@scure/base': + specifier: ^2.3.0 + version: 2.3.0 '@seedpass/core': specifier: workspace:* version: link:../core