Không khoá mã hoá nào nên sống mãi. Khoá càng dùng lâu, càng nhiều dữ liệu phụ thuộc vào nó, và một lần lộ càng thảm khốc; các chuẩn tuân thủ (PCI-DSS, v.v.) cũng bắt xoay khoá định kỳ. Nhưng đây là lúc nhiều người bế tắc: "đổi khoá thì chẳng phải phải giải mã rồi mã hoá lại toàn bộ hàng triệu bản ghi sao?" — một việc tốn kém, rủi ro, phải dừng dịch vụ. May thay, có một mô hình khiến xoay khoá thành thao tác rẻ và an toàn: bao khoá (envelope encryption). Bài này (phần 16 loạt Mật mã) dựng demo thật bằng python để thấy cách nó hoạt động qua một chu trình xoay khoá đầy đủ.
Vì sao "mã hoá lại tất cả" là cách sai
Nếu bạn mã hoá mọi bản ghi trực tiếp bằng một khoá chính duy nhất, thì xoay khoá bắt buộc phải đọc từng bản ghi, giải mã, mã hoá lại bằng khoá mới, ghi đè. Với kho lớn: hàng giờ chạy, rủi ro hỏng giữa chừng, và trong lúc đó khoá cũ lẫn mới đều phải sống. Không hệ thống nghiêm túc nào làm vậy mỗi kỳ xoay khoá.
Bao khoá: hai tầng KEK và DEK
Ý tưởng cốt lõi: đừng mã hoá dữ liệu trực tiếp bằng khoá chính. Thay vào đó dùng hai tầng:
- DEK (Data Encryption Key): khoá con, sinh mới cho mỗi bản ghi (hoặc mỗi tệp), dùng để mã hoá dữ liệu thật.
- KEK (Key Encryption Key): khoá gốc, ít dùng, chỉ để bọc (wrap) các DEK — tức mã hoá chính DEK.
Mỗi bản ghi lưu: kek_id (biết dùng KEK nào) + ciphertext dữ liệu + DEK đã bị bọc. Khoá chính (KEK) không bao giờ chạm trực tiếp vào dữ liệu.
dek = AESGCM.generate_key() # DEK mới mỗi bản ghi
ct = AESGCM(dek).encrypt(n1, data) # dữ liệu <- DEK
wrapped = AESGCM(KEK[kek_id]).encrypt(n2, dek) # DEK <- KEK (bọc)
# lưu: { kek_id, ct, wrapped, n1, n2 }

Hình 1: Bao khoá hai tầng — KEK bọc DEK, DEK mã hoá dữ liệu; mỗi bản ghi lưu kek_id. Xoay khoá chỉ cần re-wrap DEK (vài chục byte), không đụng ciphertext dữ liệu.
Đo thật: một chu trình xoay khoá đầy đủ
Mình chạy đủ bốn bước trên dữ liệu thật:

Hình 2: Chạy thật — (1) kek-v1 mã hoá hai bản ghi; (2) xoay sang kek-v2, dữ liệu mới dùng v2 nhưng rec_a cũ (v1) vẫn giải được; (3) re-wrap rec_a: chỉ dek_wrapped đổi (4841... → c007...), dữ liệu giữ nguyên; (4) thu hồi kek-v1 → rec_b (chưa re-wrap) hỏng, rec_a (đã re-wrap) vẫn chạy.
Bốn bước, tất cả đo thật:
- Ban đầu: có
kek-v1, hai bản ghi được mã hoá, giải ra đúng nội dung. - Xoay sang kek-v2: chỉ thêm một KEK mới. Dữ liệu mới (rec_c) dùng v2; dữ liệu cũ (rec_a, v1) vẫn giải được bình thường. Không cần đụng vào kho ngay — hệ thống hỗ trợ nhiều phiên bản khoá cùng lúc nhờ
kek_id. - Re-wrap (chuyển dần bản ghi cũ sang v2): mở DEK bằng v1 rồi bọc lại bằng v2. Chỉ trường
dek_wrappedđổi (thấy rõ4841...→c007...), còn ciphertext của dữ liệu không hề bị mã hoá lại. Đây là mấu chốt: re-wrap thao tác trên vài chục byte DEK, không phải trên toàn bộ dữ liệu. - Thu hồi kek-v1: sau khi mọi bản ghi đã re-wrap, xoá v1 khỏi kho. Thử nghiệm cho thấy rec_b (mình cố tình chưa re-wrap) giờ không giải được nữa (
KeyError kek-v1), còn rec_a (đã re-wrap) vẫn chạy. Bài học rõ ràng: chỉ thu hồi khoá cũ sau khi chắc chắn không còn bản ghi nào trỏ vào nó.
Vì sao mô hình này thắng
Xoay khoá gốc (KEK) giờ là thao tác rẻ: bạn re-wrap các DEK (mỗi cái vài chục byte) thay vì mã hoá lại gigabyte dữ liệu. Trong lúc chuyển, hệ thống đọc được cả dữ liệu cũ lẫn mới nhờ kek_id — không downtime. Và vì mỗi bản ghi có DEK riêng, lộ một DEK chỉ ảnh hưởng một bản ghi, không phải cả kho. Đây chính là mô hình mà các dịch vụ KMS (AWS KMS, Google Cloud KMS, HashiCorp Vault) dùng: KEK nằm trong KMS/HSM không bao giờ rời ra, dịch vụ chỉ gửi DEK lên để bọc/mở.
Đánh đổi cần cân nhắc
Xoay khoá gốc (re-wrap DEK) khác với thay dữ liệu (re-encrypt). Re-wrap rẻ và nên làm định kỳ. Nhưng nếu chính một DEK bị lộ, re-wrap không cứu được — dữ liệu bản ghi đó đã mã hoá bằng DEK cũ, phải thực sự mã hoá lại bằng DEK mới. May là DEK theo từng bản ghi nên phạm vi hẹp. Hiểu rõ mình đang xoay tầng nào.
Phải theo dõi bản ghi nào còn dùng khoá cũ trước khi thu hồi. Như demo cho thấy, thu hồi KEK khi còn bản ghi trỏ vào nó là mất dữ liệu vĩnh viễn. Cần một cơ chế đếm/quét (background job re-wrap dần) và chỉ xoá khoá cũ khi số bản ghi dùng nó về 0. Đừng xoá theo lịch cứng mà không kiểm.
Đừng tự dựng KMS cho production nếu tránh được. Demo này minh hoạ nguyên lý; hệ thật nên để KEK trong một KMS/HSM chuyên dụng — nơi khoá gốc không bao giờ lộ ra ngoài, có kiểm toán truy cập, và tự lo phần lưu trữ khoá an toàn. Tự quản khoá gốc trong file/biến môi trường là dời rủi ro chứ không loại bỏ nó.
Ba ý mang về
- Bao khoá tách khoá chính khỏi dữ liệu: KEK bọc DEK, DEK mã hoá dữ liệu, mỗi bản ghi lưu
kek_id. Nhờ đó xoay khoá gốc không cần mã hoá lại kho — đo thật, chỉdek_wrappedđổi còn ciphertext dữ liệu giữ nguyên. - Nhiều phiên bản khoá sống song song trong lúc chuyển: đo thật, sau khi thêm kek-v2, dữ liệu mới dùng v2 mà dữ liệu cũ (v1) vẫn giải được — không downtime, re-wrap dần trong nền.
- Chỉ thu hồi khoá cũ khi không còn ai dùng: đo thật, xoá kek-v1 làm bản ghi chưa re-wrap hỏng vĩnh viễn (
KeyError) trong khi bản đã re-wrap vẫn chạy — phải quét hết trước khi thu hồi, và nên để KEK trong KMS/HSM thật.
Nguồn
- Google Cloud — Envelope encryption: https://cloud.google.com/kms/docs/envelope-encryption
- AWS — AWS KMS concepts: envelope encryption: https://docs.aws.amazon.com/kms/latest/developerguide/concepts.html#enveloping
- NIST SP 800-57 Part 1 — Recommendation for Key Management (cryptoperiods): https://csrc.nist.gov/pubs/sp/800/57/pt1/r5/final
Phần sau ta xem một lỗ hổng tinh vi mà mắt thường không thấy: so sánh bí mật không hằng thời gian rò rỉ thông tin qua thời gian chạy — vì sao == để so token là nguy hiểm, và cách so hằng-thời-gian đúng.