diff --git a/docs/agent_security_model.md b/docs/agent_security_model.md index aa69dc0b..f3b04822 100644 --- a/docs/agent_security_model.md +++ b/docs/agent_security_model.md @@ -77,7 +77,9 @@ the credential itself. Append-only, HMAC-chained (`sig = HMAC(key_index, prev_sig || canonical payload)`), keyed by the profile's index key, so only the seed holder can -extend or verify it. `agent audit-verify` walks the chain and fails at the +extend or verify it. A signed head file records the expected record count +and last signature — without it, deleting the log or a suffix of it leaves a +shorter file that still verifies perfectly. `agent audit-verify` walks the chain and fails at the first break. Writes are serialized so concurrent requests cannot interleave and desynchronize it. Recorded: unlocks, token lifecycle, index reads, deliveries (with the sink and argv), and every denial with its reason. @@ -85,7 +87,12 @@ Secret values are never recorded. ## Known limits, stated rather than hidden -1. **Same-uid attackers win** — see above. +1. **Same-uid attackers win** — see above. Concretely: a scoped agent is + given the app directory, so the capability is deliberately stored + *outside* it (in `XDG_RUNTIME_DIR`), but a token holder that can read that + file can still unset its token and act as the owner. Treat a token as a + convenience and an audit trail, not as a sandbox. Real containment needs a + separate uid or a container. 2. **`use` can be turned into `reveal`** by a holder that picks the command, unless an exec allowlist is set. 3. **Memory is not scrubbed.** Seeds live in the daemon's heap while diff --git a/js/packages/cli/src/agent.ts b/js/packages/cli/src/agent.ts index eb171957..9252cf8d 100644 --- a/js/packages/cli/src/agent.ts +++ b/js/packages/cli/src/agent.ts @@ -22,10 +22,11 @@ */ import { createServer, createConnection, type Socket } from "node:net"; -import { chmod, rm } from "node:fs/promises"; +import { chmod, mkdir, open, rm } from "node:fs/promises"; import { existsSync, readFileSync } from "node:fs"; -import { randomBytes, timingSafeEqual } from "node:crypto"; -import { writeFile } from "node:fs/promises"; +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; +import { tmpdir } from "node:os"; +import { dirname } from "node:path"; import { join } from "node:path"; import process from "node:process"; import { deriveKeyIndex, sha256Hex, utf8, type Entry } from "@seedpass/core"; @@ -44,6 +45,13 @@ import { INDEX_FILENAME } from "./appDir.js"; export const DEFAULT_TTL_SECONDS = 900; +/** How far a token-supplied TOTP timestamp may differ from now. */ +export const TIMESTAMP_SKEW_SECONDS = 120; + +/** Guards against an unauthenticated peer exhausting memory (see serve). */ +export const MAX_LINE_BYTES = 512 * 1024; +export const MAX_CONNECTIONS = 64; + export type TokenScope = "read" | "use" | "reveal"; export interface TokenRecord { @@ -73,6 +81,20 @@ export function agentSocketPath(appDir: string): string { return process.env["SEEDPASS_AGENT_SOCK"] ?? join(appDir, "agent.sock"); } +/** + * Where the owner capability for a given socket lives. + * + * Keyed by a hash of the socket path so multiple agents coexist, and placed + * under XDG_RUNTIME_DIR when available (tmpfs, 0700, cleared on logout). + */ +export function capabilityPathFor(socketPath: string): string { + const explicit = process.env["SEEDPASS_AGENT_CAP_FILE"]; + if (explicit) return explicit; + const runtime = process.env["XDG_RUNTIME_DIR"] || tmpdir(); + const key = createHash("sha256").update(socketPath).digest("hex").slice(0, 16); + return join(runtime, `seedpass-agent-${key}.cap`); +} + interface Held { mnemonic: string; expiresAt: number; @@ -101,6 +123,8 @@ export class AgentDaemon { private audits = new Map(); private auditQueue: Promise = Promise.resolve(); private capability = ""; + private expiryTimer: ReturnType | null = null; + private connections = 0; private server = createServer((socket) => this.serve(socket)); constructor( @@ -110,9 +134,17 @@ export class AgentDaemon { private readonly appDir?: string, ) {} - /** Path of the 0600 file holding the owner capability. */ + /** + * Path of the 0600 file holding the owner capability. + * + * Deliberately NOT beside the socket: a scoped agent is given the app + * directory (it needs fingerprints.json and the socket), so a capability + * stored there is inside the blast radius of the principal it is meant to + * exclude. It lives in the user's runtime directory instead, under a 0700 + * parent, so handing out the app directory no longer hands out ownership. + */ get capabilityPath(): string { - return `${this.socketPath}.cap`; + return capabilityPathFor(this.socketPath); } /** @@ -232,29 +264,62 @@ export class AgentDaemon { return { error: { ok: false, error: "profile not unlocked" } }; } const id = String(msg["id"] ?? ""); + const tokenSecret = String(msg["token"] ?? ""); + + // Check the token BEFORE decrypting anything. Opening the vault first + // meant an unauthenticated caller could distinguish existing entry ids + // from missing ones (an enumeration oracle), leave no audit record for + // the misses, and burn a full PBKDF2 unlock per probe. + const preAuth = this.authorize(tokenSecret, fingerprint, action); + if (!preAuth.token) { + await this.auditLog(fingerprint, "access_denied", { + action, + entry_id: id, + reason: preAuth.deny, + }); + return { error: { ok: false, error: `denied: ${preAuth.deny}` } }; + } + 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}` } }; + // hasOwn: "__proto__"/"constructor" would otherwise resolve to inherited + // properties and pass the constraint checks against undefined values. + const entry = Object.hasOwn(vault.index.entries, id) + ? (vault.index.entries[id] as Entry) + : undefined; - const auth = this.authorize(String(msg["token"] ?? ""), fingerprint, action, { - kind: entry.kind, - label: entry.label, - }); - if (!auth.token) { + // A denial and a missing entry must be indistinguishable: otherwise the + // response still discloses which ids exist. + const constraintsOk = + entry !== undefined && this.tokenMaySee(preAuth.token, entry); + if (!constraintsOk) { await this.auditLog(fingerprint, "access_denied", { action, entry_id: id, - kind: entry.kind, - reason: auth.deny, + reason: entry === undefined ? "no such entry" : "entry outside token constraints", }); - return { error: { ok: false, error: `denied: ${auth.deny}` } }; + return { + error: { ok: false, error: `denied: no accessible entry ${id}` }, + }; } + const auth = { token: preAuth.token }; if (sinkSpec) { const allowed = auth.token.exec_allowlist; + if (allowed && allowed.length > 0 && sinkSpec.sink === "clipboard") { + // The clipboard is readable by every process in the session, so it + // defeats an allowlist just as thoroughly as an arbitrary command. + await this.auditLog(fingerprint, "access_denied", { + action, + entry_id: id, + reason: "clipboard sink is not permitted by a command-restricted token", + }); + return { + error: { ok: false, error: "denied: clipboard not permitted by this token" }, + }; + } if (sinkSpec.sink !== "clipboard" && allowed && allowed.length > 0) { const [cmd] = parseCommandSpec(sinkSpec.command); if (!allowed.includes(cmd)) { @@ -270,7 +335,23 @@ export class AgentDaemon { } } - const ts = msg["timestamp"] !== undefined ? Number(msg["timestamp"]) : undefined; + // A caller-chosen TOTP timestamp turns one use into a code valid at any + // future moment, outliving the token's TTL entirely. Clamp to a small + // window around now for token callers. + let ts: number | undefined; + if (msg["timestamp"] !== undefined) { + const requested = Number(msg["timestamp"]); + const now = Math.floor(Date.now() / 1000); + if (!Number.isFinite(requested) || Math.abs(requested - now) > TIMESTAMP_SKEW_SECONDS) { + return { + error: { + ok: false, + error: `timestamp must be within ${TIMESTAMP_SKEW_SECONDS}s of now`, + }, + }; + } + ts = requested; + } const secret = materializeSecret(vault.index, id, entry, held.mnemonic, { ...(ts !== undefined && { timestamp: ts }), }); @@ -284,18 +365,45 @@ export class AgentDaemon { if (alive) throw new Error(`agent already running at ${this.socketPath}`); await rm(this.socketPath, { force: true }); } - await new Promise((resolve, reject) => { - this.server.once("error", reject); - this.server.listen(this.socketPath, () => resolve()); - }); + // Unix socket permissions are checked at connect(), so a socket that is + // briefly world-connectable between listen() and chmod() can be grabbed + // in that window. Create it with a restrictive umask instead. + const previousUmask = process.umask(0o177); + try { + await new Promise((resolve, reject) => { + this.server.once("error", reject); + this.server.listen(this.socketPath, () => resolve()); + }); + } finally { + process.umask(previousUmask); + } await chmod(this.socketPath, 0o600); + // A TTL that is only enforced when a request happens is not a TTL: an + // idle agent would hold the seed in memory indefinitely past expiry. + this.expiryTimer = setInterval(() => this.expire(), 1000); + this.expiryTimer.unref?.(); + this.capability = randomBytes(32).toString("base64url"); - await writeFile(this.capabilityPath, this.capability, { mode: 0o600 }); - await chmod(this.capabilityPath, 0o600); + const capPath = this.capabilityPath; + await mkdir(dirname(capPath), { recursive: true, mode: 0o700 }); + // wx: never adopt a file an attacker pre-created with looser modes. + await rm(capPath, { force: true }); + const capHandle = await open(capPath, "wx", 0o600); + try { + await capHandle.write(this.capability); + await capHandle.chmod(0o600); + await capHandle.sync(); + } finally { + await capHandle.close(); + } } async stop(): Promise { + if (this.expiryTimer) { + clearInterval(this.expiryTimer); + this.expiryTimer = null; + } this.held.clear(); this.tokens.clear(); this.audits.clear(); @@ -539,8 +647,24 @@ export class AgentDaemon { } private serve(socket: Socket): void { + if (this.connections >= MAX_CONNECTIONS) { + socket.destroy(); + return; + } + this.connections++; + socket.on("close", () => { + this.connections--; + }); let buffer = ""; socket.on("data", (chunk) => { + // Without a cap, a peer that never sends a newline grows this string + // without bound while every chunk rescans it — gigabytes of RSS and a + // pegged event loop, from an unauthenticated connection. + if (buffer.length + chunk.length > MAX_LINE_BYTES) { + socket.write(JSON.stringify({ ok: false, error: "request too large" }) + "\n"); + socket.destroy(); + return; + } buffer += chunk.toString("utf8"); let nl; while ((nl = buffer.indexOf("\n")) >= 0) { @@ -575,7 +699,7 @@ export class AgentClient { return fromEnv; } try { - this.capability = readFileSync(`${this.socketPath}.cap`, "utf8").trim(); + this.capability = readFileSync(capabilityPathFor(this.socketPath), "utf8").trim(); } catch { this.capability = ""; } @@ -646,6 +770,12 @@ export class AgentClient { return Number(r["locked"] ?? 0); } + /** Owner-only: shut the daemon down, wiping every held seed. */ + async shutdown(): Promise { + const r = await this.ownerRequest({ op: "shutdown" }); + if (!r["ok"]) throw new Error(String(r["error"])); + } + async status(): Promise { const r = await this.ownerRequest({ op: "status" }); if (!r["ok"]) throw new Error(String(r["error"])); @@ -660,6 +790,7 @@ export class AgentClient { labelRegex?: string; ttl?: number; uses?: number; + execAllowlist?: string[]; }): Promise<{ token: string; record: TokenInfo }> { const r = await this.ownerRequest({ op: "token-issue", @@ -670,6 +801,7 @@ export class AgentClient { label_regex: options.labelRegex, ttl: options.ttl, uses: options.uses, + exec_allowlist: options.execAllowlist, }); if (!r["ok"]) throw new Error(String(r["error"])); return { token: String(r["token"]), record: r["record"] as TokenInfo }; diff --git a/js/packages/cli/src/audit.ts b/js/packages/cli/src/audit.ts index fd2efb67..6416722b 100644 --- a/js/packages/cli/src/audit.ts +++ b/js/packages/cli/src/audit.ts @@ -10,6 +10,7 @@ import { appendFile, readFile } from "node:fs/promises"; import { existsSync } from "node:fs"; import { canonicalJson, hmacSha256Hex, utf8 } from "@seedpass/core"; +import { atomicWrite } from "./vaultFile.js"; const GENESIS = "0".repeat(64); @@ -28,8 +29,24 @@ function signPayload(key: Uint8Array, prevSig: string, payload: string): string return hmacSha256Hex(key, utf8(prevSig + payload)); } +/** + * A signed head: the record count and last signature, stored beside the log. + * + * Chain linkage alone only detects edits *within* the file. Deleting the log + * (or a suffix of it) leaves a shorter file that still verifies perfectly — + * an attacker can excise the evidence of their own access. The head pins the + * expected length, and is itself HMAC'd so it cannot simply be rewritten + * without the key. + */ +interface AuditHead { + count: number; + last_sig: string; + mac: string; +} + export class AuditLog { private lastSig = GENESIS; + private count = 0; private loaded = false; constructor( @@ -37,10 +54,24 @@ export class AuditLog { private readonly key: Uint8Array, ) {} + private get headPath(): string { + return `${this.path}.head`; + } + + private headMac(count: number, lastSig: string): string { + return hmacSha256Hex(this.key, utf8(`seedpass-audit-head|${count}|${lastSig}`)); + } + private async loadTail(): Promise { if (this.loaded) return; - this.loaded = true; - if (!existsSync(this.path)) return; + if (!existsSync(this.path)) { + this.loaded = true; + return; + } + // Note the ordering: `loaded` is latched only after the read succeeds. + // Latching first meant a single transient EACCES left lastSig at GENESIS + // and the next append re-signed from genesis mid-file, breaking the + // chain permanently. const lines = (await readFile(this.path, "utf8")).split("\n").filter((l) => l.trim()); const last = lines[lines.length - 1]; if (last) { @@ -50,6 +81,8 @@ export class AuditLog { // keep genesis; verify() will surface corruption } } + this.count = lines.length; + this.loaded = true; } async log(event: string, details: Record = {}): Promise { @@ -59,11 +92,47 @@ export class AuditLog { const sig = signPayload(this.key, this.lastSig, payload); await appendFile(this.path, canonicalJson({ ...entry, sig }) + "\n", { mode: 0o600 }); this.lastSig = sig; + this.count += 1; + const head: AuditHead = { + count: this.count, + last_sig: sig, + mac: this.headMac(this.count, sig), + }; + await atomicWrite(this.headPath, utf8(JSON.stringify(head))); } - /** Verify the whole chain; returns records or throws at the first break. */ + /** + * Verify the whole chain against the signed head. + * + * Returns the records, or throws at the first break — including a length + * or head mismatch, which is what catches deletion rather than editing. + */ static async verify(path: string, key: Uint8Array): Promise { - if (!existsSync(path)) return []; + const headPath = `${path}.head`; + let head: AuditHead | null = null; + if (existsSync(headPath)) { + try { + head = JSON.parse(await readFile(headPath, "utf8")) as AuditHead; + } catch { + throw new Error("audit head is unreadable; the log cannot be trusted"); + } + const expectedMac = hmacSha256Hex( + key, + utf8(`seedpass-audit-head|${head.count}|${head.last_sig}`), + ); + if (expectedMac !== head.mac) { + throw new Error("audit head signature mismatch; the log cannot be trusted"); + } + } + if (!existsSync(path)) { + if (head && head.count > 0) { + throw new Error( + `audit log is missing but its head records ${head.count} entries; ` + + `the log was deleted`, + ); + } + return []; + } const lines = (await readFile(path, "utf8")).split("\n").filter((l) => l.trim()); const records: AuditRecord[] = []; let prevSig = GENESIS; @@ -86,6 +155,17 @@ export class AuditLog { prevSig = record.sig; records.push(record); } + if (head) { + if (records.length !== head.count) { + throw new Error( + `audit log has ${records.length} records but its head records ` + + `${head.count}; ${head.count - records.length} were removed`, + ); + } + if (prevSig !== head.last_sig) { + throw new Error("audit log tail does not match its signed head"); + } + } return records; } } diff --git a/js/packages/cli/src/capabilities.ts b/js/packages/cli/src/capabilities.ts index bb8d97aa..77e12dac 100644 --- a/js/packages/cli/src/capabilities.ts +++ b/js/packages/cli/src/capabilities.ts @@ -38,10 +38,13 @@ export function capabilities(): Record { tokens: { commands: ["agent token-issue/token-list/token-revoke"], scopes: ["read", "use", "reveal"], - constraints: ["kinds", "label_regex", "ttl", "uses"], + constraints: ["kinds", "label_regex", "ttl", "uses", "exec_allowlist"], token_env_var: "SEEDPASS_TOKEN", enforcement: "session agent (secrets materialized agent-side in token mode)", - escalation: "token mode can never fall back to owner access", + escalation: + "token mode refuses owner operations, but this is a same-uid process " + + "boundary: a token holder that can read the owner capability file can " + + "still escalate. See docs/agent_security_model.md.", }, audit: { commands: ["agent audit-verify", "agent audit-tail"], diff --git a/js/packages/cli/src/program.ts b/js/packages/cli/src/program.ts index f8ea25b4..ae1471a9 100644 --- a/js/packages/cli/src/program.ts +++ b/js/packages/cli/src/program.ts @@ -1201,10 +1201,14 @@ export function buildProgram(io: ProgramIo = defaultIo): Command { .option("--label-regex ", "restrict to matching labels", ".*") .option("--ttl ", "token lifetime", "300") .option("--uses ", "max secret deliveries", "1") + .option( + "--exec-allowlist ", + "restrict `use` to these commands (without it, a use-scoped holder can read the secret)", + ) .action( async (o: { name: string; scope: string[]; kind?: string[]; labelRegex: string; - ttl: string; uses: string; + ttl: string; uses: string; execAllowlist?: string[]; }) => { const opts = program.opts() as GlobalOpts; const app = new AppDir(resolveAppDir(opts.appDir)); @@ -1218,6 +1222,7 @@ export function buildProgram(io: ProgramIo = defaultIo): Command { labelRegex: o.labelRegex, ttl: parseIntOption(o.ttl, "--ttl", { min: 1 }), uses: parseIntOption(o.uses, "--uses", { min: 1 }), + ...(o.execAllowlist !== undefined && { execAllowlist: o.execAllowlist }), }); io.out( JSON.stringify( @@ -1294,7 +1299,9 @@ export function buildProgram(io: ProgramIo = defaultIo): Command { .action(async () => { const app = new AppDir(resolveAppDir((program.opts() as GlobalOpts).appDir)); const client = new AgentClient(agentSocketPath(app.root)); - await client.request({ op: "shutdown" }); + // Must authenticate, and must not claim success it did not get: the + // user is being told their seeds were wiped. + await client.shutdown(); io.out(JSON.stringify({ agent: "stopped" })); }); diff --git a/js/packages/cli/src/refs.ts b/js/packages/cli/src/refs.ts index 1314c1b2..6a42d8f0 100644 --- a/js/packages/cli/src/refs.ts +++ b/js/packages/cli/src/refs.ts @@ -61,8 +61,61 @@ export function resolveEntry(index: VaultIndex, refOrQuery: string): ResolvedEnt throw new Error(`no entry matches "${refOrQuery}"`); } +/** + * Fields safe to expose. An allowlist, not a denylist: entry schemas are + * deliberately loose, so a secret-bearing field added by a newer writer (or + * by the Python side) would otherwise pass straight through a denylist. + */ +const VISIBLE_FIELDS = new Set([ + "type", + "kind", + "label", + "archived", + "date_added", + "date_modified", + "modified_ts", + "notes", + "tags", + "links", + "origin", + "index", + "length", + "gen_version", + "username", + "url", + "period", + "digits", + "deterministic", + "word_count", + "fingerprint", + "file_type", + "key", + "key_type", + "user_id", + "policy", +]); + +/** Fields whose presence is reported but whose value never is. */ const SECRET_FIELDS = new Set(["secret", "value", "content"]); +/** + * Custom fields carry user-defined values, and Python masks the ones marked + * `is_hidden`. Their labels are useful metadata; their values are not ours + * to hand out. + */ +function redactCustomFields(value: unknown): unknown { + if (!Array.isArray(value)) return []; + return value.map((field) => { + if (typeof field !== "object" || field === null) return {}; + const f = field as Record; + return { + label: f["label"], + is_hidden: Boolean(f["is_hidden"]), + has_value: f["value"] !== undefined && f["value"] !== "", + }; + }); +} + /** * Metadata view of an entry: everything an agent may see by default. * Secret-bearing fields are replaced by presence flags. @@ -71,10 +124,17 @@ export function entryMetadata(id: string, entry: Entry): Record const out: Record = { id, ref: refFor(id) }; for (const [k, v] of Object.entries(entry)) { if (SECRET_FIELDS.has(k)) { - out[`has_${k}`] = true; + out[`has_${k}`] = v !== undefined && v !== ""; continue; } - out[k] = v; + if (k === "custom_fields") { + out[k] = redactCustomFields(v); + continue; + } + // Anything not explicitly known to be non-secret is reported as present + // rather than shown. + if (VISIBLE_FIELDS.has(k)) out[k] = v; + else out[`has_${k}`] = true; } return out; } diff --git a/js/packages/cli/test/agentSecurity.test.ts b/js/packages/cli/test/agentSecurity.test.ts index 068f5848..723d65b0 100644 --- a/js/packages/cli/test/agentSecurity.test.ts +++ b/js/packages/cli/test/agentSecurity.test.ts @@ -9,6 +9,7 @@ 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"; @@ -261,6 +262,109 @@ describe("sink children do not inherit the vault's secrets", () => { }); }); +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>; + const row = rows.find((x) => x["label"] === "with-fields")!; + const fields = row["custom_fields"] as Array>; + 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>((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); + } + }); + 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(