Quay lại blog
Security10 min read

Luồng ghép nối

Bài viết này giải thích cách hai thiết bị PlainApp thiết lập niềm tin lần đầu tiên — cách chúng khám phá lẫn nhau, trao đổi khóa, và đi đến khóa vận chuyển ChaCha20 chung dùng để mã hóa mọi tin nhắn trò chuyện, truyền tệp và tín hiệu trạng thái hiện diện sau đó. Kiến trúc trò chuyện và kênh tiêu thụ khóa này được trình bày riêng trong bài viết Kiến trúc Trò chuyện.

Mục lục

Tại sao cần ghép nối {#why-pairing-exists}

PlainApp không có máy chủ tài khoản trung tâm. Do đó, các thiết bị phải trả lời hai câu hỏi trước khi có thể giao tiếp:

  1. "Bạn là ai?" — mỗi thiết bị tự sinh một clientId ổn định khi khởi động lần đầu (một id 13 ký tự được dẫn xuất từ vật liệu khóa Ed25519 của nó). Đây là định danh duy nhất được dùng cho định tuyến, trạng thái hiện diện và tư cách thành viên kênh.
  2. "Tôi có thể tin bạn không?" — không có máy chủ xác nhận nhận dạng, cách duy nhất để chắc chắn một peer đúng như tuyên bố là con người xác nhận ghép nối trên cả hai thiết bị và giao thức xác minh chữ ký mật mã.

Ghép nối tạo ra một sản phẩm duy nhất: một hàng DPeer trong cơ sở dữ liệu với status="paired", một ChaCha20 key (bí mật vận chuyển chung), và public_key Ed25519 của peer (để xác minh chữ ký tin nhắn trong tương lai). Mọi giao thức sau này trong hệ thống con trò chuyện đều giả định hai trường này tồn tại.

Diagram 1
1

Mô hình Niềm tin & Mật mã {#trust-model--cryptography}

Ghép nối sử dụng hai nguyên thủy mật mã độc lập:

Nguyên thủyMục đíchVòng đời
Ed25519 (chữ ký)Xác thực yêu cầu và phản hồi ghép nối. Xác minh "thực sự đến từ thiết bị tuyên bố gửi nó" và gắn dấu thời gian để chống phát lại.Khóa ký là khóa nhận dạng dài hạn của thiết bị. Nửa công khai được lưu làm DPeer.public_key và sau này được PeerChatParser.decrypt dùng để xác minh chữ ký của mọi tin nhắn trò chuyện.
X25519-style ECDH (thỏa thuận khóa)Tạo bí mật chung trở thành khóa vận chuyển ChaCha20. Hai thiết bị tính toán cùng một bí mật mà không bao giờ truyền nó.Cặp khóa tạm thời được sinh ra cho mỗi phiên ghép nối, bị hủy ngay sau khi khóa chung được tính toán. Bí mật 32 byte kết quả được lưu làm DPeer.key và được tái sử dụng trong suốt vòng đời của lần ghép nối.

Không có PIN, không có mã QR, không có mã out-of-band. Niềm tin được thiết lập bằng:

  1. Con người chạm Accept trên thiết bị đáp ứng (người dùng khẳng định "đúng, đây là thiết bị tôi muốn ghép nối").
  2. Cả hai bên xác minh chữ ký Ed25519 của nhau trên yêu cầu/phản hồi (chứng minh bên đáp ứng đang nói chuyện với cùng thiết bị đã khởi tạo phiên và ngược lại).
  3. Một cửa sổ dấu thời gian ±5 phút trên cả hai tin nhắn (ngăn phát lại một lần bắt tay cũ bị chụp).

Sự bất đối xứng này quan trọng: một xác nhận con người đơn lẻ sẽ dễ bị tấn công man-in-the-middle (kẻ tấn công có thể ghép nối với cả hai bên riêng biệt). Chữ ký Ed25519 trên khóa công khai ECDH ngăn chặn điều này — bên đáp ứng xác minh yêu cầu được ký bởi cùng khóa Ed25519 đã khởi tạo phiên, và ngược lại, nên MITM không thể trong suốt thay thế khóa ECDH của mình mà không kiểm soát khóa ký dài hạn.

Sơ đồ Thành phần {#component-map}

Toàn bộ mã ghép nối nằm trong gói discover/ (không phải chat/peer/pair/):

Diagram 2
2

Vị trí tệp

Thành phầnĐường dẫn (dưới shared/src/commonMain/kotlin/com/ismartcoding/plain/)
LANDiscoverManagerdiscover/LANDiscoverManager.kt
PairingCorediscover/PairingCore.kt
PairingInitiatordiscover/PairingInitiator.kt
PairingResponderdiscover/PairingResponder.kt
PairingSecuritydiscover/PairingSecurity.kt
PairingSessionStorediscover/PairingSessionStore.kt
PairingPeerStorediscover/PairingPeerStore.kt
PairingMessengerdiscover/PairingMessenger.kt

Giai đoạn Khám phá {#discovery-phase}

Trước khi ghép nối có thể diễn ra, các thiết bị phải tìm thấy nhau. LANDiscoverManager chạy liên tục khi ứng dụng khởi động:

Diagram 3
3

Tại sao khám phá có hướng lại được mã hóa

DISCOVER quảng bá không tiết lộ gì nhạy cảm (chỉ fromId=clientId), nên việc bất kỳ thiết bị nào trên LAN nhìn thấy cũng không sao. Tuy nhiên, biến thể có hướng được dùng khi một thiết bị đã biết clientId của thiết bị kia (ví dụ đã ghép nối nhưng IP của peer đã đổi) và muốn đánh thức nó. Mã hóa clientId mục tiêu bằng khóa chung của peer nghĩa là:

  • Peer đúng có thể giải mã toId, nhận ra mình, và trả lời.
  • Mọi thiết bị khác trên LAN chỉ thấy ciphertext — chúng không thể liệt kê những clientId mà người gửi đang cố gắng liên lạc.

Đây là một thuộc tính quyền riêng tư nhỏ nhưng thực tế: quan sát viên LAN thụ động không thể xây dựng đồ thị ai đang ghép nối với ai.

Các cờ Aware trong phản hồi

DISCOVER_REPLY mang awareSupported và awareRunning. Chúng không được lưu persists vào cơ sở dữ liệu — chúng được lưu trong bộ nhớ trong PeerCacher và làm mới trên mỗi phản hồi (và cả từ BLE scan-response serviceData). Tầng vận chuyển tham khảo chúng để quyết định liệu có thử liên kết Wi-Fi Aware hay bỏ qua thẳng sang BLE.

Trình tự Ghép nối (Happy Path) {#pairing-sequence-happy-path}

Luồng end-to-end khi cả hai thiết bị cùng trên một LAN và người dùng chấp nhận ghép nối:

Diagram 4
4

Tại sao cả hai bên lưu peer độc lập

Lưu ý rằng cả bên khởi tạo (bước 9) và bên đáp ứng (bước 7) đều gọi PairingPeerStore.save(...) cho thiết bị khác. Điều này có chủ đích: mỗi thiết bị kết thúc với một hàng DPeer được khóa bởi clientId của bên kia, chứa bản sao riêng của khóa ChaCha20 chung và khóa công khai Ed25519 của bên kia. Không có sổ đăng ký trung tâm — ghép nối là đối xứng và tự chứa.

Tại sao bên đáp ứng tính khóa trước

acceptPairingRequest của bên đáp ứng tính toán khóa chung ngay khi chấp nhận và lưu persists nó. Điều này có nghĩa bên đáp ứng có thể bắt đầu nhận lưu lượng đã mã hóa trước khi phản hồi quay trở lại bên khởi tạo. Nếu phản hồi bị mất trong quá trình truyền, bên đáp ứng vẫn đã ghép nối — chỉ bên khởi tạo cần thử lại.

Chi tiết Trao đổi Khóa {#key-exchange-details}

Lõi mật mã của ghép nối là một thỏa thuận khóa ECDH kiểu X25519 chuẩn, nhưng với chữ ký Ed25519 được phân lớp trên nó để xác thực.

Diagram 5
5

Chữ ký thực sự bảo vệ cái gì

Tải trọng đã ký (toSignatureData()) là một nối chuẩn của các trường yêu cầu ổn định: fromId, fromName, port, deviceType, ecdhPublicKey, signaturePublicKey, timestamp, và ips. Bằng cách ký ecdhPublicKey cùng với signaturePublicKey dài hạn, giao thức gắn khóa tạm thời vào nhận dạng của thiết bị. Kẻ tấn công không thể thay thế khóa công khai ECDH của mình trên đường truyền mà không làm chữ ký không hợp lệ — và chúng không thể giả mạo chữ ký mà không kiểm soát khóa Ed25519 dài hạn.

Đây là điều đánh bại man-in-the-middle: ngay cả khi kẻ tấn công chuyển tiếp mọi gói tin giữa hai thiết bị, chúng không thể đọc lưu lượng đã mã hóa (vì không có khóa riêng ECDH của bên nào) và không thể thay thế khóa ECDH của mình (vì chữ ký sẽ bị hỏng).

Luồng Chấp nhận/Từ chối của bên đáp ứng {#responder-acceptdecline-flow}

Bên đáp ứng hiển thị hộp thoại UI khi PAIR_REQUEST đến. Người dùng có thể chấp nhận hoặc từ chối.

Diagram 6
6

Tại sao bên đáp ứng kích hoạt PairingSuccessEvent ngay khi chấp nhận

acceptPairingRequest của bên đáp ứng gọi PairingPeerStore.save(...) và kích hoạt PairingSuccessEvent trước khi gửi phản hồi. Điều này có chủ đích: nếu phản hồi không bao giờ đến bên khởi tạo (lỗi mạng), bên đáp ứng vẫn đã ghép nối — lần sau bên khởi tạo cố gắng ghép nối, hàng DPeer đã tồn tại của bên đáp ứng sẽ được hệ thống trạng thái hiện diện nhận. Bên khởi tạo chỉ cần thử lại; bên đáp ứng không cần xác nhận lại.

Luồng Hủy {#cancel-flow}

Bất kỳ bên nào cũng có thể hủy một ghép nối đang diễn ra.

Diagram 7
7

Lưu ý rằng DPairingCancel được gửi qua chỉ LAN đơn truyền (bên khởi tạo đã có IP của bên đáp ứng từ giai đoạn khám phá), trong khi phản hồi từ chối được gửi qua cả LAN và BLE vì bên đáp ứng không thể chắc chắn truyền tải nào mà bên khởi tạo có thể liên lạc được.

Truyền tải Đa kênh (LAN + BLE) {#dual-channel-delivery-lan--ble}

Khi bên đáp ứng gửi DPairingResponse, nó làm như vậy qua cả LAN và BLE đồng thời. Bên khởi tạo chấp nhận bản sao đầu tiên và âm thầm loại bỏ bản trùng lặp.

Diagram 8
8

Tại sao BlePairingSessionStore tồn tại

Khi PAIR_REQUEST đến qua BLE, bên đáp ứng không có LAN IP cho bên khởi tạo — chỉ có BLE MAC address của nó. BlePairingSessionStore ánh xạ peerId → MAC để phản hồi có thể được định tuyến lại qua BLE nếu cần. Đây là một ánh xạ nhỏ, trong bộ nhớ, tạm thời, chỉ được tạo cho các yêu cầu định tuyến BLE và bị xóa sau khi phản hồi gửi xong.

Lưu trữ Phiên & Peer {#session--peer-storage}

Hai kho tham gia ghép nối, với thời gian sống rất khác nhau:

Diagram 9
9

Tại sao clientId là định danh duy nhất được lưu persists

Android ngẫu nhiên hóa BLE MAC address trên mỗi kết nối, nên lưu nó sẽ vô dụng. clientId được dẫn xuất từ vật liệu khóa Ed25519 dài hạn của thiết bị, nên nó:

  • Ổn định qua các lần cài lại ứng dụng (khóa nằm trong platform keystore).
  • Tự xác thực — bất kỳ ai tuyên bố một clientId phải chứng minh họ giữ khóa riêng Ed25519 tương ứng (được xác minh trên mỗi tin nhắn đã ký).
  • Bảo vệ quyền riêng tư — chỉ tiền tố SHA-256 8 byte (shortId) được quảng bá qua BLE để khám phá; clientId đầy đủ chỉ được tiết lộ cho các thiết bị mà bạn thực sự ghép nối.

Thuộc tính Bảo mật {#security-properties}

Thuộc tínhCách đạt được
Tính bảo mậtToàn bộ tầng vận chuyển được mã hóa ChaCha20 với khóa chung dẫn xuất từ ECDH. Khóa không bao giờ rời hai thiết bị sau khi ghép nối.
Xác thựcMọi tin nhắn đã ký (yêu cầu/phản hồi ghép nối, createChatItem trò chuyện, kênh invite/update/kick) đều được xác minh Ed25519 với public_key đã lưu của người gửi.
Toàn vẹnChữ ký Ed25519 bao phủ toàn bộ thân yêu cầu; mọi giả mạo làm chữ ký không hợp lệ.
Chống phát lại±5 min timestamp window (thực thi bởi PeerChatParser và PairingSecurity). ChatMessageReceiver.seenSignatures khử trùng lặp trong cửa sổ.
Chống man-in-the-middleKhóa công khai ECDH tạm thời được ký cùng với khóa công khai Ed25519 dài hạn. MITM không thể thay thế khóa ECDH của mình mà không làm chữ ký hỏng.
Tính bảo mật hướng trước (hạn chế)Cặp khóa ECDH là tạm thời cho mỗi phiên ghép nối. Xâm phạm khóa Ed25519 dài hạn sau này không giải mã được lưu lượng quá khứ (khóa chung vẫn cần thiết — nhưng nếu cả khóa riêng ECDH và DPeer.key đã lưu đều bị xóa, các bản chụp quá khứ không thể giải mã).
Chống từ chối dịch vụonDatagram bọc mọi tin nhắn trong try/catch để gói tin malformed không thể làm chết discovery receiver. PeerCircuitBreaker bỏ qua transport flaky trong 30 s sau 2 lần thất bại.
Quyền riêng tư (khám phá có hướng)LANDiscoverManager.discoverSpecificDevice mã hóa clientId mục tiêu bằng khóa của peer — quan sát viên LAN thụ động không thể liệt kê ai đang ghép nối với ai.
Ổn định nhận dạngclientId được dẫn xuất từ vật liệu khóa Ed25519 dài hạn trong platform keystore — ổn định qua các lần cài lại, tự xác thực, và không gắn với số điện thoại hay email.

Những trường hợp ghép nối KHÔNG bảo vệ được

  • Xâm phạm thiết bị vật lý. Nếu kẻ tấn công có quyền root trên thiết bị đã ghép nối, chúng có thể đọc khóa chung từ cơ sở dữ liệu và mạo danh peer đó. Không có thực thi kho khóa được hỗ trợ phần cứng cho khóa vận chuyển chung (chỉ cho khóa ký Ed25519, qua SignatureHelper).
  • Tấn công chuyển tiếp tích cực. Kẻ tấn công có thể đồng thời chuyển tiếp lưu lượng BLE và LAN giữa hai thiết bị tưởng rằng chúng đang ghép nối với nhau, có thể về mặt lý thuyết định vị mình ở giữa — nhưng chữ ký Ed25519 trên khóa công khai ECDH nghĩa là chúng không thể đọc lưu lượng, chỉ chuyển tiếp. Đây là cùng một đánh đổi như ghép nối Bluetooth không có so sánh số.
  • Chặn ở cấp mạng. Tường lửa có thể chặn UDP multicast, BLE có thể bị jam, và Wi-Fi Aware có thể không khả dụng. Hệ thống suy giảm một cách duyên dáng (BLE là fallback đảm bảo cho paired peer) nhưng không thể vượt qua một mạng đang actively hostile.

Tóm tắt Máy trạng thái {#state-machine-recap}

Diagram 10
10

Đọc thêm {#further-reading}

  • Chat Architecture — shared key dùng cho việc gì: send/receive peer chat, channel fan-out, presence, file downloads.
  • apitest/groups/discovery.sh — executable test plan exercise discovery và pairing API surface end-to-end.