目錄
為什麼需要配對 {#why-pairing-exists}
PlainApp 沒有中央帳號伺服器。因此裝置在通訊之前必須回答兩個問題:
- 「你是誰?」 —— 每台裝置在首次啟動時產生一個穩定的
clientId(一個 13 字元的 ID,由其 Ed25519 金鑰材料推導而來)。這是唯一用於路由、上線狀態與頻道成員資格的識別碼。 - 「我能信任你嗎?」 —— 在沒有伺服器為身分背書的情況下,要確認對端確實是它所宣稱的身分,唯一的方式是由人類在兩台裝置上確認配對,並由協定驗證加密簽章。
配對過程產出單一成品:資料庫中一筆 DPeer 記錄,包含 status="paired"、一個 ChaCha20 key(共享的傳輸密鑰)以及對端的 Ed25519 public_key(用於驗證後續訊息簽章)。聊天子系統中的所有後續協定都假定這兩個欄位存在。
信任模型與密碼學 {#trust-model--cryptography}
配對使用兩個相互獨立的密碼學基元:
| 基元 | 用途 | 生命週期 |
|---|---|---|
| Ed25519(簽章) | 對配對請求與回應進行認證。驗證「這確實來自宣稱發送它的裝置」並綁定時間戳以防止重放。 | 簽章金鑰即裝置的長期身分金鑰。其公鑰部分以 DPeer.public_key 儲存,後續被 PeerChatParser.decrypt 用於驗證每則聊天訊息的簽章。 |
| X25519 風格 ECDH(金鑰協商) | 產生共享密鑰作為 ChaCha20 傳輸金鑰。兩台裝置在不傳輸該密鑰的情況下計算出相同的秘密。 | 每次配對工作階段產生一對臨時金鑰,在計算出共享金鑰後立即丟棄。最終得到的 32 位元組密鑰以 DPeer.key 儲存,並在配對的整個生命週期中重複使用。 |
這裡沒有 PIN、沒有 QR code、沒有帶外驗證碼。信任透過以下方式建立:
- 人類在回應方裝置上點選 Accept(使用者聲明「是的,這就是我想配對的裝置」)。
- 雙方在請求/回應上驗證對方的 Ed25519 簽章(證明回應方正在與發起工作階段的同一台裝置通訊,反之亦然)。
- 兩則訊息上都有 ±5 分鐘的時間戳視窗(防止重放舊的已擷取握手)。
這種不對稱性至關重要:單憑一次人類確認會容易受到中間人攻擊(攻擊者可能分別與兩端配對)。ECDH 公鑰上的 Ed25519 簽章可防止這一點——回應方驗證請求是由發起工作階段的同一把 Ed25519 金鑰簽署的,反之亦然,因此 MITM 無法在不受控於長期簽章金鑰的情況下透明地替換自己的 ECDH 金鑰。
元件對照表 {#component-map}
所有配對相關程式碼都位於 discover/ 套件中(而非 chat/peer/pair/):
檔案位置
| 元件 | 路徑(位於 shared/src/commonMain/kotlin/com/ismartcoding/plain/ 下) |
|---|---|
LANDiscoverManager | discover/LANDiscoverManager.kt |
PairingCore | discover/PairingCore.kt |
PairingInitiator | discover/PairingInitiator.kt |
PairingResponder | discover/PairingResponder.kt |
PairingSecurity | discover/PairingSecurity.kt |
PairingSessionStore | discover/PairingSessionStore.kt |
PairingPeerStore | discover/PairingPeerStore.kt |
PairingMessenger | discover/PairingMessenger.kt |
探索階段 {#discovery-phase}
在配對發生之前,裝置必須找到彼此。LANDiscoverManager 在應用程式啟動後持續運行:
為什麼定向探索要加密
廣播式 DISCOVER 不揭露任何敏感資訊(僅含 fromId=clientId),因此 LAN 上的任何裝置看到它都無妨。但定向變體用於一台裝置已經知道另一台的 clientId(例如已配對但對端 IP 已變更)並希望喚醒對方的情境。使用對端的共享金鑰加密目標 clientId 意味著:
- 正確的對端可以解密
toId,識別出自己,並回覆。 - LAN 上的其他所有裝置只能看到密文——它們無法列舉發送方正嘗試聯繫哪些
clientId。
這是一個小而實在的隱私特性:被動 LAN 觀察者無法建立「誰與誰配對」的關係圖。
回覆中的 Aware 旗標
DISCOVER_REPLY 帶有 awareSupported 與 awareRunning。這些不會持久化到資料庫——它們以記憶體方式儲存在 PeerCacher 中,並在每次回覆時重新整理(同時也從 BLE 掃描回應的 serviceData 中重新整理)。傳輸層會查詢這些旗標以決定是嘗試 Wi-Fi Aware 連結還是直接跳到 BLE。
配對序列(正常路徑) {#pairing-sequence-happy-path}
當兩台裝置處於同一 LAN 且使用者接受配對時的端對端流程:
為什麼雙方各自獨立儲存對端資訊
請注意,發起方(步驟 9)與回應方(步驟 7)都為對方裝置呼叫 PairingPeerStore.save(...)。這是有意為之:每台裝置最終都會得到一筆以對方 clientId 為鍵的 DPeer 記錄,其中包含自己那份共享 ChaCha20 金鑰的副本以及對方的 Ed25519 公鑰。沒有中央註冊表——配對是對稱且自包含的。
為什麼回應方先計算金鑰
回應方的 acceptPairingRequest 在接受時立即計算共享金鑰並持久化。這意味著回應方可以在回應返回到發起方之前就開始接收加密流量。如果回應在傳輸過程中遺失,回應方仍處於已配對狀態——只有發起方需要重試。
金鑰交換細節 {#key-exchange-details}
配對的密碼學核心是標準的 X25519 風格 ECDH 金鑰協商,但在其上疊加 Ed25519 簽章進行認證。
簽章實際保護的內容
被簽署的負載(toSignatureData())是穩定請求欄位的標準化串接:fromId、fromName、port、deviceType、ecdhPublicKey、signaturePublicKey、timestamp 與 ips。透過對 ecdhPublicKey 與長期 signaturePublicKey 一起簽署,協定將臨時金鑰綁定到裝置身分。攻擊者無法在傳輸過程中替換自己的 ECDH 公鑰而不使簽章失效——而且他們也無法在不控制長期 Ed25519 金鑰的情況下偽造簽章。
這正是擊敗中間人攻擊的機制:即使攻擊者在兩台裝置之間中繼每個封包,他們也無法讀取加密流量(因為他們沒有任何一方的 ECDH 私鑰),也無法替換自己的 ECDH 金鑰(因為簽章會失效)。
回應方接受/拒絕流程 {#responder-acceptdecline-flow}
當 PAIR_REQUEST 到達時,回應方會顯示一個 UI 對話方塊。使用者可以接受或拒絕。
為什麼回應方在接受時立即觸發 PairingSuccessEvent
回應方的 acceptPairingRequest 呼叫 PairingPeerStore.save(...) 並在傳送回應之前觸發 PairingSuccessEvent。這是深思熟慮的設計:如果回應永遠無法到達發起方(網路故障),回應方仍處於已配對狀態——下次發起方嘗試配對時,回應方已存在的 DPeer 記錄會被上線狀態系統拾取。發起方只需重試;回應方無需再次確認。
取消流程 {#cancel-flow}
任一端都可以取消進行中的配對。
請注意,DPairingCancel 僅透過 LAN 單播傳送(發起方在探索階段已取得回應方的 IP),而拒絕回應則透過 LAN 與 BLE 同時傳送,因為回應方無法確定發起方在哪條傳輸鏈路上可達。
雙通道傳遞(LAN + BLE) {#dual-channel-delivery-lan--ble}
當回應方傳送 DPairingResponse 時,會透過 LAN 與 BLE 同時傳送。發起方接受第一份副本並靜默捨棄重複副本。
為什麼存在 BlePairingSessionStore
當 PAIR_REQUEST 透過 BLE 到達時,回應方沒有發起方的 LAN IP——只有其 BLE MAC 位址。BlePairingSessionStore 維護 peerId → MAC 的對應,以便在需要時透過 BLE 將回應路由回去。這是一個小型、記憶體內的臨時對應表,僅在請求透過 BLE 路由時填入,並在回應傳送後清除。
工作階段與對端儲存 {#session--peer-storage}
兩個儲存區參與配對,但生命週期截然不同:
為什麼 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 分鐘時間戳視窗(由 PeerChatParser 與 PairingSecurity 強制執行)。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}
延伸閱讀
- Chat Architecture —— 共享金鑰的用途:對端聊天傳送/接收、頻道扇出、上線狀態、檔案下載。
apitest/groups/discovery.sh—— 可執行的測試計畫,端對端演練探索與配對的 API 介面。