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("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { decryptBackup, encryptBackup } from "../src/main/backup-crypto";
|
||||
import {
|
||||
LocalBackupApi,
|
||||
runLocalBackupExport,
|
||||
runLocalBackupImport,
|
||||
validateBackupPassphrase
|
||||
} from "../src/renderer/backup-flow";
|
||||
import { BackupPassphraseDialog } from "../src/renderer/ui/BackupPassphraseDialog";
|
||||
|
||||
function createApi(overrides: Partial<LocalBackupApi> = {}): LocalBackupApi {
|
||||
return {
|
||||
exportBackup: vi.fn(async () => ({ saved: true })),
|
||||
selectBackupImport: vi.fn(async () => ({ selected: true, requiresPassphrase: true })),
|
||||
importBackup: vi.fn(async () => ({ restored: true, relaunch: false, message: "Einstellungen wiederhergestellt" })),
|
||||
cancelBackupImport: vi.fn(async () => undefined),
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe("local backup passphrase flow", () => {
|
||||
it("rejects an export confirmation mismatch before IPC", () => {
|
||||
expect(validateBackupPassphrase("export", "first test phrase", "second test phrase")).toBe("Die Passphrasen stimmen nicht überein");
|
||||
});
|
||||
|
||||
it("cancels export without invoking the backup API", async () => {
|
||||
const api = createApi();
|
||||
const result = await runLocalBackupExport(api, async () => null);
|
||||
|
||||
expect(result).toEqual({ saved: false });
|
||||
expect(api.exportBackup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cancels a selected MDD2 import and clears the pending main operation", async () => {
|
||||
const api = createApi();
|
||||
const result = await runLocalBackupImport(api, async () => null);
|
||||
|
||||
expect(result).toEqual({ restored: false, relaunch: false, message: "Abgebrochen" });
|
||||
expect(api.cancelBackupImport).toHaveBeenCalledOnce();
|
||||
expect(api.importBackup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves import untouched when file selection is cancelled", async () => {
|
||||
const requestPassphrase = vi.fn(async () => "unused");
|
||||
const api = createApi({
|
||||
selectBackupImport: vi.fn(async () => ({ selected: false, requiresPassphrase: false, message: "Abgebrochen" }))
|
||||
});
|
||||
const result = await runLocalBackupImport(api, requestPassphrase);
|
||||
|
||||
expect(result).toEqual({ restored: false, relaunch: false, message: "Abgebrochen" });
|
||||
expect(requestPassphrase).not.toHaveBeenCalled();
|
||||
expect(api.importBackup).not.toHaveBeenCalled();
|
||||
expect(api.cancelBackupImport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("imports MDD1 without requesting a passphrase", async () => {
|
||||
const requestPassphrase = vi.fn(async () => "unused");
|
||||
const api = createApi({
|
||||
selectBackupImport: vi.fn(async () => ({ selected: true, requiresPassphrase: false }))
|
||||
});
|
||||
|
||||
await runLocalBackupImport(api, requestPassphrase);
|
||||
|
||||
expect(requestPassphrase).not.toHaveBeenCalled();
|
||||
expect(api.importBackup).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
|
||||
it("completes an export and import round-trip without returning the passphrase", async () => {
|
||||
let backup: Buffer | undefined;
|
||||
let restored = "";
|
||||
const api = createApi({
|
||||
exportBackup: vi.fn(async (passphrase) => {
|
||||
backup = encryptBackup("round-trip payload", passphrase);
|
||||
return { saved: true };
|
||||
}),
|
||||
selectBackupImport: vi.fn(async () => ({ selected: true, requiresPassphrase: true })),
|
||||
importBackup: vi.fn(async (passphrase) => {
|
||||
if (!backup) {
|
||||
throw new Error("Backup missing");
|
||||
}
|
||||
restored = decryptBackup(backup, passphrase);
|
||||
return { restored: true, relaunch: false, message: "Einstellungen wiederhergestellt" };
|
||||
})
|
||||
});
|
||||
|
||||
const exported = await runLocalBackupExport(api, async () => "one-operation test phrase");
|
||||
const imported = await runLocalBackupImport(api, async () => "one-operation test phrase");
|
||||
|
||||
expect(exported).toEqual({ saved: true });
|
||||
expect(imported).toEqual({ restored: true, relaunch: false, message: "Einstellungen wiederhergestellt" });
|
||||
expect(restored).toBe("round-trip payload");
|
||||
expect(JSON.stringify([exported, imported])).not.toContain("one-operation test phrase");
|
||||
});
|
||||
|
||||
it("renders two password fields for export and one for import", () => {
|
||||
const exportHtml = renderToStaticMarkup(
|
||||
<BackupPassphraseDialog mode="export" onCancel={() => {}} onSubmit={() => {}} />
|
||||
);
|
||||
const importHtml = renderToStaticMarkup(
|
||||
<BackupPassphraseDialog mode="import" onCancel={() => {}} onSubmit={() => {}} />
|
||||
);
|
||||
|
||||
expect(exportHtml.match(/type="password"/g)).toHaveLength(2);
|
||||
expect(importHtml.match(/type="password"/g)).toHaveLength(1);
|
||||
expect(exportHtml).toContain("Passphrase bestätigen");
|
||||
expect(importHtml).not.toContain("Passphrase bestätigen");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { IPC_CHANNELS } from "../src/shared/ipc";
|
||||
import type { ElectronApi } from "../src/shared/preload-api";
|
||||
|
||||
const electron = vi.hoisted(() => ({
|
||||
api: undefined as ElectronApi | undefined,
|
||||
invoke: vi.fn<(...args: unknown[]) => Promise<unknown>>(async () => undefined)
|
||||
}));
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
contextBridge: {
|
||||
exposeInMainWorld: (_name: string, api: ElectronApi) => {
|
||||
electron.api = api;
|
||||
}
|
||||
},
|
||||
ipcRenderer: {
|
||||
invoke: electron.invoke,
|
||||
on: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
send: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
describe("backup preload contract", () => {
|
||||
beforeAll(async () => {
|
||||
await import("../src/preload/preload");
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
electron.invoke.mockClear();
|
||||
});
|
||||
|
||||
it("forwards an export passphrase without adding it to the result contract", async () => {
|
||||
electron.invoke.mockResolvedValueOnce({ saved: true });
|
||||
const result = await electron.api?.exportBackup("one-operation test phrase");
|
||||
|
||||
expect(electron.invoke).toHaveBeenCalledWith(IPC_CHANNELS.EXPORT_BACKUP, "one-operation test phrase");
|
||||
expect(result).toEqual({ saved: true });
|
||||
});
|
||||
|
||||
it("keeps import selection and cancellation passphrase-free", async () => {
|
||||
electron.invoke.mockResolvedValueOnce({ selected: true, requiresPassphrase: true });
|
||||
await electron.api?.selectBackupImport();
|
||||
await electron.api?.cancelBackupImport();
|
||||
|
||||
expect(electron.invoke).toHaveBeenNthCalledWith(1, IPC_CHANNELS.SELECT_BACKUP_IMPORT);
|
||||
expect(electron.invoke).toHaveBeenNthCalledWith(2, IPC_CHANNELS.CANCEL_BACKUP_IMPORT);
|
||||
});
|
||||
|
||||
it("forwards an import passphrase only to the consuming operation", async () => {
|
||||
electron.invoke.mockResolvedValueOnce({ restored: true, relaunch: false, message: "Einstellungen wiederhergestellt" });
|
||||
const result = await electron.api?.importBackup("one-operation test phrase");
|
||||
|
||||
expect(electron.invoke).toHaveBeenCalledWith(IPC_CHANNELS.IMPORT_BACKUP, "one-operation test phrase");
|
||||
expect(result).toEqual({ restored: true, relaunch: false, message: "Einstellungen wiederhergestellt" });
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ describe("OverlayHost", () => {
|
||||
it("renders every desktop overlay slot exactly once", () => {
|
||||
const slots = {
|
||||
confirm: <span>confirm-slot</span>,
|
||||
backupPassphrase: <span>backup-passphrase-slot</span>,
|
||||
onlineBackup: <span>backup-slot</span>,
|
||||
diagnostics: <span>diagnostics-slot</span>,
|
||||
deleteConfirmation: <span>delete-slot</span>,
|
||||
|
||||
@@ -171,7 +171,9 @@ export function createVisualElectronApi(
|
||||
restart: async () => {},
|
||||
quit: async () => {},
|
||||
exportBackup: async () => ({ saved: true }),
|
||||
selectBackupImport: async () => ({ selected: true, requiresPassphrase: false }),
|
||||
importBackup: async () => ({ restored: true, relaunch: false, message: "Visual backup importiert" }),
|
||||
cancelBackupImport: async () => {},
|
||||
exportOnlineBackup: async () => ({ key: "visual-online-backup-key" }),
|
||||
importOnlineBackup: async () => ({ restored: true, relaunch: false, message: "Visual online backup importiert" }),
|
||||
exportSupportBundle: async () => ({ saved: true, filePath: "C:\\Visual\\Support\\support.zip" }),
|
||||
|
||||
Reference in New Issue
Block a user