Hash mật khẩu: vì sao sha256 có salt vẫn vỡ khi database bị lộ? Trung cấp
Thời gian đọc ước tính: 10 phút
Vấn đề
Phần tiêu đề “Vấn đề”Bản dump bảng users của một shop bị lộ. Hệ thống cũ lưu sha256(salt + password), salt 8 byte riêng cho mỗi user. Đội dev tin rằng có salt là đủ.
Trên một core CPU máy dev (Apple M5, Node 24.17), demo đo được khoảng 2,3 triệu lần sha256(salt+pw) mỗi giây; GPU còn nhanh hơn nhiều. Mật khẩu phổ biến như Mua-sam-2024 rơi rất nhanh.
Phải lưu mật khẩu thế nào để bản dump gần như vô dụng, và chuyển hàng triệu hash cũ sang mà không bắt mọi người đổi mật khẩu?
Chọn gì? (tóm tắt trước)
Phần tiêu đề “Chọn gì? (tóm tắt trước)”Tham số lấy từ OWASP Password Storage Cheat Sheet, truy cập ngày 2026-10-24:
| Tình huống | Chọn | Điều kiện |
|---|---|---|
| Hệ thống mới | Argon2id m=19456 (19 MiB), t=2, p=1 |
Hoặc cấu hình tương đương: 46 MiB/t=1, 12 MiB/t=3, 9 MiB/t=4, 7 MiB/t=5, đều p=1 |
| Không có Argon2id | scrypt N=2^17, r=8, p=1 (128 MiB) |
Tương đương: N=2^16/p=2, 2^15/p=3, 2^14/p=5, 2^13/p=10 |
| Bắt buộc FIPS-140 | PBKDF2-HMAC-SHA256 600.000 vòng | HMAC-SHA512: 220.000; HMAC-SHA1: 1.400.000, chỉ legacy |
| Legacy đã dùng bcrypt | bcrypt work factor ≥ 10 | Mật khẩu tối đa 72 byte |
| DB còn SHA-256/MD5 | Wrap ngay argon2id(sha256_old), rehash khi đăng nhập |
Ghi rõ scheme từng dòng |
Khái niệm cốt lõi
Phần tiêu đề “Khái niệm cốt lõi”SHA-256 được thiết kế để nhanh. Kẻ có bản dump đoán offline: không rate limit, không lockout. Tốc độ băm chính là tốc độ đoán.
| Thành phần | Chống được | Không chống được | Lưu ở đâu |
|---|---|---|---|
| Salt (ngẫu nhiên, mỗi user một salt) | Rainbow table, nhận ra hai user trùng mật khẩu | Brute-force từng user | Cùng hash, công khai |
| Pepper (secret chung) | Bản dump DB đơn lẻ bị crack | Lộ cả DB lẫn secret | Ngoài DB: KMS, secret manager |
Work factor (t, vòng lặp) |
Tốc độ đoán trên CPU | GPU song song hàng nghìn lõi | Trong PHC string |
Memory-hard (m, N·r) |
Lợi thế GPU/ASIC: mỗi lần thử cần nhiều RAM | Mật khẩu quá yếu | Trong PHC string |
Rainbow table là bảng hash → mật khẩu tính sẵn; salt riêng mỗi user làm bảng đó vô dụng. Salt không làm từng lần đoán chậm hơn; đó là việc của work factor và bộ nhớ.
Argon2 và scrypt là hàm memory-hard: mỗi lần băm phải giữ một mảng lớn trong RAM. GPU có nhiều lõi nhưng ít RAM mỗi lõi, nên số lần thử song song bị giới hạn bởi bộ nhớ.
Pepper là tuỳ chọn; crypto.argon2 nhận nó qua tham số secret. Đổi pepper cần mật khẩu gốc, nên lưu kèm id phiên bản (PHC có tham số keyid).
PHC string: lưu tham số cùng hash
Phần tiêu đề “PHC string: lưu tham số cùng hash”$argon2id$v=19$m=19456,t=2,p=1$vCDiwTb/NCC+X03gkja2jg$RCbALSwq0iAYJoiEggLTiN/AJO8PdP7/0cj3oNRdsGo thuật toán version tham số salt (B64, 16 byte) hash (B64, 32 byte)B64 là base64 chuẩn bỏ dấu =. Vì tham số nằm trong chuỗi, hash cũ và mới cùng tồn tại trong một cột; code đọc tham số của từng dòng để verify, rồi biết dòng nào cần nâng cấp.
Cách hoạt động
Phần tiêu đề “Cách hoạt động”Đăng ký và đăng nhập của user Lan, đi theo demo rehash-demo.ts. Hash của Lan được tạo khi chính sách còn là m=4096, t=3:
POST /signupĐăng ký: Lan gửi mật khẩu qua TLS. API không lưu và không log giá trị này.
| Bước | Trong DB | Ở API |
|---|---|---|
| 1–4 | PHC m=19456,t=2,p=1 |
Salt mới, hash 32 byte |
| 5–7 | PHC cũ m=4096,t=3 |
Hash lại với tham số và salt cũ, so → true |
| 8 | PHC mới m=19456,t=2 |
Chỉ rehash sau khi verify đúng |
Vì sao so bằng timingSafeEqual? === dừng ở byte khác đầu tiên, nên thời gian phản hồi rò thông tin. Node yêu cầu hai buffer cùng độ dài, khác thì ném ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH.
Quyết định lúc đăng nhập phụ thuộc scheme của từng dòng:
Đang tải diagram…
User không đăng nhập lại thì sao? Rehash chỉ chạy khi có mật khẩu gốc. OWASP đưa hai cách: wrap hash cũ làm input cho thuật toán mới (không cần mật khẩu), hoặc xoá hash của user bỏ đi lâu và buộc reset mật khẩu.
Wrap argon2id(sha256_old) chạy ngay bằng migration offline. OWASP lưu ý hash xếp lớp có thể dễ crack hơn, nên thay bằng argon2id(pw) ở lần đăng nhập kế tiếp.
Chi tiết vận hành
Phần tiêu đề “Chi tiết vận hành”API của Node 24:
crypto.argon2có từ v24.7.0, Stability 1.2 (release candidate).memorytính bằng KiB (19 MiB là19456);nonce(salt) nên random, ≥ 16 byte.crypto.scrypt:maxmemmặc định 32 MiB, lỗi khi khoảng128 × N × r > maxmem. Tham số OWASP cần 128 MiB: tăngmaxmem, đừng hạN.- Dùng bản async (
argon2,scrypt,pbkdf2): chạy trên libuv threadpool, mặc định 4 thread. Bản*Syncchặn event loop.
Chọn tham số: bảng trên là mức tối thiểu. OWASP khuyên hash dưới một giây, thử trên chính server production: đo ms/hash, nhân với tải đăng nhập đỉnh, rồi mới tăng m hoặc t.
DoS ở endpoint đăng nhập: hash chậm là chi phí cho cả server. Demo: 16 lần argon2id đồng thời mất 66 ms vì xếp hàng qua 4 thread. Ước tính từ số đo, một process xử lý khoảng 320 hash/giây và cần 19 MiB cho mỗi hash đang chạy.
Cách chặn: rate limit theo tài khoản và IP, giới hạn số hash đồng thời, đặt độ dài tối đa cho mật khẩu (OWASP: mật khẩu rất dài có thể gây DoS).
bcrypt và 72 byte: bcrypt chỉ dùng 72 byte đầu; chữ tiếng Việt có dấu tốn 2–3 byte UTF-8. OWASP khuyên chặn ở 72 byte. Demo không đo bcrypt (không có package).
Ví dụ thực tế
Phần tiêu đề “Ví dụ thực tế”Demo trong examples/src/password-hashing/. Số đo đổi theo lần chạy:
npx --prefix examples ts-node examples/src/password-hashing/bench.tsnpx --prefix examples ts-node examples/src/password-hashing/rehash-demo.tsnode v24.17.0, CPU Apple M5, 1 threadcách băm ms/hash thử/giây/core RAM thêm (MiB)sha256(pw) 0.00045 2,231,823 ~0sha256(salt+pw) 0.00043 2,338,263 ~0pbkdf2-sha256 600k 45.1 22 0scrypt N=2^17,r=8,p=1 156.0 6 128argon2id m=19456,t=2,p=1 12.4 80 19argon2id m=47104,t=1,p=1 15.6 64 46argon2 async x16 : 66 ms tổngĐọc kết quả:
- “RAM thêm” = maxRSS process con trừ RSS trước khi băm; khớp
mvà128 × N × r. PBKDF2 gần như không cần RAM, nên GPU song song tốt. - Số SHA-256 gồm cả overhead của
createHashtrong JS; GPU còn nhanh hơn nhiều.
1. Vector mẫu của đặc tả PHC (hunter2, secret = pepper): khớp spec: true bỏ pepper khớp spec: false3. Đăng nhập với hash tham số cũ (m=4096,t=3): verify rồi rehash sai mật khẩu: false (không rehash) rehash: argon2id 4096/3 -> $argon2id$v=19$m=19456,t=2,p=1$f...4. Hash SHA-256 cũ của user không đăng nhập: wrap ngay, không cần mật khẩu scheme sau đăng nhập: argon2id5. timingSafeEqual khác độ dài : ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTHBước 1: crypto.argon2 tái tạo đúng vector mẫu của đặc tả PHC, kể cả pepper qua secret. phc.ts chỉ minh hoạ; production dùng thư viện đã được review, không tự viết crypto.
Trade-offs
Phần tiêu đề “Trade-offs”Ưu điểm
-
Mỗi lần đoán offline tốn hàng chục ms và nhiều MiB RAM.
-
PHC string cho phép nâng tham số dần.
-
Pepper thêm một lớp khi chỉ DB bị lộ.
< slot="cons"> >
-
Đăng nhập tốn CPU, RAM; endpoint thành mục tiêu DoS.
-
Pepper khó xoay: cần mật khẩu gốc.
-
Không cứu mật khẩu quá phổ biến; vẫn cần MFA.
Nhược điểm
Checklist production
Phần tiêu đề “Checklist production”- Dùng argon2id với tham số OWASP hiện tại; ghi ngày tra cứu trong code.
- Đo
ms/hashtrên máy production; chọn tham số cao nhất mà tải đăng nhập đỉnh chịu được, dưới một giây. - Lưu PHC string; có hàm
needsRehashchạy sau mỗi lần verify đúng. - Gọi bản async; giới hạn số hash đồng thời và rate limit theo tài khoản, IP.
- Đặt độ dài tối đa cho mật khẩu; bcrypt thì chặn ở 72 byte.
- Hash cũ: wrap ngay bằng migration, đếm số dòng còn scheme cũ, có hạn xoá.
- Pepper (nếu dùng) để trong secret manager; không log mật khẩu.
Sai lầm thường gặp
Phần tiêu đề “Sai lầm thường gặp”Lỗi hay gặp nhất khi tự viết bước verify:
So hash bằng chuỗi: rò thời gian, và bỏ qua tham số đã lưu
Hay gặp
const ok = sha256(salt + pw).toString('hex') === row.hash;Nên viết
const { params, salt, hash } = parsePhc(row.password_hash);const ok = timingSafeEqual(await argon2id(pw, salt, params), hash);Vì sao
Bản sai vừa dùng hash nhanh vừa so bằng ===. Bản đúng đọc tham số từ PHC, so constant-time.
Với hệ thống còn bcrypt, bẫy nằm ở cách lách giới hạn 72 byte:
| Cách hiểu sai | Hệ quả | Cần làm |
|---|---|---|
| Có salt thì SHA-256 an toàn | 2,3 triệu lần thử/giây/core | Hash chậm, memory-hard |
| Login chậm thì hạ tham số | Dump dễ crack hơn | Rate limit, giới hạn đồng thời |
| Rehash cả bảng bằng job đêm | Không có mật khẩu gốc để rehash | Wrap ngay, rehash lúc đăng nhập |
Tự kiểm tra
Phần tiêu đề “Tự kiểm tra”- Salt chống được rainbow table, vì sao không làm brute-force một user chậm hơn?
- Vì sao 16 lần argon2id đồng thời mất 66 ms chứ không phải 12 ms?
- Vì sao
needsRehashchỉ chạy sau khi verify đúng? - Wrap
argon2id(sha256_old)có cần mật khẩu gốc không, và vì sao vẫn phải thay bằngargon2id(pw)?
Tóm tắt
Phần tiêu đề “Tóm tắt”Tham khảo
Phần tiêu đề “Tham khảo”- OWASP — Password Storage Cheat Sheet (truy cập 2026-10-24): tham số, salt, pepper, nâng cấp hash cũ, pre-hash bcrypt.
- Node.js v24 — Crypto: tham số
argon2,maxmemcủascrypt,timingSafeEqual. - C2SP — PHC String Format: cú pháp, B64, vector mẫu argon2id.
- RFC 9106 — Argon2: thiết kế memory-hard và các biến thể.