security: encrypt synchronized Saved Searches (Closes #1126)
This commit is contained in:
parent
3d4be0a913
commit
a9cf88b3ff
12
README.md
12
README.md
|
|
@ -199,7 +199,11 @@ each envelope to its operation key and field purpose so rows and fields cannot b
|
||||||
Existing plaintext snapshot and ledger rows migrate atomically on their first read without changing
|
Existing plaintext snapshot and ledger rows migrate atomically on their first read without changing
|
||||||
freshness, revisions, ordering, replay, or conflict semantics. Synchronized unfiled Draft collections
|
freshness, revisions, ordering, replay, or conflict semantics. Synchronized unfiled Draft collections
|
||||||
use a separate AES-256-GCM key and authenticate the account and revision; existing plaintext rows
|
use a separate AES-256-GCM key and authenticate the account and revision; existing plaintext rows
|
||||||
likewise migrate on first read. Other private stores are not encrypted at the application layer. Web
|
likewise migrate on first read. Synchronized Saved Search collections use the private-state key and
|
||||||
|
authenticate each envelope to its normalized account, preventing rows from being substituted between
|
||||||
|
operators. Existing plaintext Saved Searches migrate atomically on first read without advancing their
|
||||||
|
revision; missing, wrong, or modified key material returns no saved-view content. Other private stores
|
||||||
|
are not encrypted at the application layer. Web
|
||||||
Push subscriptions use a third, independent
|
Push subscriptions use a third, independent
|
||||||
AES-256-GCM key authenticated to the device session, with keyed endpoint indexes preserving
|
AES-256-GCM key authenticated to the device session, with keyed endpoint indexes preserving
|
||||||
single-device enrollment without retaining capability URLs. Existing plaintext subscriptions
|
single-device enrollment without retaining capability URLs. Existing plaintext subscriptions
|
||||||
|
|
@ -271,12 +275,12 @@ 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 worker-shared live/Find Work snapshots, synchronized Today/Later
|
# Required for worker-shared live/Find Work snapshots, synchronized Today/Later
|
||||||
# planning state, and the Security activity journal. Keep this key independent
|
# planning and Saved Search state, and the Security activity journal. Keep this key independent
|
||||||
# from the Draft key and inject the
|
# from the Draft key and inject the
|
||||||
# base64 encoding of exactly 32 random bytes from a secret manager. Never commit
|
# base64 encoding of exactly 32 random bytes from a secret manager. Never commit
|
||||||
# it. Missing, malformed, wrong-key, or modified state fails closed without
|
# it. Missing, malformed, wrong-key, or modified state fails closed without
|
||||||
# returning content. Legacy Today/Later and Security activity rows migrate
|
# returning content. Legacy Today/Later, Saved Search, and Security activity rows
|
||||||
# atomically on first use without changing logical revisions or journal IDs.
|
# migrate atomically on first use without changing logical revisions or journal IDs.
|
||||||
export STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEY='<base64-encoded-32-byte-key>'
|
export STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEY='<base64-encoded-32-byte-key>'
|
||||||
# Required for cross-device unfiled Draft sync. The single-key setting remains
|
# Required for cross-device unfiled Draft sync. The single-key setting remains
|
||||||
# supported for the first deployment of keyring-capable code and writes v1 envelopes.
|
# supported for the first deployment of keyring-capable code and writes v1 envelopes.
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
"""Durable, account-scoped saved Search views."""
|
"""Durable, account-scoped saved Search views."""
|
||||||
|
|
||||||
import json
|
|
||||||
import re
|
import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from src.private_state import connect_private_sqlite
|
from src.private_state import connect_private_sqlite
|
||||||
|
from src.state_encryption import PrivateStateCipher, PrivateStateEncryptionError, private_state_encryption_key
|
||||||
|
|
||||||
|
|
||||||
_REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
_REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
||||||
|
|
@ -21,10 +21,21 @@ class SavedSearchConflict(ValueError):
|
||||||
|
|
||||||
|
|
||||||
class SavedSearchStore:
|
class SavedSearchStore:
|
||||||
def __init__(self, path: str | Path, *, limit: int = 20, timeout: float = 1.0):
|
def __init__(
|
||||||
|
self,
|
||||||
|
path: str | Path,
|
||||||
|
*,
|
||||||
|
limit: int = 20,
|
||||||
|
timeout: float = 1.0,
|
||||||
|
encryption_key: bytes | None = None,
|
||||||
|
):
|
||||||
self.path = Path(path)
|
self.path = Path(path)
|
||||||
self.limit = limit
|
self.limit = limit
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
|
self._cipher = PrivateStateCipher(
|
||||||
|
encryption_key if encryption_key is not None else private_state_encryption_key(),
|
||||||
|
store="saved-searches",
|
||||||
|
)
|
||||||
self._initialize()
|
self._initialize()
|
||||||
|
|
||||||
def _initialize(self) -> None:
|
def _initialize(self) -> None:
|
||||||
|
|
@ -50,19 +61,31 @@ class SavedSearchStore:
|
||||||
raise ValueError("login is required")
|
raise ValueError("login is required")
|
||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
@staticmethod
|
def _snapshot(self, row, login: str) -> tuple[dict, bool]:
|
||||||
def _snapshot(row) -> dict:
|
if row is None:
|
||||||
return {"revision": 0, "views": []} if row is None else {
|
return {"revision": 0, "views": []}, False
|
||||||
"revision": int(row[0]), "views": json.loads(row[1])
|
views, legacy = self._cipher.open(row[1], binding=f"views:{login}")
|
||||||
}
|
if not isinstance(views, list):
|
||||||
|
raise PrivateStateEncryptionError("private state could not be decrypted")
|
||||||
|
return {"revision": int(row[0]), "views": views}, legacy
|
||||||
|
|
||||||
|
def _sealed_views(self, login: str, views: list[dict]) -> str:
|
||||||
|
return self._cipher.seal(views, binding=f"views:{login}")
|
||||||
|
|
||||||
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:
|
||||||
row = connection.execute(
|
row = connection.execute(
|
||||||
"SELECT revision, views FROM saved_searches WHERE login = ?",
|
"SELECT revision, views FROM saved_searches WHERE login = ?",
|
||||||
(self._login(login),),
|
(login,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
return self._snapshot(row)
|
snapshot, legacy = self._snapshot(row, login)
|
||||||
|
if row is not None and legacy:
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE saved_searches SET views = ? WHERE login = ? AND views = ?",
|
||||||
|
(self._sealed_views(login, snapshot["views"]), login, row[1]),
|
||||||
|
)
|
||||||
|
return snapshot
|
||||||
|
|
||||||
def _normalize(self, views: list[dict]) -> list[dict]:
|
def _normalize(self, views: list[dict]) -> list[dict]:
|
||||||
if not isinstance(views, list):
|
if not isinstance(views, list):
|
||||||
|
|
@ -113,13 +136,13 @@ class SavedSearchStore:
|
||||||
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(views)
|
normalized = self._normalize(views)
|
||||||
serialized = json.dumps(normalized, separators=(",", ":"))
|
serialized = self._sealed_views(login, normalized)
|
||||||
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, views FROM saved_searches WHERE login = ?", (login,)
|
"SELECT revision, views FROM saved_searches WHERE login = ?", (login,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
current = self._snapshot(row)
|
current, _legacy = self._snapshot(row, login)
|
||||||
if current["revision"] != expected_revision:
|
if current["revision"] != expected_revision:
|
||||||
raise SavedSearchConflict(current)
|
raise SavedSearchConflict(current)
|
||||||
revision = expected_revision + 1
|
revision = expected_revision + 1
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src import main
|
from src import main
|
||||||
from src.saved_search_store import SavedSearchConflict, SavedSearchStore
|
from src.saved_search_store import SavedSearchConflict, SavedSearchStore
|
||||||
|
from src.state_encryption import PrivateStateEncryptionError
|
||||||
|
|
||||||
|
|
||||||
def view(view_id="release", name="Release queue", query="mobile", **scope):
|
def view(view_id="release", name="Release queue", query="mobile", **scope):
|
||||||
|
|
@ -16,6 +20,105 @@ def view(view_id="release", name="Release queue", query="mobile", **scope):
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_saved_searches_encrypt_private_views_and_survive_restart(tmp_path):
|
||||||
|
database = tmp_path / "saved-searches.sqlite3"
|
||||||
|
key = b"s" * 32
|
||||||
|
canary = "private-launch-query-canary"
|
||||||
|
store = SavedSearchStore(database, encryption_key=key)
|
||||||
|
|
||||||
|
created = store.replace(
|
||||||
|
"Timmy",
|
||||||
|
0,
|
||||||
|
[view(name="Private launch", query=canary, repository="private/launch")],
|
||||||
|
)
|
||||||
|
|
||||||
|
with sqlite3.connect(database) as connection:
|
||||||
|
payload = connection.execute(
|
||||||
|
"SELECT views FROM saved_searches WHERE login = 'timmy'"
|
||||||
|
).fetchone()[0]
|
||||||
|
assert payload.startswith("v1:")
|
||||||
|
assert "Private launch" not in payload
|
||||||
|
assert canary not in payload
|
||||||
|
assert "private/launch" not in payload
|
||||||
|
assert SavedSearchStore(database, encryption_key=key).get("timmy") == created
|
||||||
|
|
||||||
|
|
||||||
|
def test_saved_searches_lazily_migrate_plaintext_without_changing_revision(tmp_path):
|
||||||
|
database = tmp_path / "saved-searches.sqlite3"
|
||||||
|
key = b"s" * 32
|
||||||
|
legacy_views = [view(name="Legacy private view", query="legacy-canary")]
|
||||||
|
SavedSearchStore(database, encryption_key=key)
|
||||||
|
with sqlite3.connect(database) as connection:
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO saved_searches(login, revision, views) VALUES (?, ?, ?)",
|
||||||
|
("timmy", 7, json.dumps(legacy_views)),
|
||||||
|
)
|
||||||
|
|
||||||
|
snapshot = SavedSearchStore(database, encryption_key=key).get("Timmy")
|
||||||
|
|
||||||
|
with sqlite3.connect(database) as connection:
|
||||||
|
migrated = connection.execute(
|
||||||
|
"SELECT revision, views FROM saved_searches WHERE login = 'timmy'"
|
||||||
|
).fetchone()
|
||||||
|
assert snapshot == {"revision": 7, "views": legacy_views}
|
||||||
|
assert migrated[0] == 7
|
||||||
|
assert migrated[1].startswith("v1:")
|
||||||
|
assert "legacy-canary" not in migrated[1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_saved_searches_fail_closed_with_wrong_key(tmp_path):
|
||||||
|
database = tmp_path / "saved-searches.sqlite3"
|
||||||
|
SavedSearchStore(database, encryption_key=b"s" * 32).replace(
|
||||||
|
"timmy", 0, [view(query="wrong-key-canary")]
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
PrivateStateEncryptionError, match="private state could not be decrypted"
|
||||||
|
):
|
||||||
|
SavedSearchStore(database, encryption_key=b"x" * 32).get("timmy")
|
||||||
|
|
||||||
|
|
||||||
|
def test_saved_searches_reject_tampered_or_malformed_payloads(tmp_path):
|
||||||
|
database = tmp_path / "saved-searches.sqlite3"
|
||||||
|
store = SavedSearchStore(database, encryption_key=b"s" * 32)
|
||||||
|
store.replace("timmy", 0, [view(query="tamper-canary")])
|
||||||
|
with sqlite3.connect(database) as connection:
|
||||||
|
payload = connection.execute(
|
||||||
|
"SELECT views FROM saved_searches WHERE login = 'timmy'"
|
||||||
|
).fetchone()[0]
|
||||||
|
|
||||||
|
for invalid in (payload[:-1] + ("A" if payload[-1] != "A" else "B"), "not-json"):
|
||||||
|
with sqlite3.connect(database) as connection:
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE saved_searches SET views = ? WHERE login = 'timmy'",
|
||||||
|
(invalid,),
|
||||||
|
)
|
||||||
|
with pytest.raises(
|
||||||
|
PrivateStateEncryptionError, match="private state could not be decrypted"
|
||||||
|
):
|
||||||
|
store.get("timmy")
|
||||||
|
|
||||||
|
|
||||||
|
def test_saved_searches_reject_ciphertext_substituted_between_accounts(tmp_path):
|
||||||
|
database = tmp_path / "saved-searches.sqlite3"
|
||||||
|
store = SavedSearchStore(database, encryption_key=b"s" * 32)
|
||||||
|
store.replace("timmy", 0, [view(query="timmy-private")])
|
||||||
|
store.replace("alexander", 0, [view(query="alexander-private")])
|
||||||
|
with sqlite3.connect(database) as connection:
|
||||||
|
timmy_payload = connection.execute(
|
||||||
|
"SELECT views FROM saved_searches WHERE login = 'timmy'"
|
||||||
|
).fetchone()[0]
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE saved_searches SET views = ? WHERE login = 'alexander'",
|
||||||
|
(timmy_payload,),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
PrivateStateEncryptionError, match="private state could not be decrypted"
|
||||||
|
):
|
||||||
|
store.get("alexander")
|
||||||
|
|
||||||
|
|
||||||
def test_saved_searches_are_revisioned_ordered_and_account_scoped(tmp_path):
|
def test_saved_searches_are_revisioned_ordered_and_account_scoped(tmp_path):
|
||||||
store = SavedSearchStore(tmp_path / "saved-searches.sqlite3")
|
store = SavedSearchStore(tmp_path / "saved-searches.sqlite3")
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user