feat: encrypted backup import/export

AES-256-GCM + PBKDF2 encrypted config backup (.mhu files).
Export/import all accounts, settings, and history.
Pre-import safety backup of current config.
Password modal with confirmation for export.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Administrator 2026-03-11 19:20:41 +01:00
parent 39ccb904ef
commit ffc5b5576b
7 changed files with 291 additions and 0 deletions

71
lib/backup-crypto.js Normal file
View File

@ -0,0 +1,71 @@
const crypto = require('crypto');
const MAGIC = Buffer.from('MHU1');
const SALT_LEN = 16;
const IV_LEN = 12;
const TAG_LEN = 16;
const KEY_LEN = 32;
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);
}
/**
* Encrypt a config object with a password.
* Returns a Buffer: MHU1 | salt(16) | iv(12) | tag(16) | ciphertext
*/
function encrypt(config, password) {
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 cipher = crypto.createCipheriv(ALGO, key, iv);
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
const tag = cipher.getAuthTag();
return Buffer.concat([MAGIC, salt, iv, tag, encrypted]);
}
/**
* Decrypt a .mhu buffer with a password.
* Returns the config object or throws on invalid password/data.
*/
function decrypt(buffer, password) {
if (buffer.length < MAGIC.length + SALT_LEN + IV_LEN + TAG_LEN + 1) {
throw new Error('Ungültiges Backup-Format');
}
const magic = buffer.subarray(0, 4);
if (!magic.equals(MAGIC)) {
throw new Error('Keine gültige .mhu Backup-Datei');
}
let offset = MAGIC.length;
const salt = buffer.subarray(offset, offset += SALT_LEN);
const iv = buffer.subarray(offset, offset += IV_LEN);
const tag = buffer.subarray(offset, offset += TAG_LEN);
const ciphertext = buffer.subarray(offset);
const key = deriveKey(password, salt);
const decipher = crypto.createDecipheriv(ALGO, key, iv);
decipher.setAuthTag(tag);
let decrypted;
try {
decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
} catch {
throw new Error('Falsches Passwort oder beschädigte Datei');
}
try {
return JSON.parse(decrypted.toString('utf-8'));
} catch {
throw new Error('Entschlüsselte Daten sind kein gültiges JSON');
}
}
module.exports = { encrypt, decrypt };

37
main.js
View File

@ -8,6 +8,7 @@ const VidmolyUploader = require('./lib/vidmoly-upload');
const VoeUploader = require('./lib/voe-upload');
const DoodstreamUploader = require('./lib/doodstream-upload');
const { checkForUpdate, installUpdate, abortUpdate } = require('./lib/updater');
const backupCrypto = require('./lib/backup-crypto');
let mainWindow;
const configStore = new ConfigStore(app);
@ -626,6 +627,42 @@ ipcMain.handle('clear-history', () => {
return true;
});
// --- Backup export / import ---
ipcMain.handle('export-backup', async (_event, password) => {
const config = configStore.load();
const encrypted = backupCrypto.encrypt(config, password);
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 };
fs.writeFileSync(filePath, encrypted);
return { ok: true, path: filePath };
});
ipcMain.handle('import-backup', async (_event, password) => {
const { canceled, filePaths } = await dialog.showOpenDialog(mainWindow, {
title: 'Backup importieren',
filters: [{ name: 'Multi-Hoster Backup', extensions: ['mhu'] }],
properties: ['openFile']
});
if (canceled || !filePaths.length) return { ok: false, canceled: true };
const buffer = fs.readFileSync(filePaths[0]);
const config = backupCrypto.decrypt(buffer, password);
// Safety net: save current config before overwriting
const preImportPath = configStore.filePath + '.pre-import.json';
try { fs.copyFileSync(configStore.filePath, preImportPath); } catch {}
await configStore.save(config);
if (config.history) {
// Overwrite history too (save() doesn't touch history)
const full = configStore.load();
full.history = config.history;
await configStore._atomicWrite(JSON.stringify(full, null, 2));
}
return { ok: true, config: configStore.load() };
});
ipcMain.handle('copy-to-clipboard', (_event, text) => {
clipboard.writeText(text);
return true;

View File

@ -50,6 +50,10 @@ contextBridge.exposeInMainWorld('api', {
ipcRenderer.on('app:update-progress', (_event, data) => callback(data));
},
// Backup
exportBackup: (password) => ipcRenderer.invoke('export-backup', password),
importBackup: (password) => ipcRenderer.invoke('import-backup', password),
// Debug
debugTestUpload: () => ipcRenderer.invoke('debug-test-upload'),
debugLog: (msg) => ipcRenderer.invoke('debug-log', msg),

View File

@ -800,6 +800,75 @@ 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';
}
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 || {};
closeBackupModal();
renderSettingsPanel();
renderAccountsList();
loadHistory();
showCopyToast('Backup importiert');
}
} catch (err) {
status.textContent = err.message || 'Import fehlgeschlagen';
}
}
}
document.addEventListener('click', (e) => {
if (!e.target.closest('.context-menu')) hideContextMenu();
});
@ -2186,6 +2255,23 @@ function setupListeners() {
});
});
document.getElementById('saveSettingsBtn').addEventListener('click', saveSettings);
// --- Backup export / import ---
document.getElementById('exportBackupBtn').addEventListener('click', () => openBackupModal('export'));
document.getElementById('importBackupBtn').addEventListener('click', () => openBackupModal('import'));
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

@ -219,6 +219,14 @@
<span class="save-feedback" id="saveFeedback">Änderungen werden automatisch gespeichert.</span>
<button class="btn btn-secondary" id="saveSettingsBtn">Jetzt speichern</button>
</div>
<div class="settings-backup-section">
<h2>Backup</h2>
<p class="settings-hint">Alle Accounts, Einstellungen und den Upload-Verlauf verschlüsselt exportieren oder importieren.</p>
<div class="settings-backup-buttons">
<button class="btn btn-secondary" id="exportBackupBtn">Backup exportieren</button>
<button class="btn btn-secondary" id="importBackupBtn">Backup importieren</button>
</div>
</div>
</div>
</div>
@ -232,6 +240,30 @@
</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="copy-links">Links kopieren</div>

View File

@ -705,6 +705,8 @@ body {
.toggle-vis:hover { border-color: var(--border-hover); }
.settings-save-row { display: flex; justify-content: flex-end; align-items: center; gap: 8px; margin-top: 12px; }
.settings-backup-section { margin-top: 24px; border-top: 1px solid var(--border); padding-top: 16px; }
.settings-backup-buttons { display: flex; gap: 8px; }
.save-feedback { font-size: 12px; color: var(--success); }
.settings-empty {
padding: 28px 16px;

View File

@ -0,0 +1,59 @@
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const { encrypt, decrypt } = require('../lib/backup-crypto');
describe('backup-crypto', () => {
const sampleConfig = {
hosters: { 'doodstream.com': { enabled: true, apiKey: 'test-key-123' } },
hosterSettings: { 'doodstream.com': { retries: 3 } },
globalSettings: { alwaysOnTop: false },
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!');
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');
buf[buf.length - 1] ^= 0xff; // flip last byte
assert.throws(() => decrypt(buf, 'pw'), /Falsches Passwort/);
});
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/);
});
it('decrypt with too-short buffer throws', () => {
assert.throws(() => decrypt(Buffer.alloc(10), 'pw'), /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('each encryption produces different output (random salt/iv)', () => {
const a = encrypt(sampleConfig, 'same');
const b = encrypt(sampleConfig, 'same');
assert.ok(!a.equals(b), 'two encryptions should differ');
// but both decrypt to same result
assert.deepStrictEqual(decrypt(a, 'same'), decrypt(b, 'same'));
});
});