Your Database Will Leak Someday: Field-Level Encryption and Blind Indexes for PII with Python and PostgreSQL
Tuần này HN đang bàn rất nhiều về vụ rò rỉ dữ liệu ở Đan Mạch: thông tin cá nhân của 8,8 triệu người bị lộ. Đọc qua các vụ breach lớn vài năm gần đây, mình thấy kịch bản gần như lặp lại: lộ một bản backup, một bucket S3 cấu hình sai, một lỗi SQL injection, hoặc một tài khoản read-only của bên analytics bị lấy mất. Điểm chung là kẻ tấn công đọc được database ở dạng plaintext. Lúc đó TLS hay disk encryption cũng không giúp được gì. Bài này chia sẻ cách mình làm field-level encryption cho dữ liệu PII (email, số điện thoại, CCCD...) bằng Python và PostgreSQL. Kèm theo đó là kỹ thuật blind index, để vẫn tìm kiếm được trên dữ liệu đã mã hóa.
Tại sao "encryption at rest" là chưa đủ
Phần lớn team đều tick vào ô "encryption at rest" trên RDS hoặc Cloud SQL rồi coi như xong. Vấn đề là lớp mã hóa này chỉ bảo vệ khi ai đó lấy cắp ổ cứng vật lý. Còn khi query qua PostgreSQL, dữ liệu được giải mã trong suốt cho bất kỳ ai có quyền SQL.
graph TD
A[Attacker] -->|SQL injection| DB[(PostgreSQL)]
A -->|Backup bị lộ| BK[pg_dump file]
A -->|Tài khoản analytics| DB
DB --> D1[Disk encryption: KHÔNG bảo vệ]
DB --> D2[Field-level encryption: Chỉ thấy ciphertext]
BK --> D2
```
Với field-level encryption, **application** mã hóa dữ liệu trước khi ghi xuống DB. Database chỉ lưu bytes vô nghĩa. Muốn đọc được, kẻ tấn công phải lấy được cả database lẫn key. Mà key thì nằm ở chỗ khác: KMS, Vault, hoặc ít nhất là secret manager, tách biệt với DB.
## Thiết kế: AES-256-GCM + blind index
Thiết kế mình hay dùng gồm ba thành phần:
1. **AES-256-GCM** để mã hóa từng field. GCM là authenticated encryption: nếu ai đó sửa ciphertext thì lúc decrypt sẽ báo lỗi ngay chứ không trả về rác.
2. **AAD (Additional Authenticated Data)** gắn ciphertext với đúng row và đúng column. Nhờ vậy attacker không thể copy `email_enc` của user A sang user B.
3. **Blind index**: HMAC-SHA256 của giá trị đã normalize, dùng để query `WHERE email = ?` mà không cần giải mã cả bảng.
```mermaid
sequenceDiagram
participant App
participant KMS as Secret Manager
participant DB as PostgreSQL
App->>KMS: Lấy encryption key + index key (lúc khởi động)
App->>App: encrypt(email, aad) với AES-GCM
App->>App: blind_index = HMAC(index_key, lower(email))
App->>DB: INSERT email_enc, email_bidx
App->>DB: SELECT ... WHERE email_bidx = HMAC(input)
DB-->>App: email_enc
App->>App: decrypt(email_enc, aad)
```
Lưu ý quan trọng: **encryption key và index key phải là hai key khác nhau**. Dùng chung một key cho hai mục đích là anti-pattern kinh điển trong crypto.
## Code thực tế: Python 3.12 + cryptography + psycopg 3
Đầu tiên, generate key và đưa vào secret manager. Tuyệt đối không commit vào `.env` trong repo:
```bash
# Key 32 bytes cho AES-256
openssl rand -base64 32 # -> PII_KEY_V1
openssl rand -base64 32 # -> PII_INDEX_KEY
pip install "cryptography>=43" "psycopg[binary]>=3.2"
```
Schema PostgreSQL. Các cột mã hóa đều là `bytea`:
```sql
CREATE TABLE users (
id uuid PRIMARY KEY,
email_enc bytea NOT NULL,
email_bidx bytea NOT NULL,
phone_enc bytea,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX users_email_bidx ON users (email_bidx);
```
Module crypto. Mình prefix thêm 1 byte `key_version` để sau này rotate key không phải migrate toàn bộ dữ liệu trong một đêm:
```python
import base64, hashlib, hmac, os, uuid
import psycopg
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
KEYS = {1: base64.b64decode(os.environ["PII_KEY_V1"])}
CURRENT_VERSION = 1
INDEX_KEY = base64.b64decode(os.environ["PII_INDEX_KEY"])
def encrypt(plaintext: str, aad: bytes) -> bytes:
nonce = os.urandom(12) # KHÔNG BAO GIỜ reuse nonce với cùng key
ct = AESGCM(KEYS[CURRENT_VERSION]).encrypt(nonce, plaintext.encode(), aad)
return bytes([CURRENT_VERSION]) + nonce + ct
def decrypt(blob: bytes, aad: bytes) -> str:
version, nonce, ct = blob[0], blob[1:13], blob[13:]
return AESGCM(KEYS[version]).decrypt(nonce, ct, aad).decode()
def blind_index(value: str) -> bytes:
normalized = value.strip().lower()
return hmac.new(INDEX_KEY, normalized.encode(), hashlib.sha256).digest()
def create_user(conn: psycopg.Connection, email: str, phone: str) -> uuid.UUID:
uid = uuid.uuid4()
conn.execute(
"INSERT INTO users (id, email_enc, email_bidx, phone_enc) VALUES (%s, %s, %s, %s)",
(uid,
encrypt(email, f"users:{uid}:email".encode()),
blind_index(email),
encrypt(phone, f"users:{uid}:phone".encode())),
)
return uid
def find_by_email(conn: psycopg.Connection, email: str) -> dict | None:
row = conn.execute(
"SELECT id, email_enc FROM users WHERE email_bidx = %s",
(blind_index(email),),
).fetchone()
if not row:
return None
uid, blob = row
return {"id": uid, "email": decrypt(bytes(blob), f"users:{uid}:email".encode())}
```
Thử dump bảng `users` ra: bạn chỉ thấy toàn `\x01a3f9...`. Bản `pg_dump` có lọt ra ngoài thì cũng chỉ là đống bytes vô dụng nếu không có key.
## Những cái bẫy mình đã gặp
**Nonce reuse.** Với AES-GCM, chỉ cần dùng lại một cặp (key, nonce) là đủ để lộ XOR của hai plaintext, đồng thời mất luôn khả năng authentication. Hãy luôn dùng `os.urandom(12)`, đừng tự chế counter. Nếu một key phải mã hóa hàng tỷ record, cân nhắc chuyển sang **AES-GCM-SIV** hoặc XChaCha20-Poly1305 (nonce 24 bytes).
**Blind index vẫn leak thông tin.** HMAC là deterministic, nên attacker vẫn biết được hai row nào có cùng email. Với field có ít giá trị như giới tính hay tỉnh thành, chỉ cần frequency analysis là đoán ra. Quy tắc của mình: **chỉ tạo blind index cho field có cardinality cao** và thực sự cần tìm kiếm chính xác. Các field còn lại thì chỉ encrypt.
**Không còn LIKE, không còn ORDER BY.** Tìm kiếm kiểu `email LIKE '%@gmail.com'` không làm được nữa. Nếu business thật sự cần, hãy tạo thêm một blind index riêng cho domain, đừng vì thế mà bỏ mã hóa.
**Key rotation.** Thêm `PII_KEY_V2` vào dict `KEYS` và đổi `CURRENT_VERSION = 2`. Dữ liệu mới sẽ dùng key mới, dữ liệu cũ vẫn decrypt được nhờ byte version. Sau đó chạy một background job re-encrypt dần theo batch (`WHERE get_byte(email_enc, 0) = 1 LIMIT 1000`). Riêng index key thì rotate tốn công hơn nhiều vì phải tính lại toàn bộ index, nên hãy bảo vệ nó thật kỹ ngay từ đầu.
**Plaintext lọt qua cửa sau.** Mã hóa DB xong mà vẫn log `logger.info(f"User {email} signed up")`, hoặc đẩy nguyên payload lên Sentry, thì cũng như không. Hãy grep log, error tracker, analytics events và message queue. PII thường rò rỉ ở những chỗ đó chứ không phải ở DB.
**Hiệu năng.** AES-GCM có AES-NI hỗ trợ trên hầu hết CPU hiện đại, mỗi lần encrypt/decrypt chỉ mất cỡ micro giây. Bottleneck thật sự thường là việc fetch key từ KMS. Hãy cache key trong memory lúc app khởi động, đừng gọi KMS cho từng request.
## Kết luận
Breach là chuyện "khi nào" chứ không phải "có hay không". Mục tiêu thực tế là khi DB bị lộ thì thứ lộ ra phải là thứ vô dụng. Checklist cho tuần này:
- **Liệt kê các cột PII** trong schema: email, phone, CCCD, địa chỉ, ngày sinh. Đánh dấu cột nào cần search exact match.
- **Generate hai key riêng biệt** (encryption + index), cất vào secret manager (AWS Secrets Manager, GCP Secret Manager, Vault). Không để chung server với DB, không commit vào git.
- **Dùng AES-256-GCM kèm AAD** gắn với `table:id:column`, prefix byte version ngay từ ngày đầu để sau này rotate key cho dễ.
- **Chỉ tạo blind index cho field high-cardinality** và luôn normalize (`strip().lower()`) trước khi HMAC.
- **Audit log và error tracker** để chắc chắn PII không bị ghi ra dạng plaintext ở đó.
- **Bắt đầu từ bảng nhạy cảm nhất** (thường là `users`), migrate dần bằng dual-write chứ đừng big-bang.
Làm hết những việc này mất khoảng vài ngày công. So với cái giá phải trả khi tên công ty bạn lên trang nhất HN vì một vụ breach, khoản đầu tư này rẻ hơn rất nhiều.
All Rights Reserved