From 4b645d8460b0f23f8170aebed29bbbca54d1a220 Mon Sep 17 00:00:00 2001 From: timmy Date: Sat, 22 Aug 2026 06:09:06 +0000 Subject: [PATCH] feat: encrypt completed Filed review history (Closes #1252) --- README.md | 12 +- scripts/rotate_private_state.py | 13 ++ src/completed_filed_review_store.py | 143 ++++++++++++++------- tests/test_completed_filed_review_store.py | 62 +++++++++ tests/test_rotate_private_state.py | 30 +++++ 5 files changed, 205 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index 8f8e2fb..bf716a5 100644 --- a/README.md +++ b/README.md @@ -254,10 +254,12 @@ each envelope to its operation key and field purpose so rows and fields cannot b Existing plaintext snapshot and ledger rows migrate atomically on their first read without changing freshness, revisions, ordering, replay, or conflict semantics. Synchronized unfiled Draft collections use a separate AES-256-GCM key and authenticate the account and revision; existing plaintext rows -likewise migrate on first read. Synchronized Saved Search collections use the private-state key and -authenticate each envelope to its normalized account, preventing rows from being substituted between -operators. Existing plaintext Saved Searches migrate atomically on first read without advancing their -revision; missing, wrong, or modified key material returns no saved-view content. Other private stores +likewise migrate on first read. Synchronized Saved Search collections and completed Filed review +receipts use the private-state key and authenticate each envelope to its normalized account, preventing +rows from being substituted between operators. Existing plaintext Saved Searches migrate atomically on +first read without advancing their revision; existing completed Filed receipts migrate transactionally at +startup without changing order or acknowledgement state. Missing, wrong, or modified key material +returns no saved-view or receipt content. Other private stores are not encrypted at the application layer. Web Push subscriptions use a third, independent AES-256-GCM key authenticated to the device session, with keyed endpoint indexes preserving @@ -330,7 +332,7 @@ export STACKCHAIN_PASSKEY_MAX_CHALLENGES=10000 # Optional; defaults to STACKCHAIN_STATE_DIR/login-attempts.sqlite3. export STACKCHAIN_LOGIN_ATTEMPT_DB='/var/lib/stackchain-dashboard/login-attempts.sqlite3' # Required for worker-shared live/Find Work snapshots, synchronized Today/Later -# planning and Saved Search state, and the Security activity journal. Keep this key independent +# planning, Saved Search state, completed Filed review receipts, and the Security activity journal. Keep this key independent # from the Draft key and inject the # base64 encoding of exactly 32 random bytes from a secret manager. Never commit # it. Missing, malformed, wrong-key, or modified state fails closed without diff --git a/scripts/rotate_private_state.py b/scripts/rotate_private_state.py index 7709b93..8b1248d 100755 --- a/scripts/rotate_private_state.py +++ b/scripts/rotate_private_state.py @@ -78,6 +78,19 @@ STORES = ( Store("saved-searches", "saved-searches", "STACKCHAIN_SAVED_SEARCH_DB", "saved-searches.sqlite3", ( Table("saved_searches", ("login",), (Field("views", "views:{login}"),)), )), + Store( + "completed-filed-reviews", + "completed-filed-reviews", + "STACKCHAIN_COMPLETED_FILED_REVIEW_DB", + "completed-filed-reviews.sqlite3", + ( + Table( + "completed_filed_review_collections", + ("login",), + (Field("receipts", "receipts:{login}"),), + ), + ), + ), Store("security-events", "security-events", "STACKCHAIN_SECURITY_EVENT_DB", "security-events.sqlite3", ( Table("security_events", ("id",), (Field("payload", "event:{id}"),)), )), diff --git a/src/completed_filed_review_store.py b/src/completed_filed_review_store.py index 907ccdd..31de891 100644 --- a/src/completed_filed_review_store.py +++ b/src/completed_filed_review_store.py @@ -1,4 +1,4 @@ -"""Durable, account-scoped completed Filed review receipts.""" +"""Durable, encrypted, account-scoped completed Filed review receipts.""" import re import sqlite3 @@ -6,33 +6,68 @@ from datetime import datetime from pathlib import Path from src.private_state import connect_private_sqlite +from src.state_encryption import PrivateStateCipher, PrivateStateEncryptionError, private_state_encryption_config _REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") class CompletedFiledReviewStore: - def __init__(self, path: str | Path, *, limit: int = 200, timeout: float = 1.0): + def __init__( + self, + path: str | Path, + *, + limit: int = 200, + timeout: float = 1.0, + encryption_key: bytes | None = None, + ): self.path = Path(path) self.limit = limit self.timeout = timeout + self._cipher = PrivateStateCipher( + encryption_key if encryption_key is not None else private_state_encryption_config(), + store="completed-filed-reviews", + ) self._initialize() def _initialize(self) -> None: with self._connect() as connection: connection.execute("PRAGMA journal_mode=WAL") + connection.execute("PRAGMA secure_delete=ON") connection.execute( """ - CREATE TABLE IF NOT EXISTS completed_filed_reviews ( - login TEXT NOT NULL, - repository TEXT NOT NULL, - issue_number INTEGER NOT NULL, - updated_at TEXT NOT NULL, - touched_at INTEGER NOT NULL, - PRIMARY KEY (login, repository, issue_number) + CREATE TABLE IF NOT EXISTS completed_filed_review_collections ( + login TEXT PRIMARY KEY, + receipts TEXT NOT NULL ) """ ) + legacy = connection.execute( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'completed_filed_reviews'" + ).fetchone() + if legacy: + connection.execute("BEGIN IMMEDIATE") + rows = connection.execute( + "SELECT login, repository, issue_number, updated_at " + "FROM completed_filed_reviews ORDER BY login, touched_at" + ).fetchall() + grouped: dict[str, list[dict]] = {} + for login, repository, number, updated_at in rows: + grouped.setdefault(login, []).append({ + "repository": repository, + "number": int(number), + "updated_at": updated_at, + }) + for login, receipts in grouped.items(): + normalized = [self._receipt(item) for item in receipts][-self.limit:] + connection.execute( + "INSERT INTO completed_filed_review_collections(login, receipts) VALUES (?, ?) " + "ON CONFLICT(login) DO UPDATE SET receipts=excluded.receipts", + (login, self._seal(login, normalized)), + ) + connection.execute("DROP TABLE completed_filed_reviews") + with self._connect() as connection: + connection.execute("PRAGMA wal_checkpoint(TRUNCATE)") def _connect(self) -> sqlite3.Connection: return connect_private_sqlite(self.path, timeout=self.timeout) @@ -45,7 +80,7 @@ class CompletedFiledReviewStore: return normalized @staticmethod - def _receipt(raw: dict) -> tuple[str, int, str]: + def _receipt(raw: dict) -> dict: if not isinstance(raw, dict): raise ValueError("receipt must be an object") repository = raw.get("repository") @@ -63,23 +98,41 @@ class CompletedFiledReviewStore: raise ValueError("updated_at is invalid") from error if parsed.tzinfo is None: raise ValueError("updated_at is invalid") - return repository, number, updated_at + return {"repository": repository, "number": number, "updated_at": updated_at} + + def _open(self, login: str, payload: str | None) -> tuple[list[dict], bool]: + if payload is None: + return [], False + value, stale = self._cipher.open(payload, binding=f"receipts:{login}") + if not isinstance(value, list): + raise PrivateStateEncryptionError("private state could not be decrypted") + try: + return [self._receipt(item) for item in value], stale + except ValueError as error: + raise PrivateStateEncryptionError("private state could not be decrypted") from error + + def _seal(self, login: str, receipts: list[dict]) -> str: + return self._cipher.seal(receipts, binding=f"receipts:{login}") @staticmethod - def _snapshot(rows) -> dict: - return {"receipts": [ - {"repository": row[0], "number": int(row[1]), "updated_at": row[2]} - for row in rows - ]} + def _snapshot(receipts: list[dict]) -> dict: + return {"receipts": receipts} def get(self, login: str) -> dict: + login = self._login(login) with self._connect() as connection: - rows = connection.execute( - "SELECT repository, issue_number, updated_at FROM completed_filed_reviews " - "WHERE login = ? ORDER BY touched_at", - (self._login(login),), - ).fetchall() - return self._snapshot(rows) + row = connection.execute( + "SELECT receipts FROM completed_filed_review_collections WHERE login = ?", + (login,), + ).fetchone() + receipts, stale = self._open(login, row[0] if row else None) + if row is not None and stale: + connection.execute( + "UPDATE completed_filed_review_collections SET receipts = ? " + "WHERE login = ? AND receipts = ?", + (self._seal(login, receipts), login, row[0]), + ) + return self._snapshot(receipts) def merge(self, login: str, receipts: list[dict]) -> dict: login = self._login(login) @@ -88,35 +141,25 @@ class CompletedFiledReviewStore: normalized = [self._receipt(receipt) for receipt in receipts] with self._connect() as connection: connection.execute("BEGIN IMMEDIATE") - touched = int(connection.execute( - "SELECT COALESCE(MAX(touched_at), 0) FROM completed_filed_reviews WHERE login = ?", + row = connection.execute( + "SELECT receipts FROM completed_filed_review_collections WHERE login = ?", (login,), - ).fetchone()[0]) - for repository, number, updated_at in normalized: - current = connection.execute( - "SELECT updated_at FROM completed_filed_reviews " - "WHERE login = ? AND repository = ? AND issue_number = ?", - (login, repository, number), - ).fetchone() - if current is not None and current[0] >= updated_at: + ).fetchone() + current, _stale = self._open(login, row[0] if row else None) + for incoming in normalized: + match = next(( + item for item in current + if item["repository"] == incoming["repository"] and item["number"] == incoming["number"] + ), None) + if match is not None and match["updated_at"] >= incoming["updated_at"]: continue - touched += 1 - connection.execute( - "INSERT INTO completed_filed_reviews " - "(login, repository, issue_number, updated_at, touched_at) VALUES (?, ?, ?, ?, ?) " - "ON CONFLICT(login, repository, issue_number) DO UPDATE SET " - "updated_at=excluded.updated_at, touched_at=excluded.touched_at", - (login, repository, number, updated_at, touched), - ) + if match is not None: + current.remove(match) + current.append(incoming) + current = current[-self.limit:] connection.execute( - "DELETE FROM completed_filed_reviews WHERE login = ? AND rowid NOT IN (" - "SELECT rowid FROM completed_filed_reviews WHERE login = ? " - "ORDER BY touched_at DESC LIMIT ?)", - (login, login, self.limit), + "INSERT INTO completed_filed_review_collections(login, receipts) VALUES (?, ?) " + "ON CONFLICT(login) DO UPDATE SET receipts=excluded.receipts", + (login, self._seal(login, current)), ) - rows = connection.execute( - "SELECT repository, issue_number, updated_at FROM completed_filed_reviews " - "WHERE login = ? ORDER BY touched_at", - (login,), - ).fetchall() - return self._snapshot(rows) + return self._snapshot(current) diff --git a/tests/test_completed_filed_review_store.py b/tests/test_completed_filed_review_store.py index 111f656..117d39d 100644 --- a/tests/test_completed_filed_review_store.py +++ b/tests/test_completed_filed_review_store.py @@ -1,3 +1,5 @@ +import sqlite3 + import httpx import pytest @@ -21,6 +23,30 @@ def test_receipts_merge_without_lost_updates_and_remain_account_scoped(tmp_path) assert store.get("alexander") == {"receipts": []} +def test_receipt_history_is_encrypted_and_bound_to_its_account(tmp_path): + database = tmp_path / "completed-filed.sqlite3" + store = CompletedFiledReviewStore(database, encryption_key=b"f" * 32) + timmy = receipt("private-timmy/repository", 912) + alexander = receipt("private-alexander/repository", 427) + + store.merge("timmy-private-login", [timmy]) + store.merge("alexander-private-login", [alexander]) + + raw = database.read_bytes() + assert b"private-timmy/repository" not in raw + assert b"private-alexander/repository" not in raw + with sqlite3.connect(database) as connection: + rows = connection.execute( + "SELECT login, receipts FROM completed_filed_review_collections ORDER BY login" + ).fetchall() + connection.execute( + "UPDATE completed_filed_review_collections SET receipts = ? WHERE login = ?", + (rows[1][1], rows[0][0]), + ) + with pytest.raises(RuntimeError, match="private state could not be decrypted"): + store.get("alexander-private-login") + + def test_receipts_keep_newest_revision_and_bound_each_account(tmp_path): store = CompletedFiledReviewStore(tmp_path / "completed-filed.sqlite3", limit=2) newer = receipt(updated_at="2026-08-16T12:00:00Z") @@ -32,6 +58,42 @@ def test_receipts_keep_newest_revision_and_bound_each_account(tmp_path): assert bounded == {"receipts": [receipt("stackchain/web", 2), receipt("stackchain/docs", 3)]} +def test_legacy_plaintext_receipts_migrate_for_every_account_without_loss(tmp_path): + database = tmp_path / "completed-filed.sqlite3" + with sqlite3.connect(database) as connection: + connection.execute( + "CREATE TABLE completed_filed_reviews (" + "login TEXT NOT NULL, repository TEXT NOT NULL, issue_number INTEGER NOT NULL, " + "updated_at TEXT NOT NULL, touched_at INTEGER NOT NULL, " + "PRIMARY KEY (login, repository, issue_number))" + ) + connection.executemany( + "INSERT INTO completed_filed_reviews VALUES (?, ?, ?, ?, ?)", + [ + ("timmy", "legacy-private/first", 1, "2026-08-14T12:00:00Z", 1), + ("timmy", "legacy-private/second", 2, "2026-08-15T12:00:00Z", 2), + ("alexander", "legacy-private/other", 3, "2026-08-16T12:00:00Z", 1), + ], + ) + + store = CompletedFiledReviewStore(database, encryption_key=b"m" * 32) + + assert store.get("timmy") == {"receipts": [ + receipt("legacy-private/first", 1, "2026-08-14T12:00:00Z"), + receipt("legacy-private/second", 2, "2026-08-15T12:00:00Z"), + ]} + assert store.get("alexander") == {"receipts": [ + receipt("legacy-private/other", 3, "2026-08-16T12:00:00Z") + ]} + raw = database.read_bytes() + assert b"legacy-private/first" not in raw + assert b"legacy-private/other" not in raw + with sqlite3.connect(database) as connection: + assert connection.execute( + "SELECT count(*) FROM sqlite_master WHERE name = 'completed_filed_reviews'" + ).fetchone()[0] == 0 + + @pytest.mark.parametrize("candidate", [ {"repository": "bad", "number": 1, "updated_at": "2026-08-15T12:00:00Z"}, {"repository": "stackchain/api", "number": 0, "updated_at": "2026-08-15T12:00:00Z"}, diff --git a/tests/test_rotate_private_state.py b/tests/test_rotate_private_state.py index c00398a..5f05ec8 100644 --- a/tests/test_rotate_private_state.py +++ b/tests/test_rotate_private_state.py @@ -6,6 +6,7 @@ import subprocess import sys from pathlib import Path +from src.completed_filed_review_store import CompletedFiledReviewStore from src.saved_search_store import SavedSearchStore @@ -81,3 +82,32 @@ def test_rotation_command_fails_closed_and_never_prints_private_content(tmp_path assert secret not in completed.stdout assert "timmy" not in completed.stdout assert completed.stderr == "" + + +def test_rotation_command_rewraps_completed_filed_history_without_printing_it(tmp_path): + state = tmp_path / "state" + path = state / "completed-filed-reviews.sqlite3" + private_receipt = { + "repository": "private-rotation/canary", + "number": 1252, + "updated_at": "2026-08-22T12:00:00Z", + } + CompletedFiledReviewStore(path, encryption_key=b"o" * 32).merge("timmy", [private_receipt]) + + completed = run_rotation(state) + + assert completed.returncode == 0, completed.stderr + report = json.loads(completed.stdout) + assert report["completed-filed-reviews"] == { + "current": 0, "failed": 0, "migrated": 1, "total": 1 + } + assert "private-rotation" not in completed.stdout + assert "timmy" not in completed.stdout + with sqlite3.connect(path) as connection: + payload = connection.execute( + "SELECT receipts FROM completed_filed_review_collections WHERE login = 'timmy'" + ).fetchone()[0] + assert payload.startswith("v2:next:") + assert CompletedFiledReviewStore( + path, encryption_key=({"next": b"n" * 32}, "next") + ).get("timmy") == {"receipts": [private_receipt]} -- 2.43.0