Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 54 additions & 4 deletions lib/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 = [];
Expand All @@ -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}`);
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down
65 changes: 45 additions & 20 deletions tests/config.test.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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')
}
}));

Expand All @@ -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;
Expand Down Expand Up @@ -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);
});

Expand Down Expand Up @@ -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' });
});
Expand All @@ -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' });
});
Expand Down Expand Up @@ -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);
});

Expand Down
81 changes: 81 additions & 0 deletions tests/token_encryption_repro.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading