Mã hoá dữ liệu nhạy cảm đúng cách với AES-GCM

Cách mã hoá số điện thoại, địa chỉ, ghi chú khách hàng để lỡ lộ file sao lưu cũng không đọc được, kèm code Node.js, Python, PHP đã chạy thật.

Phát LâmĐăng Cập nhật 7 phút đọcTrung cấp
Mã hoá dữ liệu nhạy cảm đúng cách với AES-GCM
Mục lục
  1. Hình dung bằng một chiếc hộp khoá
  2. Làm thế nào
  3. Cho bạn nào viết code
  4. Những lỗi mình hay gặp
  5. Nên làm gì tiếp

Bảng khách hàng của shop có số điện thoại, địa chỉ giao hàng và vài ghi chú nội bộ. File sao lưu database nằm trên một máy khác, hoặc một bạn dev cũ vẫn còn quyền đọc. Nếu ai đó chép được file này, bạn muốn họ chỉ thấy một đống ký tự vô nghĩa. Muốn vậy thì phải mã hoá dữ liệu trước khi lưu, và phải mã hoá đúng cách. Mã hoá sai thì nhìn bên ngoài vẫn thấy toàn ký tự lạ, nhưng người có chút kiến thức vẫn gỡ ra được.

Hộp khoá biến tài liệu thành khối mã hoá kèm con dấu xác thực, với chuỗi chấm nonce khác nhau mỗi lần
Mỗi lần mã hoá dùng một nonce mới; tag là con dấu xác thực.

Hình dung bằng một chiếc hộp khoá

AES-GCM là cách mã hoá mà hầu hết ngôn ngữ lập trình đều có sẵn. Hãy nghĩ nó như chiếc hộp có khoá và có tem niêm phong. Bạn bỏ dữ liệu vào, khoá lại, dán tem. Ai mở hộp mà tem đã bị rách thì biết ngay có người đụng vào. Bốn thứ cần nắm:

Chìa khoá là một dãy 32 byte ngẫu nhiên, giữ kín. Số dùng một lần (gọi là nonce) dài 12 byte, không cần giữ bí mật, nhưng mỗi lần khoá hộp phải dùng một số mới. Ghi chú đi kèm (gọi là AAD) là dòng chữ viết ngoài hộp, ví dụ "đây là dữ liệu của khách số 42". Nó không bị giấu, nhưng nếu ai sửa dòng chữ đó thì tem coi như hỏng. Cuối cùng là con tem niêm phong (gọi là tag), dài 16 byte, do máy tự tạo ra.

Khi mở hộp, nếu chìa sai, nội dung bị sửa một bit, hoặc ghi chú ngoài hộp bị đổi, việc mở sẽ thất bại hẳn. Bạn không bao giờ nhận được dữ liệu "gần đúng".

Làm thế nào

Quy trình gọn như sau.

  1. Sinh chìa khoá 32 byte ngẫu nhiên từ bộ sinh số của hệ điều hành, cất ở nơi quản lý bí mật (biến môi trường, két bí mật của nhà cung cấp cloud), không đưa vào mã nguồn hay file log.
  2. Mỗi lần mã hoá, sinh nonce mới 12 byte ngẫu nhiên.
  3. Gắn ghi chú đi kèm cho đúng ngữ cảnh. Ví dụ số dư của tài khoản 7 thì ghi chú là acct-7.
  4. Lưu cả gói gồm: số phiên bản, nonce, dữ liệu đã mã hoá, tem. Số phiên bản (1 byte) giúp sau này đổi thuật toán hay đổi chìa mà vẫn đọc được dữ liệu cũ.
  5. Khi giải mã, luôn kiểm tra kết quả. Nếu thất bại thì từ chối, đừng cho chạy tiếp.

Nonce lặp là lỗi nặng nhất của AES-GCM. Mình thử mã hoá hai câu khác nhau bằng cùng một chìa và cùng một nonce. Kết quả: trộn hai bản mã bằng phép XOR cho ra đúng thứ mà trộn hai bản gốc cho ra, nghĩa là người ngoài moi được thông tin mà không cần chìa. Nonce ngẫu nhiên 12 byte thì đủ an toàn đến khoảng 4 tỷ lần mã hoá với một chìa (2^32, theo khuyến nghị NIST SP 800-38D); ở mức đó xác suất trùng cỡ 1 trên 8,6 tỷ. Mình cũng sinh thử 1.000.000 nonce và không thấy cặp nào trùng. Nếu khối lượng lớn hơn, hãy đổi chìa định kỳ, hoặc dùng XChaCha20-Poly1305 với nonce 24 byte, chỗ này ngẫu nhiên vô tư (PHP có sẵn trong thư viện sodium).

Còn chìa khoá lấy từ mật khẩu người dùng thì không được dùng thẳng mật khẩu, cũng không được băm SHA-256 một lần, vì mật khẩu ít biến thể nên bị đoán rất nhanh. Phải qua một hàm cố tình chạy chậm và ngốn bộ nhớ, như Argon2id hoặc scrypt, kèm một chuỗi muối ngẫu nhiên 16 byte lưu cạnh dữ liệu. Với một chìa gốc dùng cho nhiều việc, hãy tách ra chìa con riêng cho từng việc bằng HKDF. Mình đã kiểm cả ba hàm này cho kết quả giống nhau byte theo byte giữa các ngôn ngữ: HKDF ở Node, Python, PHP; scrypt ở Node và Python; Argon2id ở Python và PHP. Tham số trong bài chỉ là ví dụ chạy thử, bạn nên đối chiếu OWASP Password Storage Cheat Sheet bản mới nhất.

Cho bạn nào viết code

Cùng một định dạng gói thì mã hoá ở ngôn ngữ này, giải mã ở ngôn ngữ kia được. Mình thử mã hoá một câu tiếng Việt có dấu ở từng ngôn ngữ, rồi giải mã ở cả ba: 9 trên 9 lần ra đúng câu gốc. Dưới đây là phần lõi, đã chạy thật.

Máy bên trái khoá tài liệu thành viên nang, máy bên phải mở ra đúng tài liệu đó, hai máy chung một khoá
Mã hoá ở một ngôn ngữ, giải mã ở ngôn ngữ khác, miễn cùng định dạng và khoá.
// Node.js
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';

export function encrypt(key, text, aad = '') {
  const nonce = randomBytes(12);
  const c = createCipheriv('aes-256-gcm', key, nonce, { authTagLength: 16 });
  c.setAAD(Buffer.from(aad));
  const ct = Buffer.concat([c.update(text, 'utf8'), c.final()]);
  return Buffer.concat([Buffer.from([1]), nonce, ct, c.getAuthTag()]).toString('base64');
}

export function decrypt(key, b64, aad = '') {
  const raw = Buffer.from(b64, 'base64');
  if (raw.length < 29 || raw[0] !== 1) throw new Error('bad format');
  const d = createDecipheriv('aes-256-gcm', key, raw.subarray(1, 13), { authTagLength: 16 });
  d.setAAD(Buffer.from(aad));
  d.setAuthTag(raw.subarray(raw.length - 16));
  return Buffer.concat([d.update(raw.subarray(13, raw.length - 16)), d.final()]).toString('utf8');
}
# Python (gói cryptography)
import base64, os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM

def encrypt(key, text, aad=''):
    nonce = os.urandom(12)
    ct = AESGCM(key).encrypt(nonce, text.encode(), aad.encode())   # đã gồm tem ở cuối
    return base64.b64encode(b'\x01' + nonce + ct).decode()

def decrypt(key, b64, aad=''):
    raw = base64.b64decode(b64)
    if len(raw) < 29 or raw[0] != 1:
        raise ValueError('bad format')
    return AESGCM(key).decrypt(raw[1:13], raw[13:], aad.encode()).decode()
// PHP (openssl)
function enc(string $key, string $plain, string $aad = ''): string {
    $nonce = random_bytes(12);
    $ct = openssl_encrypt($plain, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $nonce, $tag, $aad, 16);
    return base64_encode("\x01" . $nonce . $ct . $tag);
}
function dec(string $key, string $b64, string $aad = ''): string {
    $raw = base64_decode($b64, true);
    if ($raw === false || strlen($raw) < 29 || $raw[0] !== "\x01") throw new RuntimeException('bad format');
    $plain = openssl_decrypt(substr($raw, 13, -16), 'aes-256-gcm', $key, OPENSSL_RAW_DATA,
                             substr($raw, 1, 12), substr($raw, -16), $aad);
    if ($plain === false) throw new RuntimeException('tag mismatch');
    return $plain;
}

Hai chỗ dễ dính bẫy. Một là PHP: openssl_decrypt trả về false khi tem sai chứ không ném lỗi, nên không kiểm là app chạy tiếp với dữ liệu rỗng. Hai là Node: nếu createDecipheriv không khai authTagLength: 16, mình thử và thấy tem bị cắt còn 4 byte vẫn được chấp nhận. Khai đủ thì tem ngắn bị từ chối.

Mình cũng thử ba kiểu phá: đổi ghi chú đi kèm (từ user-42|v1 sang user-43|v1), lật một bit trong dữ liệu, và dùng sai chìa. Cả ba ngôn ngữ đều từ chối.

Những lỗi mình hay gặp

Có một kiểu lỗi mình thấy lặp lại: người ta đặt nonce là một số cố định cho "đỡ lỡ quên", hoặc đếm từ 0 và mỗi lần khởi động lại tiến trình thì đếm lại từ 0. Cả hai đều là dùng lại nonce. Kiểu thứ hai là chọn AES chế độ ECB hoặc CBC mà không có tem xác thực, rồi tự ghép thêm HMAC, hay sai ở chi tiết nhỏ. Kiểu thứ ba là quên ghi chú đi kèm, nên một người có quyền ghi vào database chép được gói mã hoá của khách này sang dòng của khách khác và nó vẫn mở được. Cuối cùng là chìa khoá nằm ngay trong repo, hoặc biến môi trường bị in ra log khi app báo lỗi.

Nên làm gì tiếp

Nếu bạn là chủ shop, câu cần hỏi dev là: dữ liệu nhạy cảm của mình có được mã hoá có tem xác thực không, và chìa khoá cất ở đâu. Nếu bạn là dev, kiểm lại ba chỗ: nonce mới ở mỗi lần mã hoá, có ghi chú đi kèm theo bản ghi, và có xử lý lỗi khi giải mã. Khi cần rà soát toàn bộ phần mã hoá của app theo một danh sách chuẩn, mục A04 trong Tự kiểm tra bảo mật website với OWASP Top 10 (2025) là điểm bắt đầu. Nếu thứ bạn cần là ký xác nhận chứ không phải giấu dữ liệu, xem Thẻ đăng nhập JWT dùng chung giữa Node.js, PHP, Python và C#.

AESBảo mật full-stackNode.jsPythonPHPaes-gcmencryptionaeadnonceargon2hkdfnodejspythonphp

Nguồn tham khảo

  1. NIST SP 800-38D - Recommendation for GCM and GMAC
  2. RFC 5116 - An Interface and Algorithms for Authenticated Encryption
  3. RFC 5869 - HKDF
  4. RFC 9106 - Argon2
  5. RFC 7914 - scrypt
  6. OWASP Password Storage Cheat Sheet
  7. Node.js crypto - createCipheriv, createDecipheriv, hkdfSync, scryptSync
  8. pyca/cryptography - AESGCM
  9. PHP - openssl_encrypt
  10. libsodium - Argon2 password hashing, XChaCha20-Poly1305