mirror of
https://github.com/ruvnet/ruflo.git
synced 2026-11-10 06:23:28 +09:00
feat(encryption): ADR-096 Phase 2 — wire vault into session-tools
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.
This commit is contained in:
1 parent
cb9a9f346c
commit
98aa2560e5
3 files changed
+293
-9
No files matched your search
@@ -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<string, string | undefined> = {};
|
||||
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-<ts>-<rand>.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<string, unknown>);
|
||||
}
|
||||
async function runRestore(sessionId: string) {
|
||||
return sessionRestoreTool.handler({ sessionId } as Record<string, unknown>);
|
||||
}
|
||||
|
||||
describe('session-tools encryption-at-rest (ADR-096 Phase 2)', () => {
|
||||
let workdir: string;
|
||||
let cwdSpy: ReturnType<typeof vi.spyOn> | 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<string, unknown>)) as {
|
||||
sessions: Array<{ name?: string }>;
|
||||
};
|
||||
const names = result.sessions.map(s => s.name).filter(Boolean).sort();
|
||||
expect(names).toEqual(['encrypted-1', 'plain-1']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user