fix(security): require passphrases for local backups
Derive every MDD2 key from a non-empty user passphrase and the per-backup scrypt salt while keeping the embedded application material isolated to read-only MDD1 migration imports. Normalize missing, wrong, and authentication-failure results to the same controlled decryption error. Add the modal-based export confirmation and format-aware import flow across renderer, preload, IPC, and controller boundaries. Clear transient passphrase state on completion or cancellation, retain pending import data only until consumption, and keep passphrases out of results, snapshots, payloads, and logs. Cover mismatch and cancellation paths, MDD1 passphrase-free migration, preload forwarding, successful UI/crypto round-trips, one-byte-short ciphertext, authenticated empty ciphertext, and truncated legacy envelopes.
This commit is contained in:
+53
-40
@@ -1,6 +1,6 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
const APP_KEY_MATERIAL = "MDD-v2-backup-aes256gcm-2026";
|
||||
const LEGACY_APP_KEY_MATERIAL = "MDD-v2-backup-aes256gcm-2026";
|
||||
const ALGORITHM = "aes-256-gcm";
|
||||
const KEY_LENGTH = 32;
|
||||
const SALT_LENGTH = 16;
|
||||
@@ -9,24 +9,26 @@ const AUTH_TAG_LENGTH = 16;
|
||||
const PREFIX = Buffer.from("MDD");
|
||||
const LEGACY_MAGIC = Buffer.from("MDD1");
|
||||
const MAGIC = Buffer.from("MDD2");
|
||||
const LEGACY_HEADER_LENGTH = LEGACY_MAGIC.length + IV_LENGTH + AUTH_TAG_LENGTH;
|
||||
const HEADER_LENGTH = MAGIC.length + SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH;
|
||||
|
||||
function deriveLegacyKey(): Buffer {
|
||||
return crypto.createHash("sha256").update(APP_KEY_MATERIAL).digest();
|
||||
}
|
||||
|
||||
function deriveKey(salt: Buffer): Buffer {
|
||||
return crypto.scryptSync(APP_KEY_MATERIAL, salt, KEY_LENGTH);
|
||||
}
|
||||
const LEGACY_HEADER_LENGTH = LEGACY_MAGIC.length + IV_LENGTH + AUTH_TAG_LENGTH;
|
||||
const HEADER_LENGTH = MAGIC.length + SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH;
|
||||
const DECRYPTION_ERROR = "Backup-Datei konnte nicht entschlüsselt werden";
|
||||
|
||||
function deriveLegacyKey(): Buffer {
|
||||
return crypto.createHash("sha256").update(LEGACY_APP_KEY_MATERIAL).digest();
|
||||
}
|
||||
|
||||
function deriveKey(passphrase: string, salt: Buffer): Buffer {
|
||||
return crypto.scryptSync(passphrase, salt, KEY_LENGTH);
|
||||
}
|
||||
|
||||
function decryptAuthenticated(
|
||||
ciphertext: Buffer,
|
||||
key: Buffer,
|
||||
iv: Buffer,
|
||||
authTag: Buffer,
|
||||
authenticatedData?: Buffer
|
||||
): string {
|
||||
key: Buffer,
|
||||
iv: Buffer,
|
||||
authTag: Buffer,
|
||||
authenticatedData?: Buffer,
|
||||
errorMessage = "Backup-Datei ist beschädigt oder konnte nicht authentifiziert werden"
|
||||
): string {
|
||||
try {
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
|
||||
if (authenticatedData) {
|
||||
@@ -34,8 +36,8 @@ function decryptAuthenticated(
|
||||
}
|
||||
decipher.setAuthTag(authTag);
|
||||
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
|
||||
} catch {
|
||||
throw new Error("Backup-Datei ist beschädigt oder konnte nicht authentifiziert werden");
|
||||
} catch {
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,10 +56,13 @@ function decryptLegacyBackup(data: Buffer): string {
|
||||
);
|
||||
}
|
||||
|
||||
function decryptCurrentBackup(data: Buffer): string {
|
||||
if (data.length < HEADER_LENGTH) {
|
||||
throw new Error("Backup-Datei zu kurz oder ungültig");
|
||||
}
|
||||
function decryptCurrentBackup(data: Buffer, passphrase?: string): string {
|
||||
if (data.length < HEADER_LENGTH) {
|
||||
throw new Error("Backup-Datei zu kurz oder ungültig");
|
||||
}
|
||||
if (typeof passphrase !== "string" || passphrase.trim().length === 0) {
|
||||
throw new Error(DECRYPTION_ERROR);
|
||||
}
|
||||
const saltStart = MAGIC.length;
|
||||
const ivStart = saltStart + SALT_LENGTH;
|
||||
const authTagStart = ivStart + IV_LENGTH;
|
||||
@@ -66,30 +71,38 @@ function decryptCurrentBackup(data: Buffer): string {
|
||||
const iv = data.subarray(ivStart, authTagStart);
|
||||
return decryptAuthenticated(
|
||||
data.subarray(ciphertextStart),
|
||||
deriveKey(salt),
|
||||
iv,
|
||||
data.subarray(authTagStart, ciphertextStart),
|
||||
data.subarray(0, authTagStart)
|
||||
);
|
||||
}
|
||||
|
||||
export function encryptBackup(plaintext: string): Buffer {
|
||||
const salt = crypto.randomBytes(SALT_LENGTH);
|
||||
const iv = crypto.randomBytes(IV_LENGTH);
|
||||
const key = deriveKey(salt);
|
||||
deriveKey(passphrase, salt),
|
||||
iv,
|
||||
data.subarray(authTagStart, ciphertextStart),
|
||||
data.subarray(0, authTagStart),
|
||||
DECRYPTION_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
export function encryptBackup(plaintext: string, passphrase: string): Buffer {
|
||||
if (typeof passphrase !== "string" || passphrase.trim().length === 0) {
|
||||
throw new Error("Backup-Passphrase erforderlich");
|
||||
}
|
||||
const salt = crypto.randomBytes(SALT_LENGTH);
|
||||
const iv = crypto.randomBytes(IV_LENGTH);
|
||||
const key = deriveKey(passphrase, salt);
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
|
||||
cipher.setAAD(Buffer.concat([MAGIC, salt, iv]));
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
||||
return Buffer.concat([MAGIC, salt, iv, cipher.getAuthTag(), encrypted]);
|
||||
}
|
||||
|
||||
export function decryptBackup(data: Buffer): string {
|
||||
return Buffer.concat([MAGIC, salt, iv, cipher.getAuthTag(), encrypted]);
|
||||
}
|
||||
|
||||
export function isMdd2Backup(data: Buffer): boolean {
|
||||
return data.length >= MAGIC.length && data.subarray(0, MAGIC.length).equals(MAGIC);
|
||||
}
|
||||
|
||||
export function decryptBackup(data: Buffer, passphrase?: string): string {
|
||||
if (data.length < MAGIC.length) {
|
||||
throw new Error("Backup-Datei zu kurz oder ungültig");
|
||||
}
|
||||
const magic = data.subarray(0, MAGIC.length);
|
||||
if (magic.equals(MAGIC)) {
|
||||
return decryptCurrentBackup(data);
|
||||
const magic = data.subarray(0, MAGIC.length);
|
||||
if (magic.equals(MAGIC)) {
|
||||
return decryptCurrentBackup(data, passphrase);
|
||||
}
|
||||
if (magic.equals(LEGACY_MAGIC)) {
|
||||
return decryptLegacyBackup(data);
|
||||
|
||||
Reference in New Issue
Block a user