security: encrypt synchronized unfiled drafts (Closes #1098)
This commit is contained in:
1 parent
19a1184b1c
commit
d3f53b5a38
6 files changed
+216
-20
No files matched your search
+12
-4
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user