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
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user