Hash mật khẩu: bcrypt, argon2 và lỗi thường gặp
Bài trước ta đã bóc một JWT và tập thói quen hỏi: token này ai giữ, sống bao lâu, bị lộ thì sao. Với mật khẩu, câu hỏi đó còn khắc nghiệt hơn, vì mật khẩu người dùng thường dùng lại ở nhiều nơi. Nếu database bị dump, kẻ tấn công đọc được gì? Bài này giữ câu trả lời ở mức: họ chỉ thấy một chuỗi hash chậm, có salt riêng cho từng dòng, và không thể dịch ngược hàng loạt.
Sau bài này bạn sẽ:
- Giải thích được vì sao MD5 hay SHA-256 trần không dùng để lưu mật khẩu
- Chọn được cost cho bcrypt và tham số cho argon2id
- Viết được cặp hàm hash/verify và biết khi nào cần rehash
- Nhận ra các lỗi hay gặp khi đưa phần lưu mật khẩu vào project
Vì sao SHA-256 trần là bẫy
SHA-256 được thiết kế để chạy nhanh. Trên một GPU, người ta thử được hàng tỷ phép băm mỗi giây. Mật khẩu người dùng thật thì rất đoán được: "123456", "matkhau123", tên + năm sinh. Không có salt, hai user cùng mật khẩu cho ra hai dòng hash giống hệt nhau, và một rainbow table dựng sẵn trả lời được hàng triệu dòng trong vài giây.
Quy tắc: hàm băm nhanh dùng cho checksum file, không dùng cho mật khẩu. Mật khẩu cần hàm cố tình chậm.
Note: "Chậm" ở đây tính bằng đơn vị trăm mili giây cho một lần đăng nhập. Bạn chịu được 200ms mỗi lần login, kẻ tấn công phải trả 200ms cho mỗi lần thử, nhân với số mật khẩu họ muốn dò.
Bài tập 1. Bạn dump database và thấy hai user khác nhau nhưng cột password giống hệt nhau, mỗi dòng là 32 ký tự hex. Bạn kết luận gì?
Xem gợi ý
Hai khả năng: hệ thống dùng MD5/SHA-256 không salt, hoặc có salt nhưng salt cố định dùng chung. Cả hai đều cho phép tra rainbow table và thấy ngay ai dùng chung mật khẩu. Cần chuyển sang bcrypt/argon2 và rehash dần khi user đăng nhập.
bcrypt: cost factor và giới hạn 72 byte
bcrypt tự sinh salt ngẫu nhiên cho mỗi lần hash và nhét luôn vào chuỗi kết quả, nên bạn không cần thêm cột salt:
$2b$12$KIXQJ3nZ0eG9pQ7rVb1uPe8Yw1s0oQ3H9m2Xk4Tz6Rc8Ld0Fh5Ag
│ │ └── salt (22 ký tự) + hash
│ └── cost = 12
└── phiên bản thuật toán
Cost là số mũ: cost 12 nghĩa là 2^12 vòng lặp. Mỗi lần tăng cost thêm 1, thời gian hash gấp đôi. Trên phần cứng thông thường năm 2024, cost 10 đến 12 là vùng hợp lý, và bạn nên đo trên máy thật của mình thay vì copy từ blog.
import bcrypt from "bcrypt";
const COST = 12;
export async function hashPassword(plain) {
return bcrypt.hash(plain, COST);
}
export async function verifyPassword(plain, stored) {
return bcrypt.compare(plain, stored); // timing-safe, đọc cost từ chính `stored`
}
Một chi tiết ít người biết: bcrypt chỉ dùng 72 byte đầu của mật khẩu. Mật khẩu 100 ký tự vẫn chỉ được kiểm tra 72 byte đầu, phần đuôi bị bỏ qua âm thầm. Nếu bạn cần hỗ trợ mật khẩu dài hơn, hãy pre-hash bằng SHA-256 rồi base64 trước khi đưa vào bcrypt, đừng cắt chuỗi bằng slice.
Bài tập 2. Trên máy bạn, cost 10 mất 60ms. Cost 12 mất bao lâu, và cost 14 thì sao? Con số nào bạn chọn cho API đăng nhập?
Xem gợi ý
Cost 12 khoảng 240ms, cost 14 khoảng 960ms. Với API đăng nhập thông thường, 200 đến 300ms là điểm cân bằng giữa an toàn và trải nghiệm, nên cost 12 là mặc định hợp lý. Cost 14 chỉ hợp lý nếu bạn chấp nhận gần 1 giây mỗi lần login hoặc có phần cứng mạnh.
argon2id: lựa chọn hiện đại
bcrypt vẫn tốt, nhưng argon2id là khuyến nghị hiện tại của OWASP. Khác biệt cốt lõi: argon2 là hàm memory-hard, mỗi lần hash chiếm một vùng RAM lớn. GPU và ASIC vốn mạnh vì chạy song song hàng nghìn phép tính nhỏ, nay bị chặn bởi chính dung lượng bộ nhớ.
import argon2 from "argon2";
export function hashPassword(plain) {
return argon2.hash(plain, {
type: argon2.argon2id,
memoryCost: 19456, // KiB, khoảng 19 MiB
timeCost: 2,
parallelism: 1,
});
}
export const verifyPassword = (plain, stored) => argon2.verify(stored, plain);
Ba tham số cần hiểu: memoryCost là RAM cho mỗi lần hash, timeCost là số vòng lặp, parallelism là số luồng. Đặt parallelism cao hơn số core của server là tự bắn vào chân mình khi có đợt đăng nhập đồng loạt.
| Tiêu chí | bcrypt | argon2id |
|---|---|---|
| Điều chỉnh độ khó | cost (2^n vòng) | memory + time + parallelism |
| Chống GPU/ASIC | Khá | Rất tốt (memory-hard) |
| Giới hạn mật khẩu | 72 byte | Không giới hạn thực tế |
| Độ phổ biến | Rất cao, thư viện chín | Cao và đang tăng |
| Khi nào chọn | Hệ thống cũ, team quen bcrypt | Project mới, ưu tiên mặc định |
Bài tập 3. VPS của bạn có 1GB RAM, 2 vCPU, và một đợt flash sale có thể tạo 50 request đăng nhập cùng lúc. Chọn tham số argon2id và giải thích.
Xem gợi ý
Dùng memoryCost: 19456 (19 MiB), timeCost: 2, parallelism: 1. 50 request cùng lúc tốn khoảng 950 MiB RAM, sát trần 1GB, nên cần rate limit ở tầng API hoặc giảm số worker. parallelism: 1 vì 2 vCPU không nên bị chia nhỏ thêm cho từng lần hash.
Những lỗi tôi gặp nhiều nhất khi review code auth
Danh sách này lặp lại gần như mọi lần tôi đọc code auth do người mới viết, kể cả code do AI sinh ra.
- Dùng MD5/SHA-256 trần hoặc "tự thêm salt" bằng cách nối chuỗi thủ công. Không sai về ý tưởng, sai ở chỗ hàm vẫn quá nhanh.
- Tự viết vòng lặp băm để "cho chậm hơn". Đừng phát minh lại bcrypt bằng vòng
for. - So sánh hash bằng
===. Đây là so sánh không timing-safe; dùngbcrypt.comparehoặcargon2.verify. - Log mật khẩu hoặc log body request. Một lần
console.log(req.body)là mật khẩu nằm trong log vài tháng. - Không rehash khi nâng tham số. Khi tăng cost, dữ liệu cũ vẫn ở cost cũ mãi. Cách đúng: sau khi verify thành công, kiểm tra cost trong chuỗi hash, nếu thấp hơn hiện tại thì hash lại mật khẩu vừa nhập và cập nhật DB.
- Giới hạn độ dài mật khẩu ngầm kiểu cắt còn 20 ký tự. Người dùng passphrase dài bị giảm bảo mật mà không biết.
- Pepper hard-code trong repo. Pepper là khoá bí mật thêm vào trước khi hash, nhưng phải nằm trong secret manager biến môi trường, không commit.
- const hash = crypto.createHash("sha256").update(password).digest("hex");
- db.save({ password: hash });
+ const hash = await bcrypt.hash(password, 12);
+ db.save({ password: hash });
Bài tập 4. Đoạn code sau có ba lỗi, tìm giúp tôi:
app.post("/login", async (req, res) => {
console.log("login attempt", req.body);
const user = await db.findUser(req.body.email);
if (user.password === md5(req.body.password)) {
return res.json({ ok: true });
}
res.status(401).json({ ok: false });
});
Xem gợi ý
Một: log nguyên req.body, làm lộ mật khẩu vào log. Hai: dùng MD5 trần, không salt, không chậm. Ba: so sánh bằng ===, không timing-safe. Bonus: không có rate limit, và thông báo lỗi nên giống nhau cho "sai email" và "sai mật khẩu" để tránh dò tài khoản.
Ba câu tự vấn trước khi merge phần lưu mật khẩu
- Ai đọc được hash này? Nếu bị dump, kẻ tấn công phải trả bao nhiêu giây cho mỗi lần thử? Nếu trả lời dưới 1ms, bạn đang dùng sai thuật toán.
- Tham số này đo trên máy nào? Đừng copy cost từ blog. Đo trên chính máy chủ sẽ chạy, rồi ghi lại con số vào README để người sau không hạ xuống cho "nhanh".
- Có đường nâng cấp không? Luôn viết sẵn nhánh rehash sau khi verify. Chi phí bây giờ là 5 dòng code, chi phí sau này là migrate toàn bộ user.
- Hash ở server, không phải client. Hash ở client vẫn bị replay y nguyên, và nó khiến bạn tưởng mình an toàn hơn thực tế.
Một mẹo nhỏ khi làm việc với AI: mỗi lần nó sinh hàm hash, hãy hỏi lại "cost này lấy từ đâu, mật khẩu dài hơn 72 byte thì sao". Câu trả lời sẽ cho bạn biết nó hiểu vấn đề hay chỉ chép mẫu.
Chốt lại và bước sang checkpoint
- Mật khẩu cần hàm băm cố tình chậm: bcrypt với cost 10 đến 12, hoặc argon2id với khoảng 19 MiB RAM.
- Salt do thư viện tự sinh và lưu kèm trong chuỗi hash, không cần cột riêng.
- Verify bằng
compare/verify, không bằng===, và luôn có nhánh rehash khi nâng tham số. - Ba lỗi chí mạng: MD5/SHA trần, log mật khẩu, không bao giờ nâng cấp hash cũ.
Tự kiểm tra nhanh bằng các câu điền khuyết dưới đây:
bcrypt lưu _____ ngay trong chuỗi hash nên không cần thêm cột riêng trong database. Tăng cost của bcrypt thêm 1 bậc làm thời gian hash _____. argon2id được khuyến nghị vì đây là hàm _____, gây khó cho GPU và ASIC. bcrypt chỉ dùng _____ byte đầu tiên của mật khẩu. Để kiểm tra mật khẩu với hash đã lưu, bcrypt cung cấp hàm _____ thay vì so sánh chuỗi.
Ôn lại toàn bộ ý chính của bài bằng bộ thẻ sau:
Front: Vì sao không lưu mật khẩu bằng SHA-256 trần? Back: Hàm quá nhanh, GPU thử được hàng tỷ lần mỗi giây và không có salt mặc định.
Front: Salt là gì và ai tạo ra nó? Back: Chuỗi ngẫu nhiên thêm vào trước khi hash; bcrypt và argon2 tự sinh và lưu kèm trong chuỗi hash.
Front: bcrypt cost 12 nghĩa là gì? Back: 2^12 vòng lặp; mỗi bậc cost tăng thêm làm thời gian hash gấp đôi.
Front: bcrypt dùng bao nhiêu byte đầu của mật khẩu? Back: 72 byte; phần dư bị bỏ qua âm thầm.
Front: Bộ ba tham số của argon2id là gì? Back: memoryCost, timeCost và parallelism.
Front: Tham số argon2id gợi ý cho project mới? Back: memoryCost 19456 KiB (khoảng 19 MiB), timeCost 2, parallelism 1.
Front: argon2 mạnh hơn bcrypt ở điểm cốt lõi nào? Back: Là hàm memory-hard: tốn RAM nên GPU và ASIC khó tấn công song song.
Front: Vì sao không so sánh hash bằng ===? Back: So sánh chuỗi không timing-safe; dùng bcrypt.compare hoặc argon2.verify.
Front: Khi nào cần rehash mật khẩu? Back: Khi nâng cost hoặc tham số, thực hiện sau lần verify thành công với hash cũ.
Front: Chuỗi hash bcrypt chứa những gì? Back: Phiên bản thuật toán, cost, salt và hash, ví dụ $2b$12$...
Front: Hash mật khẩu ở client hay server? Back: Ở server; hash phía client vẫn bị replay y nguyên.
Front: Pepper là gì và cất ở đâu? Back: Khoá bí mật thêm vào trước khi hash, đặt trong secret manager hoặc biến môi trường, không commit.
Bài tiếp theo là checkpoint của module: bạn sẽ gặp lại session, cookie, JWT và hash mật khẩu trong cùng một đề bài, để tự quyết định hệ thống của mình giữ trạng thái đăng nhập theo cách nào và lưu mật khẩu ra sao.