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:
thePR0M3TH3ANandClaude Fable 5 committed 2026-08-14 15:06:02 -04:00
1 parent e84a7e1c0e
commit 274bc0e1b8
7 files changed
+710 -28

No files matched your search

+247 -8
View File
@@ -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"]) };
}
}
+91
View File
@@ -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<string, unknown>;
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<void> {
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<string, unknown> = {}): Promise<void> {
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<AuditRecord[]> {
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;
}
}
+13 -2
View File
@@ -35,11 +35,22 @@ export function capabilities(): Record<string, unknown> {
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",
],
};
}
+172 -17
View File
@@ -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<string
async function resolveMnemonic(app: AppDir, opts: GlobalOpts): Promise<string> {
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<OpenedVault> {
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<ReadAccess> {
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 <refOrQuery>")
.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 <text>")
.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 <timestamp>", "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 <seconds>", "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 <name>", "token name", "agent")
.option("--scope <scope...>", "read/use/reveal (repeatable)", ["read"])
.option("--kind <kind...>", "restrict to entry kinds (repeatable)")
.option("--label-regex <re>", "restrict to matching labels", ".*")
.option("--ttl <seconds>", "token lifetime", "300")
.option("--uses <n>", "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 <tokenId>")
.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 <count>", "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")