fix(crypto): rotate the backups v1.39.x stored as one stream

A weekly or uploaded backup written by v1.39.x is a single ~hlgcm1. stream
in data_backups.data. Its key id sits behind the stream marker, which the
string codec's parser does not read, so key rotation filed every such row
as legacy, failed to decrypt it and counted it as an error. On a host that
ran v1.39.x that was every weekly copy, and the rotation never finished
clean.

Rotation now reads the key id of a streamed value and re-seals it as a
stream under the active key, without touching what it holds.
This commit is contained in:
Marc-André Bombeck committed 2026-09-25 23:54:34 +02:00
1 parent fabd691128
commit b0f9bbe530
4 files changed
+91 -3

No files matched your search

@@ -40,6 +40,7 @@ import {
type CorpusClient,
} from "@/lib/crypto/encryption-corpus";
import { packBackupBlob, unpackBackupBlob } from "@/lib/export/backup-blob";
import { legacyStreamedBlob } from "@/__tests__/helpers/legacy-backup-blob";
import { getPrismaClient, truncateAllTables } from "./setup";
const TEST_USER_ID = "user-rotation-blobs";
@@ -153,6 +154,37 @@ describe("key rotation over the non-suffixed ciphertext columns", () => {
expect(unpackBackupBlob(row.data)).toBe(BACKUP_JSON);
});
it("rotates the single stream v1.39.1 stored, and it stays readable", async () => {
// The `~hlgcm1.` form carries its key id behind a marker the string
// codec's parser does not read, so the walk used to file it as legacy
// and fail to decrypt it: the one copy form every v1.39.x host had was
// counted as an error on every rotation.
const prisma = getPrismaClient();
const previous = process.env.ENCRYPTION_ACTIVE_KEY_ID;
process.env.ENCRYPTION_ACTIVE_KEY_ID = "v1";
_resetCryptoCacheForTests();
const stored = legacyStreamedBlob(BACKUP_JSON);
process.env.ENCRYPTION_ACTIVE_KEY_ID = previous;
_resetCryptoCacheForTests();
const row = await prisma.dataBackup.create({
data: { userId: TEST_USER_ID, type: "WEEKLY_AUTO", data: stored },
});
const result = await rotateColumn(
{ dataBackup: prisma.dataBackup } as unknown as CorpusClient,
column("DataBackup", "data"),
);
expect(result).toMatchObject({ scanned: 1, rotated: 1, errors: 0 });
process.env.ENCRYPTION_KEYS = JSON.stringify({ v2: "2".repeat(64) });
_resetCryptoCacheForTests();
const after = await prisma.dataBackup.findUniqueOrThrow({
where: { id: row.id },
});
expect(after.data!.startsWith("~hlgcm1.v2.")).toBe(true);
expect(unpackBackupBlob(after.data!)).toBe(BACKUP_JSON);
});
it("drops an unreadable idempotency row instead of failing the run", async () => {
const prisma = getPrismaClient();
const seed = async (key: string, body: string) =>