返回部落格
Security10 min read

配對流程

本文說明兩台 PlainApp 裝置如何首次建立信任——它們如何發現彼此、交換金鑰,並最終得到共享的 ChaCha20 傳輸金鑰,之後每則聊天訊息、檔案傳輸與上線狀態心跳都以此金鑰加密。使用此金鑰的聊天與頻道架構在另一篇《Chat Architecture》文章中另行介紹。

目錄

為什麼需要配對 {#why-pairing-exists}

PlainApp 沒有中央帳號伺服器。因此裝置在通訊之前必須回答兩個問題:

  1. 「你是誰?」 —— 每台裝置在首次啟動時產生一個穩定的 clientId(一個 13 字元的 ID,由其 Ed25519 金鑰材料推導而來)。這是唯一用於路由、上線狀態與頻道成員資格的識別碼。
  2. 「我能信任你嗎?」 —— 在沒有伺服器為身分背書的情況下,要確認對端確實是它所宣稱的身分,唯一的方式是由人類在兩台裝置上確認配對,並由協定驗證加密簽章。

配對過程產出單一成品:資料庫中一筆 DPeer 記錄,包含 status="paired"、一個 ChaCha20 key(共享的傳輸密鑰)以及對端的 Ed25519 public_key(用於驗證後續訊息簽章)。聊天子系統中的所有後續協定都假定這兩個欄位存在。

Diagram 1
1

信任模型與密碼學 {#trust-model--cryptography}

配對使用兩個相互獨立的密碼學基元:

基元用途生命週期
Ed25519(簽章)對配對請求與回應進行認證。驗證「這確實來自宣稱發送它的裝置」並綁定時間戳以防止重放。簽章金鑰即裝置的長期身分金鑰。其公鑰部分以 DPeer.public_key 儲存,後續被 PeerChatParser.decrypt 用於驗證每則聊天訊息的簽章。
X25519 風格 ECDH(金鑰協商)產生共享密鑰作為 ChaCha20 傳輸金鑰。兩台裝置在不傳輸該密鑰的情況下計算出相同的秘密。每次配對工作階段產生一對臨時金鑰,在計算出共享金鑰後立即丟棄。最終得到的 32 位元組密鑰以 DPeer.key 儲存,並在配對的整個生命週期中重複使用。

這裡沒有 PIN、沒有 QR code、沒有帶外驗證碼。信任透過以下方式建立:

  1. 人類在回應方裝置上點選 Accept(使用者聲明「是的,這就是我想配對的裝置」)。
  2. 雙方在請求/回應上驗證對方的 Ed25519 簽章(證明回應方正在與發起工作階段的同一台裝置通訊,反之亦然)。
  3. 兩則訊息上都有 ±5 分鐘的時間戳視窗(防止重放舊的已擷取握手)。

這種不對稱性至關重要:單憑一次人類確認會容易受到中間人攻擊(攻擊者可能分別與兩端配對)。ECDH 公鑰上的 Ed25519 簽章可防止這一點——回應方驗證請求是由發起工作階段的同一把 Ed25519 金鑰簽署的,反之亦然,因此 MITM 無法在不受控於長期簽章金鑰的情況下透明地替換自己的 ECDH 金鑰。

元件對照表 {#component-map}

所有配對相關程式碼都位於 discover/ 套件中(而非 chat/peer/pair/):

Diagram 2
2

檔案位置

元件路徑(位於 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

探索階段 {#discovery-phase}

在配對發生之前,裝置必須找到彼此。LANDiscoverManager 在應用程式啟動後持續運行:

Diagram 3
3

為什麼定向探索要加密

廣播式 DISCOVER 不揭露任何敏感資訊(僅含 fromId=clientId),因此 LAN 上的任何裝置看到它都無妨。但定向變體用於一台裝置已經知道另一台的 clientId(例如已配對但對端 IP 已變更)並希望喚醒對方的情境。使用對端的共享金鑰加密目標 clientId 意味著:

  • 正確的對端可以解密 toId,識別出自己,並回覆。
  • LAN 上的其他所有裝置只能看到密文——它們無法列舉發送方正嘗試聯繫哪些 clientId

這是一個小而實在的隱私特性:被動 LAN 觀察者無法建立「誰與誰配對」的關係圖。

回覆中的 Aware 旗標

DISCOVER_REPLY 帶有 awareSupportedawareRunning。這些不會持久化到資料庫——它們以記憶體方式儲存在 PeerCacher 中,並在每次回覆時重新整理(同時也從 BLE 掃描回應的 serviceData 中重新整理)。傳輸層會查詢這些旗標以決定是嘗試 Wi-Fi Aware 連結還是直接跳到 BLE。

配對序列(正常路徑) {#pairing-sequence-happy-path}

當兩台裝置處於同一 LAN 且使用者接受配對時的端對端流程:

Diagram 4
4

為什麼雙方各自獨立儲存對端資訊

請注意,發起方(步驟 9)與回應方(步驟 7)都為對方裝置呼叫 PairingPeerStore.save(...)。這是有意為之:每台裝置最終都會得到一筆以對方 clientId 為鍵的 DPeer 記錄,其中包含自己那份共享 ChaCha20 金鑰的副本以及對方的 Ed25519 公鑰。沒有中央註冊表——配對是對稱且自包含的。

為什麼回應方先計算金鑰

回應方的 acceptPairingRequest 在接受時立即計算共享金鑰並持久化。這意味著回應方可以在回應返回到發起方之前就開始接收加密流量。如果回應在傳輸過程中遺失,回應方仍處於已配對狀態——只有發起方需要重試。

金鑰交換細節 {#key-exchange-details}

配對的密碼學核心是標準的 X25519 風格 ECDH 金鑰協商,但在其上疊加 Ed25519 簽章進行認證。

Diagram 5
5

簽章實際保護的內容

被簽署的負載(toSignatureData())是穩定請求欄位的標準化串接:fromIdfromNameportdeviceTypeecdhPublicKeysignaturePublicKeytimestampips。透過對 ecdhPublicKey 與長期 signaturePublicKey 一起簽署,協定將臨時金鑰綁定到裝置身分。攻擊者無法在傳輸過程中替換自己的 ECDH 公鑰而不使簽章失效——而且他們也無法在不控制長期 Ed25519 金鑰的情況下偽造簽章。

這正是擊敗中間人攻擊的機制:即使攻擊者在兩台裝置之間中繼每個封包,他們也無法讀取加密流量(因為他們沒有任何一方的 ECDH 私鑰),也無法替換自己的 ECDH 金鑰(因為簽章會失效)。

回應方接受/拒絕流程 {#responder-acceptdecline-flow}

PAIR_REQUEST 到達時,回應方會顯示一個 UI 對話方塊。使用者可以接受或拒絕。

Diagram 6
6

為什麼回應方在接受時立即觸發 PairingSuccessEvent

回應方的 acceptPairingRequest 呼叫 PairingPeerStore.save(...) 並在傳送回應之前觸發 PairingSuccessEvent。這是深思熟慮的設計:如果回應永遠無法到達發起方(網路故障),回應方仍處於已配對狀態——下次發起方嘗試配對時,回應方已存在的 DPeer 記錄會被上線狀態系統拾取。發起方只需重試;回應方無需再次確認。

取消流程 {#cancel-flow}

任一端都可以取消進行中的配對。

Diagram 7
7

請注意,DPairingCancel 僅透過 LAN 單播傳送(發起方在探索階段已取得回應方的 IP),而拒絕回應則透過 LAN 與 BLE 同時傳送,因為回應方無法確定發起方在哪條傳輸鏈路上可達。

雙通道傳遞(LAN + BLE) {#dual-channel-delivery-lan--ble}

當回應方傳送 DPairingResponse 時,會透過 LAN 與 BLE 同時傳送。發起方接受第一份副本並靜默捨棄重複副本。

Diagram 8
8

為什麼存在 BlePairingSessionStore

PAIR_REQUEST 透過 BLE 到達時,回應方沒有發起方的 LAN IP——只有其 BLE MAC 位址。BlePairingSessionStore 維護 peerId → MAC 的對應,以便在需要時透過 BLE 將回應路由回去。這是一個小型、記憶體內的臨時對應表,僅在請求透過 BLE 路由時填入,並在回應傳送後清除。

工作階段與對端儲存 {#session--peer-storage}

兩個儲存區參與配對,但生命週期截然不同:

Diagram 9
9

為什麼 clientId 是唯一持久化的識別碼

Android 在每次連線時隨機化 BLE MAC 位址,因此儲存它毫無用處。clientId 推導自裝置的長期 Ed25519 金鑰材料,因此它具有以下特性:

  • 穩定 —— 在應用程式重新安裝後保持不變(金鑰儲存在平台 keystore 中)。
  • 自認證 —— 任何宣稱擁有某個 clientId 的人都必須證明其持有對應的 Ed25519 私鑰(在每則簽章訊息上驗證)。
  • 隱私保護 —— 僅 8 位元組的 SHA-256 前綴(shortId)會透過 BLE 廣播用於探索;完整的 clientId 僅向您實際配對的裝置公開。

安全屬性 {#security-properties}

屬性實現方式
機密性所有傳輸都使用由 ECDH 推導的共享金鑰進行 ChaCha20 加密。配對後該金鑰永不離開這兩台裝置。
認證性每則簽章訊息(配對請求/回應、聊天 createChatItem、頻道 invite/update/kick)都根據發送方儲存的 public_key 進行 Ed25519 驗證。
完整性Ed25519 簽章涵蓋整個請求主體;任何竄改都會使簽章失效。
抗重放±5 分鐘時間戳視窗(由 PeerChatParserPairingSecurity 強制執行)。ChatMessageReceiver.seenSignatures 在視窗內進行去重。
抗中間人攻擊臨時 ECDH 公鑰與長期 Ed25519 公鑰一起簽署。MITM 無法在不破壞簽章的情況下替換自己的 ECDH 金鑰。
前向保密(有限)ECDH 金鑰對在每次配對工作階段中都是臨時的。後續洩露長期 Ed25519 金鑰無法解密過往流量(仍需共享金鑰——但如果 ECDH 私鑰與已儲存的 DPeer.key 都被清除,過往擷取將無法被解密)。
抗拒絕服務onDatagram 將每則訊息包裝在 try/catch 中,因此格式錯誤的封包無法終止探索接收器。PeerCircuitBreaker 在 2 次失敗後跳過不穩定的傳輸 30 秒。
隱私(定向探索)LANDiscoverManager.discoverSpecificDevice 使用對端的金鑰加密目標 clientId——被動 LAN 觀察者無法列舉誰與誰配對。
身分穩定性clientId 推導自平台 keystore 中的長期 Ed25519 金鑰材料——在重新安裝後保持穩定、可自認證,且不與電話號碼或電子郵件綁定。

配對無法防禦的威脅

  • 實體裝置被攻陷。 如果攻擊者在一台已配對裝置上取得 root 權限,他們可以從資料庫讀取共享金鑰並冒充該對端。共享傳輸金鑰沒有硬體支援的金鑰儲存強制保護(僅 Ed25519 簽章金鑰透過 SignatureHelper 受此保護)。
  • 主動中繼攻擊。 能夠在兩台自以為正在互相配對的裝置之間同時中繼 BLE 與 LAN 流量的攻擊者,理論上可以居中——但 ECDH 公鑰上的 Ed25519 簽章意味著他們無法讀取流量,只能中繼。這與不帶數字比較的藍牙配對是同樣的權衡。
  • 網路層封鎖。 防火牆可以封鎖 UDP 多播,BLE 可以被干擾,Wi-Fi Aware 可能不可用。系統會優雅降級(BLE 是已配對對端的保證備援),但無法繞過主動敵意的網路。

狀態機回顧 {#state-machine-recap}

Diagram 10
10

延伸閱讀

  • Chat Architecture —— 共享金鑰的用途:對端聊天傳送/接收、頻道扇出、上線狀態、檔案下載。
  • apitest/groups/discovery.sh —— 可執行的測試計畫,端對端演練探索與配對的 API 介面。