Bài trước kết luận rằng SHA-256 băm được hơn 3 GB mỗi giây, và đó là điều tốt. Bài này cho thấy cùng tính chất ấy là thảm họa khi lưu mật khẩu.
Câu hỏi đặt ra không phải "làm sao để database không bị lộ" (việc đó cũng cần làm), mà là: khi database bị lộ, qua một bản sao lưu bỏ quên, một lỗi SQL injection, một nhân viên cũ, thì mật khẩu của người dùng còn an toàn được bao lâu? Câu hỏi này quan trọng vì người dùng dùng lại mật khẩu ở nhiều nơi: lộ mật khẩu ở trang của bạn có thể là lộ email ngân hàng của họ.
Lộ trình: các cách lưu sai (văn bản thô, mã hóa, băm nhanh), bảng cầu vồng và salt, hàm băm chậm cố ý (bcrypt, scrypt, Argon2id), đo tốc độ và ước lượng thời gian dò, cách trang này băm và kiểm tra mật khẩu, đăng nhập không lộ thông tin, rồi chính sách mật khẩu, MFA và passkey. Đo trên máy thật bằng Python 3.12 với argon2-cffi và bcrypt.
Ba cách lưu sai
Lưu văn bản thô. Lộ database là lộ hết. Nghe hiển nhiên nhưng vẫn xảy ra: năm 2019, Facebook thừa nhận hàng trăm triệu mật khẩu bị ghi dạng thô vào log nội bộ. Log, bản sao lưu, công cụ theo dõi lỗi đều là chỗ mật khẩu hay lọt vào.
Mã hóa mật khẩu. Mã hóa thì giải mã được, và khóa thường nằm ngay trên máy chủ đó. Kẻ lấy được database thường lấy được luôn khóa. Năm 2013, Adobe lộ 153 triệu tài khoản có mật khẩu mã hóa bằng 3DES ở chế độ ECB, chế độ mã hóa từng khối riêng rẽ: cùng mật khẩu ra cùng bản mã, và gợi ý mật khẩu lưu dạng thô ngay bên cạnh. Người ta đoán ngược ra phần lớn mật khẩu mà không cần khóa.
Băm nhanh, không salt. sha256(mật_khẩu) thì một chiều, nhưng có hai vấn đề:
no salt : 8d969eef6ecad3c2 8d969eef6ecad3c2
Hai người dùng cùng đặt 123456 thì có cùng bản băm. Nhìn database là biết ai dùng chung mật khẩu, và bản băm của 123456 nằm sẵn trên Google. Đó là vấn đề thứ nhất. Vấn đề thứ hai là tốc độ, ở mục dưới.
Bảng cầu vồng và salt
Vì bản băm của một mật khẩu luôn như nhau, kẻ tấn công có thể tính trước bản băm cho hàng tỷ mật khẩu phổ biến một lần, rồi tra ngược cho mọi database bị lộ. Bảng cầu vồng (rainbow table) là một cấu trúc nén cho bảng tra đó.
Salt là một chuỗi ngẫu nhiên riêng cho từng người dùng, băm cùng với mật khẩu và lưu ngay cạnh bản băm. Salt không bí mật. Việc của nó:
- Hai người cùng mật khẩu có bản băm khác nhau.
- Bảng tính sẵn vô dụng: muốn tính trước thì phải tính cho từng salt, tức là không còn "trước" nữa.
- Kẻ tấn công phải dò từng tài khoản một, không dò cả database một lúc được.
Salt không làm việc dò một tài khoản chậm đi. Mật khẩu yếu vẫn bị dò ra nhanh nếu hàm băm nhanh. Muốn chặn việc đó, phải làm cho hàm băm chậm.
Thêm một lớp nữa là pepper: một khóa bí mật chung, giữ ngoài database (biến môi trường, KMS), trộn vào bằng HMAC trước khi băm. Kẻ chỉ có database mà không có pepper thì không dò được gì. Nó hữu ích, nhưng thêm việc quản lý khóa (đổi pepper là phải đợi mọi người đăng nhập lại), nên nhiều hệ thống, kể cả trang này, chưa dùng.
Hàm băm chậm cố ý
Mật khẩu khác mọi dữ liệu khác ở một điểm: không gian rất nhỏ. Người dùng không chọn ngẫu nhiên trong 2²⁵⁶ khả năng; họ chọn tên, ngày sinh, từ điển, mẫu bàn phím. Kẻ tấn công không cần phá SHA-256, chỉ cần thử nhanh. Vậy hàm băm cho mật khẩu phải được thiết kế để chậm và tốn tài nguyên, với một mức chỉnh được:
| Hàm | Năm | Chỉnh được | Ghi chú |
|---|---|---|---|
| PBKDF2 | 2000 | Số vòng lặp | Lặp HMAC hàng trăm nghìn lần. Không tốn bộ nhớ, nên GPU chạy song song rất giỏi |
| bcrypt | 1999 | cost (mỗi +1 là gấp đôi thời gian) | Tốn 4 KB bộ nhớ, đủ làm khó GPU một chút. Chỉ lấy 72 byte đầu của mật khẩu |
| scrypt | 2009 | CPU, bộ nhớ | Hàm đầu tiên cố ý tốn nhiều bộ nhớ |
| Argon2id | 2015 | thời gian, bộ nhớ, số luồng | Thắng cuộc thi Password Hashing Competition. OWASP khuyên dùng đầu tiên |
Ý tưởng then chốt của scrypt và Argon2 là tốn bộ nhớ (memory-hard). Một GPU có hàng nghìn lõi nhưng mỗi lõi có rất ít bộ nhớ. Nếu mỗi lần băm cần 64 MB RAM, GPU 24 GB chỉ chạy song song được khoảng 380 lần băm thay vì hàng chục nghìn. Phần cứng chuyên dụng (ASIC) cũng mất lợi thế vì bộ nhớ đắt hơn mạch tính toán.
Chữ "id" trong Argon2id là kiểu lai: nửa đầu chống tấn công kênh kề (đo thời gian truy cập bộ nhớ), nửa sau chống GPU. Đó là kiểu nên dùng.
Đo: nhanh cỡ nào, chậm cỡ nào
Mỗi hàm chạy một lúc trên một lõi CPU:
| Hàm | Lần băm / giây | Mỗi lần |
|---|---|---|
| SHA-256 | 4.289.985 | 0,0002 ms |
| bcrypt cost 10 | 25,0 | 40,0 ms |
| bcrypt cost 12 | 6,2 | 162,4 ms |
| Argon2id m=19 MiB, t=2, p=1 (mức tối thiểu OWASP) | 78,0 | 12,8 ms |
| Argon2id m=64 MiB, t=3, p=4 (trang này) | 43,8 | 22,8 ms |
Argon2id với tham số của trang này chậm hơn SHA-256 khoảng 100.000 lần. Với người dùng thật, 23 ms khi đăng nhập là không cảm nhận được. Với kẻ dò mật khẩu, đó là khác biệt giữa vài giờ và vài thế kỷ:
| Mật khẩu | Số khả năng | Dò hết bằng SHA-256 | Dò hết bằng Argon2id |
|---|---|---|---|
| 8 chữ thường | 2,1 × 10¹¹ | 13,5 giờ | 151 năm |
| 8 ký tự bất kỳ in được | 6,6 × 10¹⁵ | 49 năm | 4,8 triệu năm |
| 4 từ ngẫu nhiên (từ điển 7.776 từ) | 3,7 × 10¹⁵ | 27 năm | 2,7 triệu năm |
Hai lưu ý để đọc bảng cho đúng:
- Đây là một lõi CPU chạy Python. Kẻ tấn công thật dùng GPU, nhiều máy, phần mềm chuyên dụng như hashcat, nhanh hơn nhiều bậc với SHA-256. Với Argon2id, khoảng cách hẹp hơn hẳn vì bộ nhớ, đó chính là mục đích của nó. Nên đọc cột thứ hai như "tỉ lệ", không phải thời gian tuyệt đối.
- Không ai dò "hết". Kẻ tấn công dò theo thứ tự khả năng: danh sách mật khẩu đã lộ (hàng tỷ mục), từ điển, biến thể (
Matkhau@2026). Mật khẩu123456bị tìm ra trong lần thử đầu tiên với bất kỳ hàm băm nào. Hàm băm chậm chỉ bảo vệ được mật khẩu không nằm ở đầu danh sách.
Dòng cuối cũng đáng để ý: 4 từ ngẫu nhiên kiểu ngựa-pin-kẹp-giấy mạnh ngang 8 ký tự hỗn loạn kiểu k#9Lq!2x, mà dễ nhớ hơn nhiều. Chữ quan trọng là ngẫu nhiên, do máy chọn, không phải do người nghĩ ra.
Trang này băm và kiểm tra mật khẩu thế nào
Mật khẩu của viet.sarl dùng pwdlib với cấu hình khuyến nghị:
password_hash = PasswordHash.recommended() # Argon2id
Kết quả lưu trong cột password_hash có dạng chuẩn PHC, tự mô tả mọi thứ cần để kiểm tra lại:
$argon2id$v=19$m=65536,t=3,p=4$FhQAgDXAUPiL1lk487RfZg$QgrBgUbAcVtU5fbxJpdWHi21jVNjMA0es0uyq08+uts
thuật toán phiên bản bộ nhớ 64 MiB, 3 vòng, 4 luồng salt (16 byte) bản băm (32 byte)
Băm 123456 hai lần cho hai chuỗi khác nhau hoàn toàn vì salt khác nhau. Vì tham số nằm ngay trong chuỗi, sau này muốn tăng độ khó chỉ cần đổi cấu hình: bản băm cũ vẫn kiểm tra được bằng tham số cũ, và khi người dùng đăng nhập thành công thì băm lại bằng tham số mới (pwdlib có verify_and_update cho việc này).
Băm mất 23 ms CPU, nên trang này chạy nó trong một luồng riêng (run_in_threadpool) để không chặn vòng lặp sự kiện của máy chủ async, đúng như bài về luồng và GIL đã nói về việc tính toán nặng trong code async.
Đăng nhập không lộ email có tồn tại hay không
Form đăng nhập có một rò rỉ kinh điển. Nếu email không tồn tại, máy chủ trả lời ngay; nếu tồn tại, nó phải chạy Argon2id 23 ms rồi mới trả lời "sai mật khẩu". Thông báo lỗi có giống nhau thì thời gian vẫn khác. Đo:
| Trường hợp | Không có băm giả | Có băm giả |
|---|---|---|
| Email có tồn tại, sai mật khẩu | 22,03 ms | 22,08 ms |
| Email không tồn tại | 0,000 ms | 21,81 ms |
Không có biện pháp gì thì chỉ cần đo thời gian là biết email nào đã đăng ký: danh sách đó đáng giá cho lừa đảo, và là bước đầu để dò mật khẩu. Trang này kiểm tra với một bản băm giả khi không tìm thấy người dùng, để hai trường hợp mất cùng thời gian:
# Verified against when the email is unknown, so a miss takes as long as a wrong password.
_DUMMY_HASH = password_hash.hash(secrets.token_urlsafe())
...
hashed = user.password_hash if user and user.password_hash else _DUMMY_HASH
ok = await run_in_threadpool(password_hash.verify, password, hashed)
Cùng một thông báo "sai email hoặc mật khẩu" cho cả hai trường hợp, và trang quên mật khẩu cũng hiện cùng một thông báo dù email có tồn tại hay không. Trang đăng ký thì không giấu được (email đã dùng phải báo là đã dùng), nên nó cần giới hạn số lần thử. Tài liệu của trang ghi rõ một chỗ đã chấp nhận đánh đổi: người thử mật khẩu cũ của tài khoản đã chuyển sang Google sẽ được nhắc dùng Google, tức là có lộ rằng tài khoản tồn tại.
Chính sách mật khẩu ngày nay
Hướng dẫn NIST SP 800-63B (Mỹ) bản mới đã đảo ngược nhiều thói quen cũ:
| Nên | Không nên |
|---|---|
| Tối thiểu 15 ký tự khi mật khẩu là yếu tố duy nhất (8 nếu đi kèm MFA), cho phép dài tới ít nhất 64 | Bắt buộc phải có hoa, số, ký tự đặc biệt |
| Kiểm tra với danh sách mật khẩu đã lộ (ví dụ API Have I Been Pwned, chỉ gửi 5 ký tự đầu của bản băm SHA-1) | Bắt đổi mật khẩu định kỳ khi không có dấu hiệu bị lộ |
| Cho dán mật khẩu (để dùng trình quản lý mật khẩu) | Câu hỏi bảo mật ("tên thú cưng đầu tiên") |
| Giới hạn số lần thử, khóa tạm | Gợi ý mật khẩu |
Quy tắc phức tạp làm người dùng chọn Matkhau@1, thứ có trong mọi từ điển. Bắt đổi định kỳ làm họ chọn Matkhau@2. Độ dài và việc chặn mật khẩu đã lộ hiệu quả hơn nhiều. Trang này cho mật khẩu 8–128 ký tự, không ép kiểu ký tự; mức tối thiểu 8 thấp hơn khuyến nghị 15 của NIST cho mật khẩu không có MFA, một việc nên sửa.
Nhưng bảo vệ tốt nhất cho mật khẩu là không phụ thuộc vào nó:
- MFA (xác thực nhiều yếu tố): mã TOTP 6 số trong ứng dụng (thực chất là HMAC của thời gian hiện tại, bài 2), khóa phần cứng. Lộ mật khẩu chưa đủ để vào.
- Passkey (WebAuthn): thiết bị giữ một khóa bí mật riêng cho từng trang, đăng nhập bằng cách ký một thử thách (bài 4). Máy chủ chỉ lưu khóa công khai, nên lộ database không lộ gì cả. Và vì chữ ký gắn với tên miền, trang lừa đảo giả mạo không lấy được gì.
- Đăng nhập bằng Google (bài 7): đẩy cả việc giữ mật khẩu sang một bên có đội bảo mật lớn hơn.
Tóm lại
- Câu hỏi đúng: khi database lộ thì mật khẩu còn an toàn bao lâu. Không lưu thô, không mã hóa, không băm nhanh.
- Salt ngẫu nhiên cho từng người: cùng mật khẩu khác bản băm, bảng tính sẵn vô dụng, phải dò từng tài khoản. Salt không bí mật; pepper thì có.
- Băm mật khẩu phải chậm và tốn bộ nhớ: dùng Argon2id (hoặc bcrypt, scrypt).
- Đo được: SHA-256 4,3 triệu lần/giây, Argon2id của trang này 44 lần/giây (23 ms), chậm hơn ~100.000 lần. Mật khẩu 8 chữ thường: 13,5 giờ so với 151 năm trên một lõi.
- Không hàm băm nào cứu được mật khẩu có trong danh sách đã lộ. Kiểm tra danh sách đó khi đăng ký.
- Không tìm thấy email thì vẫn kiểm tra với bản băm giả (0 ms → 21,8 ms, bằng trường hợp sai mật khẩu).
- Dài hơn tốt hơn phức tạp hơn. Tốt nhất là MFA, passkey, hoặc đăng nhập bằng một nhà cung cấp lớn.
Bài tiếp theo đi vào mật mã khóa công khai: làm sao hai bên chưa từng gặp nhau có chung một bí mật, tự tính RSA và Diffie-Hellman bằng số nhỏ, RSA so với Ed25519, và mật mã hậu lượng tử mà trang này đang dùng.