AES và AEAD: mã hóa card token và PII trong DB

4 câu hỏi~30 phútTừ khóa: AES, block cipher, ECB, CBC, GCM, IV, nonce reuse, AEAD, AAD, padding oracle, envelope encryption, DEK, KEK

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 đề

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:

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

ModeBí mậtIntegrityPaddingGhi chú
ECBLộ mẫuKhôngCóKhông bao giờ dùng
CBCCó (IV ngẫu nhiên)Không, cần thêm HMAC (encrypt-then-MAC)Có, rủi ro padding oracleHệ thống cũ
CTRCóKhôngKhôngNền của GCM
GCMCóCó (tag)KhôngMặc định nên dùng; nonce reuse là thảm họa
ChaCha20-Poly1305CóCóKhôngNhanh 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ế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:

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ớpChống đượcKhông chống được
TLS (bài TLS)Nghe lén trên mạngAi đọc DB
Disk / TDE encryptionMất ổ cứng, snapshot đĩa bị lộDBA, SQL injection, backup logic (pg_dump)
Application-level (AES-GCM)DBA, dump, SQL injection đọc cộtKẻ 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


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.