Coverage for apps/integrations/encryption.py: 85%
23 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-04 17:24 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-04 17:24 +0000
1"""Fernet secret-at-rest helper (design D2).
3Secrets are encrypted with the key in ``INTEGRATIONS_FERNET_KEY``. In dev/test, when no key is
4configured, an ephemeral key is generated for the process (secrets then do not survive a
5restart — fine locally; production MUST set the key, and should rotate to KMS envelope
6encryption later, per the design's open questions).
7"""
9from __future__ import annotations
11import logging
12from functools import lru_cache
14from cryptography.fernet import Fernet, InvalidToken
15from django.conf import settings
17logger = logging.getLogger(__name__)
20@lru_cache(maxsize=1)
21def _fernet() -> Fernet:
22 key = getattr(settings, "INTEGRATIONS_FERNET_KEY", "") or ""
23 if not key: 23 ↛ 29line 23 didn't jump to line 29 because the condition on line 23 was always true
24 key = Fernet.generate_key().decode()
25 logger.warning(
26 "INTEGRATIONS_FERNET_KEY not set; using an ephemeral dev key "
27 "(stored secrets will not survive a process restart)."
28 )
29 return Fernet(key.encode() if isinstance(key, str) else key)
32def encrypt_secret(plaintext: str) -> bytes:
33 return _fernet().encrypt(plaintext.encode())
36def decrypt_secret(token: bytes | memoryview | None) -> str | None:
37 """Decrypt a stored blob; returns None for empty input or an undecryptable blob (defensive —
38 a rotated key must not crash a request)."""
39 if not token:
40 return None
41 try:
42 return _fernet().decrypt(bytes(token)).decode()
43 except InvalidToken:
44 logger.warning("Failed to decrypt a stored secret (key rotated or corrupt).")
45 return None