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

1"""Fernet secret-at-rest helper (design D2). 

2 

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""" 

8 

9from __future__ import annotations 

10 

11import logging 

12from functools import lru_cache 

13 

14from cryptography.fernet import Fernet, InvalidToken 

15from django.conf import settings 

16 

17logger = logging.getLogger(__name__) 

18 

19 

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) 

30 

31 

32def encrypt_secret(plaintext: str) -> bytes: 

33 return _fernet().encrypt(plaintext.encode()) 

34 

35 

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