security: encrypt synchronized unfiled drafts (Closes #1098)
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 committed 2026-08-18 22:07:02 +00:00
1 parent 19a1184b1c
commit d3f53b5a38
6 files changed
+216 -20

No files matched your search

+12 -4
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.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",
+90 -8
View File
@@ -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",