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