security: encrypt Security activity journal (Closes #1118)
This commit is contained in:
1 parent
91fdd958c2
commit
7f2681c1c6
4 files changed
+288
-28
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user