Files
SeedPass/js/packages/cli/test/agentSecurity.test.ts
T
thePR0M3TH3ANandClaude Fable 5 dafae50ca4 security: fix agent-boundary findings from the fourth reviewer
The reviewer that took longest found the most, including one bug that lies
to the user about a security-relevant outcome.

- `agent stop` never sent the capability and never checked the reply. The
  daemon refused the shutdown, the CLI printed {"agent":"stopped"} anyway,
  and the seed stayed in memory and still served owner-mnemonic. The command
  documents itself as wiping held seeds. It now authenticates and reports
  what actually happened.

- The owner capability lived at <appDir>/agent.sock.cap -- inside the very
  directory a scoped agent must be given to find fingerprints.json and the
  socket. A token holder could `unset SEEDPASS_TOKEN` and the CLI would read
  the capability on its behalf. It now lives under XDG_RUNTIME_DIR in a 0700
  parent, created with wx so a pre-planted file is never adopted. This raises
  the bar; it does not make a token a sandbox, and capabilities.ts no longer
  claims otherwise.

- Redaction was a three-key denylist over deliberately loose schemas, so
  `custom_fields` -- including values Python masks as is_hidden -- went to
  any read-scoped token verbatim. It is now an allowlist that recurses into
  custom_fields, reporting labels and presence but never values.

- Authorization ran after the vault was decrypted and after the entry
  existence check, giving an unauthenticated caller an entry-id enumeration
  oracle, no audit record for the misses, and a full PBKDF2 unlock per probe.
  Tokens are now checked first, denials are indistinguishable for missing
  and forbidden entries, and every denial is audited.

- The audit chain had no anchor: deleting the log, or a suffix of it, left
  something that verified perfectly. A signed head now pins the record count
  and tail signature. Separately, `loaded` was latched before the read that
  could throw, so one transient EACCES made every later append sign from
  genesis and broke the chain permanently.

- The exec allowlist -- documented as the only real containment for a `use`
  grant -- was bypassable via the clipboard (a session-wide channel) and had
  no CLI flag at all, so no shipped token could carry one. Both fixed.

- Unauthenticated connections could buffer without bound, growing to
  gigabytes while each chunk rescanned the accumulated string. Line size and
  concurrent connections are now capped.

- A token-supplied TOTP timestamp was unbounded, so one use minted codes
  valid decades ahead, outliving the token's TTL. Clamped to a small window.

- Prototype keys ("__proto__", "constructor") resolved to inherited
  properties, passed constraint checks against undefined values and burned a
  use; now an own-property check. The socket was created with the umask mode
  and chmod'd afterwards, leaving a connectable window; it is now created
  under a restrictive umask. TTL was enforced only when a request arrived, so
  an idle agent held the seed indefinitely past expiry; there is now a timer.

220 core + 78 CLI tests green; 36/36 cross-implementation checks green.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-14 23:58:58 -04:00

388 lines
14 KiB
TypeScript

/**
* 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 { join as joinPath } from "node:path";
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<string, unknown>): Promise<Record<string, unknown>> {
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<string, unknown>);
}
});
socket.on("error", (e) => {
clearTimeout(timer);
reject(e);
});
});
}
async function run(
env: Record<string, string | undefined>,
...argv: string[]
): Promise<{ stdout: string; error?: unknown }> {
const saved: Record<string, string | undefined> = {};
for (const [k, v] of Object.entries(env)) {
saved[k] = process.env[k];
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
const out: string[] = [];
const io: ProgramIo = { out: (l: string) => out.push(l), err: () => {} };
let error: unknown;
try {
await buildProgram(io).parseAsync(["node", "seedpass-js", "--app-dir", appDir, ...argv]);
} catch (e) {
error = e;
} finally {
for (const [k, v] of Object.entries(saved)) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
}
return { stdout: out.join("\n"), error };
}
beforeAll(async () => {
appDir = await mkdtemp(join(tmpdir(), "seedpass-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<Record<string, unknown>>;
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);
});
it("rejects non-string credentials instead of coercing them", async () => {
// String(["abc"]) === "abc", so an array credential would otherwise pass
// a String()-based comparison unnoticed.
for (const shaped of [[token], { t: token }, 1, true, null]) {
const r = await rawRequest({ op: "vault-index", fingerprint: FINGERPRINT, token: shaped });
expect(r["ok"], `token as ${JSON.stringify(shaped)} must be refused`).toBe(false);
}
for (const shaped of [["x"], {}, 0]) {
const r = await rawRequest({ op: "owner-mnemonic", fingerprint: FINGERPRINT, cap: shaped });
expect(r["ok"]).toBe(false);
}
const badCommand = await rawRequest({
op: "use-sink", fingerprint: FINGERPRINT, id: "0", token,
sink: "exec", command: "not-an-array",
});
expect(badCommand["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("second-audit findings", () => {
it("keeps the owner capability outside the app directory", async () => {
// A scoped agent is given the app directory; a capability stored there
// would hand it the escape hatch along with the socket.
expect(daemon.capabilityPath.startsWith(appDir)).toBe(false);
const info = await stat(daemon.capabilityPath);
expect(info.mode & 0o077).toBe(0);
});
it("agent stop authenticates and does not claim success it did not get", async () => {
const unauthenticated = await rawRequest({ op: "shutdown" });
expect(unauthenticated["ok"]).toBe(false);
// The daemon must still be alive and still holding the seed
expect((await rawRequest({ op: "ping" }))["pong"]).toBe(true);
});
it("never returns custom_field values to a read token", async () => {
await run(
{ SEEDPASS_MNEMONIC: MNEMONIC },
"entry", "add", "key-value", "with-fields", "k", "v",
);
// Inject a hidden custom field the way a Python-side writer would
const { openVault, saveVault } = await import("../src/vaultFile.js");
const vaultPath = join(appDir, FINGERPRINT, "seedpass_entries_db.json.enc");
const vault = await openVault(vaultPath, MNEMONIC);
const target = Object.entries(vault.index.entries).find(
([, e]) => e.label === "with-fields",
)!;
(target[1] as { custom_fields?: unknown[] }).custom_fields = [
{ label: "recovery_code", value: "HIDDEN-FIELD-SECRET", is_hidden: true },
];
await saveVault(vault);
const r = await rawRequest({ op: "vault-index", fingerprint: FINGERPRINT, token });
expect(r["ok"]).toBe(true);
expect(JSON.stringify(r)).not.toContain("HIDDEN-FIELD-SECRET");
const rows = r["entries"] as Array<Record<string, unknown>>;
const row = rows.find((x) => x["label"] === "with-fields")!;
const fields = row["custom_fields"] as Array<Record<string, unknown>>;
expect(fields[0]!["label"]).toBe("recovery_code");
expect(fields[0]!["has_value"]).toBe(true);
expect(fields[0]).not.toHaveProperty("value");
});
it("does not disclose which entry ids exist to an unauthenticated caller", async () => {
const existing = await rawRequest({ op: "secret", fingerprint: FINGERPRINT, id: "0" });
const missing = await rawRequest({ op: "secret", fingerprint: FINGERPRINT, id: "9999" });
expect(existing["ok"]).toBe(false);
expect(missing["ok"]).toBe(false);
// Identical denial: an enumeration oracle is an information leak
expect(existing["error"]).toBe(missing["error"]);
});
it("rejects an oversized request instead of buffering it", async () => {
const reply = await new Promise<Record<string, unknown>>((resolve, reject) => {
const socket = createConnection(socketPath);
let buf = "";
const timer = setTimeout(() => {
socket.destroy();
reject(new Error("timeout"));
}, 10000);
socket.on("connect", () => {
// No newline: without a cap this grows unbounded and is rescanned.
socket.write("x".repeat(1_000_000));
});
socket.on("data", (c) => {
buf += c.toString();
const nl = buf.indexOf("\n");
if (nl >= 0) {
clearTimeout(timer);
socket.destroy();
resolve(JSON.parse(buf.slice(0, nl)) as Record<string, unknown>);
}
});
socket.on("error", (e) => {
clearTimeout(timer);
reject(e);
});
});
expect(reply["ok"]).toBe(false);
expect(String(reply["error"])).toContain("too large");
});
it("clamps a token-supplied TOTP timestamp to now", async () => {
const far = await rawRequest({
op: "secret", fingerprint: FINGERPRINT, id: "1", token, timestamp: 4000000000,
});
expect(far["ok"]).toBe(false);
expect(String(far["error"])).toMatch(/timestamp|denied/);
});
it("detects a truncated audit log", async () => {
const auditPath = join(appDir, FINGERPRINT, "audit.log");
const original = await readFile(auditPath, "utf8");
const lines = original.split("\n").filter((l) => l.trim());
// Excise the most recent records, as an attacker covering their tracks
await writeFile(auditPath, lines.slice(0, Math.max(1, lines.length - 2)).join("\n") + "\n");
const r = await run({ SEEDPASS_MNEMONIC: MNEMONIC }, "agent", "audit-verify");
expect(String((r.error as Error).message)).toMatch(/removed|head/);
await writeFile(auditPath, original);
});
});
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");
});
});