From 15a9cb67213789cf87e5bfc03e738a62dc2b9bb7 Mon Sep 17 00:00:00 2001 From: Derek Rein Date: Wed, 4 Mar 2026 08:49:16 +0700 Subject: [PATCH] feat: add audit log and history operation for companion wallet Append-only JSONL audit log captures every wallet operation with full input/output details. Sensitive fields (mnemonic, signatures, signed transactions) are redacted before writing. A new `history` CLI operation queries the log with filters (--operation, --chain, --account, --last, --since). Co-Authored-By: Claude Opus 4.6 --- .changeset/audit-log-history.md | 5 + packages/companion-wallet/src/audit.ts | 65 +++++++ packages/companion-wallet/src/cli.ts | 114 ++++++++++- packages/companion-wallet/src/index.ts | 3 + packages/companion-wallet/src/types.ts | 25 ++- packages/companion-wallet/test/audit.test.ts | 195 +++++++++++++++++++ packages/companion-wallet/test/cli.test.ts | 59 ++++++ 7 files changed, 460 insertions(+), 6 deletions(-) create mode 100644 .changeset/audit-log-history.md create mode 100644 packages/companion-wallet/src/audit.ts create mode 100644 packages/companion-wallet/test/audit.test.ts diff --git a/.changeset/audit-log-history.md b/.changeset/audit-log-history.md new file mode 100644 index 0000000..b1a9b36 --- /dev/null +++ b/.changeset/audit-log-history.md @@ -0,0 +1,5 @@ +--- +"@walletconnect/companion-wallet": minor +--- + +Add audit log and history operation for companion wallet diff --git a/packages/companion-wallet/src/audit.ts b/packages/companion-wallet/src/audit.ts new file mode 100644 index 0000000..5a0ec23 --- /dev/null +++ b/packages/companion-wallet/src/audit.ts @@ -0,0 +1,65 @@ +import { appendFileSync, readFileSync, mkdirSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { homedir } from "node:os"; +import type { AuditEntry, HistoryInput } from "./types.js"; + +const AUDIT_LOG = join(homedir(), ".config", "wallet", "audit.log"); + +/** Append an audit entry as a JSONL line. Never throws. */ +export function appendAuditEntry(entry: AuditEntry): void { + try { + mkdirSync(dirname(AUDIT_LOG), { recursive: true, mode: 0o700 }); + appendFileSync(AUDIT_LOG, JSON.stringify(entry) + "\n", { + mode: 0o600, + }); + } catch { + // Audit must never crash the wallet + } +} + +/** Read audit log with optional filters. Returns [] if log doesn't exist. */ +export function readAuditLog(filters: HistoryInput = {}): AuditEntry[] { + let lines: string[]; + try { + lines = readFileSync(AUDIT_LOG, "utf-8").split("\n").filter(Boolean); + } catch { + return []; + } + + let entries: AuditEntry[] = []; + for (const line of lines) { + try { + entries.push(JSON.parse(line) as AuditEntry); + } catch { + // Skip malformed lines + } + } + + if (filters.operation) { + const op = filters.operation.toLowerCase(); + entries = entries.filter((e) => e.operation.toLowerCase() === op); + } + + if (filters.chain) { + const ch = filters.chain.toLowerCase(); + entries = entries.filter((e) => e.chain?.toLowerCase() === ch); + } + + if (filters.account) { + const acc = filters.account.toLowerCase(); + entries = entries.filter((e) => e.account?.toLowerCase() === acc); + } + + if (filters.since) { + const sinceDate = new Date(filters.since).getTime(); + if (!Number.isNaN(sinceDate)) { + entries = entries.filter((e) => new Date(e.timestamp).getTime() >= sinceDate); + } + } + + if (filters.last !== undefined && filters.last > 0) { + entries = entries.slice(-filters.last); + } + + return entries; +} diff --git a/packages/companion-wallet/src/cli.ts b/packages/companion-wallet/src/cli.ts index 21218aa..6dab906 100644 --- a/packages/companion-wallet/src/cli.ts +++ b/packages/companion-wallet/src/cli.ts @@ -16,6 +16,7 @@ import { recordSessionUsage, SessionError, } from "./sessions.js"; +import { appendAuditEntry, readAuditLog } from "./audit.js"; import { ExitCode, type Operation, @@ -30,6 +31,7 @@ import { type RevokeSessionInput, type GetSessionInput, type ErrorResponse, + type HistoryInput, } from "./types.js"; function getVersion(): string { @@ -44,23 +46,48 @@ function getVersion(): string { } } +let _stdinCache: string | undefined; + async function readStdin(): Promise { - if (process.stdin.isTTY) return ""; + if (_stdinCache !== undefined) return _stdinCache; + + if (process.stdin.isTTY) { + _stdinCache = ""; + return _stdinCache; + } const chunks: Buffer[] = []; for await (const chunk of process.stdin) { chunks.push(chunk); } - return Buffer.concat(chunks).toString("utf-8").trim(); + _stdinCache = Buffer.concat(chunks).toString("utf-8").trim(); + return _stdinCache; } -function respond(data: object): void { +let respond = (data: object): void => { process.stdout.write(JSON.stringify(data) + "\n"); -} +}; -function respondError(error: string, code: string): void { +let respondError = (error: string, code: string): void => { const resp: ErrorResponse = { error, code }; process.stdout.write(JSON.stringify(resp) + "\n"); +}; + +/** Fields that must never appear in audit logs */ +const REDACTED_FIELDS = new Set(["mnemonic", "signedTransaction", "signature"]); + +function sanitizeAuditData(data: unknown): unknown { + if (!data || typeof data !== "object") return data; + const obj = data as Record; + const sanitized: Record = {}; + for (const [key, value] of Object.entries(obj)) { + if (REDACTED_FIELDS.has(key)) { + sanitized[key] = "[REDACTED]"; + } else { + sanitized[key] = value; + } + } + return sanitized; } async function parseInput(): Promise { @@ -91,6 +118,7 @@ async function handleInfo(): Promise { "get-session", "fund", "drain", + "history", ], chains: SUPPORTED_CHAINS, }; @@ -387,6 +415,27 @@ async function handleDrain(): Promise { } } +async function handleHistory(): Promise { + try { + const lastRaw = parseCliArg("last"); + const filters: HistoryInput = { + operation: parseCliArg("operation"), + chain: parseCliArg("chain"), + account: parseCliArg("account"), + last: lastRaw ? Number(lastRaw) : undefined, + since: parseCliArg("since"), + }; + const entries = readAuditLog(filters); + respond({ entries }); + } catch (err) { + respondError( + err instanceof Error ? err.message : "History read failed", + "HISTORY_ERROR", + ); + process.exit(ExitCode.ERROR); + } +} + function validateSessionOrExit( sessionId: string, operation: string, @@ -421,6 +470,7 @@ const HANDLERS: Record Promise> = { "get-session": handleGetSession, fund: handleFund, drain: handleDrain, + history: handleHistory, }; async function main(): Promise { @@ -434,6 +484,60 @@ async function main(): Promise { process.exit(ExitCode.UNSUPPORTED); } + // Eagerly read stdin so it's cached for both audit and handler + const rawStdin = await readStdin(); + + // Audit context — captured during execution, written on exit + const shouldAudit = operation !== "history"; + let auditOutput: unknown = undefined; + let auditError: string | undefined = undefined; + + if (shouldAudit) { + const origRespond = respond; + const origRespondError = respondError; + + respond = (data: object): void => { + auditOutput = data; + origRespond(data); + }; + + respondError = (error: string, code: string): void => { + auditError = error; + auditOutput = { error, code }; + origRespondError(error, code); + }; + + process.on("exit", () => { + let parsedInput: unknown = undefined; + try { + parsedInput = rawStdin ? JSON.parse(rawStdin) : undefined; + } catch { + parsedInput = rawStdin || undefined; + } + + const inputObj = + parsedInput && typeof parsedInput === "object" && parsedInput !== null + ? (parsedInput as Record) + : undefined; + + // Sanitize sensitive fields from audit entries + const sanitizedInput = sanitizeAuditData(parsedInput); + const sanitizedOutput = sanitizeAuditData(auditOutput); + + appendAuditEntry({ + timestamp: new Date().toISOString(), + operation, + input: sanitizedInput, + output: sanitizedOutput, + account: parseCliArg("account") ?? (typeof inputObj?.account === "string" ? inputObj.account : undefined), + chain: parseCliArg("chain") ?? (typeof inputObj?.chain === "string" ? inputObj.chain : undefined), + sessionId: typeof inputObj?.sessionId === "string" ? inputObj.sessionId : undefined, + success: auditError === undefined, + error: auditError, + }); + }); + } + try { await HANDLERS[operation](); } catch (err) { diff --git a/packages/companion-wallet/src/index.ts b/packages/companion-wallet/src/index.ts index 790d64e..43314c2 100644 --- a/packages/companion-wallet/src/index.ts +++ b/packages/companion-wallet/src/index.ts @@ -17,6 +17,7 @@ export { recordSessionUsage, SessionError, } from "./sessions.js"; +export { appendAuditEntry, readAuditLog } from "./audit.js"; export { ExitCode } from "./types.js"; export type { Operation, @@ -43,4 +44,6 @@ export type { BalanceResponse, WalletFile, ErrorResponse, + AuditEntry, + HistoryInput, } from "./types.js"; diff --git a/packages/companion-wallet/src/types.ts b/packages/companion-wallet/src/types.ts index e2be22e..e48e1da 100644 --- a/packages/companion-wallet/src/types.ts +++ b/packages/companion-wallet/src/types.ts @@ -25,7 +25,8 @@ export type Operation = | "get-session" | "balance" | "fund" - | "drain"; + | "drain" + | "history"; /** Info response */ export interface InfoResponse { @@ -181,3 +182,25 @@ export interface WalletFile { address: string; mnemonic: string; } + +/** Audit log entry */ +export interface AuditEntry { + timestamp: string; + operation: string; + input: unknown; + output: unknown; + account?: string; + chain?: string; + sessionId?: string; + success: boolean; + error?: string; +} + +/** History query input (CLI flags) */ +export interface HistoryInput { + operation?: string; + chain?: string; + account?: string; + last?: number; + since?: string; +} diff --git a/packages/companion-wallet/test/audit.test.ts b/packages/companion-wallet/test/audit.test.ts new file mode 100644 index 0000000..cea4832 --- /dev/null +++ b/packages/companion-wallet/test/audit.test.ts @@ -0,0 +1,195 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdirSync, rmSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { randomBytes } from "node:crypto"; + +const TEST_DIR = join(tmpdir(), `wallet-audit-test-${randomBytes(8).toString("hex")}`); + +import { vi } from "vitest"; +vi.mock("node:os", async () => { + const actual = await vi.importActual("node:os"); + return { + ...actual, + homedir: () => join(TEST_DIR, "home"), + }; +}); + +const { appendAuditEntry, readAuditLog } = await import("../src/audit.js"); + +const AUDIT_LOG = join(TEST_DIR, "home", ".config", "wallet", "audit.log"); + +describe("audit", () => { + beforeEach(() => { + mkdirSync(join(TEST_DIR, "home", ".config", "wallet"), { recursive: true }); + }); + + afterEach(() => { + rmSync(TEST_DIR, { recursive: true, force: true }); + }); + + describe("appendAuditEntry", () => { + it("creates log file on first write", () => { + appendAuditEntry({ + timestamp: "2025-01-01T00:00:00.000Z", + operation: "info", + input: undefined, + output: { name: "companion-wallet" }, + success: true, + }); + + const content = readFileSync(AUDIT_LOG, "utf-8"); + const entry = JSON.parse(content.trim()); + expect(entry.operation).toBe("info"); + expect(entry.success).toBe(true); + }); + + it("appends multiple JSONL entries", () => { + appendAuditEntry({ + timestamp: "2025-01-01T00:00:00.000Z", + operation: "info", + input: undefined, + output: {}, + success: true, + }); + appendAuditEntry({ + timestamp: "2025-01-01T00:01:00.000Z", + operation: "accounts", + input: undefined, + output: { accounts: [] }, + success: true, + }); + + const lines = readFileSync(AUDIT_LOG, "utf-8").split("\n").filter(Boolean); + expect(lines).toHaveLength(2); + expect(JSON.parse(lines[0]).operation).toBe("info"); + expect(JSON.parse(lines[1]).operation).toBe("accounts"); + }); + }); + + describe("readAuditLog", () => { + it("returns empty array when log does not exist", () => { + const entries = readAuditLog(); + expect(entries).toEqual([]); + }); + + it("skips malformed lines", () => { + writeFileSync( + AUDIT_LOG, + '{"timestamp":"2025-01-01T00:00:00.000Z","operation":"info","input":null,"output":{},"success":true}\n' + + "not-json\n" + + '{"timestamp":"2025-01-01T00:01:00.000Z","operation":"accounts","input":null,"output":{},"success":true}\n', + ); + + const entries = readAuditLog(); + expect(entries).toHaveLength(2); + expect(entries[0].operation).toBe("info"); + expect(entries[1].operation).toBe("accounts"); + }); + + it("filters by operation", () => { + appendAuditEntry({ + timestamp: "2025-01-01T00:00:00.000Z", + operation: "info", + input: undefined, + output: {}, + success: true, + }); + appendAuditEntry({ + timestamp: "2025-01-01T00:01:00.000Z", + operation: "accounts", + input: undefined, + output: {}, + success: true, + }); + + const entries = readAuditLog({ operation: "info" }); + expect(entries).toHaveLength(1); + expect(entries[0].operation).toBe("info"); + }); + + it("filters by account (case-insensitive)", () => { + appendAuditEntry({ + timestamp: "2025-01-01T00:00:00.000Z", + operation: "sign-message", + input: {}, + output: {}, + account: "0xAbC123", + success: true, + }); + appendAuditEntry({ + timestamp: "2025-01-01T00:01:00.000Z", + operation: "sign-message", + input: {}, + output: {}, + account: "0xDEF456", + success: true, + }); + + const entries = readAuditLog({ account: "0xabc123" }); + expect(entries).toHaveLength(1); + expect(entries[0].account).toBe("0xAbC123"); + }); + + it("filters by chain", () => { + appendAuditEntry({ + timestamp: "2025-01-01T00:00:00.000Z", + operation: "send-transaction", + input: {}, + output: {}, + chain: "eip155:1", + success: true, + }); + appendAuditEntry({ + timestamp: "2025-01-01T00:01:00.000Z", + operation: "send-transaction", + input: {}, + output: {}, + chain: "eip155:10", + success: true, + }); + + const entries = readAuditLog({ chain: "eip155:1" }); + expect(entries).toHaveLength(1); + expect(entries[0].chain).toBe("eip155:1"); + }); + + it("filters by since", () => { + appendAuditEntry({ + timestamp: "2025-01-01T00:00:00.000Z", + operation: "info", + input: undefined, + output: {}, + success: true, + }); + appendAuditEntry({ + timestamp: "2025-06-01T00:00:00.000Z", + operation: "accounts", + input: undefined, + output: {}, + success: true, + }); + + const entries = readAuditLog({ since: "2025-03-01T00:00:00.000Z" }); + expect(entries).toHaveLength(1); + expect(entries[0].operation).toBe("accounts"); + }); + + it("returns last N entries", () => { + for (let i = 0; i < 5; i++) { + appendAuditEntry({ + timestamp: `2025-01-0${i + 1}T00:00:00.000Z`, + operation: "info", + input: undefined, + output: { i }, + success: true, + }); + } + + const entries = readAuditLog({ last: 2 }); + expect(entries).toHaveLength(2); + expect((entries[0].output as { i: number }).i).toBe(3); + expect((entries[1].output as { i: number }).i).toBe(4); + }); + }); +}); diff --git a/packages/companion-wallet/test/cli.test.ts b/packages/companion-wallet/test/cli.test.ts index f42e812..e96faca 100644 --- a/packages/companion-wallet/test/cli.test.ts +++ b/packages/companion-wallet/test/cli.test.ts @@ -61,6 +61,7 @@ describe("cli", () => { expect(info.capabilities).toContain("send-transaction"); expect(info.capabilities).toContain("balance"); expect(info.capabilities).toContain("grant-session"); + expect(info.capabilities).toContain("history"); expect(info.chains).toContain("eip155:1"); expect(info.chains).toContain("eip155:8453"); }); @@ -191,6 +192,64 @@ describe("cli", () => { }); }); + describe("history", () => { + it("returns empty entries when no log exists", () => { + const { stdout, exitCode } = runCli("history"); + expect(exitCode).toBe(0); + + const output = JSON.parse(stdout); + expect(output.entries).toEqual([]); + }); + + it("logs operations and reads them back via history", () => { + // Run info to create an audit entry + runCli("info"); + + // Check history has the info entry + const { stdout, exitCode } = runCli("history"); + expect(exitCode).toBe(0); + + const { entries } = JSON.parse(stdout); + expect(entries.length).toBeGreaterThanOrEqual(1); + expect(entries.some((e: { operation: string }) => e.operation === "info")).toBe(true); + }); + + it("does not log history itself", () => { + // Run info then history + runCli("info"); + runCli("history"); + + // Check history — should only contain info, not history + const { stdout } = runCli("history"); + const { entries } = JSON.parse(stdout); + const historyEntries = entries.filter( + (e: { operation: string }) => e.operation === "history", + ); + expect(historyEntries).toHaveLength(0); + }); + + it("filters by --operation", () => { + runCli("info"); + runCli("generate"); + + const { stdout } = runCli("history", undefined, ["--operation", "info"]); + const { entries } = JSON.parse(stdout); + expect(entries.length).toBeGreaterThanOrEqual(1); + expect(entries.every((e: { operation: string }) => e.operation === "info")).toBe(true); + }); + + it("filters by --last", () => { + runCli("info"); + runCli("generate"); + runCli("accounts"); + + const { stdout } = runCli("history", undefined, ["--last", "1"]); + const { entries } = JSON.parse(stdout); + expect(entries).toHaveLength(1); + expect(entries[0].operation).toBe("accounts"); + }); + }); + describe("sessions via CLI", () => { it("grant, get, and revoke session lifecycle", () => { const genResult = runCli("generate");