Hash password: chậm có chủ đích, salt, pepper, migrate hash cũ
Tiếp đi: hash password. Bài trước nói SHA-256 không dùng cho password. Nó là hàm một chiều mà, sao lại không được?
Một chiều thì đúng, nhưng nhanh chính là vấn đề. Cách cấu hình PasswordEncoder trong Spring đã có ở bài Password, CORS, CSRF; bài này đi vào lý do bên dưới: vì sao chậm, chỉnh cost thế nào, pepper, và chuyển hash cũ.
1. Bài toán
shop-app bị dump bảng users (backup S3 để public, SQL injection...). Kẻ tấn công có 1 triệu dòng:
email | password_hash
an@shop.example | 5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8
Câu hỏi: trong bao lâu thì họ ra được password thật?
2. Vấn đề với SHA-256 thô
- Quá nhanh: một GPU tính hàng tỷ SHA-256 mỗi giây. Password người dùng thường ngắn, có quy luật, nằm trong danh sách lộ (rockyou...).
- Không salt: cùng password ra cùng hash. Hash trên chính là
"password", tra Google là thấy. Rainbow table (bảng tra tính sẵn) dùng được ngay. - Hai user cùng password lộ cùng lúc; crack một hash là crack mọi tài khoản dùng chung.
3. Giải pháp
- Salt: 16 bytes SecureRandom, khác nhau cho mỗi user, lưu cạnh hash (không bí mật). Phá rainbow table, buộc kẻ tấn công crack từng user riêng.
- Hàm chậm có tham số cost: mỗi lần thử tốn ~100–500 ms CPU (và RAM). Login chậm 0.3 s không ai để ý; brute-force 1 tỷ lần thì thành hàng chục năm.
- Memory-hard (scrypt, Argon2): mỗi lần thử cần nhiều MB RAM, GPU/ASIC mất lợi thế song song.
- Pepper (tùy chọn): secret chung lưu ngoài DB (KMS, env). Lộ DB mà không lộ pepper thì hash vô dụng.
| Thuật toán | Tham số | Ghi chú |
|---|---|---|
| Argon2id | memory m, iterations t, parallelism p | Lựa chọn đầu tiên (OWASP). Ví dụ m=19 MiB, t=2, p=1 |
| scrypt | N, r, p | Memory-hard, ví dụ N=2^17, r=8, p=1 |
| bcrypt | cost (log2 số vòng) | Phổ biến, mặc định Spring. Cost ≥ 10; chỉ dùng 72 bytes đầu của password |
| PBKDF2-HMAC-SHA256 | iterations | Khi cần FIPS. ≥ 600 000 vòng |
4. Code từng bước
Bước 1: Dependency. Argon2 trong Spring Security cần Bouncy Castle:
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk18on</artifactId>
</dependency>
Bước 2: Config, để cost ra cấu hình để chỉnh theo phần cứng:
security:
password:
argon2:
memory-kib: 19456
iterations: 2
pepper: ${PASSWORD_PEPPER}
Bước 3: Code Java. Đo thời gian trên đúng máy production để chọn tham số:
public class CostBenchmark {
public static void main(String[] args) {
for (int mem : new int[]{19456, 47104, 65536}) {
var enc = new Argon2PasswordEncoder(16, 32, 1, mem, 2); // salt 16B, hash 32B, p=1
String h = enc.encode("warm-up");
long t0 = System.nanoTime();
for (int i = 0; i < 20; i++) enc.matches("warm-up", h);
System.out.printf("m=%d KiB -> %d ms/hash%n", mem, (System.nanoTime() - t0) / 20 / 1_000_000);
}
}
}
// Chọn mức cho ~200-500 ms/hash và kiểm tra RAM: 50 login đồng thời x 46 MiB = 2.3 GiB
Pepper bằng HMAC trước khi đưa vào Argon2 (giữ input ngắn, cố định độ dài):
@Component
public class PepperedPasswordEncoder implements PasswordEncoder {
private final PasswordEncoder delegate;
private final SecretKeySpec pepper;
public PepperedPasswordEncoder(@Value("${security.password.pepper}") String pepperB64,
PasswordEncoder delegate) {
this.pepper = new SecretKeySpec(Base64.getDecoder().decode(pepperB64), "HmacSHA256");
this.delegate = delegate;
}
private String pre(CharSequence raw) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(pepper);
return Base64.getEncoder().encodeToString(
mac.doFinal(raw.toString().getBytes(StandardCharsets.UTF_8)));
} catch (GeneralSecurityException e) {
throw new IllegalStateException(e);
}
}
@Override public String encode(CharSequence raw) { return delegate.encode(pre(raw)); }
@Override public boolean matches(CharSequence raw, String hash) { return delegate.matches(pre(raw), hash); }
}
Lưu ý: có pepper thì xoay pepper rất khó (phải đợi user login lại). Cân nhắc trước khi dùng.
Bước 4: Test bằng curl
curl -s -o /dev/null -w "%{time_total}\n" -X POST http://localhost:8080/auth/login \
-H "Content-Type: application/json" -d '{"email":"an@shop.example","password":"wrong"}'
# 0.31 (thời gian chủ yếu ở Argon2 - đúng như mong muốn)
Kèm theo: rate limit endpoint login, vì hash chậm cũng là bề mặt DoS.
5. Cách hoạt động
Đăng ký:
salt = SecureRandom(16)
hash = Argon2id(password, salt, m, t, p)
lưu: $argon2id$v=19$m=19456,t=2,p=1$<salt b64>$<hash b64>
^ thuật toán và tham số nằm ngay trong chuỗi -> đổi cost không làm hỏng hash cũ
Đăng nhập:
đọc chuỗi -> tách salt, m, t, p -> tính lại -> so sánh constant-time
Kẻ tấn công (có DB, không có pepper):
mỗi user salt riêng -> không dùng rainbow table
mỗi lần đoán 0.3 s + 19 MiB RAM -> GPU vô dụng
không có pepper -> không đoán được gì
6. So sánh
| SHA-256 | SHA-256 + salt | bcrypt | Argon2id | |
|---|---|---|---|---|
| Rainbow table | Bị | Chống được | Chống được | Chống được |
| Brute-force GPU | Rất dễ | Rất dễ | Khó | Khó nhất |
| Chỉnh cost | Không | Không | cost | m, t, p |
| Giới hạn input | Không | Không | 72 bytes | Không |
7. Khi nào nên dùng / không nên dùng
- Argon2id: hệ thống mới. bcrypt: đã dùng sẵn, vẫn ổn. PBKDF2: yêu cầu FIPS.
- Không dùng các hàm này cho API key / token entropy cao: SHA-256 là đủ, và hash chậm sẽ làm mọi request chậm.
- Không mã hóa password (AES) "để có thể gửi lại cho user". Không bao giờ cần đọc lại password.
shop-app cũ lưu md5(password) cho 2 triệu user. Mình không biết password gốc thì làm sao đổi sang Argon2? Chờ user login lại thì những người không bao giờ login vẫn nằm đó dạng MD5.
Đúng, chỉ "rehash khi login" là chưa đủ. Cách tốt là kết hợp hai bước:
Bước A: bọc ngay (wrap) toàn bộ hash cũ, chạy một batch job, không cần password gốc:
{md5-argon2} Argon2id(md5_hex, salt) -- thay cho md5_hex trong DB
Ngay sau job, DB không còn MD5 thô nào. Login: tính md5(input) rồi so qua Argon2.
Bước B: khi user login thành công, rehash bằng password gốc thành {argon2} thuần:
public class LegacyMd5WrappedEncoder implements PasswordEncoder {
private final Argon2PasswordEncoder argon2 = Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8();
private static String md5Hex(CharSequence raw) {
try {
byte[] d = MessageDigest.getInstance("MD5")
.digest(raw.toString().getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(d);
} catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); }
}
@Override public String encode(CharSequence raw) { return argon2.encode(md5Hex(raw)); }
@Override public boolean matches(CharSequence raw, String hash) { return argon2.matches(md5Hex(raw), hash); }
}
// Đăng ký vào DelegatingPasswordEncoder với id "md5-argon2", mặc định "argon2".
// upgradeEncoding() trả true với {md5-argon2} -> Spring gọi UserDetailsPasswordService
// để lưu hash mới sau khi login thành công.
Sau 6–12 tháng, có thể buộc reset password với những tài khoản còn {md5-argon2}.
Hai năm nữa server mạnh hơn, mình muốn tăng bcrypt cost từ 10 lên 12. Có phải reset password toàn bộ không?
Không cần. Cost nằm ngay trong chuỗi hash ($2a$10$...), nên hash cũ vẫn verify được bằng encoder cost 12. Chỉ cần:
- Đổi encoder thành
new BCryptPasswordEncoder(12). BCryptPasswordEncoder.upgradeEncoding()trảtruekhi hash có cost thấp hơn, Spring tự rehash lúc login thành công nếu cóUserDetailsPasswordService.
Mỗi +1 cost bcrypt là gấp đôi thời gian. Đo lại trước khi tăng: 10 → 12 là chậm 4 lần.
Phỏng vấn hỏi "lưu password thế nào cho đúng" thì trả lời sao?
Q1. Vì sao không dùng SHA-256 để hash password?
🇻🇳 Tiếng Việt:
SHA-256 được thiết kế để nhanh, GPU tính hàng tỷ lần mỗi giây, nên brute-force password yếu rất dễ. Password cần hàm chậm có chủ đích, có salt riêng mỗi user và tốt nhất là memory-hard như Argon2id, để mỗi lần đoán tốn thời gian và RAM.
🇬🇧 English:
SHA-256 is designed to be fast, and GPUs compute billions per second, so brute-forcing weak passwords is easy. Passwords need a deliberately slow function with a unique per-user salt, ideally memory-hard like Argon2id, so every guess costs time and memory.
Q2. Salt và pepper khác nhau thế nào?
🇻🇳 Tiếng Việt:
Salt là giá trị ngẫu nhiên riêng cho mỗi user, lưu cùng hash, không bí mật, dùng để chống rainbow table và buộc crack từng user. Pepper là một secret chung lưu ngoài database như trong KMS, nên nếu chỉ lộ database thì hash vẫn không crack được. Đổi lại, pepper khó xoay vòng.
🇬🇧 English:
A salt is a random per-user value stored next to the hash; it is not secret and defeats rainbow tables. A pepper is a shared secret kept outside the database, for example in a KMS, so a database-only leak is not enough to crack hashes. The trade-off is that a pepper is hard to rotate.
Q3. Migrate từ MD5 sang Argon2 thế nào?
🇻🇳 Tiếng Việt:
Em chạy job bọc mọi hash MD5 cũ bằng Argon2, nên database không còn MD5 thô. Khi user đăng nhập thành công, em rehash password gốc sang Argon2 thuần, dùng DelegatingPasswordEncoder với prefix để phân biệt. Tài khoản không login sau một thời gian thì buộc reset.
🇬🇧 English:
I run a job that wraps every legacy MD5 hash with Argon2, so no raw MD5 remains in the database. On each successful login I rehash the plaintext password with plain Argon2, using DelegatingPasswordEncoder prefixes to tell formats apart. Accounts that never log in are forced to reset after a deadline.
Cách nhớ ngắn gọn
- Password: chậm + salt + memory-hard → Argon2id (hoặc bcrypt).
- Chỉnh cost cho ~200–500 ms trên máy thật, rate limit login.
- Salt công khai, pepper bí mật ngoài DB.
- Migrate: wrap ngay, rehash khi login, buộc reset phần còn lại.
Tự kiểm tra
Salt có cần giữ bí mật không?
Không. Nó lưu cùng hash. Mục đích là mỗi user khác nhau để chống rainbow table.
Memory-hard nghĩa là gì, vì sao quan trọng?
Mỗi lần tính cần nhiều RAM, GPU/ASIC không còn lợi thế chạy song song hàng nghìn phép tính rẻ.
bcrypt có giới hạn gì cần biết?
Chỉ dùng 72 bytes đầu của password; cost là log2, +1 là gấp đôi thời gian.
Tăng cost có phải reset password mọi user không?
Không. Tham số nằm trong chuỗi hash; rehash dần khi login nhờ upgradeEncoding().
Token API entropy cao có cần Argon2 không?
Không. SHA-256 là đủ vì không brute-force được không gian 256 bit; Argon2 chỉ làm request chậm.