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:
parent
39ccb904ef
commit
ffc5b5576b
71
lib/backup-crypto.js
Normal file
71
lib/backup-crypto.js
Normal 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
37
main.js
@ -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;
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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">×</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>
|
||||
|
||||
@ -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;
|
||||
|
||||
59
tests/backup-crypto.test.js
Normal file
59
tests/backup-crypto.test.js
Normal 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'));
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user