feat(backup): drop password prompt on export/import
File stays AES-GCM encrypted with a fixed app-internal key — opaque without the app, which is the only protection we actually need for locally-stored API keys. Removes the modal and both password dialogs.
This commit is contained in:
parent
43433cbc00
commit
3e9483e222
@ -9,20 +9,24 @@ const ITERATIONS = 100_000;
|
|||||||
const DIGEST = 'sha512';
|
const DIGEST = 'sha512';
|
||||||
const ALGO = 'aes-256-gcm';
|
const ALGO = 'aes-256-gcm';
|
||||||
|
|
||||||
function deriveKey(password, salt) {
|
// Fixed app-internal passphrase — backups are opaque without the app, which is
|
||||||
return crypto.pbkdf2Sync(password, salt, ITERATIONS, KEY_LEN, DIGEST);
|
// enough protection for API keys stored locally. We keep the AES-GCM envelope
|
||||||
|
// (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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encrypt a config object with a password.
|
* Encrypt a config object.
|
||||||
* Returns a Buffer: MHU1 | salt(16) | iv(12) | tag(16) | ciphertext
|
* Returns a Buffer: MHU1 | salt(16) | iv(12) | tag(16) | ciphertext
|
||||||
*/
|
*/
|
||||||
function encrypt(config, password) {
|
function encrypt(config) {
|
||||||
if (!password || typeof password !== 'string') throw new Error('Passwort darf nicht leer sein');
|
|
||||||
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(password, salt);
|
const key = deriveKey(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()]);
|
||||||
@ -34,11 +38,10 @@ function encrypt(config, password) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decrypt a .mhu buffer with a password.
|
* Decrypt a .mhu buffer.
|
||||||
* Returns the config object or throws on invalid password/data.
|
* Returns the config object or throws on invalid data.
|
||||||
*/
|
*/
|
||||||
function decrypt(buffer, password) {
|
function decrypt(buffer) {
|
||||||
if (!password || typeof password !== 'string') throw new Error('Passwort darf nicht leer sein');
|
|
||||||
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');
|
||||||
}
|
}
|
||||||
@ -54,7 +57,7 @@ function decrypt(buffer, password) {
|
|||||||
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(password, salt);
|
const key = deriveKey(salt);
|
||||||
const decipher = crypto.createDecipheriv(ALGO, key, iv);
|
const decipher = crypto.createDecipheriv(ALGO, key, iv);
|
||||||
decipher.setAuthTag(tag);
|
decipher.setAuthTag(tag);
|
||||||
|
|
||||||
@ -62,7 +65,7 @@ function decrypt(buffer, password) {
|
|||||||
try {
|
try {
|
||||||
decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error('Falsches Passwort oder beschädigte Datei');
|
throw new Error('Backup-Datei beschädigt oder aus einer inkompatiblen Version');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
10
main.js
10
main.js
@ -966,20 +966,20 @@ ipcMain.handle('clear-history', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// --- Backup export / import ---
|
// --- Backup export / import ---
|
||||||
ipcMain.handle('export-backup', async (_event, password) => {
|
ipcMain.handle('export-backup', async () => {
|
||||||
const config = configStore.load();
|
|
||||||
const encrypted = backupCrypto.encrypt(config, password);
|
|
||||||
const { canceled, filePath } = await dialog.showSaveDialog(mainWindow, {
|
const { canceled, filePath } = await dialog.showSaveDialog(mainWindow, {
|
||||||
title: 'Backup exportieren',
|
title: 'Backup exportieren',
|
||||||
defaultPath: `multi-hoster-backup-${new Date().toISOString().slice(0, 10)}.mhu`,
|
defaultPath: `multi-hoster-backup-${new Date().toISOString().slice(0, 10)}.mhu`,
|
||||||
filters: [{ name: 'Multi-Hoster Backup', extensions: ['mhu'] }]
|
filters: [{ name: 'Multi-Hoster Backup', extensions: ['mhu'] }]
|
||||||
});
|
});
|
||||||
if (canceled || !filePath) return { ok: false, canceled: true };
|
if (canceled || !filePath) return { ok: false, canceled: true };
|
||||||
|
const config = configStore.load();
|
||||||
|
const encrypted = backupCrypto.encrypt(config);
|
||||||
fs.writeFileSync(filePath, encrypted);
|
fs.writeFileSync(filePath, encrypted);
|
||||||
return { ok: true, path: filePath };
|
return { ok: true, path: filePath };
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('import-backup', async (_event, password) => {
|
ipcMain.handle('import-backup', async () => {
|
||||||
const { canceled, filePaths } = await dialog.showOpenDialog(mainWindow, {
|
const { canceled, filePaths } = await dialog.showOpenDialog(mainWindow, {
|
||||||
title: 'Backup importieren',
|
title: 'Backup importieren',
|
||||||
filters: [{ name: 'Multi-Hoster Backup', extensions: ['mhu'] }],
|
filters: [{ name: 'Multi-Hoster Backup', extensions: ['mhu'] }],
|
||||||
@ -987,7 +987,7 @@ ipcMain.handle('import-backup', async (_event, password) => {
|
|||||||
});
|
});
|
||||||
if (canceled || !filePaths.length) return { ok: false, canceled: true };
|
if (canceled || !filePaths.length) return { ok: false, canceled: true };
|
||||||
const buffer = fs.readFileSync(filePaths[0]);
|
const buffer = fs.readFileSync(filePaths[0]);
|
||||||
const imported = backupCrypto.decrypt(buffer, password);
|
const imported = backupCrypto.decrypt(buffer);
|
||||||
// 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).' };
|
||||||
|
|||||||
@ -59,8 +59,8 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Backup
|
// Backup
|
||||||
exportBackup: (password) => ipcRenderer.invoke('export-backup', password),
|
exportBackup: () => ipcRenderer.invoke('export-backup'),
|
||||||
importBackup: (password) => ipcRenderer.invoke('import-backup', password),
|
importBackup: () => ipcRenderer.invoke('import-backup'),
|
||||||
|
|
||||||
// Folder Monitor
|
// Folder Monitor
|
||||||
folderMonitorStart: (settings) => ipcRenderer.invoke('folder-monitor:start', settings),
|
folderMonitorStart: (settings) => ipcRenderer.invoke('folder-monitor:start', settings),
|
||||||
|
|||||||
@ -1130,77 +1130,36 @@ function copySelectedRecentLinks() {
|
|||||||
if (links.length) { window.api.copyToClipboard(links.join('\n')); showCopyToast(`${links.length} Links kopiert`); }
|
if (links.length) { window.api.copyToClipboard(links.join('\n')); showCopyToast(`${links.length} Links kopiert`); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Backup modal ---
|
// --- Backup export / import ---
|
||||||
let _backupMode = null; // 'export' | 'import'
|
async function doBackupExport() {
|
||||||
|
|
||||||
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 {
|
try {
|
||||||
const result = await window.api.exportBackup(pw);
|
const result = await window.api.exportBackup();
|
||||||
if (result.canceled) { status.textContent = ''; return; }
|
if (result && result.ok) showCopyToast('Backup exportiert');
|
||||||
if (result.ok) {
|
|
||||||
closeBackupModal();
|
|
||||||
showCopyToast('Backup exportiert');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
status.textContent = err.message || 'Export fehlgeschlagen';
|
alert('Export fehlgeschlagen: ' + (err.message || err));
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
status.textContent = 'Importiere...';
|
|
||||||
|
async function doBackupImport() {
|
||||||
try {
|
try {
|
||||||
const result = await window.api.importBackup(pw);
|
const result = await window.api.importBackup();
|
||||||
if (result.canceled) { status.textContent = ''; return; }
|
if (!result || result.canceled) return;
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
config = result.config;
|
config = result.config;
|
||||||
hosterSettings = config.hosterSettings || {};
|
hosterSettings = config.hosterSettings || {};
|
||||||
// Refresh global settings state (always-on-top, etc.)
|
|
||||||
alwaysOnTopState = !!(config.globalSettings && config.globalSettings.alwaysOnTop);
|
alwaysOnTopState = !!(config.globalSettings && config.globalSettings.alwaysOnTop);
|
||||||
window.api.setAlwaysOnTop(alwaysOnTopState);
|
window.api.setAlwaysOnTop(alwaysOnTopState);
|
||||||
closeBackupModal();
|
|
||||||
renderSettings();
|
renderSettings();
|
||||||
renderAccounts();
|
renderAccounts();
|
||||||
renderHosterSummary();
|
renderHosterSummary();
|
||||||
renderHosterModal();
|
renderHosterModal();
|
||||||
loadHistory();
|
loadHistory();
|
||||||
showCopyToast('Backup importiert');
|
showCopyToast('Backup importiert');
|
||||||
|
} else if (result.error) {
|
||||||
|
alert('Import fehlgeschlagen: ' + result.error);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
status.textContent = err.message || 'Import fehlgeschlagen';
|
alert('Import fehlgeschlagen: ' + (err.message || err));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2288,8 +2247,8 @@ function renderSettings() {
|
|||||||
arrow.innerHTML = isOpen ? '▶' : '▼';
|
arrow.innerHTML = isOpen ? '▶' : '▼';
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('exportBackupBtn').addEventListener('click', () => openBackupModal('export'));
|
document.getElementById('exportBackupBtn').addEventListener('click', doBackupExport);
|
||||||
document.getElementById('importBackupBtn').addEventListener('click', () => openBackupModal('import'));
|
document.getElementById('importBackupBtn').addEventListener('click', doBackupImport);
|
||||||
|
|
||||||
// --- Separator before hoster panels ---
|
// --- Separator before hoster panels ---
|
||||||
const separator = document.createElement('div');
|
const separator = document.createElement('div');
|
||||||
@ -3239,20 +3198,6 @@ function setupListeners() {
|
|||||||
});
|
});
|
||||||
document.getElementById('saveSettingsBtn').addEventListener('click', saveSettings);
|
document.getElementById('saveSettingsBtn').addEventListener('click', saveSettings);
|
||||||
|
|
||||||
// --- Backup export / import (modal listeners stay here, button listeners in renderSettings) ---
|
|
||||||
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 () => {
|
document.getElementById('clearHistoryBtn').addEventListener('click', async () => {
|
||||||
if (!confirm('Verlauf wirklich löschen?')) return;
|
if (!confirm('Verlauf wirklich löschen?')) return;
|
||||||
await window.api.clearHistory();
|
await window.api.clearHistory();
|
||||||
|
|||||||
@ -237,30 +237,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</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="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="start-selected">Ausgewählte starten</div>
|
||||||
<div class="ctx-item" data-action="retry-selected">Erneut versuchen</div>
|
<div class="ctx-item" data-action="retry-selected">Erneut versuchen</div>
|
||||||
|
|||||||
@ -10,62 +10,39 @@ describe('backup-crypto', () => {
|
|||||||
history: [{ file: 'test.mkv', link: 'https://example.com/abc' }]
|
history: [{ file: 'test.mkv', link: 'https://example.com/abc' }]
|
||||||
};
|
};
|
||||||
|
|
||||||
it('encrypt then decrypt round-trips with correct password', () => {
|
it('encrypt then decrypt round-trips', () => {
|
||||||
const buf = encrypt(sampleConfig, 'mySecret!');
|
const buf = encrypt(sampleConfig);
|
||||||
const result = decrypt(buf, 'mySecret!');
|
const result = decrypt(buf);
|
||||||
assert.deepStrictEqual(result, sampleConfig);
|
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', () => {
|
it('decrypt with corrupted data throws', () => {
|
||||||
const buf = encrypt(sampleConfig, 'pw');
|
const buf = encrypt(sampleConfig);
|
||||||
buf[buf.length - 1] ^= 0xff; // flip last byte
|
buf[buf.length - 1] ^= 0xff; // flip last byte
|
||||||
assert.throws(() => decrypt(buf, 'pw'), /Falsches Passwort/);
|
assert.throws(() => decrypt(buf), /beschädigt|inkompatiblen/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('decrypt with invalid magic throws', () => {
|
it('decrypt with invalid magic throws', () => {
|
||||||
// Buffer must be long enough to pass the length check (>= 4+16+12+16+1 = 49)
|
// 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'
|
const buf = Buffer.alloc(60, 0x41); // 60 bytes of 'A'
|
||||||
assert.throws(() => decrypt(buf, 'pw'), /Keine gültige/);
|
assert.throws(() => decrypt(buf), /Keine gültige/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('decrypt with too-short buffer throws', () => {
|
it('decrypt with too-short buffer throws', () => {
|
||||||
assert.throws(() => decrypt(Buffer.alloc(10), 'pw'), /Ungültiges Backup-Format/);
|
assert.throws(() => decrypt(Buffer.alloc(10)), /Ungültiges Backup-Format/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles empty config gracefully', () => {
|
it('handles empty config gracefully', () => {
|
||||||
const empty = { hosters: {}, hosterSettings: {}, globalSettings: {}, history: [] };
|
const empty = { hosters: {}, hosterSettings: {}, globalSettings: {}, history: [] };
|
||||||
const buf = encrypt(empty, 'pw');
|
const buf = encrypt(empty);
|
||||||
assert.deepStrictEqual(decrypt(buf, 'pw'), empty);
|
assert.deepStrictEqual(decrypt(buf), empty);
|
||||||
});
|
|
||||||
|
|
||||||
it('handles unicode passwords', () => {
|
|
||||||
const buf = encrypt(sampleConfig, 'Pässwört🔑');
|
|
||||||
const result = decrypt(buf, 'Pässwört🔑');
|
|
||||||
assert.deepStrictEqual(result, sampleConfig);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('encrypt rejects empty password', () => {
|
|
||||||
assert.throws(() => encrypt(sampleConfig, ''), /Passwort/);
|
|
||||||
assert.throws(() => encrypt(sampleConfig, null), /Passwort/);
|
|
||||||
assert.throws(() => encrypt(sampleConfig, undefined), /Passwort/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('decrypt rejects empty password', () => {
|
|
||||||
const buf = encrypt(sampleConfig, 'valid');
|
|
||||||
assert.throws(() => decrypt(buf, ''), /Passwort/);
|
|
||||||
assert.throws(() => decrypt(buf, null), /Passwort/);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('each encryption produces different output (random salt/iv)', () => {
|
it('each encryption produces different output (random salt/iv)', () => {
|
||||||
const a = encrypt(sampleConfig, 'same');
|
const a = encrypt(sampleConfig);
|
||||||
const b = encrypt(sampleConfig, 'same');
|
const b = encrypt(sampleConfig);
|
||||||
assert.ok(!a.equals(b), 'two encryptions should differ');
|
assert.ok(!a.equals(b), 'two encryptions should differ');
|
||||||
// but both decrypt to same result
|
// but both decrypt to same result
|
||||||
assert.deepStrictEqual(decrypt(a, 'same'), decrypt(b, 'same'));
|
assert.deepStrictEqual(decrypt(a), decrypt(b));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user