from __future__ import annotations import base64 import hashlib import json from functools import lru_cache from typing import Any, Mapping, Protocol, runtime_checkable from cryptography.fernet import Fernet, InvalidToken from govoplan_core.settings import settings class SecretConfigurationError(RuntimeError): pass class SecretDecryptionError(RuntimeError): pass class TransientPayloadError(RuntimeError): pass CAPABILITY_SECURITY_SECRET_PROVIDER = "security.secretProvider" # noqa: S105 # nosec B105 - capability identifier. @runtime_checkable class SecretProvider(Protocol): def store_secret(self, *, scope: str, name: str, value: str) -> str: ... def read_secret(self, secret_ref: str) -> str | None: ... def delete_secret(self, secret_ref: str) -> None: ... def _normalize_fernet_key(value: str) -> bytes: candidate = value.strip().encode("utf-8") try: Fernet(candidate) return candidate except (TypeError, ValueError): raw = None try: raw = base64.b64decode(candidate) except Exception as exc: raise SecretConfigurationError("MASTER_KEY_B64 must be a Fernet key or base64-encoded 32-byte key") from exc if len(raw) != 32: raise SecretConfigurationError("MASTER_KEY_B64 must decode to exactly 32 bytes") return base64.urlsafe_b64encode(raw) @lru_cache(maxsize=1) def _fernet() -> Fernet: if settings.master_key_b64: return Fernet(_normalize_fernet_key(settings.master_key_b64)) if settings.app_env.lower() in {"dev", "test", "local"}: key = base64.urlsafe_b64encode(hashlib.sha256(b"multi-seal-mail-development-master-key").digest()) return Fernet(key) raise SecretConfigurationError("MASTER_KEY_B64 is required outside dev/test/local environments") def encrypt_secret(value: str | None) -> str | None: if value in (None, ""): return None return _fernet().encrypt(str(value).encode("utf-8")).decode("utf-8") def decrypt_secret(value: str | None) -> str | None: if not value: return None try: return _fernet().decrypt(value.encode("utf-8")).decode("utf-8") except InvalidToken as exc: raise SecretDecryptionError("Stored secret cannot be decrypted with the configured master key") from exc def seal_transient_payload(payload: Mapping[str, Any]) -> str: """Seal a short-lived JSON object without persisting server-side state.""" encoded = json.dumps( dict(payload), separators=(",", ":"), sort_keys=True, ).encode("utf-8") return _fernet().encrypt(encoded).decode("utf-8") def open_transient_payload(token: str, *, ttl_seconds: int) -> dict[str, Any]: """Open a sealed JSON object and enforce its maximum age.""" if ttl_seconds <= 0: raise ValueError("Transient payload TTL must be positive") try: encoded = _fernet().decrypt(token.encode("utf-8"), ttl=ttl_seconds) payload = json.loads(encoded.decode("utf-8")) except (InvalidToken, UnicodeDecodeError, json.JSONDecodeError, TypeError, ValueError) as exc: raise TransientPayloadError("Transient payload is invalid or expired") from exc if not isinstance(payload, dict): raise TransientPayloadError("Transient payload must contain a JSON object") return payload