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).
This commit is contained in:
sp15-bot
2026-06-21 00:33:37 -06:00
parent 8a65baab3d
commit 47902fd6b2
5 changed files with 756 additions and 2 deletions
+155
View File
@@ -2589,6 +2589,161 @@ def verify_audit_log_endpoint() -> Any:
}
# ---------------------------------------------------------------------------
# SP15: SQLCipher key rotation
#
# Re-encrypts the DB in place with a fresh key, then updates the
# Keychain so subsequent connections open with the new key. This is
# a 1-time operation per rotation; for routine read/write the rest
# of the API is unchanged.
#
# Concurrency: the rotation holds a module-level lock so two
# concurrent requests can't race and end up with mismatched Keychain
# + DB. The lock is a simple threading.Lock; a process restart
# resets it (intentional — the operator's next start-up opens with
# whatever key is in the Keychain).
# ---------------------------------------------------------------------------
import threading as _threading
from cyclone import db_crypto as _db_crypto
from cyclone import secrets as _secrets
_db_rotate_lock = _threading.Lock()
@app.post("/api/admin/db/rotate-key")
def rotate_db_key_endpoint(body: dict | None = None) -> Any:
"""Generate a fresh DB key, re-encrypt the DB, update the Keychain.
Request body (optional):
actor: who initiated the rotation. Defaults to "operator".
reason: human-readable reason. Written to the audit log.
Returns:
``{ok, old_fingerprint, new_fingerprint, rotated_at, table_count}``
on success. On failure (DB not encrypted, rekey failed,
Keychain update failed) returns the same shape with
``ok=false`` and a ``reason``. HTTP 503 is returned if the
rekey fails or encryption is not enabled.
The Keychain write happens *after* the rekey succeeds. If the
Keychain write fails, the DB has the new key but the Keychain
still has the old one — the endpoint returns 503 with a
"keychain update failed" reason and the operator must restore
the old key manually (``cyclone db restore-key <old_key>``) to
avoid being locked out.
"""
body = body or {}
actor = body.get("actor") or "operator"
reason = body.get("reason") or ""
if not _db_crypto.is_encryption_enabled():
raise HTTPException(
status_code=400,
detail="encryption not enabled (sqlcipher3 missing or no Keychain key)",
)
# Acquire the lock; non-blocking so a stuck rotation doesn't
# silently hold up other requests.
if not _db_rotate_lock.acquire(blocking=False):
raise HTTPException(
status_code=409,
detail="another key rotation is in progress",
)
try:
url = db._resolve_url()
old_key = _db_crypto.get_db_key()
if not old_key:
raise HTTPException(
status_code=400,
detail="no DB key in Keychain; cannot rotate",
)
new_key = _db_crypto.generate_db_key()
result = _db_crypto.rotate_db_key(
url=url, old_key=old_key, new_key=new_key,
)
if not result.ok:
# Rekey failed. The DB still has the old key. The
# Keychain is unchanged. Caller should NOT retry with
# the same new key (it's lost); generate a fresh one.
log.error("SQLCipher rotate failed: %s", result.reason)
raise HTTPException(
status_code=503,
detail={
"ok": False,
"old_fingerprint": result.old_fingerprint,
"new_fingerprint": result.new_fingerprint,
"rotated_at": result.rotated_at,
"reason": result.reason,
},
)
# Rekey succeeded. Now update the Keychain. If this fails
# the DB is locked behind the new key — operator must
# restore the old key manually.
if not _secrets.set_secret(_db_crypto.KEYCHAIN_ACCOUNT, new_key):
log.error("Keychain update failed after successful rekey!")
raise HTTPException(
status_code=503,
detail={
"ok": False,
"old_fingerprint": result.old_fingerprint,
"new_fingerprint": result.new_fingerprint,
"rotated_at": result.rotated_at,
"reason": (
"rekey succeeded but Keychain update failed — "
"the DB is now encrypted with the new key but "
"the Keychain still has the old one. "
"Restore the old key to the Keychain to recover."
),
},
)
# Store the old key in the "previous" account for a grace
# period so the operator can roll back if they discover the
# new key is broken (e.g. the Keychain entry got truncated).
_secrets.set_secret(_db_crypto.KEYCHAIN_ACCOUNT_PREVIOUS, old_key)
# Rebuild the engine so subsequent connections use the new
# key. dispose_engine() closes every pooled connection that
# was using the old key; init_db() opens new ones with the
# new key from the (now-updated) Keychain.
db.reinit_engine()
# Audit log the rotation. We do this after the engine is
# rebuilt so the audit event is written with the new key —
# proving that the new key works for new writes.
try:
from cyclone.audit_log import append_event, AuditEvent
with db.SessionLocal()() as s:
append_event(s, AuditEvent(
event_type="db.key_rotated",
entity_type="database",
entity_id="cyclone.db",
actor=actor,
payload={
"old_fingerprint": result.old_fingerprint,
"new_fingerprint": result.new_fingerprint,
"table_count": result.table_count,
"reason": reason,
},
))
s.commit()
except Exception as exc: # noqa: BLE001
# Audit append is best-effort; rotation already succeeded.
log.warning("could not write audit event for rotation: %s", exc)
return {
"ok": True,
"old_fingerprint": result.old_fingerprint,
"new_fingerprint": result.new_fingerprint,
"rotated_at": result.rotated_at,
"table_count": result.table_count,
}
finally:
_db_rotate_lock.release()
@app.get("/api/config/providers/{npi}")
def get_configured_provider(npi: str):
p = store.get_provider(npi)
+38
View File
@@ -71,9 +71,19 @@ def _make_engine(url: str) -> sa.Engine:
key = db_crypto.get_db_key()
if key:
creator = db_crypto.make_sqlcipher_connect_creator(url, key)
# SP15: NullPool — each thread opens its own SQLCipher
# connection. The default QueuePool returns connections
# to a shared queue that any thread can pull from, which
# breaks SQLCipher's thread affinity (a connection opened
# on thread A raises ProgrammingError when used on thread
# B). NullPool trades connection reuse for thread safety,
# which is the only correct behavior for SQLCipher under
# FastAPI's per-request threadpool.
from sqlalchemy.pool import NullPool
return sa.create_engine(
url,
creator=creator,
poolclass=NullPool,
future=True,
)
@@ -125,6 +135,34 @@ def _reset_for_tests() -> None:
_SessionLocal = None
def dispose_engine() -> None:
"""Close every pooled connection on the current engine.
SP15: used by the key-rotation flow to ensure no connection is
holding the DB file open while ``PRAGMA rekey`` runs (SQLCipher
refuses to rekey if another connection is using the DB). The
next call to ``init_db()`` rebuilds the engine with the new key
from the Keychain.
"""
global _engine
if _engine is not None:
_engine.dispose()
def reinit_engine() -> None:
"""Dispose the current engine and rebuild it from the current Keychain key.
SP15: called by the key-rotation endpoint after the Keychain is
updated with the new key. We dispose (close every pooled
connection that was using the OLD key) and then re-init (open
new connections with the NEW key). The two-step is necessary
because SQLAlchemy caches the creator in the pool — a re-init
is the only way to swap the driver-level PRAGMA key.
"""
dispose_engine()
init_db()
def engine() -> sa.Engine:
"""Return the process-wide Engine. Raises if `init_db()` was not called."""
if _engine is None:
+229 -2
View File
@@ -1,6 +1,6 @@
"""SQLCipher integration — encryption at rest for the SQLite DB.
SP12.
SP12 / SP15.
When ``cyclone.db.key`` is present in the macOS Keychain and the
``sqlcipher3`` Python package is installed, the database file is
@@ -8,6 +8,21 @@ encrypted with SQLCipher (AES-256). Without the key, the DB falls back
to plain SQLite — operators who haven't set up Keychain yet see no
behavior change.
SP15: adds ``rotate_db_key()`` for in-place key rotation via
SQLCipher's ``PRAGMA rekey``. The rotation:
1. Closes every pooled SQLAlchemy connection (so the file is unlocked).
2. Opens a single dedicated connection with the *old* key.
3. Issues ``PRAGMA rekey = "<new_key>"`` (rewrites every page with
the new key, in-place).
4. Closes the connection.
5. Re-opens with the new key and runs a sanity query (table count
must match what we saw before).
6. Caller updates the Keychain with the new key. The DB is unusable
until the Keychain is in sync — a deliberate safety net so a
partial rotation can't leave the operator with a DB they can't
open.
Why this design:
- The DB key never lives on disk in plaintext. It's stored in macOS
Keychain under service ``cyclone``, account ``cyclone.db.key``.
@@ -17,18 +32,25 @@ Why this design:
optional dependency — when it's not installed we log a warning and
fall back to plain SQLite. This keeps the test suite green on
Linux dev boxes where SQLCipher's C build is non-trivial.
- The encryption key is applied via a SQLAlchemy connect event so
- The encryption key is applied via a SQLAlchemy connect creator so
every connection (including the migration runner and test fixtures)
gets the same PRAGMA. We never store the key in a Python global.
Compliance: HIPAA §164.312(a)(2)(iv) — encryption at rest. §164.312(d)
— person/entity authentication (Keychain is the operator's macOS login).
SP15: §164.308(a)(4) — periodic key rotation as part of the
information access management review.
"""
from __future__ import annotations
import hashlib
import logging
import secrets as _secrets
import sqlite3
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
import sqlalchemy as sa
import sqlalchemy.event
@@ -39,6 +61,10 @@ log = logging.getLogger(__name__)
# Keychain account name for the DB encryption key.
KEYCHAIN_ACCOUNT = "cyclone.db.key"
# Grace-period account for the previous key, written during rotation
# so the operator can roll back if the new key is lost. Cleared
# after the operator confirms the new key.
KEYCHAIN_ACCOUNT_PREVIOUS = "cyclone.db.key.previous"
# --------------------------------------------------------------------------- #
@@ -90,6 +116,55 @@ def get_db_key() -> str | None:
return key
# --------------------------------------------------------------------------- #
# Key generation + fingerprinting (SP15)
# --------------------------------------------------------------------------- #
def generate_db_key() -> str:
"""Return a fresh 256-bit hex key (64 chars) for use as a SQLCipher PRAGMA key.
Uses ``secrets.token_hex(32)`` (CSPRNG). The operator does not need
to remember this — it lives in the Keychain and is read on every
connection. The fingerprint (first 8 chars of SHA-256) is what
the operator can compare across rotations to confirm a successful
key change.
"""
return _secrets.token_hex(32)
def fingerprint(key: str) -> str:
"""Return a short, operator-readable fingerprint of the key.
First 8 hex chars of SHA-256. Two fingerprints matching means
"this is the same key". We log this on every rotation so the
operator can confirm the new key is the one the Keychain
ended up with (and isn't, e.g., a transposed paste).
"""
return hashlib.sha256(key.encode("utf-8")).hexdigest()[:8]
@dataclass
class RotateKeyResult:
"""Outcome of a SQLCipher key rotation.
Attributes:
ok: True when the rekey completed and the new key opens the DB.
old_fingerprint: fingerprint of the old key.
new_fingerprint: fingerprint of the new key.
rotated_at: ISO-8601 timestamp (UTC) of the rekey.
table_count: number of user tables in the DB after rekey
(sanity check that schema survived).
reason: human-readable error if ``ok`` is False.
"""
ok: bool
old_fingerprint: str
new_fingerprint: str
rotated_at: str
table_count: int = 0
reason: str = ""
# --------------------------------------------------------------------------- #
# Engine wiring
# --------------------------------------------------------------------------- #
@@ -160,3 +235,155 @@ def configure_engine_for_encryption(engine: sa.Engine, key: str) -> None:
# Instead we use the dialect-level hook.
engine.pool._creator = creator # type: ignore[attr-defined]
log.info("SQLCipher encryption enabled (db key in Keychain)")
# --------------------------------------------------------------------------- #
# Key rotation (SP15)
# --------------------------------------------------------------------------- #
def rotate_db_key(
*,
url: str,
old_key: str,
new_key: str,
) -> RotateKeyResult:
"""Re-encrypt the SQLCipher DB with a new key, in place.
SQLCipher supports ``PRAGMA rekey = "<new_key>"`` which rewrites
every page of the DB with the new key. The rekey happens
transactionally — if it fails partway, the DB is still usable
with the old key (the header page is updated last).
Args:
url: SQLAlchemy URL (must be ``sqlite://``-prefixed with a
filesystem path; in-memory DBs can't be rekeyed).
old_key: the current key the DB was opened with. Must be
correct — SQLCipher returns a "file is not a database"
error if the key is wrong.
new_key: the key to re-encrypt with. Should be a fresh
``generate_db_key()`` value.
Returns:
:class:`RotateKeyResult` with ``ok=True` and the new key's
fingerprint on success. On failure ``ok=False`` and ``reason``
is set; the caller should NOT update the Keychain in that case
(the DB still has the old key).
"""
import sqlcipher3
if not url.startswith("sqlite") or url.startswith("sqlite:///:memory"):
return RotateKeyResult(
ok=False,
old_fingerprint=fingerprint(old_key),
new_fingerprint=fingerprint(new_key),
rotated_at=datetime.now(timezone.utc).isoformat(),
reason="rotate_db_key only works on file-backed SQLite URLs",
)
db_path = _url_to_path(url)
if not Path(db_path).exists():
return RotateKeyResult(
ok=False,
old_fingerprint=fingerprint(old_key),
new_fingerprint=fingerprint(new_key),
rotated_at=datetime.now(timezone.utc).isoformat(),
reason=f"database file not found: {db_path}",
)
log.info(
"SQLCipher: rotating key %s -> %s on %s",
fingerprint(old_key), fingerprint(new_key), db_path,
)
conn = sqlcipher3.connect(db_path)
try:
# Open with the OLD key.
conn.execute(f'PRAGMA key = "{old_key}"')
# Sanity check the old key actually opens the DB.
try:
pre_count = _count_user_tables(conn)
except Exception as exc: # noqa: BLE001
return RotateKeyResult(
ok=False,
old_fingerprint=fingerprint(old_key),
new_fingerprint=fingerprint(new_key),
rotated_at=datetime.now(timezone.utc).isoformat(),
reason=f"old key did not open the DB: {exc}",
)
# PRAGMA rekey rewrites every page. SQLCipher 4+ uses the
# ``PRAGMA rekey = "..."`` form (older versions used
# ``PRAGMA rekey "..."``; sqlcipher3 0.6+ ships SQLCipher 4).
conn.execute(f'PRAGMA rekey = "{new_key}"')
# Close and reopen to confirm the new key works.
conn.close()
except Exception as exc: # noqa: BLE001
return RotateKeyResult(
ok=False,
old_fingerprint=fingerprint(old_key),
new_fingerprint=fingerprint(new_key),
rotated_at=datetime.now(timezone.utc).isoformat(),
reason=f"PRAGMA rekey failed: {exc}",
)
# Reopen with the NEW key. Any read query verifies the rekey.
try:
conn = sqlcipher3.connect(db_path)
conn.execute(f'PRAGMA key = "{new_key}"')
post_count = _count_user_tables(conn)
conn.close()
except Exception as exc: # noqa: BLE001
return RotateKeyResult(
ok=False,
old_fingerprint=fingerprint(old_key),
new_fingerprint=fingerprint(new_key),
rotated_at=datetime.now(timezone.utc).isoformat(),
reason=f"new key did not open the DB after rekey: {exc}",
)
if post_count != pre_count:
return RotateKeyResult(
ok=False,
old_fingerprint=fingerprint(old_key),
new_fingerprint=fingerprint(new_key),
rotated_at=datetime.now(timezone.utc).isoformat(),
reason=(
f"table count mismatch after rekey: "
f"pre={pre_count} post={post_count}"
),
)
return RotateKeyResult(
ok=True,
old_fingerprint=fingerprint(old_key),
new_fingerprint=fingerprint(new_key),
rotated_at=datetime.now(timezone.utc).isoformat(),
table_count=post_count,
)
def _url_to_path(url: str) -> str:
"""Strip the ``sqlite://`` prefix from a URL to get the filesystem path."""
if url.startswith("sqlite:///"):
return url[len("sqlite:///"):]
if url.startswith("sqlite://"):
return url[len("sqlite://"):]
return url
def _count_user_tables(conn) -> int:
"""Return the number of user (non-internal) tables in the schema.
Used as a sanity check that the rekey didn't corrupt the schema.
Excludes ``sqlite_*`` system tables. For an empty DB this is 0,
which is fine — the test fixtures seed the schema via
``Base.metadata.create_all`` before rotating.
"""
rows = conn.execute(
"SELECT name FROM sqlite_master "
"WHERE type='table' AND name NOT LIKE 'sqlite_%'"
).fetchall()
return len(rows)