Files
SeedPass/js/packages/cli/test/tokens.test.ts
T
thePR0M3TH3ANandClaude Fable 5 274bc0e1b8 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]>
2026-08-14 15:06:02 -04:00

181 lines
7.2 KiB
TypeScript

/**
* 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<string, string | undefined>,
...argv: string[]
): Promise<{ stdout: string; error?: unknown }> {
const saved: Record<string, string | undefined> = {};
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<string, unknown>;
}[];
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");
});
});