From d3f53b5a38f88a73bcfe5c3bb4355044b0f0dee8 Mon Sep 17 00:00:00 2001 From: timmy Date: Tue, 18 Aug 2026 22:07:02 +0000 Subject: [PATCH 1/2] security: encrypt synchronized unfiled drafts (Closes #1098) --- README.md | 13 +++- requirements.txt | 1 + src/main.py | 16 +++-- src/unfiled_draft_store.py | 98 ++++++++++++++++++++++++--- tests/test_private_state.py | 2 +- tests/test_unfiled_draft_store.py | 106 ++++++++++++++++++++++++++++-- 6 files changed, 216 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 6638521..1473399 100644 --- a/README.md +++ b/README.md @@ -191,8 +191,10 @@ cannot alter a newer crash-recovery claim. Device purge cancels an active drain private outbox storage. Results are coordinated through a bounded SQLite ledger. All private SQLite stores enforce a filesystem boundary independently of the service umask: the database directory is repaired to owner-only `0700`, database and SQLite sidecar files are owner-only `0600`, and symlinked database -paths are rejected before access. This protects state from unrelated local accounts, but it is not -encryption at rest; secure host access, encrypted volumes, and private backups are still required. +paths are rejected before access. Synchronized unfiled Draft collections add AES-256-GCM payload +encryption with account and revision authentication; existing plaintext rows migrate on their first +read without changing revision or order. Other private stores are not encrypted at the application +layer, so secure host access, encrypted volumes, and private backups are still required. Set `STACKCHAIN_STATE_DIR` to a persistent, writable service directory (or set `STACKCHAIN_IDEMPOTENCY_DB` to an explicit SQLite path); the local default is `.stackchain-state/idempotency.sqlite3`. Ledger reads and @@ -258,6 +260,13 @@ export STACKCHAIN_PASSKEY_MAX_CHALLENGES_PER_SOURCE=10 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 cross-device unfiled Draft sync. Inject the base64 encoding of +# exactly 32 random bytes from a secret manager; never commit the value. +# Generate once, for example: openssl rand -base64 32 +export STACKCHAIN_UNFILED_DRAFT_ENCRYPTION_KEY='' +# Keep this key stable and back it up separately from the database. There is no +# online key rotation: replacing or losing it makes existing encrypted Drafts +# unavailable. Restore the prior key to recover them before planning a rotation. # Trust forwarding headers only from these immediate reverse-proxy networks. export STACKCHAIN_TRUSTED_PROXY_CIDRS='127.0.0.0/8' # Optional Web Push. Generate a VAPID key pair outside the repo and inject it. diff --git a/requirements.txt b/requirements.txt index cd3011c..3a8f0ed 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ fastapi==0.133.1 +cryptography==50.0.0 httpx==0.28.1 pydantic==2.13.4 Pillow==12.3.0 diff --git a/src/main.py b/src/main.py index 1eeba24..ad97d6b 100644 --- a/src/main.py +++ b/src/main.py @@ -60,7 +60,12 @@ from src.push_endpoint_policy import UnsafePushEndpoint, validate_public_push_en from src.push_subscription_store import PushSubscriptionStore from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit from src.saved_search_store import SavedSearchConflict, SavedSearchStore -from src.unfiled_draft_store import UnfiledDraftConflict, UnfiledDraftStore +from src.unfiled_draft_store import ( + UnfiledDraftConflict, + UnfiledDraftEncryptionError, + UnfiledDraftStore, + decode_unfiled_draft_encryption_key, +) from src.security_event_store import SecurityEventStore, SecurityEventStoreError from src.suggestion_engine import compute from src.later_store import LaterStore @@ -2285,7 +2290,10 @@ def _completed_filed_review_store() -> CompletedFiledReviewStore: def _unfiled_draft_store() -> UnfiledDraftStore: return UnfiledDraftStore( - os.getenv("STACKCHAIN_UNFILED_DRAFT_DB", str(_state_dir / "unfiled-drafts.sqlite3")) + os.getenv("STACKCHAIN_UNFILED_DRAFT_DB", str(_state_dir / "unfiled-drafts.sqlite3")), + encryption_key=decode_unfiled_draft_encryption_key( + os.getenv("STACKCHAIN_UNFILED_DRAFT_ENCRYPTION_KEY", "") + ), ) @@ -2386,7 +2394,7 @@ async def get_unfiled_drafts(response: Response): login = await _confirmed_login() try: snapshot = await asyncio.to_thread(_unfiled_draft_store().get, login) - except (OSError, sqlite3.Error): + except (OSError, sqlite3.Error, UnfiledDraftEncryptionError): raise HTTPException( status_code=503, detail="Draft synchronization is unavailable", @@ -2416,7 +2424,7 @@ async def replace_unfiled_drafts(payload: UnfiledDraftCollection): ) except ValueError as exc: raise HTTPException(status_code=422, detail=str(exc)) - except (OSError, sqlite3.Error): + except (OSError, sqlite3.Error, UnfiledDraftEncryptionError): raise HTTPException( status_code=503, detail="Draft synchronization is unavailable", diff --git a/src/unfiled_draft_store.py b/src/unfiled_draft_store.py index c8d385e..7df0557 100644 --- a/src/unfiled_draft_store.py +++ b/src/unfiled_draft_store.py @@ -3,11 +3,15 @@ import base64 import binascii import json +import os import re import sqlite3 from datetime import datetime from pathlib import Path +from cryptography.exceptions import InvalidTag +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + from src.private_state import connect_private_sqlite @@ -24,16 +28,41 @@ class UnfiledDraftConflict(ValueError): self.snapshot = snapshot +class UnfiledDraftEncryptionError(RuntimeError): + """Raised when private draft state cannot be authenticated and decrypted.""" + + +def decode_unfiled_draft_encryption_key(encoded: str) -> bytes: + """Decode the independently injected 256-bit draft-encryption key.""" + try: + key = base64.b64decode(encoded, validate=True) + except (binascii.Error, ValueError) as error: + raise UnfiledDraftEncryptionError( + "unfiled draft encryption key is invalid" + ) from error + if len(key) != 32: + raise UnfiledDraftEncryptionError( + "unfiled draft encryption key must decode to exactly 32 bytes" + ) + return key + + class UnfiledDraftStore: def __init__( self, path: str | Path, *, + encryption_key: bytes, limit: int = 20, max_total_bytes: int = 12 * 1024 * 1024, timeout: float = 1.0, ): self.path = Path(path) + if not isinstance(encryption_key, bytes) or len(encryption_key) != 32: + raise UnfiledDraftEncryptionError( + "unfiled draft encryption requires exactly 32 key bytes" + ) + self._cipher = AESGCM(encryption_key) self.limit = limit self.max_total_bytes = max_total_bytes self.timeout = timeout @@ -61,18 +90,71 @@ class UnfiledDraftStore: return normalized @staticmethod - def _snapshot(row) -> dict: - return {"revision": 0, "drafts": []} if row is None else { - "revision": int(row[0]), "drafts": json.loads(row[1]) - } + def _aad(login: str, revision: int) -> bytes: + return f"stackchain:unfiled-drafts:v1\0{login}\0{revision}".encode() + + def _encrypt(self, login: str, revision: int, drafts: list[dict]) -> str: + plaintext = json.dumps(drafts, separators=(",", ":")).encode() + nonce = os.urandom(12) + sealed = nonce + self._cipher.encrypt( + nonce, plaintext, self._aad(login, revision) + ) + return "v1:" + base64.urlsafe_b64encode(sealed).decode() + + def _decrypt(self, login: str, revision: int, payload: str) -> list[dict]: + try: + sealed = base64.b64decode(payload[3:], altchars=b"-_", validate=True) + if len(sealed) < 12 + 16: + raise ValueError("encrypted payload is too short") + plaintext = self._cipher.decrypt( + sealed[:12], sealed[12:], self._aad(login, revision) + ) + drafts = json.loads(plaintext) + if not isinstance(drafts, list): + raise ValueError("decrypted payload is not a collection") + return drafts + except (binascii.Error, InvalidTag, UnicodeDecodeError, ValueError, json.JSONDecodeError) as error: + raise UnfiledDraftEncryptionError( + "unfiled drafts could not be decrypted" + ) from error + + def _snapshot(self, login: str, row) -> tuple[dict, bool]: + if row is None: + return {"revision": 0, "drafts": []}, False + revision, payload = int(row[0]), row[1] + if payload.startswith("v1:"): + drafts = self._decrypt(login, revision, payload) + return {"revision": revision, "drafts": drafts}, False + try: + drafts = json.loads(payload) + except (TypeError, json.JSONDecodeError) as error: + raise UnfiledDraftEncryptionError( + "legacy unfiled drafts could not be decoded" + ) from error + if not isinstance(drafts, list): + raise UnfiledDraftEncryptionError( + "legacy unfiled drafts could not be decoded" + ) + return {"revision": revision, "drafts": drafts}, True def get(self, login: str) -> dict: + login = self._login(login) with self._connect() as connection: + connection.execute("BEGIN IMMEDIATE") row = connection.execute( "SELECT revision, drafts FROM unfiled_drafts WHERE login = ?", - (self._login(login),), + (login,), ).fetchone() - return self._snapshot(row) + snapshot, legacy = self._snapshot(login, row) + if legacy: + connection.execute( + "UPDATE unfiled_drafts SET drafts = ? WHERE login = ?", + ( + self._encrypt(login, snapshot["revision"], snapshot["drafts"]), + login, + ), + ) + return snapshot @staticmethod def _filing_plan(raw: object) -> dict | None: @@ -233,16 +315,16 @@ class UnfiledDraftStore: if not isinstance(expected_revision, int) or isinstance(expected_revision, bool) or expected_revision < 0: raise ValueError("revision is invalid") normalized = self._normalize(drafts) - serialized = json.dumps(normalized, separators=(",", ":")) with self._connect() as connection: connection.execute("BEGIN IMMEDIATE") row = connection.execute( "SELECT revision, drafts FROM unfiled_drafts WHERE login = ?", (login,) ).fetchone() - current = self._snapshot(row) + current, _legacy = self._snapshot(login, row) if current["revision"] != expected_revision: raise UnfiledDraftConflict(current) revision = expected_revision + 1 + serialized = self._encrypt(login, revision, normalized) connection.execute( "INSERT INTO unfiled_drafts(login, revision, drafts) VALUES (?, ?, ?) " "ON CONFLICT(login) DO UPDATE SET revision=excluded.revision, drafts=excluded.drafts", diff --git a/tests/test_private_state.py b/tests/test_private_state.py index d2ebb9d..9ed3401 100644 --- a/tests/test_private_state.py +++ b/tests/test_private_state.py @@ -84,7 +84,7 @@ def test_private_sqlite_connection_rejects_a_symlink_before_opening_target(tmp_p TodayStore, LaterStore, SavedSearchStore, - UnfiledDraftStore, + lambda path: UnfiledDraftStore(path, encryption_key=b"d" * 32), CompletedFiledReviewStore, PushSubscriptionStore, LiveSnapshotStore, diff --git a/tests/test_unfiled_draft_store.py b/tests/test_unfiled_draft_store.py index e0d1448..e0bd225 100644 --- a/tests/test_unfiled_draft_store.py +++ b/tests/test_unfiled_draft_store.py @@ -1,10 +1,16 @@ +import json +import sqlite3 + import httpx import pytest -from src import main +from src import main, unfiled_draft_store from src.unfiled_draft_store import UnfiledDraftConflict, UnfiledDraftStore +ENCRYPTION_KEY = b"d" * 32 + + def draft(draft_id="phone-capture", *, title="Broken checkout", evidence=None): return { "id": draft_id, @@ -23,8 +29,77 @@ def draft(draft_id="phone-capture", *, title="Broken checkout", evidence=None): } +def test_unfiled_drafts_encrypt_private_content_and_authenticate_the_account(tmp_path): + path = tmp_path / "unfiled.sqlite3" + store = UnfiledDraftStore(path, encryption_key=ENCRYPTION_KEY) + + created = store.replace("timmy", 0, [draft()]) + + with sqlite3.connect(path) as connection: + payload = connection.execute( + "SELECT drafts FROM unfiled_drafts WHERE login = 'timmy'" + ).fetchone()[0] + connection.execute( + "INSERT INTO unfiled_drafts(login, revision, drafts) VALUES (?, ?, ?)", + ("alexander", 1, payload), + ) + assert payload.startswith("v1:") + assert "Broken checkout" not in payload + assert "Steps from the field" not in payload + assert "cG5nLWJ5dGVz" not in payload + assert store.get("timmy") == created + with pytest.raises( + unfiled_draft_store.UnfiledDraftEncryptionError, + match="could not be decrypted", + ): + store.get("alexander") + + +def test_unfiled_drafts_migrate_plaintext_without_changing_revision_or_order(tmp_path): + path = tmp_path / "unfiled.sqlite3" + store = UnfiledDraftStore(path, encryption_key=ENCRYPTION_KEY) + legacy = [draft(), draft("second", title="Second")] + with sqlite3.connect(path) as connection: + connection.execute( + "INSERT INTO unfiled_drafts(login, revision, drafts) VALUES (?, ?, ?)", + ("timmy", 7, json.dumps(legacy, separators=(",", ":"))), + ) + + assert store.get("timmy") == {"revision": 7, "drafts": legacy} + with sqlite3.connect(path) as connection: + migrated = connection.execute( + "SELECT revision, drafts FROM unfiled_drafts WHERE login = 'timmy'" + ).fetchone() + assert migrated[0] == 7 + assert migrated[1].startswith("v1:") + assert "Broken checkout" not in migrated[1] + + +def test_unfiled_drafts_reject_tampered_ciphertext(tmp_path): + path = tmp_path / "unfiled.sqlite3" + store = UnfiledDraftStore(path, encryption_key=ENCRYPTION_KEY) + store.replace("timmy", 0, [draft()]) + with sqlite3.connect(path) as connection: + payload = connection.execute( + "SELECT drafts FROM unfiled_drafts WHERE login = 'timmy'" + ).fetchone()[0] + replacement = "A" if payload[-1] != "A" else "B" + connection.execute( + "UPDATE unfiled_drafts SET drafts = ? WHERE login = 'timmy'", + (payload[:-1] + replacement,), + ) + + with pytest.raises( + unfiled_draft_store.UnfiledDraftEncryptionError, + match="could not be decrypted", + ): + store.get("timmy") + + def test_unfiled_drafts_are_revisioned_ordered_and_account_scoped(tmp_path): - store = UnfiledDraftStore(tmp_path / "unfiled.sqlite3") + store = UnfiledDraftStore( + tmp_path / "unfiled.sqlite3", encryption_key=ENCRYPTION_KEY + ) created = store.replace(" Timmy ", 0, [draft(), draft("second", title="Second")]) @@ -37,7 +112,12 @@ def test_unfiled_drafts_are_revisioned_ordered_and_account_scoped(tmp_path): def test_unfiled_drafts_bound_collection_and_decoded_evidence(tmp_path): - store = UnfiledDraftStore(tmp_path / "unfiled.sqlite3", limit=2, max_total_bytes=12) + store = UnfiledDraftStore( + tmp_path / "unfiled.sqlite3", + encryption_key=ENCRYPTION_KEY, + limit=2, + max_total_bytes=12, + ) with pytest.raises(ValueError, match="limited to 2"): store.replace("timmy", 0, [draft("one", evidence=[]), draft("two", evidence=[]), draft("three", evidence=[])]) @@ -52,7 +132,9 @@ def test_unfiled_drafts_bound_collection_and_decoded_evidence(tmp_path): def test_unfiled_drafts_allow_untitled_photo_evidence_but_reject_empty_records(tmp_path): - store = UnfiledDraftStore(tmp_path / "unfiled.sqlite3") + store = UnfiledDraftStore( + tmp_path / "unfiled.sqlite3", encryption_key=ENCRYPTION_KEY + ) photo_only = draft(title="") validated = main.UnfiledDraft.model_validate(photo_only).model_dump() @@ -66,7 +148,9 @@ def test_unfiled_drafts_allow_untitled_photo_evidence_but_reject_empty_records(t def test_unfiled_drafts_validate_and_round_trip_complete_filing_plan(tmp_path): - store = UnfiledDraftStore(tmp_path / "unfiled.sqlite3") + store = UnfiledDraftStore( + tmp_path / "unfiled.sqlite3", encryption_key=ENCRYPTION_KEY + ) planned = draft(evidence=[]) planned.pop("evidence") planned["filing_plan"] = { @@ -102,6 +186,10 @@ async def test_unfiled_draft_api_is_account_scoped_csrf_protected_and_conflict_s monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3")) monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.sqlite3")) monkeypatch.setenv("STACKCHAIN_UNFILED_DRAFT_DB", str(tmp_path / "drafts.sqlite3")) + monkeypatch.setenv( + "STACKCHAIN_UNFILED_DRAFT_ENCRYPTION_KEY", + "ZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGQ=", + ) identity = {"login": "Timmy"} async def user(): @@ -133,6 +221,10 @@ async def test_unfiled_draft_api_is_account_scoped_csrf_protected_and_conflict_s fetched = await client.get("/api/v1/unfiled-drafts") identity["login"] = "Alexander" isolated = await client.get("/api/v1/unfiled-drafts") + monkeypatch.delenv("STACKCHAIN_UNFILED_DRAFT_ENCRYPTION_KEY") + unavailable = await client.get("/api/v1/unfiled-drafts") + monkeypatch.setenv("STACKCHAIN_UNFILED_DRAFT_ENCRYPTION_KEY", "not-base64") + malformed = await client.get("/api/v1/unfiled-drafts") assert forbidden.status_code == 403 assert saved.status_code == 200 @@ -144,3 +236,7 @@ async def test_unfiled_draft_api_is_account_scoped_csrf_protected_and_conflict_s assert fetched.json() == saved.json() assert fetched.headers["cache-control"] == "no-store" assert isolated.json() == {"revision": 0, "drafts": []} + assert unavailable.status_code == 503 + assert unavailable.json()["detail"] == "Draft synchronization is unavailable" + assert malformed.status_code == 503 + assert malformed.json()["detail"] == "Draft synchronization is unavailable" From e79c4577743fd8fba75e4b1478d7d0ba58a9ae9a Mon Sep 17 00:00:00 2001 From: timmy Date: Tue, 18 Aug 2026 22:15:09 +0000 Subject: [PATCH 2/2] test: inject draft encryption key into release journey --- tests/e2e/test_mobile_offline_issue_release.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/e2e/test_mobile_offline_issue_release.py b/tests/e2e/test_mobile_offline_issue_release.py index c1c1cba..1c0bc0c 100644 --- a/tests/e2e/test_mobile_offline_issue_release.py +++ b/tests/e2e/test_mobile_offline_issue_release.py @@ -84,6 +84,9 @@ def release_server(archive: Path, tmp_path: Path, gitea_url: str): "STACKCHAIN_DASHBOARD_AUTH_MODE": "operator", "STACKCHAIN_DASHBOARD_ACCESS_TOKEN": ACCESS_TOKEN, "STACKCHAIN_DASHBOARD_SESSION_SECRET": "artifact-browser-independent-session-secret-863", + "STACKCHAIN_UNFILED_DRAFT_ENCRYPTION_KEY": ( + "ZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGQ=" + ), "STACKCHAIN_DASHBOARD_PUBLIC_ORIGIN": origin, "STACKCHAIN_STATE_DIR": str(tmp_path / "state"), }