Add encrypted administrative recovery for online backup keys

This commit is contained in:
Sucukdeluxe
2026-09-22 04:34:27 +02:00
parent 5e15d4f864
commit 7f6adac78b
16 changed files with 318 additions and 14 deletions
+2 -1
View File
@@ -55,7 +55,8 @@ describe("renderer localization", () => {
});
it.each([
["Dieser Schlüssel stellt deine Einstellungen inklusive gespeicherter Zugangsdaten und hinterlegter Proxy-Liste wieder her. Bewahre ihn wie ein Passwort auf.", "This key restores your settings, including saved credentials and the configured proxy list. Keep it as secure as a password."],
["Dieser Schlüssel stellt deine Einstellungen inklusive gespeicherter Zugangsdaten und hinterlegter Proxy-Liste wieder her. Bewahre ihn wie ein Passwort auf. Eine verschlüsselte Schlüsselkopie ermöglicht dem Betreiber, den Schlüssel und damit die Sicherung wiederherzustellen.", "This key restores your settings, including saved credentials and the configured proxy list. Keep it as secure as a password. An encrypted copy allows the operator to recover the key and access the backup."],
["Online-Sicherung konnte nicht erstellt werden. Verbindung und serverseitige Schlüsselwiederherstellung prüfen.", "Online backup could not be created. Check the connection and server-side key recovery configuration."],
["Füge den vollständigen MDD2-Schlüssel ein. Einstellungen und eine enthaltene Proxy-Liste werden durch die gespeicherte Version ersetzt.", "Paste the complete MDD2 key. Settings and any included proxy list will be replaced by the stored version."],
["Einstellungen und Proxy-Liste aus Online-Sicherung wiederhergestellt", "Settings and proxy list restored from online backup"],
["Einstellungen wiederhergestellt; Proxy-only wurde deaktiviert, weil die Online-Sicherung keine Proxy-Liste enthält", "Settings restored; Proxy-only was disabled because the online backup contains no proxy list"]
+89
View File
@@ -1,10 +1,13 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { generateKeyPairSync } from "node:crypto";
import { spawnSync } from "node:child_process";
import { once } from "node:events";
import { afterEach, describe, expect, it } from "vitest";
import type { AppSettings } from "../src/shared/types";
import { createBackupServer } from "../services/backup-api/src/server.mjs";
import { recoverOnlineKey } from "../services/backup-api/src/recovery.mjs";
import { createOnlineBackup, deleteOnlineBackup, downloadOnlineBackup, uploadOnlineBackup } from "../src/main/online-backup";
const servers: ReturnType<typeof createBackupServer>[] = [];
@@ -16,6 +19,92 @@ afterEach(async () => {
});
describe("online backup client and service", () => {
it("rejects private or weak recovery keys in the service configuration", () => {
const pair = generateKeyPairSync("rsa", { modulusLength: 2048, publicKeyEncoding: { type: "spki", format: "pem" }, privateKeyEncoding: { type: "pkcs8", format: "pem" } });
expect(() => createBackupServer({ rootDir: "unused", recoveryPublicKey: pair.privateKey })).toThrow(/public/);
expect(() => createBackupServer({ rootDir: "unused", recoveryPublicKey: pair.publicKey })).toThrow(/3072/);
});
it("stores only an encrypted recovery key and recovers it with the separate admin key", async () => {
const directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), "mdd-recovery-"));
directories.push(directory);
const rootDir = path.join(directory, "data");
const pair = generateKeyPairSync("rsa", {
modulusLength: 3072,
publicKeyEncoding: { type: "spki", format: "pem" },
privateKeyEncoding: { type: "pkcs8", format: "pem" }
});
const server = createBackupServer({ rootDir, recoveryPublicKey: pair.publicKey });
servers.push(server);
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
if (!address || typeof address === "string") throw new Error("Missing address");
const baseUrl = `http://127.0.0.1:${address.port}`;
const settings = { token: "recovery-secret", outputDir: "D:\\Downloads" } as AppSettings;
const created = createOnlineBackup(settings, "2.0.90");
await uploadOnlineBackup(created.record, baseUrl, created.key);
const raw = await fs.promises.readFile(path.join(rootDir, `${created.record.id}.json`), "utf8");
expect(raw).not.toContain(created.key);
expect(raw).not.toContain("recovery-secret");
expect(raw).not.toContain(pair.privateKey);
const stored = { ...JSON.parse(raw), id: created.record.id };
expect(recoverOnlineKey(stored, pair.privateKey)).toBe(created.key);
expect(() => recoverOnlineKey({ ...stored, blob: stored.blob + "A" }, pair.privateKey)).toThrow();
expect(() => recoverOnlineKey({ ...stored, id: "AAAAAAAAAAAAAAAAAAAAAA" }, pair.privateKey)).toThrow();
expect(() => recoverOnlineKey({ ...stored, recovery: undefined }, pair.privateKey)).toThrow();
const wrongPair = generateKeyPairSync("rsa", { modulusLength: 3072, privateKeyEncoding: { type: "pkcs8", format: "pem" }, publicKeyEncoding: { type: "spki", format: "pem" } });
expect(() => recoverOnlineKey(stored, wrongPair.privateKey)).toThrow();
const corrupted = { ...stored.recovery, ciphertext: (stored.recovery.ciphertext[0] === "A" ? "B" : "A") + stored.recovery.ciphertext.slice(1) };
expect(() => recoverOnlineKey({ ...stored, recovery: corrupted }, pair.privateKey)).toThrow();
const restore = await fetch(`${baseUrl}/v1/backups/restore`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ id: stored.id }) });
expect(await restore.json()).toEqual({ blob: stored.blob });
expect((await downloadOnlineBackup(created.key, baseUrl)).settings).toEqual(settings);
expect((await fetch(`${baseUrl}/v1/backups/recover`, { method: "POST" })).status).toBe(404);
const rejected = await fetch(`${baseUrl}/v1/backups`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...created.record, recovery: { ...stored.recovery, keyId: "wrong-key" } }) });
expect(rejected.status).toBe(400);
const privateFile = path.join(directory, "private.pem");
const outputFile = path.join(directory, "recovered.txt");
await fs.promises.writeFile(privateFile, pair.privateKey, { mode: 0o600 });
const cli = path.resolve("services/backup-api/src/recovery-admin.mjs");
const run = (...args: string[]) => spawnSync(process.execPath, [cli, ...args], { encoding: "utf8" });
const listed = run("list", rootDir);
expect(listed.status).toBe(0);
expect(JSON.parse(listed.stdout).id).toBe(stored.id);
expect(listed.stdout).not.toContain(created.key);
const recovered = run("recover", rootDir, stored.id, privateFile, outputFile);
expect(recovered.status).toBe(0);
expect(recovered.stdout + recovered.stderr).not.toContain(created.key);
expect((await fs.promises.readFile(outputFile, "utf8")).trim()).toBe(created.key);
expect(run("recover", rootDir, stored.id, privateFile, outputFile).status).toBe(1);
expect(run("recover", rootDir, "../invalid", privateFile, outputFile).status).toBe(1);
expect(run("recover", rootDir, stored.id, privateFile, path.join(rootDir, "leaked.txt")).status).toBe(1);
const keyDir = path.join(directory, "new-keys");
expect(run("init", keyDir).status).toBe(0);
const initialPrivate = await fs.promises.readFile(path.join(keyDir, "private.pem"), "utf8");
expect(run("init", keyDir).status).toBe(1);
expect(await fs.promises.readFile(path.join(keyDir, "private.pem"), "utf8")).toBe(initialPrivate);
await deleteOnlineBackup(created.key, baseUrl);
expect(fs.existsSync(path.join(rootDir, `${stored.id}.json`))).toBe(false);
});
it("does not silently upload without recovery when the service is not configured", async () => {
const rootDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "mdd-no-recovery-"));
directories.push(rootDir);
const server = createBackupServer({ rootDir });
servers.push(server);
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
if (!address || typeof address === "string") throw new Error("Missing address");
const baseUrl = `http://127.0.0.1:${address.port}`;
const created = createOnlineBackup({ token: "secret" } as AppSettings, "2.0.90");
await expect(uploadOnlineBackup(created.record, baseUrl, created.key)).rejects.toThrow(/nicht eingerichtet/);
expect(await fs.promises.readdir(rootDir)).toEqual([]);
await uploadOnlineBackup(created.record, baseUrl);
expect((await downloadOnlineBackup(created.key, baseUrl)).settings.token).toBe("secret");
});
it("keeps every export independently restorable and deletes only the selected snapshot", async () => {
const rootDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "mdd-online-backup-"));
directories.push(rootDir);
+18
View File
@@ -29,6 +29,24 @@ function settings(): AppSettings {
}
describe("online backup key", () => {
it("fails closed on an invalid recovery descriptor without uploading a backup", async () => {
const urls: string[] = [];
const server = http.createServer((request, response) => {
urls.push(request.url || "");
request.resume();
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ version: 1, keyId: "invalid", publicKey: "server-secret-value" }));
});
servers.push(server);
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
if (!address || typeof address === "string") throw new Error("Missing address");
const created = createOnlineBackup(settings(), "2.0.90");
await expect(uploadOnlineBackup(created.record, `http://127.0.0.1:${address.port}`, created.key)).rejects.toThrow("Wiederherstellungsschlüssel des Sicherungsservers ist ungültig");
expect(urls).toEqual(["/v1/backups/recovery-key"]);
});
it("creates a compact key and restores every immutable settings snapshot independently", () => {
const first = createOnlineBackup(settings(), "2.0.0", "2026-08-07T00:00:00.000Z");
const second = createOnlineBackup({ ...settings(), outputDir: "E:\\Neu" }, "2.0.0", "2026-08-08T00:00:00.000Z");