Aug 21 - Fernet key rotation

This commit is contained in:
2026-08-21 10:18:22 -04:00
parent cb7c244872
commit d04190ba09
5 changed files with 578 additions and 15 deletions
+247
View File
@@ -0,0 +1,247 @@
"""
tests/test_fernet_rotation.py
-----------------------------
MT-23: control-plane Fernet key rotation.
The property that matters is continuity of access: at no point during a
rotation may a stored tenant credential become unreadable. A key change that
leaves one row encrypted under a discarded key locks that tenant out of its
own database, and nothing surfaces it until the next request for that tenant.
These tests exercise the rotation the way the runbook does — old key alone,
then both keys, then new key alone — and assert the credential survives each
step. The last of those is the one that catches a premature retirement.
`control.crypto` reads its keys from the environment on every call, so the
fixtures set os.environ directly rather than patching the module.
"""
import os
import pytest
from cryptography.fernet import Fernet, InvalidToken
from control import crypto
KEY_A = Fernet.generate_key().decode() # "old" key
KEY_B = Fernet.generate_key().decode() # "new" key
KEY_C = Fernet.generate_key().decode() # a key never used to encrypt
@pytest.fixture
def clean_env():
"""Isolate the crypto env vars and restore whatever was there."""
saved = {k: os.environ.get(k) for k in (
'CONTROL_FERNET_KEY', 'CONTROL_FERNET_KEYS_OLD',
'CONTROL_FERNET_KEY_VERSION',
)}
try:
yield
finally:
for k, v in saved.items():
if v is None:
os.environ.pop(k, None)
else:
os.environ[k] = v
def _configure(primary, old=(), version=1):
os.environ['CONTROL_FERNET_KEY'] = primary
os.environ['CONTROL_FERNET_KEYS_OLD'] = ','.join(old)
os.environ['CONTROL_FERNET_KEY_VERSION'] = str(version)
# ── Baseline behaviour ───────────────────────────────────────────────────────
def test_roundtrip_with_a_single_key(clean_env):
_configure(KEY_A)
token = crypto.encrypt('s3cret-pw')
assert token != 's3cret-pw'
assert crypto.decrypt(token) == 's3cret-pw'
def test_none_passes_through(clean_env):
_configure(KEY_A)
assert crypto.encrypt(None) is None
assert crypto.decrypt(None) is None
assert crypto.rotate(None) is None
assert crypto.can_decrypt(None) is True
def test_missing_key_raises_a_clear_error(clean_env):
os.environ.pop('CONTROL_FERNET_KEY', None)
with pytest.raises(RuntimeError) as exc:
crypto.encrypt('x')
assert 'CONTROL_FERNET_KEY' in str(exc.value)
def test_unknown_key_cannot_decrypt(clean_env):
_configure(KEY_A)
token = crypto.encrypt('pw')
_configure(KEY_C) # unrelated key, no old keys
assert crypto.can_decrypt(token) is False
with pytest.raises(InvalidToken):
crypto.decrypt(token)
# ── The rotation itself ──────────────────────────────────────────────────────
def test_old_ciphertext_readable_after_key_change(clean_env):
"""Step 2 of the runbook: new primary, old key retained for decrypt."""
_configure(KEY_A, version=1)
token = crypto.encrypt('tenant-pw')
_configure(KEY_B, old=[KEY_A], version=2)
assert crypto.decrypt(token) == 'tenant-pw'
assert crypto.can_decrypt(token) is True
def test_new_writes_use_the_primary_key_only(clean_env):
"""A credential written after rotation must not depend on the old key."""
_configure(KEY_B, old=[KEY_A], version=2)
token = crypto.encrypt('fresh-pw')
# Old key alone must NOT open it — proves the primary did the encrypting.
_configure(KEY_A, version=1)
assert crypto.can_decrypt(token) is False
def test_rotate_rewrites_under_the_primary_key(clean_env):
"""Step 4: after rotate(), the old key is no longer needed."""
_configure(KEY_A, version=1)
token = crypto.encrypt('tenant-pw')
_configure(KEY_B, old=[KEY_A], version=2)
rotated = crypto.rotate(token)
assert rotated != token
assert crypto.decrypt(rotated) == 'tenant-pw'
# Step 5: retire KEY_A. The rotated token must still open.
_configure(KEY_B, version=2)
assert crypto.decrypt(rotated) == 'tenant-pw'
# And the un-rotated one must not — this is what verify-keys catches.
assert crypto.can_decrypt(token) is False
def test_rotation_preserves_plaintext_across_two_generations(clean_env):
"""A credential must survive being rotated twice, A -> B -> C."""
_configure(KEY_A, version=1)
token = crypto.encrypt('long-lived-pw')
_configure(KEY_B, old=[KEY_A], version=2)
token = crypto.rotate(token)
_configure(KEY_C, old=[KEY_B], version=3) # KEY_A already retired
token = crypto.rotate(token)
_configure(KEY_C, version=3) # all predecessors retired
assert crypto.decrypt(token) == 'long-lived-pw'
def test_stale_primary_in_old_list_is_tolerated(clean_env):
"""An operator leaving the primary in CONTROL_FERNET_KEYS_OLD is harmless."""
_configure(KEY_B, old=[KEY_B, KEY_A], version=2)
token = crypto.encrypt('pw')
assert crypto.decrypt(token) == 'pw'
def test_blank_entries_in_old_key_list_are_ignored(clean_env):
"""Trailing commas and whitespace must not break key loading."""
os.environ['CONTROL_FERNET_KEY'] = KEY_B
os.environ['CONTROL_FERNET_KEYS_OLD'] = f' {KEY_A} , , '
os.environ['CONTROL_FERNET_KEY_VERSION'] = '2'
assert crypto.old_keys() == [KEY_A.encode()]
_configure(KEY_A, version=1)
token = crypto.encrypt('pw')
os.environ['CONTROL_FERNET_KEY'] = KEY_B
os.environ['CONTROL_FERNET_KEYS_OLD'] = f' {KEY_A} , , '
assert crypto.decrypt(token) == 'pw'
# ── Version stamping ─────────────────────────────────────────────────────────
def test_key_version_defaults_to_one(clean_env):
_configure(KEY_A)
os.environ.pop('CONTROL_FERNET_KEY_VERSION', None)
assert crypto.current_key_version() == 1
def test_key_version_survives_a_garbage_value(clean_env):
_configure(KEY_A)
os.environ['CONTROL_FERNET_KEY_VERSION'] = 'not-a-number'
assert crypto.current_key_version() == 1
def test_fingerprint_identifies_a_key_without_revealing_it(clean_env):
_configure(KEY_A)
fp = crypto.key_fingerprint()
assert len(fp) == 12
assert KEY_A not in fp
assert fp == crypto.key_fingerprint(KEY_A)
assert fp != crypto.key_fingerprint(KEY_B)
# ── Model-level stamping (no DB required) ────────────────────────────────────
def test_tenant_set_password_stamps_the_current_version(clean_env):
from control.models import Tenant
_configure(KEY_A, version=1)
t = Tenant()
t.set_db_password('pw-one')
assert t.key_version == 1
assert t.db_password == 'pw-one'
_configure(KEY_B, old=[KEY_A], version=2)
t.set_db_password('pw-two')
assert t.key_version == 2
assert t.db_password == 'pw-two'
def test_tenant_rotate_updates_ciphertext_and_version(clean_env):
from control.models import Tenant
_configure(KEY_A, version=1)
t = Tenant()
t.set_db_password('rotate-me')
before = t.db_password_enc
_configure(KEY_B, old=[KEY_A], version=2)
assert t.rotate_db_password_key() is True
assert t.db_password_enc != before
assert t.key_version == 2
assert t.db_password == 'rotate-me'
# Retire the old key — the rotated row must still open.
_configure(KEY_B, version=2)
assert t.db_password == 'rotate-me'
def test_tenant_without_credential_is_stamped_not_rotated(clean_env):
from control.models import Tenant
_configure(KEY_B, old=[KEY_A], version=2)
t = Tenant()
assert t.db_password_enc is None
assert t.rotate_db_password_key() is False
# Stamped anyway, so it stops appearing as outstanding work.
assert t.key_version == 2
def test_db_uri_still_builds_after_rotation(clean_env):
"""Rotation must not disturb the URI the routing layer depends on."""
from control.models import Tenant
_configure(KEY_A, version=1)
t = Tenant(db_host='localhost', db_port=3306,
db_name='jqc_t1', db_user='jqc_t1')
t.set_db_password('p@ss word//')
uri_before = t.db_uri
_configure(KEY_B, old=[KEY_A], version=2)
t.rotate_db_password_key()
_configure(KEY_B, version=2)
assert t.db_uri == uri_before
assert 'p%40ss+word%2F%2F' in t.db_uri # still URL-encoded