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
58 lines
1.9 KiB
Python
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:]
|