diff --git a/js/packages/cli/src/agent.ts b/js/packages/cli/src/agent.ts index 6a5a71b0..071c03ae 100644 --- a/js/packages/cli/src/agent.ts +++ b/js/packages/cli/src/agent.ts @@ -4,26 +4,41 @@ * 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. + * Two classes of caller, distinguished by the server — not by the client: + * + * - Owner ops (put, owner-mnemonic, token-issue/list/revoke, lock, status, + * shutdown) require the capability secret written to a 0600 file beside + * the socket. Socket permissions alone cannot tell the owner's CLI apart + * from a scoped agent that merely holds a token. + * - Token ops (vault-index, secret, use-sink) require a bearer token and + * are constrained by its scopes. vault-index returns redacted metadata, + * never the decrypted index; `secret` requires the reveal scope; and + * `use-sink` runs the sink here so plaintext never crosses back. + * + * This process is the enforcement point for the plan-section-9.3 lease and + * token model. Enforcement must live here because the CLI is untrusted from + * the daemon's point of view: any local process can speak this protocol. */ import { createServer, createConnection, type Socket } from "node:net"; import { chmod, rm } from "node:fs/promises"; -import { existsSync } from "node:fs"; -import { randomBytes } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import { randomBytes, timingSafeEqual } from "node:crypto"; +import { writeFile } from "node:fs/promises"; import { join } from "node:path"; import process from "node:process"; import { deriveKeyIndex, sha256Hex, utf8, type Entry } from "@seedpass/core"; import { openVault } from "./vaultFile.js"; import { materializeSecret } from "./secrets.js"; +import { entryMetadata } from "./refs.js"; +import { + clipboardSink, + execSink, + parseCommandSpec, + stdinSink, + type SinkResult, +} from "./sinks.js"; import { AuditLog } from "./audit.js"; import { INDEX_FILENAME } from "./appDir.js"; @@ -42,6 +57,14 @@ export interface TokenRecord { expires_at: number; uses_remaining: number; revoked: boolean; + /** + * Commands this token may hand a secret to. Empty/absent means any. + * + * A `use`-scoped holder who picks the command can always read the secret + * from inside it, so this is the only mechanism that actually contains a + * `use` grant rather than merely recording it. + */ + exec_allowlist?: string[]; } export type TokenInfo = Omit; @@ -60,10 +83,24 @@ export interface AgentStatusProfile { expires_at: number; } +/** Operations only the owner may invoke, proven by the capability secret. */ +const OWNER_OPS = new Set([ + "put", + "owner-mnemonic", + "token-issue", + "token-list", + "token-revoke", + "lock", + "status", + "shutdown", +]); + export class AgentDaemon { private held = new Map(); private tokens = new Map(); private audits = new Map(); + private auditQueue: Promise = Promise.resolve(); + private capability = ""; private server = createServer((socket) => this.serve(socket)); constructor( @@ -73,6 +110,47 @@ export class AgentDaemon { private readonly appDir?: string, ) {} + /** Path of the 0600 file holding the owner capability. */ + get capabilityPath(): string { + return `${this.socketPath}.cap`; + } + + /** + * Owner authentication. + * + * The socket's 0600 mode proves only "some process running as this user"; + * it does not distinguish the owner's CLI from a scoped agent that was + * handed a token. Privileged operations therefore require a capability + * secret written to a 0600 file at startup, which raises the bar to + * "can read the owner's files" — the same bar as the vault itself. + * + * Residual risk, stated plainly: a same-uid attacker who can read that + * file (or ptrace this process) still wins. Defending against that + * requires an OS-level boundary this daemon cannot provide. + */ + private isOwner(msg: Record): boolean { + const presented = String(msg["cap"] ?? ""); + if (!presented || !this.capability) return false; + const a = Buffer.from(presented); + const b = Buffer.from(this.capability); + return a.length === b.length && timingSafeEqual(a, b); + } + + /** Serialize audit appends so concurrent requests cannot break the chain. */ + private auditLog( + fingerprint: string, + event: string, + details: Record, + ): Promise { + const log = this.audit(fingerprint); + if (!log) return Promise.resolve(); + this.auditQueue = this.auditQueue.then( + () => log.log(event, details), + () => log.log(event, details), + ); + return this.auditQueue as Promise; + } + private audit(fingerprint: string): AuditLog | null { if (!this.appDir) return null; const held = this.held.get(fingerprint); @@ -120,6 +198,85 @@ export class AgentDaemon { return { token }; } + /** Would this token be allowed to act on this entry at all? */ + private tokenMaySee(token: TokenRecord, entry: { kind: string; label: string }): boolean { + if (token.kinds && !token.kinds.includes(entry.kind)) return false; + try { + return new RegExp(token.label_regex).test(entry.label); + } catch { + return false; + } + } + + /** + * Common path for token-authorized secret access: open the vault, find the + * entry, authorize the action, and materialize the secret. Never returns + * the secret to a caller that was not authorized for the exact action. + */ + private async resolveForToken( + msg: Record, + fingerprint: string, + action: Exclude, + sinkSpec?: { sink: string; command: string[] }, + ): Promise< + | { error: Record } + | { + entry: Entry; + id: string; + token: TokenRecord; + secret: { value: string; descriptor: string }; + } + > { + const held = this.held.get(fingerprint); + if (!held || !this.appDir) { + return { error: { ok: false, error: "profile not unlocked" } }; + } + const id = String(msg["id"] ?? ""); + const vault = await openVault( + join(this.appDir, fingerprint, INDEX_FILENAME), + held.mnemonic, + ); + const entry = vault.index.entries[id] as Entry | undefined; + if (!entry) return { error: { ok: false, error: `no entry ${id}` } }; + + const auth = this.authorize(String(msg["token"] ?? ""), fingerprint, action, { + kind: entry.kind, + label: entry.label, + }); + if (!auth.token) { + await this.auditLog(fingerprint, "access_denied", { + action, + entry_id: id, + kind: entry.kind, + reason: auth.deny, + }); + return { error: { ok: false, error: `denied: ${auth.deny}` } }; + } + + if (sinkSpec) { + const allowed = auth.token.exec_allowlist; + if (sinkSpec.sink !== "clipboard" && allowed && allowed.length > 0) { + const [cmd] = parseCommandSpec(sinkSpec.command); + if (!allowed.includes(cmd)) { + await this.auditLog(fingerprint, "access_denied", { + action, + entry_id: id, + reason: `command '${cmd}' not in the token's exec allowlist`, + }); + return { + error: { ok: false, error: `denied: command '${cmd}' not permitted by this token` }, + }; + } + } + } + + const ts = msg["timestamp"] !== undefined ? Number(msg["timestamp"]) : undefined; + const secret = materializeSecret(vault.index, id, entry, held.mnemonic, { + ...(ts !== undefined && { timestamp: ts }), + }); + return { entry, id, token: auth.token, secret }; + } + async start(): Promise { if (existsSync(this.socketPath)) { // A live agent refuses to be replaced; a stale socket file is cleaned. @@ -132,24 +289,49 @@ export class AgentDaemon { this.server.listen(this.socketPath, () => resolve()); }); await chmod(this.socketPath, 0o600); + + this.capability = randomBytes(32).toString("base64url"); + await writeFile(this.capabilityPath, this.capability, { mode: 0o600 }); + await chmod(this.capabilityPath, 0o600); } async stop(): Promise { this.held.clear(); + this.tokens.clear(); + this.audits.clear(); + this.capability = ""; await new Promise((resolve) => this.server.close(() => resolve())); await rm(this.socketPath, { force: true }); + await rm(this.capabilityPath, { force: true }); + } + + /** Drop everything derived from a profile's seed. */ + private forget(fingerprint: string): void { + this.held.delete(fingerprint); + // Tokens are only meaningful while the profile is unlocked; leaving them + // resident would let them spring back to life on the next unlock. + for (const [id, token] of this.tokens) { + if (token.fingerprint === fingerprint) this.tokens.delete(id); + } + // The audit logger holds a key derived from the seed. + this.audits.delete(fingerprint); } private expire(): void { const now = Date.now() / 1000; for (const [fp, held] of this.held) { - if (held.expiresAt <= now) this.held.delete(fp); + if (held.expiresAt <= now) this.forget(fp); } } private async handle(msg: Record): Promise> { this.expire(); - switch (msg["op"]) { + const op = String(msg["op"] ?? ""); + if (OWNER_OPS.has(op) && !this.isOwner(msg)) { + // Do not reveal whether the profile exists or is unlocked. + return { ok: false, error: "owner capability required" }; + } + switch (op) { case "token-issue": { // Owner-only op: local socket (0600) is the trust boundary, and the // profile must currently be unlocked in this agent. @@ -170,15 +352,19 @@ export class AgentDaemon { expires_at: Math.floor(Date.now() / 1000 + Number(msg["ttl"] ?? 300)), uses_remaining: Number(msg["uses"] ?? 1), revoked: false, + ...(Array.isArray(msg["exec_allowlist"]) && (msg["exec_allowlist"] as string[]).length + ? { exec_allowlist: msg["exec_allowlist"] as string[] } + : {}), }; this.tokens.set(record.id, record); - await this.audit(fingerprint)?.log("token_issued", { + await this.auditLog(fingerprint, "token_issued", { token_id: record.id, name: record.name, scopes: record.scopes, kinds: record.kinds, label_regex: record.label_regex, uses: record.uses_remaining, + exec_allowlist: record.exec_allowlist ?? null, }); // The plaintext token is returned exactly once, at issuance. return { ok: true, token: secret, record: { ...record, secret_hash: undefined } }; @@ -194,18 +380,21 @@ export class AgentDaemon { const token = this.tokens.get(String(msg["token_id"] ?? "")); if (!token) return { ok: false, error: "unknown token id" }; token.revoked = true; - await this.audit(token.fingerprint)?.log("token_revoked", { token_id: token.id }); + await this.auditLog(token.fingerprint, "token_revoked", { token_id: token.id }); return { ok: true }; } case "vault-index": { - // Token-mode read of the decrypted index (metadata access). + // Token-mode read. Returns REDACTED metadata only: handing back the + // decrypted index would give a read-scoped token every imported TOTP + // secret, key-value value and document body in the vault. const fingerprint = String(msg["fingerprint"] ?? ""); const held = this.held.get(fingerprint); if (!held || !this.appDir) return { ok: false, error: "profile not unlocked" }; const auth = this.authorize(String(msg["token"] ?? ""), fingerprint, "read"); if (!auth.token) { - await this.audit(fingerprint)?.log("access_denied", { - action: "read", reason: auth.deny, + await this.auditLog(fingerprint, "access_denied", { + action: "read", + reason: auth.deny, }); return { ok: false, error: `denied: ${auth.deny}` }; } @@ -213,46 +402,82 @@ export class AgentDaemon { join(this.appDir, fingerprint, INDEX_FILENAME), held.mnemonic, ); - await this.audit(fingerprint)?.log("index_read", { token_id: auth.token.id }); - return { ok: true, index: vault.index }; + // Only entries the token could actually act on are listed at all. + const rows = Object.entries(vault.index.entries) + .filter(([, e]) => this.tokenMaySee(auth.token!, e)) + .map(([id, e]) => entryMetadata(id, e)); + await this.auditLog(fingerprint, "index_read", { + token_id: auth.token.id, + entries_returned: rows.length, + }); + return { ok: true, entries: rows, schema_version: vault.index.schema_version }; } case "secret": { - // Token-mode secret materialization for a single entry. + // Plaintext egress. Requires the `reveal` scope specifically: `use` + // must not be able to pull a secret back across the socket. const fingerprint = String(msg["fingerprint"] ?? ""); - const held = this.held.get(fingerprint); - if (!held || !this.appDir) return { ok: false, error: "profile not unlocked" }; - const action = msg["action"] === "reveal" ? "reveal" : "use"; - const id = String(msg["id"] ?? ""); - const vault = await openVault( - join(this.appDir, fingerprint, INDEX_FILENAME), - held.mnemonic, - ); - const entry = vault.index.entries[id] as Entry | undefined; - if (!entry) return { ok: false, error: `no entry ${id}` }; - const auth = this.authorize(String(msg["token"] ?? ""), fingerprint, action, { - kind: entry.kind, - label: entry.label, - }); - if (!auth.token) { - await this.audit(fingerprint)?.log("access_denied", { - action, entry_id: id, kind: entry.kind, reason: auth.deny, - }); - return { ok: false, error: `denied: ${auth.deny}` }; - } - const ts = msg["timestamp"] !== undefined ? Number(msg["timestamp"]) : undefined; - const secret = materializeSecret(vault.index, id, entry, held.mnemonic, { - ...(ts !== undefined && { timestamp: ts }), - }); - await this.audit(fingerprint)?.log("secret_delivered", { - action, + const resolved = await this.resolveForToken(msg, fingerprint, "reveal"); + if ("error" in resolved) return resolved.error; + const { entry, id, secret, token } = resolved; + await this.auditLog(fingerprint, "secret_revealed", { entry_id: id, kind: entry.kind, label: entry.label, - token_id: auth.token.id, - uses_remaining: auth.token.uses_remaining, + token_id: token.id, + uses_remaining: token.uses_remaining, }); return { ok: true, value: secret.value, descriptor: secret.descriptor }; } + case "use-sink": { + // The `use` scope delivers a secret to a sink WITHOUT returning it. + // The child process runs here, in the agent, so the plaintext never + // crosses the socket back to the token holder. + const fingerprint = String(msg["fingerprint"] ?? ""); + const sink = String(msg["sink"] ?? ""); + const command = (msg["command"] as string[] | undefined) ?? []; + const resolved = await this.resolveForToken(msg, fingerprint, "use", { + sink, + command, + }); + if ("error" in resolved) return resolved.error; + const { entry, id, secret, token } = resolved; + + let result: SinkResult; + try { + if (sink === "clipboard") { + result = await clipboardSink(secret.value); + } else { + const [cmd, args] = parseCommandSpec(command); + result = + sink === "exec" + ? await execSink(secret.value, cmd, args) + : await stdinSink(secret.value, cmd, args); + } + } catch (e) { + await this.auditLog(fingerprint, "delivery_failed", { + entry_id: id, + token_id: token.id, + sink, + command, + reason: String(e), + }); + return { ok: false, error: `sink failed: ${String(e)}` }; + } + + await this.auditLog(fingerprint, "secret_delivered", { + entry_id: id, + kind: entry.kind, + label: entry.label, + token_id: token.id, + uses_remaining: token.uses_remaining, + sink, + // Record exactly what received the secret. A `use` token holder who + // chooses the command can still read the value from inside it — + // containment comes from the exec allowlist, accountability from here. + command, + }); + return { ok: true, descriptor: secret.descriptor, ...result }; + } case "put": { const fingerprint = String(msg["fingerprint"] ?? ""); const mnemonic = String(msg["mnemonic"] ?? ""); @@ -260,10 +485,13 @@ export class AgentDaemon { const ttl = Number(msg["ttl"] ?? this.defaultTtl); const expiresAt = Math.floor(Date.now() / 1000 + ttl); this.held.set(fingerprint, { mnemonic, expiresAt }); - await this.audit(fingerprint)?.log("vault_unlocked", { ttl }); + await this.auditLog(fingerprint, "vault_unlocked", { ttl }); return { ok: true, expires_at: expiresAt }; } - case "get": { + case "owner-mnemonic": { + // Owner-capability gated: this hands back the parent seed so the + // owner's CLI can operate the vault locally. It is deliberately NOT + // reachable with a scoped token. const held = this.held.get(String(msg["fingerprint"] ?? "")); return held ? { ok: true, mnemonic: held.mnemonic } : { ok: false, error: "locked" }; } @@ -271,10 +499,12 @@ export class AgentDaemon { const fp = msg["fingerprint"]; if (fp === undefined) { const n = this.held.size; - this.held.clear(); + for (const held of [...this.held.keys()]) this.forget(held); return { ok: true, locked: n }; } - const had = this.held.delete(String(fp)); + const key = String(fp); + const had = this.held.has(key); + this.forget(key); return { ok: true, locked: had ? 1 : 0 }; } case "status": { @@ -317,8 +547,30 @@ export class AgentDaemon { } export class AgentClient { + private capability: string | null = null; + constructor(private readonly socketPath: string) {} + /** Owner capability, read from the 0600 file the daemon writes. */ + private ownerCapability(): string { + if (this.capability !== null) return this.capability; + const fromEnv = process.env["SEEDPASS_AGENT_CAP"]; + if (fromEnv) { + this.capability = fromEnv; + return fromEnv; + } + try { + this.capability = readFileSync(`${this.socketPath}.cap`, "utf8").trim(); + } catch { + this.capability = ""; + } + return this.capability; + } + + private ownerRequest(msg: Record): Promise> { + return this.request({ ...msg, cap: this.ownerCapability() }); + } + static async ping(socketPath: string): Promise { try { await new AgentClient(socketPath).request({ op: "ping" }); @@ -358,14 +610,15 @@ export class AgentClient { } async put(fingerprint: string, mnemonic: string, ttl?: number): Promise { - const r = await this.request({ op: "put", fingerprint, mnemonic, ...(ttl && { ttl }) }); + const r = await this.ownerRequest({ 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 { + /** Owner-only: fetch the parent seed for local vault operations. */ + async ownerMnemonic(fingerprint: string): Promise { try { - const r = await this.request({ op: "get", fingerprint }); + const r = await this.ownerRequest({ op: "owner-mnemonic", fingerprint }); return r["ok"] ? String(r["mnemonic"]) : null; } catch { return null; @@ -373,12 +626,14 @@ export class AgentClient { } async lock(fingerprint?: string): Promise { - const r = await this.request({ op: "lock", ...(fingerprint && { fingerprint }) }); + const r = await this.ownerRequest({ op: "lock", ...(fingerprint && { fingerprint }) }); + if (!r["ok"]) throw new Error(String(r["error"])); return Number(r["locked"] ?? 0); } async status(): Promise { - const r = await this.request({ op: "status" }); + const r = await this.ownerRequest({ op: "status" }); + if (!r["ok"]) throw new Error(String(r["error"])); return (r["profiles"] ?? []) as AgentStatusProfile[]; } @@ -391,7 +646,7 @@ export class AgentClient { ttl?: number; uses?: number; }): Promise<{ token: string; record: TokenInfo }> { - const r = await this.request({ + const r = await this.ownerRequest({ op: "token-issue", fingerprint: options.fingerprint, name: options.name, @@ -406,27 +661,52 @@ export class AgentClient { } async tokenList(fingerprint: string): Promise { - const r = await this.request({ op: "token-list", fingerprint }); + const r = await this.ownerRequest({ op: "token-list", fingerprint }); if (!r["ok"]) throw new Error(String(r["error"])); return r["tokens"] as TokenInfo[]; } async tokenRevoke(tokenId: string): Promise { - const r = await this.request({ op: "token-revoke", token_id: tokenId }); + const r = await this.ownerRequest({ op: "token-revoke", token_id: tokenId }); if (!r["ok"]) throw new Error(String(r["error"])); } - async vaultIndex(fingerprint: string, token: string): Promise { + /** Redacted entry metadata the token is allowed to see. */ + async vaultEntries( + fingerprint: string, + token: string, + ): Promise[]> { const r = await this.request({ op: "vault-index", fingerprint, token }); if (!r["ok"]) throw new Error(String(r["error"])); - return r["index"]; + return r["entries"] as Record[]; + } + + /** Ask the agent to deliver a secret to a sink; plaintext stays inside it. */ + async useSink(options: { + fingerprint: string; + id: string; + token: string; + sink: "clipboard" | "exec" | "stdin"; + command: string[]; + timestamp?: number; + }): Promise> { + const r = await this.request({ + op: "use-sink", + fingerprint: options.fingerprint, + id: options.id, + token: options.token, + sink: options.sink, + command: options.command, + ...(options.timestamp !== undefined && { timestamp: options.timestamp }), + }); + if (!r["ok"]) throw new Error(String(r["error"])); + return r; } async secret(options: { fingerprint: string; id: string; token: string; - action: "use" | "reveal"; timestamp?: number; }): Promise<{ value: string; descriptor: string }> { const r = await this.request({ @@ -434,7 +714,6 @@ export class AgentClient { fingerprint: options.fingerprint, id: options.id, token: options.token, - action: options.action, ...(options.timestamp !== undefined && { timestamp: options.timestamp }), }); if (!r["ok"]) throw new Error(String(r["error"])); diff --git a/js/packages/cli/src/program.ts b/js/packages/cli/src/program.ts index 201d1abc..e74d864b 100644 --- a/js/packages/cli/src/program.ts +++ b/js/packages/cli/src/program.ts @@ -9,7 +9,7 @@ import { Command } from "commander"; import process from "node:process"; -import { entryMetadata, resolveEntry } from "./refs.js"; +import { entryMetadata, parseRef, resolveEntry } from "./refs.js"; import { materializeSecret } from "./secrets.js"; import { clipboardSink, execSink, parseCommandSpec, stdinSink } from "./sinks.js"; import { capabilities } from "./capabilities.js"; @@ -62,6 +62,32 @@ import { existsSync, statSync } from "node:fs"; import { basename, dirname, extname, join } from "node:path"; import { homedir } from "node:os"; +/** + * Parse a numeric option, refusing anything that would poison the vault. + * + * `Number("not-a-number")` is NaN, which JSON.stringify writes as null; a + * single bad --length once persisted an unreadable entry that failed schema + * validation on every subsequent open. Validate before anything is stored. + */ +function parseIntOption(raw: string, name: string, opts: { min?: number; max?: number } = {}): number { + const value = Number(raw); + if (!Number.isFinite(value) || !Number.isInteger(value)) { + throw new Error(`${name} must be a whole number (got ${JSON.stringify(raw)})`); + } + if (opts.min !== undefined && value < opts.min) { + throw new Error(`${name} must be at least ${opts.min} (got ${value})`); + } + if (opts.max !== undefined && value > opts.max) { + throw new Error(`${name} must be at most ${opts.max} (got ${value})`); + } + return value; +} + +/** Unix timestamps may be large but must still be real integers. */ +function parseUnixTime(raw: string): number { + return parseIntOption(raw, "--at", { min: 0 }); +} + /** Expand a leading ~ the way Python's Path.expanduser does. */ function resolveHome(p: string): string { return p.startsWith("~") ? join(homedir(), p.slice(1)) : p; @@ -118,7 +144,7 @@ async function resolveMnemonic(app: AppDir, opts: GlobalOpts): Promise { try { const fp = await currentFingerprint(app, opts); const client = new AgentClient(agentSocketPath(app.root)); - const held = await client.get(fp); + const held = await client.ownerMnemonic(fp); if (held) return held; } catch { // fall through to the error below @@ -159,13 +185,51 @@ async function openFromOptions(opts: GlobalOpts): Promise { * this process. */ interface ReadAccess { - index: import("@seedpass/core").VaultIndex; + /** Redacted metadata rows, already filtered to what the caller may see. */ + rows(): Promise>>; + /** Resolve a ref/label/id to an entry id, using visible metadata only. */ + resolveId(refOrQuery: string): Promise<{ id: string; ref: string; label: string; kind: string }>; mode: "owner" | "token"; - secretFor( + /** Plaintext egress. In token mode this requires the reveal scope. */ + reveal(id: string, timestamp?: number): Promise<{ value: string; descriptor: string }>; + /** Deliver to a sink. In token mode the agent runs the sink itself. */ + deliver( id: string, - action: "use" | "reveal", + sink: "clipboard" | "exec" | "stdin", + command: string[], timestamp?: number, - ): Promise<{ value: string; descriptor: string }>; + ): Promise>; +} + +function resolveFromRows( + rows: Array>, + refOrQuery: string, +): { id: string; ref: string; label: string; kind: string } { + const asRow = (r: Record) => ({ + id: String(r["id"]), + ref: String(r["ref"]), + label: String(r["label"] ?? ""), + kind: String(r["kind"] ?? ""), + }); + const refId = parseRef(refOrQuery); + const byId = (id: string) => rows.find((r) => String(r["id"]) === id); + if (refId !== null) { + const hit = byId(refId); + if (!hit) throw new Error(`no entry for reference ${refOrQuery}`); + return asRow(hit); + } + if (/^\d+$/.test(refOrQuery)) { + const hit = byId(refOrQuery); + if (hit) return asRow(hit); + } + const labelHits = rows.filter((r) => String(r["label"] ?? "") === refOrQuery); + if (labelHits.length === 1) return asRow(labelHits[0]!); + if (labelHits.length > 1) { + throw new Error( + `label "${refOrQuery}" is ambiguous (${labelHits.length} entries); use an id or sp:// reference`, + ); + } + throw new Error(`no entry matches "${refOrQuery}"`); } async function openReadAccess(opts: GlobalOpts): Promise { @@ -174,32 +238,60 @@ async function openReadAccess(opts: GlobalOpts): Promise { if (token && !process.env["SEEDPASS_MNEMONIC"]) { const fp = await currentFingerprint(app, opts); const client = new AgentClient(agentSocketPath(app.root)); - const index = parseVaultIndex(await client.vaultIndex(fp, token)); + let cached: Array> | null = null; + const rows = async () => { + cached ??= await client.vaultEntries(fp, token); + return cached; + }; return { - index, mode: "token", - secretFor: async (id, action, timestamp) => { - return client.secret({ + rows, + resolveId: async (refOrQuery) => resolveFromRows(await rows(), refOrQuery), + reveal: async (id, timestamp) => + client.secret({ fingerprint: fp, id, token, - action, ...(timestamp !== undefined && { timestamp }), - }); - }, + }), + deliver: async (id, sink, command, timestamp) => + client.useSink({ + fingerprint: fp, + id, + token, + sink, + command, + ...(timestamp !== undefined && { timestamp }), + }), }; } const vault = await openFromOptions(opts); + const localRows = async () => + Object.entries(vault.index.entries).map(([id, e]) => entryMetadata(id, e)); + const localSecret = (id: string, timestamp?: number) => { + const entry = vault.index.entries[id]; + if (!entry) throw new Error(`no entry ${id}`); + return materializeSecret(vault.index, id, entry, vault.mnemonic, { + ...(timestamp !== undefined && { timestamp }), + }); + }; return { - index: vault.index, mode: "owner", - secretFor: async (id, action, timestamp) => { - void action; - const entry = vault.index.entries[id]; - if (!entry) throw new Error(`no entry ${id}`); - return materializeSecret(vault.index, id, entry, vault.mnemonic, { - ...(timestamp !== undefined && { timestamp }), - }); + rows: localRows, + resolveId: async (refOrQuery) => { + const hit = resolveEntry(vault.index, refOrQuery); + return { id: hit.id, ref: hit.ref, label: hit.entry.label, kind: hit.entry.kind }; + }, + reveal: async (id, timestamp) => localSecret(id, timestamp), + deliver: async (id, sink, command, timestamp) => { + const secret = localSecret(id, timestamp); + const result = + sink === "clipboard" + ? await clipboardSink(secret.value) + : sink === "exec" + ? await execSink(secret.value, ...parseCommandSpec(command)) + : await stdinSink(secret.value, ...parseCommandSpec(command)); + return { descriptor: secret.descriptor, ...result }; }, }; } @@ -232,10 +324,7 @@ export function buildProgram(io: ProgramIo = defaultIo): Command { .description("list entries as references + metadata (no secrets)") .action(async () => { const access = await openReadAccess(program.opts()); - const rows = Object.entries(access.index.entries).map(([id, e]) => - entryMetadata(id, e), - ); - io.out(JSON.stringify(rows, null, 2)); + io.out(JSON.stringify(await access.rows(), null, 2)); }); entry @@ -243,8 +332,9 @@ export function buildProgram(io: ProgramIo = defaultIo): Command { .description("show one entry's reference + metadata (no secrets)") .action(async (refOrQuery: string) => { const access = await openReadAccess(program.opts()); - const hit = resolveEntry(access.index, refOrQuery); - io.out(JSON.stringify(entryMetadata(hit.id, hit.entry), null, 2)); + const hit = await access.resolveId(refOrQuery); + const row = (await access.rows()).find((r) => String(r["id"]) === hit.id); + io.out(JSON.stringify(row, null, 2)); }); entry @@ -253,12 +343,13 @@ export function buildProgram(io: ProgramIo = defaultIo): Command { .action(async (text: string) => { const access = await openReadAccess(program.opts()); const needle = text.toLowerCase(); - const rows = Object.entries(access.index.entries) - .filter(([, e]) => { - const hay = [e.label, e.notes ?? "", ...(e.tags ?? [])].join("\n").toLowerCase(); - return hay.includes(needle); - }) - .map(([id, e]) => entryMetadata(id, e)); + const rows = (await access.rows()).filter((r) => { + const tags = Array.isArray(r["tags"]) ? (r["tags"] as string[]) : []; + const hay = [String(r["label"] ?? ""), String(r["notes"] ?? ""), ...tags] + .join("\n") + .toLowerCase(); + return hay.includes(needle); + }); io.out(JSON.stringify(rows, null, 2)); }); @@ -300,7 +391,7 @@ export function buildProgram(io: ProgramIo = defaultIo): Command { o: { length: string; username?: string; url?: string; notes?: string; tags?: string[]; archived?: boolean }, ) => { const vault = await openFromOptions(program.opts()); - const id = addPasswordEntry(vault.index, label, Number(o.length), { + const id = addPasswordEntry(vault.index, label, parseIntOption(o.length, "--length", { min: 8, max: 128 }), { ...commonOpts(o), ...(o.username !== undefined && { username: o.username }), ...(o.url !== undefined && { url: o.url }), @@ -324,8 +415,8 @@ export function buildProgram(io: ProgramIo = defaultIo): Command { const vault = await openFromOptions(program.opts()); const opts = { ...commonOpts(o), - period: Number(o.period), - digits: Number(o.digits), + period: parseIntOption(o.period, "--period", { min: 1 }), + digits: parseIntOption(o.digits, "--digits", { min: 6, max: 10 }), }; const id = o.secret ? addTotpImported(vault.index, label, o.secret, opts) @@ -379,7 +470,7 @@ export function buildProgram(io: ProgramIo = defaultIo): Command { ).action( async (label: string, o: { words: string; notes?: string; tags?: string[]; archived?: boolean }) => { const vault = await openFromOptions(program.opts()); - const words = Number(o.words); + const words = parseIntOption(o.words, "--words"); if (words !== 12 && words !== 18 && words !== 24) { throw new Error("--words must be 12, 18 or 24"); } @@ -554,8 +645,8 @@ export function buildProgram(io: ProgramIo = defaultIo): Command { ...(o.url !== undefined && { url: o.url }), ...(o.notes !== undefined && { notes: o.notes }), ...(o.tags !== undefined && { tags: o.tags }), - ...(o.period !== undefined && { period: Number(o.period) }), - ...(o.digits !== undefined && { digits: Number(o.digits) }), + ...(o.period !== undefined && { period: parseIntOption(o.period, "--period", { min: 1 }) }), + ...(o.digits !== undefined && { digits: parseIntOption(o.digits, "--digits", { min: 6, max: 10 }) }), ...(o.key !== undefined && { key: o.key }), ...(o.value !== undefined && { value: o.value }), ...(o.content !== undefined && { content: o.content }), @@ -637,7 +728,7 @@ export function buildProgram(io: ProgramIo = defaultIo): Command { .option("--at ", "unix time to compute codes at") .action(async (cmdOpts: { at?: string }) => { const vault = await openFromOptions(program.opts()); - const ts = cmdOpts.at !== undefined ? Number(cmdOpts.at) : Math.floor(Date.now() / 1000); + const ts = cmdOpts.at !== undefined ? parseUnixTime(cmdOpts.at) : Math.floor(Date.now() / 1000); const rows = Object.entries(vault.index.entries) .filter(([, e]) => e.kind === "totp" && !e.archived) .map(([id, e]) => { @@ -660,11 +751,10 @@ export function buildProgram(io: ProgramIo = defaultIo): Command { .option("--at ", "TOTP: unix time to compute the code at") .action(async (refOrQuery: string, cmdOpts: { at?: string }) => { const access = await openReadAccess(program.opts()); - const hit = resolveEntry(access.index, refOrQuery); - const secret = await access.secretFor( + const hit = await access.resolveId(refOrQuery); + const secret = await access.reveal( hit.id, - "reveal", - cmdOpts.at !== undefined ? Number(cmdOpts.at) : undefined, + cmdOpts.at !== undefined ? parseUnixTime(cmdOpts.at) : undefined, ); io.out(secret.value); }); @@ -694,28 +784,25 @@ export function buildProgram(io: ProgramIo = defaultIo): Command { throw new Error("choose exactly one sink: --clipboard, --exec, or --stdin-to"); } const access = await openReadAccess(program.opts()); - const hit = resolveEntry(access.index, refOrQuery); - const secret = await access.secretFor( + const hit = await access.resolveId(refOrQuery); + const sink = cmdOpts.clipboard ? "clipboard" : cmdOpts.exec ? "exec" : "stdin"; + const command = cmdOpts.clipboard ? [] : (cmdOpts.exec ?? cmdOpts.stdinTo!); + // In token mode the agent runs the sink: the secret is never sent + // back to this process. + const result = await access.deliver( hit.id, - "use", - cmdOpts.at !== undefined ? Number(cmdOpts.at) : undefined, + sink, + command, + cmdOpts.at !== undefined ? parseUnixTime(cmdOpts.at) : undefined, ); - - let result; - if (cmdOpts.clipboard) { - result = await clipboardSink(secret.value); - } else if (cmdOpts.exec) { - const [cmd, args] = parseCommandSpec(cmdOpts.exec); - result = await execSink(secret.value, cmd, args); - } else { - const [cmd, args] = parseCommandSpec(cmdOpts.stdinTo!); - result = await stdinSink(secret.value, cmd, args); - } io.out( JSON.stringify({ - delivered: secret.descriptor, + delivered: result["descriptor"], ref: hit.ref, - ...result, + mode: access.mode, + ...Object.fromEntries( + Object.entries(result).filter(([k]) => k !== "descriptor"), + ), }), ); }, @@ -857,7 +944,7 @@ export function buildProgram(io: ProgramIo = defaultIo): Command { .action(async (indexStr: string) => { const { app, fp, mnemonic, cfg } = await nostrContext(program.opts()); const relays = cfg["relays"] as string[]; - const i = Number(indexStr) - 1; + const i = parseIntOption(indexStr, "index", { min: 1 }) - 1; if (!Number.isInteger(i) || i < 0 || i >= relays.length) { throw new Error(`index out of range 1..${relays.length}`); } @@ -883,7 +970,7 @@ export function buildProgram(io: ProgramIo = defaultIo): Command { keys.privateKeyHex, deriveKeyIndex(mnemonic), encrypted, - { limit: Number(o.chunkLimit) }, + { limit: parseIntOption(o.chunkLimit, "--chunk-limit", { min: 64 }) }, ); io.out( JSON.stringify( @@ -963,7 +1050,7 @@ export function buildProgram(io: ProgramIo = defaultIo): Command { .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), app.root); + const daemon = new AgentDaemon(agentSocketPath(app.root), parseIntOption(o.ttl, "--ttl", { min: 1 }), app.root); await daemon.start(); io.out(JSON.stringify({ agent: "running", socket: agentSocketPath(app.root) })); await new Promise(() => {}); // run until killed @@ -993,8 +1080,8 @@ export function buildProgram(io: ProgramIo = defaultIo): Command { scopes: o.scope as ("read" | "use" | "reveal")[], ...(o.kind !== undefined && { kinds: o.kind }), labelRegex: o.labelRegex, - ttl: Number(o.ttl), - uses: Number(o.uses), + ttl: parseIntOption(o.ttl, "--ttl", { min: 1 }), + uses: parseIntOption(o.uses, "--uses", { min: 1 }), }); io.out( JSON.stringify( @@ -1053,7 +1140,7 @@ export function buildProgram(io: ProgramIo = defaultIo): Command { join(app.profileDir(fp), "audit.log"), deriveKeyIndex(mnemonic), ); - io.out(JSON.stringify(records.slice(-Number(o.n)), null, 2)); + io.out(JSON.stringify(records.slice(-parseIntOption(o.n, "-n", { min: 1 })), null, 2)); }); agent @@ -1102,17 +1189,25 @@ export function buildProgram(io: ProgramIo = defaultIo): Command { ...(o.allowedSpecialChars !== undefined && { allowedSpecialChars: o.allowedSpecialChars }), ...(o.specialMode !== undefined && { specialMode: o.specialMode }), ...(o.excludeAmbiguous !== undefined && { excludeAmbiguous: o.excludeAmbiguous }), - ...(o.minUppercase !== undefined && { minUppercase: Number(o.minUppercase) }), - ...(o.minLowercase !== undefined && { minLowercase: Number(o.minLowercase) }), - ...(o.minDigits !== undefined && { minDigits: Number(o.minDigits) }), - ...(o.minSpecial !== undefined && { minSpecial: Number(o.minSpecial) }), + ...(o.minUppercase !== undefined && { + minUppercase: parseIntOption(o.minUppercase, "--min-uppercase", { min: 0 }), + }), + ...(o.minLowercase !== undefined && { + minLowercase: parseIntOption(o.minLowercase, "--min-lowercase", { min: 0 }), + }), + ...(o.minDigits !== undefined && { + minDigits: parseIntOption(o.minDigits, "--min-digits", { min: 0 }), + }), + ...(o.minSpecial !== undefined && { + minSpecial: parseIntOption(o.minSpecial, "--min-special", { min: 0 }), + }), }; const bip85 = Bip85.fromMnemonic(requireMnemonic()); io.out( generatePassword(bip85, { - length: Number(o.length), - index: Number(o.index), - genVersion: Number(o.genVersion), + length: parseIntOption(o.length, "--length", { min: 8, max: 128 }), + index: parseIntOption(o.index, "--index", { min: 0 }), + genVersion: parseIntOption(o.genVersion, "--gen-version", { min: 1, max: 2 }), policy, }), ); @@ -1157,7 +1252,7 @@ export function buildProgram(io: ProgramIo = defaultIo): Command { const expiresAt = await client.put( fp, mnemonic, - o.ttl !== undefined ? Number(o.ttl) : undefined, + o.ttl !== undefined ? parseIntOption(o.ttl, "--ttl", { min: 1 }) : undefined, ); io.out(JSON.stringify({ unlocked: fp, expires_at: expiresAt })); }); diff --git a/js/packages/cli/src/sinks.ts b/js/packages/cli/src/sinks.ts index 68540865..c3a7d0db 100644 --- a/js/packages/cli/src/sinks.ts +++ b/js/packages/cli/src/sinks.ts @@ -16,6 +16,39 @@ export interface SinkResult { /** Env var the --exec sink injects into the child process. */ export const EXEC_ENV_VAR = "SEEDPASS_SECRET"; +/** + * Variables that must never reach a sink child. + * + * A helper invoked to receive one password would otherwise inherit the + * parent seed, the master password, a bearer token and the agent socket + * path — everything needed to take the whole vault. + */ +const FORBIDDEN_ENV = [ + "SEEDPASS_MNEMONIC", + "SEEDPASS_PASSWORD", + "SEEDPASS_TOKEN", + "SEEDPASS_AGENT_SOCK", + "SEEDPASS_AGENT_CAP", + "SEEDPASS_APP_DIR", +]; + +/** Variables a child plausibly needs to run at all. */ +const PASSTHROUGH_ENV = ["PATH", "HOME", "LANG", "LC_ALL", "TERM", "TZ", "TMPDIR", "DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY"]; + +/** + * Build the environment for a sink child: a minimal allowlist, never the + * caller's full environment. + */ +export function sinkEnv(extra: Record = {}): Record { + const env: Record = {}; + for (const key of PASSTHROUGH_ENV) { + const value = process.env[key]; + if (value !== undefined) env[key] = value; + } + for (const key of FORBIDDEN_ENV) delete env[key]; + return { ...env, ...extra }; +} + /** * Split a sink command spec into [command, ...args]. * @@ -42,7 +75,7 @@ export async function execSink( return new Promise((resolve, reject) => { const child = spawn(command, args, { stdio: ["ignore", "inherit", "inherit"], - env: { ...process.env, [EXEC_ENV_VAR]: secret }, + env: sinkEnv({ [EXEC_ENV_VAR]: secret }), }); child.on("error", reject); child.on("close", (code) => { @@ -64,7 +97,7 @@ export async function stdinSink( return new Promise((resolve, reject) => { const child = spawn(command, args, { stdio: ["pipe", "inherit", "inherit"], - env: process.env, + env: sinkEnv(), }); child.on("error", reject); child.stdin.write(secret); @@ -95,7 +128,11 @@ const CLIPBOARD_COMMANDS: Array<{ cmd: string; args: string[] }> = [ */ function feedClipboardTool(secret: string, cmd: string, args: string[]): Promise { return new Promise((resolve, reject) => { - const child = spawn(cmd, args, { stdio: ["pipe", "ignore", "ignore"], detached: true }); + const child = spawn(cmd, args, { + stdio: ["pipe", "ignore", "ignore"], + detached: true, + env: sinkEnv(), + }); let settled = false; const fail = (e: Error) => { if (!settled) { diff --git a/js/packages/cli/test/agentSecurity.test.ts b/js/packages/cli/test/agentSecurity.test.ts new file mode 100644 index 00000000..cd49cc80 --- /dev/null +++ b/js/packages/cli/test/agentSecurity.test.ts @@ -0,0 +1,265 @@ +/** + * Attacks against the agent's socket, spoken directly — not through the CLI. + * + * These reproduce a security review's findings: the CLI's authorization + * checks are worthless if the daemon accepts privileged operations from any + * local process, so every assertion here bypasses the CLI entirely. + */ + +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { createConnection } from "node:net"; +import { mkdtemp, readFile, writeFile, stat } from "node:fs/promises"; +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 = "agent-security-pw"; + +let appDir: string; +let socketPath: string; +let daemon: AgentDaemon; +let token: string; + +/** Speak the wire protocol directly, as a hostile local process would. */ +function rawRequest(msg: Record): Promise> { + return new Promise((resolve, reject) => { + const socket = createConnection(socketPath); + let buffer = ""; + const timer = setTimeout(() => { + socket.destroy(); + reject(new Error("timeout")); + }, 5000); + 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(); + resolve(JSON.parse(buffer.slice(0, nl)) as Record); + } + }); + socket.on("error", (e) => { + clearTimeout(timer); + reject(e); + }); + }); +} + +async function run( + env: Record, + ...argv: string[] +): Promise<{ stdout: string; error?: unknown }> { + 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-agentsec-")); + socketPath = join(appDir, "agent.sock"); + process.env["SEEDPASS_AGENT_SOCK"] = socketPath; + daemon = new AgentDaemon(socketPath, 900, appDir); + await daemon.start(); + + await run( + { SEEDPASS_MNEMONIC: MNEMONIC, SEEDPASS_PASSWORD: PASSWORD }, + "fingerprint", "add", + ); + await run( + { SEEDPASS_MNEMONIC: MNEMONIC }, + "entry", "add", "key-value", "api-key", "k", "top-secret-value", + ); + await run( + { SEEDPASS_MNEMONIC: MNEMONIC }, + "entry", "add", "totp", "imported", "--secret", "JBSWY3DPEHPK3PXP", + ); + await run({ SEEDPASS_MNEMONIC: undefined, SEEDPASS_PASSWORD: PASSWORD }, "vault", "unlock"); + + const issued = JSON.parse( + (await run({}, "agent", "token-issue", "--scope", "read", "use", "--uses", "50", "--ttl", "600")) + .stdout, + ); + token = issued.token; +}); + +afterAll(async () => { + await daemon.stop(); + delete process.env["SEEDPASS_AGENT_SOCK"]; +}); + +describe("unauthenticated socket access", () => { + it("cannot retrieve the parent seed", async () => { + const r = await rawRequest({ op: "owner-mnemonic", fingerprint: FINGERPRINT }); + expect(r["ok"]).toBe(false); + expect(String(r["error"])).toContain("owner capability"); + expect(JSON.stringify(r)).not.toContain("abandon"); + }); + + it("cannot issue tokens", async () => { + const r = await rawRequest({ + op: "token-issue", + fingerprint: FINGERPRINT, + scopes: ["read", "use", "reveal"], + uses: 999, + }); + expect(r["ok"]).toBe(false); + expect(r["token"]).toBeUndefined(); + }); + + it("cannot lock, shut down, inspect status, or inject a seed", async () => { + for (const op of ["lock", "shutdown", "status"]) { + const r = await rawRequest({ op }); + expect(r["ok"]).toBe(false); + } + const injected = await rawRequest({ + op: "put", + fingerprint: "AAAAAAAAAAAAAAAA", + mnemonic: MNEMONIC, + }); + expect(injected["ok"]).toBe(false); + + // The profile is still unlocked and the owner still works + const status = JSON.parse((await run({}, "agent", "status")).stdout); + expect(status.map((p: { fingerprint: string }) => p.fingerprint)).toContain(FINGERPRINT); + }); + + it("a wrong capability is rejected", async () => { + const r = await rawRequest({ + op: "owner-mnemonic", + fingerprint: FINGERPRINT, + cap: "not-the-capability", + }); + expect(r["ok"]).toBe(false); + }); + + it("keeps the capability file readable only by the owner", async () => { + const info = await stat(daemon.capabilityPath); + expect(info.mode & 0o077).toBe(0); + }); +}); + +describe("token holders cannot exceed their grant", () => { + it("a read-scoped request never returns the decrypted index", async () => { + const r = await rawRequest({ op: "vault-index", fingerprint: FINGERPRINT, token }); + expect(r["ok"]).toBe(true); + // Redacted rows only — no raw entries map, no secret material + expect(r["index"]).toBeUndefined(); + const serialized = JSON.stringify(r); + expect(serialized).not.toContain("top-secret-value"); + expect(serialized).not.toContain("JBSWY3DPEHPK3PXP"); + const rows = r["entries"] as Array>; + expect(rows.find((x) => x["label"] === "api-key")!["has_value"]).toBe(true); + expect(rows.find((x) => x["label"] === "imported")!["has_secret"]).toBe(true); + }); + + it("a use-scoped request cannot pull plaintext back over the socket", async () => { + const r = await rawRequest({ + op: "secret", + fingerprint: FINGERPRINT, + id: "0", + token, + action: "use", + }); + expect(r["ok"]).toBe(false); + expect(String(r["error"])).toContain("reveal"); + expect(JSON.stringify(r)).not.toContain("top-secret-value"); + }); + + it("a use-sink delivery returns a receipt, not the secret", async () => { + const dir = await mkdtemp(join(tmpdir(), "seedpass-sec-sink-")); + const capture = join(dir, "captured.txt"); + const script = join(dir, "cap.sh"); + await writeFile(script, `#!/bin/sh\nprintf '%s' "$SEEDPASS_SECRET" > "${capture}"\n`, { + mode: 0o755, + }); + const r = await rawRequest({ + op: "use-sink", + fingerprint: FINGERPRINT, + id: "0", + token, + sink: "exec", + command: [script], + }); + expect(r["ok"]).toBe(true); + expect(JSON.stringify(r)).not.toContain("top-secret-value"); + expect(await readFile(capture, "utf8")).toBe("top-secret-value"); + }); + + it("cannot use its token to reach owner operations", async () => { + const r = await rawRequest({ op: "owner-mnemonic", fingerprint: FINGERPRINT, cap: token }); + expect(r["ok"]).toBe(false); + }); +}); + +describe("sink children do not inherit the vault's secrets", () => { + it("SEEDPASS_* variables are stripped from the child environment", async () => { + const dir = await mkdtemp(join(tmpdir(), "seedpass-env-")); + const capture = join(dir, "env.txt"); + const script = join(dir, "dump.sh"); + await writeFile( + script, + `#!/bin/sh\nenv | grep '^SEEDPASS_' | sort > "${capture}"\n`, + { mode: 0o755 }, + ); + // Run as the owner with every sensitive variable set in the parent. + const r = await run( + { + SEEDPASS_MNEMONIC: MNEMONIC, + SEEDPASS_PASSWORD: PASSWORD, + SEEDPASS_TOKEN: token, + }, + "use", "api-key", "--exec", script, + ); + expect(r.error).toBeUndefined(); + const dumped = await readFile(capture, "utf8"); + expect(dumped).toContain("SEEDPASS_SECRET=top-secret-value"); + expect(dumped).not.toContain("SEEDPASS_MNEMONIC"); + expect(dumped).not.toContain("SEEDPASS_PASSWORD"); + expect(dumped).not.toContain("SEEDPASS_TOKEN"); + expect(dumped).not.toContain("SEEDPASS_AGENT_SOCK"); + }); +}); + +describe("locking clears derived state", () => { + it("tokens do not survive a lock and later unlock", async () => { + const fresh = JSON.parse( + (await run({}, "agent", "token-issue", "--scope", "read", "--ttl", "600")).stdout, + ).token; + expect( + (await rawRequest({ op: "vault-index", fingerprint: FINGERPRINT, token: fresh }))["ok"], + ).toBe(true); + + await run({ SEEDPASS_MNEMONIC: undefined }, "vault", "lock"); + await run( + { SEEDPASS_MNEMONIC: undefined, SEEDPASS_PASSWORD: PASSWORD }, + "vault", "unlock", + ); + + const after = await rawRequest({ op: "vault-index", fingerprint: FINGERPRINT, token: fresh }); + expect(after["ok"]).toBe(false); + expect(String(after["error"])).toContain("unknown token"); + }); +}); diff --git a/js/packages/cli/test/tokens.test.ts b/js/packages/cli/test/tokens.test.ts index 788c8abe..e507c69c 100644 --- a/js/packages/cli/test/tokens.test.ts +++ b/js/packages/cli/test/tokens.test.ts @@ -97,11 +97,14 @@ describe("token issuance", () => { }); describe("token-mode access (no mnemonic anywhere)", () => { - it("reads metadata but the index never exposes the mnemonic", async () => { + it("lists only the entries the token may act on, with no secret values", async () => { const r = await run(asTokenHolder(token), "entry", "list"); const rows = JSON.parse(r.stdout) as { label: string }[]; - expect(rows.map((x) => x.label).sort()).toEqual(["api-token", "internal-db", "site-login"]); + // Scoped to kind key_value and labels matching ^api-, so the password + // entry and the non-matching key_value are not even disclosed. + expect(rows.map((x) => x.label)).toEqual(["api-token"]); expect(r.stdout).not.toContain("sinkable-secret"); + expect(r.stdout).not.toContain("off-limits"); }); it("delivers a permitted secret to a sink", async () => { @@ -121,11 +124,17 @@ describe("token-mode access (no mnemonic anywhere)", () => { const reveal = await run(asTokenHolder(token), "entry", "reveal", "api-token"); expect(String((reveal.error as Error).message)).toContain("scope 'reveal' not granted"); + // Entries outside the token's constraints are not resolvable at all — + // the agent never returned them, so their existence is not disclosed. const wrongKind = await run(asTokenHolder(token), "use", "site-login", "--stdin-to", "cat"); - expect(String((wrongKind.error as Error).message)).toContain("kind 'password' not granted"); + expect(String((wrongKind.error as Error).message)).toContain("no entry matches"); const wrongLabel = await run(asTokenHolder(token), "use", "internal-db", "--stdin-to", "cat"); - expect(String((wrongLabel.error as Error).message)).toContain("label not matched"); + expect(String((wrongLabel.error as Error).message)).toContain("no entry matches"); + + // Addressing a forbidden entry by id still fails, in the agent + const byId = await run(asTokenHolder(token), "use", "sp://entry/2", "--stdin-to", "cat"); + expect(String((byId.error as Error).message)).toMatch(/no entry|denied/); }); it("exhausts after the allotted uses", async () => { diff --git a/js/packages/cli/vitest.config.ts b/js/packages/cli/vitest.config.ts new file mode 100644 index 00000000..1b5a892b --- /dev/null +++ b/js/packages/cli/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + // These tests exercise real KDFs: profile creation and unlock each run + // PBKDF2 at 200k iterations (and Argon2id where configured), which + // comfortably exceeds the 5s default once several files run in parallel. + testTimeout: 60_000, + hookTimeout: 60_000, + }, +}); diff --git a/js/packages/core/vitest.config.ts b/js/packages/core/vitest.config.ts new file mode 100644 index 00000000..3940309c --- /dev/null +++ b/js/packages/core/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + // Parity cases run real KDFs — PBKDF2 at 100k iterations per password + // case, Argon2id for the KDF fixtures. The 5s default is unrealistic for + // these, especially in the jsdom run or on a loaded machine. + testTimeout: 120_000, + hookTimeout: 120_000, + }, +});