Hãy nghĩ về mã TOTP sáu số như một mật khẩu dùng một lần được viết lên tấm bảng trắng treo giữa phòng: ai đứng trong phòng, nhìn thấy nó, đều đọc và dùng được — nó không quan tâm người đọc là bạn hay kẻ trộm vừa lẻn vào. WebAuthn thì ngược lại, giống một cái chìa khoá chỉ vừa đúng một ổ khoá và từ chối mọi ổ khác, kể cả ổ giả làm y hệt. Cả bài này là chuyện đo cho ra sự khác nhau giữa "một con số ai cầm cũng xài được" và "một chữ ký chỉ đúng ở đúng một nơi".

Mật khẩu, dù băm mạnh và giới hạn tần suất, vẫn có thể bị đánh cắp — qua rò rỉ, qua phishing, qua dùng lại. Xác thực hai bước (2FA) thêm một yếu tố thứ hai: thứ bạn có bên cạnh thứ bạn biết. Tôi đo hai công nghệ 2FA phổ biến nhất — TOTP (mã sáu số trong Google Authenticator) và WebAuthn (khoá bảo mật, vân tay) — để thấy chúng bảo vệ được gì và, quan trọng hơn nhiều, cái gì thì không.

Toàn bộ chạy trong container tôi tự dựng, tự dọn. Tôi chỉ tấn công tiến trình của chính mình, không nhắm vào hệ thống của ai.

TOTP hoạt động thế nào

TOTP (Time-based One-Time Password) sinh một mã sáu số từ hai thứ: một bí mật được chia sẻ giữa server và ứng dụng của bạn lúc thiết lập, và thời gian hiện tại. Cả hai bên tính cùng một công thức nên ra cùng một mã, mà chẳng cần trao đổi gì qua mạng. Tôi dựng một TOTP thật và đo ba tính chất của nó.

Đo được gì

Ba lần chạy, kết quả giống hệt nhau:

Mã đổi mỗi 30 giây. Trong suốt một cửa sổ 30 giây, mã không đổi — bốn lần đọc liên tiếp cho cùng một số. Sang cửa sổ mới, mã đổi hoàn toàn.

Cửa sổ chấp nhận là ±31 giây:

Đồng hồ server lệch Kết quả
−60 giây từ chối
−31 đến +31 giây chấp nhận
+60 giây từ chối

TOTP thuần không tự chống phát lại:

Lần thử Kết quả
Dùng mã lần 1 hợp lệ, ghi nhận
Dùng lại cùng mã (trong 30 giây) hợp lệ — trừ khi server tự lưu mã đã dùng

Vì sao TOTP không cần mạng

Có một tính chất khiến TOTP hấp dẫn, mà cũng chính là gốc rễ điểm yếu của nó: nó hoạt động hoàn toàn ngoại tuyến. Lúc thiết lập, server và ứng dụng của bạn thống nhất một bí mật chung (mã QR bạn quét chính là bí mật đó, mã hoá base32). Sau đó, cả hai bên tính mã từ HMAC(bí mật, thời gian) một cách độc lập — điện thoại không cần mạng, server chẳng gửi gì cho bạn. Đó là lý do Google Authenticator chạy ngon lành trên máy bay.

Nhưng đúng vì mã được tính chỉ từ bí mật và thời gian, nó không chứa một mẩu thông tin nào về ngữ cảnh — không biết ai đang hỏi, cho trang nào, trong phiên nào. Nó là một con số đúng-cho-mọi-người-mọi-nơi trong 30 giây. Cái đơn giản đó vừa là sức mạnh (không cần hạ tầng gì) vừa là điểm yếu (chuyển tiếp được). WebAuthn đánh đổi theo hướng ngược lại: nó cần trao đổi qua mạng với server, nhưng bù lại gắn được chữ ký vào đúng ngữ cảnh.

Ba điều số đo nói thẳng vào mặt

Cửa sổ ±31 giây là một đánh đổi bắt buộc, không phải một lỗi. Đồng hồ của điện thoại và của server không bao giờ khớp tuyệt đối. Nếu server chỉ chấp nhận đúng mã của giây hiện tại, một người dùng có đồng hồ lệch 20 giây sẽ chẳng bao giờ đăng nhập nổi. Nên server chấp nhận cả mã của cửa sổ trước và sau (valid_window=1) — nới khoảng hợp lệ thành 90 giây thay vì 30. Cái giá đi kèm: nếu kẻ tấn công lấy được mã, nó có tối đa ~90 giây để dùng, chứ không phải 30. Cửa sổ càng rộng thì càng dễ dùng nhưng cũng càng cho kẻ tấn công nhiều thời gian.

TOTP không tự chống phát lại — và đây là lỗi cấu hình hay bị bỏ sót nhất. Trong 30–90 giây một mã còn hợp lệ, nó dùng được nhiều lần. Nếu kẻ tấn công chặn được một mã (qua phishing, qua malware), nó xài lại thoải mái trong cửa sổ đó. Cách chặn: server phải lưu lại mã (hoặc bước thời gian) vừa dùng thành công và từ chối nếu thấy nó quay lại. Thư viện TOTP thường không làm hộ bạn việc này — verify() chỉ kiểm mã có đúng theo thời gian không, chứ không kiểm đã dùng chưa. Tôi phải tự thêm một tập DA_DUNG để bịt.

Và điểm yếu lớn nhất của TOTP: nó không chống nổi phishing thời gian thực. Mã TOTP chỉ là sáu chữ số — nó không biết bạn đang ở trang nào. Kịch bản: nạn nhân bị dụ vào một trang phishing giống hệt ngân hàng, nhập cả mật khẩu lẫn mã TOTP. Trang phishing chuyển tiếp cả hai tới trang thật ngay lập tức — thừa sức trong 90 giây mã còn sống. Trang thật thấy mật khẩu đúng, mã đúng, và cho vào. TOTP không có cách nào chặn điều này, đơn giản vì nó không thể biết mã đang được gõ vào một trang giả. Đây chính là chỗ tấm bảng trắng giữa phòng lộ ra nhược điểm của nó.

WebAuthn giải đúng cái lỗ đó

WebAuthn (khoá bảo mật vật lý, Touch ID, Windows Hello) khác về bản chất: thay vì một mã bạn gõ, nó dùng chữ ký mật mã gắn với tên miền của trang. Khi bạn xác thực, trình duyệt ký một thử thách cùng với origin (tên miền) của trang đang mở. Server kiểm chữ ký đó phải khớp đúng tên miền của chính nó.

Áp vào kịch bản phishing: nạn nhân ở trang ngan-hang-gia.com, trình duyệt ký thử thách với origin ngan-hang-gia.com. Kẻ tấn công chuyển chữ ký đó sang ngan-hang-that.com, nhưng server thật thấy origin trong chữ ký là ngan-hang-gia.com, không khớp — từ chối. Chữ ký không thể chuyển từ tên miền này sang tên miền khác. Phishing thất bại về mặt toán học, chứ không phải nhờ người dùng đủ cảnh giác.

Đây là khác biệt cốt lõi: TOTP là bí mật con người chuyển tay được; WebAuthn là chữ ký máy gắn chặt vào tên miền. Cái đầu chuyển tiếp được nên phishing được; cái sau thì không, dù kẻ tấn công có khéo đến đâu.

Nghĩa là gì trong thực tế

  • Bật 2FA — bất kỳ loại nào — vẫn là một nâng cấp bảo mật lớn. TOTP dù có điểm yếu phishing vẫn chặn được phần lớn tấn công (rò rỉ mật khẩu, dùng lại, dò trực tuyến). Đừng để cái cớ "TOTP không hoàn hảo" ngăn bạn bật nó.
  • Nếu triển khai TOTP, nhớ tự chống phát lại. Lưu bước thời gian của mã vừa dùng, từ chối nếu thấy lại. Thư viện không làm hộ.
  • Với tài khoản giá trị cao, ưu tiên WebAuthn/passkey. Đây là loại 2FA duy nhất chống được phishing thời gian thực — thứ mà cả TOTP lẫn SMS đều thua.
  • SMS là loại yếu nhất (không đo ở đây): ngoài điểm yếu phishing của TOTP, nó còn dính SIM-swap. Dùng khi không còn lựa chọn nào khác, đừng chọn đầu.

Cùng một triết lý, đổi tầng công nghệ

Cái ranh giới giữa "bí mật chuyển tay được" và "chứng cứ gắn chặt ngữ cảnh" không riêng gì của 2FA — nó lặp lại ở khắp nơi trong xác thực, và nhìn sang các hệ khác thấy rõ.

Trong thế giới Java, các thư viện như java-otp hay GoogleAuth cho bạn verify() đúng kiểu tôi đo: chúng kiểm mã theo thời gian nhưng không tự nhớ mã đã dùng — bạn phải tự lưu bước thời gian vào một Set hay một bảng để chống phát lại, y hệt tập DA_DUNG của tôi. Còn với WebAuthn, thư viện chuẩn của giới Java là webauthn4j, và điểm mấu chốt khi tích hợp nó luôn là xác minh origin — bỏ bước kiểm origin đi là bạn vứt luôn cái tính năng chống phishing mà cả WebAuthn sinh ra để có.

Trong Go, cặp pquerna/otp và go-webauthn/webauthn phản chiếu đúng hai triết lý này: cái trước là một hàm thuần tính-mã-theo-thời-gian không trạng thái, cái sau bắt bạn giữ trạng thái phiên và kiểm RPID/origin ở mỗi lần xác thực. Không phải ngẫu nhiên mà cái an toàn hơn lại là cái đòi nhiều trạng thái hơn.

Còn ở cơ sở dữ liệu, cùng một bài học hiện ra dưới dạng token: một bearer token (JWT, session id) giống hệt TOTP — ai cầm được là dùng được, nên nếu nó rò ra là hỏng; trong khi cơ chế an toàn hơn là token gắn chặt (token binding, mTLS client cert) buộc token chỉ dùng được từ đúng client đã tạo ra nó. Cùng câu chuyện "cầm là xài" đối lại "chỉ đúng nơi mới xài", chỉ khác tầng.

Mẫu số chung: một chứng cứ xác thực chỉ an toàn bằng đúng lượng ngữ cảnh mà nó mang theo. Một con số trần trụi có thể tuồn từ tay này sang tay khác, từ trang giả sang trang thật, và thế là phishing thắng. Ràng chứng cứ vào một ngữ cảnh không giả mạo được — tên miền, thiết bị, kết nối — thì kẻ chặn được nó cũng chẳng làm gì với nó. Câu hỏi đúng cho mọi cơ chế xác thực không phải "bí mật này có mạnh không" mà là "nếu ai đó chặn được nó, họ có dùng lại ở nơi khác được không".

Muốn tự thấy thì vào phần cài đặt bảo mật của một tài khoản quan trọng và xem nó cho bạn những lựa chọn 2FA nào. Nếu chỉ có SMS, đó là mức yếu nhất — tìm xem có TOTP không. Nếu có TOTP, bật ngay. Và nếu thấy "khoá bảo mật" hay "passkey", đó là lựa chọn mạnh nhất chống phishing — bật nó cho tài khoản bạn không thể để mất, như email chính, nơi mọi liên kết đặt lại mật khẩu khác đổ về.