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) =>