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 }

Ảnh chụp đoạn mã nền tối minh hoạ xoay khoá không khoá nào nên sống mãi làm sao xoay mà không mã hoá lại tất cả, vấn đề mã hoá lại toàn bộ kho là bất khả thi khoá cần xoay định kỳ lộ khoá tuân thủ giới hạn thiệt hại cách ngây thơ đổi khoá giải mã rồi mã hoá lại cả triệu bản ghi tốn kém rủi ro phải dừng dịch vụ, giải pháp bao khoá envelope encryption hai tầng KEK Key Encryption Key khoá gốc ít dùng chỉ để bọc các DEK DEK Data Encryption Key khoá con mã hoá dữ liệu thật mỗi bản ghi lưu kek_id cộng ciphertext cộng DEK đã bọc dek bằng random ct bằng AESGCM dek encrypt data wrapped bằng AESGCM KEK id encrypt dek bọc DEK bằng KEK, xoay bằng thêm KEK mới cộng re-wrap DEK không đụng dữ liệu dữ liệu mới dùng kek-v2 dữ liệu cũ vẫn giải bằng kek-v1 re-wrap mở DEK bằng v1 rồi bọc lại bằng v2 chỉ vài chục byte DEK đổi ciphertext dữ liệu giữ nguyên xong hết mới thu hồi xoá kek-v1

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:

Ảnh chụp bảng kết quả chạy thật envelope encryption cộng xoay khoá output thật, một ban đầu chỉ có kek-v1 hai bản ghi cũ rec_a kek_id kek-v1 giải Ho so khach hang A rec_b kek_id kek-v1 giải Ho so khach hang B, hai xoay sang kek-v2 dữ liệu mới dùng v2 cũ vẫn đọc được rec_c kek_id kek-v2 giải Ho so khach hang C moi rec_a cũ v1 vẫn giải được Ho so khach hang A không phải mã hoá lại toàn bộ kho ngay lập tức, ba re-wrap rec_a v1 sang v2 chỉ bọc lại DEK không mã hoá lại dữ liệu dek_wrapped đổi 4841ed29ab1d9db4 sang c007c983dc61a4ef rec_a kek_id giờ kek-v2 giải vẫn ra Ho so khach hang A, bốn thu hồi kek-v1 bản chưa re-wrap thì hỏng bản đã re-wrap vẫn chạy rec_b chưa re-wrap còn trỏ v1 không giải được KeyError kek-v1 rec_a đã re-wrap sang v2 giải bình thường Ho so khach hang A bài học chỉ thu hồi khoá cũ sau khi mọi bản ghi đã re-wrap

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:

  1. Ban đầu: có kek-v1, hai bản ghi được mã hoá, giải ra đúng nội dung.
  2. 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.
  3. 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.
  4. 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ề

  1. 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.
  2. 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.
  3. 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

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.