bcrypt vs Argon2id: 187ms trên CPU, 95x speedup trên GPU

Cùng 200ms wall-clock, bcrypt và Argon2id không cùng bậc an toàn. Benchmark RTX 3070 Ti + lý do hashcat mất 11 năm mới support Argon2 đàng hoàng.

bcrypt vs Argon2id: 187ms trên CPU, 95x speedup trên GPU

Mình đang viết lại Auth Service cho một project Django. Phiên bản cũ dùng bcrypt với cost=10, chạy ổn vài năm, không có sự cố gì. Lần này mình quyết định migrate sang Argon2id vì đọc OWASP Password Storage Cheat Sheet, họ khuyến nghị Argon2id là default cho project mới, params tối thiểu m=19 MiB, t=2, p=1.

Đổi xong, mình chạy performance test. Số trông không ổn lắm. Hash time chậm hơn rõ rệt, memory footprint của container nhảy lên cả trăm MiB ở mỗi request đỉnh điểm. User flow vẫn chạy, nhưng mình đang trả một cái giá mà chưa hiểu hết. Câu hỏi đầu tiên hiện ra trong đầu: vậy đánh đổi là gì nhỉ? Mình bỏ thêm CPU và RAM ra để được cái gì? Argon2 có thực sự an toàn hơn bcrypt đáng kể, hay đây chỉ là một cargo cult mới mà cộng đồng security đang đẩy nhau theo?

Mình bắt đầu đào. Mất một buổi tối. Câu trả lời cuối cùng không nằm ở những con số trong cheat sheet, cũng không nằm ở wall-clock trên Grafana. Nó nằm ở một sự thật mà mình tin là hầu hết developer Việt Nam (kể cả mình của ba ngày trước) đang hiểu sai về password hashing.

Sự thật đó là: thời gian hash trên CPU không phải đơn vị đo độ an toàn. Nó chỉ là đơn vị đo trải nghiệm người dùng. Đó là hai thứ khác nhau, và chúng ta đã nhầm hai cái đó suốt mười năm qua.

Bài này là cuộc đào đó. Mình sẽ chứng minh bằng thực nghiệm thật trên máy mình, không phải lý thuyết. Cuối bài bạn sẽ hiểu vì sao hashcat support bcrypt suốt 15 năm chạy mượt mà, nhưng đến tháng 8 năm 2025 mới có Argon2 support đàng hoàng - và speedup GPU/CPU lúc đó cũng kém bcrypt cả một bậc.

bcrypt vs Argon2id: cùng 200ms có nghĩa cùng an toàn không?

Không. bcrypt cost=12 và Argon2id 64 MiB cùng mất khoảng 200ms trên CPU, nhưng trên GPU attacker, bcrypt bị bẻ nhanh hơn 95 lần còn Argon2id chỉ 4-5 lần. Wall-clock không đo được memory-hardness - và đó chính là trục mà bcrypt thiếu.

Bcrypt với cost factor 12 mất khoảng 200 mili giây để hash một password trên CPU server thông thường. Argon2id với memory 64 MiB, 3 lần lặp, 4 lane song song cũng mất khoảng 200 mili giây trên cùng CPU. Trải nghiệm người đăng nhập giống hệt nhau. Cảm giác "công sức" của server cũng giống nhau.

Câu hỏi là: nếu cả hai tốn cùng từng đó CPU time, thì có nghĩa cả hai cùng độ an toàn không?

Câu trả lời sách giáo khoa: không, Argon2 mới hơn, dùng Argon2 đi. OWASP cũng nói thế [1].

Câu trả lời sách giáo khoa đúng nhưng chưa đủ. Nó không nói vì sao Argon2 mạnh hơn. Nó cũng không nói vì sao "mạnh hơn" lại không hiện ra trong cái wall-clock 200 mili giây đó.

Để trả lời câu hỏi vì sao, mình cần đào ba layer. Mỗi layer trả lời được câu trên một lượt, nhưng không đủ. Đến layer thứ tư, câu chuyện mới gọn lại.

Layer 1: bcrypt được sinh ra cho CPU của năm 1999

Niels Provos và David Mazières trình bày bcrypt tại USENIX 1999 [2]. Đọc paper, có một câu khá khiêm tốn ngay đầu: "user passwords have fixed entropy while hardware constantly improves". Dịch ra: password người dùng đặt không khá lên theo thời gian, nhưng máy tính của attacker thì khá lên đều đều mỗi năm. Vậy thuật toán hash phải có cơ chế thích nghi, phải biết tự làm chậm mình lại khi máy tính nhanh lên. Tinh thần này là chung của cả thập niên 90s - cùng giai đoạn RSA-2048 ra đời từ một đêm say rượu năm 1977 và mật mã hiện đại đang định hình.

Cách Provos và Mazières giải bài toán này là một sáng tạo gọn ghẽ: thay vì viết một hash function mới, họ sửa Blowfish (một block cipher đã có sẵn) để key schedule trở nên cố ý đắt. Họ gọi nó là EksBlowfish (Expensive key schedule Blowfish). Key schedule chạy lặp \(2^c\) vòng, với \(c\) là cost factor có thể chỉnh từ 4 đến 31.

Cost=10 nghĩa là 1024 vòng. Cost=12 là 4096. Mỗi tăng 1 đơn vị cost, thời gian gấp đôi. Đẹp. Tham số tăng tuyến tính, chi phí tăng theo cấp số nhân.

Có một chi tiết quan trọng mà ít ai chú ý. EksBlowfish chỉ cần khoảng 4 KiB memory để chạy, đủ để chứa 4 S-box của Blowfish (mỗi S-box 256 phần tử, 4 byte = 1 KiB) cộng với P-array. Và 4 KiB này là cố định, không phụ thuộc cost factor. Bạn tăng cost lên 31, vẫn 4 KiB.

Cấu trúc EksBlowfish của bcrypt: bốn S-box và P-array gói gọn trong 4 KiB. Vòng lặp key schedule chạy \(2^c\) lần, nhưng kích thước memory không đổi dù cost factor là 4 hay 31.

Năm 1999, đây là một quyết định hoàn hảo. CPU phổ thông năm đó có L1 cache khoảng 16-32 KiB. Bcrypt nằm gọn trong L1, chạy mượt, không bao giờ phải chạm RAM. Bài toán "máy tính khá lên theo thời gian" được giải bằng cost factor có thể chỉnh tăng. Mọi thứ nhất quán.

Vấn đề là CPU năm 1999 không phải kẻ tấn công duy nhất trong tương lai.

Layer 2: Percival 2009 và phát minh ra "memory-hard"

Năm 2009, Colin Percival trình bày scrypt tại BSDCan [3]. Paper đó có một định nghĩa mà sau này trở thành nền móng của cả Argon2.

Percival nói: một hàm hash là memory-hard nếu để chạy nó với N đơn vị compute, bạn phải có ít nhất \(\Omega(N)\) đơn vị memory chạm vào. Không thể đổi memory lấy compute. Không thể giảm memory bằng cách chấp nhận chạy lâu hơn.

Định nghĩa nghe khô khan, nhưng implication thì không. Nếu một thuật toán memory-hard yêu cầu 64 MiB cho mỗi lần hash, thì attacker muốn chạy 1000 lần hash song song phải có 64 GiB memory. Không phải 64 MiB chia ra. Phải 64 GiB toàn cục.

Sự khác nhau giữa non memory-hard và memory-hard. Với bcrypt, ba thread cùng share một block 4 KiB trong L1 cache. Với Argon2id, mỗi thread cần riêng 64 MiB, tổng phải 192 MiB cho cả ba - không thể share, không thể đổi memory lấy compute.

Đây là chìa khoá. Bcrypt cố định 4 KiB. Memory cost không scale với cost factor. Nghĩa là bcrypt KHÔNG phải memory-hard. Cost factor chỉ chống được brute force trên CPU. Nó không chống được attacker có hardware khác.

Layer 3: Argon2 sinh ra để trả thù cho memory-hardness

Năm 2013, IACR phát động Password Hashing Competition [4]. Cuộc thi chạy hai năm. Tháng 7 năm 2015, ba nhà nghiên cứu Đại học Luxembourg (Alex Biryukov, Daniel Dinu, Dmitry Khovratovich) thắng với Argon2 [5].

Argon2 có ba variant. Argon2d truy cập memory phụ thuộc password (nhanh hơn, nhưng có thể bị side-channel attack). Argon2i truy cập memory độc lập password (chậm hơn, an toàn side-channel). Argon2id là hybrid: nửa đầu chạy như Argon2i, nửa sau như Argon2d [6].

RFC 9106 [7] sau đó chuẩn hoá Argon2id là default. Các tham số chính:

  • \(m\): memory cost, tính bằng KiB
  • \(t\): number of passes (time cost)
  • \(p\): parallelism (số lane)

RFC khuyến nghị hai cấu hình:

\[\text{FIRST}: m = 2^{21} \text{ KiB} = 2 \text{ GiB}, \quad t=1, \quad p=4\]

\[\text{SECOND}: m = 2^{16} \text{ KiB} = 64 \text{ MiB}, \quad t=3, \quad p=4\]

OWASP nhẹ tay hơn, cho phép xuống tới \(m = 19 \text{ MiB}, t=2, p=1\) [1]. Đó là con số kỳ kỳ mà mình tự hỏi đầu bài. Vì sao 19 chứ không phải 20 hay 16? OWASP liệt kê ba config equivalent (m=19 t=2, m=12 t=3, m=7 t=5, tất cả p=1), tất cả derive từ tính toán memory-time product để đạt độ an toàn tương đương cấu hình SECOND của RFC. Con số 19 đến từ tính toán cụ thể chứ không phải làm tròn cho đẹp.

OK lý thuyết đến đây. Đây là chỗ nhiều bài blog dừng lại. "Argon2 memory-hard, GPU resist tốt hơn, dùng đi." Mình muốn đi xa hơn. Mình muốn đo.

Thực nghiệm 1: cùng wall-clock, khác cơ chế

Mình viết một benchmark Python đơn giản. Hash 30 password ngẫu nhiên với mỗi config, đo median thời gian. Toàn bộ code và results đã public ở maycuatroi1/bcrypt-vs-argon2-benchmark. Chạy trong môi trường:

  • CPU: Intel Core i7-12700F (12 cores, 5.0 GHz turbo)
  • RAM: 64 GiB
  • Python 3.13.5, bcrypt 4.3.0, argon2-cffi 25.1.0

Kết quả:

Config p50 (ms) p95 (ms) Throughput (H/s)
bcrypt cost=8 11.94 12.22 83.8
bcrypt cost=10 46.40 48.66 21.6
bcrypt cost=12 186.74 191.02 5.4
bcrypt cost=14 764.05 773.99 1.3
argon2id m=19 MiB t=2 p=1 19.29 21.85 51.8
argon2id m=64 MiB t=3 p=4 50.48 59.80 19.8
argon2id m=256 MiB t=3 p=4 210.07 241.65 4.8
Thời gian hash trên CPU i7-12700F. Mỗi cột là median của 30 lần hash. bcrypt cost=12 và Argon2id m=256MB t=3 p=4 ra số gần nhau (187ms vs 210ms). bcrypt cost=10 và Argon2id m=64MB t=3 p=4 cũng gần nhau (46ms vs 50ms). Trên trục thời gian, hai thuật toán không phân biệt được.

Có hai cặp đáng để ý:

  • bcrypt cost=10 (~46 ms) và Argon2id m=64 MiB t=3 p=4 (~50 ms), cùng "experience" cho user
  • bcrypt cost=12 (~187 ms) và Argon2id m=256 MiB t=3 p=4 (~210 ms), cùng "experience"

Nếu chỉ nhìn wall-clock, bạn không thể chọn được giữa hai cái. Cả hai đều mất từng đó CPU time. Cả hai đều làm cho user login chờ từng đó.

Nhưng cái ẩn dưới wall-clock đó là memory footprint. Bcrypt: 4 KiB cho mọi cost. Argon2id: từ 19 MiB đến 256 MiB, tuỳ chọn.

Memory footprint mỗi hash, thang log. bcrypt cố định 4 KiB cho mọi cost factor. Argon2id từ 19 MiB tới 256 MiB. Chênh lệch là 5 đến 6 bậc độ lớn. Trục này không xuất hiện trên đồng hồ wall-clock.

Khoảng cách giữa hai thuật toán là 5 đến 6 bậc độ lớn về memory cost. Năm bậc độ lớn. Cùng một đơn vị thời gian CPU.

Đây là cái mình tin nhiều người (gồm cả mình tuần trước) chưa kịp nội hoá. Chúng ta đã quen đo "an toàn" bằng thời gian CPU, kiểu "cost=12 mất 200ms, chắc đủ rồi". Cái đo đó bỏ qua một trục.

Speed vs memory cho từng cấu hình. Khi bạn pair bcrypt cost=12 với Argon2id ở cùng wall-clock, chấm Argon2id cách chấm bcrypt 16000 lần trên trục memory. Trục này không xuất hiện trên đồng hồ wall-clock. Đó là lý do bcrypt và Argon2id trông bằng nhau trên CPU, nhưng không bằng nhau trong thế giới attacker.

Nhưng cho đến giờ, mình mới đang nói về user experience. Phần thú vị là attacker experience. Đây là chỗ GPU bước vào.

Thực nghiệm 2: GPU đến và mọi thứ vỡ tung

Mình chạy hashcat v7.0.0 trên RTX 3070 Ti (8 GiB VRAM, 6144 CUDA cores), CUDA 13.1, đẩy qua WSL2. Hashcat tạo password ngẫu nhiên theo mask ?l?l?l?l?l?l?l?l (8 chữ thường) và thử match với hash mình đã sinh sẵn. Đo throughput sau 30 giây.

Đầu tiên là bcrypt ở các cost factor khác nhau:

Cost factor bcrypt GPU H/s (RTX 3070 Ti) CPU H/s (i7-12700F) Speedup GPU/CPU
5 (default benchmark -b) 63,513 ~250 (extrapolate) ~254x
8 8,168 83.8 97x
10 2,022 21.6 94x
12 507 5.4 95x
14 126 1.3 96x

Tỷ lệ speedup ổn định khoảng 95 lần ở mọi cost factor cao. Đó không phải con số ngẫu nhiên. RTX 3070 Ti có 48 streaming multiprocessor, mỗi cái chạy được vài thread bcrypt song song trong shared memory. Hashcat tận dụng tối đa cấu trúc đó.

Giờ đến phần thú vị: Argon2id.

Mình kể bạn nghe một câu chuyện. Cho đến gần đây, nếu bạn gõ hashcat -b -m 70300 (Argon2id), hashcat trả về error: module không tồn tại. Trong nhiều năm, mainline hashcat không hỗ trợ Argon2. Hơn 300 hash mode khác (MD5, SHA-1, NTLM, WPA-PSK, Bitcoin wallet, KeePass, cả scrypt), nhưng không có Argon2. Một số researcher tự fork ra implementation riêng, nhưng không bao giờ merge vào mainline.

Đến tháng 8 năm 2025 - sau gần 11 năm Argon2 thắng PHC - hashcat v7.0.0 release [8]. Trong changelog: "Argon2 is an increasingly popular 'GPU resistant' key derivation function". Cuối cùng cũng có Argon2 native, mode 34000. Theo release notes, performance trên RTX 4090 cho Argon2id m=64MiB t=3 p=1 là 1703 H/s.

Đây là phần mình tin là kill shot. Trên cùng RTX 3070 Ti, cùng hashcat -b:

Mode Throughput GPU Wall-clock/hash
bcrypt cost=5 (mode 3200) 63,513 H/s 90 ms
Argon2id m=64MiB t=3 p=1 (mode 34000) 411 H/s 55 ms
Argon2id reference bridge (mode 70000) 25 H/s -

Cùng một GPU. Cùng một benchmark tool. bcrypt cost=5 nhanh hơn Argon2id 155 lần. Và Argon2id ở cấu hình OWASP minimum đã đè tốc độ GPU xuống ngang ngửa với bcrypt cost=12 - config mà người ta coi là "đắt" trên CPU.

Nói cách khác: cùng một dòng GPU mà cracker đang dùng năm 2026, bcrypt cost=10 cho ra 2022 H/s, Argon2id m=64MiB t=3 cho ra 411 H/s. Argon2id chậm hơn bcrypt cost=10 5 lần trên GPU, dù wall-clock CPU gần giống nhau (46ms vs 50ms). Đó là 5 lần khoảng cách miễn phí mà bạn được khi đổi sang Argon2.

Vì sao chênh lệch lớn vậy? Memory-hard function làm cho GPU mất hết lợi thế. RTX 3070 Ti có 8 GiB VRAM. Argon2id \(m = 64\) MiB cần độc lập 64 MiB cho mỗi thread đang chạy. Trừ overhead, GPU đó chỉ chứa được khoảng 100 thread song song. So với 6144 CUDA cores đang ngồi rảnh, đó là 1.6% utilization.

Bcrypt: 8 GiB ÷ 4 KiB = 2 triệu thread song song lý thuyết. Argon2id 64 MiB: 100 thread. Tỷ lệ khoảng 20000 lần. Đó là khoảng cách giữa "GPU phá vỡ" và "GPU không buồn cố gắng".

Cùng một con RTX 3070 Ti với 8 GiB VRAM và 6144 CUDA cores. Với bcrypt 4 KiB/thread, chứa được 2 triệu thread song song. Với Argon2id 64 MiB/thread, chỉ chứa được khoảng 100 thread. Lợi thế parallelism của GPU bốc hơi với memory-hard hash.

Và đây là điểm mình nghĩ là tinh tế nhất: hashcat phải mất 11 năm mới chịu support Argon2 không phải vì khó implement. Có nhiều CUDA Argon2 impl đã chứng minh feasible từ 2018. Lý do là làm ra thì cũng không nhanh hơn CPU bao nhiêu, không bõ công bảo trì. Đến khi team hashcat tìm ra trick warp-shuffle instructions trên modern GPU (Ampere trở lên) thì mới có 4500% speedup so với reference impl - và lúc đó performance "đáng kể" cuối cùng là 411 H/s cho một consumer card tầm $500. Một CPU server hiện đại cũng làm được 96 H/s [8]. Speedup GPU/CPU cho Argon2id cuối cùng chỉ tầm 4-5 lần.

Đối chiếu với bcrypt 95x. Đó là khoảng cách của hai thế hệ password hash function.

Tóm tắt: bcrypt vs Argon2id trên RTX 3070 Ti

Thuộc tính bcrypt cost=12 Argon2id m=64MiB t=3
Wall-clock CPU (i7-12700F) 187 ms 50 ms
Memory mỗi hash 4 KiB 64 MiB
GPU throughput (RTX 3070 Ti) 507 H/s 411 H/s
Speedup GPU/CPU 95x 4-5x
Năm hashcat support native 2009 2025 (v7.0.0)
Parallel thread tối đa trên 8 GiB VRAM ~2 triệu ~100

Nhưng mà: không phải bcrypt vứt đi

Đến đây mình đã làm bcrypt nghe như thuốc độc. Cần một phút thẳng thắn để cân lại, vì bcrypt thực sự vẫn ổn trong nhiều ngữ cảnh.

Bcrypt không bao giờ thực sự "sụp". Vụ Ashley Madison 2015 [9] là ví dụ tiêu biểu hay bị hiểu sai. 11 triệu password bị crack trong 10 ngày, nghe như bcrypt thua. Thực ra Ashley Madison hash password bằng cả hai thuật toán song song: bcrypt cho login flow, và MD5 cho một loại token cũ mà legacy code dùng. CynoSure Prime tìm thấy cái MD5, crack qua đó. Bcrypt phần của hệ thống không bị xâm phạm. Nó im lặng, làm việc, đứng vững. So sánh thêm với những bài học password storage từ vụ Meta lưu plaintext - đó mới là sự cố do kiến trúc sai, không phải do thuật toán hash yếu.

72-byte limit của bcrypt không phải bug arbitrary. Nhiều người chỉ trích bcrypt cắt password ở 72 byte. Đây không phải bug, đó là hệ quả tự nhiên của key schedule Blowfish. Blowfish dùng 18-word P-array (72 byte). Provos và Mazières không "chọn" limit này, họ kế thừa nó từ Blowfish. Có thể workaround bằng cách pre-hash với SHA-256, nhưng đó là quyết định của application, không phải lỗi bcrypt.

Library bcrypt khắp nơi và ổn định. Trên Python, bcrypt đã được tinh chỉnh hơn 10 năm, gần như không có CVE đáng kể nào. Argon2-cffi cũng tốt, nhưng có ít người maintain hơn. Trên một số môi trường lạ (embedded, FIPS-required), bcrypt còn lựa chọn dễ hơn Argon2.

Cost factor 10-12 năm 2026 vẫn ổn với mục đích thông thường. Nếu bạn đang chạy một app cho 1000 user, một attacker bỏ vài ngàn USD thuê GPU để crack hash của họ là viễn cảnh không hợp lý. bcrypt cost=12 chống được casual attacker, đó là 90% kịch bản thật.

Cái dở của bcrypt không phải là "nó yếu". Cái dở là nó không trả lời được câu hỏi đúng. Câu hỏi đúng năm 2026 là: "khi attacker có hardware specialized, làm sao password hash của tôi vẫn đắt với họ?" Bcrypt trả lời câu cũ: "làm sao password hash thích nghi khi CPU nhanh hơn?" Hai câu khác nhau. Câu mới sinh ra sau khi GPU programming trở nên phổ thông năm 2010-2012, một thập kỷ sau khi bcrypt ra đời.

Mental model mới: work factor có hai trục

Cái mình rút ra sau buổi đào này là một cách suy nghĩ gọn hơn về password hashing.

Mọi hash function chậm có thể được tham số hoá theo hai trục:

  1. Trục thời gian: bao nhiêu CPU instruction phải chạy để hash xong
  2. Trục không gian: bao nhiêu memory phải động đến trong lúc hash

Bcrypt cho phép chỉnh trục đầu (cost factor). Trục thứ hai cố định 4 KiB. Argon2 cho phép chỉnh cả hai trục, độc lập.

Không gian tham số của password hash, hai trục: thời gian CPU và memory cost. bcrypt nằm trên một đường nằm ngang ở y=4 KiB - chỉ trượt được dọc trục thời gian. Argon2id chiếm một vùng hình chữ nhật trải rộng trên cả hai trục. User experience chỉ thấy trục thời gian. Attacker phải trả giá cho cả hai.

Khi bạn pick một cấu hình password hash, bạn đang chọn một điểm trên mặt phẳng (thời gian, không gian). User experience phụ thuộc vào trục thời gian. Attacker cost phụ thuộc vào cả hai. Một thuật toán chỉ tham số hoá một trục thì có một vùng cấu hình rất rộng mà user trả giá nhưng attacker không trả giá.

Đó là vì sao OWASP, IETF, và mọi nhà nghiên cứu password đều đồng ý: Argon2id default cho project mới. Không phải vì bcrypt "yếu". Vì bcrypt thiếu một trục.

FAQ: Câu hỏi thường gặp về bcrypt vs Argon2id

Hệ thống mình đang chạy bcrypt cost=10 được 5 năm rồi, có cần migrate không?

Câu trả lời ngắn của mình: phụ thuộc vào threat model, không phải vào "Argon2 mới hơn". Nếu app của bạn không phải target cao giá (không lưu credit card, không phải fintech, không phải user database lớn), bcrypt cost=10 vẫn chống được attacker thông thường. Casual attacker thuê một con RTX 3070 Ti trên vast.ai khoảng 0.2 USD/giờ, crack 2022 H/s. Với 10 triệu user, kể cả khi 1% dùng password yếu trong top-10000 wordlist, attacker mất tầm 13 giờ để bẻ được 100 nghìn account. Đó là viễn cảnh thực, không phải lý thuyết.

Nhưng migrate không phải bật công tắc. Bạn không có plaintext password, nên không hash lại được hàng loạt. Cách thường làm là dual-hash: khi user login lần kế, verify bằng bcrypt cũ, nếu đúng thì hash lại bằng Argon2id và lưu đè. Cần code path support cả hai algorithm trong thời gian transition (thường 6 tháng đến 2 năm tuỳ traffic). Test cẩn thận. Đừng làm vì hype.

Argon2id 64 MiB mỗi hash, vậy login flow có dễ bị DDoS không?

Đây là câu hỏi mình rất thích vì nó là trade-off thật, không phải FUD. Có, Argon2id tăng memory amplification attack surface. Một con server 16 GiB RAM về lý thuyết handle được khoảng 250 concurrent Argon2id m=64MiB t=3 hash. Bcrypt cost=12 cùng server đó handle được hàng triệu concurrent ở cost RAM (cost CPU mới là bottleneck).

Hai cách giải. Một là chọn config nhẹ hơn cho login path: OWASP minimum m=19 MiB t=2 p=1 vẫn an toàn hơn bcrypt cost=12 trên GPU resistance, mà chỉ tốn 19 MiB. Hai là đặt rate limiting nghiêm ở edge (Cloudflare, nginx limit_req) trước khi request chạm vào hash function. Cả hai cách đều standard. Chưa thấy ai gặp sự cố DDoS thật vì chuyển sang Argon2id, miễn là có rate limiting cơ bản.

Tại sao Argon2id chứ không phải Argon2d hay Argon2i?

Argon2d truy cập memory phụ thuộc password value. Nhanh hơn, GPU-resistant tốt hơn, nhưng để lại side-channel: attacker quan sát cache pattern có thể leak thông tin về password. Phù hợp cho cryptocurrency mining (không có user thật), không phù hợp cho password.

Argon2i truy cập memory độc lập password. Không side-channel, nhưng có tradeoff về memory-hardness. Joël Alwen và Jeremiah Blocki năm 2016 chứng minh có practical attack vào Argon2i làm giảm memory cost đáng kể khi number of passes thấp [10]. RFC 9106 phải khuyến nghị \(t \geq 3\) cho Argon2i để chống attack này, trong khi Argon2id chỉ cần \(t \geq 1\).

Argon2id chạy nửa đầu kiểu i, nửa sau kiểu d. Lấy ưu điểm cả hai. Đây là default RFC 9106 khuyến nghị, và là cái bạn nên dùng cho password. Argon2d và Argon2i chỉ để cho ngữ cảnh chuyên biệt.

scrypt thì sao, nó cũng memory-hard mà?

Đúng, scrypt là cái đầu tiên introduce khái niệm memory-hard, và nó vẫn an toàn nếu config tốt. Vấn đề thực dụng của scrypt là parameter tuning khó. Nó có \(N\) (memory factor), \(r\) (block size), \(p\) (parallelism), nhưng tương tác giữa ba tham số này không trực giác. Tăng \(N\) thì memory và time đều tăng theo \(N\), không tách rời được như Argon2. Hệ quả: nếu bạn muốn memory cao mà thời gian thấp, scrypt không cho bạn lựa chọn đó.

scrypt cũng yếu hơn về time-memory tradeoff resistance. Argon2 ra đời 6 năm sau scrypt, học từ analysis của scrypt và đóng được lỗ hổng đó. Nếu dùng scrypt đã ổn định thì giữ, không cần đổi. Nếu chọn mới thì Argon2id.

Django/Spring/Express có support Argon2id built-in chưa?

Django: từ 1.10 (2016) đã support qua django[argon2] extra. Add 'django.contrib.auth.hashers.Argon2PasswordHasher' vào đầu PASSWORD_HASHERS list, Django auto-migrate khi user login lần kế. Cấu hình mặc định của Django dùng params hợp lý sẵn. Nếu bạn đang tối ưu Django backend, xem thêm cách tránh lỗi N+1 query - vì auth flow thường chạy chung với ORM lookup.

Spring Security: từ 5.3 (2020) có Argon2PasswordEncoder. Default params là RFC recommendation SECOND (\(m=64\) MiB).

Express/Node.js: dùng package argon2 (node-argon2), maintained bởi Ranisalt. API gọn, params hợp lý mặc định.

Một số ORM/framework cũ hơn (ví dụ Flask-Bcrypt) không có Argon2 native, phải dùng argon2-cffi (Python) hoặc argon2-browser (JS) riêng. Code thêm 5-10 dòng, không phải rào cản.

Bcrypt cắt password ở 72 byte, có thể bypass bằng pre-hash không?

Có, và đây là pattern phổ biến: pre-hash password bằng SHA-512 trước rồi mới feed vào bcrypt. Dropbox dùng cách này từ 2016 [11]: AES256(bcrypt(SHA512(password), cost=10), pepper). SHA-512 cho output 64 byte, vẫn nằm trong 72-byte limit của bcrypt, và vô hiệu hoá khả năng attacker dùng password siêu dài để làm DoS bcrypt (bcrypt key schedule chạy lâu hơn khi input dài).

Có một gotcha. Nếu feed binary hash vào bcrypt, có thể gặp null byte trong middle, một số implementation bcrypt cũ (đặc biệt PHP trước 7.3) sẽ truncate ở null byte. Defensive practice: encode base64 hoặc hex sau khi SHA, trước khi đẩy vào bcrypt. Dropbox cũng dùng base64 cho lý do này.

Nếu đang dùng Python bcrypt library hoặc bcrypt-js modern, các implementation này đã fix null byte issue, nhưng pre-hash vẫn là defensive practice tốt. Lưu ý: pre-hash không sửa được vấn đề bcrypt thiếu trục memory. Đó là patch khác, không phải migration.

Resolution

Bài này không phải lời kêu gọi migrate ngay. Migrate password hash của hệ thống production là một việc tế nhị. Bạn phải hash lại khi user login lần kế (vì plaintext không lưu), phải xử lý fallback verify cho cả hai algo trong thời gian transition, phải test cẩn thận. Nhiều hệ thống ổn định đã dùng bcrypt cost=10 trong 5-7 năm và chưa từng có sự cố. Migrate cho có là tạo rủi ro mới không cần thiết.

Cái mình muốn để lại sau buổi đào này gọn hơn: nếu bạn đang quyết định password hash cho một project mới năm 2026, đừng pick bcrypt vì "đó là default trong Django" hay "đó là cái team mình quen". Pick Argon2id vì bạn hiểu vì sao memory-hard quan trọng. Đừng dùng cost=10 vì OWASP bảo cost min 10. Chọn vì bạn biết RTX 3070 Ti bẻ bcrypt cost=10 với tốc độ 2022 H/s, trong khi cùng GPU đó chỉ bẻ Argon2id m=64MiB ở 411 H/s. Năm lần khoảng cách miễn phí. Trên cùng wall-clock 50ms cho user.

Câu hỏi cuối cùng mình để lại cho bạn: lần kế bạn review một PR có password hashing, bạn sẽ hỏi gì? "Cost factor bao nhiêu?" hay "Memory cost bao nhiêu?" Hai câu hỏi không giống nhau, và sau bài này, bạn biết chính xác sự khác nhau.

Reproducibility note: toàn bộ code thực nghiệm và baseline results đã commit ở repo public github.com/maycuatroi1/bcrypt-vs-argon2-benchmark. Clone về, chạy bash run-native.sh (hoặc docker compose run --rm benchmark) để reproduce CPU benchmark trên máy bạn. GPU benchmark cần WSL2 với CUDA-on-WSL hoặc Linux native, hashcat 7.0+, và một GPU NVIDIA. Code MIT license, free for everything.

Nếu bạn có gì muốn cãi lại, hoặc nghĩ mình đang nhầm chỗ nào về interpretation các con số trên, mình muốn nghe.

Bình

References

  1. Password Storage Cheat Sheet. OWASP Cheat Sheet Series. Recommend Argon2id min m=19MiB t=2 p=1, hoặc các config khác cùng độ an toàn. bcrypt được liệt kê là legacy, work factor min 10, max 72-byte input
  2. Niels Provos, David Mazières. A Future-Adaptable Password Scheme. USENIX Annual Technical Conference, FREENIX Track. 1999. Bài paper giới thiệu bcrypt, đề xuất EksBlowfish (block cipher với key schedule cố ý đắt) để password hash thích nghi theo thời gian
  3. Colin Percival. Stronger Key Derivation via Sequential Memory-Hard Functions. BSDCan 2009. 2009-05. Định nghĩa formal đầu tiên về memory-hard function, foundation cho cả Argon2
  4. Password Hashing Competition. password-hashing.net. 2013-2015. Cuộc thi mở 2013-2015, Argon2 thắng tháng 7/2015. Finalists được commend: Catena, Lyra2, Makwa, yescrypt
  5. Alex Biryukov, Daniel Dinu, Dmitry Khovratovich. Argon2: the memory-hard function for password hashing and other applications. University of Luxembourg. 2015. Spec gốc của Argon2 nộp cho PHC, có phân tích GPU/ASIC resistance
  6. Argon2. Wikipedia. Tổng quan ba variants Argon2d/i/id và lịch sử PHC win 2015
  7. A. Biryukov, D. Dinu, D. Khovratovich, S. Josefsson. RFC 9106: Argon2 Memory-Hard Function for Password Hashing and Proof-of-Work Applications. IETF. 2021-09. Spec chính thức của Argon2, recommend Argon2id, params recommend t=1 m=2GiB p=4 (FIRST) hoặc t=3 m=64MiB p=4 (SECOND)
  8. Hashcat 7.0.0 release notes. hashcat (GitHub). 2025-08. Release notes v7.0.0. Argon2 added qua warp-shuffle GPU implementation, 4500% speedup vs reference. Official benchmark Argon2id m=64MiB t=3 p=1: RTX 4090 1703 H/s, RX 7900 XTX 1367 H/s, i7-14700K 96 H/s, Ryzen 9 9900X 92 H/s
  9. Ashley Madison data breach. Wikipedia. 2015-07. Vụ leak 2015. Ashley Madison dùng bcrypt cho live site nhưng song song hash MD5 cho token, chính cái MD5 này bị crack 11M passwords, KHÔNG phải bcrypt sụp
  10. Joël Alwen, Jeremiah Blocki. Towards Practical Attacks on Argon2i and Balloon Hashing. IACR Cryptology ePrint Archive. 2016. Chứng minh attack thực tế vào Argon2i giảm memory cost đáng kể khi số pass thấp. Lý do RFC 9106 khuyến nghị t≥3 cho Argon2i và default sang Argon2id
  11. Devdatta Akhawe. How Dropbox securely stores your passwords. Dropbox Tech Blog. 2016-09. Dropbox sử dụng SHA512 → bcrypt cost=10 → AES256 với global pepper. Pattern pre-hash bằng SHA giải quyết 72-byte limit và chống DoS qua long password