mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
The shortest value either codec writes is 40 base64 characters. A shorter string under an ...Encrypted member is not ciphertext, and reading it as a legacy value would have made a restore demand the v1 key for a field that holds none.
179 lines
5.9 KiB
TypeScript
179 lines
5.9 KiB
TypeScript
/**
|
|
* The key ids a backup's inner ciphertext needs, and the verdict against the
|
|
* keys this host has.
|
|
*
|
|
* The values are produced by the real codecs under real keys, then the host's
|
|
* key set is changed underneath them, which is exactly what an operator does
|
|
* when a rotation is followed by dropping the old key.
|
|
*/
|
|
import { Buffer } from "node:buffer";
|
|
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
|
|
import { _resetCryptoCacheForTests, encrypt, encryptBytes } from "@/lib/crypto";
|
|
import {
|
|
assessBackupKeys,
|
|
BackupKeyIdCollector,
|
|
createBackupKeyIdTextScanner,
|
|
describeBackupKeyProblem,
|
|
innerCiphertextKeyId,
|
|
} from "../backup-key-ids";
|
|
|
|
const K1 = "11".repeat(32);
|
|
const K2 = "22".repeat(32);
|
|
const K3 = "33".repeat(32);
|
|
|
|
function useKeys(keys: Record<string, string>, active: string) {
|
|
delete process.env.ENCRYPTION_KEY;
|
|
process.env.ENCRYPTION_KEYS = JSON.stringify(keys);
|
|
process.env.ENCRYPTION_ACTIVE_KEY_ID = active;
|
|
_resetCryptoCacheForTests();
|
|
}
|
|
|
|
const saved = {
|
|
key: process.env.ENCRYPTION_KEY,
|
|
keys: process.env.ENCRYPTION_KEYS,
|
|
active: process.env.ENCRYPTION_ACTIVE_KEY_ID,
|
|
};
|
|
|
|
beforeEach(() => useKeys({ old: K1, cur: K2 }, "old"));
|
|
afterEach(() => {
|
|
for (const [name, value] of [
|
|
["ENCRYPTION_KEY", saved.key],
|
|
["ENCRYPTION_KEYS", saved.keys],
|
|
["ENCRYPTION_ACTIVE_KEY_ID", saved.active],
|
|
] as const) {
|
|
if (value === undefined) delete process.env[name];
|
|
else process.env[name] = value;
|
|
}
|
|
_resetCryptoCacheForTests();
|
|
});
|
|
|
|
/** A Bytes column holding the string codec, as a DR payload carries it. */
|
|
const bytesString = (plain: string) =>
|
|
Buffer.from(encrypt(plain), "utf8").toString("base64");
|
|
/** A Bytes column holding the binary codec, as a document's content. */
|
|
const binary = (plain: string) =>
|
|
encryptBytes(Buffer.from(plain)).toString("base64");
|
|
|
|
describe("innerCiphertextKeyId", () => {
|
|
it("reads the key id of each stored layout", () => {
|
|
expect(innerCiphertextKeyId(encrypt("note"))).toEqual({
|
|
keyId: "old",
|
|
form: "string",
|
|
});
|
|
expect(innerCiphertextKeyId(bytesString("note"))).toEqual({
|
|
keyId: "old",
|
|
form: "bytes-string",
|
|
});
|
|
expect(innerCiphertextKeyId(binary("document"))).toEqual({
|
|
keyId: "old",
|
|
form: "binary",
|
|
});
|
|
});
|
|
|
|
it("reads a value with no key id as the legacy v1 key", () => {
|
|
const legacy = Buffer.from("A".repeat(40)).toString("base64");
|
|
expect(innerCiphertextKeyId(legacy)?.keyId).toBe("v1");
|
|
});
|
|
|
|
it("ignores an empty value, a short one, and one that is not base64", () => {
|
|
expect(innerCiphertextKeyId("")).toBeNull();
|
|
expect(innerCiphertextKeyId("yes")).toBeNull();
|
|
expect(
|
|
innerCiphertextKeyId("not ciphertext at all, and long enough to count!"),
|
|
).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("assessBackupKeys", () => {
|
|
function fileWrittenUnderOld() {
|
|
return {
|
|
userId: "u1",
|
|
moodEntries: [{ noteEncrypted: bytesString("a mood note") }],
|
|
documents: [{ contentEncrypted: binary("%PDF-1.4 …") }],
|
|
appSettings: { adminAiKeyEncrypted: encrypt("sk-something") },
|
|
};
|
|
}
|
|
|
|
it("passes a file whose keys are all configured and open", () => {
|
|
const collector = new BackupKeyIdCollector();
|
|
collector.visit(fileWrittenUnderOld());
|
|
collector.visit({ notesEncrypted: bytesString("x") }, "measurements");
|
|
const verdict = assessBackupKeys(collector);
|
|
expect(verdict).toEqual({
|
|
keyIds: ["old"],
|
|
missing: [],
|
|
unreadable: [],
|
|
affectedValues: 0,
|
|
});
|
|
expect(describeBackupKeyProblem(verdict)).toBeNull();
|
|
});
|
|
|
|
it("names a key the file needs and the host no longer has", () => {
|
|
const file = fileWrittenUnderOld();
|
|
useKeys({ cur: K2 }, "cur");
|
|
const collector = new BackupKeyIdCollector();
|
|
collector.visit(file);
|
|
const verdict = assessBackupKeys(collector);
|
|
expect(verdict.missing).toEqual(["old"]);
|
|
expect(verdict.affectedValues).toBe(3);
|
|
expect(describeBackupKeyProblem(verdict)).toContain("'old'");
|
|
});
|
|
|
|
it("leaves out sections the caller will not write", () => {
|
|
useKeys({ old: K1, cur: K2 }, "cur");
|
|
const file = {
|
|
moodEntries: [{ noteEncrypted: bytesString("fine") }],
|
|
appSettings: {
|
|
adminAiKeyEncrypted: `gone.${Buffer.alloc(40).toString("base64")}`,
|
|
},
|
|
};
|
|
const collector = new BackupKeyIdCollector();
|
|
collector.visit(file);
|
|
expect(assessBackupKeys(collector).missing).toEqual(["gone"]);
|
|
expect(
|
|
assessBackupKeys(collector, { ignoreSections: new Set(["appSettings"]) })
|
|
.missing,
|
|
).toEqual([]);
|
|
});
|
|
|
|
it("catches an id that is configured with different key material", () => {
|
|
const file = fileWrittenUnderOld();
|
|
useKeys({ old: K3, cur: K2 }, "cur");
|
|
const collector = new BackupKeyIdCollector();
|
|
collector.visit(file);
|
|
const verdict = assessBackupKeys(collector);
|
|
expect(verdict.missing).toEqual([]);
|
|
expect(verdict.unreadable).toEqual(["old"]);
|
|
});
|
|
});
|
|
|
|
describe("createBackupKeyIdTextScanner", () => {
|
|
it("finds every key id in JSON fed in arbitrary pieces", () => {
|
|
const early = bytesString("written before the rotation");
|
|
useKeys({ old: K1, cur: K2 }, "cur");
|
|
const late = binary("written after it");
|
|
const json = JSON.stringify({
|
|
measurements: [{ id: "m1", notesEncrypted: early }],
|
|
documents: [{ contentEncrypted: late, note: "plain" }],
|
|
plain: "noteEncrypted",
|
|
});
|
|
for (const size of [1, 7, 33, 4096]) {
|
|
const scanner = createBackupKeyIdTextScanner();
|
|
for (let i = 0; i < json.length; i += size) {
|
|
scanner.feed(Buffer.from(json.slice(i, i + size), "utf8"));
|
|
}
|
|
expect(scanner.keyIds()).toEqual(["cur", "old"]);
|
|
}
|
|
});
|
|
|
|
it("reads pretty-printed JSON", () => {
|
|
const scanner = createBackupKeyIdTextScanner();
|
|
scanner.feed(
|
|
JSON.stringify({ a: { noteEncrypted: bytesString("x") } }, null, 2),
|
|
);
|
|
expect(scanner.keyIds()).toEqual(["old"]);
|
|
});
|
|
});
|