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:<base64>'.

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.
This commit is contained in:
Administrator 2026-04-19 11:53:59 +02:00
parent b80ca7238d
commit 60ceea41d7
3 changed files with 98 additions and 9 deletions

View File

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

75
lib/secret-store.js Normal file
View File

@ -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 };

11
main.js
View File

@ -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');