feat(backup): drop password prompt on export/import

File stays AES-GCM encrypted with a fixed app-internal key — opaque
without the app, which is the only protection we actually need for
locally-stored API keys. Removes the modal and both password dialogs.
This commit is contained in:
Administrator 2026-04-17 11:17:21 +02:00
parent 43433cbc00
commit 3e9483e222
6 changed files with 62 additions and 161 deletions

View File

@ -9,20 +9,24 @@ const ITERATIONS = 100_000;
const DIGEST = 'sha512';
const ALGO = 'aes-256-gcm';
function deriveKey(password, salt) {
return crypto.pbkdf2Sync(password, salt, ITERATIONS, KEY_LEN, DIGEST);
// Fixed app-internal passphrase — backups are opaque without the app, which is
// enough protection for API keys stored locally. We keep the AES-GCM envelope
// (with random salt/iv) so each export is still distinct and authenticated.
const APP_PASSPHRASE = 'multi-hoster-upload::backup::v1';
function deriveKey(salt) {
return crypto.pbkdf2Sync(APP_PASSPHRASE, salt, ITERATIONS, KEY_LEN, DIGEST);
}
/**
* Encrypt a config object with a password.
* Encrypt a config object.
* Returns a Buffer: MHU1 | salt(16) | iv(12) | tag(16) | ciphertext
*/
function encrypt(config, password) {
if (!password || typeof password !== 'string') throw new Error('Passwort darf nicht leer sein');
function encrypt(config) {
const plaintext = Buffer.from(JSON.stringify(config), 'utf-8');
const salt = crypto.randomBytes(SALT_LEN);
const iv = crypto.randomBytes(IV_LEN);
const key = deriveKey(password, salt);
const key = deriveKey(salt);
const cipher = crypto.createCipheriv(ALGO, key, iv);
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
@ -34,11 +38,10 @@ function encrypt(config, password) {
}
/**
* Decrypt a .mhu buffer with a password.
* Returns the config object or throws on invalid password/data.
* Decrypt a .mhu buffer.
* Returns the config object or throws on invalid data.
*/
function decrypt(buffer, password) {
if (!password || typeof password !== 'string') throw new Error('Passwort darf nicht leer sein');
function decrypt(buffer) {
if (buffer.length < MAGIC.length + SALT_LEN + IV_LEN + TAG_LEN + 1) {
throw new Error('Ungültiges Backup-Format');
}
@ -54,7 +57,7 @@ function decrypt(buffer, password) {
const tag = buffer.subarray(offset, offset += TAG_LEN);
const ciphertext = buffer.subarray(offset);
const key = deriveKey(password, salt);
const key = deriveKey(salt);
const decipher = crypto.createDecipheriv(ALGO, key, iv);
decipher.setAuthTag(tag);
@ -62,7 +65,7 @@ function decrypt(buffer, password) {
try {
decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
} catch {
throw new Error('Falsches Passwort oder beschädigte Datei');
throw new Error('Backup-Datei beschädigt oder aus einer inkompatiblen Version');
}
try {

10
main.js
View File

@ -966,20 +966,20 @@ ipcMain.handle('clear-history', async () => {
});
// --- Backup export / import ---
ipcMain.handle('export-backup', async (_event, password) => {
const config = configStore.load();
const encrypted = backupCrypto.encrypt(config, password);
ipcMain.handle('export-backup', async () => {
const { canceled, filePath } = await dialog.showSaveDialog(mainWindow, {
title: 'Backup exportieren',
defaultPath: `multi-hoster-backup-${new Date().toISOString().slice(0, 10)}.mhu`,
filters: [{ name: 'Multi-Hoster Backup', extensions: ['mhu'] }]
});
if (canceled || !filePath) return { ok: false, canceled: true };
const config = configStore.load();
const encrypted = backupCrypto.encrypt(config);
fs.writeFileSync(filePath, encrypted);
return { ok: true, path: filePath };
});
ipcMain.handle('import-backup', async (_event, password) => {
ipcMain.handle('import-backup', async () => {
const { canceled, filePaths } = await dialog.showOpenDialog(mainWindow, {
title: 'Backup importieren',
filters: [{ name: 'Multi-Hoster Backup', extensions: ['mhu'] }],
@ -987,7 +987,7 @@ ipcMain.handle('import-backup', async (_event, password) => {
});
if (canceled || !filePaths.length) return { ok: false, canceled: true };
const buffer = fs.readFileSync(filePaths[0]);
const imported = backupCrypto.decrypt(buffer, password);
const imported = backupCrypto.decrypt(buffer);
// Validate imported data has required structure
if (!imported || typeof imported !== 'object' || !imported.hosters || !imported.hosterSettings || !imported.globalSettings) {
return { ok: false, error: 'Backup-Datei hat ungültige Struktur (hosters, hosterSettings oder globalSettings fehlt).' };

View File

@ -59,8 +59,8 @@ contextBridge.exposeInMainWorld('api', {
},
// Backup
exportBackup: (password) => ipcRenderer.invoke('export-backup', password),
importBackup: (password) => ipcRenderer.invoke('import-backup', password),
exportBackup: () => ipcRenderer.invoke('export-backup'),
importBackup: () => ipcRenderer.invoke('import-backup'),
// Folder Monitor
folderMonitorStart: (settings) => ipcRenderer.invoke('folder-monitor:start', settings),

View File

@ -1130,77 +1130,36 @@ function copySelectedRecentLinks() {
if (links.length) { window.api.copyToClipboard(links.join('\n')); showCopyToast(`${links.length} Links kopiert`); }
}
// --- Backup modal ---
let _backupMode = null; // 'export' | 'import'
function openBackupModal(mode) {
_backupMode = mode;
const modal = document.getElementById('backupPasswordModal');
const title = document.getElementById('backupModalTitle');
const confirmRow = document.getElementById('backupConfirmRow');
const status = document.getElementById('backupModalStatus');
document.getElementById('backupPassword').value = '';
document.getElementById('backupPasswordConfirm').value = '';
status.textContent = '';
if (mode === 'export') {
title.textContent = 'Backup verschlüsseln';
confirmRow.style.display = '';
document.getElementById('confirmBackupBtn').textContent = 'Exportieren';
} else {
title.textContent = 'Backup entschlüsseln';
confirmRow.style.display = 'none';
document.getElementById('confirmBackupBtn').textContent = 'Importieren';
// --- Backup export / import ---
async function doBackupExport() {
try {
const result = await window.api.exportBackup();
if (result && result.ok) showCopyToast('Backup exportiert');
} catch (err) {
alert('Export fehlgeschlagen: ' + (err.message || err));
}
modal.style.display = 'flex';
document.getElementById('backupPassword').focus();
}
function closeBackupModal() {
document.getElementById('backupPasswordModal').style.display = 'none';
_backupMode = null;
}
async function confirmBackupAction() {
const pw = document.getElementById('backupPassword').value;
const status = document.getElementById('backupModalStatus');
if (!pw) { status.textContent = 'Bitte Passwort eingeben.'; return; }
if (_backupMode === 'export') {
const pw2 = document.getElementById('backupPasswordConfirm').value;
if (pw !== pw2) { status.textContent = 'Passwörter stimmen nicht überein.'; return; }
status.textContent = 'Exportiere...';
try {
const result = await window.api.exportBackup(pw);
if (result.canceled) { status.textContent = ''; return; }
if (result.ok) {
closeBackupModal();
showCopyToast('Backup exportiert');
}
} catch (err) {
status.textContent = err.message || 'Export fehlgeschlagen';
}
} else {
status.textContent = 'Importiere...';
try {
const result = await window.api.importBackup(pw);
if (result.canceled) { status.textContent = ''; return; }
if (result.ok) {
config = result.config;
hosterSettings = config.hosterSettings || {};
// Refresh global settings state (always-on-top, etc.)
alwaysOnTopState = !!(config.globalSettings && config.globalSettings.alwaysOnTop);
window.api.setAlwaysOnTop(alwaysOnTopState);
closeBackupModal();
renderSettings();
renderAccounts();
renderHosterSummary();
renderHosterModal();
loadHistory();
showCopyToast('Backup importiert');
}
} catch (err) {
status.textContent = err.message || 'Import fehlgeschlagen';
async function doBackupImport() {
try {
const result = await window.api.importBackup();
if (!result || result.canceled) return;
if (result.ok) {
config = result.config;
hosterSettings = config.hosterSettings || {};
alwaysOnTopState = !!(config.globalSettings && config.globalSettings.alwaysOnTop);
window.api.setAlwaysOnTop(alwaysOnTopState);
renderSettings();
renderAccounts();
renderHosterSummary();
renderHosterModal();
loadHistory();
showCopyToast('Backup importiert');
} else if (result.error) {
alert('Import fehlgeschlagen: ' + result.error);
}
} catch (err) {
alert('Import fehlgeschlagen: ' + (err.message || err));
}
}
@ -2288,8 +2247,8 @@ function renderSettings() {
arrow.innerHTML = isOpen ? '&#9654;' : '&#9660;';
});
document.getElementById('exportBackupBtn').addEventListener('click', () => openBackupModal('export'));
document.getElementById('importBackupBtn').addEventListener('click', () => openBackupModal('import'));
document.getElementById('exportBackupBtn').addEventListener('click', doBackupExport);
document.getElementById('importBackupBtn').addEventListener('click', doBackupImport);
// --- Separator before hoster panels ---
const separator = document.createElement('div');
@ -3239,20 +3198,6 @@ function setupListeners() {
});
document.getElementById('saveSettingsBtn').addEventListener('click', saveSettings);
// --- Backup export / import (modal listeners stay here, button listeners in renderSettings) ---
document.getElementById('closeBackupModalBtn').addEventListener('click', closeBackupModal);
document.getElementById('cancelBackupModalBtn').addEventListener('click', closeBackupModal);
document.getElementById('confirmBackupBtn').addEventListener('click', confirmBackupAction);
document.getElementById('backupPasswordModal').addEventListener('click', (e) => {
if (e.target.id === 'backupPasswordModal') closeBackupModal();
});
document.getElementById('backupPassword').addEventListener('keydown', (e) => {
if (e.key === 'Enter') confirmBackupAction();
});
document.getElementById('backupPasswordConfirm').addEventListener('keydown', (e) => {
if (e.key === 'Enter') confirmBackupAction();
});
document.getElementById('clearHistoryBtn').addEventListener('click', async () => {
if (!confirm('Verlauf wirklich löschen?')) return;
await window.api.clearHistory();

View File

@ -237,30 +237,6 @@
</div>
</div>
<div class="modal-overlay" id="backupPasswordModal" style="display:none">
<div class="modal-card" style="width:min(400px,100%)">
<div class="modal-header">
<div><h3 id="backupModalTitle">Passwort</h3></div>
<button class="icon-btn" id="closeBackupModalBtn" aria-label="Schließen">&times;</button>
</div>
<div class="modal-body">
<div class="settings-row">
<label for="backupPassword">Passwort</label>
<input type="password" class="key-input" id="backupPassword" placeholder="Passwort eingeben" autocomplete="off">
</div>
<div class="settings-row" id="backupConfirmRow" style="display:none">
<label for="backupPasswordConfirm">Bestätigen</label>
<input type="password" class="key-input" id="backupPasswordConfirm" placeholder="Passwort wiederholen" autocomplete="off">
</div>
<div class="account-modal-status" id="backupModalStatus"></div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="cancelBackupModalBtn">Abbrechen</button>
<button class="btn btn-primary" id="confirmBackupBtn">OK</button>
</div>
</div>
</div>
<div class="context-menu" id="contextMenu" style="display:none">
<div class="ctx-item" data-action="start-selected">Ausgewählte starten</div>
<div class="ctx-item" data-action="retry-selected">Erneut versuchen</div>

View File

@ -10,62 +10,39 @@ describe('backup-crypto', () => {
history: [{ file: 'test.mkv', link: 'https://example.com/abc' }]
};
it('encrypt then decrypt round-trips with correct password', () => {
const buf = encrypt(sampleConfig, 'mySecret!');
const result = decrypt(buf, 'mySecret!');
it('encrypt then decrypt round-trips', () => {
const buf = encrypt(sampleConfig);
const result = decrypt(buf);
assert.deepStrictEqual(result, sampleConfig);
});
it('decrypt with wrong password throws', () => {
const buf = encrypt(sampleConfig, 'correct');
assert.throws(() => decrypt(buf, 'wrong'), /Falsches Passwort/);
});
it('decrypt with corrupted data throws', () => {
const buf = encrypt(sampleConfig, 'pw');
const buf = encrypt(sampleConfig);
buf[buf.length - 1] ^= 0xff; // flip last byte
assert.throws(() => decrypt(buf, 'pw'), /Falsches Passwort/);
assert.throws(() => decrypt(buf), /beschädigt|inkompatiblen/);
});
it('decrypt with invalid magic throws', () => {
// Buffer must be long enough to pass the length check (>= 4+16+12+16+1 = 49)
const buf = Buffer.alloc(60, 0x41); // 60 bytes of 'A'
assert.throws(() => decrypt(buf, 'pw'), /Keine gültige/);
assert.throws(() => decrypt(buf), /Keine gültige/);
});
it('decrypt with too-short buffer throws', () => {
assert.throws(() => decrypt(Buffer.alloc(10), 'pw'), /Ungültiges Backup-Format/);
assert.throws(() => decrypt(Buffer.alloc(10)), /Ungültiges Backup-Format/);
});
it('handles empty config gracefully', () => {
const empty = { hosters: {}, hosterSettings: {}, globalSettings: {}, history: [] };
const buf = encrypt(empty, 'pw');
assert.deepStrictEqual(decrypt(buf, 'pw'), empty);
});
it('handles unicode passwords', () => {
const buf = encrypt(sampleConfig, 'Pässwört🔑');
const result = decrypt(buf, 'Pässwört🔑');
assert.deepStrictEqual(result, sampleConfig);
});
it('encrypt rejects empty password', () => {
assert.throws(() => encrypt(sampleConfig, ''), /Passwort/);
assert.throws(() => encrypt(sampleConfig, null), /Passwort/);
assert.throws(() => encrypt(sampleConfig, undefined), /Passwort/);
});
it('decrypt rejects empty password', () => {
const buf = encrypt(sampleConfig, 'valid');
assert.throws(() => decrypt(buf, ''), /Passwort/);
assert.throws(() => decrypt(buf, null), /Passwort/);
const buf = encrypt(empty);
assert.deepStrictEqual(decrypt(buf), empty);
});
it('each encryption produces different output (random salt/iv)', () => {
const a = encrypt(sampleConfig, 'same');
const b = encrypt(sampleConfig, 'same');
const a = encrypt(sampleConfig);
const b = encrypt(sampleConfig);
assert.ok(!a.equals(b), 'two encryptions should differ');
// but both decrypt to same result
assert.deepStrictEqual(decrypt(a, 'same'), decrypt(b, 'same'));
assert.deepStrictEqual(decrypt(a), decrypt(b));
});
});