mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
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:
1 parent
fabd691128
commit
b0f9bbe530
4 files changed
+91
-3
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user