"""PII encryption per AGENTS.md §4.4: AES-256-GCM (NOT Fernet).""" import base64 import hashlib import os from django.conf import settings from cryptography.hazmat.primitives.ciphers.aead import AESGCM class PhoneEncryption: """Encrypt phone numbers with AES-256-GCM, index via SHA-256 hash, display masked. Storage: phone_encrypted = base64(nonce || ciphertext || tag) Index: phone_hash = sha256(plaintext) — used for equality lookups Display: 138****1234 """ NONCE_LEN = 12 @staticmethod def _key() -> bytes: key_b64 = settings.PHONE_ENCRYPTION_KEY if not key_b64: raise RuntimeError( "PHONE_ENCRYPTION_KEY is not configured. " "Generate with: python -c 'import secrets,base64; print(base64.b64encode(secrets.token_bytes(32)).decode())'" ) key = base64.b64decode(key_b64) if len(key) != 32: raise RuntimeError("PHONE_ENCRYPTION_KEY must decode to exactly 32 bytes (AES-256).") return key @classmethod def encrypt(cls, phone: str) -> str: if phone is None: raise ValueError("phone cannot be None") aesgcm = AESGCM(cls._key()) nonce = os.urandom(cls.NONCE_LEN) ct = aesgcm.encrypt(nonce, phone.encode("utf-8"), None) return base64.b64encode(nonce + ct).decode("ascii") @classmethod def decrypt(cls, ciphertext_b64: str) -> str: raw = base64.b64decode(ciphertext_b64) nonce, ct = raw[: cls.NONCE_LEN], raw[cls.NONCE_LEN :] aesgcm = AESGCM(cls._key()) return aesgcm.decrypt(nonce, ct, None).decode("utf-8") @staticmethod def hash(phone: str) -> str: return hashlib.sha256(phone.encode("utf-8")).hexdigest() @staticmethod def mask(phone: str) -> str: if not phone or len(phone) < 7: return "***" return phone[:3] + "****" + phone[-4:]