0

Your Users Will Pick '123456': Password Storage and Breached-Password Checks Done Right

Tuần này HN có một thread hơn 300 điểm về vụ rò rỉ dữ liệu CPR ở Đan Mạch, và chi tiết khiến ai cũng thở dài là: mật khẩu 123456 nằm ngay trong câu chuyện. Năm 2026 rồi mà vẫn vậy. Đọc comment thì thấy phần lớn mọi người đổ lỗi cho user. Mình thì nghĩ khác: user sẽ luôn chọn 123456, việc của developer là làm sao để hệ thống không chấp nhận nó và không để lộ nó khi bị dump database. Bài này mình chia sẻ cách mình xử lý password trong các dự án thực tế: hash bằng Argon2id, chặn password đã bị lộ bằng k-anonymity, và migrate hash cũ mà không bắt user đổi mật khẩu.

1. Ngừng tự chế, dùng Argon2id với tham số đúng

Mình vẫn gặp codebase ở Việt Nam lưu md5(password) hoặc sha256(password + salt). Vấn đề là MD5 hay SHA-256 được thiết kế để nhanh. Một con RTX 4090 có thể thử hàng chục tỷ MD5 mỗi giây. Khi database bị dump, mấy password yếu kiểu 123456, matkhau123, iloveyou sẽ bị crack trong vài giây.

Password hashing cần chậm và tốn RAM một cách có chủ đích. Lựa chọn hiện tại là Argon2id (thắng Password Hashing Competition, được OWASP khuyến nghị). Mức tối thiểu OWASP đưa ra: m=19 MiB, t=2, p=1. Nếu server dư RAM thì cứ tăng lên.

Với Python, mình dùng argon2-cffi:

# pip install argon2-cffi>=23.1.0
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError, InvalidHashError

# OWASP minimum: Argon2id, memory 19 MiB, 2 iterations, 1 lane
ph = PasswordHasher(time_cost=2, memory_cost=19456, parallelism=1)

def hash_password(plain: str) -> str:
    # Output dạng: $argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>
        return ph.hash(plain)
        
        def verify_and_upgrade(stored_hash: str, plain: str, save_hash) -> bool:
            try:
                    ph.verify(stored_hash, plain)
                        except (VerifyMismatchError, InvalidHashError):
                                return False
                                    # Nếu sau này tăng memory_cost, hash cũ sẽ tự được nâng cấp khi user login
                                        if ph.check_needs_rehash(stored_hash):
                                                save_hash(ph.hash(plain))
                                                    return True
                                                    ```
                                                    
                                                    Điểm hay của format `$argon2id$...` là tham số và salt nằm luôn trong chuỗi hash. Bạn không cần cột `salt` riêng, và khi muốn tăng độ khó thì chỉ cần đổi config, `check_needs_rehash` lo phần còn lại.
                                                    
                                                    Mẹo thực tế: benchmark trên đúng loại máy production. Mục tiêu là mỗi lần hash mất khoảng 200–500ms. Nhanh hơn thì tăng `memory_cost`, chậm quá thì endpoint login dễ bị DoS. Login endpoint luôn phải đi kèm rate limit.
                                                    
                                                    ## 2. Migrate hash cũ mà không bắt user reset password
                                                    
                                                    Câu hỏi mình hay nhận: hệ thống có 2 triệu user đang dùng MD5, chẳng lẽ bắt reset hết? Không cần. Có hai giai đoạn:
                                                    
                                                    ```mermaid
                                                    flowchart TD
                                                        A[User login] --> B{Loại hash trong DB?}
                                                            B -->|argon2id| C[Verify Argon2id]
                                                                B -->|wrapped md5| D[Verify argon2 của md5 password]
                                                                    D -->|OK| E[Hash lại bằng Argon2id thuần]
                                                                        E --> F[Cập nhật DB]
                                                                            C -->|OK| G[Login thành công]
                                                                                F --> G
                                                                                    C -->|Fail| H[Từ chối]
                                                                                        D -->|Fail| H
                                                                                        ```
                                                                                        
                                                                                        - **Giai đoạn 1 (làm ngay, offline):** chạy batch job bọc toàn bộ hash cũ: `argon2id(md5_hash)`, đánh dấu `hash_type = 'wrapped_md5'`. Sau bước này, kể cả DB bị dump thì attacker cũng phải đối mặt với Argon2id. Đừng chờ user login mới bảo vệ họ.
                                                                                        - **Giai đoạn 2 (dần dần):** khi user login thành công, tính `md5(password)`, verify với hash đã bọc, đúng thì hash lại bằng Argon2id thuần và xoá flag.
                                                                                        
                                                                                        Sau 6–12 tháng, những account còn ở dạng wrapped thường là account chết. Lúc đó có thể buộc reset password cho nhóm này.
                                                                                        
                                                                                        ## 3. Chặn password đã bị lộ bằng k-anonymity
                                                                                        
                                                                                        Hash tốt cũng không cứu được password `123456`, vì attacker thử nó đầu tiên. NIST SP 800-63B (bản rev 4) nói rõ: **bỏ mấy rule kiểu bắt có ký tự đặc biệt, thay vào đó check password với danh sách password đã bị lộ**. Cái rule bắt có chữ hoa, số, ký tự đặc biệt chỉ sinh ra `Password@123`, cũng nằm trong mọi wordlist.
                                                                                        
                                                                                        API Pwned Passwords của Have I Been Pwned dùng kỹ thuật **k-anonymity**: bạn chỉ gửi 5 ký tự đầu của SHA-1, server trả về khoảng vài trăm suffix khớp prefix đó, việc so sánh làm ở phía bạn. Password thật không bao giờ rời khỏi server của bạn.
                                                                                        
                                                                                        ```mermaid
                                                                                        sequenceDiagram
                                                                                            participant U as User
                                                                                                participant S as Backend
                                                                                                    participant H as Pwned Passwords API
                                                                                                        U->>S: Đăng ký với password
                                                                                                            S->>S: SHA-1 rồi tách prefix 5 ký tự và suffix
                                                                                                                S->>H: GET /range/7C4A8
                                                                                                                    H-->>S: Danh sách suffix:count
                                                                                                                        S->>S: So khớp suffix ở local
                                                                                                                            S-->>U: Từ chối nếu count lớn hơn 0
                                                                                                                            ```
                                                                                                                            
                                                                                                                            Thử nhanh bằng bash xem `123456` bị lộ bao nhiêu lần:
                                                                                                                            
                                                                                                                            ```bash
                                                                                                                            HASH=$(printf '%s' '123456' | shasum -a 1 | awk '{print toupper($1)}')
                                                                                                                            PREFIX=${HASH:0:5}
                                                                                                                            SUFFIX=${HASH:5}
                                                                                                                            curl -s https://api.pwnedpasswords.com/range/$PREFIX | grep $SUFFIX
                                                                                                                            # D09CA3762AF61E59520943DC26494F8941B:<con số hàng chục triệu>
                                                                                                                            ```
                                                                                                                            
                                                                                                                            Còn đây là bản Node.js 20+ mình dùng trong signup và đổi password flow, không cần thêm dependency nào:
                                                                                                                            
                                                                                                                            ```js
                                                                                                                            import { createHash } from 'node:crypto';
                                                                                                                            
                                                                                                                            export async function pwnedCount(password) {
                                                                                                                              const sha1 = createHash('sha1').update(password).digest('hex').toUpperCase();
                                                                                                                                const prefix = sha1.slice(0, 5);
                                                                                                                                  const suffix = sha1.slice(5);
                                                                                                                                  
                                                                                                                                    try {
                                                                                                                                        const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`, {
                                                                                                                                              headers: { 'Add-Padding': 'true', 'User-Agent': 'my-app-password-check' },
                                                                                                                                                    signal: AbortSignal.timeout(2000),
                                                                                                                                                        });
                                                                                                                                                            if (!res.ok) return null;
                                                                                                                                                                const body = await res.text();
                                                                                                                                                                    for (const line of body.split('\n')) {
                                                                                                                                                                          const [hashSuffix, count] = line.trim().split(':');
                                                                                                                                                                                if (hashSuffix === suffix) return parseInt(count, 10);
                                                                                                                                                                                    }
                                                                                                                                                                                        return 0;
                                                                                                                                                                                          } catch {
                                                                                                                                                                                              return null; // API lỗi hoặc timeout: fail-open, log lại để theo dõi
                                                                                                                                                                                                }
                                                                                                                                                                                                }
                                                                                                                                                                                                ```
                                                                                                                                                                                                
                                                                                                                                                                                                Vài lưu ý khi đưa lên production:
                                                                                                                                                                                                
                                                                                                                                                                                                - Header `Add-Padding: true` giúp response có kích thước gần như cố định, người nghe lén traffic không đoán được prefix qua độ dài response.
                                                                                                                                                                                                - **Fail-open hay fail-closed?** Mình chọn fail-open (cho đăng ký tiếp nếu API chết), vì đây là lớp phòng thủ bổ sung chứ không phải lớp duy nhất. Hệ thống tài chính có thể chọn ngược lại.
                                                                                                                                                                                                - Nếu không muốn phụ thuộc API bên ngoài, có thể tải toàn bộ dataset về (vài chục GB) và tự host. Với hầu hết dự án vừa và nhỏ thì gọi API là đủ.
                                                                                                                                                                                                - Chỉ check khi **đặt hoặc đổi** password, không check mỗi lần login.
                                                                                                                                                                                                
                                                                                                                                                                                                ## 4. Những thứ nhỏ nhưng hay bị quên
                                                                                                                                                                                                
                                                                                                                                                                                                - **Độ dài tối thiểu:** NIST rev 4 yêu cầu tối thiểu 8 ký tự, và 15 nếu password là yếu tố xác thực duy nhất. Tối đa nên cho ít nhất 64 ký tự để user dùng passphrase hoặc password manager thoải mái.
                                                                                                                                                                                                - **Không truncate, không cấm paste.** Cấm paste thì user chỉ còn cách gõ password ngắn và dễ nhớ.
                                                                                                                                                                                                - **Log:** grep thử log của bạn với từ khoá `password`. Mình từng thấy request body chứa password nằm nguyên trong log ELK.
                                                                                                                                                                                                - **So sánh constant-time:** thư viện Argon2 đã lo phần này, đừng tự viết `hash == stored`.
                                                                                                                                                                                                - **MFA** cho admin panel là bắt buộc. Password tốt đến đâu cũng có thể bị phishing.
                                                                                                                                                                                                
                                                                                                                                                                                                ## Kết luận
                                                                                                                                                                                                
                                                                                                                                                                                                Vụ `123456` ở Đan Mạch không phải lỗi của một user, mà là lỗi của hệ thống đã chấp nhận nó. Checklist mình áp dụng cho mọi dự án:
                                                                                                                                                                                                
                                                                                                                                                                                                1. **Hash bằng Argon2id** với tối thiểu `m=19456, t=2, p=1`, benchmark trên máy production để mỗi lần hash mất 200–500ms.
                                                                                                                                                                                                2. **Bật `check_needs_rehash`** để nâng tham số dần mà không cần migration lớn.
                                                                                                                                                                                                3. **Hash cũ (MD5/SHA)?** Bọc ngay bằng Argon2id trong một batch job, sau đó nâng cấp dần khi user login.
                                                                                                                                                                                                4. **Bỏ composition rules**, thay bằng check Pwned Passwords qua k-anonymity khi đăng ký và đổi password.
                                                                                                                                                                                                5. **Rate limit login endpoint, bật MFA cho admin, và grep log** tìm password bị lộ.
                                                                                                                                                                                                
                                                                                                                                                                                                Tất cả gom lại chưa tới 100 dòng code, làm trong một buổi chiều là xong. Rẻ hơn rất nhiều so với việc tên công ty bạn lên top HN vì một vụ breach.

All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.