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.
|
// (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
37
main.js
@ -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).' };
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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 || {};
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user