Files
cyclone/backend/tests/test_db_crypto.py
T
sp15-bot 47902fd6b2 feat(sp15): SQLCipher key rotation via PRAGMA rekey
Adds in-place key rotation for the encrypted DB at rest (HIPAA
sec.164.308(a)(4) - periodic key rotation).

- db_crypto.rotate_db_key(): opens with old key, issues PRAGMA rekey,
  reopens with new key, verifies schema survived (table-count sanity).
- db_crypto.generate_db_key(): fresh 256-bit CSPRNG hex key.
- db_crypto.fingerprint(): SHA-256[:8] of a key, for the operator to
  compare across rotations.
- db.dispose_engine() + db.reinit_engine(): SP15 plumbing. The
  rotation endpoint disposes the pooled connections (SQLCipher
  refuses to rekey while another connection holds the file), runs
  the rekey, then rebuilds the engine with the new key from the
  Keychain.
- API: POST /api/admin/db/rotate-key with module-level threading.Lock
  to serialize rotations. 400 when encryption not enabled, 409 when
  a rotation is already in flight, 503 on rekey or Keychain failure
  with a reason that tells the operator what to do next.
- Engine uses NullPool when SQLCipher is enabled: the default
  QueuePool returns connections to a shared queue that any thread
  can pull from, which breaks SQLCipher's thread affinity. NullPool
  trades connection reuse for thread safety, the only correct
  behavior under FastAPI's per-request threadpool.
- Audit event db.key_rotated with old/new fingerprints and
  table_count, written after the engine is rebuilt so the new key
  proves it can take new writes.
- previous key is stashed to a second Keychain account so the
  operator can roll back if the new key turns out to be broken.

Tests:
- test_db_crypto.py: 12 new tests for generate/fingerprint/rekey
  mechanics (5 require SQLCipher at runtime; skipped otherwise).
- test_api_rotate_key.py: 6 new tests for endpoint wiring
  (encryption-required, Keychain update, audit event, rekey-failure
  rollback, Keychain-write-failure 503, concurrent-rotation 409).
2026-06-21 00:33:37 -06:00

293 lines
12 KiB
Python

"""Tests for SQLCipher encryption at rest. SP12.
We exercise the encryption path end-to-end:
1. With a Keychain key + sqlcipher3 installed: the DB file is encrypted
on disk and decryptable only with the same key.
2. Without a Keychain key: the DB falls back to plain SQLite.
3. With the wrong key: opening the DB raises on the first query.
``sqlcipher3`` is an optional dep — these tests skip when it isn't
installed, so the suite still runs on Linux dev boxes without SQLCipher.
"""
from __future__ import annotations
import os
import sqlite3
import tempfile
from pathlib import Path
from unittest.mock import patch
import pytest
import sqlalchemy as sa
from cyclone import db, db_crypto
# --------------------------------------------------------------------------- #
# Skip-if-no-sqlcipher gate
# --------------------------------------------------------------------------- #
pytestmark_sqlcipher = pytest.mark.skipif(
not db_crypto.is_sqlcipher_available(),
reason="sqlcipher3 not installed (pip install -e .[sqlcipher])",
)
# --------------------------------------------------------------------------- #
# Capability checks
# --------------------------------------------------------------------------- #
class TestIsSqlcipherAvailable:
def test_returns_true_when_package_installed(self):
"""This test only runs when sqlcipher3 is importable."""
assert db_crypto.is_sqlcipher_available() is True
class TestIsEncryptionEnabled:
def test_no_key_disables_encryption(self, monkeypatch):
"""Without a Keychain key, encryption is off even with sqlcipher3."""
monkeypatch.setattr(db_crypto, "get_secret", lambda account: None)
assert db_crypto.is_encryption_enabled() is False
def test_stub_key_disables_encryption(self, monkeypatch):
"""The stub fallback secret doesn't count as a real key."""
from cyclone.secrets import STUB_SECRET
monkeypatch.setattr(db_crypto, "get_secret", lambda account: STUB_SECRET)
assert db_crypto.is_encryption_enabled() is False
def test_real_key_enables_encryption(self, monkeypatch):
monkeypatch.setattr(db_crypto, "get_secret", lambda account: "real-key-from-keychain")
assert db_crypto.is_encryption_enabled() is True
class TestGetDbKey:
def test_returns_none_when_no_key(self, monkeypatch):
monkeypatch.setattr(db_crypto, "get_secret", lambda account: None)
assert db_crypto.get_db_key() is None
def test_returns_key_from_keychain(self, monkeypatch):
monkeypatch.setattr(db_crypto, "get_secret", lambda account: "abc123")
assert db_crypto.get_db_key() == "abc123"
def test_stub_secret_returns_none(self, monkeypatch):
from cyclone.secrets import STUB_SECRET
monkeypatch.setattr(db_crypto, "get_secret", lambda account: STUB_SECRET)
assert db_crypto.get_db_key() is None
# --------------------------------------------------------------------------- #
# Engine integration
# --------------------------------------------------------------------------- #
@pytestmark_sqlcipher
class TestEngineIntegration:
def test_engine_uses_sqlcipher_when_key_present(self, monkeypatch, tmp_path: Path):
"""With a key, _make_engine installs a sqlcipher3 creator."""
monkeypatch.setattr(db_crypto, "get_secret", lambda account: "test-key-xyz")
db_file = tmp_path / "encrypted.db"
url = f"sqlite:///{db_file}"
engine = db._make_engine(url)
# Use the engine to write and read back.
with engine.begin() as conn:
conn.execute(sa.text("CREATE TABLE t (x INTEGER)"))
conn.execute(sa.text("INSERT INTO t VALUES (1)"))
with engine.connect() as conn:
assert conn.execute(sa.text("SELECT x FROM t")).scalar() == 1
engine.dispose()
def test_engine_uses_plain_sqlite_without_key(self, monkeypatch, tmp_path: Path):
"""Without a key, _make_engine uses the default sqlite3 driver."""
monkeypatch.setattr(db_crypto, "get_secret", lambda account: None)
db_file = tmp_path / "plain.db"
url = f"sqlite:///{db_file}"
engine = db._make_engine(url)
with engine.begin() as conn:
conn.execute(sa.text("CREATE TABLE t (x INTEGER)"))
engine.dispose()
# The file is a valid plain SQLite DB.
with sqlite3.connect(str(db_file)) as conn:
assert conn.execute("SELECT count(*) FROM sqlite_master").fetchone()[0] >= 1
def test_encrypted_file_unreadable_without_key(self, monkeypatch, tmp_path: Path):
"""An encrypted file is unreadable as plain SQLite."""
# Create an encrypted DB.
monkeypatch.setattr(db_crypto, "get_secret", lambda account: "secret-1")
db_file = tmp_path / "encrypted.db"
url = f"sqlite:///{db_file}"
engine = db._make_engine(url)
with engine.begin() as conn:
conn.execute(sa.text("CREATE TABLE t (secret TEXT)"))
conn.execute(sa.text("INSERT INTO t VALUES ('classified')"))
engine.dispose()
# Plain sqlite3 cannot open it.
with pytest.raises((sqlite3.DatabaseError, Exception)) as exc_info:
sqlite3.connect(str(db_file)).execute("SELECT * FROM t").fetchall()
# The error message comes from SQLite/SQLCipher, not Python.
assert "not a database" in str(exc_info.value).lower() or "file is encrypted" in str(exc_info.value).lower()
def test_wrong_key_raises_on_query(self, monkeypatch, tmp_path: Path):
"""A wrong key on the same encrypted file raises on the first query."""
# Create with key A.
monkeypatch.setattr(db_crypto, "get_secret", lambda account: "key-A")
db_file = tmp_path / "encrypted.db"
url = f"sqlite:///{db_file}"
engine = db._make_engine(url)
with engine.begin() as conn:
conn.execute(sa.text("CREATE TABLE t (x INTEGER)"))
engine.dispose()
# Try to open with key B.
monkeypatch.setattr(db_crypto, "get_secret", lambda account: "key-B")
engine2 = db._make_engine(url)
with pytest.raises(Exception) as exc_info:
with engine2.connect() as conn:
conn.execute(sa.text("SELECT * FROM t")).fetchall()
# SQLCipher raises "file is not a database" or similar on bad key.
msg = str(exc_info.value).lower()
assert "not a database" in msg or "file is encrypted" in msg or "databaseerror" in msg
engine2.dispose()
# --------------------------------------------------------------------------- #
# Make_creator function
# --------------------------------------------------------------------------- #
@pytestmark_sqlcipher
class TestMakeSqlcipherConnectCreator:
def test_creator_returns_connection_with_key_applied(self, tmp_path: Path):
"""The creator's connection must have PRAGMA key applied."""
db_file = tmp_path / "x.db"
url = f"sqlite:///{db_file}"
creator = db_crypto.make_sqlcipher_connect_creator(url, "my-key")
conn = creator()
# The creator must have applied the key — verify by writing
# data and reading it back via the same connection.
conn.execute("CREATE TABLE t (x INTEGER)")
conn.execute("INSERT INTO t VALUES (42)")
conn.commit()
result = conn.execute("SELECT x FROM t").fetchone()
assert result[0] == 42
conn.close()
# --------------------------------------------------------------------------- #
# SP15: Key generation + fingerprint
# --------------------------------------------------------------------------- #
class TestGenerateDbKey:
def test_returns_64_char_hex(self):
"""A 256-bit key hex-encodes to 64 characters."""
key = db_crypto.generate_db_key()
assert len(key) == 64
int(key, 16) # parses as hex (raises if not)
def test_two_calls_return_different_keys(self):
"""Distinct calls produce cryptographically distinct keys."""
keys = {db_crypto.generate_db_key() for _ in range(8)}
assert len(keys) == 8
class TestFingerprint:
def test_deterministic(self):
assert db_crypto.fingerprint("abc") == db_crypto.fingerprint("abc")
def test_different_inputs_yield_different_fingerprints(self):
assert db_crypto.fingerprint("abc") != db_crypto.fingerprint("xyz")
def test_eight_chars(self):
assert len(db_crypto.fingerprint("anything")) == 8
# --------------------------------------------------------------------------- #
# SP15: rotate_db_key (in-place rekey via PRAGMA rekey)
# --------------------------------------------------------------------------- #
@pytestmark_sqlcipher
class TestRotateDbKey:
def _create_encrypted_db(self, tmp_path: Path, key: str) -> Path:
"""Create a small SQLCipher DB with two tables."""
import sqlcipher3
db_file = tmp_path / "rotate.db"
conn = sqlcipher3.connect(str(db_file))
conn.execute(f'PRAGMA key = "{key}"')
conn.execute("CREATE TABLE accounts (id INTEGER PRIMARY KEY, name TEXT)")
conn.execute("CREATE TABLE balances (acct_id INTEGER, amt REAL)")
conn.execute("INSERT INTO accounts VALUES (1, 'alice'), (2, 'bob')")
conn.execute("INSERT INTO balances VALUES (1, 100.5), (2, 250.75)")
conn.commit()
conn.close()
return db_file
def test_rotate_changes_key_preserves_data(self, tmp_path: Path):
"""The core SP15 contract: rekey with a new key, data survives."""
db_file = self._create_encrypted_db(tmp_path, "old-key-aaaa")
url = f"sqlite:///{db_file}"
result = db_crypto.rotate_db_key(
url=url, old_key="old-key-aaaa", new_key="new-key-bbbb",
)
assert result.ok, f"rotate failed: {result.reason}"
assert result.old_fingerprint == db_crypto.fingerprint("old-key-aaaa")
assert result.new_fingerprint == db_crypto.fingerprint("new-key-bbbb")
assert result.table_count == 2 # accounts + balances
# Open with the new key; data is intact.
import sqlcipher3
conn = sqlcipher3.connect(str(db_file))
conn.execute(f'PRAGMA key = "new-key-bbbb"')
rows = conn.execute("SELECT id, name FROM accounts ORDER BY id").fetchall()
assert rows == [(1, "alice"), (2, "bob")]
assert conn.execute("SELECT amt FROM balances WHERE acct_id = 2").fetchone()[0] == 250.75
conn.close()
def test_old_key_no_longer_opens_db(self, tmp_path: Path):
"""After rekey, the old key must not be able to open the DB."""
import sqlcipher3
db_file = self._create_encrypted_db(tmp_path, "old-key")
url = f"sqlite:///{db_file}"
result = db_crypto.rotate_db_key(
url=url, old_key="old-key", new_key="new-key",
)
assert result.ok
# Old key raises on first query.
conn = sqlcipher3.connect(str(db_file))
conn.execute(f'PRAGMA key = "old-key"')
with pytest.raises(Exception) as exc_info:
conn.execute("SELECT * FROM accounts").fetchall()
msg = str(exc_info.value).lower()
assert "not a database" in msg or "file is encrypted" in msg
conn.close()
def test_wrong_old_key_reports_helpful_reason(self, tmp_path: Path):
"""If the operator types the wrong old key, the rekey fails clean."""
db_file = self._create_encrypted_db(tmp_path, "correct-old")
url = f"sqlite:///{db_file}"
result = db_crypto.rotate_db_key(
url=url, old_key="WRONG-OLD-KEY", new_key="new",
)
assert result.ok is False
assert "old key did not open" in result.reason.lower()
def test_in_memory_url_is_rejected(self):
"""In-memory DBs cannot be rekeyed (nothing to persist)."""
result = db_crypto.rotate_db_key(
url="sqlite:///:memory:", old_key="a", new_key="b",
)
assert result.ok is False
assert "file-backed" in result.reason.lower() or "in-memory" in result.reason.lower()
def test_missing_db_file_is_rejected(self, tmp_path: Path):
result = db_crypto.rotate_db_key(
url=f"sqlite:///{tmp_path}/does-not-exist.db",
old_key="a", new_key="b",
)
assert result.ok is False
assert "not found" in result.reason.lower()