mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-11-06 04:42:35 +00:00
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:
1 parent
4f2b8a79bf
commit
841d0984b3
10 files changed
+812
-8
No files matched your search
@@ -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 |
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@scure/base": "^2.3.0",
|
||||
"@seedpass/core": "workspace:*",
|
||||
"commander": "^14.0.2"
|
||||
},
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
}
|
||||
@@ -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)}`);
|
||||
}
|
||||
}
|
||||
@@ -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)",
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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")
|
||||
|
||||
@@ -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>");
|
||||
});
|
||||
});
|
||||
Generated
+3
@@ -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
|
||||
|
||||
Reference in new issue
Block a user