Files
fonrey/core/encryption.py
ishenwei 9a7d06b34e feat: scaffold Django multi-tenant project with 5 of 9 apps
Phase 1 scaffolding: config/, core/, base models, AES-256-GCM phone encryption, enums mirror

apps.tenant: Tenant + Domain (django-tenants)

apps.org: 11 models (OrgUnit hierarchy, Staff, audit logs)

apps.account: 4 models (UserAccount as AUTH_USER_MODEL, login/password tracking)

apps.permission: 7 models (RBAC + overrides + datascope + append-only changelog)

apps.region: 5 models (District, BusinessArea, MetroLine, MetroStation, School)

All migrations generated, manage.py check passes
2026-04-29 17:01:55 +08:00

58 lines
1.9 KiB
Python

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