Files
SeedPass/js/packages/cli/src/audit.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

92 lines
2.9 KiB
TypeScript

/**
* 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;
}
}