Files
HealthLog/src/lib/workouts/route-geometry-cipher.ts
T
Marc-André Bombeck 29c5b5b83b fix(crypto): one read order for a sealed column beside its readable one
Notes, workout tracks and the cached insights payload each read their
ciphertext and legacy column their own way: the cache read the readable
column first, and the two that read the ciphertext first disagreed on a
value that does not open. readSealedOrLegacy fixes the order (ciphertext
whenever present, the readable column only without one, never as a stand-in
for a failed open) and makes each caller state what a failed open means.
2026-10-20 09:29:03 +02:00

63 lines
2.6 KiB
TypeScript

/**
* Workout GPS track ↔ encrypted column (v1.39.4).
*
* `WorkoutRoute.geometry` held the GeoJSON LineString of every outdoor workout
* readable in the database, and the first and last points of most tracks are
* the person's front door. From v1.39.4 the track is stored in
* `geometryEncrypted`: the JSON text sealed as one binary AES-256-GCM value
* (`encryptBytes`, labelled with {@link WORKOUT_ROUTE_GEOMETRY_AAD}). The
* binary codec rather than the base64 string codec the notes use, because a
* track of 20 000 points is a blob and base64 would add a third to it.
*
* Every reader goes through {@link readRouteGeometry}: the ciphertext when a
* row has one, the legacy readable column for a row the boot-time backfill has
* not reached yet. A ciphertext that does not open reads as no track, with a
* warning on the request's event: it is never replaced by the readable column,
* and one damaged track must not fail the workout detail or the insight job
* that reads it. `decryptRouteGeometry` itself still throws.
*
* The wire shape is unchanged: readers hand back the same GeoJSON object the
* JSONB column used to return.
*/
import { Buffer } from "node:buffer";
import { decryptBytes, encryptBytes } from "@/lib/crypto";
import { WORKOUT_ROUTE_GEOMETRY_AAD } from "@/lib/crypto/encrypted-columns";
import { readSealedOrLegacy, warnAndDrop } from "@/lib/crypto/sealed-or-legacy";
/** Seal a route's GeoJSON geometry for `WorkoutRoute.geometryEncrypted`. */
export function encryptRouteGeometry(
geometry: unknown,
): Uint8Array<ArrayBuffer> {
const sealed = encryptBytes(
Buffer.from(JSON.stringify(geometry), "utf8"),
WORKOUT_ROUTE_GEOMETRY_AAD,
);
const out = new Uint8Array(new ArrayBuffer(sealed.byteLength));
out.set(sealed);
return out;
}
/** Open a sealed route geometry. Throws on a bad key id or a tampered value. */
export function decryptRouteGeometry(sealed: Uint8Array): unknown {
const plain = decryptBytes(Buffer.from(sealed), WORKOUT_ROUTE_GEOMETRY_AAD);
return JSON.parse(plain.toString("utf8")) as unknown;
}
/**
* The geometry a route row holds: the ciphertext first, the legacy readable
* column only when there is no ciphertext. Null when the row holds neither,
* and null (with a warning) when the ciphertext does not open.
*/
export function readRouteGeometry(row: {
geometry?: unknown;
geometryEncrypted?: Uint8Array | null;
}): unknown {
return readSealedOrLegacy<unknown>({
sealed: row.geometryEncrypted,
legacy: row.geometry,
open: decryptRouteGeometry,
onUndecryptable: warnAndDrop("workout route decrypt failed"),
});
}