Hash password: chậm có chủ đích, salt, pepper, migrate hash cũ

4 câu hỏi~25 phútTừ khóa: rainbow table, salt, bcrypt, scrypt, Argon2id, work factor, memory-hard, pepper, hash migration

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ô

3. Giải pháp

Thuật toánTham sốGhi chú
Argon2idmemory m, iterations t, parallelism pLựa chọn đầu tiên (OWASP). Ví dụ m=19 MiB, t=2, p=1
scryptN, r, pMemory-hard, ví dụ N=2^17, r=8, p=1
bcryptcost (log2 số vòng)Phổ biến, mặc định Spring. Cost ≥ 10; chỉ dùng 72 bytes đầu của password
PBKDF2-HMAC-SHA256iterationsKhi 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-256SHA-256 + saltbcryptArgon2id
Rainbow tableBịChống đượcChống đượcChống được
Brute-force GPURất dễRất dễKhóKhó nhất
Chỉnh costKhôngKhôngcostm, t, p
Giới hạn inputKhôngKhông72 bytesKhông

7. Khi nào nên dùng / không nên dùng

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:

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


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.