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:
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user