Xây dựng ứng dụng chat mã hoá đầu cuối (E2EE) với libsodium

Thiết kế khoá của một app chat E2EE: khoá riêng cho từng cuộc trò chuyện, chuỗi tin nhắn có chữ ký, và giới hạn chung khi phát hiện server gian lận.

· Đăng ngày · Cập nhật · 7 phút đọc · Phiên bản: libsodium.js 0.8 · Về dự án →

"Mã hoá đầu cuối" (end-to-end encryption, E2EE) nghe thì đơn giản: mã hoá trên máy người gửi, giải mã trên máy người nhận, server ở giữa chỉ thấy dữ liệu đã mã hoá. Khi làm E2EE Chat, phần khó hơn không nằm ở thuật toán mà ở một câu hỏi: nếu chính server gian lận thì sao?

Bài này đi qua thiết kế khoá của app, cách dùng libsodium cho từng việc, và những giới hạn chung mà các thiết kế kiểu này đều phải chấp nhận. Nếu bạn đang tìm hiểu E2EE, hy vọng nó giúp bạn hình dung các mảnh ghép khớp với nhau thế nào.

Tổng quan hệ thống

Phần Công nghệ
Web app (PWA) React 19, TanStack Router và Query, Tailwind CSS 4, libsodium
API Fastify 5, Drizzle ORM với PostgreSQL, Socket.IO, Web Push
File Object storage tương thích S3
Tài khoản Keycloak, OpenID Connect với PKCE

Đọc và ghi đi qua REST, còn mọi thay đổi (tin mới, đang gõ, đã xem...) được đẩy realtime qua Socket.IO. Server tạo user ở lần gọi API đầu tiên, sau khi xác minh access token của Keycloak.

Điểm cốt lõi: server chỉ từng thấy ciphertext. Tin nhắn, reaction, nội dung file đính kèm, và cả metadata của file (tên, loại, dung lượng, thời lượng) đều được mã hoá ngay trong trình duyệt.

Các loại khoá

Khoá Thuật toán Lưu ở đâu
Cặp khoá danh tính X25519 (crypto_box_keypair) Public key trên server; secret key chỉ trong IndexedDB
Cặp khoá ký Ed25519, dẫn xuất từ secret key X25519 Công bố cùng khoá danh tính; không lưu riêng
Bản sao lưu khoá Argon2id (crypto_pwhash) + secretbox, cụm mật khẩu tối thiểu 12 ký tự Server, chỉ chủ tài khoản đọc được
Khoá cuộc trò chuyện 32 byte ngẫu nhiên, niêm phong cho từng thành viên (crypto_box_seal) Server, mỗi thành viên một bản
Tin nhắn và file secretbox (XSalsa20-Poly1305) với khoá con từ crypto_kdf Trên tin nhắn

Khoá cho từng cuộc trò chuyện

Mỗi cuộc trò chuyện có một khoá đối xứng 32 byte. Người tạo khoá niêm phong nó bằng public key X25519 của từng thành viên (crypto_box_seal, hay "sealed box": mã hoá bằng public key của người nhận, chỉ secret key tương ứng mới mở được) rồi gửi lên server. Server giữ các bản đã niêm phong nhưng không mở được bản nào.

Sơ đồ: trình duyệt của người tạo sinh khoá cuộc trò chuyện phiên bản N, niêm phong bằng crypto_box_seal cho public key X25519 của Alice, Bob và Carol; server giữ ba bản niêm phong nhưng không mở được bản nào; mỗi thành viên mở bản của mình bằng secret key riêng; tin nhắn và file được mã hoá bằng secretbox với khoá con dẫn xuất từ khoá cuộc trò chuyện.Trình duyệt người tạotạo khoá cuộc trò chuyện phiên bản N: 32 byte ngẫu nhiêncrypto_box_sealcho public key của từng ngườiServerniêm phong choAliceniêm phong choBobniêm phong choCarolgiữ mỗi thành viên một bản niêm phong, không mở được bản nàoAlicemở bằngsecret key riêngBobmở bằngsecret key riêngCarolmở bằngsecret key riêngTin nhắn và filesecretbox, khoá con từ crypto_kdf
Server giữ mọi bản của khoá cuộc trò chuyện nhưng không đọc được bản nào.

Khoá có phiên bản. Khi thành viên nhóm thay đổi, khoá được xoay vòng: server chỉ chấp nhận thay đổi thành viên đi cùng một phiên bản khoá mới. Người rời nhóm không nhận được khoá tiếp theo, và người gửi tin kế tiếp sẽ là người tạo khoá mới. Bảng khoá là chỉ thêm: phiên bản cũ vẫn còn, nên lịch sử cũ vẫn đọc được.

Mất máy thì sao

Secret key chỉ nằm trong IndexedDB của trình duyệt. Để dùng trên máy khác, người dùng đặt một cụm mật khẩu (passphrase). Bộ khoá được mã hoá bằng một khoá dẫn xuất từ cụm mật khẩu qua Argon2id rồi mới lưu lên server. Cụm mật khẩu phải dài ít nhất 12 ký tự và không nằm trong danh sách dễ đoán, vì Argon2id chỉ làm chậm việc dò, không cứu được một mật khẩu yếu.

Phần khó: không tin server

Mã hoá khiến server không đọc được. Nhưng server vẫn lưu và chuyển tiếp mọi thứ, nên nó vẫn có thể:

  • tự tạo một khoá cuộc trò chuyện (và vì thế biết khoá đó),
  • âm thầm thêm một thành viên vào nhóm,
  • thay public key của ai đó bằng key của chính nó,
  • giấu tin nhắn, đổi thứ tự, hoặc phát lại tin cũ.

Không thiết kế nào chặn được hết những việc này, vì server vẫn là nơi lưu và chuyển mọi thứ. Mục tiêu thực tế hơn là làm cho chúng bị phát hiện ở phía người dùng.

Mọi thứ đều được ký

Mỗi người dùng có một khoá ký Ed25519, dẫn xuất từ secret key X25519 (qua BLAKE2b có khoá, rồi crypto_sign_seed_keypair), và được công bố cùng khoá danh tính. Vì dẫn xuất được, khoá ký không cần lưu hay sao lưu riêng: có secret key X25519 là có lại khoá ký. Những gì thành viên tạo ra đều được ký:

  • Phiên bản khoá cuộc trò chuyện: ký trên cuộc trò chuyện, số phiên bản, người tạo và danh sách người nhận. Khoá do server bịa ra sẽ không được dùng để gửi tin. Thành viên do server tự thêm vào sẽ không nhận được khoá.
  • Tin nhắn và bản sửa: ký trên người gửi, phiên bản khoá, nội dung, tin được trả lời và file đính kèm. Tin không qua được kiểm tra sẽ hiện nhãn "Unverified".
  • Thu hồi tin, reaction, bản xem trước và thông báo: đều được kiểm tra. Thông báo không bao giờ hiện nội dung chưa xác minh.

Chuỗi tin nhắn

Chữ ký chứng minh ai viết, nhưng không chứng minh server đã đưa đủ tin. Vì vậy tin của mỗi thành viên trong một cuộc trò chuyện tạo thành một chuỗi có ký (cùng ý tưởng với audit log chống sửa): được đánh số 1, 2, 3..., và mỗi tin ghi lại tin mới nhất mà người gửi đã thấy. Dựa vào đó, app phát hiện được:

  • tin bị giữ lại (số thứ tự bị hổng, hoặc có tin nhắc tới một tin mình chưa nhận),
  • tin hiện sai thứ tự,
  • tin cũ bị phát lại dưới id khác, hoặc một phiên bản cũ hơn bản đã thấy.

Mỗi thiết bị giữ một sổ cái trong IndexedDB, ghi lại những gì nó đã thấy và những gì người dùng tin tưởng (khoá của bạn bè, các khoá cũ của chính mình). Sổ cái được sao lưu cho các thiết bị khác của cùng người dùng, mã hoá bằng một khoá chỉ bộ khoá của họ dẫn xuất ra được.

Khoá của người khác thay đổi

App tin khoá đầu tiên nó thấy của một liên hệ, cách làm thường gọi là TOFU (trust on first use), giống SSH. Từ đó, nếu khoá của ai đó đổi, app ngừng mã hoá cho người đó cho tới khi người dùng chấp nhận khoá mới. Muốn chắc chắn hơn, hai người so safety number với nhau bên ngoài cuộc trò chuyện: quét mã QR khi gặp mặt, đọc to cho nhau, hoặc gửi qua một app khác. Hai số không khớp thì không bao giờ được đánh dấu là đã xác minh.

Giới hạn chung của cách tiếp cận này

Hiểu giới hạn của một kỹ thuật cũng quan trọng như hiểu cách nó hoạt động. Với mọi thiết kế phát hiện gian lận dựa trên chữ ký và chuỗi tin như trên:

  • Phát hiện dựa vào những gì đến sau. Một tin bị giữ lại chỉ lộ ra khi có tin khác nhắc tới nó. Đó là lý do các giao thức như MLS dùng thêm cơ chế đồng thuận về trạng thái nhóm.
  • Mỗi thiết bị chỉ nhớ những gì nó đã thấy. Một thiết bị mới phải tin vào những gì nó được đưa, cho tới khi có thêm bằng chứng.
  • E2EE bảo vệ nội dung, không bảo vệ metadata. Server vẫn biết ai nói chuyện với ai và lúc nào. Giảm metadata cần những kỹ thuật khác hẳn, như sealed sender của Signal.

Những chi tiết nhỏ nhưng quan trọng

  • Web Push không chứa nội dung tin nhắn, vì server không có nội dung để gửi.
  • Quyền riêng tư (ẩn trạng thái online, tắt "đã xem", tắt "đang gõ") được server thực thi, và áp dụng hai chiều như WhatsApp: tắt "đã xem" thì cũng không thấy "đã xem" của người khác.
  • Giới hạn tải lên: file chưa gắn vào tin nhắn nào bị tính vào hạn mức 200 MB mỗi người và bị xoá sau 24 giờ.
  • Xoá tài khoản yêu cầu đã đăng nhập trong 5 phút gần nhất. Public key vẫn được giữ lại, để những gì người đó đã ký vẫn xác minh được.
  • Kiểm thử: logic server chạy test trên một PostgreSQL trong bộ nhớ (PGlite), và luồng end-to-end được test bằng Playwright.

Nếu bạn định tự làm

Tự thiết kế là cách tốt để hiểu từng vấn đề của E2EE. Nhưng với một sản phẩm có người dùng thật, hãy bắt đầu từ một giao thức đã được kiểm toán: Signal Protocol cho chat một-một, hoặc MLS (RFC 9420) cho nhóm. Ở mật mã, những vấn đề đáng lo nhất thường là những vấn đề mình chưa nghĩ tới, và các giao thức đó đã có nhiều người nghĩ hộ.

Bạn có thể dùng thử app tại chat.vuminhhoa.com.

Tóm tắt

  • Server chỉ nên thấy ciphertext: tin nhắn, reaction, nội dung file đính kèm và cả metadata của file đều được mã hoá trong trình duyệt.
  • Mỗi cuộc trò chuyện có một khoá 32 byte có phiên bản, niêm phong cho từng thành viên bằng crypto_box_seal, và khoá được xoay vòng mỗi khi thành viên thay đổi.
  • Bộ khoá được sao lưu, mã hoá bằng khoá dẫn xuất qua Argon2id, để người dùng chuyển sang máy mới. Nhưng Argon2id chỉ làm chậm việc dò, nên cụm mật khẩu phải dài và không nằm trong danh sách dễ đoán.
  • Mã hoá ngăn server đọc, không ngăn server gian lận. Mục tiêu thực tế là làm cho gian lận bị phát hiện ở phía người dùng.
  • Ký mọi thứ bằng khoá Ed25519 dẫn xuất từ secret key X25519, và xâu tin của mỗi thành viên thành chuỗi để tin bị giữ lại, sai thứ tự hay bị phát lại đều lộ ra.
  • Tin khoá đầu tiên của một liên hệ, ngừng mã hoá cho họ khi khoá đổi, và so safety number bên ngoài cuộc trò chuyện khi cần chắc chắn hơn.
  • Việc phát hiện dựa vào tin đến sau, thiết bị mới phải tin những gì nó được đưa, và metadata vẫn lộ. Với sản phẩm có người dùng thật, hãy bắt đầu từ Signal Protocol hoặc MLS.

Nếu bạn đã tự thiết kế mã hoá đầu cuối cho app của mình, mình rất muốn được trao đổi. Email của mình ở trang chủ.