Loạt bài phòng thủ: ta tìm hiểu cách lưu mật khẩu an toàn, không hướng dẫn tấn công.
Khi cần lưu mật khẩu người dùng, phản xạ của nhiều lập trình viên là "băm nó bằng SHA-256" — vì SHA-256 là hàm băm mật mã mạnh, một chiều, không đảo ngược được. Nghe hợp lý. Nhưng nó sai, và cái sai nằm ở một thuộc tính mà bình thường ta coi là ưu điểm: SHA-256 rất nhanh. Nhanh là tốt khi băm file hay kiểm tra toàn vẹn dữ liệu. Nhưng với mật khẩu, nhanh là thảm hoạ: nếu cơ sở dữ liệu bị lộ, kẻ tấn công có thể thử hàng trăm triệu mật khẩu mỗi giây để dò ngược.
Giải pháp đúng là dùng một hàm băm thiết kế riêng cho mật khẩu — bcrypt, scrypt, hay argon2 — mà điểm cốt lõi là chậm có kiểm soát. Nghe nghịch lý: ta cố ý làm nó chậm. Bài này (phần 2 loạt Bảo mật web) đo thật sự khác biệt tốc độ, cho thấy "chậm" là tính năng chứ không phải lỗi, và giải thích vai trò của salt.
Cơ chế: nhanh là điểm yếu, chậm là tính năng

Hình 1: SHA-256 băm mật khẩu trong ~38ns — quá nhanh, kẻ tấn công thử hàng trăm triệu/giây nếu DB lộ. bcrypt chậm có kiểm soát qua cost (số vòng lặp theo hàm mũ 2^cost, mỗi cost +1 chậm gấp đôi) — chọn cost cho ~250ms. Salt tự sinh ngẫu nhiên nhúng trong hash: cùng mật khẩu cho hash khác nhau, chống rainbow table.
Đo thật trong go-lab
Mình đo trong go-lab (golang 1.23, x/crypto/bcrypt): tốc độ SHA-256, bcrypt theo từng cost factor, và tính chất của salt.

Hình 2: Kết quả thật — SHA-256: 38 ns/hash (~26 triệu/giây); bcrypt: cost 8=12.9ms, cost 10=51.2ms, cost 12=204.8ms, cost 14=819.8ms (mỗi cost +1 chậm gấp đôi); salt: cùng mật khẩu cho hai hash khác nhau, cả hai verify đúng.
Ba con số đáng khắc ghi:
- SHA-256: 38 nano-giây — nhanh đến mức nguy hiểm. Một core CPU băm được ~26 triệu mật khẩu mỗi giây, và với GPU/cluster thì hàng tỉ. Nghĩa là nếu một DB lưu mật khẩu dạng SHA-256 bị lộ, kẻ tấn công dò ngược mọi mật khẩu yếu/trung bình trong thời gian ngắn. Sự nhanh — ưu điểm của SHA-256 cho mục đích khác — chính là tử huyệt của nó cho mật khẩu.
- bcrypt: chậm theo hàm mũ, có kiểm soát. cost 8 là 12.9ms, cost 10 là 51.2ms, cost 12 là 204.8ms, cost 14 là 819.8ms. Mỗi lần tăng cost thêm 1, thời gian gấp đôi (hàm mũ
2^cost) — bạn thấy rõ cost 10→12 (hai bước) chậm gấp ~4. Điều này cho bạn một "núm vặn": chọn cost sao cho ~250ms/hash. Người dùng thật chỉ chịu 250ms khi đăng nhập (không nhận ra), nhưng kẻ tấn công chỉ thử được ~4 mật khẩu/giây thay vì hàng triệu — brute-force trở nên bất khả thi về kinh tế. SHA-256 nhanh gấp ~1,3 triệu lần bcrypt cost 10, và đó chính xác là lý do bcrypt an toàn. - Salt: cùng mật khẩu, hash khác nhau. Băm cùng một mật khẩu hai lần cho hai hash khác hẳn — vì bcrypt tự sinh một salt ngẫu nhiên và nhúng vào hash. Điều này chống rainbow table (bảng hash tính sẵn cho các mật khẩu phổ biến): vì mỗi hash có salt riêng, bảng tính sẵn vô dụng. Và verify vẫn hoạt động với cả hai hash — vì salt nằm trong chuỗi hash,
CompareHashAndPasswordđọc salt ra để băm lại và so.
Đánh đổi cần cân nhắc
Cost cao an toàn hơn nhưng tốn CPU và latency đăng nhập — chọn ~250ms. Cost là đánh đổi trực tiếp giữa an toàn và tài nguyên. Cost quá thấp (như 8) thì nhanh nhưng dễ brute-force hơn; cost quá cao (như 14, ~820ms) thì an toàn nhưng mỗi đăng nhập tốn gần một giây CPU — dưới tải cao, server băm mật khẩu có thể trở thành nút thắt (và thành vector DoS: kẻ tấn công spam đăng nhập để ngốn CPU). Khuyến nghị thực dụng: chọn cost sao cho ~250ms trên phần cứng production của bạn, và tăng dần theo năm khi CPU mạnh lên. Đo thật trên máy thật, đừng copy con số.
bcrypt giới hạn 72 byte — biết để không bị cắt âm thầm. Một chi tiết ít người biết: bcrypt chỉ dùng 72 byte đầu của mật khẩu, phần sau bị bỏ qua. Với mật khẩu thường thì không sao, nhưng nếu bạn cho passphrase rất dài hoặc (sai lầm) băm trước bằng thứ gì đó rồi đưa vào bcrypt, có thể bị cắt mà không báo. Argon2id không có giới hạn này. Nếu cần hỗ trợ mật khẩu dài, cân nhắc argon2id, hoặc hiểu rõ giới hạn 72 byte.
Argon2id là khuyến nghị mới nhất — và đừng bao giờ tự nghĩ thuật toán. Bcrypt vẫn an toàn và phổ biến, nhưng khuyến nghị hiện đại (OWASP) ưu tiên argon2id — nó chống tấn công GPU/ASIC tốt hơn vì tốn nhiều bộ nhớ (memory-hard), không chỉ tốn CPU; bạn chỉnh được cả thời gian lẫn RAM. Dù chọn cái nào, nguyên tắc tuyệt đối: dùng thư viện đã được kiểm chứng, đừng bao giờ tự nghĩ ra thuật toán băm mật khẩu (tự xoắn SHA-256 nhiều vòng là một sai lầm kinh điển — thiếu các tính chất quan trọng). Và tất nhiên: không bao giờ lưu mật khẩu dạng plaintext hay SHA-256 trần.
Ba ý mang về
- SHA-256 nhanh = SAI cho mật khẩu: đo thật, SHA-256 băm một mật khẩu trong 38ns (~26 triệu/giây một core) — nếu DB lộ, kẻ tấn công dò ngược hàng trăm triệu mật khẩu/giây; sự nhanh là ưu điểm cho băm file nhưng là tử huyệt cho mật khẩu.
- bcrypt chậm là tính năng, điều chỉnh bằng cost: đo thật, cost 8=12.9ms, 10=51.2ms, 12=204.8ms, 14=819.8ms — mỗi cost +1 chậm gấp đôi (hàm mũ); SHA-256 nhanh gấp ~1,3 triệu lần bcrypt cost 10, và đó là lý do bcrypt an toàn; chọn cost cho ~250ms/hash.
- Salt chống rainbow table, và dùng thư viện chuẩn: bcrypt tự sinh salt ngẫu nhiên nhúng trong hash nên cùng mật khẩu cho hash khác nhau (verify vẫn đúng); chọn cost cân bằng an toàn/latency (cẩn thận DoS); ưu tiên argon2id (memory-hard); đừng tự nghĩ thuật toán; không bao giờ lưu plaintext/SHA-256 trần.
Nguồn
- OWASP — Password Storage Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html
- Go — golang.org/x/crypto/bcrypt: https://pkg.go.dev/golang.org/x/crypto/bcrypt
- IETF RFC 9106 — Argon2 Memory-Hard Function: https://www.rfc-editor.org/rfc/rfc9106.html
Phần sau ta bàn về một lỗ hổng tinh vi: timing attack — khi so sánh chuỗi bí mật (token, chữ ký) bằng == thông thường, thời gian so sánh rò rỉ thông tin về chuỗi đúng; đo thật và chống bằng so sánh constant-time.