"""Encryption at rest for OAuth tokens and other sensitive material. A single Fernet key (from settings.ENCRYPTION_KEY or a persisted generated key) encrypts all sensitive columns. Refresh tokens / client secrets are NEVER stored in plaintext and NEVER logged. """ import base64 import os from cryptography.fernet import Fernet from app.core.config import settings _KEY_FILE = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), ".encryption_key") def _load_or_create_key() -> bytes: env_key = settings.ENCRYPTION_KEY if env_key: # Accept urlsafe-base64 Fernet key (44 chars) directly. try: Fernet(env_key.encode()) return env_key.encode() except Exception: pass if os.path.exists(_KEY_FILE): with open(_KEY_FILE, "rb") as f: return f.read().strip() key = Fernet.generate_key() fd = os.open(_KEY_FILE, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) try: os.write(fd, key) finally: os.close(fd) return key _f = Fernet(_load_or_create_key()) def encrypt(value: str) -> str: if value is None: return None return _f.encrypt(value.encode("utf-8")).decode("ascii") def decrypt(value: str) -> str: if not value: return "" return _f.decrypt(value.encode("ascii")).decode("utf-8") def rotate_key_clear() -> None: # pragma: no cover - admin utility os.remove(_KEY_FILE) === campaign_engine signatures === app/services/campaign_engine.py:19:def utcnow(): app/services/campaign_engine.py:23:class RotationAssigner: app/services/campaign_engine.py:90:class SubjectSelector: app/services/campaign_engine.py:108:class TemplateSequencer: app/services/campaign_engine.py:137:class PhoneSelector: app/services/campaign_engine.py:155:async def get_setting(db: AsyncSession, key: str, default): app/services/campaign_engine.py:163:def get_setting_sync(key: str, default): app/services/campaign_engine.py:176:async def get_campaign_entities(db: AsyncSession, campaign: Campaign): app/services/campaign_engine.py:201:async def materialize_campaign(db: AsyncSession, campaign: Campaign, recipient_rows: Sequence[Recipient], app/services/campaign_service.py:14:async def start_campaign(db: AsyncSession, campaign: Campaign) -> dict: app/services/campaign_service.py:46:async def pause_campaign(db: AsyncSession, campaign: Campaign) -> dict: app/services/campaign_service.py:52:async def resume_campaign(db: AsyncSession, campaign: Campaign) -> dict: app/services/campaign_service.py:56:async def cancel_campaign(db: AsyncSession, campaign: Campaign) -> dict: