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
+125 -1
View File
@@ -8,6 +8,8 @@ the bootstrap actions on top of an already-migrated control DB.
python -m control.cli seed
python -m control.cli create-superadmin --username admin --email you@example.com
python -m control.cli list-plans
python -m control.cli verify-keys
python -m control.cli rotate-keys --dry-run
"""
import argparse
@@ -15,7 +17,7 @@ import getpass
import sys
from control.base import control_session
from control.models import Plan, Superadmin
from control.models import Plan, Superadmin, Tenant
from control.seed import seed_plans
@@ -58,6 +60,113 @@ def _cmd_create_superadmin(args):
print(f"Superadmin '{args.username}' created.")
def _cmd_verify_keys(_args):
"""MT-23: confirm every stored credential is readable under the current key set.
Run this BEFORE retiring a key and again after rotating. It is the step
that makes rotation safe: it proves no row still depends on a key that is
about to be removed, instead of finding out at the next tenant request.
"""
from control import crypto
current = crypto.current_key_version()
print(f'Primary key fingerprint : {crypto.key_fingerprint()}')
print(f'Retired keys configured : {len(crypto.old_keys())}')
print(f'Current key version : {current}')
print('-' * 60)
ok = failed = empty = stale = 0
failures = []
with control_session() as s:
tenants = s.query(Tenant).order_by(Tenant.id).all()
for t in tenants:
if not t.db_password_enc:
empty += 1
continue
if crypto.can_decrypt(t.db_password_enc):
ok += 1
else:
failed += 1
failures.append((t.id, t.slug))
if (t.key_version or 1) < current:
stale += 1
print(f' readable : {ok}')
print(f' no credential set : {empty}')
print(f' UNREADABLE : {failed}')
print(f' awaiting rotation : {stale} (key_version < {current})')
if failures:
print('\nUnreadable rows — do NOT retire any key:')
for tid, slug in failures:
print(f' tenant {tid} ({slug})')
sys.exit(1)
if stale:
print(f'\n{stale} row(s) still on an older key. Run: '
'python -m control.cli rotate-keys')
else:
print('\nAll credentials current. Safe to drop retired keys from '
'CONTROL_FERNET_KEYS_OLD.')
def _cmd_rotate_keys(args):
"""MT-23: re-encrypt tenant credentials under the primary Fernet key.
Idempotent — rows already at the current version are skipped unless
--force is given. Each tenant is committed independently so a failure
part-way leaves a resumable state rather than an all-or-nothing rollback.
"""
from control import crypto
from cryptography.fernet import InvalidToken
current = crypto.current_key_version()
print(f'Primary key fingerprint : {crypto.key_fingerprint()}')
print(f'Target key version : {current}')
if args.dry_run:
print('MODE: dry run — nothing will be written.')
print('-' * 60)
rotated = skipped = empty = errors = 0
with control_session() as s:
tenants = s.query(Tenant).order_by(Tenant.id).all()
for t in tenants:
if (t.key_version or 1) >= current and not args.force:
skipped += 1
continue
if args.dry_run:
label = 'would rotate' if t.db_password_enc else 'would stamp (no credential)'
print(f' tenant {t.id} ({t.slug}): {label}')
rotated += 1
continue
try:
changed = t.rotate_db_password_key()
s.flush()
if changed:
rotated += 1
print(f' tenant {t.id} ({t.slug}): rotated -> v{t.key_version}')
else:
empty += 1
print(f' tenant {t.id} ({t.slug}): no credential, stamped '
f'-> v{t.key_version}')
except InvalidToken:
errors += 1
print(f' tenant {t.id} ({t.slug}): ERROR — no configured key opens '
f'this credential. Add the retiring key to '
f'CONTROL_FERNET_KEYS_OLD and re-run.')
except Exception as exc:
errors += 1
print(f' tenant {t.id} ({t.slug}): ERROR — {exc}')
print('-' * 60)
print(f'rotated={rotated} skipped={skipped} no_credential={empty} errors={errors}')
if errors:
print('\nRotation incomplete. Do NOT retire any key. Fix the errors above '
'and re-run — completed rows are skipped automatically.')
sys.exit(1)
if not args.dry_run:
print('\nNow run: python -m control.cli verify-keys')
def main(argv=None):
parser = argparse.ArgumentParser(prog='control.cli',
description='JQC control-plane CLI')
@@ -73,6 +182,21 @@ def main(argv=None):
help='Omit to be prompted securely.')
p_sa.set_defaults(func=_cmd_create_superadmin)
sub.add_parser(
'verify-keys',
help='Check every tenant credential decrypts under the current key set',
).set_defaults(func=_cmd_verify_keys)
p_rot = sub.add_parser(
'rotate-keys',
help='Re-encrypt tenant credentials under the primary Fernet key',
)
p_rot.add_argument('--dry-run', action='store_true',
help='Report what would change; write nothing.')
p_rot.add_argument('--force', action='store_true',
help='Re-encrypt even rows already at the current version.')
p_rot.set_defaults(func=_cmd_rotate_keys)
args = parser.parse_args(argv)
args.func(args)