Cách giữ bí mật an toàn nhất là không giữ chính bí mật đó. Một khách sạn không lưu lại vân tay của bạn để mở cửa phòng — nó lưu một thứ suy ra từ vân tay, đủ để kiểm khi bạn quay lại nhưng không thể dựng ngược lại ngón tay bạn. Lưu mật khẩu người dùng đúng cách hoạt động y hệt: bạn 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 — và bài này tôi đo cả ba trên cùng một máy, ở cùng mức chi phí, để trả lời câu 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. (Các con số là thời gian và RAM đo trong container, không phải perf counter phần cứng.)
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. 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 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 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 là một lần băm mất đủ ngắn để 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ỷ.
Tôi hiệu chỉnh cả ba để mỗi lần băm mất khoảng 200 ms — 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 |
Ba đ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, 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, nên với bcrypt 4 KB nó chạy hàng nghìn phép băm song song, còn 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. 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 xu hướng chuyển từ CPU sang bộ nhớ
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 — 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, nhưng 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.
Ra thực tế thì dùng argon2id nếu có thể — nó là khuyến nghị hiện hành (OWASP), memory-hard và có tham số linh hoạt nhất (argon2-cffi cho 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 tôi, máy bạn khác. Và 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í.
Mẫu số chung
Hai ý cốt lõi ở đây — bắt kẻ tấn công tốn đúng cái tài nguyên nó khó mở rộng nhất, và một tham số chi phí điều chỉnh được để theo kịp phần cứng — không đóng khung trong mật khẩu, chúng là cách mọi tầng chống lại việc "phần cứng ngày mai phá được thứ an toàn hôm nay".
Trong thế giới Java, đây đúng là thiết kế của Spring Security: DelegatingPasswordEncoder gắn tiền tố {argon2}/{bcrypt} vào mỗi hash để nâng cấp thuật toán dần mà không phá dữ liệu cũ, và BCryptPasswordEncoder/Argon2PasswordEncoder đều nhận work factor — chính là tham số chi phí bạn tăng theo năm. Cùng ý "chi phí điều chỉnh được, nâng dần" mà blog này áp cho hash mật khẩu.
Trong Go, golang.org/x/crypto cho cả bcrypt, scrypt và argon2 với đúng các tham số cost/memory/parallelism — và lời khuyên của cộng đồng Go y hệt: chọn argon2id, đo trên máy thật rồi đặt tham số, đừng chép hằng số từ ví dụ.
Còn ở cơ sở dữ liệu, nguyên tắc "đừng lưu bí mật ở dạng có thể dùng ngược" trải rộng: pgcrypto có crypt() với thuật toán blowfish (bf) và cost, để chính DB băm chậm thay vì lưu SHA nhanh; và ở tầng thiết kế, token đặt lại mật khẩu nên là một lần, hết hạn nhanh — cũng là "đừng để một bí mật rò ra dùng lại được mãi", họ hàng gần của salt phá rainbow table.
Mẫu số chung: an toàn trước phần cứng tương lai không đến từ một thuật toán "mạnh" cố định mà từ việc buộc kẻ tấn công tốn tài nguyên nó khó mở rộng rẻ (bộ nhớ, chứ không phải phép tính) và để lại một núm chi phí bạn vặn lên theo thời gian — vì cái an toàn hôm nay chỉ an toàn nếu bạn theo kịp được đà rẻ đi của phần cứng. Vì thế câu hỏi đúng khi chọn cách bảo vệ một bí mật lâu dài không phải "thuật toán này mạnh cỡ nào hôm nay" mà là "nó có núm để tôi tăng chi phí khi phần cứng nhanh lên không, và nó bắt kẻ tấn công tốn đúng thứ đắt không" — vì kẻ tấn công của bạn là kẻ tấn công của mười năm nữa với phần cứng của mười năm nữa.
Muốn tự soi trong 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.