Có một nghịch lý ở tim của việc lưu mật khẩu: mọi thứ khác trong lập trình, ta muốn nhanh; riêng băm mật khẩu, ta muốn chậm — và chậm có chủ đích. Dùng SHA-256 hay MD5 để băm mật khẩu (một sai lầm vẫn còn phổ biến) chính là dùng nhầm công cụ, vì chúng được thiết kế để nhanh, và "nhanh" ở đây nghĩa là kẻ tấn công dò được hàng tỉ mật khẩu mỗi giây. Bài này (phần 18, khép lại loạt Mật mã) đo thật bằng Go để thấy vì sao Argon2 — hàm băm mật khẩu hiện đại, chậm và tốn bộ nhớ có chủ đích — là câu trả lời đúng.
Vì sao hàm băm nhanh là thảm hoạ cho mật khẩu
Khi CSDL người dùng bị lộ (chuyện xảy ra thường xuyên), kẻ tấn công cầm trong tay các hash mật khẩu và bắt đầu dò ngược: thử hàng loạt mật khẩu phổ biến, băm chúng, so với hash lộ ra. Nếu bạn băm bằng SHA-256, cuộc dò này nhanh khủng khiếp — SHA-256 sinh ra để băm dữ liệu tốc độ cao, và GPU hiện đại băm được hàng tỉ lần mỗi giây. Mọi mật khẩu trong danh sách phổ biến bị bẻ trong tích tắc.
Băm mật khẩu cần điều ngược lại: chậm (mỗi lần thử tốn thời gian đáng kể) và khó song song hoá (GPU/ASIC không thể nhân tốc độ). Đó chính xác là những gì Argon2 được thiết kế để làm.
Argon2id: chậm cộng "bộ nhớ cứng"
Argon2 (bản khuyến nghị là Argon2id) có ba tham số điều chỉnh chi phí:
t(time cost): số vòng lặp — càng nhiều càng chậm.mem(memory cost): lượng RAM mỗi lần băm chiếm, tính KiB — đây là điểm mấu chốt.par(parallelism): số luồng song song.
argon2.IDKey(pw, salt, t, mem, par, 32) // Argon2id, ra 32 byte
Tính bộ nhớ cứng (memory-hard) là vũ khí chính. Mỗi lần băm chiếm hàng chục MiB RAM. GPU có hàng nghìn lõi tính toán, nhưng thiếu RAM để cấp cho mỗi lõi vài chục MiB cùng lúc — nên chúng không thể chạy nghìn phép băm song song như với SHA-256. Chi phí bộ nhớ vô hiệu hoá đúng lợi thế của phần cứng dò mật khẩu.

Hình 1: Băm mật khẩu cần chậm và khó song song, ngược với SHA-256. Argon2id điều chỉnh bằng t (vòng), mem (bộ nhớ — vũ khí chống GPU), par (luồng); luôn kèm salt ngẫu nhiên và so kết quả bằng hàm hằng thời gian.
Đo thật: Argon2 chậm hơn SHA-256 gần 559.000 lần
Mình đo bằng Go với tham số khuyến nghị OWASP (Argon2id, t=2, mem=19 MiB, par=1):

Hình 2: Chạy thật — Argon2id băm 1 lần mất 18,3 ms; xác minh đúng/sai cho true/false; tăng bộ nhớ 8→128 MiB kéo thời gian 3,7 → 323,9 ms; SHA-256 chỉ 32,8 ns/lần (~31 triệu lần/giây/lõi), tức Argon2id chậm hơn ~559.000 lần.
Ba điều rút ra, tất cả đo thật:
- Một lần băm SHA-256 mất 32,8 ns — khoảng 31 triệu lần/giây trên một lõi CPU (GPU còn nhanh hơn nhiều bậc). Đây là vì sao SHA-256 là "mồi ngon" cho dò mật khẩu.
- Argon2id với tham số OWASP mất 18,3 ms — chậm hơn SHA-256 khoảng 559.000 lần. Với người dùng thật đăng nhập, 18 ms là không đáng kể; với kẻ dò hàng triệu mật khẩu, nó là bức tường.
- Tăng
mem/tkéo dài thời gian tuyến tính: 3,7 ms (8 MiB) → 22,1 ms (19 MiB) → 120,9 ms (64 MiB) → 323,9 ms (128 MiB). Bạn tự chỉnh chi phí này để cân giữa trải nghiệm người dùng và độ khó cho kẻ tấn công.
Hai thứ không bao giờ được quên: salt và so hằng thời gian
Argon2 mạnh nhưng chỉ đúng khi dùng kèm hai thứ:
- Salt ngẫu nhiên mỗi mật khẩu (16 byte trong demo): khiến hai người dùng cùng mật khẩu có hash khác nhau, và vô hiệu hoá "rainbow table" (bảng hash tính sẵn). Không salt là hỏng dù thuật toán tốt.
- So kết quả bằng hàm hằng thời gian (
subtle.ConstantTimeCompare) — đúng bài học phần trước: so hash bằng==mở ra kênh phụ thời gian.
Đánh đổi cần cân nhắc
Tham số phải chỉnh theo phần cứng server của bạn, không copy mù. Con số OWASP (19 MiB, t=2) là sàn khuyến nghị. Hãy đo trên chính server production và tăng mem/t tới mức mỗi lần băm khoảng vài chục ms — đủ chậm cho kẻ tấn công nhưng người dùng không thấy trễ. Đặt quá cao (như 324 ms/lần ở demo) có thể khiến server sập dưới tải đăng nhập lớn; đặt quá thấp thì không bảo vệ đủ. Đây là bài toán cân bằng, cần đo lại định kỳ khi phần cứng mạnh lên.
Argon2id là lựa chọn mặc định, nhưng bcrypt/scrypt vẫn ổn. Nếu ngôn ngữ/thư viện của bạn chưa có Argon2 chín muồi, bcrypt (giới hạn 72 byte đầu vào) hoặc scrypt vẫn an toàn hơn xa hàm băm thường. Điều cấm kỵ là dùng SHA/MD5 trần cho mật khẩu — bất kể "có thêm salt" cũng không cứu được vì chúng vẫn quá nhanh.
Băm mật khẩu chỉ là một lớp. Nó bảo vệ khi CSDL đã lộ, nhưng không thay cho: giới hạn số lần đăng nhập sai, phát hiện dò mật khẩu, xác thực hai yếu tố, và không lưu mật khẩu ở nơi không cần. Phòng thủ theo lớp — Argon2 là lớp cuối khi mọi thứ khác đã thủng.
Ba ý mang về
- Băm mật khẩu phải chậm có chủ đích: đo thật, SHA-256 chạy ~31 triệu lần/giây/lõi (mồi cho GPU dò mật khẩu), còn Argon2id chậm hơn ~559.000 lần (18,3 ms/lần) — không đáng kể cho người dùng nhưng là bức tường cho kẻ tấn công.
- Bộ nhớ cứng là vũ khí chống GPU/ASIC: đo thật, tăng
mem8→128 MiB kéo thời gian 3,7 → 323,9 ms; vì mỗi lần băm chiếm hàng chục MiB RAM, phần cứng nghìn lõi không nhân song song được — hãy chỉnh tham số theo server của bạn. - Luôn kèm salt ngẫu nhiên và so hằng thời gian: salt mỗi mật khẩu chặn rainbow table; so hash bằng
ConstantTimeComparetránh kênh phụ thời gian — và tuyệt đối không dùng SHA/MD5 trần cho mật khẩu.
Nguồn
- RFC 9106 — Argon2 Memory-Hard Function for Password Hashing: https://datatracker.ietf.org/doc/html/rfc9106
- OWASP — Password Storage Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html
- Go — golang.org/x/crypto/argon2: https://pkg.go.dev/golang.org/x/crypto/argon2
Đến đây khép lại loạt Mật mã — từ băm và HMAC, mã hoá đối xứng AES-GCM và ChaCha20-Poly1305, PBKDF2, RSA và đường cong elliptic, chữ ký số, trao khoá X25519, CSPRNG, TOTP, JWT, mTLS, xoay khoá, so sánh hằng thời gian, cho tới băm mật khẩu Argon2 ở bài này. Điểm chung xuyên suốt: mật mã không phải phép thuật mà là những công cụ có tính chất đo được — và mỗi bài đều chạy công nghệ thật, đo output thật, để bạn thấy chính xác chúng bảo vệ được gì và không bảo vệ được gì. Nguyên tắc cuối cùng, cũng là quan trọng nhất: đừng tự chế mật mã — hãy dùng thư viện chuẩn, tham số khuyến nghị, và hiểu rõ mô hình đe doạ của mình.