Seri này đi từ những viên gạch nhỏ nhất của mật mã học tới những thứ bạn dùng hằng ngày mà ít khi nghĩ tới: mật khẩu được lưu ra sao, ổ khóa trên thanh địa chỉ nghĩa là gì, JWT là gì, "Đăng nhập bằng Google" chạy thế nào, vì sao git push không hỏi mật khẩu. Ví dụ thật lấy từ chính trang này: viet.sarl băm mật khẩu bằng Argon2id, ký phiên đăng nhập bằng JWT, đăng nhập Google bằng OAuth có PKCE, chạy HTTPS qua Caddy và Cloudflare, và được deploy qua SSH.
Bài đầu tiên dựng nền: bảo mật bảo vệ cái gì, ba công cụ cơ bản và việc của từng cái, vì sao an toàn nằm ở khóa chứ không ở việc giấu thuật toán, và thứ mà mọi công cụ khác đều đứng lên trên: số ngẫu nhiên.
Lộ trình: bộ ba CIA, băm, mã hóa và chữ ký, nguyên lý Kerckhoffs, XOR và mã hóa hoàn hảo, số ngẫu nhiên (kèm thí nghiệm đoán trước random), rồi bản đồ cả seri. Thí nghiệm chạy bằng Python 3.12 trên máy thật, mỗi cái vài giây.
Bảo mật bảo vệ cái gì
Câu trả lời kinh điển có ba chữ, gọi là bộ ba CIA:
| Nghĩa là | Ví dụ khi mất | |
|---|---|---|
| Confidentiality (bí mật) | Chỉ người được phép mới đọc được | Lộ email, mật khẩu, tin nhắn |
| Integrity (toàn vẹn) | Dữ liệu không bị sửa mà không ai biết | Kẻ đứng giữa đổi số tài khoản nhận tiền |
| Availability (sẵn sàng) | Hệ thống vẫn phục vụ được | Trang sập vì bị tấn công từ chối dịch vụ |
Thêm hai câu hỏi hay đi kèm: xác thực (authentication, bạn là ai?) và phân quyền (authorization, bạn được làm gì?). Mật mã học lo phần lớn bí mật, toàn vẹn và xác thực. Phần sẵn sàng thuộc về hạ tầng, ví dụ Cloudflare đứng trước trang này.
Một thói quen quan trọng hơn mọi thuật toán: trước khi chọn công cụ, hỏi đang chống ai. Một người dùng chung Wi-Fi quán cà phê, một người có bản sao database, một nhân viên có quyền admin, hay một nhà nước. Mỗi câu trả lời cần một lớp khác nhau. Bài cuối seri làm đúng việc đó cho chính trang này.
Ba công cụ: băm, mã hóa, chữ ký
Hầu hết mọi thứ trong seri được ghép từ ba công cụ:
| Công cụ | Đầu vào → đầu ra | Đảo ngược được? | Cần khóa? | Dùng để |
|---|---|---|---|---|
| Hàm băm | dữ liệu bất kỳ → "dấu vân tay" cố định (ví dụ 32 byte) | Không | Không (HMAC thì có) | Kiểm tra toàn vẹn, lưu mật khẩu, định danh nội dung |
| Mã hóa | bản rõ + khóa → bản mã | Có, nếu có khóa | Có | Giữ bí mật |
| Chữ ký số | dữ liệu + khóa bí mật → chữ ký; ai cũng kiểm được bằng khóa công khai | Không cần | Cặp khóa | Chứng minh ai viết, và không bị sửa |
Mã hóa chia làm hai họ:
- Đối xứng: cùng một khóa để khóa và mở (AES, ChaCha20). Rất nhanh, nhưng hai bên phải có chung khóa từ trước.
- Bất đối xứng (khóa công khai): một cặp khóa. Khóa công khai đưa cho cả thế giới, khóa bí mật giữ riêng (RSA, đường cong elliptic). Chậm hơn nhiều, nhưng giải quyết được bài toán "làm sao có chung khóa với một máy chủ mình chưa gặp bao giờ".
Trong thực tế, hai họ luôn đi cùng nhau: dùng bất đối xứng để thống nhất một khóa và xác minh danh tính, rồi dùng đối xứng để mã hóa dữ liệu thật. TLS, SSH, Signal đều làm vậy. Bài 5 sẽ mổ xẻ chính xác cách HTTPS ghép chúng lại.
Một điểm hay bị nhầm: mã hóa không đảm bảo toàn vẹn. Một bản mã có thể bị sửa sao cho khi giải mã ra một bản rõ khác mà người nhận không biết. Vì vậy các thuật toán hiện đại luôn ghép mã hóa với xác thực (AEAD).
Nguyên lý Kerckhoffs: giấu khóa, không giấu thuật toán
Năm 1883, Auguste Kerckhoffs viết: một hệ thống mật mã phải an toàn kể cả khi kẻ địch biết mọi thứ về nó, trừ khóa. Lý do rất thực tế:
- Thuật toán nằm trong phần mềm, phần cứng, tài liệu. Sớm muộn gì nó cũng lộ (dịch ngược, nhân viên nghỉ việc, mã nguồn rò rỉ). Khóa thì đổi được trong vài phút, thuật toán thì không.
- Thuật toán công khai được hàng nghìn chuyên gia soi trong nhiều năm. AES thắng một cuộc thi công khai kéo dài 3 năm. Thuật toán bí mật chỉ được soi bởi người viết ra nó.
Hệ quả cho người viết phần mềm: đừng tự chế thuật toán mật mã, và cả đừng tự ghép các khối mật mã theo cách mới. Phần lớn lỗ hổng thực tế không đến từ việc AES bị phá, mà từ cách ghép: dùng lại nonce, so sánh chữ ký bằng ==, tự nối khóa với dữ liệu rồi băm. Seri này sẽ tự tay làm hỏng vài thứ như thế để thấy vì sao. Dùng thư viện đã được kiểm chứng (cryptography, pwdlib, pyjwt trong Python) với tham số mặc định của nó.
XOR và mã hóa hoàn hảo
Phép toán cơ bản nhất của mật mã là XOR (⊕): hai bit giống nhau ra 0, khác nhau ra 1. Nó có một tính chất kỳ diệu: (p ⊕ k) ⊕ k = p. XOR với khóa để mã hóa, XOR lần nữa với cùng khóa để giải mã.
Nếu khóa ngẫu nhiên thật sự, dài bằng thông điệp, và chỉ dùng một lần, ta có one-time pad, cách mã hóa duy nhất được chứng minh là không thể phá, kể cả với máy tính mạnh vô hạn. Lý do: với một bản mã cho trước, mọi bản rõ cùng độ dài đều có khả năng như nhau, vì luôn có một khóa biến bản mã đó thành bản rõ ấy.
Nhưng ba điều kiện đó rất khó giữ. Khóa phải dài bằng dữ liệu (mã hóa 1 GB cần 1 GB khóa, và phải trao trước bằng cách nào đó). Và nếu lỡ dùng lại khóa một lần thôi:
k = os.urandom(len(p1)) # khóa ngẫu nhiên thật
c1 = xor(p1, k) # "chuyen 500 trieu cho An luc 9h"
c2 = xor(p2, k) # cùng khóa, thông điệp khác
xor(c1, c2) == xor(p1, p2) # True: khóa triệt tiêu mất
xor(xor(c1, c2), p1) # b'hop luc 10h tai phong so 3, ok'
Kẻ nghe lén chỉ có hai bản mã, nhưng c1 ⊕ c2 bằng đúng p1 ⊕ p2: khóa biến mất. Chỉ cần đoán được một thông điệp (thường dễ: đầu email, header HTTP, một mẫu biểu cố định) là đọc được thông điệp kia. Cơ quan tình báo Liên Xô dùng lại một phần khóa one-time pad trong những năm 1940, và dự án Venona của Mỹ đã đọc được hàng nghìn bức điện nhờ đúng lỗi đó.
Các thuật toán hiện đại như AES-CTR, ChaCha20 thực chất là "one-time pad với khóa sinh ra từ một khóa ngắn": từ khóa 32 byte và một số dùng một lần (nonce), chúng sinh ra một dòng byte dài tùy ý trông như ngẫu nhiên, rồi XOR với dữ liệu. Vì vậy chúng thừa hưởng đúng điểm yếu này: dùng lại nonce là lộ dữ liệu.
Số ngẫu nhiên: nền móng bị quên
Khóa, salt, nonce, token đặt lại mật khẩu, mã phiên, state của OAuth, verifier của PKCE: tất cả đều là số ngẫu nhiên. Nếu kẻ tấn công đoán được chúng, mọi thuật toán phía trên đều vô nghĩa, dù là AES-256.
Python có hai nguồn số ngẫu nhiên, và chúng khác nhau về bản chất:
random |
secrets / os.urandom |
|
|---|---|---|
| Thuật toán | Mersenne Twister (MT19937) | Bộ sinh của hệ điều hành (CSPRNG) |
| Thiết kế cho | Mô phỏng, game, xáo bài | Khóa, token, mật khẩu |
| Đoán được? | Có, sau khi thấy đủ số | Không, theo những gì ta biết |
Mersenne Twister có trạng thái trong là 624 số 32 bit. Mỗi số nó trả ra là một số trong trạng thái đó đi qua bước "làm rối" (tempering), mà bước này đảo ngược được. Vậy nếu nhìn thấy 624 số liên tiếp, ta khôi phục được toàn bộ trạng thái, rồi tính trước mọi số tiếp theo.
Thí nghiệm: một "máy chủ" tạo random.Random với hạt giống lấy từ os.urandom (không đoán được). Kẻ tấn công thấy 624 số mà máy chủ để lộ (ví dụ một API trả về ID ngẫu nhiên), đảo ngược bước tempering, dựng một bản sao, rồi so 10.000 số tiếp theo:
victim = random.Random(os.urandom(16)) # hạt giống không đoán được
seen = [victim.getrandbits(32) for _ in range(624)] # kẻ tấn công quan sát
state = [untemper(v) for v in seen] # đảo ngược tempering (~15 dòng)
clone = random.Random()
clone.setstate((3, tuple(state + [624]), None))
predicted next 10000 outputs: 10000
victim token d3b8e33596bf3516f782843061294435
clone token d3b8e33596bf3516f782843061294435 True
Đoán đúng 10.000 trên 10.000 số, và cả "token đặt lại mật khẩu" 128 bit mà máy chủ tạo sau đó. Hạt giống mạnh không cứu được gì: vấn đề nằm ở thuật toán, không ở hạt giống. Cả thí nghiệm mất chưa tới một giây.
secrets (bọc os.urandom) lấy số từ bộ sinh ngẫu nhiên an toàn của hệ điều hành. Nhân hệ điều hành gom độ hỗn loạn (entropy) từ phần cứng, ví dụ thời điểm ngắt, nhiễu của mạch, lệnh sinh số ngẫu nhiên trong CPU, rồi trộn qua một thuật toán mật mã. Biết bao nhiêu đầu ra cũng không suy ra được đầu ra tiếp theo. Trang này tạo state và verifier của OAuth bằng nó:
def new_state() -> tuple[str, str]:
"""(state, PKCE code verifier)."""
return secrets.token_urlsafe(24), secrets.token_urlsafe(48)
secrets.token_urlsafe(32) cho 32 byte ngẫu nhiên, mã hóa base64 thành 43 ký tự, tức 256 bit: đoán mò thì vũ trụ hết tuổi trước. Quy tắc đơn giản: thứ gì liên quan tới bảo mật thì dùng secrets, không bao giờ dùng random. Đây cũng là lỗi mà các công cụ kiểm tra như Bandit hay Ruff (luật S311) bắt được tự động.
Bản đồ seri
| Bài | Nội dung | Công cụ nền |
|---|---|---|
| 1 | Ba công cụ, số ngẫu nhiên (bài này) | |
| 2 | Hàm băm: SHA-256, va chạm, HMAC | Băm |
| 3 | Lưu mật khẩu: vì sao không dùng SHA-256, Argon2id | Băm chậm |
| 4 | Khóa công khai: RSA, Diffie-Hellman, đường cong elliptic, hậu lượng tử | Bất đối xứng |
| 5 | TLS/HTTPS và chứng chỉ | Cả ba |
| 6 | Mã hóa dữ liệu lúc lưu: ổ đĩa, database, từng cột | Đối xứng + quản lý khóa |
| 7 | OAuth 2.0, OpenID Connect, PKCE | Băm, chữ ký, TLS |
| 8 | SSH | Trao đổi khóa, chữ ký |
| 9 | Ghép lại: bảo mật của chính trang này | Tất cả |
Một quy ước xuyên suốt: các bài in ra khóa và token sinh riêng cho thí nghiệm. Không có secret, khóa hay địa chỉ máy chủ thật nào của trang này xuất hiện ở đây. Đó cũng là bài học đầu tiên về vận hành: secret không bao giờ đi vào git, log hay bài viết.
Tóm lại
- Bảo mật bảo vệ bí mật, toàn vẹn, sẵn sàng, cộng với xác thực và phân quyền. Luôn hỏi trước: đang chống ai.
- Ba công cụ: băm (một chiều, kiểm tra toàn vẹn), mã hóa (hai chiều, cần khóa; đối xứng nhanh, bất đối xứng giải bài toán trao khóa), chữ ký (chứng minh ai viết).
- Mã hóa một mình không chống được sửa dữ liệu. Thuật toán hiện đại ghép mã hóa với xác thực.
- Kerckhoffs: an toàn nằm ở khóa. Không tự chế crypto, không tự ghép theo cách mới.
- One-time pad hoàn hảo nhưng dùng lại khóa là lộ:
c1 ⊕ c2 = p1 ⊕ p2. Mã dòng hiện đại thừa hưởng đúng điểm yếu này ở nonce. randomđoán được hoàn toàn sau 624 số (đúng 10.000/10.000 trong thí nghiệm). Thứ gì liên quan tới bảo mật thì dùngsecrets.
Bài tiếp theo đi vào công cụ đầu tiên: hàm băm. Đổi 1 bit thì đổi bao nhiêu bit đầu ra, vì sao MD5 và SHA-1 bị loại, va chạm đến sớm hơn bạn nghĩ bao nhiêu, và một cuộc tấn công giả được "chữ ký" sha256(khóa + dữ liệu) mà không cần biết khóa.