security: remove plaintext journal metadata columns

This commit is contained in:
timmy 2026-08-19 07:37:35 +00:00
parent 7f2681c1c6
commit be9ce8ede6
2 changed files with 43 additions and 24 deletions

View File

@ -75,10 +75,6 @@ class SecurityEventStore:
""" """
CREATE TABLE IF NOT EXISTS security_events ( CREATE TABLE IF NOT EXISTS security_events (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT,
method TEXT,
device_label TEXT,
target TEXT,
payload TEXT, payload TEXT,
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'completed', status TEXT NOT NULL DEFAULT 'completed',
@ -97,8 +93,11 @@ class SecurityEventStore:
connection.execute( connection.execute(
"ALTER TABLE security_events ADD COLUMN operation_id TEXT" "ALTER TABLE security_events ADD COLUMN operation_id TEXT"
) )
if "payload" not in columns: plaintext_columns = {"kind", "method", "device_label", "target"}
self._migrate_plaintext(connection) if "payload" not in columns or plaintext_columns.intersection(columns):
self._migrate_plaintext(
connection, has_payload="payload" in columns
)
connection.execute( connection.execute(
"CREATE INDEX IF NOT EXISTS security_events_created " "CREATE INDEX IF NOT EXISTS security_events_created "
"ON security_events(created_at DESC, id DESC)" "ON security_events(created_at DESC, id DESC)"
@ -113,20 +112,19 @@ class SecurityEventStore:
"Security activity is temporarily unavailable" "Security activity is temporarily unavailable"
) from exc ) from exc
def _migrate_plaintext(self, connection: sqlite3.Connection) -> None: def _migrate_plaintext(
self, connection: sqlite3.Connection, *, has_payload: bool
) -> None:
connection.execute("PRAGMA secure_delete = ON") connection.execute("PRAGMA secure_delete = ON")
payload_column = "payload," if has_payload else "NULL AS payload,"
rows = connection.execute( rows = connection.execute(
"SELECT id, kind, method, device_label, target, created_at, status, operation_id " f"SELECT id, {payload_column} kind, method, device_label, target, "
"FROM security_events ORDER BY id" "created_at, status, operation_id FROM security_events ORDER BY id"
).fetchall() ).fetchall()
connection.execute( connection.execute(
""" """
CREATE TABLE security_events_encrypted ( CREATE TABLE security_events_encrypted (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT,
method TEXT,
device_label TEXT,
target TEXT,
payload TEXT, payload TEXT,
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'completed', status TEXT NOT NULL DEFAULT 'completed',
@ -136,6 +134,7 @@ class SecurityEventStore:
) )
for ( for (
event_id, event_id,
payload,
kind, kind,
method, method,
device_label, device_label,
@ -149,7 +148,10 @@ class SecurityEventStore:
"(id, payload, created_at, status, operation_id) VALUES (?, ?, ?, ?, ?)", "(id, payload, created_at, status, operation_id) VALUES (?, ?, ?, ?, ?)",
( (
event_id, event_id,
self._seal_event(event_id, kind, method, device_label, target), payload
or self._seal_event(
event_id, kind, method, device_label, target
),
created_at, created_at,
status, status,
operation_id, operation_id,

View File

@ -8,6 +8,26 @@ from src.security_event_store import SecurityEventStore, SecurityEventStoreError
PRIVATE_KEY = b"e" * 32 PRIVATE_KEY = b"e" * 32
def test_security_event_schema_exposes_no_plaintext_metadata_columns(tmp_path):
store = SecurityEventStore(
tmp_path / "security.sqlite3", clock=lambda: 1_000, encryption_key=PRIVATE_KEY
)
store.record("issue_closed", method="passkey", target="private/repo#42")
with sqlite3.connect(store.path) as connection:
columns = {
row[1]
for row in connection.execute("PRAGMA table_info(security_events)")
}
assert columns == {
"id",
"payload",
"created_at",
"status",
"operation_id",
}
def test_security_event_payload_is_encrypted_at_rest_and_survives_restart(tmp_path): def test_security_event_payload_is_encrypted_at_rest_and_survives_restart(tmp_path):
path = tmp_path / "security.sqlite3" path = tmp_path / "security.sqlite3"
canaries = { canaries = {
@ -21,11 +41,10 @@ def test_security_event_payload_is_encrypted_at_rest_and_survives_restart(tmp_pa
store.record(**canaries) store.record(**canaries)
with sqlite3.connect(path) as connection: with sqlite3.connect(path) as connection:
row = connection.execute( payload = connection.execute(
"SELECT kind, method, device_label, target, payload FROM security_events" "SELECT payload FROM security_events"
).fetchone() ).fetchone()[0]
assert row[:4] == (None, None, None, None) assert payload.startswith("v1:")
assert row[4].startswith("v1:")
database_bytes = path.read_bytes() database_bytes = path.read_bytes()
assert all(value.encode() not in database_bytes for value in canaries.values()) assert all(value.encode() not in database_bytes for value in canaries.values())
reopened = SecurityEventStore(path, clock=lambda: 1_001, encryption_key=PRIVATE_KEY) reopened = SecurityEventStore(path, clock=lambda: 1_001, encryption_key=PRIVATE_KEY)
@ -74,11 +93,10 @@ def test_legacy_security_events_migrate_without_changing_journal_semantics(tmp_p
assert store.list(limit=1).events[0].status == "completed" assert store.list(limit=1).events[0].status == "completed"
with sqlite3.connect(path) as connection: with sqlite3.connect(path) as connection:
rows = connection.execute( rows = connection.execute(
"SELECT id, kind, method, device_label, target, payload FROM security_events ORDER BY id" "SELECT id, payload FROM security_events ORDER BY id"
).fetchall() ).fetchall()
assert [row[0] for row in rows] == [7, 9] 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[1].startswith("v1:") for row in rows)
assert all(row[5].startswith("v1:") for row in rows)
database_bytes = path.read_bytes() database_bytes = path.read_bytes()
assert b"legacy_sign_in_canary" not in database_bytes assert b"legacy_sign_in_canary" not in database_bytes
assert b"Legacy Tablet Canary" not in database_bytes assert b"Legacy Tablet Canary" not in database_bytes
@ -144,8 +162,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)") row[1] for row in sqlite3.connect(store.path).execute("PRAGMA table_info(security_events)")
} }
assert columns == { assert columns == {
"id", "kind", "method", "device_label", "target", "payload", "created_at", "id", "payload", "created_at", "status", "operation_id",
"status", "operation_id",
} }