From 6f94e13cb589058496502dbbb1f2e4c04ecb4c43 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Tue, 11 Aug 2026 22:08:38 +0200 Subject: [PATCH] feat(security): harden local backup encryption Write new backups as MDD2 envelopes with per-file scrypt salts, random IVs, and AES-256-GCM authenticated encryption. Authenticate the versioned header and normalize malformed or tampered payload failures without exposing protected data. Keep MDD1 decryption as read-only migration compatibility and add regression coverage for known legacy imports, truncation, unsupported versions, nondeterministic output, and tampering across every protected envelope field. --- src/main/backup-crypto.ts | 96 ++++++++++++++++++++++++++++++------- tests/backup-crypto.test.ts | 49 ++++++++++++++----- 2 files changed, 115 insertions(+), 30 deletions(-) diff --git a/src/main/backup-crypto.ts b/src/main/backup-crypto.ts index 617821e..aa76073 100644 --- a/src/main/backup-crypto.ts +++ b/src/main/backup-crypto.ts @@ -2,38 +2,100 @@ import crypto from "node:crypto"; const APP_KEY_MATERIAL = "MDD-v2-backup-aes256gcm-2026"; const ALGORITHM = "aes-256-gcm"; +const KEY_LENGTH = 32; +const SALT_LENGTH = 16; const IV_LENGTH = 12; const AUTH_TAG_LENGTH = 16; -const MAGIC = Buffer.from("MDD1"); +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 deriveKey(): Buffer { +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); +} + +function decryptAuthenticated( + ciphertext: Buffer, + key: Buffer, + iv: Buffer, + authTag: Buffer, + authenticatedData?: Buffer +): string { + try { + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH }); + if (authenticatedData) { + decipher.setAAD(authenticatedData); + } + 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"); + } +} + +function decryptLegacyBackup(data: Buffer): string { + if (data.length < LEGACY_HEADER_LENGTH) { + throw new Error("Backup-Datei zu kurz oder ungültig"); + } + const ivStart = LEGACY_MAGIC.length; + const authTagStart = ivStart + IV_LENGTH; + const ciphertextStart = authTagStart + AUTH_TAG_LENGTH; + return decryptAuthenticated( + data.subarray(ciphertextStart), + deriveLegacyKey(), + data.subarray(ivStart, authTagStart), + data.subarray(authTagStart, ciphertextStart) + ); +} + +function decryptCurrentBackup(data: Buffer): string { + if (data.length < HEADER_LENGTH) { + throw new Error("Backup-Datei zu kurz oder ungültig"); + } + const saltStart = MAGIC.length; + const ivStart = saltStart + SALT_LENGTH; + const authTagStart = ivStart + IV_LENGTH; + const ciphertextStart = authTagStart + AUTH_TAG_LENGTH; + const salt = data.subarray(saltStart, ivStart); + 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 key = deriveKey(); + const salt = crypto.randomBytes(SALT_LENGTH); const iv = crypto.randomBytes(IV_LENGTH); + const key = deriveKey(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()]); - const authTag = cipher.getAuthTag(); - return Buffer.concat([MAGIC, iv, authTag, encrypted]); + return Buffer.concat([MAGIC, salt, iv, cipher.getAuthTag(), encrypted]); } export function decryptBackup(data: Buffer): string { - if (data.length < MAGIC.length + IV_LENGTH + AUTH_TAG_LENGTH) { + 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)) { - throw new Error("Keine gültige MDD-Backup-Datei (falsche Signatur)"); + if (magic.equals(MAGIC)) { + return decryptCurrentBackup(data); } - const iv = data.subarray(MAGIC.length, MAGIC.length + IV_LENGTH); - const authTag = data.subarray(MAGIC.length + IV_LENGTH, MAGIC.length + IV_LENGTH + AUTH_TAG_LENGTH); - const ciphertext = data.subarray(MAGIC.length + IV_LENGTH + AUTH_TAG_LENGTH); - - const key = deriveKey(); - const decipher = crypto.createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH }); - decipher.setAuthTag(authTag); - const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]); - return decrypted.toString("utf8"); + if (magic.equals(LEGACY_MAGIC)) { + return decryptLegacyBackup(data); + } + if (magic.subarray(0, PREFIX.length).equals(PREFIX)) { + throw new Error("Nicht unterstützte MDD-Backup-Version"); + } + throw new Error("Keine gültige MDD-Backup-Datei (falsche Signatur)"); } diff --git a/tests/backup-crypto.test.ts b/tests/backup-crypto.test.ts index 211baab..fd4da32 100644 --- a/tests/backup-crypto.test.ts +++ b/tests/backup-crypto.test.ts @@ -5,7 +5,7 @@ describe("backup-crypto", () => { it("encrypts and decrypts a round-trip correctly", () => { const original = JSON.stringify({ version: 2, - settings: { token: "my-secret-api-key", outputDir: "C:\\Downloads" }, + settings: { outputDir: "C:\\Downloads" }, session: { packages: {}, items: {} }, history: [{ id: "h1", name: "Test" }] }); @@ -16,39 +16,62 @@ describe("backup-crypto", () => { }); it("produces binary output that is not plaintext readable", () => { - const secret = "super-secret-token-12345"; - const plaintext = JSON.stringify({ settings: { token: secret } }); + const sensitiveValue = "value-that-must-not-be-readable"; + const plaintext = JSON.stringify({ settings: { value: sensitiveValue } }); const encrypted = encryptBackup(plaintext); - expect(encrypted.toString("utf8")).not.toContain(secret); - expect(encrypted.toString("latin1")).not.toContain(secret); + expect(encrypted.toString("utf8")).not.toContain(sensitiveValue); + expect(encrypted.toString("latin1")).not.toContain(sensitiveValue); }); - it("starts with the MDD1 magic bytes", () => { + it("writes the MDD2 backup format", () => { const encrypted = encryptBackup("test"); - expect(encrypted.subarray(0, 4).toString("utf8")).toBe("MDD1"); + expect(encrypted.subarray(0, 4).toString("utf8")).toBe("MDD2"); + expect(encrypted.length).toBeGreaterThan(48); }); - it("produces different ciphertext for the same input (random IV)", () => { + it("reads the legacy backup format for migration", () => { + const legacy = Buffer.from("TUREMQcHBwcHBwcHBwcHB7h4ood1DE8Wc+BPgzE6EYdio3HAN/UB1Mru6Fmvtw==", "base64"); + expect(decryptBackup(legacy)).toBe("legacy payload"); + }); + + it("uses a new salt and IV for every encryption", () => { const plaintext = "same input data"; const a = encryptBackup(plaintext); const b = encryptBackup(plaintext); expect(a.equals(b)).toBe(false); + expect(a.subarray(4, 20).equals(b.subarray(4, 20))).toBe(false); + expect(a.subarray(20, 32).equals(b.subarray(20, 32))).toBe(false); expect(decryptBackup(a)).toBe(plaintext); expect(decryptBackup(b)).toBe(plaintext); }); it("throws on truncated data", () => { const encrypted = encryptBackup("test data"); - const truncated = encrypted.subarray(0, 10); - expect(() => decryptBackup(truncated)).toThrow(); + expect(() => decryptBackup(encrypted.subarray(0, 47))).toThrow(/zu kurz|ungültig/); }); - it("throws on corrupted ciphertext", () => { + it.each([ + ["salt", 4], + ["IV", 20], + ["authentication tag", 32], + ["ciphertext", 48] + ])("rejects a modified %s", (_part, offset) => { const encrypted = encryptBackup("test data"); const corrupted = Buffer.from(encrypted); - corrupted[corrupted.length - 1] ^= 0xff; - expect(() => decryptBackup(corrupted)).toThrow(); + corrupted[offset] ^= 0xff; + expect(() => decryptBackup(corrupted)).toThrow(/beschädigt|authentifiziert/); + }); + + it("rejects modified legacy authentication data", () => { + const legacy = Buffer.from("TUREMQcHBwcHBwcHBwcHB7h4ood1DE8Wc+BPgzE6EYdio3HAN/UB1Mru6Fmvtw==", "base64"); + legacy[16] ^= 0xff; + expect(() => decryptBackup(legacy)).toThrow(/beschädigt|authentifiziert/); + }); + + it("rejects unsupported backup versions", () => { + const unsupported = Buffer.concat([Buffer.from("MDD3"), Buffer.alloc(44)]); + expect(() => decryptBackup(unsupported)).toThrow(/Version/); }); it("throws on wrong magic bytes", () => {