From 5475a8b9e6f7e4ba303b4f1bc6b228816282147e Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 8 May 2026 09:30:13 +0000 Subject: [PATCH] security: encrypt token cache and configuration at rest Implemented encryption for `config.json` and `token-cache.json` using `aes-256-ctr` with a key derived from system information. This provides defense in depth for sensitive credentials like authentication tokens and client secrets. Added backward compatibility to read existing plaintext files and updated tests to handle the new encrypted format. Co-authored-by: cmuench <211294+cmuench@users.noreply.github.com> --- lib/config.js | 58 ++++++++++++++++++-- tests/config.test.js | 65 +++++++++++++++------- tests/token_encryption_repro.test.js | 81 ++++++++++++++++++++++++++++ 3 files changed, 180 insertions(+), 24 deletions(-) create mode 100644 tests/token_encryption_repro.test.js diff --git a/lib/config.js b/lib/config.js index a74025d..b67b2f7 100644 --- a/lib/config.js +++ b/lib/config.js @@ -2,6 +2,7 @@ import fs from 'fs'; import path from 'path'; import envPaths from 'env-paths'; import { mkdirp } from 'mkdirp'; +import crypto from 'crypto'; import os from 'os'; @@ -38,13 +39,49 @@ async function migrateOldConfig() { } } +function getEncryptionKey() { + const info = os.userInfo().username + os.hostname(); + return crypto.createHash('sha256').update(info).digest(); +} + +function encrypt(text) { + const iv = crypto.randomBytes(16); + const key = getEncryptionKey(); + const cipher = crypto.createCipheriv('aes-256-ctr', key, iv); + const encrypted = Buffer.concat([cipher.update(text), cipher.final()]); + return iv.toString('hex') + ':' + encrypted.toString('hex'); +} + +function decrypt(text) { + try { + const textParts = text.split(':'); + const iv = Buffer.from(textParts.shift(), 'hex'); + const encryptedText = Buffer.from(textParts.join(':'), 'hex'); + const key = getEncryptionKey(); + const decipher = crypto.createDecipheriv('aes-256-ctr', key, iv); + const decrypted = Buffer.concat([decipher.update(encryptedText), decipher.final()]); + return decrypted.toString(); + } catch (e) { + return null; + } +} + export async function loadConfig() { try { await migrateOldConfig(); if (!fs.existsSync(CONFIG_FILE)) { return { $schema: "https://mage-remote-run.muench.dev/config.schema.json", profiles: {}, activeProfile: null, plugins: [] }; } - const data = fs.readFileSync(CONFIG_FILE, 'utf-8'); + const rawData = fs.readFileSync(CONFIG_FILE, 'utf-8'); + let data = rawData; + + if (!rawData.trim().startsWith('{')) { + const decrypted = decrypt(rawData); + if (decrypted) { + data = decrypted; + } + } + const config = JSON.parse(data); if (!config.plugins) { config.plugins = []; @@ -59,7 +96,9 @@ export async function loadConfig() { export async function saveConfig(config) { try { await mkdirp(CONFIG_DIR); - fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 0o600 }); + const data = JSON.stringify(config, null, 2); + const encryptedData = encrypt(data); + fs.writeFileSync(CONFIG_FILE, encryptedData, { mode: 0o600 }); fs.chmodSync(CONFIG_FILE, 0o600); if (process.env.DEBUG) { console.log(`Configuration saved to ${CONFIG_FILE}`); @@ -98,7 +137,16 @@ export async function loadTokenCache() { if (!fs.existsSync(TOKEN_CACHE_FILE)) { return {}; } - const data = fs.readFileSync(TOKEN_CACHE_FILE, 'utf-8'); + const rawData = fs.readFileSync(TOKEN_CACHE_FILE, 'utf-8'); + let data = rawData; + + if (!rawData.trim().startsWith('{')) { + const decrypted = decrypt(rawData); + if (decrypted) { + data = decrypted; + } + } + return JSON.parse(data); } catch (e) { console.error("Error loading token cache:", e.message); @@ -109,7 +157,9 @@ export async function loadTokenCache() { export async function saveTokenCache(cache) { try { await mkdirp(CACHE_DIR); - fs.writeFileSync(TOKEN_CACHE_FILE, JSON.stringify(cache, null, 2), { mode: 0o600 }); + const data = JSON.stringify(cache, null, 2); + const encryptedData = encrypt(data); + fs.writeFileSync(TOKEN_CACHE_FILE, encryptedData, { mode: 0o600 }); fs.chmodSync(TOKEN_CACHE_FILE, 0o600); } catch (e) { console.error("Error saving token cache:", e.message); diff --git a/tests/config.test.js b/tests/config.test.js index 58ccbfa..f34c194 100644 --- a/tests/config.test.js +++ b/tests/config.test.js @@ -1,5 +1,6 @@ import { jest } from '@jest/globals'; import path from 'path'; +import crypto from 'crypto'; // Helper to verify path handling const isDarwin = process.platform === 'darwin'; @@ -36,7 +37,9 @@ jest.unstable_mockModule('env-paths', () => ({ jest.unstable_mockModule('os', () => ({ default: { - homedir: jest.fn(() => MOCK_HOME) + homedir: jest.fn(() => MOCK_HOME), + userInfo: jest.fn(() => ({ username: 'testuser' })), + hostname: jest.fn(() => 'testhost') } })); @@ -45,6 +48,21 @@ const configMod = await import('../lib/config.js'); const fs = (await import('fs')).default; const { mkdirp } = await import('mkdirp'); +function decrypt(text) { + try { + const textParts = text.split(':'); + const iv = Buffer.from(textParts.shift(), 'hex'); + const encryptedText = Buffer.from(textParts.join(':'), 'hex'); + const info = 'testuser' + 'testhost'; + const key = crypto.createHash('sha256').update(info).digest(); + const decipher = crypto.createDecipheriv('aes-256-ctr', key, iv); + const decrypted = Buffer.concat([decipher.update(encryptedText), decipher.final()]); + return decrypted.toString(); + } catch (e) { + return null; + } +} + describe('Config Management', () => { let consoleLogSpy; let consoleErrorSpy; @@ -121,11 +139,14 @@ describe('Config Management', () => { await configMod.saveConfig(config); expect(mkdirp).toHaveBeenCalledWith(EXPECTED_CONFIG_DIR); - expect(fs.writeFileSync).toHaveBeenCalledWith( - EXPECTED_CONFIG_FILE, - JSON.stringify(config, null, 2), - { mode: 0o600 } - ); + + const writeCall = fs.writeFileSync.mock.calls.find(call => call[0] === EXPECTED_CONFIG_FILE); + expect(writeCall).toBeDefined(); + expect(writeCall[2]).toEqual({ mode: 0o600 }); + + const decrypted = JSON.parse(decrypt(writeCall[1])); + expect(decrypted).toEqual(config); + expect(fs.chmodSync).toHaveBeenCalledWith(EXPECTED_CONFIG_FILE, 0o600); }); @@ -163,15 +184,14 @@ describe('Config Management', () => { await configMod.addProfile('NewProfile', { url: 'http://test.com' }); // Verify saveConfig was called with correct data - expect(fs.writeFileSync).toHaveBeenCalledWith( - EXPECTED_CONFIG_FILE, - expect.stringContaining('"NewProfile":'), - expect.anything() - ); + const writeCall = fs.writeFileSync.mock.calls.find(call => call[0] === EXPECTED_CONFIG_FILE); + expect(writeCall).toBeDefined(); + + const decrypted = decrypt(writeCall[1]); + expect(decrypted).toContain('"NewProfile":'); // Verify activeProfile was set - const saveCall = fs.writeFileSync.mock.calls[0]; - const savedConfig = JSON.parse(saveCall[1]); + const savedConfig = JSON.parse(decrypted); expect(savedConfig.activeProfile).toBe('NewProfile'); expect(savedConfig.profiles['NewProfile']).toEqual({ url: 'http://test.com' }); }); @@ -187,8 +207,10 @@ describe('Config Management', () => { await configMod.addProfile('NewProfile', { url: 'http://test.com' }); - const saveCall = fs.writeFileSync.mock.calls[0]; - const savedConfig = JSON.parse(saveCall[1]); + const writeCall = fs.writeFileSync.mock.calls.find(call => call[0] === EXPECTED_CONFIG_FILE); + expect(writeCall).toBeDefined(); + + const savedConfig = JSON.parse(decrypt(writeCall[1])); expect(savedConfig.activeProfile).toBe('Existing'); expect(savedConfig.profiles['NewProfile']).toEqual({ url: 'http://test.com' }); }); @@ -272,11 +294,14 @@ describe('Config Management', () => { await configMod.saveTokenCache(cache); expect(mkdirp).toHaveBeenCalledWith(EXPECTED_CACHE_DIR); - expect(fs.writeFileSync).toHaveBeenCalledWith( - EXPECTED_TOKEN_CACHE_FILE, - JSON.stringify(cache, null, 2), - { mode: 0o600 } - ); + + const writeCall = fs.writeFileSync.mock.calls.find(call => call[0] === EXPECTED_TOKEN_CACHE_FILE); + expect(writeCall).toBeDefined(); + expect(writeCall[2]).toEqual({ mode: 0o600 }); + + const decrypted = JSON.parse(decrypt(writeCall[1])); + expect(decrypted).toEqual(cache); + expect(fs.chmodSync).toHaveBeenCalledWith(EXPECTED_TOKEN_CACHE_FILE, 0o600); }); diff --git a/tests/token_encryption_repro.test.js b/tests/token_encryption_repro.test.js new file mode 100644 index 0000000..d3f966a --- /dev/null +++ b/tests/token_encryption_repro.test.js @@ -0,0 +1,81 @@ +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import { jest } from '@jest/globals'; + +const TEST_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'mage-remote-run-vulnerability-')); +const CONFIG_DIR = path.join(TEST_DIR, 'config'); +const CACHE_DIR = path.join(TEST_DIR, 'cache'); + +jest.unstable_mockModule('env-paths', () => ({ + default: () => ({ + config: CONFIG_DIR, + cache: CACHE_DIR + }) +})); + +jest.unstable_mockModule('os', () => ({ + default: { + ...os, + homedir: () => TEST_DIR, + userInfo: () => ({ username: 'testuser' }), + hostname: () => 'testhost' + } +})); + +const { loadTokenCache, saveTokenCache, loadConfig, saveConfig } = await import('../lib/config.js'); + +describe('Token Encryption Verification', () => { + afterAll(() => { + fs.rmSync(TEST_DIR, { recursive: true, force: true }); + }); + + test('token cache is stored ENCRYPTED', async () => { + const cache = { "test-token": "secret-value" }; + await saveTokenCache(cache); + + const cacheFile = path.join(CACHE_DIR, 'token-cache.json'); + const content = fs.readFileSync(cacheFile, 'utf8'); + + // It is NO LONGER plaintext JSON + expect(() => JSON.parse(content)).toThrow(); + expect(content).toContain(':'); // IV:Data format + + // But we can still load it + const loadedCache = await loadTokenCache(); + expect(loadedCache).toEqual(cache); + }); + + test('config is stored ENCRYPTED', async () => { + const config = { profiles: { "test": { "auth": { "clientSecret": "very-secret" } } } }; + await saveConfig(config); + + let configFile; + if (process.platform === 'darwin') { + configFile = path.join(TEST_DIR, '.config', 'mage-remote-run', 'config.json'); + } else { + configFile = path.join(CONFIG_DIR, 'config.json'); + } + + const content = fs.readFileSync(configFile, 'utf8'); + + // It is NO LONGER plaintext JSON + expect(() => JSON.parse(content)).toThrow(); + + // But we can still load it + const loadedConfig = await loadConfig(); + expect(loadedConfig).toEqual(expect.objectContaining({ + profiles: config.profiles + })); + }); + + test('can still load OLD plaintext files', async () => { + const oldCache = { "old": "data" }; + const cacheFile = path.join(CACHE_DIR, 'token-cache.json'); + fs.mkdirSync(CACHE_DIR, { recursive: true }); + fs.writeFileSync(cacheFile, JSON.stringify(oldCache)); + + const loadedCache = await loadTokenCache(); + expect(loadedCache).toEqual(oldCache); + }); +});