👋 Chào mừng đến với Nocoem — kiến thức Backend, System Design và lập trình. Khám phá ngay

Hàm băm Bcrypt là gì?

Hàm băm bcrypt cho phép chúng ta xây dựng một nền tảng bảo mật mật khẩu có thể mở rộng theo sức mạnh tính toán và luôn băm mọi mật khẩu kèm theo salt.
Hàm băm Bcrypt là gì?
TL;DR: Bcrypt là hàm băm chuyên dùng để lưu mật khẩu, xây trên thuật toán Blowfish. Nó tự thêm salt và cho phép tăng dần chi phí tính toán (cost factor) qua thời gian, nên không sợ phần cứng ngày càng mạnh bắt kịp. Bài này giải thích vì sao các hàm băm thông thường (như crypt cũ của UNIX, hay MD5/SHA-256) không đủ an toàn cho mật khẩu, cơ chế 2 giai đoạn bên trong bcrypt, và cách dùng thư viện bcrypt trong Node.js.

Bạn đang băm mật khẩu người dùng bằng SHA-256 rồi lưu thẳng vào database? Nghe có vẻ an toàn, nhưng đó lại là một trong những sai lầm phổ biến nhất khi làm bảo mật ứng dụng. Vấn đề không nằm ở SHA-256 yếu, mà ở chỗ nó quá nhanh: nhanh tới mức kẻ tấn công có thể thử hàng tỷ mật khẩu mỗi giây chỉ bằng một chiếc GPU tầm trung. Bcrypt sinh ra để giải quyết đúng vấn đề đó, một hàm băm cố tình chậm, thiết kế riêng cho mật khẩu.

Bcrypt là gì?

Bcrypt được thiết kế bởi Niels Provos và David Mazières, dựa trên thuật toán Blowfish: b là viết tắt của Blowfish, còn crypt lấy từ tên hàm băm mật khẩu truyền thống của UNIX. Nói ngắn gọn, đây là một hàm băm mật khẩu (password hashing function) chuyên dụng, không phải hàm băm đa năng như MD5 hay SHA-256.

Vì sao cần một hàm băm mật khẩu chuyên dụng?

Hàm crypt gốc của UNIX là một ví dụ điển hình cho việc không thích nghi kịp với tốc độ phần cứng. Theo tài liệu của Provos & Mazières trên USENIX, vào năm 1976, crypt chỉ băm được chưa tới 4 mật khẩu mỗi giây, một tốc độ khiến nhóm phát triển UNIX khi đó khá yên tâm về độ an toàn. Nhưng 20 năm sau, một máy tính bình thường với phần mềm tối ưu đã có thể băm tới 200.000 mật khẩu mỗi giây bằng chính hàm đó.

Với tốc độ này, kẻ tấn công dễ dàng thực hiện tấn công từ điển (dictionary attack) ở quy mô lớn. Vậy nên bạn cần một thuật toán trở nên khó bị phá vỡ hơn theo cấp số nhân khi phần cứng ngày càng mạnh lên, chứ không phải một hằng số tốc độ cố định.

Đây là lúc một "nhược điểm" của Blowfish lại trở thành lợi thế. Mã hóa Blowfish vốn là thuật toán khối rất nhanh, ngoại trừ một khâu: thiết lập khóa (key schedule). Mỗi lần đổi khóa, Blowfish cần khối lượng xử lý tương đương mã hóa khoảng 4 KB văn bản, chậm hơn hẳn so với các thuật toán mã hóa khối khác. Bình thường đây là điểm trừ, nhưng với việc băm mật khẩu, việc thiết lập khóa chậm lại giúp làm chậm cả tấn công từ điển lẫn brute-force.

Bcrypt tận dụng đúng điểm đó: kết hợp giai đoạn thiết lập khóa đắt đỏ của Blowfish với một số lần lặp có thể điều chỉnh, để tăng dần khối lượng tính toán của hàm băm. Lợi ích lớn nhất nằm ở chỗ bạn có thể tăng số lần lặp theo thời gian, khiến bcrypt chậm hơn khi cần, nên luôn mở rộng được theo sức mạnh phần cứng hiện tại thay vì bị bỏ lại phía sau.

bcrypt được thiết kế để băm mật khẩu, nên nó là một thuật toán chậm một cách có chủ đích. Điều này tốt cho việc lưu mật khẩu, vì nó làm giảm số lượng mật khẩu mà kẻ tấn công có thể thử mỗi giây khi thực hiện tấn công từ điển.

Một lợi ích khác của bcrypt: nó yêu cầu một giá trị muối (salt) theo mặc định, bạn không cần tự nhớ thêm bước đó. Giờ hãy xem cơ chế bên trong hoạt động ra sao.

Bcrypt hoạt động như thế nào?

Provos và Mazières dùng chính giai đoạn thiết lập khóa đắt đỏ của Blowfish để xây một thuật toán thiết lập khóa mới, gọi là eksblowfish (viết tắt của expensive key schedule Blowfish). Bcrypt vận hành qua hai giai đoạn chính dựa trên eksblowfish.

Giai đoạn 1: Thiết lập khóa

Một hàm tên EksBlowfishSetup nhận vào chi phí mong muốn (cost), salt và mật khẩu để khởi tạo trạng thái của eksblowfish. Sau đó, bcrypt tiêu tốn rất nhiều thời gian để chạy một quá trình thiết lập khóa phức tạp, trong đó mật khẩu đóng vai trò khóa chính. Nếu người dùng chọn mật khẩu yếu hoặc ngắn, thuật toán sẽ kéo dài nó thành một khóa dài hơn: kỹ thuật này gọi là key stretching.

Giai đoạn 2: Mã hóa giá trị đặc biệt

Một giá trị cố định tên OrpheanBeholderScryDoubt (192-bit) được mã hóa 64 lần bằng eksblowfish, theo chế độ ECB, sử dụng trạng thái tạo ra từ giai đoạn 1. Kết quả đầu ra gồm chi phí (cost) và giá trị muối 128-bit, kết hợp với kết quả của vòng lặp mã hóa, tạo thành chuỗi băm cuối cùng.

Lưu ý về ECB: ECB ở đây chỉ là chi tiết nội bộ của thuật toán bcrypt (mã hoá lặp một hằng số cố định), không phải lời khuyên dùng chế độ ECB để mã hoá dữ liệu ứng dụng. Với dữ liệu thật, ECB bị coi là không an toàn (lộ mẫu lặp trong bản mã) và nên tránh; hãy dùng các chế độ như AES-GCM.
Sơ đồ 2 giai đoạn của bcrypt: thiết lập khóa và mã hóa giá trị đặc biệt

Sau khi chạy xong cả hai giai đoạn, giá trị băm cuối cùng sẽ có tiền tố $2a$, $2y$ hoặc $2b$ để đánh dấu phiên bản bcrypt đang dùng.

Nếu muốn hình dung dễ hơn: bcrypt giống một cánh cửa két sắt có cơ chế hẹn giờ (time-lock) cố tình chậm. Kẻ trộm có đúng mã số cũng phải chờ cơ chế xoay xong mới mở được, mỗi lần thử tốn một khoảng thời gian cố định, bất kể máy móc của họ mạnh cỡ nào. Bạn có thể vặn cơ chế đó chậm thêm (tăng cost) bất cứ lúc nào phần cứng bên ngoài mạnh lên.

Tính năng bảo mật của bcrypt

Bcrypt đảm bảo các thuộc tính quan trọng của một hàm băm mật khẩu:

  • Chống tìm ảnh gốc (preimage resistant): từ chuỗi băm, gần như không thể suy ngược lại mật khẩu gốc.
  • Không gian salt đủ lớn: giúp ngăn chặn tấn công bảng cầu vồng (rainbow table attack), vì kẻ tấn công không thể tính sẵn bảng tra cứu cho mọi salt có thể.
  • Chi phí tính toán có thể điều chỉnh (adaptable cost): cho phép mở rộng độ khó theo sức mạnh phần cứng hiện tại.

Nhờ thiết kế đó, dù bcrypt ra đời từ năm 1999, nó vẫn được xem là lựa chọn an toàn cho tới hiện tại, miễn là bạn chọn cost factor phù hợp (xem mục thực hành bên dưới).

bcrypt hay Argon2id? bcrypt vẫn an toàn nếu chọn cost hợp lý, nhưng với hệ thống mới, nhiều khuyến nghị hiện nay (gồm OWASP) ưu tiên Argon2id. Lý do: bcrypt chỉ đắt về CPU, còn Argon2id đắt cả về bộ nhớ (memory-hard), khiến bẻ khoá hàng loạt bằng GPU/ASIC tốn kém hơn nhiều. Nếu đang chạy bcrypt với cost đủ cao thì chưa cần vội đổi; nếu làm mới thì cân nhắc Argon2id (hoặc scrypt) ngay từ đầu.

Ví dụ: Dùng bcrypt trong Node.js

Cài đặt thư viện bcrypt:

npm install bcrypt

Tạo muối và băm mật khẩu

const bcrypt = require("bcrypt");
const saltRounds = 10;
const plainTextPassword = "DFGh5546*%^__90";

bcrypt.genSalt(saltRounds)
  .then(salt => bcrypt.hash(plainTextPassword, salt))
  .then(hash => console.log(`Hash: ${hash}`))
  .catch(err => console.error(err));
Hash: $2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy (chuỗi này khác nhau mỗi lần chạy vì salt sinh ngẫu nhiên)

Hoặc gọn hơn, để bcrypt.hash tự tạo salt cho bạn:

bcrypt.hash(plainTextPassword, saltRounds)
  .then(hash => console.log(`Hash: ${hash}`))
  .catch(err => console.error(err));

Xác thực mật khẩu

Giả sử storedHash dưới đây là chuỗi bcrypt đã lưu trong database từ bước băm ở trên:

const storedHash = "$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy";

bcrypt.compare(plainTextPassword, storedHash)
  .then(result => console.log(result))
  .catch(err => console.error(err));
true

bcrypt.compare tự tách cost và salt từ storedHash, băm lại plainTextPassword với đúng thông số đó rồi so sánh hai chuỗi. Nếu mật khẩu nhập vào sai, kết quả sẽ là false thay vì ném lỗi.

Đọc hiểu chuỗi hash bcrypt

Chuỗi bcrypt lưu trong database thực ra đã gói sẵn mọi thứ cần để xác thực về sau: phiên bản, cost, salt và hash. Tách thử ví dụ $2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy:

$2b$ 10 $ N9qo8uLOickgx2ZMRZoMye IjZAgcfl7p92ldGxad68LJZdL17lhWy
 |    |    |                      |
 |    |    |                      +-- hash (31 ký tự base64)
 |    |    +-- salt (22 ký tự base64)
 |    +-- cost factor (10)
 +-- phiên bản ($2a$ / $2y$ / $2b$)

Nhờ salt và cost nằm ngay trong chuỗi, khi xác thực bcrypt.compare đọc lại đúng thông số đó để băm mật khẩu nhập vào rồi so sánh. Đây cũng là lý do bạn chỉ cần lưu đúng một chuỗi này trong database, không phải lưu salt riêng ở cột khác.

Thực hành tốt nhất khi chọn chi phí tính toán

Một thách thức thật sự với các kỹ sư bảo mật là xác định chi phí tính toán (cost factor) hợp lý cho bcrypt. Theo OWASP Password Storage Cheat Sheet, bạn nên:

  • Nghiên cứu trải nghiệm người dùng (UX) để xác định thời gian chờ hợp lý khi đăng ký và đăng nhập.
  • Điều chỉnh cost factor để bcrypt chạy trong thời gian chấp nhận được trên đúng phần cứng thật của bạn.
  • Phân tích cùng đội bảo mật xem thời gian tính toán đó có đủ để giảm thiểu rủi ro tấn công hay không.

Người dùng thường chấp nhận được thời gian chờ 1-2 giây, nhưng với kẻ tấn công đang thử hàng loạt mật khẩu, cùng một mức chi phí đó lại gây cản trở rất lớn.

Để có cảm nhận định lượng, đây là thời gian băm một mật khẩu ở vài mức cost (đo thực tế trên một CPU Intel desktop; máy khác sẽ lệch, nên luôn tự đo trên phần cứng của bạn):

Cost factorThời gian mỗi lần băm
8~13 ms
10 (mức mặc định phổ biến)~52 ms
12~208 ms
14~846 ms

Điểm mấu chốt: cost tăng theo cấp số nhân, mỗi +1 gần như gấp đôi thời gian. Vì thế nhảy từ 10 lên 14 nặng gấp khoảng 16 lần chứ không phải "nhỉnh hơn một chút". Hãy chọn mức cao nhất mà vẫn giữ được thời gian băm trong ngưỡng chấp nhận được ở phía server (thường quanh 200-300 ms).

Cạm bẫy thường gặp:
  • Do sức mạnh phần cứng tiếp tục tăng theo thời gian (thường được ví với định luật Moore), bạn cần định kỳ tăng cost factor. Nhưng tăng đột ngột mà không có kế hoạch di chuyển (migration) có thể khiến việc xác thực chậm bất thường hoặc dồn tải lên server đúng lúc nhiều người cùng đăng nhập.
  • Đừng tự viết lại thuật toán bcrypt hay tự chế cơ chế salt riêng. Luôn dùng thư viện đã được kiểm chứng như bcrypt/bcryptjs cho Node.js, hoặc thư viện tương đương của ngôn ngữ bạn đang dùng.
  • Bcrypt giới hạn độ dài mật khẩu đầu vào ở 72 byte; phần vượt quá sẽ bị bỏ qua âm thầm. Nếu ứng dụng của bạn cho phép mật khẩu rất dài, cân nhắc băm trước bằng SHA-256 rồi base64 trước khi đưa qua bcrypt (base64 để tránh chuỗi có byte NUL làm bcrypt cắt cụt), hoặc chuyển sang thuật toán khác như Argon2. Chỉ dùng kỹ thuật pre-hash này khi thực sự cần vượt giới hạn 72 byte; đừng tự chế nhiều lớp hash tuỳ tiện (kiểu SHA256 rồi lại SHA256 rồi bcrypt), vì tự thiết kế cơ chế riêng thường tạo ra lỗ hổng chứ không tăng an toàn.
  • Cost factor càng cao, CPU tiêu tốn cho mỗi lần đăng nhập càng nhiều; đừng tăng tùy tiện mà không đo tải thực tế trên server, kẻo vô tình tạo ra một kiểu tấn công từ chối dịch vụ (DoS) do chính hệ thống của bạn gây ra.

Tóm tắt

  • Bcrypt là hàm băm mật khẩu chuyên dụng dựa trên Blowfish, tận dụng đúng điểm "chậm" của giai đoạn thiết lập khóa để chống lại brute-force và tấn công từ điển.
  • Bcrypt hoạt động qua hai giai đoạn: thiết lập khóa (eksblowfish) rồi mã hóa 64 lần một giá trị cố định theo chế độ ECB.
  • Salt được yêu cầu mặc định, và chi phí tính toán (cost factor) có thể tăng dần theo thời gian để bắt kịp phần cứng ngày càng mạnh.
  • Chọn cost factor cần cân bằng giữa trải nghiệm người dùng và khả năng cản trở kẻ tấn công; đổi cost factor cần có kế hoạch di chuyển, và nhớ giới hạn 72 byte của mật khẩu đầu vào.
  • Với hệ thống mới, cân nhắc Argon2id (đắt cả CPU lẫn bộ nhớ, chống GPU tốt hơn) thay cho bcrypt; bcrypt hiện có vẫn ổn nếu cost đủ cao.

Nguồn tham khảo

- Kai

Thấy bài viết hữu ích?

0 lượt thích

Đăng nhận xét