Aug 21 - Fernet key rotation
This commit is contained in:
+133
-13
@@ -3,39 +3,159 @@ control/crypto.py
|
||||
-----------------
|
||||
Symmetric encryption for sensitive control-plane fields (tenant DB passwords).
|
||||
|
||||
Fernet (AES-128-CBC + HMAC) keyed by the CONTROL_FERNET_KEY env var.
|
||||
Generate a key once:
|
||||
Fernet (AES-128-CBC + HMAC). MT-23 adds key rotation via ``MultiFernet``.
|
||||
|
||||
Key configuration
|
||||
-----------------
|
||||
``CONTROL_FERNET_KEY``
|
||||
The **primary** key. Everything is encrypted with this one.
|
||||
|
||||
``CONTROL_FERNET_KEYS_OLD``
|
||||
Comma-separated retired keys, newest first. Decrypt-only: they open
|
||||
ciphertext written before the last rotation, and are never used to encrypt.
|
||||
Empty or unset in steady state.
|
||||
|
||||
``CONTROL_FERNET_KEY_VERSION``
|
||||
Integer, default 1. Stamped onto every row as it is (re-)encrypted, so
|
||||
rotation progress is a SQL query rather than a guess:
|
||||
|
||||
SELECT COUNT(*) FROM tenants WHERE key_version < <current>;
|
||||
|
||||
Generate a key:
|
||||
|
||||
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
|
||||
Store it in the server environment / .env (never in git). Key rotation requires
|
||||
re-encrypting existing tenant rows.
|
||||
Rotation procedure
|
||||
------------------
|
||||
1. Generate a new key.
|
||||
2. Move the current ``CONTROL_FERNET_KEY`` value into ``CONTROL_FERNET_KEYS_OLD``
|
||||
(prepending it if that variable already holds keys), set the new key as
|
||||
``CONTROL_FERNET_KEY``, and bump ``CONTROL_FERNET_KEY_VERSION``.
|
||||
3. ``python -m control.cli verify-keys`` — confirms every stored credential is
|
||||
still readable under the new key set **before** anything is rewritten.
|
||||
4. ``python -m control.cli rotate-keys`` — re-encrypts every row under the
|
||||
primary key and stamps the new version.
|
||||
5. ``python -m control.cli verify-keys`` again, then drop the retired key from
|
||||
``CONTROL_FERNET_KEYS_OLD``.
|
||||
|
||||
Steps 3 and 5 are the point of the design: at no moment is there a window
|
||||
where a key has been retired but rows still depend on it.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from cryptography.fernet import Fernet, MultiFernet
|
||||
|
||||
_KEY_ENV = 'CONTROL_FERNET_KEY'
|
||||
_OLD_KEY_ENV = 'CONTROL_FERNET_KEYS_OLD'
|
||||
_VERSION_ENV = 'CONTROL_FERNET_KEY_VERSION'
|
||||
|
||||
|
||||
def _fernet() -> Fernet:
|
||||
key = os.environ.get('CONTROL_FERNET_KEY')
|
||||
def _as_bytes(key):
|
||||
return key.encode() if isinstance(key, str) else key
|
||||
|
||||
|
||||
def primary_key():
|
||||
"""The single key used for encryption. Raises if unset."""
|
||||
key = os.environ.get(_KEY_ENV)
|
||||
if not key:
|
||||
raise RuntimeError(
|
||||
"CONTROL_FERNET_KEY is not set. Generate one with: "
|
||||
f"{_KEY_ENV} is not set. Generate one with: "
|
||||
"python -c \"from cryptography.fernet import Fernet; "
|
||||
"print(Fernet.generate_key().decode())\""
|
||||
)
|
||||
return Fernet(key.encode() if isinstance(key, str) else key)
|
||||
return _as_bytes(key)
|
||||
|
||||
|
||||
def old_keys():
|
||||
"""Retired decrypt-only keys, in the order they are tried."""
|
||||
raw = os.environ.get(_OLD_KEY_ENV) or ''
|
||||
return [_as_bytes(k.strip()) for k in raw.split(',') if k.strip()]
|
||||
|
||||
|
||||
def current_key_version():
|
||||
"""Integer stamped onto rows encrypted under the current primary key."""
|
||||
try:
|
||||
return int(os.environ.get(_VERSION_ENV, 1))
|
||||
except (TypeError, ValueError):
|
||||
return 1
|
||||
|
||||
|
||||
def key_fingerprint(key=None):
|
||||
"""Short, non-reversible identifier for a key — safe to log.
|
||||
|
||||
Lets an operator confirm which key a process actually loaded without ever
|
||||
putting key material in a log line or a support ticket.
|
||||
"""
|
||||
material = _as_bytes(key) if key is not None else primary_key()
|
||||
return hashlib.sha256(material).hexdigest()[:12]
|
||||
|
||||
|
||||
def _multi():
|
||||
"""MultiFernet: primary first (it encrypts), retired keys after it.
|
||||
|
||||
Ordering matters — MultiFernet encrypts with the first key and tries each
|
||||
in turn when decrypting.
|
||||
"""
|
||||
keys = [Fernet(primary_key())]
|
||||
seen = {primary_key()}
|
||||
for k in old_keys():
|
||||
if k in seen:
|
||||
continue # tolerate a stale copy of the primary in the old list
|
||||
seen.add(k)
|
||||
keys.append(Fernet(k))
|
||||
return MultiFernet(keys)
|
||||
|
||||
|
||||
# Kept for backward compatibility: earlier code imported _fernet() directly.
|
||||
def _fernet():
|
||||
return _multi()
|
||||
|
||||
|
||||
def encrypt(plaintext):
|
||||
"""Encrypt a string to a Fernet token (str). Passes None through."""
|
||||
"""Encrypt a string to a Fernet token (str) under the primary key.
|
||||
|
||||
Passes None through.
|
||||
"""
|
||||
if plaintext is None:
|
||||
return None
|
||||
return _fernet().encrypt(plaintext.encode()).decode()
|
||||
return _multi().encrypt(plaintext.encode()).decode()
|
||||
|
||||
|
||||
def decrypt(token):
|
||||
"""Decrypt a Fernet token back to the original string. Passes None through."""
|
||||
"""Decrypt a Fernet token, trying the primary key then each retired key.
|
||||
|
||||
Passes None through. Raises ``cryptography.fernet.InvalidToken`` when no
|
||||
configured key opens the token.
|
||||
"""
|
||||
if token is None:
|
||||
return None
|
||||
return _fernet().decrypt(token.encode()).decode()
|
||||
return _multi().decrypt(token.encode()).decode()
|
||||
|
||||
|
||||
def rotate(token):
|
||||
"""Re-encrypt an existing token under the primary key.
|
||||
|
||||
``MultiFernet.rotate`` decrypts with whichever key works and re-encrypts
|
||||
with the first, preserving the plaintext without it ever being handled
|
||||
here. Passes None through.
|
||||
"""
|
||||
if token is None:
|
||||
return None
|
||||
return _multi().rotate(token.encode()).decode()
|
||||
|
||||
|
||||
def can_decrypt(token):
|
||||
"""True when some configured key opens this token. Never raises.
|
||||
|
||||
Used by ``control.cli verify-keys`` to audit every stored credential
|
||||
before a key is retired.
|
||||
"""
|
||||
if token is None:
|
||||
return True # nothing stored is not a failure
|
||||
try:
|
||||
decrypt(token)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
Reference in New Issue
Block a user