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

+17 -3
View File
@@ -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({