feat(backup): import legacy password-encrypted backups

Try app-internal key first (new format); on failure, signal the
renderer to prompt for the old password and retry. Lets users import
.mhu files that were exported with a custom password in v2.7.6 or
earlier without downgrading.
This commit is contained in:
Administrator 2026-04-17 11:22:33 +02:00
parent 90c7fe297d
commit edb614f985
5 changed files with 156 additions and 34 deletions

View File

@ -14,8 +14,8 @@ const ALGO = 'aes-256-gcm';
// (with random salt/iv) so each export is still distinct and authenticated. // (with random salt/iv) so each export is still distinct and authenticated.
const APP_PASSPHRASE = 'multi-hoster-upload::backup::v1'; const APP_PASSPHRASE = 'multi-hoster-upload::backup::v1';
function deriveKey(salt) { function deriveKey(passphrase, salt) {
return crypto.pbkdf2Sync(APP_PASSPHRASE, salt, ITERATIONS, KEY_LEN, DIGEST); 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 plaintext = Buffer.from(JSON.stringify(config), 'utf-8');
const salt = crypto.randomBytes(SALT_LEN); const salt = crypto.randomBytes(SALT_LEN);
const iv = crypto.randomBytes(IV_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 cipher = crypto.createCipheriv(ALGO, key, iv);
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]); const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
@ -39,9 +39,12 @@ function encrypt(config) {
/** /**
* Decrypt a .mhu buffer. * 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) { if (buffer.length < MAGIC.length + SALT_LEN + IV_LEN + TAG_LEN + 1) {
throw new Error('Ungültiges Backup-Format'); throw new Error('Ungültiges Backup-Format');
} }
@ -57,25 +60,36 @@ function decrypt(buffer) {
const tag = buffer.subarray(offset, offset += TAG_LEN); const tag = buffer.subarray(offset, offset += TAG_LEN);
const ciphertext = buffer.subarray(offset); const ciphertext = buffer.subarray(offset);
const key = deriveKey(salt); const tryPassphrase = (passphrase) => {
const decipher = crypto.createDecipheriv(ALGO, key, iv); const key = deriveKey(passphrase, salt);
decipher.setAuthTag(tag); 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; // 1) Try the app-internal key (new format, no password required).
try { const fromApp = tryPassphrase(APP_PASSPHRASE);
decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]); if (fromApp) return fromApp;
} catch {
throw new Error('Backup-Datei beschädigt oder aus einer inkompatiblen Version'); // 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 { const err = new Error('Dieses Backup wurde mit einem Passwort verschlüsselt');
return JSON.parse(decrypted.toString('utf-8')); err.needsPassword = true;
} catch { throw err;
throw new Error('Entschlüsselte Daten sind kein gültiges JSON');
} finally {
decrypted.fill(0);
key.fill(0);
}
} }
module.exports = { encrypt, decrypt }; module.exports = { encrypt, decrypt };

37
main.js
View File

@ -15,6 +15,7 @@ const FolderMonitor = require('./lib/folder-monitor');
const RemoteServer = require('./lib/remote-server'); const RemoteServer = require('./lib/remote-server');
let mainWindow; let mainWindow;
let _lastImportPath = null;
let dropTargetWindow = null; let dropTargetWindow = null;
let tray = null; let tray = null;
const configStore = new ConfigStore(app); const configStore = new ConfigStore(app);
@ -979,15 +980,33 @@ ipcMain.handle('export-backup', async () => {
return { ok: true, path: filePath }; return { ok: true, path: filePath };
}); });
ipcMain.handle('import-backup', async () => { ipcMain.handle('import-backup', async (_event, legacyPassword) => {
const { canceled, filePaths } = await dialog.showOpenDialog(mainWindow, { let buffer;
title: 'Backup importieren', let sourcePath = _lastImportPath;
filters: [{ name: 'Multi-Hoster Backup', extensions: ['mhu'] }], if (legacyPassword && sourcePath) {
properties: ['openFile'] buffer = fs.readFileSync(sourcePath);
}); } else {
if (canceled || !filePaths.length) return { ok: false, canceled: true }; const { canceled, filePaths } = await dialog.showOpenDialog(mainWindow, {
const buffer = fs.readFileSync(filePaths[0]); title: 'Backup importieren',
const imported = backupCrypto.decrypt(buffer); 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 // Validate imported data has required structure
if (!imported || typeof imported !== 'object' || !imported.hosters || !imported.hosterSettings || !imported.globalSettings) { 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).' }; return { ok: false, error: 'Backup-Datei hat ungültige Struktur (hosters, hosterSettings oder globalSettings fehlt).' };

View File

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

View File

@ -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 { try {
const result = await window.api.importBackup(); const result = await window.api.importBackup(legacyPassword);
if (!result || result.canceled) return; if (!result || result.canceled) return;
if (result.needsPassword) {
const pw = await askLegacyBackupPassword();
if (pw) doBackupImport(pw);
return;
}
if (result.ok) { if (result.ok) {
config = result.config; config = result.config;
hosterSettings = config.hosterSettings || {}; hosterSettings = config.hosterSettings || {};

View File

@ -19,7 +19,10 @@ describe('backup-crypto', () => {
it('decrypt with corrupted data throws', () => { it('decrypt with corrupted data throws', () => {
const buf = encrypt(sampleConfig); const buf = encrypt(sampleConfig);
buf[buf.length - 1] ^= 0xff; // flip last byte 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', () => { it('decrypt with invalid magic throws', () => {
@ -38,6 +41,28 @@ describe('backup-crypto', () => {
assert.deepStrictEqual(decrypt(buf), empty); 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)', () => { it('each encryption produces different output (random salt/iv)', () => {
const a = encrypt(sampleConfig); const a = encrypt(sampleConfig);
const b = encrypt(sampleConfig); const b = encrypt(sampleConfig);