Commit Graph
7 Commits
Author SHA1 Message Date
Marc-André Bombeck b0f9bbe530 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.
2026-09-25 23:54:34 +02:00
Marc-André Bombeck 0a5fa4b2ae fix(crypto): page the key-rotation walks by id, not by cursor
The corpus walk and the rotation script paged with Prisma's cursor and skip.
Prisma resolves that cursor by reading the page's last row again, so when
that row was hard-deleted before the next page (a backup replaced, a cached
response expired), the next page came back empty and the walk ended early.
The scan then counted fewer rows than the database holds, which could read
as nothing left on the old key while rows it never reached still were.

Both walks now ask for the rows whose id is greater than the last one read,
which needs no row to still exist.
2026-09-25 09:17:25 +02:00
Marc-André Bombeck 0a54a43799 fix(crypto): walk every encrypted column in bounded pages
Key rotation and the rotation status read every column that was not
flagged as a blob with one `findMany` over the whole table.
`Measurement.notesEncrypted` and `MoodEntry.noteEncrypted` are such
columns, so rotation loaded every measurement of the instance: 1.25
million rows on one large account (#1031), and the rotation status page
did the same on every view.

Every column is now walked with the id-cursor pages the blob columns
already used: 25 rows a page for blobs, 5 000 for everything else. The
maintenance script's own per-column walks page the same way. On the seeded
account with 1 460 notes to rotate, peak heap fell from 272 MB to 153 MB,
and it no longer grows with the table.

`scanned` now counts the rows that hold ciphertext for every column, as
it already did for the blob columns, rather than every row of the table
for some columns and only the ciphertext rows for others. The runbook
says so.
2026-09-25 02:05:19 +02:00
Marc-André Bombeck 45d0fe442f fix(crypto): rotate the backup blob and stop keying the registry on names
`DataBackup.data` holds every weekly disaster-recovery snapshot and every
uploaded pack, AES-256-GCM at rest like everything else, under a column
called `data`. The rotation registry was keyed on the `*Encrypted` naming
convention, so the script never read a backup, reported zero rows
remaining, and the runbook read that zero as permission to retire the
previous key. Following the documented procedure destroyed every stored
backup — the copy an operator reaches for precisely when nothing else
works.

Register it, and register the idempotent-replay response cache the same
sweep turned up. The backup column walks in bounded batches (one row is a
whole compressed account) and re-seals the ciphertext without reading the
plaintext, so both stored envelopes come back byte-identical and so will
any later one. The cache is marked disposable: a row it cannot read is
dropped rather than counted as an error, because keeping it only
guarantees the next replay gets a body it cannot parse.

Running the script against a seeded database rather than reading it turned
up two more. It died on the mood day-context note, whose table is keyed on
the entry rather than on an `id`, and abandoned every column after it —
the backups included. And the document index's verbatim text rotated
without ever being named in the summary.

Make the naming assumption stop carrying weight. A new guard traces the
encryption helpers through their wrappers, walks every Prisma write
payload, and fails when a column receives ciphertext without being
registered. It asserts an anchored, non-zero match count so a matcher that
stops matching fails instead of passing. A second check pins each model's
primary key against the schema.

The script now ends by naming the registered columns it did NOT walk and
exiting non-zero when any is missing: a zero that means "nothing left" and
a zero that means "never looked" must not print the same. The runbook's
retire-the-key step was rewritten around that signal, and tells an
operator who already rotated on an older release how to get their backups
back.
2026-09-03 06:29:57 +02:00
Marc-André Bombeck 0e9c5f1a00 Drop unimported re-exports outside the barrels
Eighteen modules carried a convenience re-export of a symbol defined
elsewhere. In each case the callers the comment describes now import the
concrete module directly, so the line had no importer left. Where the
whole re-export block went, the comment explaining it went with it —
doctor-report-pdf-core, trend-card and medication-extract-prompt each
claimed to be a single import surface that nothing was using.

Definitions are untouched; imports that existed only to feed a removed
re-export go too.
2026-08-23 19:11:12 +02:00
Marc-André Bombeck 972c5fbf48 feat(crypto): rotate the document blob column in bounded, codec-aware batches
The registry entry for InboundDocument.contentEncrypted gains a codecField
marker: the column now holds two layouts (base64v1 string codec | binary2
binary codec), recorded per row, and its rows are up to cap-sized blobs.
scan/rotate walk such a column with id-cursor pagination (25 rows per
page — never an unbounded blob findMany) and re-encrypt each row under its
OWN codec; an unknown codec value is counted as an error and the row left
untouched, same fail-closed posture as a missing key. The walk is
idempotent, so an interrupted run resumes on re-invocation with the
already-active rows skipped.

Both rotation paths share the logic: the admin-triggered pg-boss job via
rotateCorpus() and the CLI (scripts/rotate-encryption-key.ts), which now
calls the shared batched walk for this column. The registry guard test
keeps schema, registry, and script in lock-step as before.

Unit coverage over a mocked 500-row corpus asserts the batch boundedness
(take + advancing cursor per page), both-codec rotation, per-row
fail-closed on unknown codecs, and interrupted-run resume.

Task: W1-T11.
2026-07-07 11:05:43 +02:00
Marc-André Bombeck e5f9fb7c4a feat(admin): show encryption coverage and rotation progress
Add a read-only admin Encryption section that surfaces what the rotation
tooling computes: per encrypted-column rows-per-key-id, legacy-key row
counts, and overall rotation progress, plus a 'safe to drop the legacy
key' state once every column sits on the active key. GET
/api/admin/encryption/status drives it and returns only key ids and
counts — never key material. A shared scan over the canonical encrypted-
column registry backs both this view and the rotation run. Cookie-only
admin.
2026-06-27 20:27:31 +02:00