diff --git a/pyproject.toml b/pyproject.toml index 1a4aecc..c10b66a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "packaging>=24.0", "click>=8.0", "httpx>=0.27", + "cryptography>=42", ] [project.optional-dependencies] diff --git a/steward/app.py b/steward/app.py index 9984424..92b1c2c 100644 --- a/steward/app.py +++ b/steward/app.py @@ -47,6 +47,13 @@ def create_app( plugin_dirs=[Path(d).resolve() for d in app.config["PLUGIN_DIRS"]], ) + # ── 3b. Encryption-at-rest: bind the encryptor, then encrypt legacy secrets ─ + if not testing: + from .core.crypto import init_crypto + from .core.settings import migrate_plaintext_secrets + init_crypto(app.config["SECRET_KEY"]) + migrate_plaintext_secrets(app.config["DATABASE_URL"]) + # ── 4. Load all settings from DB → populate app.config ──────────────────── if not testing: from .core.settings import ( diff --git a/steward/core/crypto.py b/steward/core/crypto.py new file mode 100644 index 0000000..2d5a1fb --- /dev/null +++ b/steward/core/crypto.py @@ -0,0 +1,93 @@ +# steward/core/crypto.py +"""Encryption-at-rest for sensitive settings (smtp/oidc/ldap/ansible secrets). + +Values are encrypted with Fernet (AES-128-CBC + HMAC) using a key derived from +the app secret key (the same /data/secret.key used for sessions). Encrypted +values are stored with an ``enc:v1:`` prefix so reads can transparently tell +ciphertext from legacy plaintext during/after migration. + +Key-loss caveat: if the app secret key is lost or changed, encrypted secrets +become unrecoverable — they must be re-entered. This ties secret recovery to +the same key the rest of the app already depends on; back it up with the data. +""" +from __future__ import annotations + +import base64 +import hashlib +import logging +import os +from pathlib import Path + +from cryptography.fernet import Fernet, InvalidToken + +logger = logging.getLogger(__name__) + +ENC_PREFIX = "enc:v1:" +_SECRET_KEY_FILE = Path("/data/secret.key") +_fernet: Fernet | None = None + + +def _make_fernet(secret_key: str) -> Fernet: + # Derive a stable 32-byte Fernet key from the app secret (any length string). + digest = hashlib.sha256(secret_key.encode("utf-8")).digest() + return Fernet(base64.urlsafe_b64encode(digest)) + + +def init_crypto(secret_key: str) -> None: + """Bind the encryptor to the app's secret key (called once at startup).""" + global _fernet + if secret_key: + _fernet = _make_fernet(secret_key) + + +def _resolve_secret_key() -> str | None: + """Fallback key resolution mirroring config (env → /data/secret.key).""" + k = os.environ.get("STEWARD_SECRET_KEY") + if k: + return k + try: + if _SECRET_KEY_FILE.exists(): + v = _SECRET_KEY_FILE.read_text().strip() + if v: + return v + except OSError: + pass + return None + + +def _get() -> Fernet | None: + global _fernet + if _fernet is None: + k = _resolve_secret_key() + if k: + _fernet = _make_fernet(k) + return _fernet + + +def is_encrypted(value) -> bool: + return isinstance(value, str) and value.startswith(ENC_PREFIX) + + +def encrypt_secret(plaintext: str) -> str: + """Return an ``enc:v1:`` token, or the input unchanged if empty / no key.""" + if not plaintext: + return plaintext + f = _get() + if f is None: + logger.warning("No secret key available — storing a secret in PLAINTEXT") + return plaintext + return ENC_PREFIX + f.encrypt(plaintext.encode("utf-8")).decode("ascii") + + +def decrypt_secret(value: str) -> str: + """Decrypt an ``enc:v1:`` token; pass through plaintext / undecryptable values.""" + if not is_encrypted(value): + return value + f = _get() + if f is None: + return value + try: + return f.decrypt(value[len(ENC_PREFIX):].encode("ascii")).decode("utf-8") + except InvalidToken: + logger.error("Could not decrypt a stored secret (wrong/rotated key?)") + return value diff --git a/steward/core/settings.py b/steward/core/settings.py index 8fa3c2c..465d563 100644 --- a/steward/core/settings.py +++ b/steward/core/settings.py @@ -104,6 +104,24 @@ DEFAULTS: dict[str, Any] = { "reports.last_sent_at": "", } +# Settings encrypted at rest (transparent encrypt-on-write / decrypt-on-read). +# Adding a key here makes new writes ciphertext; run migrate_plaintext_secrets +# to convert any existing plaintext rows. +SECRET_KEYS: set[str] = { + "smtp.password", + "oidc.client_secret", + "ldap.bind_password", + "ansible.ssh_private_key", + "ansible.become_password", + "ansible.vault_password", +} + + +def _decode(value: Any) -> Any: + """Decrypt a stored value if it's an encrypted token; else pass through.""" + from steward.core.crypto import decrypt_secret, is_encrypted + return decrypt_secret(value) if is_encrypted(value) else value + # ───────────────────────────────────────────────────────────────────────────── # Async helpers (use inside request handlers / scheduled tasks) @@ -117,27 +135,31 @@ async def get_setting(session: AsyncSession, key: str) -> Any: row = result.scalar_one_or_none() if row is None: return DEFAULTS.get(key) - return json.loads(row.value_json) + return _decode(json.loads(row.value_json)) async def set_setting(session: AsyncSession, key: str, value: Any) -> None: - """Upsert a setting. Call inside an active transaction.""" + """Upsert a setting (encrypting secret keys at rest). Call in a transaction.""" + to_store = value + if key in SECRET_KEYS and isinstance(value, str) and value: + from steward.core.crypto import encrypt_secret + to_store = encrypt_secret(value) result = await session.execute( select(AppSetting).where(AppSetting.key == key) ) row = result.scalar_one_or_none() now = datetime.now(timezone.utc) if row is None: - session.add(AppSetting(key=key, value_json=json.dumps(value), updated_at=now)) + session.add(AppSetting(key=key, value_json=json.dumps(to_store), updated_at=now)) else: - row.value_json = json.dumps(value) + row.value_json = json.dumps(to_store) row.updated_at = now async def get_all_settings(session: AsyncSession) -> dict[str, Any]: """Return flat key→value dict with defaults filled in for missing keys.""" result = await session.execute(select(AppSetting)) - stored = {row.key: json.loads(row.value_json) for row in result.scalars()} + stored = {row.key: _decode(json.loads(row.value_json)) for row in result.scalars()} out: dict[str, Any] = {} for key, default in DEFAULTS.items(): out[key] = stored.get(key, default) @@ -216,7 +238,7 @@ def load_settings_sync(db_url: str) -> dict[str, Any]: try: async with factory() as session: result = await session.execute(select(AppSetting)) - return {row.key: json.loads(row.value_json) for row in result.scalars()} + return {row.key: _decode(json.loads(row.value_json)) for row in result.scalars()} finally: await engine.dispose() @@ -230,6 +252,37 @@ def load_settings_sync(db_url: str) -> dict[str, Any]: return out +def migrate_plaintext_secrets(db_url: str) -> int: + """Encrypt any existing plaintext secret rows in place. Idempotent. + + Returns the number of values converted. Run once at startup after the + encryptor is initialised (already-encrypted rows are skipped). + """ + from steward.core.crypto import encrypt_secret, is_encrypted + + async def _run() -> int: + from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker + engine = create_async_engine(db_url, echo=False) + factory = async_sessionmaker(engine, expire_on_commit=False) + converted = 0 + try: + async with factory() as session: + async with session.begin(): + result = await session.execute( + select(AppSetting).where(AppSetting.key.in_(SECRET_KEYS)) + ) + for row in result.scalars(): + val = json.loads(row.value_json) + if isinstance(val, str) and val and not is_encrypted(val): + row.value_json = json.dumps(encrypt_secret(val)) + converted += 1 + finally: + await engine.dispose() + return converted + + return asyncio.run(_run()) + + # ───────────────────────────────────────────────────────────────────────────── # External URL helper # ───────────────────────────────────────────────────────────────────────────── diff --git a/tests/core/test_crypto.py b/tests/core/test_crypto.py new file mode 100644 index 0000000..1cf6d57 --- /dev/null +++ b/tests/core/test_crypto.py @@ -0,0 +1,46 @@ +"""Unit tests for settings encryption-at-rest (Fernet).""" +from steward.core.crypto import ( + ENC_PREFIX, + decrypt_secret, + encrypt_secret, + init_crypto, + is_encrypted, +) + + +def setup_function(): + init_crypto("unit-test-secret-key") + + +def test_roundtrip(): + tok = encrypt_secret("hunter2") + assert tok.startswith(ENC_PREFIX) + assert tok != "hunter2" + assert is_encrypted(tok) + assert decrypt_secret(tok) == "hunter2" + + +def test_empty_passes_through(): + assert encrypt_secret("") == "" + assert is_encrypted("") is False + + +def test_plaintext_decrypt_passthrough(): + assert decrypt_secret("not-encrypted") == "not-encrypted" + + +def test_is_encrypted_type_guard(): + assert is_encrypted(encrypt_secret("x")) is True + assert is_encrypted("plain") is False + assert is_encrypted(None) is False + assert is_encrypted(123) is False + + +def test_wrong_key_does_not_reveal_plaintext(): + init_crypto("key-A") + tok = encrypt_secret("secret") + init_crypto("key-B") + # Wrong key -> InvalidToken -> token returned unchanged, never the plaintext. + assert decrypt_secret(tok) != "secret" + init_crypto("key-A") + assert decrypt_secret(tok) == "secret"