Lưu mật khẩu người dùng đúng cách nghĩa là không lưu mật khẩu — lưu một giá trị băm mà từ đó không suy ngược ra mật khẩu, nhưng kiểm được mật khẩu nhập vào có khớp không. Ba thuật toán thống trị việc này: bcrypt, scrypt, argon2. Bài này đo cả ba trên cùng một máy, ở cùng mức chi phí, để trả lời câu hỏi thực dụng: chọn cái nào, và vì sao.
Toàn bộ chạy trong container tự dựng, tự dọn. Không nhắm vào hệ thống của ai.
Một thứ cả ba đều làm sẵn: salt
Trước khi bàn chọn thuật toán, có một tính chất cả ba đều có mà hàm băm thô thiếu: salt — một chuỗi ngẫu nhiên khác nhau cho mỗi mật khẩu, trộn vào trước khi băm. Trong kết quả đo, mỗi lần tôi băm cùng một mật khẩu lại ra một giá trị khác, vì salt khác. Nếu không có salt, hai người dùng đặt cùng mật khẩu sẽ có cùng giá trị băm — và kẻ tấn công dựng sẵn một bảng tra (rainbow table) băm trước hàng triệu mật khẩu phổ biến, tra một phát ra ngay. Salt phá kiểu tấn công đó: bảng tra dựng sẵn vô dụng vì mỗi mật khẩu có salt riêng, buộc kẻ tấn công phải bẻ từng mật khẩu một. Cả bcrypt, scrypt và argon2 đều sinh và nhúng salt tự động vào chuỗi băm — bạn không phải tự lo, chỉ cần đừng tắt nó.
Vì sao băm mật khẩu phải chậm
Đây là điều ngược với mọi trực giác về hiệu năng: hàm băm mật khẩu tốt là hàm băm chậm. Băm thông thường (SHA-256) thiết kế để nhanh — một GPU tính hàng tỷ SHA-256 mỗi giây, nên nếu bạn băm mật khẩu bằng SHA-256, kẻ tấn công lấy được cơ sở dữ liệu sẽ thử hàng tỷ mật khẩu mỗi giây cho tới khi trúng.
Ba thuật toán trên cố tình làm mỗi lần băm tốn thời gian, có tham số điều chỉnh được. Mục tiêu: một lần băm mất đủ lâu để bạn không cảm thấy khi đăng nhập (khoảng 200 mili giây), nhưng đủ lâu để kẻ tấn công chỉ thử được vài lần mỗi giây thay vì hàng tỷ.
Số đo, hiệu chỉnh về cùng 200ms
Tôi hiệu chỉnh cả ba để mỗi lần băm mất khoảng 200 mili giây — cùng trải nghiệm đăng nhập — rồi đo tốc độ kẻ tấn công thử đoán và lượng RAM mỗi lần băm. Ba lần chạy, giống hệt:
| Thuật toán | Thời gian băm | Kẻ tấn công đoán được | RAM mỗi lần băm |
|---|---|---|---|
| bcrypt (cost 12) | ~170 ms | ~6 lần/giây/nhân | ~4 KB |
| scrypt (N=2¹⁷) | ~206 ms | ~5 lần/giây/nhân | 128 MB |
| argon2id (m=512MB) | ~213 ms | ~5 lần/giây/nhân | 512 MB |
Điều đáng nhớ
Trên CPU, ở cùng thời gian băm, cả ba làm kẻ tấn công chậm như nhau. 5–6 lần đoán mỗi giây mỗi nhân — con số gần trùng nhau. Nếu kẻ tấn công chỉ có CPU, việc chọn thuật toán gần như không quan trọng; điều quan trọng là bạn đặt chi phí đủ cao. Đây là điểm dễ bị bỏ qua: một bcrypt cost 12 mạnh hơn một argon2 cấu hình yếu. Thuật toán không cứu được cấu hình sai.
Khác biệt thật nằm ở RAM, và nó khổng lồ. bcrypt dùng ~4 KB mỗi lần băm; argon2 ở cấu hình trên dùng 512 MB — gấp 131.072 lần. Con số này vô nghĩa trên CPU (nơi RAM dư thừa), nhưng quyết định tất cả khi kẻ tấn công dùng GPU hoặc ASIC. Một GPU có hàng nghìn nhân tính toán, nhưng chỉ vài GB RAM. Với bcrypt 4 KB, GPU chạy hàng nghìn phép băm song song. Với argon2 512 MB, cùng GPU đó chỉ chạy được vài phép cùng lúc — RAM cạn trước khi nhân cạn. Đây gọi là memory-hard (cứng theo bộ nhớ), và là lý do argon2 thắng: nó vô hiệu hoá đúng lợi thế phần cứng của kẻ tấn công.
Chi phí co giãn đúng theo tỷ lệ — và đó là tính năng. Từ bộ đo đầu tiên: bcrypt cost 10 cho kẻ tấn công 24 lần đoán/giây, cost 11 là 12, cost 12 là 6. Mỗi bậc cost gấp đôi thời gian băm và giảm một nửa tốc độ kẻ tấn công. Nghĩa là khi phần cứng nhanh gấp đôi sau vài năm, bạn chỉ cần tăng cost lên một để giữ nguyên mức bảo vệ. Tham số chi phí là cách hàm băm theo kịp định luật Moore mà không cần đổi thuật toán.
Vì sao
Ba thuật toán đại diện ba thế hệ, mỗi thế hệ thêm một chiều làm khó kẻ tấn công:
- bcrypt (1999) làm khó theo thời gian CPU. Nó chậm, nhưng dùng rất ít bộ nhớ — nên GPU/ASIC song song hoá nó dễ dàng.
- scrypt (2009) thêm chiều bộ nhớ: nó cố tình cần nhiều RAM, khiến mỗi nhân GPU phải mang theo bộ nhớ riêng, đắt hơn nhiều.
- argon2 (2015, thắng cuộc thi Password Hashing Competition) tinh chỉnh ý tưởng bộ nhớ-cứng đó, cho phép điều chỉnh riêng ba trục: thời gian, bộ nhớ, và số luồng song song.
Xu hướng rõ ràng: qua thời gian, phòng thủ chuyển từ "làm chậm CPU" sang "làm tốn bộ nhớ", vì bộ nhớ là thứ kẻ tấn công khó mở rộng rẻ nhất. Một ASIC chuyên tính SHA nhanh gấp triệu lần CPU; một ASIC chuyên argon2 vẫn phải mua 512 MB RAM cho mỗi phép băm song song, và RAM thì không rẻ đi nhanh như bóng bán dẫn.
Nghĩa là gì trong thực tế
- Dùng argon2id nếu có thể. Nó là khuyến nghị hiện hành (OWASP), vì nó memory-hard và có tham số linh hoạt nhất.
argon2-cfficho Python, có sẵn ở hầu hết ngôn ngữ. - bcrypt vẫn ổn nếu đó là cái bạn đã có — miễn cost đủ cao (12 trở lên). Đừng viết lại toàn bộ chỉ để đổi bcrypt cost 12 sang argon2; khác biệt chỉ quan trọng khi kẻ tấn công có GPU/ASIC nhắm riêng bạn.
- Chỉnh chi phí theo máy chủ của bạn, không chép số từ blog. Đo trên chính phần cứng production: tăng tham số cho tới khi một lần băm mất ~200–500 ms. Số của tôi đúng với máy của tôi; máy bạn khác.
- Không bao giờ dùng SHA/MD5 thẳng cho mật khẩu. Chúng nhanh — đúng cái bạn không muốn. "Băm" không đủ; phải là băm mật khẩu chuyên dụng có tham số chi phí.
Chỗ tôi không kết luận được
Tôi đo tốc độ kẻ tấn công trên CPU — chính máy đo. Điểm mạnh thật của argon2/scrypt lộ ra trên GPU/ASIC, nơi RAM trở thành nút thắt, và tôi không có phần cứng đó để đo trực tiếp. Con số RAM (4 KB so với 512 MB) là bằng chứng gián tiếp cho lợi thế đó, nhưng tỷ lệ tăng tốc thật trên GPU thì tôi suy luận, không đo.
Và tôi so ở ~200 ms. Mốc đúng phụ thuộc ứng dụng: đăng nhập tương tác chịu được 200–500 ms, nhưng một API xác thực mỗi request thì không — ở đó phải dùng token (các bài JWT) thay vì băm lại mật khẩu mỗi lần.
Thử ba mươi giây
Tìm chỗ mã của bạn băm mật khẩu. Nó dùng hàm gì?
Nếu là hashlib.sha256, md5, hay bất kỳ hàm băm "nhanh" nào không có tham số chi phí, bạn có một vấn đề nghiêm trọng: một lần rò cơ sở dữ liệu là kẻ tấn công thử hàng tỷ mật khẩu mỗi giây. Chuyển sang argon2id hoặc bcrypt, và đặt chi phí sao cho một lần băm mất một phần năm giây — đủ nhanh để người dùng không nhận ra, đủ chậm để kẻ tấn công chỉ thử được năm lần.