mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +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
@@ -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<string> {
|
||||
const pieces: Buffer[] = [];
|
||||
await producer(async (chunk) => {
|
||||
pieces.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
||||
});
|
||||
return legacyStreamedBlob(Buffer.concat(pieces));
|
||||
}
|
||||
@@ -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