diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 55cc19edd..811c58530 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -10130,6 +10130,18 @@ Proof: four mutations, each red, zero vacuous -- per-character floor disabled, v **Still not an honest pass:** re-scoring on the writer evidence while leaving the read passthrough. That is more tempting now than when this item was filed, because the writer evidence got better -- `[store].aad_bind` ships true (`config/settings.py:410`, threaded at `store/base.py:1879`) and relocation detection was measured refusing, so three of four limbs read verified with clean measurements. The downgrade limb is not a weaker version of the substitution limb: substitution is a VERIFICATION with a tag to check, downgrade has no tag and only a REFUSAL can protect it. The second trap is nearly reached by anyone who measures the migration: arguing the passthrough is unreachable by any legitimate value and therefore harmless. It is false on the server backends for the attachment table, false on the uploads surface entirely, and grades the wrong thing even where true -- "no legitimate value has this shape" is an argument about inputs, not a control. Proposed work, unallocated and by subject: the strict-ciphertext read at both cipher implementations, defaulting on and registered in `security_loosenings()`; server-backend migration parity for the attachment table plus a mechanical reconciliation so a future covered table cannot ship without a pass; uploaded-file store crypto parity with a distinct logged and alerted refusal event; a review of the queue-claim `CipherError` containment sites, where a stripped payload would be absorbed as an ordinary undecryptable row at warning level; the DIRECT integrity option, costed and interop-measured; the re-put ruling packet; and a re-verification that re-anchors the stale evidence, takes delivery of the enveloped surface, adds the SSH transit hop, and sets the reviewer field this record lacks. +**PRECONDITIONS BUILT 2026-09-04. The refusal is still NOT built and still needs the re-put owner ruling — this item stays open.** Everything below was re-measured by execution against `a2eef0f37` before any code was written; every line number in the 2026-08-20 research had moved. The core claim survives verbatim: `crypto.py:756-757` returned a forged plaintext unchanged from a keyed cipher, with both controls firing in the same run (a mutated ciphertext and a wrong-cell AAD each raised `CipherError`). On the uploads surface a hand-written plaintext sidecar was accepted by a KEYED store and returned filename, uploader and uploader id all attacker-chosen, while a relocated SEALED sidecar raised — the positive control separating the passthrough from a broken test. + +**A SECOND precondition as reported is FALSE, and this one is the item's own.** `attachment_chunk` was reported "unmigrated on all three backends" on the strength of its three `INSERT INTO` sites. Those sites answer *where it is written*, not *where it is sealed* — an SDS-3.8 instrument mismatch. Measured: **SQLite has had an on-open pass all along** (`store/store.py:2765-2798`), and **rotation covers the table on all three** backends. The real gap was one pass on two backends: `_encrypt_existing_rows` omitted it on `postgres.py` and `sqlserver.py` only. Both now have it, with a batch of 16 rather than the sibling passes' 500 because one row is a whole 1 MiB `DETACH_CHUNK_BYTES` slice and 500 would hold ~650 MiB resident in one transaction at store open. The `shared_body` correction **holds** and needed no new evidence: `tests/test_phi_at_rest_inventory.py` already pinned it, with the same instrument and the same SQLite-only reasoning, *before this item was filed* — so the record contained its own refutation. Not restated in the new guard (SDS-3.5). + +**Precondition 2 was worse than reported and is a standing data-loss defect, not only a blocker.** The uploaded-file store had no migration pass *and* no rotation pass. Measured: after a rotation the on-disk blob was byte-identical, and once the prior key was dropped — the step `rotate-key`'s own docstring tells the operator to take — the upload raised `CipherError` permanently. `UploadStore.reseal_to_active()` now closes both transitions and `rotate-key` calls it on the store's live cipher instance (ASVS 11.3.4). **One asymmetry is deliberate and named rather than papered over:** the store seals legacy plaintext automatically at every keyed open, whereas this pass has one caller, so a first key-enable is closed only when an operator runs the command. A whole-directory crypto sweep at API startup is unbounded boot-time work and a separate decision. + +**Precondition 3 closed.** `_scan_metas_sync`'s blanket `except Exception` folded a cipher refusal, a damaged file and a malformed sidecar into one identical warning — measured, three causes, three identical lines. Each class is now caught and logged separately, so the refusal this item contemplates would be visible on the surface where planting is easiest instead of reading like a routine post-rotation skip. + +**A mechanical reconciliation ships with it** (`tests/test_store_cipher_sweep_parity.py`): every `cell_aad` cell whose table a backend INSERTs into must be reached by BOTH sweeps, so a future covered table cannot ship half-added the way this one did. The `INSERT INTO` scoping is load-bearing — without it the check falsely accuses both server backends over `shared_body`. **Recorded because it nearly shipped broken:** the first version matched raw source text and passed the real Postgres omission, because a comment above the missing call said the word `attachment_chunk`. Prose satisfied a check about code. It now matches only executed string constants, and both backend mutations were measured red before the fix and green after. + +**Named, measured and NOT done.** The refusal, pending the ruling. The three store `_reencrypt_value` staticmethods and the uploads one are four spellings of a single seam; the leaf-module rule blocks importing the store copies, so the new one carries the same name to stay greppable. Each backend still hand-maintains its own list of composite passes — roughly 26 sites, and SQL Server has no `_CIPHER_COLUMNS` at all, so `postgres.py:856`'s "mirrors `MessageStore._CIPHER_COLUMNS`" is untrue of one backend. Driving all passes off one declaration would retire this guard and ~1,300 lines of sibling AST scaffolding, but it is ~1,000 lines in the one path **no CI leg executes** (every SQL Server and Postgres leg is keyless and returns before the sweep body), so it is a separate item. `UploadStore` is also constructed from settings in three places with a repeated `uploads_dir` gate. + ## 1170. research an honest pass for ASVS 11.3.5 -- asserting encrypt-then-MAC on hops that deliberately keep CBC-SHA2 for hospital peers > 🔢 **Re-scored 2026-08-20 -> P2.** Value **5/10** · Difficulty **3/10** · _fill-in_. The at-rest half is AEAD-only (store/crypto.py:109-111 registers AES-256-GCM alone) and the transport half is unchanged, with the deliberate six-CBC-SHA2 retention recorded at tls_policy.py:375-376 and no encrypt_then_mac reference anywhere under messagefoundry/, so nothing asserts, logs or refuses on the RFC 7366 state. Value 5 because a deploying site has a clean workaround in the shipped tls_ciphers setting (settings.py:766, forward-secrecy-validated at :935-940), leaving the default's breadth rather than an unfixable property; difficulty 3 because the item's named concrete unknown is now answered -- the stdlib exposes no negotiation state at all on this interpreter -- so the remainder is a documented finding plus the V11.3-versus-V12 scope ruling. _(was 4/10 · 7/10.)_ diff --git a/messagefoundry/__main__.py b/messagefoundry/__main__.py index a19cb1e81..f8c2711e6 100644 --- a/messagefoundry/__main__.py +++ b/messagefoundry/__main__.py @@ -4464,6 +4464,7 @@ def _rotate_key(args: argparse.Namespace) -> int: from messagefoundry.store.base import open_store, resolve_active_key from messagefoundry.store.crypto import CipherError from messagefoundry.store.keyprovider import KeyProviderError + from messagefoundry.uploads import ResealResult, UploadStore cli: dict[str, dict[str, object]] = {} if args.db is not None: @@ -4495,7 +4496,7 @@ def _rotate_key(args: argparse.Namespace) -> int: ) return 2 - async def run() -> int: + async def run() -> tuple[int, ResealResult]: import datetime from messagefoundry.store.store import SecretRotationMetaStore @@ -4503,6 +4504,21 @@ async def run() -> int: store = await open_store(settings.store) try: count = await store.reencrypt_to_active() + # BACKLOG #1169: the uploaded-file store is the OTHER surface this cipher covers, and it + # had no rotation of any kind — a rotation re-sealed every database cell and left every + # uploaded file under the retired key, so the operator's next step (dropping that key) + # silently destroyed them. Re-seal it in the SAME command, on the store's own live cipher + # instance so the AES-GCM invocation bound (ASVS 11.3.4) charges to the new key exactly + # as the store's own pass does. A second cipher over the same DEK would charge nothing. + uploads = ( + await UploadStore( + settings.store.uploads_dir, + store.cipher(), + max_bytes=settings.store.max_upload_bytes, + ).reseal_to_active() + if settings.store.uploads_dir + else ResealResult() + ) # ASVS 13.3.4: stamp the DEK rotation so the watcher's clock resets automatically (rotation # auto-detected). The store is open under the NEW active key, so its key-id is the new # fingerprint; preserve the tracked-since floor. NON-SECRET (key-id + dates only). @@ -4518,21 +4534,37 @@ async def run() -> int: tracked_since=prior.tracked_since if prior is not None else today, last_rotated=today, ) - return count + return count, uploads finally: await store.close() try: - count = asyncio.run(run()) + count, uploads = asyncio.run(run()) except CipherError as exc: - # A value couldn't be decrypted by any supplied key — the prior key is missing. Nothing was - # corrupted (a batch is all-or-nothing); supply the key and re-run. + # A value couldn't be decrypted by any supplied key — the prior key is missing. Nothing is + # corrupted: every pass is all-or-nothing per batch AND idempotent, so re-running with the + # key supplied finishes the job. Note the command now spans TWO surfaces (the store, then + # the uploaded-file store), so a failure in the second leaves the FIRST already committed + # and the ASVS 13.3.4 rotation stamp unwritten. That is safe precisely because both passes + # skip what is already under the active key — it is a resumable rotation, not a rollback. print(f"error: rotation aborted — {exc}", file=sys.stderr) return 1 except NotImplementedError as exc: print(f"error: {exc}", file=sys.stderr) return 2 - print(f"OK: re-encrypted {count} value(s) under the active key") + print( + f"OK: re-encrypted {count} value(s) under the active key" + f" (+{uploads.resealed} uploaded-file value(s) re-sealed)" + ) + if uploads.skipped: + # Say it plainly and on stderr: a skipped file is STILL under the old key, so retiring that + # key now destroys it. This is the one outcome where "OK" alone would mislead. + print( + f"warning: {uploads.skipped} uploaded-file value(s) could not be read and were NOT " + "re-sealed — they are still under the prior key. Fix the cause and re-run rotate-key " + "BEFORE removing MEFOR_STORE_ENCRYPTION_KEYS_RETIRED.", + file=sys.stderr, + ) return 0 diff --git a/messagefoundry/store/postgres.py b/messagefoundry/store/postgres.py index 6c670fbe0..eaea06c6e 100644 --- a/messagefoundry/store/postgres.py +++ b/messagefoundry/store/postgres.py @@ -163,6 +163,12 @@ _FIFO_HEADS_LANE_CHUNK = 500 # ADR 0066 §3.1: release_claimed id-chunk bound (ids per UPDATE statement). _RELEASE_CHUNK = 500 +# BACKLOG #1169: rows per batch for the `attachment_chunk` at-rest migration. Every OTHER cipher pass +# batches 500 because its rows are kilobyte-shaped; one attachment_chunk row is a whole +# DETACH_CHUNK_BYTES (1 MiB) slice, ~1.33 MiB once base64+GCM sealed. 16 keeps a batch near 21 MiB, +# which is the memory budget the 500-row passes actually spend; 500 here would be ~650 MiB held in a +# single transaction while the store is still opening. +_ATTACHMENT_CHUNK_BATCH = 16 # Advisory-lock keys passed to the TWO-key pg_advisory_xact_lock(classid, hashtext($key)). They # serialize the audit-chain append (H-7) and schema init across concurrent opens; the finalize lock is @@ -1745,6 +1751,21 @@ async def _encrypt_existing_rows(self) -> None: encrypt=True, value_col=col, ) + # The `attachment_chunk` table (#149, ADR 0105) is cipher-covered (`ciphertext`) with the + # composite PK (attachment_id, seq), so it can't ride the id-keyed loop either. Its ROTATION + # pass already existed; this ON-OPEN pass did not, so a keyless→keyed transition left legacy + # plaintext chunks unsealed on this backend while SQLite sealed them (BACKLOG #1169). + # A SMALL batch, unlike every sibling above: each row is one DETACH_CHUNK_BYTES (1 MiB) slice + # rather than a kilobyte-shaped value, so 500 would hold ~650 MiB resident in one transaction + # while the store is still opening. + total += await self._encrypt_existing_composite( + "attachment_chunk", + ("attachment_id", "seq"), + like, + encrypt=True, + value_col="ciphertext", + limit=_ATTACHMENT_CHUNK_BATCH, + ) # BIGSERIAL-id tables bind to insert-time-known natural columns (id_keyed=True; see # _CIPHER_COLUMNS) — their own composite migration passes (ASVS 11.3.3). total += await self._encrypt_existing_composite( @@ -1783,6 +1804,7 @@ async def _encrypt_existing_composite( encrypt: bool, value_col: str = "value", id_keyed: bool = False, + limit: int = 500, ) -> int: """Encrypt the ``value_col`` of a non-id-keyed table in place — the migration loop for tables that can't ride the id-keyed loop. Each value binds to ``cell_aad(table, value_col, *aad_cols)`` (ASVS @@ -1791,7 +1813,11 @@ async def _encrypt_existing_composite( ``response`` passes ``body``/``detail``. ``aad_cols`` are the composite PK for state/reference/ response; for the BIGSERIAL-id tables (``message_events``/``connection_event``/``alert_instance``) set ``id_keyed=True`` — the AAD then comes from ``aad_cols`` (insert-time-known natural columns) - while the UPDATE targets ``id`` (so a natural-column collision can never re-write the wrong row).""" + while the UPDATE targets ``id`` (so a natural-column collision can never re-write the wrong row). + + ``limit`` is the rows held in memory per batch. 500 suits the KILOBYTE-shaped columns this + started with (state/reference/response); ``attachment_chunk`` holds one 1 MiB slice per row, + where 500 would be ~650 MiB resident inside a single transaction at store open.""" rotated = 0 select_cols = ("id", *aad_cols) if id_keyed else aad_cols pk_select = ", ".join(select_cols) @@ -1801,7 +1827,7 @@ async def _encrypt_existing_composite( while True: rows = await self._fetchall( f"SELECT {pk_select}, {value_col} AS v FROM {table}" - f" WHERE {value_col} NOT LIKE $1 AND {value_col} <> '' LIMIT 500", + f" WHERE {value_col} NOT LIKE $1 AND {value_col} <> '' LIMIT {int(limit)}", like, ) if not rows: diff --git a/messagefoundry/store/sqlserver.py b/messagefoundry/store/sqlserver.py index c6bf45ea9..8a1cfff72 100644 --- a/messagefoundry/store/sqlserver.py +++ b/messagefoundry/store/sqlserver.py @@ -152,6 +152,12 @@ _FIFO_HEADS_LANE_CHUNK = 500 # ADR 0066 §3.1: release_claimed id-chunk bound (ids per UPDATE statement). _RELEASE_CHUNK = 500 +# BACKLOG #1169: rows per batch for the `attachment_chunk` at-rest migration. Every OTHER cipher pass +# batches 500 because its rows are kilobyte-shaped; one attachment_chunk row is a whole +# DETACH_CHUNK_BYTES (1 MiB) slice, ~1.33 MiB once base64+GCM sealed. 16 keeps a batch near 21 MiB, +# which is the memory budget the 500-row passes actually spend; 500 here would be ~650 MiB held in a +# single transaction while the store is still opening. +_ATTACHMENT_CHUNK_BATCH = 16 # ADR 0073: ownership-scoped reset lane-chunk bound (lane names per UPDATE's IN list) — well under # pyodbc's ~2,100-parameter bound with the fixed parameters; chunks run inside the reset's single # transaction, so the all-or-nothing recovery pass is unchanged. @@ -2805,9 +2811,53 @@ async def _encrypt_existing_rows(self) -> None: raise await self._charge_bound_batch() total += len(rows) + # `attachment_chunk` detached-document slices (#149, ADR 0105, composite PK + # (attachment_id, seq)) — a separate pass (can't ride the id-keyed loop). Its ROTATION pass + # already existed here; this ON-OPEN pass did not, so a no-key -> key transition left legacy + # plaintext chunks unsealed on SQL Server and Postgres while SQLite sealed them + # (BACKLOG #1169). `ciphertext` is NOT NULL, so the `<> ''` guard alone keeps a blank from + # becoming ciphertext-of-empty. A SMALL batch, unlike every sibling above: each row is one + # DETACH_CHUNK_BYTES (1 MiB) slice, so the usual 500 would hold ~650 MiB resident in one + # transaction while the store is still opening. It is still a whole SLICE at a time, not a + # whole document — a large attachment spans many rows and is never reassembled here. + while True: + rows = await self._fetchall( + f"SELECT TOP ({_ATTACHMENT_CHUNK_BATCH}) attachment_id, seq, ciphertext" + " FROM attachment_chunk WHERE ciphertext NOT LIKE ? AND ciphertext <> ''", + (like,), + ) + if not rows: + break + async with self._acquire() as conn, self._cursor(conn) as cur: + try: + for r in rows: + await cur.execute( + "UPDATE attachment_chunk SET ciphertext=?" + " WHERE attachment_id=? AND seq=?", + ( + self._cipher.encrypt( + r["ciphertext"], + aad=cell_aad( + "attachment_chunk", + "ciphertext", + r["attachment_id"], + r["seq"], + ), + ), + r["attachment_id"], + r["seq"], + ), + ) + await self._commit(conn) + except Exception: + await conn.rollback() + raise + await self._charge_bound_batch() + total += len(rows) if total: log.info( - "encrypted %d existing message/outbox/response/reference/state row(s) at rest", + "encrypted %d existing message/outbox/response/reference/state/attachment row(s) " + "at rest", total, ) diff --git a/messagefoundry/uploads.py b/messagefoundry/uploads.py index d79f2d188..71c35a182 100644 --- a/messagefoundry/uploads.py +++ b/messagefoundry/uploads.py @@ -35,7 +35,7 @@ import re import secrets import time -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Iterator from dataclasses import asdict, dataclass from pathlib import Path from typing import Protocol @@ -44,7 +44,7 @@ from messagefoundry.parsing.sniff import _looks_like_hl7, _lstrip_bom_ws from messagefoundry.parsing.split import split_batch from messagefoundry.store.content_search import SearchSpec, row_matches -from messagefoundry.store.crypto import Cipher, cell_aad +from messagefoundry.store.crypto import AesGcmCipher, Cipher, CipherError, cell_aad _log = logging.getLogger(__name__) @@ -299,6 +299,19 @@ def browse_messages( ) +@dataclass(frozen=True) +class ResealResult: + """What one :meth:`UploadStore.reseal_to_active` pass did. + + ``skipped`` is load-bearing, not decoration: an operator reads it to decide whether it is safe to + drop the retired key. A file this pass could not read is a file still sealed under the OLD key, + and dropping that key makes it permanently unreadable — so a non-zero ``skipped`` means "run it + again before you retire anything".""" + + resealed: int = 0 + skipped: int = 0 + + class UploadQuotaLedger(Protocol): """The ONE thing :class:`UploadStore` needs from the message store: an atomic, cross-process reservation of an uploader's in-flight upload budget (ASVS 2.3.4). @@ -437,25 +450,62 @@ def _decrypt_meta(self, stored: str, file_id: str) -> UploadedFileMeta: message_count=int(d.get("message_count", 0)), ) - def _scan_metas_sync(self) -> list[UploadedFileMeta]: - """Walk the uploads root and decrypt every well-formed ``.meta`` sidecar (UNSORTED). A - bad/foreign/undecryptable sidecar is skipped with a warning (never a body in the log), so a - rotated-away key can neither sink the listing nor silently drop a quota/retention pass. Pure - filesystem read — the caller runs it off the event loop.""" + def _iter_sidecars(self) -> Iterator[tuple[str, Path]]: + """Every ``(file_id, sidecar path)`` pair in the uploads root — the ONE definition of "a file + of ours". Both the listing scan and the re-seal pass consume it, so the id shape and the + sidecar suffix cannot drift apart between them. Anything else in the directory (an operator's + stray file, a temp file, an id that fails the strict 32-hex shape) is not ours and is skipped + without being opened.""" root = self._root if not root.is_dir(): - return [] - out: list[UploadedFileMeta] = [] + return for entry in root.iterdir(): if not entry.name.endswith(_META_SUFFIX): continue fid = entry.name[: -len(_META_SUFFIX)] - if not _FILE_ID_RE.match(fid): - continue + if _FILE_ID_RE.match(fid): + yield fid, entry + + def _scan_metas_sync(self) -> list[UploadedFileMeta]: + """Walk the uploads root and decrypt every well-formed ``.meta`` sidecar (UNSORTED). A + bad/foreign/undecryptable sidecar is skipped **with its cause named** (never a body in the + log), so a rotated-away key can neither sink the listing nor silently drop a quota/retention + pass. Pure filesystem read — the caller runs it off the event loop. + + **Each failure class is caught on its own and logged distinctly (BACKLOG #1169, CLAUDE.md + §6).** One blanket ``except Exception`` used to fold all three into a single + "skipping unreadable sidecar" line, which made three unrelated conditions indistinguishable + in the log: a key that was rotated away, a sidecar whose bytes are damaged, and the cipher + REFUSING a value. The third is the one that matters — it is the class a strict-ciphertext + read would raise in, on exactly the surface where planting a file is easiest (a pair of + plain files in a directory, no database write needed). A refusal folded into the same line + as a routine post-rotation skip is a refusal nobody can see, so the strict read cannot + honestly be built on top of this handler until the classes are separated. Separating them + does not itself refuse anything: an unmarked sidecar is still accepted today by the cipher's + read passthrough (``store/crypto.py`` ``decrypt``), which awaits an owner ruling. + + The cipher's own message is safe to log — every ``CipherError`` carries only key ids, + marker versions and algorithm names, never a decrypted value. The malformed-shape branch + logs only the exception TYPE, because a ``ValueError`` from coercing a metadata field can + echo that field's value back into the message.""" + out: list[UploadedFileMeta] = [] + for fid, entry in self._iter_sidecars(): try: out.append(self._decrypt_meta(entry.read_text(encoding="utf-8"), fid)) - except Exception: # noqa: BLE001 — a bad/foreign sidecar must not sink the scan - _log.warning("skipping unreadable uploaded-file sidecar %s", fid) + except CipherError as exc: + # The cipher declined the value. Today that is a wrong/rotated-away key or a blob + # relocated into another cell; once a strict-ciphertext read ships it is ALSO the + # refusal of an unmarked (planted or downgraded) sidecar. Named on its own so the + # two can be told apart in a log. + _log.warning("uploaded-file sidecar %s: cipher declined it: %s", fid, exc) + except (OSError, UnicodeDecodeError) as exc: + # The bytes never reached the cipher — unreadable file, or not valid UTF-8. + _log.warning("uploaded-file sidecar %s: unreadable on disk: %s", fid, exc) + except (ValueError, TypeError, KeyError) as exc: + # Decrypted, but the JSON is malformed or a field will not coerce. Type only. + _log.warning( + "uploaded-file sidecar %s: malformed metadata (%s)", fid, type(exc).__name__ + ) return out # --- public API (all disk/crypto/split work off the event loop) -------------------------------- @@ -704,6 +754,108 @@ def _delete() -> UploadedFileMeta: return await asyncio.to_thread(_delete) + async def reseal_to_active(self) -> ResealResult: + """Re-seal every uploaded ``(blob, meta)`` pair under the **active** key — the uploads half of + ``messagefoundry rotate-key`` (BACKLOG #1169, ASVS 11.2.2/11.3.3). + + The store's :meth:`~messagefoundry.store.base.Store.reencrypt_to_active` has covered its + cipher columns since WP-5; this surface had **no pass of any kind**, so an uploaded file was + left behind by both transitions the store handles. A first key-enable left it plaintext on + disk forever, and a key rotation left it under the retired key — which is a data-loss defect + on its own, because the operator's next step is to drop that key and every upload written + before the rotation then stops decrypting. + + **The two transitions are not closed the same way, and the difference is deliberate.** The + store seals legacy plaintext AUTOMATICALLY, in ``_encrypt_existing_rows`` at every keyed open. + This pass has one caller, ``rotate-key``, so it closes the rotation half automatically and + the first-key-enable half only when an operator runs that command. Wiring a whole-directory + crypto sweep into API startup is unbounded boot-time work over a directory with no batching + seam, which is a different risk and a separate decision — so it is named here rather than + quietly assumed. + + Same contract as the store's pass: rewrites values that are plaintext **or** under a retired + key, skips values already under the active key (so it is idempotent), and lets a + :class:`CipherError` propagate rather than dropping data — a value no configured key opens + means the prior key was not supplied, and the CLI says so. Also mirrors its **limitation**: + a non-``AesGcmCipher`` (the identity cipher, or a ``vault_transit`` cipher whose DEK never + enters the heap) rotates nothing and returns zeros, exactly as ``reencrypt_to_active`` does. + + Re-sealing happens at the **cipher** layer — ``encrypt(decrypt(stored))`` over the stored + string — so a body's base64 envelope is never decoded and the plaintext bytes are never + reassembled here. Files are processed one at a time and each is rewritten through the same + atomic temp-and-replace the write path uses. Peak memory is a SMALL MULTIPLE of ``max_bytes``, + not ``max_bytes`` itself: base64 and the AES-GCM round trip each hold their own copy, so a + 25 MiB upload transiently costs a few hundred MiB. A file already under the active key is + detected from its first bytes and never read whole. + + **This pass LAUNDERS an unmarked value, and that is not an oversight to overlook.** Handed a + plaintext file, the cipher's read passthrough returns it unchanged and this method then seals + it into a genuine, AAD-bound ciphertext — after which nothing distinguishes it from a file + the engine wrote itself. The store's rotation has the identical property. That is precisely + why #1169 wants a strict-ciphertext read, and this method does not substitute for one: it is + a **precondition** for it. A refusal built before this pass existed would fire on legitimate + pre-rotation uploads, because until now nothing could ever seal them. The refusal itself + awaits an owner ruling and is deliberately not built here. + + Runs entirely off the event loop.""" + return await asyncio.to_thread(self._reseal_to_active_sync) + + def _reseal_to_active_sync(self) -> ResealResult: + """The blocking half of :meth:`reseal_to_active` (disk + cipher). Caller runs it off-loop.""" + cipher = self._cipher + if not isinstance(cipher, AesGcmCipher): + return ResealResult() # identity / transit cipher — nothing this process can rotate + root = self._root + if not root.is_dir(): + return ResealResult() + active = cipher.active_marker_prefix + resealed = skipped = 0 + for fid, _sidecar in self._iter_sidecars(): + try: + blob_path, meta_path = self._paths(fid) + except UploadPathError: + # Unreachable for an id that passed _FILE_ID_RE — the guard re-checks the same shape. + # Kept anyway, because `prune_expired` guards its unlink the same way: a path guard + # this pass skipped would be the one place a bad id reaches the filesystem. + skipped += 1 + _log.warning("uploaded file %s: skipped, its id fails the path guard", fid) + continue + # The sidecar carries the AAD kind "meta"; the body carries "body" (see _encrypt_meta / + # _encrypt_blob). Re-binding the SAME cell AAD is what keeps a re-sealed value readable. + for path, kind in ((meta_path, "meta"), (blob_path, "body")): + try: + # Read the MARKER first, not the file. The idempotency test is a prefix compare, + # and a sealed 25 MiB upload is ~44 MiB on disk — reading it whole only to skip + # it made a re-run cost a full pass over every already-current file. The store's + # own passes push this filter into SQL (`WHERE col NOT LIKE ...`) for the same + # reason; on a filesystem the equivalent is a bounded read. + with path.open(encoding="utf-8") as handle: + if handle.read(len(active)) == active: + continue # already under the active key in the active format + stored = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError) as exc: + # A half-deleted pair or an unreadable file. Counted and named, never silent: + # this file is still under the OLD key and the operator must not retire it yet. + skipped += 1 + _log.warning("uploaded file %s (%s): skipped, unreadable: %s", fid, kind, exc) + continue + aad = cell_aad("uploaded_file", kind, fid) + # A CipherError here means a prior key was not supplied. It PROPAGATES, before any + # write, so the operator is told to supply it rather than losing the file. + _atomic_write_text(root, path, _reencrypt_value(cipher, stored, aad)) + # Drop the file-sized buffer before the next iteration allocates its own. Without + # this the previous file's plaintext stays reachable from the frame while the next + # one is read, roughly doubling peak memory for no reason. + del stored + resealed += 1 + if resealed or skipped: + _log.info( + "re-sealed %d uploaded-file value(s) under the active key (%d skipped)", + resealed, + skipped, + ) + return ResealResult(resealed=resealed, skipped=skipped) + async def prune_expired( self, *, now: float | None = None, retention_days: int | None = None ) -> list[UploadedFileMeta]: @@ -818,6 +970,19 @@ async def run_once(self, now: float | None = None) -> list[UploadedFileMeta]: return pruned +def _reencrypt_value(cipher: AesGcmCipher, stored: str, aad: bytes) -> str: + """Decrypt (keyring — any configured key) then re-encrypt under the active key, rebinding the + SAME cell AAD (ASVS 11.3.3). + + Deliberately named to match ``MessageStore._reencrypt_value`` and its Postgres and SQL Server + twins, which are the identical computation for the database half. They are four spellings of one + seam and folding them into ``store/crypto.py`` is worth doing — but those three are staticmethods + on store classes this LEAF module may not import (see the module docstring), so the shared name + is what keeps a grep for ``_reencrypt_value`` from missing this one. Pairing a decrypt and an + encrypt with different AADs is the mistake the single-expression form exists to prevent.""" + return cipher.encrypt(cipher.decrypt(stored, aad=aad), aad=aad) + + def _atomic_write_text(root: Path, path: Path, text: str) -> None: """Write ``text`` to ``path`` via a temp file + ``os.replace`` (atomic on the same dir), owner-only.""" tmp = root / f".{path.name}.{secrets.token_hex(4)}.tmp" diff --git a/tests/test_cli.py b/tests/test_cli.py index 7f7770d96..9e202d3f3 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -260,6 +260,97 @@ async def read_with_b_only() -> int: assert asyncio.run(read_with_b_only()) == 1 # readable under the new key alone +def _seed_store_and_upload(db: Path, uploads: Path, key: str) -> str: + """Write one queued message and one uploaded file under ``key``; return the upload's file id. + + The upload store is built on the STORE's own cipher instance, which is what `api/app.py` does + and what the ASVS 11.3.4 invocation bound requires — a second cipher over the same DEK charges + nothing to the key's persisted count. + """ + import asyncio + + from messagefoundry.store.crypto import make_cipher + from messagefoundry.store.store import MessageStore + from messagefoundry.uploads import UploadStore + + async def run() -> str: + store = await MessageStore.open(db, cipher=make_cipher(key)) + try: + await store.enqueue_message(channel_id="ch", raw=ADT_A01, deliveries=[("d", ADT_A01)]) + meta = await UploadStore(uploads, store.cipher(), max_bytes=1 << 20).save( + data=ADT_A01.encode(), filename="partner.hl7", uploader="op", uploader_id="u-op" + ) + return meta.file_id + finally: + await store.close() + + return asyncio.run(run()) + + +def _rotate_env( + monkeypatch: pytest.MonkeyPatch, *, active: str, retired: str, uploads: Path +) -> None: + monkeypatch.setenv("MEFOR_STORE_ENCRYPTION_KEY", active) + monkeypatch.setenv("MEFOR_STORE_ENCRYPTION_KEYS_RETIRED", retired) + monkeypatch.setenv("MEFOR_STORE_UPLOADS_DIR", str(uploads)) + + +def test_rotate_key_also_reseals_the_uploaded_file_store( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + """BACKLOG #1169: uploaded files ride the SAME cipher, and rotation used to skip them entirely. + + The store's columns were re-encrypted and every uploaded file was left under the retired key — + so the operator's documented next step, dropping that key, silently destroyed them. Rotate, then + read back with the new key ALONE, which is the assertion the store's own rotation test makes. + """ + import asyncio + + from messagefoundry.store.crypto import generate_key, make_cipher + from messagefoundry.store.store import MessageStore + from messagefoundry.uploads import UploadStore + + monkeypatch.chdir(tmp_path) + db, uploads = tmp_path / "rot-uploads.db", tmp_path / "uploads" + key_a, key_b = generate_key(), generate_key() + file_id = _seed_store_and_upload(db, uploads, key_a) + + _rotate_env(monkeypatch, active=key_b, retired=key_a, uploads=uploads) + assert main(["rotate-key", "--db", str(db)]) == 0 + captured = capsys.readouterr() + assert "uploaded-file value(s) re-sealed" in captured.out + assert captured.err == "" # nothing was skipped, so no retire-the-key warning + + async def read_with_b_only() -> bytes: + store = await MessageStore.open(db, cipher=make_cipher(key_b)) # key A is gone + try: + return await UploadStore(uploads, store.cipher(), max_bytes=1 << 20).read_bytes(file_id) + finally: + await store.close() + + assert asyncio.run(read_with_b_only()).decode() == ADT_A01 + + +def test_rotate_key_warns_when_an_uploaded_file_could_not_be_resealed( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + """A skipped file is STILL under the prior key, so "OK" alone would mislead the operator into + dropping a key that is still load-bearing. The warning names that consequence.""" + from messagefoundry.store.crypto import generate_key + + monkeypatch.chdir(tmp_path) + db, uploads = tmp_path / "rot-skip.db", tmp_path / "uploads" + key_a, key_b = generate_key(), generate_key() + file_id = _seed_store_and_upload(db, uploads, key_a) + (uploads / f"{file_id}.blob").unlink() # a half-deleted pair + + _rotate_env(monkeypatch, active=key_b, retired=key_a, uploads=uploads) + assert main(["rotate-key", "--db", str(db)]) == 0 + err = capsys.readouterr().err + assert "could not be read and were NOT re-sealed" in err + assert "MEFOR_STORE_ENCRYPTION_KEYS_RETIRED" in err + + def test_rotate_key_requires_a_key( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: diff --git a/tests/test_store_cipher_sweep_parity.py b/tests/test_store_cipher_sweep_parity.py new file mode 100644 index 000000000..b95018147 --- /dev/null +++ b/tests/test_store_cipher_sweep_parity.py @@ -0,0 +1,324 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 MessageFoundry Organization and contributors +"""Every cipher-covered cell a backend WRITES must be named by BOTH of that backend's sweeps. + +**The gap this closes, and why the existing guard could not see it.** +``tests/test_sqlserver_encrypt_pass_tables.py`` checks the sweeps in the *other* direction: a table a +sweep names must be a table the module creates. That catches a sweep pointing at a dropped table. It +cannot catch the opposite and more likely drift — a covered table that **no sweep names at all** — +because an omission has no literal to inspect. That is exactly what shipped: ``attachment_chunk`` +(#149, ADR 0105) was added to all three backends with a rotation pass but, on Postgres and SQL +Server, no ON-OPEN migration pass. SQLite had one. Nothing anywhere compared them. + +**Why both sweeps and not just one.** They handle the two different transitions and are not +interchangeable: ``_encrypt_existing_rows`` runs at every keyed open and seals legacy plaintext; +``reencrypt_to_active`` runs offline under ``rotate-key`` and moves values onto the active key. A +cell covered by only the second stays plaintext at rest until someone rotates a key. + +**Scoped to tables the module INSERTs into, which is the part that keeps this honest.** A naive +"every ``cell_aad`` cell must be swept" rule falsely accuses both server backends over +``shared_body``: they declare the table for schema parity but never write a row into it, so there is +nothing for a migration to seal. ``INSERT INTO`` is the instrument that separates a real omission +from a table a backend merely declares — and it is the same instrument that corrected #1169's +originally-reported ``shared_body`` precondition. That fact is pinned in +``tests/test_phi_at_rest_inventory.py`` and is deliberately not restated here. + +BACKLOG #1169, ASVS 11.3.3. Reads engine source; needs no database, driver or key, so it runs on the +plain leg — the SQL Server and Postgres CI legs are all KEYLESS and return before either sweep body. +""" + +from __future__ import annotations + +import ast +import functools +import pathlib +import re + +import pytest + +import messagefoundry + +_STORE_DIR = pathlib.Path(messagefoundry.__file__).resolve().parent / "store" +_BACKENDS = ("store.py", "postgres.py", "sqlserver.py") + +#: The two passes. Both are reached only on a KEYED handle, which is why CI never executes either. +_SWEEPS = ("_encrypt_existing_rows", "reencrypt_to_active") + +_INSERT_INTO = re.compile(r"INSERT INTO\s+(\w+)", re.IGNORECASE) + + +def _covered_cells(tree: ast.AST) -> set[tuple[str, str]]: + """Every ``cell_aad("