目录
为什么需要配对
PlainApp 没有中央账户服务器。因此设备在通信之前必须回答两个问题:
- "你是谁?" —— 每台设备在首次启动时生成一个稳定的
clientId(一个 13 字符的 ID,由其 Ed25519 密钥材料派生而来)。这是用于路由、在线状态和频道成员资格的唯一标识符。 - "我能信任你吗?" —— 在没有服务器为身份作担保的情况下,要确保对端就是其声称的身份,唯一的方式是由人类在两台设备上确认配对,并由协议验证加密签名。
配对过程产生一个最终产物:数据库中的一条 DPeer 记录,包含 status="paired"、一个 ChaCha20 key(共享的传输密钥)以及对端的 Ed25519 public_key(用于验证后续消息签名)。聊天子系统中的所有后续协议都假定这两个字段存在。
信任模型与密码学
配对使用两个相互独立的密码学原语:
| 原语 | 用途 | 生命周期 |
|---|---|---|
| Ed25519(签名) | 对配对请求和响应进行认证。验证"这确实来自声称发送它的设备"并绑定时间戳以防重放。 | 签名密钥即设备的长期身份密钥。其公钥部分以 DPeer.public_key 存储,后续被 PeerChatParser.decrypt 用于验证每条聊天消息的签名。 |
| X25519 风格 ECDH(密钥协商) | 生成共享密钥作为 ChaCha20 传输密钥。两台设备在从不传输该密钥的情况下计算出相同的秘密。 | 每次配对会话生成一对临时密钥,在计算出共享密钥后立即丢弃。最终得到的 32 字节密钥以 DPeer.key 存储,并在配对的整个生命周期中重复使用。 |
这里没有 PIN、没有二维码、没有带外验证码。信任通过以下方式建立:
- 人类在响应方设备上点击 Accept(用户在声明"是的,这就是我想配对的设备")。
- 双方在请求/响应上验证对方的 Ed25519 签名(证明响应方正在与发起会话的同一台设备通信,反之亦然)。
- 两条消息上都有 ±5 分钟的时间戳窗口(防止重放旧的被捕获的握手)。
这种不对称性至关重要:单凭一次人类确认会容易受到中间人攻击(攻击者可能分别与两端配对)。ECDH 公钥上的 Ed25519 签名可以防止这一点——响应方验证请求是由发起会话的同一 Ed25519 密钥签名的,反之亦然,因此 MITM 无法在不受控于长期签名密钥的情况下透明地替换自己的 ECDH 密钥。
组件映射
所有配对相关代码都位于 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 |
发现阶段
在配对发生之前,设备必须找到彼此。LANDiscoverManager 在应用启动后持续运行:
为什么定向发现是加密的
广播式 DISCOVER 不暴露任何敏感信息(仅含 fromId=clientId),因此 LAN 上的任何设备看到它都没关系。但定向变体用于一台设备已经知道另一台的 clientId(例如已配对但对端 IP 已变更)并希望唤醒对方的场景。使用对端的共享密钥加密目标 clientId 意味着:
- 正确的对端可以解密
toId,识别出自己,并回复。 - LAN 上的其他所有设备只能看到密文——它们无法枚举发送方正在尝试联系哪些
clientId。
这是一个小而实在的隐私特性:被动 LAN 观察者无法构建"谁与谁配对"的关系图。
回复中的 Aware 标志
DISCOVER_REPLY 携带 awareSupported 和 awareRunning。这些不会持久化到数据库——它们以内存方式存储在 PeerCacher 中,并在每次回复时刷新(同时也从 BLE 扫描响应的 serviceData 中刷新)。传输层会查询这些标志以决定是尝试 Wi-Fi Aware 链路还是直接跳到 BLE。
配对序列(正常路径)
当两台设备处于同一 LAN 且用户接受配对时的端到端流程:
为什么双方各自独立存储对端信息
注意,发起方(步骤 9)和响应方(步骤 7)都为对方设备调用 PairingPeerStore.save(...)。这是有意为之:每台设备最终都会得到一条以对方 clientId 为键的 DPeer 记录,其中包含自己那份共享 ChaCha20 密钥的副本以及对方的 Ed25519 公钥。没有中央注册表——配对是对称且自包含的。
为什么响应方先计算密钥
响应方的 acceptPairingRequest 在接受时立即计算共享密钥并持久化。这意味着响应方可以在响应返回到发起方之前就开始接收加密流量。如果响应在传输中丢失,响应方仍然处于已配对状态——只有发起方需要重试。
密钥交换细节
配对的密码学核心是标准的 X25519 风格 ECDH 密钥协商,但在其上叠加了 Ed25519 签名以进行认证。
签名实际保护的内容
被签名的载荷(toSignatureData())是稳定请求字段的规范化拼接:fromId、fromName、port、deviceType、ecdhPublicKey、signaturePublicKey、timestamp 和 ips。通过对 ecdhPublicKey 与长期 signaturePublicKey 一起签名,协议将临时密钥绑定到设备身份。攻击者无法在传输过程中替换自己的 ECDH 公钥而不使签名失效——并且他们也无法在不控制长期 Ed25519 密钥的情况下伪造签名。
这正是击败中间人攻击的机制:即使攻击者在两台设备之间中继每一个数据包,他们也无法读取加密流量(因为他们没有任一方的 ECDH 私钥),也无法替换自己的 ECDH 密钥(因为签名会失效)。
响应方接受/拒绝流程
当 PAIR_REQUEST 到达时,响应方会显示一个 UI 对话框。用户可以接受或拒绝。
为什么响应方在接受时立即触发 PairingSuccessEvent
响应方的 acceptPairingRequest 调用 PairingPeerStore.save(...) 并在发送响应之前触发 PairingSuccessEvent。这是深思熟虑的设计:如果响应永远无法到达发起方(网络故障),响应方仍然处于已配对状态——下一次发起方尝试配对时,响应方已存在的 DPeer 记录会被在线状态系统拾取。发起方只需重试;响应方无需再次确认。
取消流程
任一端都可以取消进行中的配对。
注意,DPairingCancel 仅通过 LAN 单播发送(发起方在发现阶段已经获得了响应方的 IP),而拒绝响应通过 LAN 和 BLE 同时发送,因为响应方无法确定发起方在哪条传输链路上可达。
双通道投递(LAN + BLE)
当响应方发送 DPairingResponse 时,它会通过 LAN 和 BLE 同时发送。发起方接受第一个副本并静默丢弃重复副本。
为什么存在 BlePairingSessionStore
当 PAIR_REQUEST 通过 BLE 到达时,响应方没有发起方的 LAN IP——只有其 BLE MAC 地址。BlePairingSessionStore 维护 peerId → MAC 的映射,以便在需要时通过 BLE 将响应路由回去。这是一个小的、内存中的临时映射,仅在请求通过 BLE 路由时填充,并在响应发送后清除。
会话与对端存储
两个存储参与配对,但生命周期截然不同:
为什么 clientId 是唯一持久化的标识符
Android 在每次连接时随机化 BLE MAC 地址,因此存储它是无用的。clientId 派生自设备的长期 Ed25519 密钥材料,因此它具有以下特性:
- 稳定 —— 在应用重装后保持不变(密钥存储在平台 keystore 中)。
- 自认证 —— 任何声称拥有某个
clientId的人都必须证明其持有相应的 Ed25519 私钥(在每条签名消息上验证)。 - 隐私保护 —— 仅 8 字节的 SHA-256 前缀(
shortId)会通过 BLE 广播用于发现;完整的clientId仅向你实际配对的设备公开。
安全属性
| 属性 | 实现方式 |
|---|---|
| 机密性 | 所有传输都使用由 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 是已配对对端的保底回退),但无法绕过主动敌意的网络。
状态机回顾
延伸阅读
- Chat Architecture —— 共享密钥的用途:对端聊天发送/接收、频道扇出、在线状态、文件下载。
apitest/groups/discovery.sh—— 可执行的测试计划,端到端地演练发现与配对的 API 表面。