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
+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);