From 60ceea41d73ada7080f1b828bf5352cc2325c8fa Mon Sep 17 00:00:00 2001 From: Administrator Date: Sun, 19 Apr 2026 11:53:59 +0200 Subject: [PATCH] fix: encrypt hoster credentials at rest; history CSV Link column urls-only Two issues: 1. Verlauf-Export CSV put the opaque file_code in the Link column when the upload had no real URL, so the column looked like just a bunch of IDs. Now only real http(s) URLs land in that column. 2. Hoster passwords and API keys were stored as plaintext in electron-config.json. Now wrapped with Electron's safeStorage (DPAPI on Windows, Keychain on macOS, libsecret on Linux) and stored as 'enc:v1:'. Credentials are decrypted on load so in-memory flows stay unchanged, and backups still export plaintext inside the existing .mhu envelope so they remain portable between machines/users. Legacy plaintext configs auto-migrate on next write. --- lib/config-store.js | 21 ++++++++++--- lib/secret-store.js | 75 +++++++++++++++++++++++++++++++++++++++++++++ main.js | 11 ++++--- 3 files changed, 98 insertions(+), 9 deletions(-) create mode 100644 lib/secret-store.js diff --git a/lib/config-store.js b/lib/config-store.js index 9386b55..c271f58 100644 --- a/lib/config-store.js +++ b/lib/config-store.js @@ -1,5 +1,6 @@ const fs = require('fs'); const path = require('path'); +const secretStore = require('./secret-store'); const HOSTER_SETTINGS_DEFAULTS = { retries: 3, @@ -205,12 +206,24 @@ class ConfigStore { globalSettings[key] = { ...def, ...(savedGlobal[key] || {}) }; } } - return { hosters, hosterSettings, globalSettings, history: data.history || [] }; + const result = { hosters, hosterSettings, globalSettings, history: data.history || [] }; + // Decrypt credentials stored with safeStorage so the rest of the app + // keeps working with plaintext in memory. + secretStore.decryptCredentials(result); + return result; } catch { return JSON.parse(JSON.stringify(DEFAULTS)); } } + // Deep-clone a config and encrypt its credential fields. Never mutate the + // caller's object — the rest of the app holds plaintext references. + _serializeForDisk(config) { + const clone = JSON.parse(JSON.stringify(config)); + secretStore.encryptCredentials(clone); + return JSON.stringify(clone, null, 2); + } + _enqueueWrite(fn) { this._writeQueue = this._writeQueue.then(fn, fn); return this._writeQueue; @@ -222,7 +235,7 @@ class ConfigStore { if (config.hosters) current.hosters = config.hosters; if (config.hosterSettings) current.hosterSettings = config.hosterSettings; if (config.globalSettings) current.globalSettings = config.globalSettings; - return this._atomicWrite(JSON.stringify(current, null, 2)); + return this._atomicWrite(this._serializeForDisk(current)); }); } @@ -255,7 +268,7 @@ class ConfigStore { return this._enqueueWrite(() => { const config = this.load(); config.history.push(entry); - return this._atomicWrite(JSON.stringify(config, null, 2)); + return this._atomicWrite(this._serializeForDisk(config)); }); } @@ -263,7 +276,7 @@ class ConfigStore { return this._enqueueWrite(() => { const config = this.load(); config.history = []; - return this._atomicWrite(JSON.stringify(config, null, 2)); + return this._atomicWrite(this._serializeForDisk(config)); }); } } diff --git a/lib/secret-store.js b/lib/secret-store.js new file mode 100644 index 0000000..51412e9 --- /dev/null +++ b/lib/secret-store.js @@ -0,0 +1,75 @@ +// Wraps Electron's safeStorage (OS-level credential encryption: DPAPI on +// Windows, Keychain on macOS, libsecret on Linux) to keep hoster passwords and +// API keys out of the plaintext electron-config.json. +// +// On Windows the DPAPI key is tied to the current user profile, so credentials +// encrypted here are only readable by the same Windows user. For backups we +// export to plaintext (the .mhu envelope has its own AES-GCM layer) so moving +// between machines/users works transparently. + +const SENTINEL = 'enc:v1:'; +const CRED_FIELDS = ['password', 'apiKey']; + +let _safeStorageCache = undefined; +function getSafeStorage() { + if (_safeStorageCache !== undefined) return _safeStorageCache; + try { + const { safeStorage } = require('electron'); + if (safeStorage && typeof safeStorage.isEncryptionAvailable === 'function' + && safeStorage.isEncryptionAvailable()) { + _safeStorageCache = safeStorage; + return _safeStorageCache; + } + } catch {} + _safeStorageCache = null; + return null; +} + +function isEncrypted(value) { + return typeof value === 'string' && value.startsWith(SENTINEL); +} + +function encryptField(value) { + if (!value || typeof value !== 'string') return value; + if (isEncrypted(value)) return value; + const ss = getSafeStorage(); + if (!ss) return value; + try { + const buf = ss.encryptString(value); + return SENTINEL + buf.toString('base64'); + } catch { + return value; + } +} + +function decryptField(value) { + if (!value || typeof value !== 'string') return value; + if (!isEncrypted(value)) return value; + const ss = getSafeStorage(); + if (!ss) return ''; + try { + const buf = Buffer.from(value.slice(SENTINEL.length), 'base64'); + return ss.decryptString(buf); + } catch { + return ''; + } +} + +function mapHosterAccounts(config, fn) { + if (!config || !config.hosters || typeof config.hosters !== 'object') return config; + for (const accounts of Object.values(config.hosters)) { + if (!Array.isArray(accounts)) continue; + for (const acc of accounts) { + if (!acc || typeof acc !== 'object') continue; + for (const f of CRED_FIELDS) { + if (acc[f]) acc[f] = fn(acc[f]); + } + } + } + return config; +} + +function encryptCredentials(config) { return mapHosterAccounts(config, encryptField); } +function decryptCredentials(config) { return mapHosterAccounts(config, decryptField); } + +module.exports = { encryptField, decryptField, encryptCredentials, decryptCredentials, isEncrypted }; diff --git a/main.js b/main.js index 0d10154..6c7f054 100644 --- a/main.js +++ b/main.js @@ -219,9 +219,10 @@ function flattenHistoryForExport(history) { } for (const result of results) { - const link = result && (result.download_url || result.embed_url || result.file_code) - ? String(result.download_url || result.embed_url || result.file_code) - : ''; + // Only accept real URLs. file_code alone is just an opaque ID and + // ends up looking like "nur sone Nummerierung" in the CSV. + const rawLink = result && (result.download_url || result.embed_url) || ''; + const link = /^https?:\/\//i.test(String(rawLink)) ? String(rawLink) : ''; rows.push({ batchId, batchTimestamp, @@ -1119,7 +1120,7 @@ ipcMain.handle('import-backup', async (_event, legacyPassword) => { globalSettings: importedGlobal, history: imported.history || [] }; - await configStore._atomicWrite(JSON.stringify(merged, null, 2)); + await configStore._atomicWrite(configStore._serializeForDisk(merged)); return { ok: true, config: configStore.load() }; }); @@ -1253,7 +1254,7 @@ ipcMain.on('save-global-settings-sync', (event, globalSettings) => { try { const current = configStore.load(); current.globalSettings = globalSettings; - const data = JSON.stringify(current, null, 2); + const data = configStore._serializeForDisk(current); const tmpPath = configStore.filePath + '.tmp'; const backupPath = configStore.filePath + '.bak'; fs.writeFileSync(tmpPath, data, 'utf-8');