Mã hóa dữ liệu lúc lưu: ổ đĩa, database, từng cột, và chìa khóa để ở đâu

Mỗi lớp mã hóa (ổ đĩa, database, từng cột) chống được ai và không chống được ai. Envelope encryption với KMS, đổi khóa, blind index để vẫn tra cứu được, thí nghiệm mã hóa cột email trong SQLite, và vì sao SQL injection vẫn đọc được dữ liệu đã mã hóa.

Bài trước bảo vệ dữ liệu đang đi trên mạng. Nhưng TLS kết thúc ở máy chủ, và từ đó dữ liệu được ghi xuống database, file log, bản sao lưu, ổ đĩa của nhà cung cấp cloud. Bài này nói về dữ liệu đang nằm yên (at rest).

Câu hỏi quan trọng nhất của bài không phải "dùng thuật toán gì" (câu trả lời gần như luôn là AES-256-GCM), mà là hai câu khác: đang chống ai, và khóa nằm ở đâu. Mã hóa mà khóa để ngay cạnh dữ liệu thì cũng như khóa cửa rồi treo chìa trên tay nắm.

Lộ trình: ba lớp mã hóa và mỗi lớp chống được gì, envelope encryption và KMS, đổi khóa, thí nghiệm mã hóa từng cột trong SQLite (đo chi phí), tra cứu trên dữ liệu đã mã hóa bằng blind index, giới hạn của mã hóa lúc lưu, bản sao lưu và xóa dữ liệu, rồi trang này đang làm gì. Thí nghiệm chạy bằng Python 3.12, SQLite và thư viện cryptography trên máy thật.

Ba lớp, ba kẻ tấn công

Lớp Ví dụ Chống được Không chống được
Ổ đĩa / volume BitLocker, FileVault, LUKS, ổ EBS mã hóa Mất laptop, ổ cứng cũ bị vứt hoặc bán, nhân viên trung tâm dữ liệu rút ổ Bất kỳ ai đăng nhập được vào máy đang chạy: ổ đã được mở khóa
Database (TDE) Transparent Data Encryption trong SQL Server, Oracle, MySQL; SQLCipher cho SQLite Ai lấy được file database hoặc bản sao lưu mà không có khóa Ai có quyền truy vấn database: nó giải mã giúp
Ứng dụng / từng cột App mã hóa trường ssn, email trước khi ghi Admin database, bản sao lưu, công cụ phân tích, log truy vấn chậm, nhà cung cấp database Lỗi trong chính ứng dụng: ứng dụng có khóa và giải mã giúp

Mỗi lớp lên cao thì ít người nhìn thấy dữ liệu rõ hơn, nhưng cũng tốn công hơn: lớp ổ đĩa gần như miễn phí và vô hình, còn lớp ứng dụng làm mất khả năng tìm kiếm, sắp xếp và đánh chỉ mục.

Thực tế hay gặp: bật mã hóa ổ đĩa ở mọi nơi (đa số cloud bật sẵn), và mã hóa ở tầng ứng dụng cho một số ít cột rất nhạy cảm như số căn cước, số thẻ, token của bên thứ ba hay ghi chú y tế. Không mã hóa cả database ở tầng ứng dụng.

Khóa để ở đâu: envelope encryption

Ứng dụng cần khóa để giải mã. Nếu khóa nằm trong file cấu hình trên cùng máy chủ, kẻ chiếm máy chủ có cả hai. Nếu khóa nằm trong database, càng tệ hơn.

Lời giải chuẩn là envelope encryption (mã hóa phong bì), với hai tầng khóa:

  • DEK (data encryption key): khóa mã hóa dữ liệu thật. Có thể có nhiều DEK, ví dụ mỗi bảng, mỗi khách hàng hoặc mỗi file một khóa. DEK được lưu ngay cạnh dữ liệu, nhưng ở dạng đã bị bọc (mã hóa) bởi KEK.
  • KEK (key encryption key), hay khóa chủ: chỉ dùng để bọc và mở bọc DEK. KEK không bao giờ rời khỏi KMS, tức dịch vụ quản lý khóa như AWS KMS, Google Cloud KMS, HashiCorp Vault, hay một module phần cứng (HSM).
Khởi động:   app ──"mở bọc DEK này"──► KMS (giữ KEK, ghi log, kiểm tra quyền) ──DEK rõ──► app (giữ trong RAM)
Lúc chạy:    app mã hóa / giải mã dữ liệu bằng DEK, không gọi KMS nữa

Vì sao phải hai tầng mà không mã hóa thẳng bằng KMS?

  • Tốc độ: mỗi lần gọi KMS là một request mạng mất vài mili giây. Mã hóa bằng DEK trong RAM chỉ mất vài micro giây (đo ở dưới).
  • Kiểm soát: KMS ghi log mọi lần mở bọc, giới hạn máy nào được gọi, và có thể thu hồi quyền. Kẻ ăn cắp được bản sao database cùng mọi DEK đã bọc vẫn không làm được gì nếu không gọi được KMS.
  • Đổi khóa rẻ: phần dưới.

Đổi khóa

Khóa nên được thay định kỳ, và bắt buộc thay khi nghi bị lộ. Với envelope encryption có hai mức:

Đổi gì Làm gì Đo với 20.000 dòng
KEK Mở bọc DEK bằng KEK cũ, bọc lại bằng KEK mới. Dữ liệu không đổi 3 µs (chỉ bọc lại 1 DEK)
DEK Giải mã và mã hóa lại từng dòng 45 ms

Đổi KEK gần như miễn phí, bất kể dữ liệu lớn cỡ nào. Đổi DEK thì tốn tỷ lệ với lượng dữ liệu, và với hàng tỷ dòng phải chạy dần trong nền. Vì vậy mỗi bản mã nên ghi kèm phiên bản khóa đã dùng, để dữ liệu cũ và mới cùng đọc được trong lúc chuyển. Ý tưởng giống quy tắc migration tương thích ngược khi deploy không downtime: cái cũ và cái mới phải cùng chạy được trong lúc chuyển.

Thí nghiệm: mã hóa cột email trong SQLite

Một bảng 20.000 người dùng. Bản thường lưu email dạng rõ, có chỉ mục UNIQUE. Bản mã hóa lưu email qua AES-256-GCM, với DEK được bọc bởi một KEK giả lập vai trò của KMS:

def encrypt_email(row_id: int, email: str) -> bytes:
    nonce = os.urandom(12)
    aad = f"user:{row_id}:email".encode()        # gắn bản mã vào đúng dòng, đúng cột
    return nonce + dek.encrypt(nonce, email.encode(), aad)

AAD (dữ liệu đi kèm được xác thực) là một chi tiết nhỏ mà quan trọng. Nếu không có nó, ai sửa được database có thể chép bản mã email của người A sang dòng của người B, và ứng dụng sẽ giải mã ra đúng email của A ở dòng B. Có AAD chứa mã dòng thì việc đó thất bại:

ciphertext moved to another row -> InvalidTag (AAD binds row id)

Đo chi phí:

Không mã hóa Mã hóa cột
Ghi 20.000 dòng 8,9 ms 53,2 ms (+2,2 µs mỗi dòng)
Tra theo email 0,59 µs 2,10 µs (băm blind index + truy vấn + giải mã)
Kích thước email 17 B 45 B (+12 B nonce, +16 B thẻ) + 16 B blind index

Thêm khoảng 2 µs mỗi dòng: không đáng kể so với một request web vài chục mili giây. Chi phí thật không nằm ở CPU, mà ở những thứ mất đi, phần tiếp theo.

Tra cứu trên dữ liệu đã mã hóa: blind index

Mỗi bản mã GCM có nonce ngẫu nhiên, nên cùng email mã hóa hai lần ra hai bản mã khác nhau. Đó là điều tốt, nhưng nó cũng có nghĩa là WHERE email = ? không còn chạy được.

Blind index giải quyết việc tra cứu chính xác: lưu thêm một cột là HMAC (bài 2) của giá trị, với một khóa riêng khác DEK:

def blind_index(email: str) -> bytes:
    return hmac.new(index_key, email.lower().encode(), hashlib.sha256).digest()[:16]

# tra cứu: WHERE email_idx = blind_index(input), rồi giải mã cột email

HMAC tất định (cùng email ra cùng chỉ mục), nên đánh được UNIQUE và tìm được bằng phép bằng. Không có index_key thì không tính được chỉ mục cho một email đoán thử. Nhưng có những thứ vẫn mất:

Thao tác Còn làm được?
WHERE email = ? Có, qua blind index
UNIQUE Có, trên blind index
LIKE '%example%' Không (trong thí nghiệm, đếm ra 0 dòng)
ORDER BY email, so sánh lớn hơn hay nhỏ hơn Không
Thống kê phân bố Một phần bị lộ: hai dòng có cùng blind index nghĩa là cùng giá trị

Dòng cuối là một rò rỉ thật. Với cột ít giá trị như giới tính hay tỉnh thành, blind index để lộ gần như toàn bộ phân bố, nên đừng dùng nó cho loại cột này. Có những kỹ thuật cao cấp hơn như mã hóa giữ thứ tự hay mã hóa đồng cấu, nhưng mỗi cái đều đánh đổi thêm độ an toàn hoặc rất chậm. Quy tắc thực tế: chỉ mã hóa ở tầng ứng dụng những cột không cần tìm kiếm phức tạp.

Giới hạn: ứng dụng giải mã giúp kẻ tấn công

Đây là điều hay bị hiểu nhầm nhất. Nếu ứng dụng có lỗi SQL injection hoặc một API trả về dữ liệu của người khác, mã hóa cột không giúp được gì. Kẻ tấn công không đọc file database. Họ nhờ ứng dụng đọc giúp, và ứng dụng thì có DEK trong RAM, giải mã như với mọi request khác.

Mã hóa lúc lưu chống được những người đi vòng qua ứng dụng: ăn cắp bản sao lưu, admin database tò mò, ổ đĩa bị vứt, một bản dump bị lỡ đẩy lên chỗ công khai. Chống lỗi của chính ứng dụng là việc của truy vấn có tham số, kiểm tra quyền cho từng đối tượng, và giới hạn những gì mỗi API trả về (bài 9).

Bản sao lưu, log, và xóa dữ liệu

  • Bản sao lưu thường là chỗ rò rỉ lớn nhất: chúng nằm lâu, được chép đi nhiều nơi, và ít được canh giữ hơn database chính. Mã hóa bản sao lưu bằng một khóa khác, giữ ở chỗ khác. Đồng thời phải thử khôi phục định kỳ: bản sao lưu đã mã hóa mà mất khóa thì cũng như không có bản sao lưu.
  • Log là nơi dữ liệu nhạy cảm hay lọt vào nhất: request body, câu lệnh SQL kèm giá trị, thông báo lỗi. Che dữ liệu trước khi ghi log quan trọng không kém mã hóa database.
  • Xóa bằng cách hủy khóa (crypto-shredding): nếu dữ liệu của mỗi khách hàng có DEK riêng, xóa DEK là dữ liệu đó không còn đọc được ở mọi nơi, kể cả trong các bản sao lưu cũ. Đây là cách thực tế để đáp ứng quyền được xóa dữ liệu (như trong GDPR) khi bản sao lưu không sửa được.

Trang này đang làm gì

Dữ liệu nhạy cảm của viet.sarl khá ít: email, tên hiển thị, bản băm Argon2id của mật khẩu (không phải mật khẩu, bài 3), và liên kết với tài khoản Google. Không có số thẻ hay số căn cước. Trang không mã hóa cột nào ở tầng ứng dụng, và với lượng dữ liệu này, đó là một đánh đổi hợp lý. Những việc quan trọng hơn ở đây là:

  • Secret (JWT_SECRET, khóa Google OAuth, mật khẩu SMTP) nằm trong .env.production trên máy chủ, không bao giờ vào git (.gitignore chặn).
  • Ứng dụng từ chối khởi động ở môi trường production nếu JWT_SECRET ngắn hơn 32 ký tự hoặc vẫn là secret mặc định cho môi trường dev.
  • Bản sao lưu SQLite phải được đối xử như database chính: chúng chứa email và bản băm mật khẩu.

Nếu sau này cần lưu thứ thật sự nhạy cảm, ví dụ token truy cập API của người dùng ở một dịch vụ khác, thì đó là lúc dùng đúng mẫu trong thí nghiệm: AES-GCM, AAD gắn với dòng, DEK bọc bởi một KEK nằm ngoài máy chủ.

Tóm lại

  • Ba lớp: ổ đĩa (chống mất phần cứng), database (chống mất file và bản sao lưu), ứng dụng (chống cả admin database). Lớp càng cao càng ít người thấy dữ liệu, nhưng càng mất nhiều tính năng.
  • Câu hỏi quan trọng là khóa nằm ở đâu. Envelope encryption: DEK mã hóa dữ liệu, KEK trong KMS bọc DEK.
  • Đổi KEK: 3 µs (bọc lại 1 DEK). Đổi DEK: mã hóa lại mọi dòng (45 ms cho 20.000 dòng). Ghi phiên bản khóa vào bản mã.
  • Mã hóa một cột tốn thêm khoảng 2,2 µs mỗi dòng. AAD chặn việc chép bản mã sang dòng khác.
  • Blind index (HMAC với khóa riêng) cho tra cứu chính xác và UNIQUE, nhưng mất LIKE, sắp xếp, và lộ tần suất.
  • Mã hóa lúc lưu không chống được lỗi ứng dụng, vì ứng dụng giải mã giúp kẻ tấn công. Bản sao lưu và log là chỗ rò rỉ hay gặp nhất.

Bài tiếp theo quay về ứng dụng: "Đăng nhập bằng Google" chạy thế nào. OAuth 2.0 và OpenID Connect, luồng authorization code với PKCE theo code thật của trang này, và vai trò của state.