Merge pull request 'Encrypt synchronized Saved Searches at rest' (#1127) from timmy/1126-encrypt-saved-searches into main
All checks were successful
CI / lint (push) Successful in 2m43s
CI / build-release (push) Successful in 5s
CI / browser-journey (push) Successful in 3m2s
CI / release-candidate (push) Successful in 6s

This commit is contained in:
rockachopa 2026-08-19 11:03:52 +00:00
commit 3b4f6b39a5
3 changed files with 145 additions and 15 deletions

View File

@ -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='<base64-encoded-32-byte-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.

View File

@ -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

View File

@ -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")