diff --git a/README.md b/README.md index 104c1fc..eda1cf9 100644 --- a/README.md +++ b/README.md @@ -199,7 +199,11 @@ 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. Other private stores are not encrypted at the application layer. Web +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 +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 single-device enrollment without retaining capability URLs. Existing plaintext subscriptions @@ -271,12 +275,12 @@ 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 state, and the Security activity journal. Keep this key independent +# planning and Saved Search state, 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 -# returning content. Legacy Today/Later and Security activity rows migrate -# atomically on first use without changing logical revisions or journal IDs. +# returning content. Legacy Today/Later, Saved Search, and Security activity rows +# migrate atomically on first use without changing logical revisions or journal IDs. export STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEY='' # Required for cross-device unfiled Draft sync. The single-key setting remains # supported for the first deployment of keyring-capable code and writes v1 envelopes. diff --git a/src/saved_search_store.py b/src/saved_search_store.py index 4a1e0d4..6603576 100644 --- a/src/saved_search_store.py +++ b/src/saved_search_store.py @@ -1,11 +1,11 @@ """Durable, account-scoped saved Search views.""" -import json import re import sqlite3 from pathlib import Path from src.private_state import connect_private_sqlite +from src.state_encryption import PrivateStateCipher, PrivateStateEncryptionError, private_state_encryption_key _REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") @@ -21,10 +21,21 @@ class SavedSearchConflict(ValueError): class SavedSearchStore: - def __init__(self, path: str | Path, *, limit: int = 20, timeout: float = 1.0): + def __init__( + self, + path: str | Path, + *, + limit: int = 20, + 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_key(), + store="saved-searches", + ) self._initialize() def _initialize(self) -> None: @@ -50,19 +61,31 @@ class SavedSearchStore: raise ValueError("login is required") return normalized - @staticmethod - def _snapshot(row) -> dict: - return {"revision": 0, "views": []} if row is None else { - "revision": int(row[0]), "views": json.loads(row[1]) - } + def _snapshot(self, row, login: str) -> tuple[dict, bool]: + if row is None: + return {"revision": 0, "views": []}, False + views, legacy = self._cipher.open(row[1], binding=f"views:{login}") + if not isinstance(views, list): + raise PrivateStateEncryptionError("private state could not be decrypted") + return {"revision": int(row[0]), "views": views}, legacy + + def _sealed_views(self, login: str, views: list[dict]) -> str: + return self._cipher.seal(views, binding=f"views:{login}") def get(self, login: str) -> dict: + login = self._login(login) with self._connect() as connection: row = connection.execute( "SELECT revision, views FROM saved_searches WHERE login = ?", - (self._login(login),), + (login,), ).fetchone() - return self._snapshot(row) + snapshot, legacy = self._snapshot(row, login) + if row is not None and legacy: + connection.execute( + "UPDATE saved_searches SET views = ? WHERE login = ? AND views = ?", + (self._sealed_views(login, snapshot["views"]), login, row[1]), + ) + return snapshot def _normalize(self, views: list[dict]) -> list[dict]: if not isinstance(views, list): @@ -113,13 +136,13 @@ class SavedSearchStore: if not isinstance(expected_revision, int) or isinstance(expected_revision, bool) or expected_revision < 0: raise ValueError("revision is invalid") normalized = self._normalize(views) - serialized = json.dumps(normalized, separators=(",", ":")) + serialized = self._sealed_views(login, normalized) with self._connect() as connection: connection.execute("BEGIN IMMEDIATE") row = connection.execute( "SELECT revision, views FROM saved_searches WHERE login = ?", (login,) ).fetchone() - current = self._snapshot(row) + current, _legacy = self._snapshot(row, login) if current["revision"] != expected_revision: raise SavedSearchConflict(current) revision = expected_revision + 1 diff --git a/tests/test_saved_search_store.py b/tests/test_saved_search_store.py index e0c037a..3a03a04 100644 --- a/tests/test_saved_search_store.py +++ b/tests/test_saved_search_store.py @@ -1,8 +1,12 @@ +import json +import sqlite3 + import httpx import pytest from src import main from src.saved_search_store import SavedSearchConflict, SavedSearchStore +from src.state_encryption import PrivateStateEncryptionError def view(view_id="release", name="Release queue", query="mobile", **scope): @@ -16,6 +20,105 @@ def view(view_id="release", name="Release queue", query="mobile", **scope): } +def test_saved_searches_encrypt_private_views_and_survive_restart(tmp_path): + database = tmp_path / "saved-searches.sqlite3" + key = b"s" * 32 + canary = "private-launch-query-canary" + store = SavedSearchStore(database, encryption_key=key) + + created = store.replace( + "Timmy", + 0, + [view(name="Private launch", query=canary, repository="private/launch")], + ) + + with sqlite3.connect(database) as connection: + payload = connection.execute( + "SELECT views FROM saved_searches WHERE login = 'timmy'" + ).fetchone()[0] + assert payload.startswith("v1:") + assert "Private launch" not in payload + assert canary not in payload + assert "private/launch" not in payload + assert SavedSearchStore(database, encryption_key=key).get("timmy") == created + + +def test_saved_searches_lazily_migrate_plaintext_without_changing_revision(tmp_path): + database = tmp_path / "saved-searches.sqlite3" + key = b"s" * 32 + legacy_views = [view(name="Legacy private view", query="legacy-canary")] + SavedSearchStore(database, encryption_key=key) + with sqlite3.connect(database) as connection: + connection.execute( + "INSERT INTO saved_searches(login, revision, views) VALUES (?, ?, ?)", + ("timmy", 7, json.dumps(legacy_views)), + ) + + snapshot = SavedSearchStore(database, encryption_key=key).get("Timmy") + + with sqlite3.connect(database) as connection: + migrated = connection.execute( + "SELECT revision, views FROM saved_searches WHERE login = 'timmy'" + ).fetchone() + assert snapshot == {"revision": 7, "views": legacy_views} + assert migrated[0] == 7 + assert migrated[1].startswith("v1:") + assert "legacy-canary" not in migrated[1] + + +def test_saved_searches_fail_closed_with_wrong_key(tmp_path): + database = tmp_path / "saved-searches.sqlite3" + SavedSearchStore(database, encryption_key=b"s" * 32).replace( + "timmy", 0, [view(query="wrong-key-canary")] + ) + + with pytest.raises( + PrivateStateEncryptionError, match="private state could not be decrypted" + ): + SavedSearchStore(database, encryption_key=b"x" * 32).get("timmy") + + +def test_saved_searches_reject_tampered_or_malformed_payloads(tmp_path): + database = tmp_path / "saved-searches.sqlite3" + store = SavedSearchStore(database, encryption_key=b"s" * 32) + store.replace("timmy", 0, [view(query="tamper-canary")]) + with sqlite3.connect(database) as connection: + payload = connection.execute( + "SELECT views FROM saved_searches WHERE login = 'timmy'" + ).fetchone()[0] + + for invalid in (payload[:-1] + ("A" if payload[-1] != "A" else "B"), "not-json"): + with sqlite3.connect(database) as connection: + connection.execute( + "UPDATE saved_searches SET views = ? WHERE login = 'timmy'", + (invalid,), + ) + with pytest.raises( + PrivateStateEncryptionError, match="private state could not be decrypted" + ): + store.get("timmy") + + +def test_saved_searches_reject_ciphertext_substituted_between_accounts(tmp_path): + database = tmp_path / "saved-searches.sqlite3" + store = SavedSearchStore(database, encryption_key=b"s" * 32) + store.replace("timmy", 0, [view(query="timmy-private")]) + store.replace("alexander", 0, [view(query="alexander-private")]) + with sqlite3.connect(database) as connection: + timmy_payload = connection.execute( + "SELECT views FROM saved_searches WHERE login = 'timmy'" + ).fetchone()[0] + connection.execute( + "UPDATE saved_searches SET views = ? WHERE login = 'alexander'", + (timmy_payload,), + ) + + with pytest.raises( + PrivateStateEncryptionError, match="private state could not be decrypted" + ): + store.get("alexander") + + def test_saved_searches_are_revisioned_ordered_and_account_scoped(tmp_path): store = SavedSearchStore(tmp_path / "saved-searches.sqlite3")