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); + }); +});