diff --git a/src/docker-manager.ts b/src/docker-manager.ts index e0ba703c..a3990117 100644 --- a/src/docker-manager.ts +++ b/src/docker-manager.ts @@ -6,7 +6,7 @@ import execa from 'execa'; import { DockerComposeConfig, WrapperConfig, BlockedTarget, API_PROXY_PORTS, API_PROXY_HEALTH_PORT } from './types'; import { logger } from './logger'; import { generateSquidConfig } from './squid-config'; -import { generateSessionCa, initSslDb, CaFiles, parseUrlPatterns } from './ssl-bump'; +import { generateSessionCa, initSslDb, CaFiles, parseUrlPatterns, cleanupSslKeyMaterial, unmountSslTmpfs } from './ssl-bump'; const SQUID_PORT = 3128; @@ -1618,6 +1618,15 @@ export async function cleanup(workDir: string, keepFiles: boolean, proxyLogsDir? } } + // Securely wipe SSL key material before deleting workDir + cleanupSslKeyMaterial(workDir); + + // Unmount tmpfs if it was used for SSL keys (data destroyed on unmount) + const sslDir = path.join(workDir, 'ssl'); + if (fs.existsSync(sslDir)) { + await unmountSslTmpfs(sslDir); + } + // Clean up workDir fs.rmSync(workDir, { recursive: true, force: true }); diff --git a/src/ssl-bump.test.ts b/src/ssl-bump.test.ts index acf775ab..5e4a3703 100644 --- a/src/ssl-bump.test.ts +++ b/src/ssl-bump.test.ts @@ -2,7 +2,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import execa from 'execa'; -import { parseUrlPatterns, generateSessionCa, initSslDb, isOpenSslAvailable } from './ssl-bump'; +import { parseUrlPatterns, generateSessionCa, initSslDb, isOpenSslAvailable, secureWipeFile, cleanupSslKeyMaterial } from './ssl-bump'; // Pattern constant for the safer URL character class (matches the implementation) const URL_CHAR_PATTERN = '[^\\s]*'; @@ -17,6 +17,10 @@ const mockExeca = execa as unknown as jest.Mock; beforeEach(() => { mockExeca.mockReset(); mockExeca.mockImplementation((cmd: string, args: string[]) => { + if (cmd === 'mount' || cmd === 'umount') { + // tmpfs mount/unmount - fail gracefully in tests (no root privileges) + return Promise.reject(new Error('mount not available in test')); + } if (cmd === 'openssl') { if (args[0] === 'version') { return Promise.resolve({ stdout: 'OpenSSL 3.0.0 7 Sep 2021' }); @@ -169,7 +173,10 @@ describe('SSL Bump', () => { }); it('should throw error when OpenSSL command fails', async () => { - mockExeca.mockImplementationOnce(() => { + mockExeca.mockImplementation((cmd: string) => { + if (cmd === 'mount') { + return Promise.reject(new Error('mount not available')); + } return Promise.reject(new Error('OpenSSL not found')); }); @@ -177,6 +184,16 @@ describe('SSL Bump', () => { 'Failed to generate SSL Bump CA: OpenSSL not found' ); }); + + it('should attempt tmpfs mount for ssl directory', async () => { + await generateSessionCa({ workDir: tempDir }); + + // Verify mount was attempted + expect(mockExeca).toHaveBeenCalledWith( + 'mount', + expect.arrayContaining(['-t', 'tmpfs', 'tmpfs']), + ); + }); }); describe('initSslDb', () => { @@ -253,4 +270,86 @@ describe('SSL Bump', () => { expect(result).toBe(false); }); }); + + describe('secureWipeFile', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wipe-test-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('should overwrite and delete a file', () => { + const filePath = path.join(tempDir, 'secret.key'); + fs.writeFileSync(filePath, 'SECRET KEY DATA'); + + secureWipeFile(filePath); + + expect(fs.existsSync(filePath)).toBe(false); + }); + + it('should handle non-existent files gracefully', () => { + const filePath = path.join(tempDir, 'nonexistent.key'); + expect(() => secureWipeFile(filePath)).not.toThrow(); + }); + + it('should handle empty files', () => { + const filePath = path.join(tempDir, 'empty.key'); + fs.writeFileSync(filePath, ''); + + secureWipeFile(filePath); + + expect(fs.existsSync(filePath)).toBe(false); + }); + }); + + describe('cleanupSslKeyMaterial', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ssl-cleanup-test-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('should wipe all SSL files', () => { + const sslDir = path.join(tempDir, 'ssl'); + fs.mkdirSync(sslDir, { mode: 0o700 }); + fs.writeFileSync(path.join(sslDir, 'ca-key.pem'), 'PRIVATE KEY'); + fs.writeFileSync(path.join(sslDir, 'ca-cert.pem'), 'CERTIFICATE'); + fs.writeFileSync(path.join(sslDir, 'ca-cert.der'), 'DER CERT'); + + cleanupSslKeyMaterial(tempDir); + + expect(fs.existsSync(path.join(sslDir, 'ca-key.pem'))).toBe(false); + expect(fs.existsSync(path.join(sslDir, 'ca-cert.pem'))).toBe(false); + expect(fs.existsSync(path.join(sslDir, 'ca-cert.der'))).toBe(false); + }); + + it('should wipe ssl_db certificate files', () => { + const sslDir = path.join(tempDir, 'ssl'); + fs.mkdirSync(sslDir, { mode: 0o700 }); + fs.writeFileSync(path.join(sslDir, 'ca-key.pem'), 'KEY'); + + const sslDbDir = path.join(tempDir, 'ssl_db'); + const certsDir = path.join(sslDbDir, 'certs'); + fs.mkdirSync(certsDir, { recursive: true }); + fs.writeFileSync(path.join(certsDir, 'cert1.pem'), 'CERT1'); + fs.writeFileSync(path.join(certsDir, 'cert2.pem'), 'CERT2'); + + cleanupSslKeyMaterial(tempDir); + + expect(fs.existsSync(path.join(certsDir, 'cert1.pem'))).toBe(false); + expect(fs.existsSync(path.join(certsDir, 'cert2.pem'))).toBe(false); + }); + + it('should handle missing ssl directory gracefully', () => { + expect(() => cleanupSslKeyMaterial(tempDir)).not.toThrow(); + }); + }); }); diff --git a/src/ssl-bump.ts b/src/ssl-bump.ts index 5bd6dafe..437b2d5c 100644 --- a/src/ssl-bump.ts +++ b/src/ssl-bump.ts @@ -5,7 +5,8 @@ * for Squid SSL Bump mode, which enables URL path filtering for HTTPS traffic. * * Security considerations: - * - CA key is stored only in workDir (tmpfs-backed in container) + * - CA key is stored in tmpfs (memory-only) when possible, never hitting disk + * - Keys are securely wiped (overwritten with random data) before deletion * - Certificate is valid for 1 day only * - Private key is never logged * - CA is unique per session @@ -13,6 +14,7 @@ import * as fs from 'fs'; import * as path from 'path'; +import * as crypto from 'crypto'; import execa from 'execa'; import { logger } from './logger'; @@ -40,6 +42,117 @@ export interface CaFiles { derPath: string; } +/** + * Mounts a tmpfs filesystem at the given path so SSL keys are stored in memory only. + * Falls back gracefully if mount fails (e.g., insufficient permissions). + * + * @param sslDir - Directory path to mount tmpfs on + * @returns true if tmpfs was mounted, false if fallback to disk + */ +export async function mountSslTmpfs(sslDir: string): Promise { + try { + // Mount tmpfs with restrictive options (4MB is more than enough for SSL keys) + await execa('mount', [ + '-t', 'tmpfs', + '-o', 'size=4m,mode=0700,noexec,nosuid,nodev', + 'tmpfs', + sslDir, + ]); + + logger.debug(`tmpfs mounted at ${sslDir} for SSL key storage`); + return true; + } catch (error) { + logger.debug(`Could not mount tmpfs at ${sslDir} (falling back to disk): ${error}`); + return false; + } +} + +/** + * Unmounts a tmpfs filesystem. All data is immediately destroyed since tmpfs is memory-only. + * + * @param sslDir - Directory path where tmpfs was mounted + */ +export async function unmountSslTmpfs(sslDir: string): Promise { + try { + await execa('umount', [sslDir]); + logger.debug(`tmpfs unmounted at ${sslDir} - key material destroyed`); + } catch (error) { + logger.debug(`Could not unmount tmpfs at ${sslDir}: ${error}`); + } +} + +/** + * Securely wipes a file by overwriting its contents with random data before unlinking. + * This prevents recovery of sensitive key material from disk. + * + * @param filePath - Path to the file to securely wipe + */ +export function secureWipeFile(filePath: string): void { + try { + if (!fs.existsSync(filePath)) { + return; + } + + const stat = fs.statSync(filePath); + const size = stat.size; + + if (size > 0) { + // Overwrite with random data + const fd = fs.openSync(filePath, 'w'); + const randomData = crypto.randomBytes(size); + fs.writeSync(fd, randomData); + fs.fsyncSync(fd); + fs.closeSync(fd); + } + + fs.unlinkSync(filePath); + logger.debug(`Securely wiped: ${filePath}`); + } catch (error) { + // Best-effort: if secure wipe fails, still try to delete + try { + fs.unlinkSync(filePath); + } catch { + // Ignore deletion errors during cleanup + } + logger.debug(`Could not securely wipe ${filePath}: ${error}`); + } +} + +/** + * Securely cleans up SSL key material from the workDir. + * Overwrites private keys with random data before deletion to prevent recovery. + * + * @param workDir - Working directory containing ssl/ subdirectory + */ +export function cleanupSslKeyMaterial(workDir: string): void { + const sslDir = path.join(workDir, 'ssl'); + if (!fs.existsSync(sslDir)) { + return; + } + + logger.debug('Securely wiping SSL key material...'); + + // Wipe the private key (most sensitive) + secureWipeFile(path.join(sslDir, 'ca-key.pem')); + + // Wipe other SSL files + secureWipeFile(path.join(sslDir, 'ca-cert.pem')); + secureWipeFile(path.join(sslDir, 'ca-cert.der')); + + // Clean up ssl_db (contains generated per-host certificates) + const sslDbPath = path.join(workDir, 'ssl_db'); + if (fs.existsSync(sslDbPath)) { + const certsDir = path.join(sslDbPath, 'certs'); + if (fs.existsSync(certsDir)) { + for (const file of fs.readdirSync(certsDir)) { + secureWipeFile(path.join(certsDir, file)); + } + } + } + + logger.debug('SSL key material securely wiped'); +} + /** * Generates a self-signed CA certificate for SSL Bump * @@ -53,12 +166,20 @@ export interface CaFiles { export async function generateSessionCa(config: SslBumpConfig): Promise { const { workDir, commonName = 'AWF Session CA', validityDays = 1 } = config; - // Create ssl directory in workDir + // Create ssl directory in workDir, backed by tmpfs when possible const sslDir = path.join(workDir, 'ssl'); if (!fs.existsSync(sslDir)) { fs.mkdirSync(sslDir, { recursive: true, mode: 0o700 }); } + // Attempt to mount tmpfs so keys never touch disk + const usingTmpfs = await mountSslTmpfs(sslDir); + if (usingTmpfs) { + logger.info('SSL keys stored in memory-only filesystem (tmpfs)'); + } else { + logger.debug('SSL keys stored on disk (tmpfs mount not available)'); + } + const certPath = path.join(sslDir, 'ca-cert.pem'); const keyPath = path.join(sslDir, 'ca-key.pem'); const derPath = path.join(sslDir, 'ca-cert.der');