From b0f9bbe530167aeaaa447ccea1be221bf642fb82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 25 Sep 2026 23:54:34 +0200 Subject: [PATCH] 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. --- src/__tests__/helpers/legacy-backup-blob.ts | 31 ++++++++++++++++++ src/lib/crypto.ts | 11 +++++++ src/lib/crypto/encryption-corpus.ts | 20 ++++++++++-- .../encryption-key-rotation-blobs.test.ts | 32 +++++++++++++++++++ 4 files changed, 91 insertions(+), 3 deletions(-) create mode 100644 src/__tests__/helpers/legacy-backup-blob.ts diff --git a/src/__tests__/helpers/legacy-backup-blob.ts b/src/__tests__/helpers/legacy-backup-blob.ts new file mode 100644 index 000000000..17e1ad699 --- /dev/null +++ b/src/__tests__/helpers/legacy-backup-blob.ts @@ -0,0 +1,31 @@ +/** + * The single-value backup form v1.39.1 wrote into `data_backups.data`: the + * JSON gzipped, then sealed as one AES-256-GCM stream (`~hlgcm1.…`). + * + * Nothing in the app writes it any more; copies stored in pieces replaced it + * in v1.39.2. Copies in this form still exist on every host that ran v1.39.x, + * and they still have to restore, so the tests build them with this. + */ +import { Buffer } from "node:buffer"; +import { gzipSync } from "node:zlib"; + +import { createStreamEncryptor } from "@/lib/crypto"; +import type { BackupJsonProducer } from "@/lib/export/backup-blob"; + +/** `json` in the v1.39.1 single-value form, under the active key. */ +export function legacyStreamedBlob(json: string | Buffer): string { + const encryptor = createStreamEncryptor(); + const body = encryptor.update(gzipSync(json)); + return `${encryptor.header}${body}${encryptor.final()}`; +} + +/** The same, from a producer that writes the JSON in pieces. */ +export async function legacyStreamedBlobFrom( + producer: BackupJsonProducer, +): Promise { + const pieces: Buffer[] = []; + await producer(async (chunk) => { + pieces.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); + }); + return legacyStreamedBlob(Buffer.concat(pieces)); +} diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts index 11d381643..a3a4ea563 100644 --- a/src/lib/crypto.ts +++ b/src/lib/crypto.ts @@ -578,6 +578,17 @@ export function decryptStream(stored: string): Buffer { return decryptRawStream(Buffer.from(rest.slice(dot + 1), "base64"), key); } +/** + * Re-seal a streamed ciphertext under the active key, as a streamed + * ciphertext. Used by rotation for the backups v1.39.1 stored in this form; + * the plaintext is the backup's gzip bytes and is never inspected. + */ +export function reencryptStreamToActive(stored: string): string { + const plaintext = decryptStream(stored); + const encryptor = createStreamEncryptor(); + return `${encryptor.header}${encryptor.update(plaintext)}${encryptor.final()}`; +} + /** The key id a streamed ciphertext was written under, or null when unparsable. */ export function extractStreamKeyId(stored: string): string | null { if (!isStreamCiphertext(stored)) return null; diff --git a/src/lib/crypto/encryption-corpus.ts b/src/lib/crypto/encryption-corpus.ts index 82ebf6bfc..ee7befebb 100644 --- a/src/lib/crypto/encryption-corpus.ts +++ b/src/lib/crypto/encryption-corpus.ts @@ -35,8 +35,11 @@ import { encrypt, extractKeyId, extractKeyIdFromBytes, + extractStreamKeyId, getActiveKeyId, + isStreamCiphertext, reencryptBytesToActive, + reencryptStreamToActive, } from "@/lib/crypto"; import { ENCRYPTED_COLUMNS, @@ -202,7 +205,7 @@ async function walkColumn( await onRow({ id: row[pk] as string, value, - codec: codecField ? String(row[codecField] ?? "") : null, + codec: codecField ? String(row[codecField] ?? "") : (col.codec ?? null), }); } cursor = rows[rows.length - 1]![pk] as string; @@ -228,7 +231,18 @@ function walkedKeyId( ): string | null { if (codec !== null) return blobKeyId(value as Uint8Array, codec); const ciphertext = toCiphertext(value, kind); - return ciphertext == null ? null : extractKeyId(ciphertext); + if (ciphertext == null) return null; + // The single-stream backup form v1.39.1 wrote carries its key id behind a + // `~hlgcm1.` marker, where the string codec's parser does not look. + if (isStreamCiphertext(ciphertext)) return extractStreamKeyId(ciphertext); + return extractKeyId(ciphertext); +} + +/** Re-seal a string-codec value under the active key, keeping its form. */ +function reencryptString(ciphertext: string): string { + return isStreamCiphertext(ciphertext) + ? reencryptStreamToActive(ciphertext) + : encrypt(decrypt(ciphertext)); } export interface ColumnScan { @@ -368,7 +382,7 @@ export async function rotateColumn( codec !== null ? reencryptBlob(value as Uint8Array, codec) : fromCiphertext( - encrypt(decrypt(toCiphertext(value, col.kind)!)), + reencryptString(toCiphertext(value, col.kind)!), col.kind, ); await delegate.update({ diff --git a/tests/integration/encryption-key-rotation-blobs.test.ts b/tests/integration/encryption-key-rotation-blobs.test.ts index c2187cd2f..36514503e 100644 --- a/tests/integration/encryption-key-rotation-blobs.test.ts +++ b/tests/integration/encryption-key-rotation-blobs.test.ts @@ -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) =>