From 9926b48e16c5febe59146e9789dcb53ba55f4311 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 14 Aug 2026 23:02:07 -0400 Subject: [PATCH] security: fix agent trust boundary and secret-environment leaks A security review found the agent's authorization model was enforced in the client, not the server: any local process that could open the 0600 socket could retrieve the parent seed, mint tokens, unlock/lock, or shut the agent down, because the daemon accepted privileged operations unauthenticated. It also found that scoped tokens received far more than their grant, and that sink children inherited the caller's entire environment. Server-side authorization: - Privileged operations (put, owner-mnemonic, token issue/list/revoke, lock, status, shutdown) now require a capability secret written to a 0600 file beside the socket at startup, compared in constant time. Socket permissions alone cannot distinguish the owner's CLI from a scoped agent holding a token. The residual risk is documented in the code: a same-uid attacker who can read that file, or ptrace the daemon, still wins -- that needs an OS-level boundary this process cannot provide. - The mnemonic-returning op is renamed owner-mnemonic and is owner-gated. It is unreachable with a token. Token containment: - vault-index returned the decrypted index to any read-scoped token, handing over every imported TOTP secret, key-value value and document body. It now returns redacted metadata, filtered to entries the token's kind and label constraints actually cover, so out-of-scope entries are not even disclosed. - The `secret` op now requires the reveal scope specifically. A use-scoped token can no longer pull plaintext back across the socket. - `use` is served by a new use-sink op: the agent runs the sink itself and returns a receipt, so plaintext never crosses back to the token holder. Tokens gained an optional exec allowlist, because a holder who chooses the command can always read the secret from inside it -- the allowlist is what contains a `use` grant, the audit record is what makes it accountable. Secret hygiene and state: - Sink children get a minimal allowlisted environment. SEEDPASS_MNEMONIC, SEEDPASS_PASSWORD, SEEDPASS_TOKEN, the agent socket and app dir are never inherited: a helper invoked to receive one password could previously have taken the whole vault. - Locking (and TTL expiry) now clears that profile's tokens and its audit logger, so tokens cannot spring back to life after a later unlock and the seed-derived audit key does not stay resident. - Audit appends are serialized, so concurrent requests cannot interleave and break the HMAC chain. - Numeric CLI options are validated before anything is persisted. A single `--length not-a-number` previously stored NaN as JSON null and made every later vault open fail schema validation. test/agentSecurity.test.ts speaks the wire protocol directly, bypassing the CLI, and asserts each of the review's reproductions now fails: no seed, no token minting, no lock/shutdown/status/put, no raw index for a read token, no plaintext for a use token, no owner ops via a token, and no SEEDPASS_* in a sink child. Also verifies the capability file is 0600 and that tokens die with a lock. 193 core + 60 CLI tests green; 36/36 cross-implementation checks green. Co-Authored-By: Claude Fable 5 --- js/packages/cli/src/agent.ts | 407 +++++++++++++++++---- js/packages/cli/src/program.ts | 245 +++++++++---- js/packages/cli/src/sinks.ts | 43 ++- js/packages/cli/test/agentSecurity.test.ts | 265 ++++++++++++++ js/packages/cli/test/tokens.test.ts | 17 +- js/packages/cli/vitest.config.ts | 11 + js/packages/core/vitest.config.ts | 11 + 7 files changed, 853 insertions(+), 146 deletions(-) create mode 100644 js/packages/cli/test/agentSecurity.test.ts create mode 100644 js/packages/cli/vitest.config.ts create mode 100644 js/packages/core/vitest.config.ts 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, + }, +});