import base64 import hashlib import logging import os from cryptography.hazmat.primitives.ciphers.aead import AESGCM from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC logger = logging.getLogger(__name__) _CIPHER_VERSION = b"\x01" _KEY_BYTES = 32 _MIN_KEY_LENGTH = 32 _PBKDF2_ITERATIONS = 480_000 class CryptoError(Exception): pass def _derive_key() -> bytes: key = os.getenv("FORCEPILOT_SECRET_ENCRYPTION_KEY", "") if not key: raise CryptoError("FORCEPILOT_SECRET_ENCRYPTION_KEY must be set and at least 32 characters long") raw = key.encode("utf-8") if len(raw) < _MIN_KEY_LENGTH: raise CryptoError(f"FORCEPILOT_SECRET_ENCRYPTION_KEY must be at least {_MIN_KEY_LENGTH} bytes, got {len(raw)}") deterministic_salt = hashlib.sha256(b"forcepilot:secret:salt:" + raw).digest()[:16] kdf = PBKDF2HMAC( algorithm=hashes.SHA256(), length=_KEY_BYTES, salt=deterministic_salt, iterations=_PBKDF2_ITERATIONS, ) derived = kdf.derive(raw) return deterministic_salt + derived _ENCRYPTION_KEY: bytes | None = None def get_encryption_key() -> bytes: global _ENCRYPTION_KEY if _ENCRYPTION_KEY is None: _ENCRYPTION_KEY = _derive_key() return _ENCRYPTION_KEY def refresh_encryption_key() -> None: global _ENCRYPTION_KEY _ENCRYPTION_KEY = _derive_key() logger.info("Encryption key refreshed") def encrypt_secret(plaintext: str) -> str: if not plaintext: return plaintext try: key_material = get_encryption_key() salt = key_material[:16] key = key_material[16:] aesgcm = AESGCM(key) nonce = os.urandom(12) ciphertext = aesgcm.encrypt(nonce, plaintext.encode("utf-8"), None) payload = _CIPHER_VERSION + salt + nonce + ciphertext return base64.urlsafe_b64encode(payload).decode("ascii") except CryptoError: raise except Exception as e: raise CryptoError(f"Failed to encrypt secret: {e}") from e def decrypt_secret(ciphertext_b64: str) -> str: if not ciphertext_b64: return ciphertext_b64 try: payload = base64.urlsafe_b64decode(ciphertext_b64) except Exception as e: raise CryptoError(f"Failed to base64-decode ciphertext: {e}") from e if len(payload) < 1 + 16 + 12 + 16: raise CryptoError("Ciphertext too short for valid AES-GCM payload") version = payload[0:1] if version != _CIPHER_VERSION: raise CryptoError(f"Unknown cipher version: {version!r}") salt = payload[1:17] nonce = payload[17:29] encrypted = payload[29:] try: key_material = get_encryption_key() stored_salt = key_material[:16] if salt != stored_salt: logger.warning("Salt mismatch in ciphertext: encryption key may have been rotated") key = key_material[16:] aesgcm = AESGCM(key) return aesgcm.decrypt(nonce, encrypted, None).decode("utf-8") except CryptoError: raise except Exception as e: raise CryptoError(f"Failed to decrypt secret (key mismatch or tampering): {e}") from e def is_encrypted(value: str) -> bool: if not value: return False try: payload = base64.urlsafe_b64decode(value) return len(payload) > 29 and payload[0:1] == _CIPHER_VERSION except Exception: return False