Multi-Hoster-Upload/lib/backup-crypto.js
Administrator edb614f985 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.
2026-04-17 11:22:33 +02:00

96 lines
3.1 KiB
JavaScript

const crypto = require('crypto');
const MAGIC = Buffer.from('MHU1');
const SALT_LEN = 16;
const IV_LEN = 12;
const TAG_LEN = 16;
const KEY_LEN = 32;
const ITERATIONS = 100_000;
const DIGEST = 'sha512';
const ALGO = 'aes-256-gcm';
// Fixed app-internal passphrase — backups are opaque without the app, which is
// 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(passphrase, salt) {
return crypto.pbkdf2Sync(passphrase, salt, ITERATIONS, KEY_LEN, DIGEST);
}
/**
* Encrypt a config object.
* Returns a Buffer: MHU1 | salt(16) | iv(12) | tag(16) | ciphertext
*/
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(APP_PASSPHRASE, salt);
const cipher = crypto.createCipheriv(ALGO, key, iv);
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
const tag = cipher.getAuthTag();
plaintext.fill(0);
key.fill(0);
return Buffer.concat([MAGIC, salt, iv, tag, encrypted]);
}
/**
* Decrypt a .mhu buffer.
* 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, userPassword) {
if (buffer.length < MAGIC.length + SALT_LEN + IV_LEN + TAG_LEN + 1) {
throw new Error('Ungültiges Backup-Format');
}
const magic = buffer.subarray(0, 4);
if (!magic.equals(MAGIC)) {
throw new Error('Keine gültige .mhu Backup-Datei');
}
let offset = MAGIC.length;
const salt = buffer.subarray(offset, offset += SALT_LEN);
const iv = buffer.subarray(offset, offset += IV_LEN);
const tag = buffer.subarray(offset, offset += TAG_LEN);
const ciphertext = buffer.subarray(offset);
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;
}
};
// 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');
}
const err = new Error('Dieses Backup wurde mit einem Passwort verschlüsselt');
err.needsPassword = true;
throw err;
}
module.exports = { encrypt, decrypt };