Files
HealthLog/src/lib/export/__tests__/backup-chunks.test.ts
T
Marc-André Bombeck 8ad4610370 fix(backup): make rotation, concurrent writes and old rows safe for pieces
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.
2026-09-26 01:49:02 +02:00

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();
});
});