mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-11-01 18:32:05 +00:00
Both were found by the 2026-08-17 self-review and confirmed by speaking to the socket directly. The daemon is the enforcement point for the whole token model and treats every caller as untrusted, so neither fix leans on the CLI's own validation. 1. A `put` reported as FAILED could leave the seed resident and unexpiring. State was committed to `held` before the audit append was awaited. A non-numeric ttl made `expiresAt` NaN; the audit write then threw on the non-finite value, so the caller saw ok:false while the seed stayed in memory forever (`NaN <= now` is always false) with no record it was ever unlocked. Now ttl is validated as a positive integer at the boundary, and an audit failure rolls the seed back out — ok:false means genuinely locked. 2. A `use`-scoped token could crash the whole agent. `stdinSink` wrote to the child's stdin with no error listener, so a command that exits before draining raised an unhandled EPIPE; the agent runs sinks in-process and installs no uncaughtException handler, so it exited and dropped every held seed. The least-privileged principal could take down the enforcement point by naming a command that exits early. stdin errors are now swallowed and the child's exit code carries the result, as feedClipboardTool already did. Also hardens token-issue: scopes/kinds are shape-checked before the array methods run against them (a bare-string kinds turned an exact match into a substring match), and ttl/uses go through the same positive-integer boundary so a token can no longer be minted with a NaN, never-expiring lifetime. Three regression tests speak the wire protocol directly. Verified as negative controls: with the fixes stashed, the stdin test reproduces the EPIPE uncaught exception at sinks.ts:143 and two of the three fail; with them restored all 131 CLI tests pass. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Tkj4rzKBA3uhfgk7kqjsb8
924 lines
35 KiB
TypeScript
924 lines
35 KiB
TypeScript
/**
|
|
* Session agent: a small daemon that holds unlocked parent seeds in memory
|
|
* with a TTL, so the CLI can be used across invocations without exporting
|
|
* the mnemonic into every process environment (ssh-agent model).
|
|
*
|
|
* Transport: unix domain socket (0600), one JSON object per line.
|
|
*
|
|
* Two classes of caller, distinguished by the server — not by the client:
|
|
*
|
|
* - Owner ops (put, owner-mnemonic, token-issue/list/revoke, lock, status,
|
|
* shutdown) require the capability secret written to a 0600 file beside
|
|
* the socket. Socket permissions alone cannot tell the owner's CLI apart
|
|
* from a scoped agent that merely holds a token.
|
|
* - Token ops (vault-index, secret, use-sink) require a bearer token and
|
|
* are constrained by its scopes. vault-index returns redacted metadata,
|
|
* never the decrypted index; `secret` requires the reveal scope; and
|
|
* `use-sink` runs the sink here so plaintext never crosses back.
|
|
*
|
|
* This process is the enforcement point for the plan-section-9.3 lease and
|
|
* token model. Enforcement must live here because the CLI is untrusted from
|
|
* the daemon's point of view: any local process can speak this protocol.
|
|
*/
|
|
|
|
import { createServer, createConnection, type Socket } from "node:net";
|
|
import { chmod, mkdir, open, rm } from "node:fs/promises";
|
|
import { existsSync, readFileSync } from "node:fs";
|
|
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";
|
|
import { openVault } from "./vaultFile.js";
|
|
import { materializeSecret } from "./secrets.js";
|
|
import { entryMetadata } from "./refs.js";
|
|
import {
|
|
clipboardSink,
|
|
execSink,
|
|
parseCommandSpec,
|
|
stdinSink,
|
|
type SinkResult,
|
|
} from "./sinks.js";
|
|
import { AuditLog } from "./audit.js";
|
|
import { INDEX_FILENAME } from "./appDir.js";
|
|
|
|
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;
|
|
|
|
/**
|
|
* A positive integer taken from a wire message, or null if the value is not
|
|
* one. The CLI validates these too, but the daemon cannot rely on that: it
|
|
* treats every caller as untrusted (see the module header), and the next
|
|
* thing to speak this protocol will be a browser extension.
|
|
*
|
|
* Coercions that must be rejected here rather than stored: a non-numeric ttl
|
|
* makes `expiresAt` NaN, and `NaN <= now` is always false — the seed would
|
|
* then never expire. The same NaN in a token's `expires_at` makes a token
|
|
* that never expires.
|
|
*/
|
|
function positiveIntField(value: unknown, fallback: number): number | null {
|
|
const n = value === undefined || value === null ? fallback : Number(value);
|
|
return Number.isInteger(n) && n >= 1 ? n : null;
|
|
}
|
|
|
|
export type TokenScope = "read" | "use" | "reveal";
|
|
|
|
export interface TokenRecord {
|
|
id: string;
|
|
name: string;
|
|
fingerprint: string;
|
|
secret_hash: string;
|
|
scopes: TokenScope[];
|
|
kinds: string[] | null;
|
|
label_regex: string;
|
|
expires_at: number;
|
|
uses_remaining: number;
|
|
revoked: boolean;
|
|
/**
|
|
* Commands this token may hand a secret to. Empty/absent means any.
|
|
*
|
|
* A `use`-scoped holder who picks the command can always read the secret
|
|
* from inside it, so this is the only mechanism that actually contains a
|
|
* `use` grant rather than merely recording it.
|
|
*/
|
|
exec_allowlist?: string[];
|
|
}
|
|
|
|
export type TokenInfo = Omit<TokenRecord, "secret_hash">;
|
|
|
|
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;
|
|
}
|
|
|
|
export interface AgentStatusProfile {
|
|
fingerprint: string;
|
|
expires_at: number;
|
|
}
|
|
|
|
/** Operations only the owner may invoke, proven by the capability secret. */
|
|
const OWNER_OPS = new Set([
|
|
"put",
|
|
"owner-mnemonic",
|
|
"token-issue",
|
|
"token-list",
|
|
"token-revoke",
|
|
"lock",
|
|
"status",
|
|
"shutdown",
|
|
]);
|
|
|
|
export class AgentDaemon {
|
|
private held = new Map<string, Held>();
|
|
private tokens = new Map<string, TokenRecord>();
|
|
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(
|
|
private readonly socketPath: string,
|
|
private readonly defaultTtl = DEFAULT_TTL_SECONDS,
|
|
/** Profile root; required for token-mode vault/secret serving. */
|
|
private readonly appDir?: string,
|
|
) {}
|
|
|
|
/**
|
|
* 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 capabilityPathFor(this.socketPath);
|
|
}
|
|
|
|
/**
|
|
* Owner authentication.
|
|
*
|
|
* The socket's 0600 mode proves only "some process running as this user";
|
|
* it does not distinguish the owner's CLI from a scoped agent that was
|
|
* handed a token. Privileged operations therefore require a capability
|
|
* secret written to a 0600 file at startup, which raises the bar to
|
|
* "can read the owner's files" — the same bar as the vault itself.
|
|
*
|
|
* Residual risk, stated plainly: a same-uid attacker who can read that
|
|
* file (or ptrace this process) still wins. Defending against that
|
|
* requires an OS-level boundary this daemon cannot provide.
|
|
*/
|
|
private isOwner(msg: Record<string, unknown>): boolean {
|
|
const presented = String(msg["cap"] ?? "");
|
|
if (!presented || !this.capability) return false;
|
|
const a = Buffer.from(presented);
|
|
const b = Buffer.from(this.capability);
|
|
return a.length === b.length && timingSafeEqual(a, b);
|
|
}
|
|
|
|
/** Serialize audit appends so concurrent requests cannot break the chain. */
|
|
private auditLog(
|
|
fingerprint: string,
|
|
event: string,
|
|
details: Record<string, unknown>,
|
|
): Promise<void> {
|
|
const log = this.audit(fingerprint);
|
|
if (!log) return Promise.resolve();
|
|
this.auditQueue = this.auditQueue.then(
|
|
() => log.log(event, details),
|
|
() => log.log(event, details),
|
|
);
|
|
return this.auditQueue as Promise<void>;
|
|
}
|
|
|
|
private audit(fingerprint: string): AuditLog | null {
|
|
if (!this.appDir) return null;
|
|
const held = this.held.get(fingerprint);
|
|
if (!held) return null;
|
|
let log = this.audits.get(fingerprint);
|
|
if (!log) {
|
|
log = new AuditLog(
|
|
join(this.appDir, fingerprint, "audit.log"),
|
|
deriveKeyIndex(held.mnemonic),
|
|
);
|
|
this.audits.set(fingerprint, log);
|
|
}
|
|
return log;
|
|
}
|
|
|
|
/**
|
|
* Validate a bearer token for an action against an entry, consuming one
|
|
* use on success. Returns the token record or a denial reason.
|
|
*/
|
|
private authorize(
|
|
tokenSecret: string,
|
|
fingerprint: string,
|
|
action: TokenScope,
|
|
entry?: { kind: string; label: string },
|
|
): { token?: TokenRecord; deny?: string } {
|
|
const hash = sha256Hex(utf8(tokenSecret));
|
|
const token = [...this.tokens.values()].find(
|
|
(t) => t.secret_hash === hash && t.fingerprint === fingerprint,
|
|
);
|
|
if (!token) return { deny: "unknown token" };
|
|
if (token.revoked) return { deny: "token revoked" };
|
|
if (token.expires_at <= Date.now() / 1000) return { deny: "token expired" };
|
|
if (token.uses_remaining <= 0) return { deny: "token exhausted" };
|
|
if (!token.scopes.includes(action)) return { deny: `scope '${action}' not granted` };
|
|
if (entry) {
|
|
if (token.kinds && !token.kinds.includes(entry.kind)) {
|
|
return { deny: `kind '${entry.kind}' not granted` };
|
|
}
|
|
if (!new RegExp(token.label_regex).test(entry.label)) {
|
|
return { deny: "label not matched by token constraint" };
|
|
}
|
|
}
|
|
// "read" is not consumption; secret-bearing actions decrement uses
|
|
if (action !== "read") token.uses_remaining -= 1;
|
|
return { token };
|
|
}
|
|
|
|
/** Would this token be allowed to act on this entry at all? */
|
|
private tokenMaySee(token: TokenRecord, entry: { kind: string; label: string }): boolean {
|
|
if (token.kinds && !token.kinds.includes(entry.kind)) return false;
|
|
try {
|
|
return new RegExp(token.label_regex).test(entry.label);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Common path for token-authorized secret access: open the vault, find the
|
|
* entry, authorize the action, and materialize the secret. Never returns
|
|
* the secret to a caller that was not authorized for the exact action.
|
|
*/
|
|
private async resolveForToken(
|
|
msg: Record<string, unknown>,
|
|
fingerprint: string,
|
|
action: Exclude<TokenScope, "read">,
|
|
sinkSpec?: { sink: string; command: string[] },
|
|
): Promise<
|
|
| { error: Record<string, unknown> }
|
|
| {
|
|
entry: Entry;
|
|
id: string;
|
|
token: TokenRecord;
|
|
secret: { value: string; descriptor: string };
|
|
}
|
|
> {
|
|
const held = this.held.get(fingerprint);
|
|
if (!held || !this.appDir) {
|
|
return { error: { ok: false, error: "profile not unlocked" } };
|
|
}
|
|
const id = String(msg["id"] ?? "");
|
|
const 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,
|
|
);
|
|
// 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;
|
|
|
|
// 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,
|
|
reason: entry === undefined ? "no such entry" : "entry outside token constraints",
|
|
});
|
|
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)) {
|
|
await this.auditLog(fingerprint, "access_denied", {
|
|
action,
|
|
entry_id: id,
|
|
reason: `command '${cmd}' not in the token's exec allowlist`,
|
|
});
|
|
return {
|
|
error: { ok: false, error: `denied: command '${cmd}' not permitted by this token` },
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 }),
|
|
});
|
|
return { entry, id, token: auth.token, secret };
|
|
}
|
|
|
|
async start(): Promise<void> {
|
|
if (existsSync(this.socketPath)) {
|
|
// A live agent refuses to be replaced; a stale socket file is cleaned.
|
|
const alive = await AgentClient.ping(this.socketPath);
|
|
if (alive) throw new Error(`agent already running at ${this.socketPath}`);
|
|
await rm(this.socketPath, { force: true });
|
|
}
|
|
// 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");
|
|
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();
|
|
this.capability = "";
|
|
await new Promise<void>((resolve) => this.server.close(() => resolve()));
|
|
await rm(this.socketPath, { force: true });
|
|
await rm(this.capabilityPath, { force: true });
|
|
}
|
|
|
|
/** Drop everything derived from a profile's seed. */
|
|
private forget(fingerprint: string): void {
|
|
this.held.delete(fingerprint);
|
|
// Tokens are only meaningful while the profile is unlocked; leaving them
|
|
// resident would let them spring back to life on the next unlock.
|
|
for (const [id, token] of this.tokens) {
|
|
if (token.fingerprint === fingerprint) this.tokens.delete(id);
|
|
}
|
|
// The audit logger holds a key derived from the seed.
|
|
this.audits.delete(fingerprint);
|
|
}
|
|
|
|
private expire(): void {
|
|
const now = Date.now() / 1000;
|
|
for (const [fp, held] of this.held) {
|
|
if (held.expiresAt <= now) this.forget(fp);
|
|
}
|
|
}
|
|
|
|
private async handle(msg: Record<string, unknown>): Promise<Record<string, unknown>> {
|
|
this.expire();
|
|
// Reject non-string credentials and identifiers rather than coercing
|
|
// them. String(["abc"]) is "abc", so an array would sail through a
|
|
// String() comparison; nothing good comes of accepting these shapes.
|
|
for (const field of ["op", "cap", "token", "fingerprint", "id", "sink"]) {
|
|
const value = msg[field];
|
|
if (value !== undefined && typeof value !== "string") {
|
|
return { ok: false, error: `field '${field}' must be a string` };
|
|
}
|
|
}
|
|
if (msg["command"] !== undefined) {
|
|
const command = msg["command"];
|
|
if (!Array.isArray(command) || command.some((c) => typeof c !== "string")) {
|
|
return { ok: false, error: "field 'command' must be an array of strings" };
|
|
}
|
|
}
|
|
const op = String(msg["op"] ?? "");
|
|
if (OWNER_OPS.has(op) && !this.isOwner(msg)) {
|
|
// Do not reveal whether the profile exists or is unlocked.
|
|
return { ok: false, error: "owner capability required" };
|
|
}
|
|
switch (op) {
|
|
case "token-issue": {
|
|
// Owner-only op: local socket (0600) is the trust boundary, and the
|
|
// profile must currently be unlocked in this agent.
|
|
const fingerprint = String(msg["fingerprint"] ?? "");
|
|
if (!this.held.has(fingerprint)) return { ok: false, error: "profile not unlocked" };
|
|
const scopes = msg["scopes"] ?? ["read"];
|
|
// Shape-check before .filter/.includes run against them: a bare string
|
|
// scope would throw a raw TypeError back to the caller, and a string
|
|
// `kinds` turns the exact kind match in tokenMaySee into a substring
|
|
// match (String.includes, not Array.includes).
|
|
if (!Array.isArray(scopes)) return { ok: false, error: "scopes must be an array" };
|
|
const bad = (scopes as unknown[]).filter(
|
|
(s) => !["read", "use", "reveal"].includes(s as string),
|
|
);
|
|
if (bad.length) return { ok: false, error: `unknown scopes: ${bad.join(",")}` };
|
|
const kinds = msg["kinds"];
|
|
if (kinds !== undefined && kinds !== null && !Array.isArray(kinds)) {
|
|
return { ok: false, error: "kinds must be an array" };
|
|
}
|
|
const ttl = positiveIntField(msg["ttl"], 300);
|
|
if (ttl === null) return { ok: false, error: "ttl must be a positive integer" };
|
|
const uses = positiveIntField(msg["uses"], 1);
|
|
if (uses === null) return { ok: false, error: "uses must be a positive integer" };
|
|
const secret = randomBytes(24).toString("base64url");
|
|
const record: TokenRecord = {
|
|
id: `tok-${randomBytes(6).toString("hex")}`,
|
|
name: String(msg["name"] ?? "agent"),
|
|
fingerprint,
|
|
secret_hash: sha256Hex(utf8(secret)),
|
|
scopes: scopes as TokenScope[],
|
|
kinds: (kinds as string[] | undefined) ?? null,
|
|
label_regex: String(msg["label_regex"] ?? ".*"),
|
|
expires_at: Math.floor(Date.now() / 1000 + ttl),
|
|
uses_remaining: uses,
|
|
revoked: false,
|
|
...(Array.isArray(msg["exec_allowlist"]) && (msg["exec_allowlist"] as string[]).length
|
|
? { exec_allowlist: msg["exec_allowlist"] as string[] }
|
|
: {}),
|
|
};
|
|
this.tokens.set(record.id, record);
|
|
await this.auditLog(fingerprint, "token_issued", {
|
|
token_id: record.id,
|
|
name: record.name,
|
|
scopes: record.scopes,
|
|
kinds: record.kinds,
|
|
label_regex: record.label_regex,
|
|
uses: record.uses_remaining,
|
|
exec_allowlist: record.exec_allowlist ?? null,
|
|
});
|
|
// The plaintext token is returned exactly once, at issuance.
|
|
return { ok: true, token: secret, record: { ...record, secret_hash: undefined } };
|
|
}
|
|
case "token-list": {
|
|
const fingerprint = String(msg["fingerprint"] ?? "");
|
|
const list = [...this.tokens.values()]
|
|
.filter((t) => t.fingerprint === fingerprint)
|
|
.map(({ secret_hash: _hash, ...info }) => info);
|
|
return { ok: true, tokens: list };
|
|
}
|
|
case "token-revoke": {
|
|
const token = this.tokens.get(String(msg["token_id"] ?? ""));
|
|
if (!token) return { ok: false, error: "unknown token id" };
|
|
token.revoked = true;
|
|
await this.auditLog(token.fingerprint, "token_revoked", { token_id: token.id });
|
|
return { ok: true };
|
|
}
|
|
case "vault-index": {
|
|
// Token-mode read. Returns REDACTED metadata only: handing back the
|
|
// decrypted index would give a read-scoped token every imported TOTP
|
|
// secret, key-value value and document body in the vault.
|
|
const fingerprint = String(msg["fingerprint"] ?? "");
|
|
const held = this.held.get(fingerprint);
|
|
if (!held || !this.appDir) return { ok: false, error: "profile not unlocked" };
|
|
const auth = this.authorize(String(msg["token"] ?? ""), fingerprint, "read");
|
|
if (!auth.token) {
|
|
await this.auditLog(fingerprint, "access_denied", {
|
|
action: "read",
|
|
reason: auth.deny,
|
|
});
|
|
return { ok: false, error: `denied: ${auth.deny}` };
|
|
}
|
|
const vault = await openVault(
|
|
join(this.appDir, fingerprint, INDEX_FILENAME),
|
|
held.mnemonic,
|
|
);
|
|
// Only entries the token could actually act on are listed at all.
|
|
const rows = Object.entries(vault.index.entries)
|
|
.filter(([, e]) => this.tokenMaySee(auth.token!, e))
|
|
.map(([id, e]) => entryMetadata(id, e));
|
|
await this.auditLog(fingerprint, "index_read", {
|
|
token_id: auth.token.id,
|
|
entries_returned: rows.length,
|
|
});
|
|
return { ok: true, entries: rows, schema_version: vault.index.schema_version };
|
|
}
|
|
case "secret": {
|
|
// Plaintext egress. Requires the `reveal` scope specifically: `use`
|
|
// must not be able to pull a secret back across the socket.
|
|
const fingerprint = String(msg["fingerprint"] ?? "");
|
|
const resolved = await this.resolveForToken(msg, fingerprint, "reveal");
|
|
if ("error" in resolved) return resolved.error;
|
|
const { entry, id, secret, token } = resolved;
|
|
await this.auditLog(fingerprint, "secret_revealed", {
|
|
entry_id: id,
|
|
kind: entry.kind,
|
|
label: entry.label,
|
|
token_id: token.id,
|
|
uses_remaining: token.uses_remaining,
|
|
});
|
|
return { ok: true, value: secret.value, descriptor: secret.descriptor };
|
|
}
|
|
case "use-sink": {
|
|
// The `use` scope delivers a secret to a sink WITHOUT returning it.
|
|
// The child process runs here, in the agent, so the plaintext never
|
|
// crosses the socket back to the token holder.
|
|
const fingerprint = String(msg["fingerprint"] ?? "");
|
|
const sink = String(msg["sink"] ?? "");
|
|
const command = (msg["command"] as string[] | undefined) ?? [];
|
|
const resolved = await this.resolveForToken(msg, fingerprint, "use", {
|
|
sink,
|
|
command,
|
|
});
|
|
if ("error" in resolved) return resolved.error;
|
|
const { entry, id, secret, token } = resolved;
|
|
|
|
let result: SinkResult;
|
|
try {
|
|
if (sink === "clipboard") {
|
|
result = await clipboardSink(secret.value);
|
|
} else {
|
|
const [cmd, args] = parseCommandSpec(command);
|
|
result =
|
|
sink === "exec"
|
|
? await execSink(secret.value, cmd, args)
|
|
: await stdinSink(secret.value, cmd, args);
|
|
}
|
|
} catch (e) {
|
|
await this.auditLog(fingerprint, "delivery_failed", {
|
|
entry_id: id,
|
|
token_id: token.id,
|
|
sink,
|
|
command,
|
|
reason: String(e),
|
|
});
|
|
return { ok: false, error: `sink failed: ${String(e)}` };
|
|
}
|
|
|
|
await this.auditLog(fingerprint, "secret_delivered", {
|
|
entry_id: id,
|
|
kind: entry.kind,
|
|
label: entry.label,
|
|
token_id: token.id,
|
|
uses_remaining: token.uses_remaining,
|
|
sink,
|
|
// Record exactly what received the secret. A `use` token holder who
|
|
// chooses the command can still read the value from inside it —
|
|
// containment comes from the exec allowlist, accountability from here.
|
|
command,
|
|
});
|
|
return { ok: true, descriptor: secret.descriptor, ...result };
|
|
}
|
|
case "put": {
|
|
const fingerprint = String(msg["fingerprint"] ?? "");
|
|
const mnemonic = String(msg["mnemonic"] ?? "");
|
|
if (!fingerprint || !mnemonic) return { ok: false, error: "missing fields" };
|
|
const ttl = positiveIntField(msg["ttl"], this.defaultTtl);
|
|
if (ttl === null) return { ok: false, error: "ttl must be a positive integer" };
|
|
const expiresAt = Math.floor(Date.now() / 1000 + ttl);
|
|
// The audit key is derived from the held seed, so the seed must go in
|
|
// before the record is written — but an audit failure must then undo
|
|
// it. Otherwise a `put` that reports ok:false can leave the seed
|
|
// resident and unexpiring, with nothing on record that it was ever
|
|
// unlocked. `ok:false` has to mean the vault is genuinely still locked.
|
|
this.held.set(fingerprint, { mnemonic, expiresAt });
|
|
try {
|
|
await this.auditLog(fingerprint, "vault_unlocked", { ttl });
|
|
} catch (e) {
|
|
this.forget(fingerprint);
|
|
throw e;
|
|
}
|
|
return { ok: true, expires_at: expiresAt };
|
|
}
|
|
case "owner-mnemonic": {
|
|
// Owner-capability gated: this hands back the parent seed so the
|
|
// owner's CLI can operate the vault locally. It is deliberately NOT
|
|
// reachable with a scoped token.
|
|
const held = this.held.get(String(msg["fingerprint"] ?? ""));
|
|
return held ? { ok: true, mnemonic: held.mnemonic } : { ok: false, error: "locked" };
|
|
}
|
|
case "lock": {
|
|
const fp = msg["fingerprint"];
|
|
if (fp === undefined) {
|
|
const n = this.held.size;
|
|
for (const held of [...this.held.keys()]) this.forget(held);
|
|
return { ok: true, locked: n };
|
|
}
|
|
const key = String(fp);
|
|
const had = this.held.has(key);
|
|
this.forget(key);
|
|
return { ok: true, locked: had ? 1 : 0 };
|
|
}
|
|
case "status": {
|
|
const profiles: AgentStatusProfile[] = [...this.held.entries()].map(
|
|
([fingerprint, h]) => ({ fingerprint, expires_at: h.expiresAt }),
|
|
);
|
|
return { ok: true, profiles };
|
|
}
|
|
case "ping":
|
|
return { ok: true, pong: true };
|
|
case "shutdown":
|
|
setImmediate(() => void this.stop().then(() => process.exit(0)));
|
|
return { ok: true };
|
|
default:
|
|
return { ok: false, error: `unknown op ${String(msg["op"])}` };
|
|
}
|
|
}
|
|
|
|
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) {
|
|
const line = buffer.slice(0, nl);
|
|
buffer = buffer.slice(nl + 1);
|
|
void (async () => {
|
|
let reply: Record<string, unknown>;
|
|
try {
|
|
reply = await this.handle(JSON.parse(line) as Record<string, unknown>);
|
|
} catch (e) {
|
|
reply = { ok: false, error: String(e) };
|
|
}
|
|
socket.write(JSON.stringify(reply) + "\n");
|
|
})();
|
|
}
|
|
});
|
|
socket.on("error", () => socket.destroy());
|
|
}
|
|
}
|
|
|
|
export class AgentClient {
|
|
private capability: string | null = null;
|
|
|
|
constructor(private readonly socketPath: string) {}
|
|
|
|
/** Owner capability, read from the 0600 file the daemon writes. */
|
|
private ownerCapability(): string {
|
|
if (this.capability !== null) return this.capability;
|
|
const fromEnv = process.env["SEEDPASS_AGENT_CAP"];
|
|
if (fromEnv) {
|
|
this.capability = fromEnv;
|
|
return fromEnv;
|
|
}
|
|
try {
|
|
this.capability = readFileSync(capabilityPathFor(this.socketPath), "utf8").trim();
|
|
} catch {
|
|
this.capability = "";
|
|
}
|
|
return this.capability;
|
|
}
|
|
|
|
private ownerRequest(msg: Record<string, unknown>): Promise<Record<string, unknown>> {
|
|
return this.request({ ...msg, cap: this.ownerCapability() });
|
|
}
|
|
|
|
static async ping(socketPath: string): Promise<boolean> {
|
|
try {
|
|
await new AgentClient(socketPath).request({ op: "ping" });
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async request(msg: Record<string, unknown>): Promise<Record<string, unknown>> {
|
|
return new Promise((resolve, reject) => {
|
|
const socket = createConnection(this.socketPath);
|
|
const timer = setTimeout(() => {
|
|
socket.destroy();
|
|
reject(new Error("agent request timed out"));
|
|
}, 2000);
|
|
let buffer = "";
|
|
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();
|
|
try {
|
|
resolve(JSON.parse(buffer.slice(0, nl)) as Record<string, unknown>);
|
|
} catch (e) {
|
|
reject(e instanceof Error ? e : new Error(String(e)));
|
|
}
|
|
}
|
|
});
|
|
socket.on("error", (e) => {
|
|
clearTimeout(timer);
|
|
// "No agent running" is an ordinary state, not a fault, and it is the
|
|
// first thing a new user hits. Raw `connect ENOENT /…/agent.sock`
|
|
// makes them go read the source to find out what to do about it.
|
|
const code = (e as NodeJS.ErrnoException).code;
|
|
if (code === "ENOENT" || code === "ECONNREFUSED") {
|
|
reject(
|
|
new Error(
|
|
"no session agent is running (start one with 'seedpass-js agent start')",
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
reject(e);
|
|
});
|
|
});
|
|
}
|
|
|
|
async put(fingerprint: string, mnemonic: string, ttl?: number): Promise<number> {
|
|
const r = await this.ownerRequest({ op: "put", fingerprint, mnemonic, ...(ttl && { ttl }) });
|
|
if (!r["ok"]) throw new Error(String(r["error"]));
|
|
return Number(r["expires_at"]);
|
|
}
|
|
|
|
/** Owner-only: fetch the parent seed for local vault operations. */
|
|
async ownerMnemonic(fingerprint: string): Promise<string | null> {
|
|
try {
|
|
const r = await this.ownerRequest({ op: "owner-mnemonic", fingerprint });
|
|
return r["ok"] ? String(r["mnemonic"]) : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async lock(fingerprint?: string): Promise<number> {
|
|
const r = await this.ownerRequest({ op: "lock", ...(fingerprint && { fingerprint }) });
|
|
if (!r["ok"]) throw new Error(String(r["error"]));
|
|
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"]));
|
|
return (r["profiles"] ?? []) as AgentStatusProfile[];
|
|
}
|
|
|
|
async tokenIssue(options: {
|
|
fingerprint: string;
|
|
name?: string;
|
|
scopes?: TokenScope[];
|
|
kinds?: string[];
|
|
labelRegex?: string;
|
|
ttl?: number;
|
|
uses?: number;
|
|
execAllowlist?: string[];
|
|
}): Promise<{ token: string; record: TokenInfo }> {
|
|
const r = await this.ownerRequest({
|
|
op: "token-issue",
|
|
fingerprint: options.fingerprint,
|
|
name: options.name,
|
|
scopes: options.scopes,
|
|
kinds: options.kinds,
|
|
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 };
|
|
}
|
|
|
|
async tokenList(fingerprint: string): Promise<TokenInfo[]> {
|
|
const r = await this.ownerRequest({ op: "token-list", fingerprint });
|
|
if (!r["ok"]) throw new Error(String(r["error"]));
|
|
return r["tokens"] as TokenInfo[];
|
|
}
|
|
|
|
async tokenRevoke(tokenId: string): Promise<void> {
|
|
const r = await this.ownerRequest({ op: "token-revoke", token_id: tokenId });
|
|
if (!r["ok"]) throw new Error(String(r["error"]));
|
|
}
|
|
|
|
/** Redacted entry metadata the token is allowed to see. */
|
|
async vaultEntries(
|
|
fingerprint: string,
|
|
token: string,
|
|
): Promise<Record<string, unknown>[]> {
|
|
const r = await this.request({ op: "vault-index", fingerprint, token });
|
|
if (!r["ok"]) throw new Error(String(r["error"]));
|
|
return r["entries"] as Record<string, unknown>[];
|
|
}
|
|
|
|
/** Ask the agent to deliver a secret to a sink; plaintext stays inside it. */
|
|
async useSink(options: {
|
|
fingerprint: string;
|
|
id: string;
|
|
token: string;
|
|
sink: "clipboard" | "exec" | "stdin";
|
|
command: string[];
|
|
timestamp?: number;
|
|
}): Promise<Record<string, unknown>> {
|
|
const r = await this.request({
|
|
op: "use-sink",
|
|
fingerprint: options.fingerprint,
|
|
id: options.id,
|
|
token: options.token,
|
|
sink: options.sink,
|
|
command: options.command,
|
|
...(options.timestamp !== undefined && { timestamp: options.timestamp }),
|
|
});
|
|
if (!r["ok"]) throw new Error(String(r["error"]));
|
|
return r;
|
|
}
|
|
|
|
async secret(options: {
|
|
fingerprint: string;
|
|
id: string;
|
|
token: string;
|
|
timestamp?: number;
|
|
}): Promise<{ value: string; descriptor: string }> {
|
|
const r = await this.request({
|
|
op: "secret",
|
|
fingerprint: options.fingerprint,
|
|
id: options.id,
|
|
token: options.token,
|
|
...(options.timestamp !== undefined && { timestamp: options.timestamp }),
|
|
});
|
|
if (!r["ok"]) throw new Error(String(r["error"]));
|
|
return { value: String(r["value"]), descriptor: String(r["descriptor"]) };
|
|
}
|
|
}
|