From 274bc0e1b8a8d994f5896256deab63fd516b3818 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:06:02 -0400 Subject: [PATCH] TS port slice 10: scoped tokens, agent-side enforcement, audit chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/typescript_port_compatibility_matrix.md | 3 +- js/packages/cli/src/agent.ts | 255 ++++++++++++++++++- js/packages/cli/src/audit.ts | 91 +++++++ js/packages/cli/src/capabilities.ts | 15 +- js/packages/cli/src/program.ts | 189 ++++++++++++-- js/packages/cli/test/tokens.test.ts | 180 +++++++++++++ js/packages/core/src/util/bytes.ts | 5 + 7 files changed, 710 insertions(+), 28 deletions(-) create mode 100644 js/packages/cli/src/audit.ts create mode 100644 js/packages/cli/test/tokens.test.ts diff --git a/docs/typescript_port_compatibility_matrix.md b/docs/typescript_port_compatibility_matrix.md index cf608599..c0fa518f 100644 --- a/docs/typescript_port_compatibility_matrix.md +++ b/docs/typescript_port_compatibility_matrix.md @@ -43,13 +43,14 @@ until a later milestone. | Entry secret retrieval | P0 | green | `entry_secrets.json` | Reveal values match Python retrieval for password (v2), TOTP-at-time, nostr entry nsec (BIP-85 app 39 — NOT the sync client's 1237), seed and managed-account mnemonics. | | CLI (Milestone 5, agent-blind MVP) | P1 | partial | — | `packages/cli` seedpass-js: capabilities, entry list/get/search (reference-first, `sp://entry/`, secrets replaced by `has_*` flags), `entry add password/totp/key-value/document/seed/managed-account/nostr` (provision-blind: returns refs, never the created secret), `entry reveal` as the only plaintext egress, `use --clipboard/--exec/--stdin-to` sinks (env-var injection, never argv/stdout), vault export/import. Tests assert secrets never appear in default or provisioning output. Also: modify, archive/unarchive, links, totp-codes, util generate-password, nostr get-pubkey/list-relays/add-relay/remove-relay/sync/restore. Milestone 5 command surface complete except ssh/pgp adds and document import/export; leases/tokens are Milestone 8. | | Profiles + config (Python `~/.seedpass` layout) | P1 | green* | — | `fingerprint list/add/switch/remove`, `config get/set` over the Python directory layout (fingerprints.json, parent_seed.enc kdf/ct wrapper, index-key-encrypted config with ConfigManager defaults). *Layout-compatible and tested end-to-end in TS; opening a real Python-created profile is verified indirectly via the parent-seed wrapper fixture — direct cross-open test TODO. | +| Scoped tokens + audit chain (plan §9.3 / M8 core) | P1 | green | — (live agent tests) | Bearer tokens issued/held by the agent (hash-stored, shown once) with read/use/reveal scopes, kind + label-regex constraints, TTL and use counts; token-mode CLI reads the index and materializes secrets agent-side, never sees the mnemonic, and can never escalate to owner ops. Audit log uses Python AuditLogger's chain scheme (HMAC(prev_sig+canonical payload), KEY_INDEX key); verify/tail commands; tamper detection tested. Not yet: approval gates, high-risk partitions, persistent token store. | | Session agent (vault unlock/lock) | new (TS-only) | green | — | ssh-agent-style unix-socket daemon holding seeds with TTL (0600 socket, in-memory only); seed resolution env -> agent; full lifecycle tested with no mnemonic in the environment. Designed enforcement point for the section-9.3 lease/token layer. Python has no equivalent (its lock is in-process TUI state). | ## Environment coverage | Environment | Status | |---|---| -| Node 22 (vitest) | green — 151/151 core + 32 CLI | +| Node 22 (vitest) | green — 151/151 core + 41 CLI | | jsdom browser-like env | green — 148/148 core (3 transport tests Node-only) | | Real Chromium/Firefox (vitest browser mode) | todo — lands with Milestone 6 web app CI | diff --git a/js/packages/cli/src/agent.ts b/js/packages/cli/src/agent.ts index c1cf1dc8..6a5a71b0 100644 --- a/js/packages/cli/src/agent.ts +++ b/js/packages/cli/src/agent.ts @@ -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; + 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(); + private tokens = new Map(); + private audits = new Map(); 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 { 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): Record { + private async handle(msg: Record): Promise> { 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; - try { - reply = this.handle(JSON.parse(line) as Record); - } catch (e) { - reply = { ok: false, error: String(e) }; - } - socket.write(JSON.stringify(reply) + "\n"); + void (async () => { + let reply: Record; + try { + reply = await this.handle(JSON.parse(line) as Record); + } 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 { + 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 { + 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 { + 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"]) }; + } } diff --git a/js/packages/cli/src/audit.ts b/js/packages/cli/src/audit.ts new file mode 100644 index 00000000..fd2efb67 --- /dev/null +++ b/js/packages/cli/src/audit.ts @@ -0,0 +1,91 @@ +/** + * Tamper-evident audit log, chain-compatible with Python's AuditLogger: + * each record's sig = HMAC-SHA256(key, prev_sig + canonical_payload) where + * canonical_payload is the compact sort_keys JSON of {timestamp, event, + * details} and the genesis prev_sig is 64 zeros. The signing key is the + * profile's index-derivation key (KEY_INDEX), so only the seed holder can + * extend or verify the chain. + */ + +import { appendFile, readFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { canonicalJson, hmacSha256Hex, utf8 } from "@seedpass/core"; + +const GENESIS = "0".repeat(64); + +export interface AuditRecord { + timestamp: string; + event: string; + details: Record; + sig: string; +} + +function isoNow(): string { + return new Date().toISOString().replace("Z", "+00:00"); +} + +function signPayload(key: Uint8Array, prevSig: string, payload: string): string { + return hmacSha256Hex(key, utf8(prevSig + payload)); +} + +export class AuditLog { + private lastSig = GENESIS; + private loaded = false; + + constructor( + private readonly path: string, + private readonly key: Uint8Array, + ) {} + + private async loadTail(): Promise { + if (this.loaded) return; + this.loaded = true; + if (!existsSync(this.path)) return; + const lines = (await readFile(this.path, "utf8")).split("\n").filter((l) => l.trim()); + const last = lines[lines.length - 1]; + if (last) { + try { + this.lastSig = (JSON.parse(last) as AuditRecord).sig ?? GENESIS; + } catch { + // keep genesis; verify() will surface corruption + } + } + } + + async log(event: string, details: Record = {}): Promise { + await this.loadTail(); + const entry = { timestamp: isoNow(), event, details }; + const payload = canonicalJson(entry); + const sig = signPayload(this.key, this.lastSig, payload); + await appendFile(this.path, canonicalJson({ ...entry, sig }) + "\n", { mode: 0o600 }); + this.lastSig = sig; + } + + /** Verify the whole chain; returns records or throws at the first break. */ + static async verify(path: string, key: Uint8Array): Promise { + if (!existsSync(path)) return []; + const lines = (await readFile(path, "utf8")).split("\n").filter((l) => l.trim()); + const records: AuditRecord[] = []; + let prevSig = GENESIS; + for (let i = 0; i < lines.length; i++) { + let record: AuditRecord; + try { + record = JSON.parse(lines[i]!) as AuditRecord; + } catch { + throw new Error(`audit chain broken at line ${i + 1}: unparseable record`); + } + const payload = canonicalJson({ + timestamp: record.timestamp, + event: record.event, + details: record.details, + }); + const expected = signPayload(key, prevSig, payload); + if (expected !== record.sig) { + throw new Error(`audit chain broken at line ${i + 1}: signature mismatch`); + } + prevSig = record.sig; + records.push(record); + } + return records; + } +} diff --git a/js/packages/cli/src/capabilities.ts b/js/packages/cli/src/capabilities.ts index b1df516f..bb8d97aa 100644 --- a/js/packages/cli/src/capabilities.ts +++ b/js/packages/cli/src/capabilities.ts @@ -35,11 +35,22 @@ export function capabilities(): Record { layout: "python-compatible ~/.seedpass", commands: ["fingerprint list/add/switch/remove", "config get/set"], }, + tokens: { + commands: ["agent token-issue/token-list/token-revoke"], + scopes: ["read", "use", "reveal"], + constraints: ["kinds", "label_regex", "ttl", "uses"], + 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", + }, + audit: { + commands: ["agent audit-verify", "agent audit-tail"], + chain: "HMAC-SHA256(prev_sig + canonical_payload), keyed by KEY_INDEX", + }, not_yet_ported: [ "ssh/pgp key material", - "relay sync commands", "index0/atlas", - "leases and scoped tokens (design: plan section 9.3; enforcement point: session agent)", + "approval gates and high-risk partitions", ], }; } diff --git a/js/packages/cli/src/program.ts b/js/packages/cli/src/program.ts index 7e0ef639..833adbe2 100644 --- a/js/packages/cli/src/program.ts +++ b/js/packages/cli/src/program.ts @@ -55,6 +55,7 @@ import { join } from "node:path"; import { AppDir, resolveAppDir, INDEX_FILENAME } from "./appDir.js"; import { loadConfig, saveConfig } from "./configFile.js"; import { AgentClient, AgentDaemon, agentSocketPath, DEFAULT_TTL_SECONDS } from "./agent.js"; +import { AuditLog } from "./audit.js"; export interface ProgramIo { out(line: string): void; @@ -86,6 +87,15 @@ async function currentFingerprint(app: AppDir, opts: GlobalOpts): Promise { const env = process.env["SEEDPASS_MNEMONIC"]; if (env) return env; + // A process that authenticates with a scoped token has declared itself a + // constrained principal: it must never escalate to owner access by + // pulling the mnemonic from the unlocked agent. + if (process.env["SEEDPASS_TOKEN"]) { + throw new Error( + "token mode: this operation requires owner access (unset SEEDPASS_TOKEN " + + "and unlock as the owner)", + ); + } try { const fp = await currentFingerprint(app, opts); const client = new AgentClient(agentSocketPath(app.root)); @@ -121,6 +131,60 @@ async function openFromOptions(opts: GlobalOpts): Promise { return openVault(path, await resolveMnemonic(app, opts)); } +/** + * Read/secret access that works in two modes: + * - owner mode (mnemonic via env or agent): full local access + * - token mode (SEEDPASS_TOKEN set, no mnemonic): every read and secret + * goes through the agent, which enforces the token's scopes/kinds/label + * constraints and writes the audit trail. The mnemonic never reaches + * this process. + */ +interface ReadAccess { + index: import("@seedpass/core").VaultIndex; + mode: "owner" | "token"; + secretFor( + id: string, + action: "use" | "reveal", + timestamp?: number, + ): Promise<{ value: string; descriptor: string }>; +} + +async function openReadAccess(opts: GlobalOpts): Promise { + const token = process.env["SEEDPASS_TOKEN"]; + const app = new AppDir(resolveAppDir(opts.appDir)); + if (token && !process.env["SEEDPASS_MNEMONIC"]) { + const fp = await currentFingerprint(app, opts); + const client = new AgentClient(agentSocketPath(app.root)); + const index = parseVaultIndex(await client.vaultIndex(fp, token)); + return { + index, + mode: "token", + secretFor: async (id, action, timestamp) => { + return client.secret({ + fingerprint: fp, + id, + token, + action, + ...(timestamp !== undefined && { timestamp }), + }); + }, + }; + } + const vault = await openFromOptions(opts); + return { + index: vault.index, + mode: "owner", + secretFor: async (id, action, timestamp) => { + void action; + const entry = vault.index.entries[id]; + if (!entry) throw new Error(`no entry ${id}`); + return materializeSecret(vault.index, id, entry, vault.mnemonic, { + ...(timestamp !== undefined && { timestamp }), + }); + }, + }; +} + export function buildProgram(io: ProgramIo = defaultIo): Command { const program = new Command(); program @@ -148,8 +212,8 @@ export function buildProgram(io: ProgramIo = defaultIo): Command { .command("list") .description("list entries as references + metadata (no secrets)") .action(async () => { - const vault = await openFromOptions(program.opts()); - const rows = Object.entries(vault.index.entries).map(([id, e]) => + const access = await openReadAccess(program.opts()); + const rows = Object.entries(access.index.entries).map(([id, e]) => entryMetadata(id, e), ); io.out(JSON.stringify(rows, null, 2)); @@ -159,8 +223,8 @@ export function buildProgram(io: ProgramIo = defaultIo): Command { .command("get ") .description("show one entry's reference + metadata (no secrets)") .action(async (refOrQuery: string) => { - const vault = await openFromOptions(program.opts()); - const hit = resolveEntry(vault.index, refOrQuery); + const access = await openReadAccess(program.opts()); + const hit = resolveEntry(access.index, refOrQuery); io.out(JSON.stringify(entryMetadata(hit.id, hit.entry), null, 2)); }); @@ -168,9 +232,9 @@ export function buildProgram(io: ProgramIo = defaultIo): Command { .command("search ") .description("substring search over labels/tags/notes (no secrets)") .action(async (text: string) => { - const vault = await openFromOptions(program.opts()); + const access = await openReadAccess(program.opts()); const needle = text.toLowerCase(); - const rows = Object.entries(vault.index.entries) + const rows = Object.entries(access.index.entries) .filter(([, e]) => { const hay = [e.label, e.notes ?? "", ...(e.tags ?? [])].join("\n").toLowerCase(); return hay.includes(needle); @@ -460,11 +524,13 @@ export function buildProgram(io: ProgramIo = defaultIo): Command { .description("PLAINTEXT EGRESS: print the secret to stdout") .option("--at ", "TOTP: unix time to compute the code at") .action(async (refOrQuery: string, cmdOpts: { at?: string }) => { - const vault = await openFromOptions(program.opts()); - const hit = resolveEntry(vault.index, refOrQuery); - const secret = materializeSecret(vault.index, hit.id, hit.entry, vault.mnemonic, { - ...(cmdOpts.at !== undefined && { timestamp: Number(cmdOpts.at) }), - }); + const access = await openReadAccess(program.opts()); + const hit = resolveEntry(access.index, refOrQuery); + const secret = await access.secretFor( + hit.id, + "reveal", + cmdOpts.at !== undefined ? Number(cmdOpts.at) : undefined, + ); io.out(secret.value); }); @@ -486,11 +552,13 @@ export function buildProgram(io: ProgramIo = defaultIo): Command { if (chosen !== 1) { throw new Error("choose exactly one sink: --clipboard, --exec, or --stdin-to"); } - const vault = await openFromOptions(program.opts()); - const hit = resolveEntry(vault.index, refOrQuery); - const secret = materializeSecret(vault.index, hit.id, hit.entry, vault.mnemonic, { - ...(cmdOpts.at !== undefined && { timestamp: Number(cmdOpts.at) }), - }); + const access = await openReadAccess(program.opts()); + const hit = resolveEntry(access.index, refOrQuery); + const secret = await access.secretFor( + hit.id, + "use", + cmdOpts.at !== undefined ? Number(cmdOpts.at) : undefined, + ); let result; if (cmdOpts.clipboard) { @@ -747,12 +815,99 @@ export function buildProgram(io: ProgramIo = defaultIo): Command { .option("--ttl ", "default unlock TTL", String(DEFAULT_TTL_SECONDS)) .action(async (o: { ttl: string }) => { const app = new AppDir(resolveAppDir((program.opts() as GlobalOpts).appDir)); - const daemon = new AgentDaemon(agentSocketPath(app.root), Number(o.ttl)); + const daemon = new AgentDaemon(agentSocketPath(app.root), Number(o.ttl), app.root); await daemon.start(); io.out(JSON.stringify({ agent: "running", socket: agentSocketPath(app.root) })); await new Promise(() => {}); // run until killed }); + agent + .command("token-issue") + .description("issue a scoped bearer token (printed exactly once)") + .option("--name ", "token name", "agent") + .option("--scope ", "read/use/reveal (repeatable)", ["read"]) + .option("--kind ", "restrict to entry kinds (repeatable)") + .option("--label-regex ", "restrict to matching labels", ".*") + .option("--ttl ", "token lifetime", "300") + .option("--uses ", "max secret deliveries", "1") + .action( + async (o: { + name: string; scope: string[]; kind?: string[]; labelRegex: string; + ttl: string; uses: string; + }) => { + const opts = program.opts() as GlobalOpts; + const app = new AppDir(resolveAppDir(opts.appDir)); + const fp = await currentFingerprint(app, opts); + const client = new AgentClient(agentSocketPath(app.root)); + const issued = await client.tokenIssue({ + fingerprint: fp, + name: o.name, + scopes: o.scope as ("read" | "use" | "reveal")[], + ...(o.kind !== undefined && { kinds: o.kind }), + labelRegex: o.labelRegex, + ttl: Number(o.ttl), + uses: Number(o.uses), + }); + io.out( + JSON.stringify( + { token: issued.token, note: "shown once — store it now", record: issued.record }, + null, + 2, + ), + ); + }, + ); + + agent + .command("token-list") + .description("list issued tokens (no secrets)") + .action(async () => { + const opts = program.opts() as GlobalOpts; + const app = new AppDir(resolveAppDir(opts.appDir)); + const fp = await currentFingerprint(app, opts); + const client = new AgentClient(agentSocketPath(app.root)); + io.out(JSON.stringify(await client.tokenList(fp), null, 2)); + }); + + agent + .command("token-revoke ") + .description("revoke a token immediately") + .action(async (tokenId: string) => { + const app = new AppDir(resolveAppDir((program.opts() as GlobalOpts).appDir)); + const client = new AgentClient(agentSocketPath(app.root)); + await client.tokenRevoke(tokenId); + io.out(JSON.stringify({ revoked: tokenId })); + }); + + agent + .command("audit-verify") + .description("verify the profile's HMAC-chained audit log (owner only)") + .action(async () => { + const opts = program.opts() as GlobalOpts; + const app = new AppDir(resolveAppDir(opts.appDir)); + const fp = await currentFingerprint(app, opts); + const mnemonic = await resolveMnemonic(app, opts); + const path = join(app.profileDir(fp), "audit.log"); + const records = await AuditLog.verify(path, deriveKeyIndex(mnemonic)); + io.out(JSON.stringify({ verified: true, records: records.length, path })); + }); + + agent + .command("audit-tail") + .description("show the last audit records after verifying the chain (owner only)") + .option("-n ", "records to show", "10") + .action(async (o: { n: string }) => { + const opts = program.opts() as GlobalOpts; + const app = new AppDir(resolveAppDir(opts.appDir)); + const fp = await currentFingerprint(app, opts); + const mnemonic = await resolveMnemonic(app, opts); + const records = await AuditLog.verify( + join(app.profileDir(fp), "audit.log"), + deriveKeyIndex(mnemonic), + ); + io.out(JSON.stringify(records.slice(-Number(o.n)), null, 2)); + }); + agent .command("status") .description("show unlocked profiles and expiry times") diff --git a/js/packages/cli/test/tokens.test.ts b/js/packages/cli/test/tokens.test.ts new file mode 100644 index 00000000..788c8abe --- /dev/null +++ b/js/packages/cli/test/tokens.test.ts @@ -0,0 +1,180 @@ +/** + * Scoped-token enforcement tests: a token-holding process with NO mnemonic + * anywhere in its environment gets exactly what its scopes grant — reads, + * sink deliveries for permitted kinds/labels, nothing else — with every + * decision landing in the tamper-evident audit log. + */ + +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { mkdtemp, readFile, writeFile, appendFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import process from "node:process"; +import { mnemonics } from "@seedpass/test-vectors"; +import { generateFingerprint } from "@seedpass/core"; +import { buildProgram, AgentDaemon, agentSocketPath, type ProgramIo } from "../src/index.js"; + +const MNEMONIC = mnemonics["abandon12"]!; +const FINGERPRINT = generateFingerprint(MNEMONIC); +const PASSWORD = "token-test-pw"; + +let appDir: string; +let daemon: AgentDaemon; +let token: string; + +async function run( + env: Record, + ...argv: string[] +): Promise<{ stdout: string; error?: unknown }> { + const saved: Record = {}; + for (const [k, v] of Object.entries(env)) { + saved[k] = process.env[k]; + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + const out: string[] = []; + const io: ProgramIo = { out: (l: string) => out.push(l), err: () => {} }; + let error: unknown; + try { + await buildProgram(io).parseAsync(["node", "seedpass-js", "--app-dir", appDir, ...argv]); + } catch (e) { + error = e; + } finally { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + } + return { stdout: out.join("\n"), error }; +} + +const asOwner = { SEEDPASS_MNEMONIC: MNEMONIC }; +const asTokenHolder = (t: string) => ({ + SEEDPASS_MNEMONIC: undefined, + SEEDPASS_TOKEN: t, +}); + +beforeAll(async () => { + appDir = await mkdtemp(join(tmpdir(), "seedpass-tokens-")); + process.env["SEEDPASS_AGENT_SOCK"] = join(appDir, "agent.sock"); + daemon = new AgentDaemon(agentSocketPath(appDir), 900, appDir); + await daemon.start(); + + await run({ ...asOwner, SEEDPASS_PASSWORD: PASSWORD }, "fingerprint", "add"); + await run(asOwner, "entry", "add", "key-value", "api-token", "k", "sinkable-secret"); + await run(asOwner, "entry", "add", "key-value", "internal-db", "k", "off-limits"); + await run(asOwner, "entry", "add", "password", "site-login", "--length", "16"); + await run({ SEEDPASS_MNEMONIC: undefined, SEEDPASS_PASSWORD: PASSWORD }, "vault", "unlock"); +}); + +afterAll(async () => { + await daemon.stop(); + delete process.env["SEEDPASS_AGENT_SOCK"]; +}); + +describe("token issuance", () => { + it("issues a scoped token, printed exactly once", async () => { + const r = await run( + {}, + "agent", "token-issue", + "--name", "deploy-bot", + "--scope", "read", "use", + "--kind", "key_value", + "--label-regex", "^api-", + "--ttl", "600", + "--uses", "2", + ); + const issued = JSON.parse(r.stdout); + token = issued.token; + expect(token.length).toBeGreaterThan(20); + expect(issued.record.scopes).toEqual(["read", "use"]); + expect(issued.record).not.toHaveProperty("secret_hash"); + + const list = JSON.parse((await run({}, "agent", "token-list")).stdout); + expect(list[0].name).toBe("deploy-bot"); + expect(JSON.stringify(list)).not.toContain(token); + }); +}); + +describe("token-mode access (no mnemonic anywhere)", () => { + it("reads metadata but the index never exposes the mnemonic", async () => { + const r = await run(asTokenHolder(token), "entry", "list"); + const rows = JSON.parse(r.stdout) as { label: string }[]; + expect(rows.map((x) => x.label).sort()).toEqual(["api-token", "internal-db", "site-login"]); + expect(r.stdout).not.toContain("sinkable-secret"); + }); + + it("delivers a permitted secret to a sink", async () => { + const dir = await mkdtemp(join(tmpdir(), "seedpass-tok-sink-")); + const capture = join(dir, "captured.txt"); + const script = join(dir, "cap.sh"); + await writeFile(script, `#!/bin/sh\nprintf '%s' "$SEEDPASS_SECRET" > "${capture}"\n`, { + mode: 0o755, + }); + const r = await run(asTokenHolder(token), "use", "api-token", "--exec", script); + expect(JSON.parse(r.stdout).exitCode).toBe(0); + expect(await readFile(capture, "utf8")).toBe("sinkable-secret"); + expect(r.stdout).not.toContain("sinkable-secret"); + }); + + it("denies reveal (scope), wrong kind, and non-matching label", async () => { + const reveal = await run(asTokenHolder(token), "entry", "reveal", "api-token"); + expect(String((reveal.error as Error).message)).toContain("scope 'reveal' not granted"); + + const wrongKind = await run(asTokenHolder(token), "use", "site-login", "--stdin-to", "cat"); + expect(String((wrongKind.error as Error).message)).toContain("kind 'password' not granted"); + + const wrongLabel = await run(asTokenHolder(token), "use", "internal-db", "--stdin-to", "cat"); + expect(String((wrongLabel.error as Error).message)).toContain("label not matched"); + }); + + it("exhausts after the allotted uses", async () => { + // Use #2 of 2 (use #1 was the sink delivery above; denials don't consume) + const second = await run(asTokenHolder(token), "use", "api-token", "--stdin-to", "cat"); + expect(second.error).toBeUndefined(); + const third = await run(asTokenHolder(token), "use", "api-token", "--stdin-to", "cat"); + expect(String((third.error as Error).message)).toContain("token exhausted"); + }); + + it("revocation cuts off reads too", async () => { + const list = JSON.parse((await run({}, "agent", "token-list")).stdout); + await run({}, "agent", "token-revoke", list[0].id); + const denied = await run(asTokenHolder(token), "entry", "list"); + expect(String((denied.error as Error).message)).toContain("token revoked"); + }); + + it("token mode cannot provision or modify", async () => { + const r = await run(asTokenHolder(token), "entry", "add", "key-value", "x", "k", "v"); + expect(r.error).toBeTruthy(); + }); +}); + +describe("audit chain", () => { + it("recorded the whole story and verifies", async () => { + const r = await run(asOwner, "agent", "audit-verify"); + const summary = JSON.parse(r.stdout); + expect(summary.verified).toBe(true); + expect(summary.records).toBeGreaterThanOrEqual(6); + + const tail = JSON.parse((await run(asOwner, "agent", "audit-tail", "-n", "50")).stdout) as { + event: string; + details: Record; + }[]; + const events = tail.map((r) => r.event); + expect(events).toContain("token_issued"); + expect(events).toContain("secret_delivered"); + expect(events).toContain("access_denied"); + expect(events).toContain("token_revoked"); + const denial = tail.find((r) => r.event === "access_denied")!; + expect(String(denial.details["reason"])).toBeTruthy(); + // Secrets never appear in the audit log + expect(JSON.stringify(tail)).not.toContain("sinkable-secret"); + }); + + it("detects tampering", async () => { + const path = join(appDir, FINGERPRINT, "audit.log"); + await appendFile(path, JSON.stringify({ timestamp: "x", event: "forged", details: {}, sig: "00" }) + "\n"); + const r = await run(asOwner, "agent", "audit-verify"); + expect(String((r.error as Error).message)).toContain("audit chain broken"); + }); +}); diff --git a/js/packages/core/src/util/bytes.ts b/js/packages/core/src/util/bytes.ts index 7e2f8859..30a024c6 100644 --- a/js/packages/core/src/util/bytes.ts +++ b/js/packages/core/src/util/bytes.ts @@ -1,11 +1,16 @@ /** Small byte/string helpers shared across the core. No Node or DOM APIs. */ import { sha256 } from "@noble/hashes/sha2.js"; +import { hmac } from "@noble/hashes/hmac.js"; export function sha256Hex(data: Uint8Array): string { return bytesToHex(sha256(data)); } +export function hmacSha256Hex(key: Uint8Array, data: Uint8Array): string { + return bytesToHex(hmac(sha256, key, data)); +} + export function utf8(s: string): Uint8Array { return new TextEncoder().encode(s); }