mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-11-08 22:02:24 +00:00
TS port slice 10: scoped tokens, agent-side enforcement, audit chain
The section-9.3 permission model is now working code, enforced where the keys live: - Token model (aligned with the Python agent surface): bearer tokens with read/use/reveal scopes, entry-kind and label-regex constraints, TTL and use counts. Issued and held by the session agent — hash-stored, plaintext shown exactly once at issuance; list/revoke commands. - Token mode: with SEEDPASS_TOKEN set (and no mnemonic), the CLI reads the decrypted index and materializes secrets AGENT-SIDE. The constrained process never receives the mnemonic, and resolveMnemonic refuses owner fallback in token mode — a token can never silently escalate (the test suite caught exactly this hole and it is now closed). - Denials are precise and audited: wrong scope, wrong kind, unmatched label, expired, exhausted, revoked. Reads don't consume uses; secret deliveries do. - Audit chain in Python AuditLogger's scheme: sig = HMAC-SHA256(key_index, prev_sig + canonical_payload), genesis 64 zeros, per-profile audit.log; agent records unlocks, token lifecycle, index reads, deliveries and denials (never secret values). audit-verify/audit-tail commands; a forged record breaks the chain in tests. - 151 core + 41 CLI tests green. Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
1 parent
e84a7e1c0e
commit
274bc0e1b8
7 files changed
+710
-28
No files matched your search
@@ -18,11 +18,34 @@
|
||||
import { createServer, createConnection, type Socket } from "node:net";
|
||||
import { chmod, rm } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { randomBytes } from "node:crypto";
|
||||
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 { AuditLog } from "./audit.js";
|
||||
import { INDEX_FILENAME } from "./appDir.js";
|
||||
|
||||
export const DEFAULT_TTL_SECONDS = 900;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export type TokenInfo = Omit<TokenRecord, "secret_hash">;
|
||||
|
||||
export function agentSocketPath(appDir: string): string {
|
||||
return process.env["SEEDPASS_AGENT_SOCK"] ?? join(appDir, "agent.sock");
|
||||
}
|
||||
@@ -39,13 +62,64 @@ export interface AgentStatusProfile {
|
||||
|
||||
export class AgentDaemon {
|
||||
private held = new Map<string, Held>();
|
||||
private tokens = new Map<string, TokenRecord>();
|
||||
private audits = new Map<string, AuditLog>();
|
||||
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,
|
||||
) {}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (existsSync(this.socketPath)) {
|
||||
// A live agent refuses to be replaced; a stale socket file is cleaned.
|
||||
@@ -73,9 +147,112 @@ export class AgentDaemon {
|
||||
}
|
||||
}
|
||||
|
||||
private handle(msg: Record<string, unknown>): Record<string, unknown> {
|
||||
private async handle(msg: Record<string, unknown>): Promise<Record<string, unknown>> {
|
||||
this.expire();
|
||||
switch (msg["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"] as TokenScope[] | undefined) ?? ["read"];
|
||||
const bad = scopes.filter((s) => !["read", "use", "reveal"].includes(s));
|
||||
if (bad.length) return { ok: false, error: `unknown scopes: ${bad.join(",")}` };
|
||||
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,
|
||||
kinds: (msg["kinds"] as string[] | undefined) ?? null,
|
||||
label_regex: String(msg["label_regex"] ?? ".*"),
|
||||
expires_at: Math.floor(Date.now() / 1000 + Number(msg["ttl"] ?? 300)),
|
||||
uses_remaining: Number(msg["uses"] ?? 1),
|
||||
revoked: false,
|
||||
};
|
||||
this.tokens.set(record.id, record);
|
||||
await this.audit(fingerprint)?.log("token_issued", {
|
||||
token_id: record.id,
|
||||
name: record.name,
|
||||
scopes: record.scopes,
|
||||
kinds: record.kinds,
|
||||
label_regex: record.label_regex,
|
||||
uses: record.uses_remaining,
|
||||
});
|
||||
// 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.audit(token.fingerprint)?.log("token_revoked", { token_id: token.id });
|
||||
return { ok: true };
|
||||
}
|
||||
case "vault-index": {
|
||||
// Token-mode read of the decrypted index (metadata access).
|
||||
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.audit(fingerprint)?.log("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,
|
||||
);
|
||||
await this.audit(fingerprint)?.log("index_read", { token_id: auth.token.id });
|
||||
return { ok: true, index: vault.index };
|
||||
}
|
||||
case "secret": {
|
||||
// Token-mode secret materialization for a single entry.
|
||||
const fingerprint = String(msg["fingerprint"] ?? "");
|
||||
const held = this.held.get(fingerprint);
|
||||
if (!held || !this.appDir) return { ok: false, error: "profile not unlocked" };
|
||||
const action = msg["action"] === "reveal" ? "reveal" : "use";
|
||||
const id = String(msg["id"] ?? "");
|
||||
const vault = await openVault(
|
||||
join(this.appDir, fingerprint, INDEX_FILENAME),
|
||||
held.mnemonic,
|
||||
);
|
||||
const entry = vault.index.entries[id] as Entry | undefined;
|
||||
if (!entry) return { ok: false, error: `no entry ${id}` };
|
||||
const auth = this.authorize(String(msg["token"] ?? ""), fingerprint, action, {
|
||||
kind: entry.kind,
|
||||
label: entry.label,
|
||||
});
|
||||
if (!auth.token) {
|
||||
await this.audit(fingerprint)?.log("access_denied", {
|
||||
action, entry_id: id, kind: entry.kind, reason: auth.deny,
|
||||
});
|
||||
return { ok: false, error: `denied: ${auth.deny}` };
|
||||
}
|
||||
const ts = msg["timestamp"] !== undefined ? Number(msg["timestamp"]) : undefined;
|
||||
const secret = materializeSecret(vault.index, id, entry, held.mnemonic, {
|
||||
...(ts !== undefined && { timestamp: ts }),
|
||||
});
|
||||
await this.audit(fingerprint)?.log("secret_delivered", {
|
||||
action,
|
||||
entry_id: id,
|
||||
kind: entry.kind,
|
||||
label: entry.label,
|
||||
token_id: auth.token.id,
|
||||
uses_remaining: auth.token.uses_remaining,
|
||||
});
|
||||
return { ok: true, value: secret.value, descriptor: secret.descriptor };
|
||||
}
|
||||
case "put": {
|
||||
const fingerprint = String(msg["fingerprint"] ?? "");
|
||||
const mnemonic = String(msg["mnemonic"] ?? "");
|
||||
@@ -83,6 +260,7 @@ export class AgentDaemon {
|
||||
const ttl = Number(msg["ttl"] ?? this.defaultTtl);
|
||||
const expiresAt = Math.floor(Date.now() / 1000 + ttl);
|
||||
this.held.set(fingerprint, { mnemonic, expiresAt });
|
||||
await this.audit(fingerprint)?.log("vault_unlocked", { ttl });
|
||||
return { ok: true, expires_at: expiresAt };
|
||||
}
|
||||
case "get": {
|
||||
@@ -123,13 +301,15 @@ export class AgentDaemon {
|
||||
while ((nl = buffer.indexOf("\n")) >= 0) {
|
||||
const line = buffer.slice(0, nl);
|
||||
buffer = buffer.slice(nl + 1);
|
||||
let reply: Record<string, unknown>;
|
||||
try {
|
||||
reply = this.handle(JSON.parse(line) as Record<string, unknown>);
|
||||
} catch (e) {
|
||||
reply = { ok: false, error: String(e) };
|
||||
}
|
||||
socket.write(JSON.stringify(reply) + "\n");
|
||||
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());
|
||||
@@ -201,4 +381,63 @@ export class AgentClient {
|
||||
const r = await this.request({ op: "status" });
|
||||
return (r["profiles"] ?? []) as AgentStatusProfile[];
|
||||
}
|
||||
|
||||
async tokenIssue(options: {
|
||||
fingerprint: string;
|
||||
name?: string;
|
||||
scopes?: TokenScope[];
|
||||
kinds?: string[];
|
||||
labelRegex?: string;
|
||||
ttl?: number;
|
||||
uses?: number;
|
||||
}): Promise<{ token: string; record: TokenInfo }> {
|
||||
const r = await this.request({
|
||||
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,
|
||||
});
|
||||
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.request({ 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.request({ op: "token-revoke", token_id: tokenId });
|
||||
if (!r["ok"]) throw new Error(String(r["error"]));
|
||||
}
|
||||
|
||||
async vaultIndex(fingerprint: string, token: string): Promise<unknown> {
|
||||
const r = await this.request({ op: "vault-index", fingerprint, token });
|
||||
if (!r["ok"]) throw new Error(String(r["error"]));
|
||||
return r["index"];
|
||||
}
|
||||
|
||||
async secret(options: {
|
||||
fingerprint: string;
|
||||
id: string;
|
||||
token: string;
|
||||
action: "use" | "reveal";
|
||||
timestamp?: number;
|
||||
}): Promise<{ value: string; descriptor: string }> {
|
||||
const r = await this.request({
|
||||
op: "secret",
|
||||
fingerprint: options.fingerprint,
|
||||
id: options.id,
|
||||
token: options.token,
|
||||
action: options.action,
|
||||
...(options.timestamp !== undefined && { timestamp: options.timestamp }),
|
||||
});
|
||||
if (!r["ok"]) throw new Error(String(r["error"]));
|
||||
return { value: String(r["value"]), descriptor: String(r["descriptor"]) };
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user