Add encrypted administrative recovery for online backup keys
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user