mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
Key rotation re-sealed a single-value backup in place, which holds the stored string, its bytes, the plaintext and the new ciphertext at once; a copy can be over a hundred megabytes and the in-app rotation walked 25 of them per page, enough to run a 1 GB container out of memory. Rotation now converts each such copy into pieces under the active key instead, one row at a time. The ~hlgcm1. stream of v1.38.6 to v1.39.1 is read a megabyte at a time with its tag checked at the end, inside one transaction, so a copy that fails the check is left untouched; the copy keeps its date. The scan reads only the first characters of each value. Measured on a copy of a 2.6 million-reading account (126 MB stored): converted in 25 s with the heap flat at 57 MB under a 200 MB limit, where re-sealing in place ran out of heap. The weekly and manual backup leave an account alone while a restore of its current copy is queued or running, and count it as skipped, not failed. A reader whose copy is replaced mid-read answers 409 backup_changed, "replaced by a newer one while it was being read", rather than the undecryptable error; a restore rolls back with the same code. Pieces are sealed with a fixed associated-data label, so a value the same key sealed for another purpose never opens as a piece; rotation re-seals under the same label. A row holding both a single value and pieces, which only an older release writing after a downgrade produces, is refused with a message saying so, and the next weekly run replaces it. Rotation treats a row deleted between its read and its write as gone rather than an error.
111 lines
3.9 KiB
TypeScript
111 lines
3.9 KiB
TypeScript
/**
|
|
* One sealed piece of a stored backup, and what it refuses.
|
|
*
|
|
* Every piece is authenticated on its own, and its sealed header names the
|
|
* copy, its position and whether it is the last. Each case below is one way a
|
|
* stored copy can stop being the copy that was written; each has to be refused
|
|
* before any of the piece's content is handed on.
|
|
*/
|
|
process.env.ENCRYPTION_KEY ??=
|
|
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
|
|
|
import { Buffer } from "node:buffer";
|
|
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
import { decryptBytes, encryptBytes } from "@/lib/crypto";
|
|
import { BACKUP_CHUNK_AAD } from "@/lib/crypto/encrypted-columns";
|
|
import {
|
|
BackupIntegrityError,
|
|
newChunkStreamId,
|
|
openBackupChunk,
|
|
sealBackupChunk,
|
|
} from "../backup-chunks";
|
|
|
|
const PAYLOAD = Buffer.from("a piece of a gzip stream");
|
|
|
|
describe("sealed backup pieces", () => {
|
|
it("opens to exactly the payload it sealed", () => {
|
|
const id = newChunkStreamId();
|
|
const sealed = sealBackupChunk(id, 3, false, PAYLOAD);
|
|
expect(
|
|
openBackupChunk(sealed, { streamId: id, seq: 3, last: false }),
|
|
).toEqual(PAYLOAD);
|
|
});
|
|
|
|
it("does not carry the payload in the clear", () => {
|
|
const sealed = sealBackupChunk(newChunkStreamId(), 0, true, PAYLOAD);
|
|
expect(sealed.includes(PAYLOAD)).toBe(false);
|
|
});
|
|
|
|
it("refuses a piece whose bytes were altered", () => {
|
|
const id = newChunkStreamId();
|
|
const sealed = sealBackupChunk(id, 0, true, PAYLOAD);
|
|
const altered = Buffer.from(sealed);
|
|
altered[altered.byteLength - 1]! ^= 0x01;
|
|
expect(() =>
|
|
openBackupChunk(altered, { streamId: id, seq: 0, last: true }),
|
|
).toThrow(BackupIntegrityError);
|
|
});
|
|
|
|
it("refuses a piece stored at another position", () => {
|
|
const id = newChunkStreamId();
|
|
const sealed = sealBackupChunk(id, 1, false, PAYLOAD);
|
|
expect(() =>
|
|
openBackupChunk(sealed, { streamId: id, seq: 2, last: false }),
|
|
).toThrow(/written as piece 1/);
|
|
});
|
|
|
|
it("refuses a piece from another copy", () => {
|
|
const sealed = sealBackupChunk(newChunkStreamId(), 0, true, PAYLOAD);
|
|
expect(() =>
|
|
openBackupChunk(sealed, {
|
|
streamId: newChunkStreamId(),
|
|
seq: 0,
|
|
last: true,
|
|
}),
|
|
).toThrow(/different stored copy/);
|
|
});
|
|
|
|
it("refuses a copy cut short: the last listed piece was not written last", () => {
|
|
const id = newChunkStreamId();
|
|
const sealed = sealBackupChunk(id, 4, false, PAYLOAD);
|
|
expect(() =>
|
|
openBackupChunk(sealed, { streamId: id, seq: 4, last: true }),
|
|
).toThrow(/more were written/);
|
|
});
|
|
|
|
it("refuses a last piece followed by more", () => {
|
|
const id = newChunkStreamId();
|
|
const sealed = sealBackupChunk(id, 4, true, PAYLOAD);
|
|
expect(() =>
|
|
openBackupChunk(sealed, { streamId: id, seq: 4, last: false }),
|
|
).toThrow(/copy lists more/);
|
|
});
|
|
|
|
it("refuses a piece under a key this host does not hold", () => {
|
|
const id = newChunkStreamId();
|
|
const sealed = sealBackupChunk(id, 0, true, PAYLOAD);
|
|
// Re-label the key id in the binary header ([0x02][len][id…]).
|
|
const relabelled = Buffer.from(sealed);
|
|
relabelled[2] = "x".charCodeAt(0);
|
|
expect(() =>
|
|
openBackupChunk(relabelled, { streamId: id, seq: 0, last: true }),
|
|
).toThrow(BackupIntegrityError);
|
|
});
|
|
|
|
it("refuses a piece sealed without the backup-piece label", () => {
|
|
// The same key seals other binary values too (documents, for one). A
|
|
// value sealed for another purpose must not open as a backup piece, even
|
|
// if its plaintext happens to look like one.
|
|
const id = newChunkStreamId();
|
|
const sealed = sealBackupChunk(id, 0, true, PAYLOAD);
|
|
const plain = decryptBytes(sealed, BACKUP_CHUNK_AAD);
|
|
const unlabelled = encryptBytes(plain);
|
|
expect(() =>
|
|
openBackupChunk(unlabelled, { streamId: id, seq: 0, last: true }),
|
|
).toThrow(BackupIntegrityError);
|
|
expect(() => decryptBytes(sealed)).toThrow();
|
|
});
|
|
});
|