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:
+67
-28
@@ -1,5 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { encryptBackup, decryptBackup } from "../src/main/backup-crypto";
|
||||
import { encryptBackup, decryptBackup, isMdd2Backup } from "../src/main/backup-crypto";
|
||||
|
||||
const PASSPHRASE = "test-only backup passphrase";
|
||||
|
||||
describe("backup-crypto", () => {
|
||||
it("encrypts and decrypts a round-trip correctly", () => {
|
||||
@@ -10,46 +12,83 @@ describe("backup-crypto", () => {
|
||||
history: [{ id: "h1", name: "Test" }]
|
||||
});
|
||||
|
||||
const encrypted = encryptBackup(original);
|
||||
const decrypted = decryptBackup(encrypted);
|
||||
const encrypted = encryptBackup(original, PASSPHRASE);
|
||||
const decrypted = decryptBackup(encrypted, PASSPHRASE);
|
||||
expect(decrypted).toBe(original);
|
||||
});
|
||||
|
||||
it("produces binary output that is not plaintext readable", () => {
|
||||
const sensitiveValue = "value-that-must-not-be-readable";
|
||||
const plaintext = JSON.stringify({ settings: { value: sensitiveValue } });
|
||||
const encrypted = encryptBackup(plaintext);
|
||||
const encrypted = encryptBackup(plaintext, PASSPHRASE);
|
||||
|
||||
expect(encrypted.toString("utf8")).not.toContain(sensitiveValue);
|
||||
expect(encrypted.toString("latin1")).not.toContain(sensitiveValue);
|
||||
});
|
||||
|
||||
it("writes the MDD2 backup format", () => {
|
||||
const encrypted = encryptBackup("test");
|
||||
const encrypted = encryptBackup("test", PASSPHRASE);
|
||||
expect(encrypted.subarray(0, 4).toString("utf8")).toBe("MDD2");
|
||||
expect(encrypted.length).toBeGreaterThan(48);
|
||||
});
|
||||
|
||||
it("reads the legacy backup format for migration", () => {
|
||||
const legacy = Buffer.from("TUREMQcHBwcHBwcHBwcHB7h4ood1DE8Wc+BPgzE6EYdio3HAN/UB1Mru6Fmvtw==", "base64");
|
||||
expect(decryptBackup(legacy)).toBe("legacy payload");
|
||||
});
|
||||
it("reads the legacy backup format for migration", () => {
|
||||
const legacy = Buffer.from("TUREMQcHBwcHBwcHBwcHB7h4ood1DE8Wc+BPgzE6EYdio3HAN/UB1Mru6Fmvtw==", "base64");
|
||||
expect(decryptBackup(legacy)).toBe("legacy payload");
|
||||
expect(decryptBackup(legacy, "ignored test passphrase")).toBe("legacy payload");
|
||||
});
|
||||
|
||||
it("detects only MDD2 backups as passphrase protected", () => {
|
||||
const current = encryptBackup("test", PASSPHRASE);
|
||||
const legacy = Buffer.from("TUREMQcHBwcHBwcHBwcHB7h4ood1DE8Wc+BPgzE6EYdio3HAN/UB1Mru6Fmvtw==", "base64");
|
||||
expect(isMdd2Backup(current)).toBe(true);
|
||||
expect(isMdd2Backup(legacy)).toBe(false);
|
||||
expect(isMdd2Backup(Buffer.from('{"version":2}', "utf8"))).toBe(false);
|
||||
});
|
||||
|
||||
it("uses a new salt and IV for every encryption", () => {
|
||||
const plaintext = "same input data";
|
||||
const a = encryptBackup(plaintext);
|
||||
const b = encryptBackup(plaintext);
|
||||
const a = encryptBackup(plaintext, PASSPHRASE);
|
||||
const b = encryptBackup(plaintext, PASSPHRASE);
|
||||
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");
|
||||
expect(() => decryptBackup(encrypted.subarray(0, 47))).toThrow(/zu kurz|ungültig/);
|
||||
});
|
||||
expect(decryptBackup(a, PASSPHRASE)).toBe(plaintext);
|
||||
expect(decryptBackup(b, PASSPHRASE)).toBe(plaintext);
|
||||
});
|
||||
|
||||
it("requires a non-empty passphrase for encryption", () => {
|
||||
expect(() => encryptBackup("test", "")).toThrow(/Passphrase/);
|
||||
expect(() => encryptBackup("test", " ")).toThrow(/Passphrase/);
|
||||
});
|
||||
|
||||
it("uses the same controlled error for missing and wrong MDD2 passphrases", () => {
|
||||
const encrypted = encryptBackup("test data", PASSPHRASE);
|
||||
expect(() => decryptBackup(encrypted)).toThrow("Backup-Datei konnte nicht entschlüsselt werden");
|
||||
expect(() => decryptBackup(encrypted, " ")).toThrow("Backup-Datei konnte nicht entschlüsselt werden");
|
||||
expect(() => decryptBackup(encrypted, "wrong test passphrase")).toThrow("Backup-Datei konnte nicht entschlüsselt werden");
|
||||
});
|
||||
|
||||
it("rejects a truncated MDD2 header", () => {
|
||||
const encrypted = encryptBackup("test data", PASSPHRASE);
|
||||
expect(() => decryptBackup(encrypted.subarray(0, 47), PASSPHRASE)).toThrow(/zu kurz|ungültig/);
|
||||
});
|
||||
|
||||
it("rejects a one-byte-short authenticated ciphertext", () => {
|
||||
const encrypted = encryptBackup("x", PASSPHRASE);
|
||||
expect(() => decryptBackup(encrypted.subarray(0, -1), PASSPHRASE)).toThrow("Backup-Datei konnte nicht entschlüsselt werden");
|
||||
});
|
||||
|
||||
it("accepts an authenticated empty ciphertext", () => {
|
||||
const encrypted = encryptBackup("", PASSPHRASE);
|
||||
expect(encrypted).toHaveLength(48);
|
||||
expect(decryptBackup(encrypted, PASSPHRASE)).toBe("");
|
||||
});
|
||||
|
||||
it("rejects a truncated MDD1 backup", () => {
|
||||
const legacy = Buffer.from("TUREMQcHBwcHBwcHBwcHB7h4ood1DE8Wc+BPgzE6EYdio3HAN/UB1Mru6Fmvtw==", "base64");
|
||||
expect(() => decryptBackup(legacy.subarray(0, 31))).toThrow(/zu kurz|ungültig/);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["salt", 4],
|
||||
@@ -57,10 +96,10 @@ describe("backup-crypto", () => {
|
||||
["authentication tag", 32],
|
||||
["ciphertext", 48]
|
||||
])("rejects a modified %s", (_part, offset) => {
|
||||
const encrypted = encryptBackup("test data");
|
||||
const encrypted = encryptBackup("test data", PASSPHRASE);
|
||||
const corrupted = Buffer.from(encrypted);
|
||||
corrupted[offset] ^= 0xff;
|
||||
expect(() => decryptBackup(corrupted)).toThrow(/beschädigt|authentifiziert/);
|
||||
expect(() => decryptBackup(corrupted, PASSPHRASE)).toThrow("Backup-Datei konnte nicht entschlüsselt werden");
|
||||
});
|
||||
|
||||
it("rejects modified legacy authentication data", () => {
|
||||
@@ -75,7 +114,7 @@ describe("backup-crypto", () => {
|
||||
});
|
||||
|
||||
it("throws on wrong magic bytes", () => {
|
||||
const encrypted = encryptBackup("test data");
|
||||
const encrypted = encryptBackup("test data", PASSPHRASE);
|
||||
const wrongMagic = Buffer.from(encrypted);
|
||||
wrongMagic[0] = 0x00;
|
||||
expect(() => decryptBackup(wrongMagic)).toThrow(/Signatur/);
|
||||
@@ -87,19 +126,19 @@ describe("backup-crypto", () => {
|
||||
|
||||
it("handles large payloads", () => {
|
||||
const large = JSON.stringify({ data: "x".repeat(1_000_000) });
|
||||
const encrypted = encryptBackup(large);
|
||||
const decrypted = decryptBackup(encrypted);
|
||||
const encrypted = encryptBackup(large, PASSPHRASE);
|
||||
const decrypted = decryptBackup(encrypted, PASSPHRASE);
|
||||
expect(decrypted).toBe(large);
|
||||
});
|
||||
|
||||
it("handles unicode content", () => {
|
||||
const unicode = JSON.stringify({ name: "Ünïcödé 日本語 🎉", path: "C:\\Benutzer\\Ö" });
|
||||
const encrypted = encryptBackup(unicode);
|
||||
expect(decryptBackup(encrypted)).toBe(unicode);
|
||||
const encrypted = encryptBackup(unicode, PASSPHRASE);
|
||||
expect(decryptBackup(encrypted, PASSPHRASE)).toBe(unicode);
|
||||
});
|
||||
|
||||
it("handles empty string round-trip", () => {
|
||||
const encrypted = encryptBackup("");
|
||||
expect(decryptBackup(encrypted)).toBe("");
|
||||
const encrypted = encryptBackup("", PASSPHRASE);
|
||||
expect(decryptBackup(encrypted, PASSPHRASE)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user