Lưu mật khẩu là chỗ sai một lần thì hỏng vĩnh viễn. Bài này đo công cụ mà Spring cho sẵn, và tìm ra giới hạn của nó.
Cost quyết định mọi thứ
BCrypt cost=4 mã hoá 1,0 ms | kiểm tra 1,1 ms
BCrypt cost=8 mã hoá 13,4 ms | kiểm tra 13,9 ms
BCrypt cost=10 mã hoá 54,2 ms | kiểm tra 52,1 ms
BCrypt cost=12 mã hoá 213,2 ms | kiểm tra 210,0 ms
BCrypt cost=14 mã hoá 852,9 ms | kiểm tra 844,4 ms
Mỗi hai bậc cost là gấp bốn lần thời gian. Đó là ý đồ thiết kế: cost là số mũ.
Chậm ở đây là tính năng. Kẻ tấn công lấy được bảng mật khẩu sẽ thử hàng tỷ tổ hợp; mỗi lần thử tốn 52 mili giây thay vì một phần triệu giây là khác biệt giữa vài giờ và vài nghìn năm.
Mặc định của Spring là cost 10 — khoảng 52 ms trên máy tôi đo. Khuyến nghị chung là chọn mức cho ra 50–250 ms trên phần cứng sản xuất của bạn, nên 10 tới 12 là khoảng hợp lý năm 2026.
Đừng chọn theo cảm tính: đo trên chính máy chủ của bạn. Cost 12 trên máy yếu có thể là 800 ms, và khi đó một trận đăng nhập đồng loạt sẽ làm cạn pool luồng.
Giới hạn 72 byte
hai mật khẩu khác nhau từ byte 73 trở đi -> khớp nhau? true
hai mật khẩu khác nhau tại byte 72 -> khớp nhau? false
Đây là giới hạn của chính thuật toán, không phải lỗi cài đặt. Với mật khẩu người dùng bình thường thì không sao — ít ai gõ quá 72 ký tự. Nhưng nó thành vấn đề thật trong ba trường hợp:
Cụm mật khẩu dài đang được khuyến khích. Bốn câu tiếng Việt có dấu vượt 72 byte rất dễ — vì UTF-8 mã hoá chữ có dấu thành 2–3 byte, nên 72 byte chỉ khoảng 30–40 ký tự tiếng Việt.
Nối thêm dữ liệu vào mật khẩu trước khi băm — một mẫu thỉnh thoảng vẫn thấy. Phần nối thêm có thể rơi ra ngoài 72 byte và mất tác dụng hoàn toàn.
Băm token hoặc khoá API bằng BCrypt. Chúng dài, và bạn vừa cắt mất phần đuôi.
Cách chữa: kiểm độ dài lúc đăng ký (@Size(max = 72) tính theo byte, không phải ký tự), hoặc dùng Argon2 — nó không có giới hạn này.
Argon2 cần thêm một phụ thuộc
Argon2: NoClassDefFoundError - org/bouncycastle/crypto/params/Argon2Parameters$Builder
Argon2PasswordEncoder có sẵn trong Spring Security, nhưng nó không đi kèm thư viện thực hiện. Phải thêm:
<dependency>
<groupId>org.bouncycastle</groupId><artifactId>bcprov-jdk18on</artifactId>
</dependency>
Lỗi này chỉ nổ ra lúc chạy, ở đúng lần đăng ký đầu tiên. Đáng biết trước.
Argon2 thắng cuộc thi băm mật khẩu năm 2015 và là khuyến nghị hiện nay của OWASP. Nó chống được cả tấn công bằng GPU và ASIC vì tham số của nó gồm cả bộ nhớ chứ không chỉ thời gian — thứ mà BCrypt không có.
Nhưng BCrypt vẫn hoàn toàn chấp nhận được, đã được kiểm nghiệm hai mươi năm, và không cần phụ thuộc ngoài. Với dự án mới tôi chọn Argon2; với dự án đang chạy BCrypt thì không cần vội đổi.
DelegatingPasswordEncoder
DelegatingPasswordEncoder sinh ra: {bcrypt}$...
Đây là mặc định của Spring Security, và ý tưởng của nó rất đáng khen: tiền tố cho biết thuật toán.
{bcrypt}$2a$10$N9qo8uLOickgx2ZMRZo...
{argon2}$argon2id$v=19$m=16384,t=2,p=1$...
Nhờ vậy bạn đổi thuật toán mà không bắt ai đặt lại mật khẩu:
@Bean PasswordEncoder maHoa() {
var ma = new HashMap<String, PasswordEncoder>();
ma.put("bcrypt", new BCryptPasswordEncoder());
ma.put("argon2", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8());
return new DelegatingPasswordEncoder("argon2", ma); // mã hoá MỚI bằng argon2
}
Mật khẩu cũ có tiền tố {bcrypt} vẫn kiểm được; mật khẩu mới dùng Argon2. Và nâng cấp dần khi người dùng đăng nhập:
if (maHoa.upgradeEncoding(nguoi.getMatKhau())) {
nguoi.setMatKhau(maHoa.encode(matKhauThoNhapVao)); // chỉ lúc này mới có mật khẩu thô
repo.save(nguoi);
}
Sau vài tháng, phần lớn người dùng đã chuyển sang thuật toán mới mà không ai phải làm gì.
Năm điều không thương lượng
Không bao giờ lưu mật khẩu dạng thô. Kể cả "tạm thời", kể cả trong bảng test có dữ liệu thật.
Không dùng MD5, SHA-1, hay SHA-256. Hai cái đầu đã vỡ; cái thứ ba không vỡ nhưng quá nhanh — GPU thử hàng tỷ giá trị mỗi giây. Hàm băm mật khẩu phải chậm có chủ đích.
Không tự cài đặt. Muối, số vòng lặp, so sánh không phụ thuộc thời gian — mỗi chi tiết là một chỗ để sai.
Không ghi mật khẩu ra log. CLAUDE.md của blog này có một mục về việc đó, sau khi mật khẩu quản trị từng bị in ra ở lần khởi động đầu tiên. Log container thường được gom về nơi khác và giữ rất lâu; ai đọc được log là đăng nhập được.
Không đặt quy tắc mật khẩu kỳ quặc. Bắt buộc "ít nhất một ký tự đặc biệt, tối đa 12 ký tự" làm mật khẩu yếu đi — nó thu hẹp không gian tìm kiếm và đẩy người dùng về những mẫu đoán được. Khuyến nghị hiện nay của NIST: đặt độ dài tối thiểu (8–12), bỏ hầu hết ràng buộc thành phần, và đối chiếu với danh sách mật khẩu đã lộ.
Thêm hai thứ
Đừng nói tài khoản nào tồn tại. "Sai mật khẩu" và "không có tài khoản này" là hai thông điệp khác nhau, và khác biệt đó cho kẻ tấn công biết email nào có trong hệ thống. Trả cùng một câu.
Điều tương tự áp dụng cho thời gian phản hồi: nếu tài khoản không tồn tại thì bạn bỏ qua bước băm và trả lời sau 2 ms, còn tài khoản có thật thì 52 ms — chênh lệch đó đo được. Băm một giá trị giả cho trường hợp không tìm thấy.
Giới hạn số lần thử. Không có nó thì cost 10 chỉ làm chậm kẻ tấn công gấp mấy chục lần, chứ không chặn được. Đếm theo cửa sổ, đừng chặn cứng theo IP — bài 37 và CLAUDE.md của blog này đều nói cùng một điều.
Thử ba mươi giây
psql -c "select mat_khau from nguoi_dung limit 3"
Không thấy tiền tố {bcrypt} hoặc {argon2} nghĩa là bạn đang dùng encoder cố định, và đổi thuật toán về sau sẽ phải bắt mọi người đặt lại mật khẩu. Chuyển sang DelegatingPasswordEncoder bây giờ là rẻ; ba năm nữa thì không.
Ngày mai: xác thực — UserDetailsService, và luồng đăng nhập từ đầu tới cuối.