From 7f2681c1c6a75de0ee290f4a014d6e8aa03862db Mon Sep 17 00:00:00 2001 From: timmy Date: Wed, 19 Aug 2026 07:25:49 +0000 Subject: [PATCH] security: encrypt Security activity journal (Closes #1118) --- README.md | 18 ++-- src/security_event_store.py | 152 +++++++++++++++++++++++++---- tests/test_security_activity.py | 29 ++++++ tests/test_security_event_store.py | 117 +++++++++++++++++++++- 4 files changed, 288 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 2e627e6..dca6697 100644 --- a/README.md +++ b/README.md @@ -267,12 +267,13 @@ export STACKCHAIN_PASSKEY_MAX_CHALLENGES_PER_SOURCE=10 export STACKCHAIN_PASSKEY_MAX_CHALLENGES=10000 # Optional; defaults to STACKCHAIN_STATE_DIR/login-attempts.sqlite3. export STACKCHAIN_LOGIN_ATTEMPT_DB='/var/lib/stackchain-dashboard/login-attempts.sqlite3' -# Required for worker-shared live/Find Work snapshots and synchronized Today/Later -# planning state. Keep this key independent from the Draft key and inject the +# Required for worker-shared live/Find Work snapshots, synchronized Today/Later +# planning state, and the Security activity journal. Keep this key independent +# from the Draft key and inject the # base64 encoding of exactly 32 random bytes from a secret manager. Never commit # it. Missing, malformed, wrong-key, or modified state fails closed without -# returning content. Legacy Today/Later rows migrate atomically on first use -# without advancing their logical revision. +# returning content. Legacy Today/Later and Security activity rows migrate +# atomically on first use without changing logical revisions or journal IDs. export STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEY='' # 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. @@ -386,8 +387,13 @@ place, their live sessions remain valid, and their idle clock starts at migratio The same sheet includes **Security activity**, a reverse-chronological journal of successful token/passkey sign-ins, passkey enrollments, sign-outs, remote device revocations, issue closures, and pull-request merges. The separate SQLite journal -retains at most 10,000 events for 90 days and stores only bounded device labels and -action targets. It never stores access tokens, cookies, session/CSRF values, +retains at most 10,000 events for 90 days. Its bounded event kind, authentication +method, device label, and action target are sealed at rest with the private-state +encryption key and authenticated against the immutable event ID. Legacy plaintext +journals migrate atomically without changing IDs, timestamps, order, or pending +operations. Missing or wrong key material and modified ciphertext fail closed with +no partial activity response. The journal never stores access tokens, cookies, +session/CSRF values, credential IDs, public keys, challenges, attestation data, raw network addresses, request bodies, or comment content. Keep its database on the same class of persistent, writable storage as the session registry. Before passkey credential creation, session diff --git a/src/security_event_store.py b/src/security_event_store.py index bd032b2..fceb445 100644 --- a/src/security_event_store.py +++ b/src/security_event_store.py @@ -7,6 +7,11 @@ from pathlib import Path from typing import Callable from src.private_state import connect_private_sqlite +from src.state_encryption import ( + PrivateStateCipher, + PrivateStateEncryptionError, + private_state_encryption_key, +) class SecurityEventStoreError(RuntimeError): @@ -39,12 +44,22 @@ class SecurityEventStore: max_events: int = 10_000, retention_seconds: int = 90 * 24 * 60 * 60, lock_timeout_seconds: float = 0.1, + encryption_key: bytes | None = None, ) -> None: self.path = Path(path) self.clock = clock self.max_events = max(1, max_events) self.retention_seconds = max(1, retention_seconds) self.lock_timeout_seconds = lock_timeout_seconds + try: + self._cipher = PrivateStateCipher( + encryption_key if encryption_key is not None else private_state_encryption_key(), + store="security-events", + ) + except PrivateStateEncryptionError as exc: + raise SecurityEventStoreError( + "Security activity is temporarily unavailable" + ) from exc @staticmethod def _bounded(value: str | None, limit: int) -> str | None: @@ -60,10 +75,11 @@ class SecurityEventStore: """ CREATE TABLE IF NOT EXISTS security_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, - kind TEXT NOT NULL, + kind TEXT, method TEXT, device_label TEXT, target TEXT, + payload TEXT, created_at INTEGER NOT NULL, status TEXT NOT NULL DEFAULT 'completed', operation_id TEXT @@ -81,6 +97,8 @@ class SecurityEventStore: connection.execute( "ALTER TABLE security_events ADD COLUMN operation_id TEXT" ) + if "payload" not in columns: + self._migrate_plaintext(connection) connection.execute( "CREATE INDEX IF NOT EXISTS security_events_created " "ON security_events(created_at DESC, id DESC)" @@ -95,6 +113,53 @@ class SecurityEventStore: "Security activity is temporarily unavailable" ) from exc + def _migrate_plaintext(self, connection: sqlite3.Connection) -> None: + connection.execute("PRAGMA secure_delete = ON") + rows = connection.execute( + "SELECT id, kind, method, device_label, target, created_at, status, operation_id " + "FROM security_events ORDER BY id" + ).fetchall() + connection.execute( + """ + CREATE TABLE security_events_encrypted ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + kind TEXT, + method TEXT, + device_label TEXT, + target TEXT, + payload TEXT, + created_at INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'completed', + operation_id TEXT + ) + """ + ) + for ( + event_id, + kind, + method, + device_label, + target, + created_at, + status, + operation_id, + ) in rows: + connection.execute( + "INSERT INTO security_events_encrypted " + "(id, payload, created_at, status, operation_id) VALUES (?, ?, ?, ?, ?)", + ( + event_id, + self._seal_event(event_id, kind, method, device_label, target), + created_at, + status, + operation_id, + ), + ) + connection.execute("DROP TABLE security_events") + connection.execute( + "ALTER TABLE security_events_encrypted RENAME TO security_events" + ) + def _prune(self, connection: sqlite3.Connection, now: int) -> None: connection.execute( "DELETE FROM security_events WHERE created_at < ?", @@ -106,6 +171,24 @@ class SecurityEventStore: (self.max_events,), ) + def _seal_event( + self, + event_id: int, + kind: str, + method: str | None, + device_label: str | None, + target: str | None, + ) -> str: + return self._cipher.seal( + { + "kind": self._bounded(kind, 48) or "security_event", + "method": self._bounded(method, 32), + "device_label": self._bounded(device_label, 64), + "target": self._bounded(target, 255), + }, + binding=f"event:{event_id}", + ) + def record( self, kind: str, @@ -117,16 +200,17 @@ class SecurityEventStore: now = int(self.clock()) try: with self._connect() as connection: + cursor = connection.execute( + "INSERT INTO security_events(created_at, status) VALUES (?, 'completed')", + (now,), + ) + event_id = cursor.lastrowid + payload = self._seal_event( + event_id, kind, method, device_label, target + ) connection.execute( - "INSERT INTO security_events(kind, method, device_label, target, created_at, status) " - "VALUES (?, ?, ?, ?, ?, 'completed')", - ( - self._bounded(kind, 48) or "security_event", - self._bounded(method, 32), - self._bounded(device_label, 64), - self._bounded(target, 255), - now, - ), + "UPDATE security_events SET payload = ? WHERE id = ?", + (payload, event_id), ) self._prune(connection, now) except (OSError, sqlite3.Error) as exc: @@ -146,16 +230,19 @@ class SecurityEventStore: operation_id = secrets.token_urlsafe(24) try: with self._connect() as connection: + cursor = connection.execute( + "INSERT INTO security_events(created_at, status, operation_id) " + "VALUES (?, 'pending', ?)", + (now, operation_id), + ) + event_id = cursor.lastrowid connection.execute( - "INSERT INTO security_events(kind, method, device_label, target, created_at, status, operation_id) " - "VALUES (?, ?, ?, ?, ?, 'pending', ?)", + "UPDATE security_events SET payload = ? WHERE id = ?", ( - self._bounded(kind, 48) or "security_event", - self._bounded(method, 32), - self._bounded(device_label, 64), - self._bounded(target, 255), - now, - operation_id, + self._seal_event( + event_id, kind, method, device_label, target + ), + event_id, ), ) self._prune(connection, now) @@ -204,7 +291,7 @@ class SecurityEventStore: try: with self._connect() as connection: rows = connection.execute( - "SELECT id, kind, method, device_label, target, created_at, status " + "SELECT id, payload, created_at, status " f"FROM security_events {where} ORDER BY id DESC LIMIT ?", parameters, ).fetchall() @@ -214,7 +301,32 @@ class SecurityEventStore: ) from exc has_more = len(rows) > bounded_limit visible = rows[:bounded_limit] + events = [] + try: + for event_id, payload, created_at, status in visible: + value, _legacy = self._cipher.open( + payload, binding=f"event:{event_id}" + ) + if not isinstance(value, dict) or not isinstance(value.get("kind"), str): + raise PrivateStateEncryptionError( + "private state could not be decrypted" + ) + events.append( + SecurityEvent( + event_id, + value["kind"], + value.get("method"), + value.get("device_label"), + value.get("target"), + created_at, + status, + ) + ) + except PrivateStateEncryptionError as exc: + raise SecurityEventStoreError( + "Security activity is temporarily unavailable" + ) from exc return SecurityEventPage( - events=[SecurityEvent(*row) for row in visible], + events=events, next_cursor=visible[-1][0] if has_more else None, ) diff --git a/tests/test_security_activity.py b/tests/test_security_activity.py index 193345b..a2e44cb 100644 --- a/tests/test_security_activity.py +++ b/tests/test_security_activity.py @@ -61,6 +61,35 @@ async def test_authenticated_security_activity_lists_private_sign_in_history(sec assert b"stackchain_session" not in persisted +@pytest.mark.anyio +async def test_tampered_security_activity_returns_no_partial_history(security_access): + transport = httpx.ASGITransport(app=main.app, raise_app_exceptions=False) + async with httpx.AsyncClient(transport=transport, base_url="https://test") as client: + await client.post( + "/api/v1/session", + json={ + "access_token": "correct horse battery staple", + "device_label": "Phone", + }, + ) + with sqlite3.connect(security_access / "security.sqlite3") as connection: + payload = connection.execute( + "SELECT payload FROM security_events WHERE id = 1" + ).fetchone()[0] + replacement = "A" if payload[-1] != "A" else "B" + connection.execute( + "UPDATE security_events SET payload = ? WHERE id = 1", + (payload[:-1] + replacement,), + ) + + activity = await client.get("/api/v1/security-events") + + assert activity.status_code == 503 + assert activity.json() == { + "detail": "Security activity is temporarily unavailable" + } + + @pytest.mark.anyio async def test_security_activity_limit_is_validated(security_access): transport = httpx.ASGITransport(app=main.app) diff --git a/tests/test_security_event_store.py b/tests/test_security_event_store.py index 51b7416..8c47b09 100644 --- a/tests/test_security_event_store.py +++ b/tests/test_security_event_store.py @@ -1,6 +1,119 @@ import sqlite3 -from src.security_event_store import SecurityEventStore +import pytest + +from src.security_event_store import SecurityEventStore, SecurityEventStoreError + + +PRIVATE_KEY = b"e" * 32 + + +def test_security_event_payload_is_encrypted_at_rest_and_survives_restart(tmp_path): + path = tmp_path / "security.sqlite3" + canaries = { + "kind": "private_issue_closed_canary", + "method": "private_passkey_canary", + "device_label": "Private Operator Phone Canary", + "target": "private-org/roadmap#4242", + } + store = SecurityEventStore(path, clock=lambda: 1_000, encryption_key=PRIVATE_KEY) + + store.record(**canaries) + + with sqlite3.connect(path) as connection: + row = connection.execute( + "SELECT kind, method, device_label, target, payload FROM security_events" + ).fetchone() + assert row[:4] == (None, None, None, None) + assert row[4].startswith("v1:") + database_bytes = path.read_bytes() + assert all(value.encode() not in database_bytes for value in canaries.values()) + reopened = SecurityEventStore(path, clock=lambda: 1_001, encryption_key=PRIVATE_KEY) + event = reopened.list(limit=10).events[0] + assert (event.kind, event.method, event.device_label, event.target) == tuple( + canaries.values() + ) + + +def test_legacy_security_events_migrate_without_changing_journal_semantics(tmp_path): + path = tmp_path / "security.sqlite3" + with sqlite3.connect(path) as connection: + connection.executescript( + """ + CREATE TABLE security_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + kind TEXT NOT NULL, + method TEXT, + device_label TEXT, + target TEXT, + created_at INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'completed', + operation_id TEXT + ); + INSERT INTO security_events + (id, kind, method, device_label, target, created_at, status, operation_id) + VALUES + (7, 'legacy_sign_in_canary', 'legacy_token_canary', + 'Legacy Phone Canary', 'dashboard', 900, 'completed', NULL), + (9, 'legacy_issue_closed_canary', 'passkey', + 'Legacy Tablet Canary', 'private/repo#9', 901, 'pending', 'operation-9'); + """ + ) + + store = SecurityEventStore(path, clock=lambda: 1_000, encryption_key=PRIVATE_KEY) + page = store.list(limit=1) + older = store.list(limit=10, cursor=page.next_cursor) + + assert [(event.id, event.kind, event.created_at, event.status) for event in page.events] == [ + (9, "legacy_issue_closed_canary", 901, "pending") + ] + assert [(event.id, event.kind, event.created_at, event.status) for event in older.events] == [ + (7, "legacy_sign_in_canary", 900, "completed") + ] + store.finalize("operation-9") + assert store.list(limit=1).events[0].status == "completed" + with sqlite3.connect(path) as connection: + rows = connection.execute( + "SELECT id, kind, method, device_label, target, payload FROM security_events ORDER BY id" + ).fetchall() + assert [row[0] for row in rows] == [7, 9] + assert all(row[1:5] == (None, None, None, None) for row in rows) + assert all(row[5].startswith("v1:") for row in rows) + database_bytes = path.read_bytes() + assert b"legacy_sign_in_canary" not in database_bytes + assert b"Legacy Tablet Canary" not in database_bytes + + +def test_wrong_key_or_tampered_security_activity_fails_closed(tmp_path): + path = tmp_path / "security.sqlite3" + store = SecurityEventStore(path, clock=lambda: 1_000, encryption_key=PRIVATE_KEY) + store.record("issue_closed", target="private/repo#42") + + with pytest.raises(SecurityEventStoreError, match="temporarily unavailable"): + SecurityEventStore( + path, clock=lambda: 1_001, encryption_key=b"x" * 32 + ).list(limit=10) + + with sqlite3.connect(path) as connection: + payload = connection.execute( + "SELECT payload FROM security_events WHERE id = 1" + ).fetchone()[0] + replacement = "A" if payload[-1] != "A" else "B" + connection.execute( + "UPDATE security_events SET payload = ? WHERE id = 1", + (payload[:-1] + replacement,), + ) + with pytest.raises(SecurityEventStoreError, match="temporarily unavailable"): + store.list(limit=10) + + +def test_missing_security_activity_encryption_key_fails_with_store_error( + monkeypatch, tmp_path +): + monkeypatch.delenv("STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEY", raising=False) + + with pytest.raises(SecurityEventStoreError, match="temporarily unavailable"): + SecurityEventStore(tmp_path / "security.sqlite3", clock=lambda: 1_000) def test_security_events_are_private_bounded_and_reverse_chronological(tmp_path): @@ -31,7 +144,7 @@ def test_security_events_are_private_bounded_and_reverse_chronological(tmp_path) row[1] for row in sqlite3.connect(store.path).execute("PRAGMA table_info(security_events)") } assert columns == { - "id", "kind", "method", "device_label", "target", "created_at", + "id", "kind", "method", "device_label", "target", "payload", "created_at", "status", "operation_id", }