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:
parent
90c7fe297d
commit
edb614f985
@ -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 };
|
||||
|
||||
37
main.js
37
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).' };
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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 || {};
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user