From 98aa2560e53ed6978e6dc24a3953dc20daa96103 Mon Sep 17 00:00:00 2001 From: Reuven Date: Sun, 3 May 2026 21:05:26 -0400 Subject: [PATCH] =?UTF-8?q?feat(encryption):=20ADR-096=20Phase=202=20?= =?UTF-8?q?=E2=80=94=20wire=20vault=20into=20session-tools?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-to-end opt-in encryption for the .claude-flow/sessions/*.json store. The session writer is the smallest of the three high-tier targets, so it ships first to bound the blast radius. Terminal stores and the sql.js memory DB land in follow-up iterations. Mechanics: src/fs-secure.ts - writeFileRestricted gains an options object: { encoding, encrypt } Legacy positional encoding signature still works, so all 5 existing call sites keep compiling unchanged. - When encrypt:true AND CLAUDE_FLOW_ENCRYPT_AT_REST is set, payload is run through encryptBuffer() before writeFileSync. Otherwise the flag is silently ignored — legacy plaintext path runs unchanged. - New readFileMaybeEncrypted(path, encoding?) — magic-byte sniff, decrypt-if-RFE1, otherwise pass-through. Two-overload signature mirrors fs.readFileSync (string default, Buffer with encoding=null). src/mcp-tools/session-tools.ts - saveSession passes { encrypt: true } unconditionally. The vault gate decides whether the bytes actually get encrypted. - loadSession + listSessions switch to readFileMaybeEncrypted, so a mixed plaintext+encrypted dir enumerates cleanly during the migration window. Tests (__tests__/session-encryption.test.ts, 7 cases): encryption disabled - writes plaintext JSON (first byte '{', not 'R') - round-trips save → restore encryption enabled - on-disk blob starts with RFE1 magic - plaintext name is NOT present anywhere in the on-disk bytes (defense-in-depth: confirms encryption actually ran) - round-trips save → restore using the same key migration - a legacy plaintext file written before the gate flipped is still readable AFTER the gate flips on (sniff path) mixed dir - listSessions enumerates plain-1 + encrypted-1 in one call Verified: tsc --noEmit clean (0 errors) Vault + fs-secure + session-encryption: 60/60 Full vitest suite: 1917/1917 passing, 46 skipped, 0 failures (was 1910; +7 from the new test file) Phase 3 wires terminal-tools.saveTerminalStore. Phase 4 wires the seven memory-initializer DB writes. --- .../cli/__tests__/session-encryption.test.ts | 178 ++++++++++++++++++ v3/@claude-flow/cli/src/fs-secure.ts | 100 +++++++++- .../cli/src/mcp-tools/session-tools.ts | 24 ++- 3 files changed, 293 insertions(+), 9 deletions(-) create mode 100644 v3/@claude-flow/cli/__tests__/session-encryption.test.ts diff --git a/v3/@claude-flow/cli/__tests__/session-encryption.test.ts b/v3/@claude-flow/cli/__tests__/session-encryption.test.ts new file mode 100644 index 0000000000..e364a097dc --- /dev/null +++ b/v3/@claude-flow/cli/__tests__/session-encryption.test.ts @@ -0,0 +1,178 @@ +/** + * Integration test for ADR-096 Phase 2: session-tools wired to vault. + * + * Exercises the full save → on-disk → load round-trip across both modes + * (plaintext + encrypted) AND the migration path (legacy plaintext file + * still readable after the encryption gate flips on). + * + * The vault primitives themselves are unit-tested in encryption-vault.test.ts. + * This file pins the contract that session-tools honors the env-var gate + * AND that the magic-byte format is what actually lands on disk. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { randomBytes } from 'node:crypto'; + +import { sessionTools } from '../src/mcp-tools/session-tools.js'; +import { MAGIC, isEncryptedBlob } from '../src/encryption/vault.js'; + +// Per-test working directory so the in-process getProjectCwd() points at +// a clean tree. session-tools uses process.cwd() (via getProjectCwd) for +// the sessions dir. +const SAVED_ENV: Record = {}; +function saveEnv(...names: string[]) { + for (const n of names) SAVED_ENV[n] = process.env[n]; +} +function restoreEnv() { + for (const [n, v] of Object.entries(SAVED_ENV)) { + if (v === undefined) delete process.env[n]; + else process.env[n] = v; + } +} + +const sessionSaveTool = sessionTools.find(t => t.name === 'session_save')!; +const sessionRestoreTool = sessionTools.find(t => t.name === 'session_restore')!; +const sessionListTool = sessionTools.find(t => t.name === 'session_list')!; + +function pickSessionFile(workdir: string): string { + const dir = join(workdir, '.claude-flow', 'sessions'); + // Find the lone JSON file the save tool produces (filename is + // `session--.json`). + const fs = readFileSync; void fs; // (silence unused-import linters) + const { readdirSync } = require('node:fs') as typeof import('node:fs'); + const files = readdirSync(dir).filter(f => f.endsWith('.json')); + expect(files.length).toBe(1); + return join(dir, files[0]); +} + +async function runSave(name: string) { + return sessionSaveTool.handler({ name } as Record); +} +async function runRestore(sessionId: string) { + return sessionRestoreTool.handler({ sessionId } as Record); +} + +describe('session-tools encryption-at-rest (ADR-096 Phase 2)', () => { + let workdir: string; + let cwdSpy: ReturnType | null = null; + + beforeEach(() => { + saveEnv('CLAUDE_FLOW_ENCRYPT_AT_REST', 'CLAUDE_FLOW_ENCRYPTION_KEY'); + workdir = mkdtempSync(join(tmpdir(), 'session-enc-')); + // Pin process.cwd() so session-tools writes into our temp dir + cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(workdir); + }); + + afterEach(() => { + cwdSpy?.mockRestore(); + rmSync(workdir, { recursive: true, force: true }); + restoreEnv(); + }); + + describe('encryption disabled (legacy plaintext)', () => { + beforeEach(() => { + delete process.env.CLAUDE_FLOW_ENCRYPT_AT_REST; + delete process.env.CLAUDE_FLOW_ENCRYPTION_KEY; + }); + + it('writes plaintext JSON to disk', async () => { + const r = (await runSave('audit-test')) as { sessionId: string }; + const onDisk = readFileSync(pickSessionFile(workdir)); + // First byte is "{" not "R" — definitely not encrypted + expect(onDisk[0]).toBe(0x7b); + expect(isEncryptedBlob(onDisk)).toBe(false); + // Sanity: the JSON parses and the sessionId round-trips + const parsed = JSON.parse(onDisk.toString('utf-8')); + expect(parsed.sessionId).toBe(r.sessionId); + expect(parsed.name).toBe('audit-test'); + }); + + it('round-trips save → restore', async () => { + const saved = (await runSave('rt-plain')) as { sessionId: string }; + const restored = (await runRestore(saved.sessionId)) as { + restored?: boolean; + name?: string; + }; + expect(restored.restored).toBe(true); + expect(restored.name).toBe('rt-plain'); + }); + }); + + describe('encryption enabled (RFE1 wire format)', () => { + beforeEach(() => { + process.env.CLAUDE_FLOW_ENCRYPT_AT_REST = '1'; + process.env.CLAUDE_FLOW_ENCRYPTION_KEY = randomBytes(32).toString('hex'); + }); + + it('writes a blob that starts with the RFE1 magic', async () => { + await runSave('encrypted-test'); + const onDisk = readFileSync(pickSessionFile(workdir)); + expect(onDisk.subarray(0, 4)).toEqual(MAGIC); + expect(isEncryptedBlob(onDisk)).toBe(true); + }); + + it('produces a blob that does not contain the plaintext name', async () => { + // Defense-in-depth check: the plaintext name shouldn't appear + // anywhere in the on-disk bytes if encryption actually ran. + const secretName = `top-secret-${randomBytes(8).toString('hex')}`; + await runSave(secretName); + const onDisk = readFileSync(pickSessionFile(workdir)); + expect(onDisk.includes(Buffer.from(secretName, 'utf-8'))).toBe(false); + }); + + it('round-trips save → restore using the same key', async () => { + const saved = (await runSave('rt-encrypted')) as { sessionId: string }; + const restored = (await runRestore(saved.sessionId)) as { + restored?: boolean; + name?: string; + }; + expect(restored.restored).toBe(true); + expect(restored.name).toBe('rt-encrypted'); + }); + }); + + describe('migration: encrypt-on-write only, plaintext stays readable', () => { + it('reads a legacy plaintext file even after encryption is enabled', async () => { + // Step 1: save with encryption OFF → plaintext on disk + delete process.env.CLAUDE_FLOW_ENCRYPT_AT_REST; + delete process.env.CLAUDE_FLOW_ENCRYPTION_KEY; + const saved = (await runSave('legacy-plain')) as { sessionId: string }; + + // Step 2: turn encryption ON for the read + process.env.CLAUDE_FLOW_ENCRYPT_AT_REST = '1'; + process.env.CLAUDE_FLOW_ENCRYPTION_KEY = randomBytes(32).toString('hex'); + + // Step 3: restore should still find the plaintext file via the + // magic-byte sniff (legacy file → not encrypted → returned as-is) + const restored = (await runRestore(saved.sessionId)) as { + restored?: boolean; + name?: string; + }; + expect(restored.restored).toBe(true); + expect(restored.name).toBe('legacy-plain'); + }); + }); + + describe('listSessions handles a mixed dir', () => { + it('enumerates plaintext + encrypted sessions in one call', async () => { + // First session: plaintext + delete process.env.CLAUDE_FLOW_ENCRYPT_AT_REST; + delete process.env.CLAUDE_FLOW_ENCRYPTION_KEY; + await runSave('plain-1'); + + // Second session: encrypted + process.env.CLAUDE_FLOW_ENCRYPT_AT_REST = '1'; + process.env.CLAUDE_FLOW_ENCRYPTION_KEY = randomBytes(32).toString('hex'); + await runSave('encrypted-1'); + + const result = (await sessionListTool.handler({} as Record)) as { + sessions: Array<{ name?: string }>; + }; + const names = result.sessions.map(s => s.name).filter(Boolean).sort(); + expect(names).toEqual(['encrypted-1', 'plain-1']); + }); + }); +}); diff --git a/v3/@claude-flow/cli/src/fs-secure.ts b/v3/@claude-flow/cli/src/fs-secure.ts index 1872db9132..a4e5b69158 100644 --- a/v3/@claude-flow/cli/src/fs-secure.ts +++ b/v3/@claude-flow/cli/src/fs-secure.ts @@ -9,9 +9,22 @@ * These helpers write atomically and force mode 0600 (files) / 0700 (dirs). * chmod fails silently on Windows, where POSIX modes don't apply — that's * fine, the OS-level ACL surface there is different. + * + * ADR-096 Phase 2: optional opt-in encryption-at-rest. When the caller + * passes `encrypt: true` AND the env-gated vault is enabled, payloads are + * AES-256-GCM-encrypted before hitting disk. Reads use the magic-byte + * sniff so legacy plaintext files keep working unchanged during the + * incremental migration. */ -import { chmodSync, mkdirSync, writeFileSync } from 'node:fs'; +import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { + decryptBuffer, + encryptBuffer, + getKey, + isEncryptedBlob, + isEncryptionEnabled, +} from './encryption/vault.js'; /** * Create a directory tree with mode 0700 (owner-only). No-op if exists. @@ -21,20 +34,97 @@ export function mkdirRestricted(path: string): void { mkdirSync(path, { recursive: true, mode: 0o700 }); } +/** + * Options for writeFileRestricted. Object form so we can grow the API + * without churning every call site. + */ +export interface WriteOptions { + /** Buffer encoding when `data` is a string. Ignored for Buffer payloads. */ + encoding?: BufferEncoding; + /** + * If true AND encryption is globally enabled (CLAUDE_FLOW_ENCRYPT_AT_REST), + * encrypt the payload with AES-256-GCM before writing. If encryption is + * NOT enabled, this flag is silently ignored — the legacy plaintext path + * runs unchanged. Default: false. + */ + encrypt?: boolean; +} + /** * Write a file and tighten its permissions to mode 0600 (owner read/write). - * Equivalent to writeFileSync followed by chmod, but emphasizes intent — - * any subsequent reader auditing the codebase can grep for this name. + * + * Two call signatures, both supported (the legacy positional one keeps + * existing call sites working without churn): + * + * writeFileRestricted(path, data) // plaintext, utf-8 + * writeFileRestricted(path, data, 'utf-8') // legacy: encoding only + * writeFileRestricted(path, data, { encrypt: true }) // opt-in encryption */ export function writeFileRestricted( path: string, data: string | Buffer, - encoding: BufferEncoding = 'utf-8', + optsOrEncoding: BufferEncoding | WriteOptions = 'utf-8', ): void { - writeFileSync(path, data, encoding); + const opts: WriteOptions = + typeof optsOrEncoding === 'string' + ? { encoding: optsOrEncoding } + : optsOrEncoding; + const encoding = opts.encoding ?? 'utf-8'; + + let payload: string | Buffer = data; + if (opts.encrypt && isEncryptionEnabled()) { + const plaintext = Buffer.isBuffer(data) ? data : Buffer.from(data, encoding); + payload = encryptBuffer(plaintext, getKey()); + } + + // For encrypted payloads we always have a Buffer — pass through without an + // encoding so writeFileSync doesn't try to text-decode it. + if (Buffer.isBuffer(payload)) { + writeFileSync(path, payload); + } else { + writeFileSync(path, payload, encoding); + } + try { chmodSync(path, 0o600); } catch { // Windows / FS without POSIX modes — silently skip. } } + +/** + * Read a file and transparently decrypt if it carries the RFE1 magic. + * + * Returns a string when the caller asks for one (default utf-8). Returns + * a Buffer when `encoding` is null. This matches Node's readFileSync + * shape so the function is a near-drop-in replacement. + * + * Migration semantics: + * - If the file IS encrypted, decrypt and return. + * - If the file is NOT encrypted, return its raw bytes (string-decoded + * under `encoding` if requested). + * + * That means a reader can be migrated *first*, before its writer flips + * `encrypt: true`, without breaking on the legacy plaintext path. + */ +export function readFileMaybeEncrypted( + path: string, + encoding?: BufferEncoding, +): string; +export function readFileMaybeEncrypted( + path: string, + encoding: null, +): Buffer; +export function readFileMaybeEncrypted( + path: string, + encoding: BufferEncoding | null = 'utf-8', +): string | Buffer { + const raw = readFileSync(path); + let plain: Buffer; + if (isEncryptedBlob(raw)) { + plain = decryptBuffer(raw, getKey()); + } else { + plain = raw; + } + return encoding === null ? plain : plain.toString(encoding); +} diff --git a/v3/@claude-flow/cli/src/mcp-tools/session-tools.ts b/v3/@claude-flow/cli/src/mcp-tools/session-tools.ts index 43acdff428..fa57a39d37 100644 --- a/v3/@claude-flow/cli/src/mcp-tools/session-tools.ts +++ b/v3/@claude-flow/cli/src/mcp-tools/session-tools.ts @@ -7,7 +7,11 @@ import { existsSync, readFileSync, readdirSync, unlinkSync, statSync } from 'node:fs'; import { join } from 'node:path'; import { type MCPTool, getProjectCwd } from './types.js'; -import { mkdirRestricted, writeFileRestricted } from '../fs-secure.js'; +import { + mkdirRestricted, + readFileMaybeEncrypted, + writeFileRestricted, +} from '../fs-secure.js'; import { validateIdentifier, validateText } from './validate-input.js'; // Storage paths @@ -53,7 +57,10 @@ function loadSession(sessionId: string): SessionRecord | null { try { const path = getSessionPath(sessionId); if (existsSync(path)) { - const data = readFileSync(path, 'utf-8'); + // ADR-096 Phase 2: readFileMaybeEncrypted transparently handles both + // legacy plaintext sessions and post-migration encrypted ones via the + // RFE1 magic-byte sniff. + const data = readFileMaybeEncrypted(path, 'utf-8'); return JSON.parse(data); } } catch { @@ -66,7 +73,14 @@ function saveSession(session: SessionRecord): void { ensureSessionDir(); // audit_1776853149979: session JSON contains memory snapshots and agent // prompts — restrict to owner read/write. - writeFileRestricted(getSessionPath(session.sessionId), JSON.stringify(session, null, 2)); + // ADR-096 Phase 2: opt-in encrypt-at-rest. The encrypt flag is honored + // only when CLAUDE_FLOW_ENCRYPT_AT_REST is set; otherwise the legacy + // plaintext path runs unchanged. + writeFileRestricted( + getSessionPath(session.sessionId), + JSON.stringify(session, null, 2), + { encrypt: true }, + ); } function listSessions(): SessionRecord[] { @@ -77,7 +91,9 @@ function listSessions(): SessionRecord[] { const sessions: SessionRecord[] = []; for (const file of files) { try { - const data = readFileSync(join(dir, file), 'utf-8'); + // ADR-096 Phase 2: same magic-byte sniff for the listing path so a + // mixed plaintext+encrypted dir still enumerates cleanly. + const data = readFileMaybeEncrypted(join(dir, file), 'utf-8'); sessions.push(JSON.parse(data)); } catch { // Skip invalid files