mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-10-30 09:22:03 +00:00
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:
1 parent
ee330c52f7
commit
dafae50ca4
7 files changed
+428
-35
No files matched your search
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"],
|
||||
|
||||
@@ -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" }));
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user