"""Symmetric encryption for secrets that have to live in the database. Most of StackPilot's secrets are files on disk (``.env``, ``.secrets/*``) where filesystem permissions are the right control. A few can't be: backup destination credentials and agent tokens are needed by background jobs, so they sit in ``stackpilot.db``. This module encrypts those at rest. The key is derived from ``SECRET_KEY`` rather than being a second thing to configure — which is exactly why ``SECRET_KEY`` is now persisted (see ``config._ensure_secret``): a key that changed on every restart would take the ciphertext with it. Ciphertext is stored with an ``enc:v1:`` prefix so plaintext rows written by older versions stay recognisable and can be migrated in place. """ from __future__ import annotations import base64 import hashlib import logging from typing import Optional from cryptography.fernet import Fernet, InvalidToken from config import settings logger = logging.getLogger("stackpilot.crypto") PREFIX = "enc:v1:" _INFO = b"stackpilot-db-field-encryption-v1" class DecryptError(Exception): """Ciphertext could not be decrypted (usually: SECRET_KEY changed).""" def _fernet() -> Fernet: """Fernet built from a 32-byte key derived from SECRET_KEY. Not cached: SECRET_KEY is fixed for the process lifetime, and building a Fernet is a hash plus a base64 encode — cheap enough not to bother. """ digest = hashlib.blake2b( settings.SECRET_KEY.encode("utf-8"), key=_INFO, digest_size=32 ).digest() return Fernet(base64.urlsafe_b64encode(digest)) def is_encrypted(value: Optional[str]) -> bool: return bool(value) and value.startswith(PREFIX) def encrypt(plaintext: str) -> str: """Encrypt a string. Already-encrypted input is returned unchanged.""" if is_encrypted(plaintext): return plaintext token = _fernet().encrypt((plaintext or "").encode("utf-8")) return PREFIX + token.decode("ascii") def decrypt(value: str) -> str: """Decrypt a value written by :func:`encrypt`. Plaintext (no prefix) is passed straight through, so rows written before encryption existed keep working until the startup migration rewrites them. """ if not is_encrypted(value): return value or "" try: return _fernet().decrypt(value[len(PREFIX):].encode("ascii")).decode("utf-8") except (InvalidToken, ValueError) as exc: raise DecryptError( "Could not decrypt a stored secret. This normally means SECRET_KEY " "changed since it was saved — restore the old key, or re-enter the " "affected credentials." ) from exc