Có những lời khuyên đúng vào một thời điểm rồi hoá tín điều, được truyền lại rất lâu sau khi cái nền của nó biến mất — như câu "phải hâm sữa bình cho em bé" vốn xuất phát từ thời sữa chưa tiệt trùng, giờ chỉ còn là thói quen. Trong Linux, "đừng dùng /dev/random, nó sẽ chặn" là một tín điều đúng loại đó: nó từng đúng, và đã sai từ lâu. Bài này đo lại nó bằng con số.
(Các con số là thời gian và thông lượng đo trên VM, không phải perf counter phần cứng.)
Cái bẫy entropy không còn tồn tại
Đọc 100 MB từ mỗi thiết bị, nhân 6.12:
| Nguồn | Thời gian | Thông lượng |
|---|---|---|
/dev/random |
138 ms | 782 MB/s |
/dev/urandom |
137 ms | 771 MB/s |
entropy_avail trước: 256
entropy_avail sau : 256
Không chặn, không giảm entropy, và hai thiết bị cho cùng một tốc độ. Trên nhân trước 5.6, cùng lệnh đó có thể treo hàng giờ trên một máy chủ không có bàn phím và không có chuột — đó là hoàn cảnh sinh ra haveged, rng-tools, và hàng loạt bài viết khuyên theo dõi entropy_avail. Nhưng từ nhân 5.6, /dev/random chỉ chặn đúng một lần — lúc khởi động, trước khi bộ sinh số được gieo mầm; sau thời điểm đó nó không bao giờ chặn nữa, vì mô hình "entropy bị tiêu hao khi đọc" đã bị bỏ.
Lý do bỏ nó: một bộ sinh số giả ngẫu nhiên an toàn về mật mã, đã được gieo mầm bằng 256 bit thật, không yếu đi khi bạn đọc thêm. Việc "trừ entropy" mỗi lần đọc là mô hình sai từ đầu, và nó gây ra nhiều sự cố hơn là bảo vệ được gì. Ba hệ quả cho hôm nay: entropy_avail là 256 và luôn là 256, nên cảnh báo dựa trên nó không bao giờ kêu (mà nếu kêu thì đó là nhân rất cũ); haveged và rng-tools không còn cần thiết trên nhân hiện đại, chỉ còn ích ở đúng một chỗ là rút ngắn thời gian gieo mầm lúc khởi động trên máy ảo mới tạo; và chọn /dev/random hay /dev/urandom không còn là quyết định về hiệu năng. Cách kiểm nhân của bạn ở phía nào của mốc này:
uname -r
cat /proc/sys/kernel/random/poolsize # 256 = nhan moi; 4096 = nhan cu
Chi phí lấy 16 byte
Kích thước điển hình cho UUID, token phiên, muối băm:
| Nguồn | µs mỗi lần | An toàn mật mã |
|---|---|---|
rand() của libc |
0,02 | không |
getrandom(0) |
0,20 | có |
/dev/random |
0,21 | có |
/dev/urandom, giữ fd mở |
0,23 | có |
/dev/urandom, mở lại mỗi lần |
0,63 | có |
Ba nguồn an toàn chênh nhau chưa tới 15% — chọn cái nào không quan trọng về tốc độ. Điều quan trọng là dòng cuối: mở lại tệp mỗi lần tốn 2,7 lần, và toàn bộ phần chênh là open cộng close; với một dịch vụ sinh một token mỗi yêu cầu, đó là 0,4 µs vứt đi trên mỗi yêu cầu. getrandom() giải quyết chuyện này gọn nhất — nó là lời gọi hệ thống, không cần mô tả tệp nào:
#include <sys/random.h>
char buf[16];
getrandom(buf, sizeof buf, 0);
Nó cũng miễn nhiễm với hai vấn đề khác của /dev/urandom: hết mô tả tệp (phần 33) và chroot không có /dev.
rand() nhanh gấp mười lần và không dùng được
0,02 µs so với 0,20 µs. Cám dỗ rõ ràng, và câu trả lời cũng rõ ràng: không bao giờ dùng rand() cho bất cứ thứ gì liên quan tới bảo mật. rand() của glibc là một bộ sinh số tuyến tính — biết vài giá trị đầu ra là suy được toàn bộ dãy, cả quá khứ lẫn tương lai, nên token phiên, mã đặt lại mật khẩu, khoá API sinh bằng rand() đều đoán được. Nó dùng được cho: mô phỏng, sinh dữ liệu thử, chọn ngẫu nhiên trong thuật toán, jitter cho việc thử lại — những chỗ đó thì 0,02 µs là món hời. Một lưu ý: rand() không an toàn với đa luồng ở nhiều cài đặt; dùng rand_r() hoặc random_r() khi có nhiều luồng, nếu không bạn sẽ có hai luồng nhận cùng một dãy.
Với khối lớn thì mọi thứ bằng nhau
Lấy 4.096 byte mỗi lần:
| Nguồn | µs mỗi lần | MB/s |
|---|---|---|
getrandom(0) |
4,96 | 787,5 |
/dev/random |
5,01 | 779,8 |
/dev/urandom |
5,02 | 778,2 |
rand() |
5,27 | 741,2 |
Cả năm hội tụ về khoảng 780 MB/s, và rand() chậm nhất. Kết quả này ngược hẳn với bảng 16 byte, và lý do đơn giản: ở 16 byte, chi phí là mỗi lời gọi; ở 4 KB, chi phí là mỗi byte — bộ sinh số của nhân dùng ChaCha20 và sinh cả khối một lượt, trong khi rand() phải chạy vòng lặp bốn byte một. Nghĩa là bộ sinh số an toàn của nhân không còn chậm hơn bộ sinh số thường khi lấy lượng lớn; lập luận "dùng bộ sinh số yếu cho nhanh" không còn cơ sở đo lường nào.
Chọn đúng hàm trong từng ngôn ngữ:
| Ngôn ngữ | An toàn | Không an toàn |
|---|---|---|
| C | getrandom() |
rand() |
| Python | secrets, os.urandom |
random |
| Go | crypto/rand |
math/rand |
| Java | SecureRandom |
Random |
| Node | crypto.randomBytes |
Math.random |
| Rust | rand::rngs::OsRng |
rand::thread_rng (tuỳ mục đích) |
Một bẫy riêng của Java: SecureRandom.getInstance("NativeRandom") trên nhân cũ đọc /dev/random và chặn — đây là nguồn của rất nhiều báo cáo "ứng dụng Java treo khi khởi động trong container". Cách chữa cũ là -Djava.security.egd=file:/dev/./urandom; trên nhân từ 5.6 thì vấn đề tự biến mất, nhưng tham số đó vẫn còn trong vô số tệp cấu hình.
Mẫu số chung
Bảng trên đã liệt kê hàm đúng cho từng ngôn ngữ; điều đáng khắc sâu hơn là vì sao cái chia đúng-sai đó ở đâu cũng giống nhau — và vì sao lời khuyên "chặn/tốn entropy" lại sống dai đến thế.
Trong thế giới Java, SecureRandom với Random là đúng cùng lằn ranh: Random là một LCG 48-bit, đoán được từ một mẫu đầu ra y như rand() của C; ai từng sinh token bằng new Random() rồi bị đoán phiên đều học lại rằng "trông ngẫu nhiên" không phải "không đoán được". Và cái tín điều egd=/dev/./urandom vẫn nằm trong JAVA_OPTS khắp nơi là ví dụ sống của một lời khuyên đã hết hạn mà không ai gỡ.
Trong Go, thư viện chuẩn tách hẳn hai gói tên gần giống nhau — crypto/rand và math/rand — chính để bạn phải chọn có ý thức; dùng nhầm math/rand cho token là lỗi bảo mật kinh điển, và Go 1.20 còn tự gieo mầm math/rand để ít nhất nó không lặp dãy, nhưng vẫn không làm nó an toàn.
Còn ở cơ sở dữ liệu, cùng cái bẫy: hàm random() của SQL (Postgres, MySQL) không an toàn mật mã — dùng nó sinh token, mã OTP hay khoá là đoán được; muốn ngẫu nhiên an toàn trong DB phải gọi gen_random_uuid()/gen_random_bytes() của pgcrypto, đúng như phải chọn getrandom thay rand.
Mẫu số chung: mọi nền tảng đều có hai họ RNG trông gần giống nhau — một 'nhanh, đoán được, cho mô phỏng' và một 'an toàn mật mã, cho bí mật' — và cái an toàn không còn chậm đáng kể (một CSPRNG đã gieo mầm không yếu đi khi đọc thêm, và sinh khối lớn còn nhanh hơn RNG yếu), nên quy tắc gọn là: hễ giá trị mà kẻ khác đoán được sẽ gây hại thì dùng họ an toàn, khỏi cân nhắc tốc độ. Vì thế câu hỏi đúng khi sinh một số ngẫu nhiên không phải "cái nào nhanh hơn" mà là "nếu ai đó đoán được giá trị này thì có hại không" — nếu có, chọn họ an toàn và bỏ qua mọi lời khuyên về entropy còn sót trong tài liệu cũ.
Muốn tự kiểm nhân của mình có còn cái bẫy cũ không, trong ba mươi giây:
echo "nhan : $(uname -r)"
echo "poolsize : $(cat /proc/sys/kernel/random/poolsize) (256 = nhan moi, 4096 = cu)"
echo "avail : $(cat /proc/sys/kernel/random/entropy_avail)"
echo "--- doc 10 MB tu /dev/random ---"
time dd if=/dev/random of=/dev/null bs=1M count=10 2>&1 | tail -1
echo "avail sau: $(cat /proc/sys/kernel/random/entropy_avail)"
Nếu lệnh dd xong trong vài chục mili giây và entropy_avail không đổi, mọi lời khuyên về entropy trong tài liệu vận hành của bạn đều có thể xoá đi. Nếu nó treo, bạn đang chạy nhân trước 5.6 — và lúc đó vấn đề thật không phải entropy mà là phiên bản nhân.
Phần sau: tín hiệu và dừng êm — đo cách tiến trình nên phản ứng.