security: encrypt synchronized unfiled drafts (Closes #1098)
Some checks failed
CI / lint (pull_request) Successful in 2m58s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Failing after 2m53s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-18 22:07:02 +00:00
parent 19a1184b1c
commit d3f53b5a38
6 changed files with 216 additions and 20 deletions

View File

@ -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 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 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 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 paths are rejected before access. Synchronized unfiled Draft collections add AES-256-GCM payload
encryption at rest; secure host access, encrypted volumes, and private backups are still required. 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 Set `STACKCHAIN_STATE_DIR` to a
persistent, writable service directory (or set `STACKCHAIN_IDEMPOTENCY_DB` to an explicit 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 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 export STACKCHAIN_PASSKEY_MAX_CHALLENGES=10000
# Optional; defaults to STACKCHAIN_STATE_DIR/login-attempts.sqlite3. # Optional; defaults to STACKCHAIN_STATE_DIR/login-attempts.sqlite3.
export STACKCHAIN_LOGIN_ATTEMPT_DB='/var/lib/stackchain-dashboard/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='<base64-encoded-32-byte-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. # Trust forwarding headers only from these immediate reverse-proxy networks.
export STACKCHAIN_TRUSTED_PROXY_CIDRS='127.0.0.0/8' export STACKCHAIN_TRUSTED_PROXY_CIDRS='127.0.0.0/8'
# Optional Web Push. Generate a VAPID key pair outside the repo and inject it. # Optional Web Push. Generate a VAPID key pair outside the repo and inject it.

View File

@ -1,4 +1,5 @@
fastapi==0.133.1 fastapi==0.133.1
cryptography==50.0.0
httpx==0.28.1 httpx==0.28.1
pydantic==2.13.4 pydantic==2.13.4
Pillow==12.3.0 Pillow==12.3.0

View File

@ -60,7 +60,12 @@ from src.push_endpoint_policy import UnsafePushEndpoint, validate_public_push_en
from src.push_subscription_store import PushSubscriptionStore from src.push_subscription_store import PushSubscriptionStore
from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit
from src.saved_search_store import SavedSearchConflict, SavedSearchStore 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.security_event_store import SecurityEventStore, SecurityEventStoreError
from src.suggestion_engine import compute from src.suggestion_engine import compute
from src.later_store import LaterStore from src.later_store import LaterStore
@ -2285,7 +2290,10 @@ def _completed_filed_review_store() -> CompletedFiledReviewStore:
def _unfiled_draft_store() -> UnfiledDraftStore: def _unfiled_draft_store() -> UnfiledDraftStore:
return 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() login = await _confirmed_login()
try: try:
snapshot = await asyncio.to_thread(_unfiled_draft_store().get, login) snapshot = await asyncio.to_thread(_unfiled_draft_store().get, login)
except (OSError, sqlite3.Error): except (OSError, sqlite3.Error, UnfiledDraftEncryptionError):
raise HTTPException( raise HTTPException(
status_code=503, status_code=503,
detail="Draft synchronization is unavailable", detail="Draft synchronization is unavailable",
@ -2416,7 +2424,7 @@ async def replace_unfiled_drafts(payload: UnfiledDraftCollection):
) )
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) raise HTTPException(status_code=422, detail=str(exc))
except (OSError, sqlite3.Error): except (OSError, sqlite3.Error, UnfiledDraftEncryptionError):
raise HTTPException( raise HTTPException(
status_code=503, status_code=503,
detail="Draft synchronization is unavailable", detail="Draft synchronization is unavailable",

View File

@ -3,11 +3,15 @@
import base64 import base64
import binascii import binascii
import json import json
import os
import re import re
import sqlite3 import sqlite3
from datetime import datetime from datetime import datetime
from pathlib import Path 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 from src.private_state import connect_private_sqlite
@ -24,16 +28,41 @@ class UnfiledDraftConflict(ValueError):
self.snapshot = snapshot 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: class UnfiledDraftStore:
def __init__( def __init__(
self, self,
path: str | Path, path: str | Path,
*, *,
encryption_key: bytes,
limit: int = 20, limit: int = 20,
max_total_bytes: int = 12 * 1024 * 1024, max_total_bytes: int = 12 * 1024 * 1024,
timeout: float = 1.0, timeout: float = 1.0,
): ):
self.path = Path(path) 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.limit = limit
self.max_total_bytes = max_total_bytes self.max_total_bytes = max_total_bytes
self.timeout = timeout self.timeout = timeout
@ -61,18 +90,71 @@ class UnfiledDraftStore:
return normalized return normalized
@staticmethod @staticmethod
def _snapshot(row) -> dict: def _aad(login: str, revision: int) -> bytes:
return {"revision": 0, "drafts": []} if row is None else { return f"stackchain:unfiled-drafts:v1\0{login}\0{revision}".encode()
"revision": int(row[0]), "drafts": json.loads(row[1])
} 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: def get(self, login: str) -> dict:
login = self._login(login)
with self._connect() as connection: with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
row = connection.execute( row = connection.execute(
"SELECT revision, drafts FROM unfiled_drafts WHERE login = ?", "SELECT revision, drafts FROM unfiled_drafts WHERE login = ?",
(self._login(login),), (login,),
).fetchone() ).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 @staticmethod
def _filing_plan(raw: object) -> dict | None: 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: if not isinstance(expected_revision, int) or isinstance(expected_revision, bool) or expected_revision < 0:
raise ValueError("revision is invalid") raise ValueError("revision is invalid")
normalized = self._normalize(drafts) normalized = self._normalize(drafts)
serialized = json.dumps(normalized, separators=(",", ":"))
with self._connect() as connection: with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE") connection.execute("BEGIN IMMEDIATE")
row = connection.execute( row = connection.execute(
"SELECT revision, drafts FROM unfiled_drafts WHERE login = ?", (login,) "SELECT revision, drafts FROM unfiled_drafts WHERE login = ?", (login,)
).fetchone() ).fetchone()
current = self._snapshot(row) current, _legacy = self._snapshot(login, row)
if current["revision"] != expected_revision: if current["revision"] != expected_revision:
raise UnfiledDraftConflict(current) raise UnfiledDraftConflict(current)
revision = expected_revision + 1 revision = expected_revision + 1
serialized = self._encrypt(login, revision, normalized)
connection.execute( connection.execute(
"INSERT INTO unfiled_drafts(login, revision, drafts) VALUES (?, ?, ?) " "INSERT INTO unfiled_drafts(login, revision, drafts) VALUES (?, ?, ?) "
"ON CONFLICT(login) DO UPDATE SET revision=excluded.revision, drafts=excluded.drafts", "ON CONFLICT(login) DO UPDATE SET revision=excluded.revision, drafts=excluded.drafts",

View File

@ -84,7 +84,7 @@ def test_private_sqlite_connection_rejects_a_symlink_before_opening_target(tmp_p
TodayStore, TodayStore,
LaterStore, LaterStore,
SavedSearchStore, SavedSearchStore,
UnfiledDraftStore, lambda path: UnfiledDraftStore(path, encryption_key=b"d" * 32),
CompletedFiledReviewStore, CompletedFiledReviewStore,
PushSubscriptionStore, PushSubscriptionStore,
LiveSnapshotStore, LiveSnapshotStore,

View File

@ -1,10 +1,16 @@
import json
import sqlite3
import httpx import httpx
import pytest import pytest
from src import main from src import main, unfiled_draft_store
from src.unfiled_draft_store import UnfiledDraftConflict, UnfiledDraftStore from src.unfiled_draft_store import UnfiledDraftConflict, UnfiledDraftStore
ENCRYPTION_KEY = b"d" * 32
def draft(draft_id="phone-capture", *, title="Broken checkout", evidence=None): def draft(draft_id="phone-capture", *, title="Broken checkout", evidence=None):
return { return {
"id": draft_id, "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): 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")]) 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): 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"): with pytest.raises(ValueError, match="limited to 2"):
store.replace("timmy", 0, [draft("one", evidence=[]), draft("two", evidence=[]), draft("three", evidence=[])]) 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): 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="") photo_only = draft(title="")
validated = main.UnfiledDraft.model_validate(photo_only).model_dump() 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): 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 = draft(evidence=[])
planned.pop("evidence") planned.pop("evidence")
planned["filing_plan"] = { 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_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.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_DB", str(tmp_path / "drafts.sqlite3"))
monkeypatch.setenv(
"STACKCHAIN_UNFILED_DRAFT_ENCRYPTION_KEY",
"ZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGQ=",
)
identity = {"login": "Timmy"} identity = {"login": "Timmy"}
async def user(): 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") fetched = await client.get("/api/v1/unfiled-drafts")
identity["login"] = "Alexander" identity["login"] = "Alexander"
isolated = await client.get("/api/v1/unfiled-drafts") 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 forbidden.status_code == 403
assert saved.status_code == 200 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.json() == saved.json()
assert fetched.headers["cache-control"] == "no-store" assert fetched.headers["cache-control"] == "no-store"
assert isolated.json() == {"revision": 0, "drafts": []} 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"