diff --git a/lib/backup-crypto.js b/lib/backup-crypto.js index d6f76af..9fd9f22 100644 --- a/lib/backup-crypto.js +++ b/lib/backup-crypto.js @@ -14,8 +14,8 @@ const ALGO = 'aes-256-gcm'; // (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); +function deriveKey(passphrase, salt) { + return crypto.pbkdf2Sync(passphrase, salt, ITERATIONS, KEY_LEN, DIGEST); } /** @@ -26,7 +26,7 @@ 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(salt); + const key = deriveKey(APP_PASSPHRASE, salt); const cipher = crypto.createCipheriv(ALGO, key, iv); const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]); @@ -39,9 +39,12 @@ function encrypt(config) { /** * Decrypt a .mhu buffer. - * Returns the config object or throws on invalid data. + * Tries the app's built-in key first; if that fails and a user password is + * provided, falls back to legacy password-based decryption. Throws a special + * error with `needsPassword = true` if the app key fails and no password was + * given, so callers can prompt the user for the legacy password. */ -function decrypt(buffer) { +function decrypt(buffer, userPassword) { if (buffer.length < MAGIC.length + SALT_LEN + IV_LEN + TAG_LEN + 1) { throw new Error('Ungültiges Backup-Format'); } @@ -57,25 +60,36 @@ function decrypt(buffer) { const tag = buffer.subarray(offset, offset += TAG_LEN); const ciphertext = buffer.subarray(offset); - const key = deriveKey(salt); - const decipher = crypto.createDecipheriv(ALGO, key, iv); - decipher.setAuthTag(tag); + const tryPassphrase = (passphrase) => { + const key = deriveKey(passphrase, salt); + const decipher = crypto.createDecipheriv(ALGO, key, iv); + decipher.setAuthTag(tag); + try { + const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]); + const result = JSON.parse(decrypted.toString('utf-8')); + decrypted.fill(0); + key.fill(0); + return result; + } catch { + key.fill(0); + return null; + } + }; - let decrypted; - try { - decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]); - } catch { - throw new Error('Backup-Datei beschädigt oder aus einer inkompatiblen Version'); + // 1) Try the app-internal key (new format, no password required). + const fromApp = tryPassphrase(APP_PASSPHRASE); + if (fromApp) return fromApp; + + // 2) Legacy format: user had set their own password. + if (userPassword) { + const fromUser = tryPassphrase(userPassword); + if (fromUser) return fromUser; + 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'); - } finally { - decrypted.fill(0); - key.fill(0); - } + const err = new Error('Dieses Backup wurde mit einem Passwort verschlüsselt'); + err.needsPassword = true; + throw err; } module.exports = { encrypt, decrypt }; diff --git a/main.js b/main.js index a5c746f..b76fdeb 100644 --- a/main.js +++ b/main.js @@ -15,6 +15,7 @@ const FolderMonitor = require('./lib/folder-monitor'); const RemoteServer = require('./lib/remote-server'); let mainWindow; +let _lastImportPath = null; let dropTargetWindow = null; let tray = null; const configStore = new ConfigStore(app); @@ -979,15 +980,33 @@ ipcMain.handle('export-backup', async () => { return { ok: true, path: filePath }; }); -ipcMain.handle('import-backup', async () => { - 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 imported = backupCrypto.decrypt(buffer); +ipcMain.handle('import-backup', async (_event, legacyPassword) => { + let buffer; + let sourcePath = _lastImportPath; + if (legacyPassword && sourcePath) { + buffer = fs.readFileSync(sourcePath); + } else { + 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 }; + sourcePath = filePaths[0]; + buffer = fs.readFileSync(sourcePath); + _lastImportPath = sourcePath; + } + let imported; + try { + imported = backupCrypto.decrypt(buffer, legacyPassword); + } catch (err) { + if (err && err.needsPassword) { + return { ok: false, needsPassword: true }; + } + _lastImportPath = null; + throw err; + } + _lastImportPath = null; // 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).' }; diff --git a/preload.js b/preload.js index f3cc1ec..4f0098e 100644 --- a/preload.js +++ b/preload.js @@ -60,7 +60,7 @@ contextBridge.exposeInMainWorld('api', { // Backup exportBackup: () => ipcRenderer.invoke('export-backup'), - importBackup: () => ipcRenderer.invoke('import-backup'), + importBackup: (legacyPassword) => ipcRenderer.invoke('import-backup', legacyPassword), // Folder Monitor folderMonitorStart: (settings) => ipcRenderer.invoke('folder-monitor:start', settings), diff --git a/renderer/app.js b/renderer/app.js index d4a448a..67852b5 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -1140,10 +1140,74 @@ async function doBackupExport() { } } -async function doBackupImport() { +function askLegacyBackupPassword() { + return new Promise((resolve) => { + const overlay = document.createElement('div'); + overlay.className = 'modal-overlay'; + overlay.style.display = 'flex'; + + const card = document.createElement('div'); + card.className = 'modal-card'; + card.style.width = 'min(380px,100%)'; + + const header = document.createElement('div'); + header.className = 'modal-header'; + const h3 = document.createElement('h3'); + h3.textContent = 'Passwort erforderlich'; + header.appendChild(h3); + + const body = document.createElement('div'); + body.className = 'modal-body'; + const p = document.createElement('p'); + p.style.margin = '0 0 10px'; + p.style.fontSize = '13px'; + p.textContent = 'Dieses Backup wurde mit einem Passwort verschlüsselt.'; + const input = document.createElement('input'); + input.type = 'password'; + input.className = 'key-input'; + input.placeholder = 'Passwort'; + input.autocomplete = 'off'; + input.style.width = '100%'; + body.appendChild(p); + body.appendChild(input); + + const footer = document.createElement('div'); + footer.className = 'modal-footer'; + const cancelBtn = document.createElement('button'); + cancelBtn.className = 'btn btn-secondary'; + cancelBtn.textContent = 'Abbrechen'; + const okBtn = document.createElement('button'); + okBtn.className = 'btn btn-primary'; + okBtn.textContent = 'Importieren'; + footer.appendChild(cancelBtn); + footer.appendChild(okBtn); + + card.appendChild(header); + card.appendChild(body); + card.appendChild(footer); + overlay.appendChild(card); + document.body.appendChild(overlay); + + const done = (val) => { overlay.remove(); resolve(val); }; + okBtn.onclick = () => done(input.value || null); + cancelBtn.onclick = () => done(null); + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') done(input.value || null); + if (e.key === 'Escape') done(null); + }); + input.focus(); + }); +} + +async function doBackupImport(legacyPassword) { try { - const result = await window.api.importBackup(); + const result = await window.api.importBackup(legacyPassword); if (!result || result.canceled) return; + if (result.needsPassword) { + const pw = await askLegacyBackupPassword(); + if (pw) doBackupImport(pw); + return; + } if (result.ok) { config = result.config; hosterSettings = config.hosterSettings || {}; diff --git a/tests/backup-crypto.test.js b/tests/backup-crypto.test.js index e515935..7289179 100644 --- a/tests/backup-crypto.test.js +++ b/tests/backup-crypto.test.js @@ -19,7 +19,10 @@ describe('backup-crypto', () => { it('decrypt with corrupted data throws', () => { const buf = encrypt(sampleConfig); buf[buf.length - 1] ^= 0xff; // flip last byte - assert.throws(() => decrypt(buf), /beschädigt|inkompatiblen/); + // With no password: app-key fails → needsPassword surfaces. + assert.throws(() => decrypt(buf), (err) => err.needsPassword === true); + // With a password: both app-key and password fail → Falsches Passwort. + assert.throws(() => decrypt(buf, 'anything'), /Falsches Passwort/); }); it('decrypt with invalid magic throws', () => { @@ -38,6 +41,28 @@ describe('backup-crypto', () => { assert.deepStrictEqual(decrypt(buf), empty); }); + it('decrypts legacy password-encrypted buffer when password is provided', () => { + // Reproduce the old format: same envelope, but key derived from user password. + const crypto = require('crypto'); + const plaintext = Buffer.from(JSON.stringify(sampleConfig), 'utf-8'); + const salt = crypto.randomBytes(16); + const iv = crypto.randomBytes(12); + const key = crypto.pbkdf2Sync('oldUserPw', salt, 100_000, 32, 'sha512'); + const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); + const enc = Buffer.concat([cipher.update(plaintext), cipher.final()]); + const tag = cipher.getAuthTag(); + const legacyBuf = Buffer.concat([Buffer.from('MHU1'), salt, iv, tag, enc]); + + // Without password → should throw needsPassword + assert.throws(() => decrypt(legacyBuf), (err) => err.needsPassword === true); + + // With correct password → should decrypt + assert.deepStrictEqual(decrypt(legacyBuf, 'oldUserPw'), sampleConfig); + + // With wrong password → should throw (not needsPassword) + assert.throws(() => decrypt(legacyBuf, 'wrongPw'), /Falsches Passwort/); + }); + it('each encryption produces different output (random salt/iv)', () => { const a = encrypt(sampleConfig); const b = encrypt(sampleConfig);