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 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Fable 5 committed 2026-08-14 14:15:51 -04:00
1 parent 4f2b8a79bf
commit 841d0984b3
10 files changed
+812 -8

No files matched your search

+4 -2
View File
@@ -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/<id>`, 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/<id>`, 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 |
+1
View File
@@ -14,6 +14,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@scure/base": "^2.3.0",
"@seedpass/core": "workspace:*",
"commander": "^14.0.2"
},
+204
View File
@@ -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<string, Held>();
private server = createServer((socket) => this.serve(socket));
constructor(
private readonly socketPath: string,
private readonly defaultTtl = DEFAULT_TTL_SECONDS,
) {}
async start(): Promise<void> {
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<void>((resolve, reject) => {
this.server.once("error", reject);
this.server.listen(this.socketPath, () => resolve());
});
await chmod(this.socketPath, 0o600);
}
async stop(): Promise<void> {
this.held.clear();
await new Promise<void>((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<string, unknown>): Record<string, unknown> {
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<string, unknown>;
try {
reply = this.handle(JSON.parse(line) as Record<string, unknown>);
} 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<boolean> {
try {
await new AgentClient(socketPath).request({ op: "ping" });
return true;
} catch {
return false;
}
}
async request(msg: Record<string, unknown>): Promise<Record<string, unknown>> {
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<string, unknown>);
} 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<number> {
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<string | null> {
try {
const r = await this.request({ op: "get", fingerprint });
return r["ok"] ? String(r["mnemonic"]) : null;
} catch {
return null;
}
}
async lock(fingerprint?: string): Promise<number> {
const r = await this.request({ op: "lock", ...(fingerprint && { fingerprint }) });
return Number(r["locked"] ?? 0);
}
async status(): Promise<AgentStatusProfile[]> {
const r = await this.request({ op: "status" });
return (r["profiles"] ?? []) as AgentStatusProfile[];
}
}
+163
View File
@@ -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/<FP>/parent_seed.enc kdf/ct wrapper, password key
* ~/.seedpass/<FP>/seedpass_entries_db.json.enc index key
* ~/.seedpass/<FP>/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<string, string>;
}
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<FingerprintsFile> {
try {
const raw = JSON.parse(await readFile(this.fingerprintsPath, "utf8")) as Partial<FingerprintsFile>;
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<void> {
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<string> {
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<void> {
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<void> {
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<string> {
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)}`);
}
}
+10 -2
View File
@@ -25,13 +25,21 @@ export function capabilities(): Record<string, unknown> {
],
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)",
],
};
}
+64
View File
@@ -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<string, unknown> {
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<Record<string, unknown>> {
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<string, unknown>,
): Promise<void> {
const key = deriveIndexKeyBytes(mnemonic);
const payload = await encryptV3(key, utf8(JSON.stringify(config)));
await writeFile(join(profileDir, CONFIG_FILENAME), payload, { mode: 0o600 });
}
+3
View File
@@ -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";
+206 -4
View File
@@ -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<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) 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<OpenedVault> {
if (!opts.vault) throw new Error("--vault <file> is required");
return openVault(opts.vault, requireMnemonic());
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));
}
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 <file>", "encrypted vault index file")
.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$/, "")),
@@ -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 <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 <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);
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 <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), 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 <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 ? 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 <srcFile>")
.description("read a portable backup and print its reference summary")
+154
View File
@@ -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<string, string | undefined>, ...argv: string[]): Promise<RunResult> {
const saved: Record<string, string | undefined> = {};
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("<redacted>");
});
});
+3
View File
@@ -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