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]>
This commit is contained in:
thePR0M3TH3ANandClaude Fable 5 committed 2026-08-14 23:58:58 -04:00
1 parent ee330c52f7
commit dafae50ca4
7 files changed
+428 -35

No files matched your search

+9 -2
View File
@@ -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
+155 -23
View File
@@ -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<string, AuditLog>();
private auditQueue: Promise<unknown> = Promise.resolve();
private capability = "";
private expiryTimer: ReturnType<typeof setInterval> | 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<void>((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<void>((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<void> {
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<void> {
const r = await this.ownerRequest({ op: "shutdown" });
if (!r["ok"]) throw new Error(String(r["error"]));
}
async status(): Promise<AgentStatusProfile[]> {
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 };
+84 -4
View File
@@ -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<void> {
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<string, unknown> = {}): Promise<void> {
@@ -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<AuditRecord[]> {
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;
}
}
+5 -2
View File
@@ -38,10 +38,13 @@ export function capabilities(): Record<string, unknown> {
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"],
+9 -2
View File
@@ -1201,10 +1201,14 @@ export function buildProgram(io: ProgramIo = defaultIo): Command {
.option("--label-regex <re>", "restrict to matching labels", ".*")
.option("--ttl <seconds>", "token lifetime", "300")
.option("--uses <n>", "max secret deliveries", "1")
.option(
"--exec-allowlist <cmd...>",
"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" }));
});
+62 -2
View File
@@ -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<string, unknown>;
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<string, unknown>
const out: Record<string, unknown> = { 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;
}
+104
View File
@@ -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<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(