AES và AEAD: mã hóa card token và PII trong DB
Tiếp đi: mã hóa đối xứng. shop-app cần lưu card token của payment provider và số điện thoại khách. Mình viết Cipher.getInstance("AES") là xong đúng không?
Chưa. "AES" không chỉ rõ mode, và với provider mặc định của JDK nó thành AES/ECB/PKCS5Padding, mode tệ nhất. Cùng xem vì sao.
1. Bài toán
payment_method
id | customer_id | card_token | last4
7 | 1 | tok_live_9f8a... | 4242
card_token dùng để charge lại thẻ. Lộ token = kẻ tấn công charge được (tùy provider). Cần mã hóa ở tầng ứng dụng, để backup và DBA không đọc được.
2. Vấn đề
- ECB mã hóa từng block 16 bytes độc lập: cùng plaintext block → cùng ciphertext block. Lộ cấu trúc (ví dụ kinh điển: ảnh chim cánh cụt vẫn thấy rõ sau ECB). Hai khách cùng số điện thoại có cùng ciphertext.
- CBC không có integrity: kẻ tấn công lật bit ciphertext để sửa plaintext, và nếu server báo lỗi padding khác lỗi khác thì bị padding oracle, giải mã được toàn bộ.
- Không có integrity nghĩa là kẻ sửa DB có thể tráo
card_tokencủa khách A sang dòng khách B.
3. Giải pháp: AES-GCM (AEAD)
AES là block cipher đối xứng: cùng một key để mã hóa và giải mã, key 128/192/256 bit, block 128 bit. AES cần một mode để mã hóa dữ liệu dài hơn một block.
AEAD (Authenticated Encryption with Associated Data) = mã hóa + chống sửa trong một bước:
- Ciphertext: bí mật.
- Tag 128 bit: sửa 1 bit ciphertext hay AAD là giải mã ném lỗi.
- AAD (associated data): dữ liệu không mã hóa nhưng được gắn vào tag, ví dụ
"payment_method:7". Tráo ciphertext sang dòng khác sẽ verify fail. - IV/nonce 12 bytes: không bao giờ được lặp lại với cùng key.
4. Code từng bước
Bước 1: Dependency: không cần, javax.crypto có sẵn.
Bước 2: Config. Key 256 bit, Base64, từ env (production: từ KMS, bài 13.6):
shop:
crypto:
pii-key: ${PII_KEY} # openssl rand -base64 32
pii-key-id: k2
Bước 3: Code Java
@Component
public class AesGcmCrypto {
private static final int IV_LEN = 12, TAG_BITS = 128;
private static final SecureRandom RNG = new SecureRandom();
private final SecretKey key;
private final byte keyId;
public AesGcmCrypto(@Value("${shop.crypto.pii-key}") String keyB64) {
this.key = new SecretKeySpec(Base64.getDecoder().decode(keyB64), "AES");
this.keyId = 2;
}
/** Output: [keyId 1B][iv 12B][ciphertext + tag 16B], Base64 */
public String encrypt(String plaintext, String aad) {
try {
byte[] iv = new byte[IV_LEN];
RNG.nextBytes(iv); // IV mới mỗi lần
Cipher c = Cipher.getInstance("AES/GCM/NoPadding");
c.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, iv));
c.updateAAD(aad.getBytes(StandardCharsets.UTF_8));
byte[] ct = c.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(
ByteBuffer.allocate(1 + IV_LEN + ct.length).put(keyId).put(iv).put(ct).array());
} catch (GeneralSecurityException e) {
throw new IllegalStateException(e);
}
}
public String decrypt(String blobB64, String aad) {
try {
ByteBuffer buf = ByteBuffer.wrap(Base64.getDecoder().decode(blobB64));
byte kid = buf.get(); // chọn key theo kid khi rotate
byte[] iv = new byte[IV_LEN];
buf.get(iv);
byte[] ct = new byte[buf.remaining()];
buf.get(ct);
Cipher c = Cipher.getInstance("AES/GCM/NoPadding");
c.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, iv));
c.updateAAD(aad.getBytes(StandardCharsets.UTF_8));
return new String(c.doFinal(ct), StandardCharsets.UTF_8); // AEADBadTagException nếu bị sửa
} catch (GeneralSecurityException e) {
throw new IllegalStateException("Decrypt failed", e);
}
}
}
Gắn vào JPA bằng AttributeConverter thì không truyền được AAD theo id; với dữ liệu nhạy cảm, gọi encrypt(token, "payment_method:" + id) trong service sẽ rõ ràng hơn.
Bước 4: Test bằng curl
curl -X POST http://localhost:8080/payment-methods \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"cardToken":"tok_test_123","last4":"4242"}'
psql -c "select card_token from payment_method where id=7"
# AjK3... (Base64, mỗi lần insert cùng token ra giá trị khác vì IV ngẫu nhiên)
psql -c "update payment_method set card_token=(select card_token from payment_method where id=8) where id=7"
curl http://localhost:8080/payment-methods/7 -H "Authorization: Bearer $TOKEN"
# 500 Decrypt failed (AAD "payment_method:7" không khớp -> tráo dòng bị phát hiện)
5. Cách hoạt động
ECB: P1 ->[AES_k]-> C1 P2 ->[AES_k]-> C2 (P1 == P2 => C1 == C2) lộ mẫu
CBC: IV ─┐ ┌─ C1 ─┐
P1 ─XOR─[AES_k]┘ P2 ─XOR─[AES_k]─ C2 cần padding, không có tag
GCM (CTR + GHASH):
IV||counter1 ->[AES_k]-> keystream1 XOR P1 = C1
IV||counter2 ->[AES_k]-> keystream2 XOR P2 = C2
GHASH_H(AAD, C1, C2, len) -> Tag
Nonce reuse (cùng key, cùng IV):
C1 = P1 XOR KS, C1' = P1' XOR KS
C1 XOR C1' = P1 XOR P1' -> lộ quan hệ plaintext
+ lộ khóa GHASH H -> giả mạo tag cho mọi message
Vì vậy IV 12 bytes ngẫu nhiên là ổn tới khoảng 232 lần mã hóa với một key; vượt mức đó thì xoay key (hoặc dùng envelope encryption, mỗi bản ghi một key).
6. So sánh
| Mode | Bí mật | Integrity | Padding | Ghi chú |
|---|---|---|---|---|
| ECB | Lộ mẫu | Không | Có | Không bao giờ dùng |
| CBC | Có (IV ngẫu nhiên) | Không, cần thêm HMAC (encrypt-then-MAC) | Có, rủi ro padding oracle | Hệ thống cũ |
| CTR | Có | Không | Không | Nền của GCM |
| GCM | Có | Có (tag) | Không | Mặc định nên dùng; nonce reuse là thảm họa |
| ChaCha20-Poly1305 | Có | Có | Không | Nhanh trên CPU không có AES-NI; Java 11+ hỗ trợ |
7. Khi nào nên dùng / không nên dùng
- Nên: AES-256-GCM cho card token, PII, file nhạy cảm cần đọc lại.
- Không: mã hóa password (bài 13.3). Không tự thêm field mã hóa nếu disk encryption + TLS đã đủ yêu cầu và không cần chống DBA đọc.
- Không tự viết mode, không dùng ECB, không dùng IV cố định hoặc counter reset khi restart.
- Nếu được, dùng thư viện mức cao (Google Tink) thay vì gọi Cipher trực tiếp: nó lo IV, format, key rotation.
Nếu dùng một key AES cho cả bảng, khi phải đổi key (nghi lộ, hoặc chính sách 1 năm/lần) thì phải decrypt rồi encrypt lại hàng triệu dòng à?
Đó chính là lý do có envelope encryption. Hai tầng key:
- DEK (Data Encryption Key): AES key mã hóa dữ liệu, sinh mới cho mỗi bản ghi / mỗi file / mỗi tenant.
- KEK (Key Encryption Key): nằm trong KMS/HSM, không bao giờ rời khỏi đó. Chỉ dùng để mã hóa DEK.
Mã hóa:
DEK = random 256 bit (hoặc KMS GenerateDataKey)
ct = AES-GCM(DEK, data)
wrappedDEK = KMS.Encrypt(KEK, DEK) -> lưu cạnh ct
xóa DEK khỏi bộ nhớ
Giải mã:
DEK = KMS.Decrypt(wrappedDEK) -> có audit log, IAM kiểm soát
data = AES-GCM-decrypt(DEK, ct)
Xoay KEK:
chỉ re-wrap các DEK (32 bytes mỗi cái), dữ liệu không đụng tới
Lợi ích thêm: mỗi DEK mã hóa ít dữ liệu nên không lo giới hạn nonce; xóa DEK của một khách là "crypto-shredding" dữ liệu của họ (hữu ích cho GDPR). Chi tiết KMS ở bài 13.6.
Giữa app và DB đã bật TLS, ổ đĩa DB cũng bật encryption at rest. Vậy mã hóa ở tầng ứng dụng có thừa không?
Không thừa, vì mỗi lớp chống một kiểu tấn công khác nhau:
| Lớp | Chống được | Không chống được |
|---|---|---|
| TLS (bài TLS) | Nghe lén trên mạng | Ai đọc DB |
| Disk / TDE encryption | Mất ổ cứng, snapshot đĩa bị lộ | DBA, SQL injection, backup logic (pg_dump) |
| Application-level (AES-GCM) | DBA, dump, SQL injection đọc cột | Kẻ chiếm được chính app (có key) |
Với card token và PII nhạy cảm, nên có cả ba. Với dữ liệu thường, TLS + disk encryption là đủ.
Phỏng vấn hỏi về AES, mode và AEAD thì trả lời sao?
Q1. Vì sao không dùng ECB, và nên chọn mode nào?
🇻🇳 Tiếng Việt:
ECB mã hóa mỗi block độc lập nên cùng plaintext ra cùng ciphertext, lộ mẫu dữ liệu. CBC thì không có integrity và dễ bị padding oracle. Em dùng AES-GCM, một chế độ AEAD vừa mã hóa vừa có tag chống sửa, với IV 12 byte ngẫu nhiên mới cho mỗi lần.
🇬🇧 English:
ECB encrypts each block independently, so identical plaintext blocks produce identical ciphertext and leak patterns. CBC has no integrity and is prone to padding oracle attacks. I use AES-GCM, an AEAD mode that provides both confidentiality and an authentication tag, with a fresh random 12-byte IV for every encryption.
Q2. Dùng lại nonce trong GCM thì sao?
🇻🇳 Tiếng Việt:
Rất nguy hiểm. Cùng key và nonce sinh cùng keystream, XOR hai ciphertext lộ XOR hai plaintext, và kẻ tấn công còn khôi phục được khóa xác thực để giả mạo tag. Vì vậy nonce phải duy nhất với mỗi key, và cần xoay key trước khoảng 2 mũ 32 lần mã hóa với nonce ngẫu nhiên.
🇬🇧 English:
It is catastrophic. The same key and nonce produce the same keystream, so XORing two ciphertexts reveals the XOR of the plaintexts, and the attacker can also recover the authentication key and forge tags. Nonces must be unique per key, and with random nonces we rotate keys well before about 2^32 encryptions.
Q3. Envelope encryption là gì?
🇻🇳 Tiếng Việt:
Dữ liệu được mã hóa bằng data key, còn data key được mã hóa bằng master key nằm trong KMS. Ta lưu ciphertext cùng data key đã wrap. Master key không rời KMS, mọi lần unwrap đều có audit, và xoay master key chỉ cần re-wrap data key chứ không mã hóa lại dữ liệu.
🇬🇧 English:
Data is encrypted with a data key, and the data key is encrypted with a master key that lives in a KMS. We store the ciphertext together with the wrapped data key. The master key never leaves the KMS, every unwrap is audited, and rotating the master key only requires re-wrapping data keys, not re-encrypting the data.
Cách nhớ ngắn gọn
"AES"trơn = ECB = sai. Viết rõAES/GCM/NoPadding.- GCM: IV 12 bytes mới mỗi lần, tag 128 bit, AAD để gắn ngữ cảnh.
- Nonce reuse = mất cả bí mật lẫn integrity.
- Envelope: DEK mã hóa dữ liệu, KEK trong KMS mã hóa DEK.
Tự kiểm tra
Cipher.getInstance("AES") trên JDK mặc định dùng mode gì?
AES/ECB/PKCS5Padding. Luôn ghi rõ mode.
AAD dùng để làm gì trong ví dụ card token?
Gắn ciphertext với id bản ghi; tráo ciphertext sang dòng khác thì tag không khớp, giải mã thất bại.
IV trong GCM có cần bí mật không?
Không, lưu cạnh ciphertext. Chỉ cần duy nhất với mỗi key.
Vì sao CBC cần thêm HMAC?
CBC không có integrity; không có MAC thì bị sửa bit và padding oracle. Dùng encrypt-then-MAC hoặc chuyển sang GCM.
Xoay KEK trong envelope encryption có phải mã hóa lại dữ liệu không?
Không, chỉ re-wrap các DEK.