私钥泄漏等于别人成为这台手机:设备互认的 4 层判定

文章来源声明: 原文作者:booboosui; 来源站点:掘金; 原文链接:https://juejin.cn/post/7704230915766124595; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

把信任收敛到一个函数返回值、把能力写成白名单,是可复用的工程范式。适合做局域网内 P2P 设备互联、无账号体系的团队,参考其分层判定与明确付掉的代价。

私钥泄漏等于别人成为这台手机:设备互认的 4 层判定 --------------------------

这两天行业在集中讨论「给 Personal Agent 再加一具身体」:Tiiny 的 Personal Agent OS 拿了 Pre-A,联想发了台 1.6 升的 AI 主机,3499 美元的 Ghost Core 也在这个叙事里,乌镇还摆着三台「智能体手机」。硬件那层我插不上话,只看一个所有方案都绕不开的问题:两台设备之间,凭什么互相认定是同一个它。

我的实现里,这件事被拆成四个判定,每个判定都只回答一个问题,而且都必须回答「由谁决定」。下面是结论、证据、实现里三处容易写错的地方,以及这套设计明确付掉的代价。

一、结论:四层,每层一个「谁说了算」

层问题由谁决定落在哪
身份这台设备是谁它自己(首次使用时生成 P-256 密钥对)`PeerStore.identity()`
准入我准不准它**出示二维码的那一侧**(不是扫码方)`AndroidPeers.invite(trust)` → `inviteTrust`
冒充凭什么信它是它配对时钉住的公钥,别的一概不认`Connection.receiveHello` 的 `lookup`
边界它能碰到我多少白名单 + 每调用强制 `untrusted``GuestHost`

第一层决定「不用账号」:身份不是服务器发的,是本地生成的,代价是重装即失忆。 第二层决定方向:加入者不能给自己定级,只有已经在这台设备上的人能说「这是我自己人」。 第三层把信任收敛到一个函数返回值上,没有第二个地方读信任。 第四层把「能做什么」从提示词里拿出来,写成一张 Set。

二、证据

全部在 core/src/main/kotlin/net/kuafuai/andee/peer/,纯 JVM,不含 Android 类型,手机和 Mac 共用:

文件行数承担
`PeerProtocol.kt`209两个握手,**没有网络**:字节进、字节出
`PeerLink.kt`376帧格式、`PairingCode`、服务端、客户端、会话通道
`PeerStore.kt`191身份与钉住的公钥、关系、上次可达地址
`PeerTasks.kt`341`list_friends` / `ask_friend` 与报告回传
`PeerCrypto.kt`98只包 P-256 / SHA-256 / HMAC / HKDF / AES-GCM

关键常量(都可以在代码里 grep 到,不是估的):

// PeerLink.kt
const val HANDSHAKE_MS = 8_000L
const val MAX_FRAME = 4 * 1024 * 1024
class PairingCode(code: String, lifetimeMs: Long = 120_000L, maxTries: Int = 3)

// PeerDiscovery.kt
const val SERVICE_TYPE = "_andee-peer._tcp"
const val DEFAULT_PORT = 9120     // 固定,好让防火墙规则能写死;被占用才换

// AndroidPeers.kt
const val RESOLVE_TRIES = 5
const val RESOLVE_RETRY_MS = 800L

// BodyCoreService.kt
const val FRIEND_ASK_MS = 300_000L   // 朋友请求卡片,五分钟算没答

协议本身不绑传输。现在是 WebSocket(固定端口 9120),帧是 1 字节类型 + 若干 4 字节长度前缀字段,HANDSHAKE_MS 内没握完就断。这一点是刻意的:PeerProtocol 的类注释第一句就是「两个握手,里面没有网络」,所以整套东西包括攻击路径都能在一个进程里跑测试。

三、实现里三处容易写错的地方

1. 双方要哈希同一串字节,而「我」和「对方」是相对的

配对时那个 HMAC 覆盖的是:六位数字 + 双方身份公钥 + 角色字符串。问题在于两边调用时的视角是相反的 —— 我这边的「我的公钥」在对方那边是「对方的公钥」。PairingSession.transcript() 里的写法是把两边绕回来:

private fun transcript(initiator: Boolean, mine: Boolean): ByteArray {
    val iAmInitiatorInThisProof = if (mine) initiator else !initiator
    val first  = if (iAmInitiatorInThisProof == mine) mineKey else theirKey
    val second = if (iAmInitiatorInThisProof == mine) theirKey else mineKey
    ...
}

initiator 这个参数唯一的作用,就是让两边排出同一个顺序。连接阶段同理,Connection.signedBytes() 里那句 signerIsInitiator = if (mine) initiator else !initiator 是同一类换算。这类代码写错了不会报错,只会「一半情况能连上」—— 所以它必须是一个能在同一进程里双向跑的纯函数测试,而不是两个模拟器对着点。

2. 计数器只能在成功之后前进

会话层两个方向各一把钥匙、各一个计数器,nonce 就是「4 个零字节 + 8 字节大端计数」。解密函数是这样:

fun decrypt(sealed: ByteArray): ByteArray? {
    val plain = PeerCrypto.open(recvKey, PeerCrypto.nonceFor(recvCounter), AAD, sealed) ?: return null
    recvCounter++
    return plain
}

顺序反过来(先自增再解)会有一个后果:任何一个伪造帧都能把后面的正常通信带偏一格,之后全废。返回 null 而不是抛异常,则是为了让上层把「这帧不算」和「这条链路坏了」分开处理 —— 前者丢掉继续,后者才关连接。

3. 「找不到」在这个平台上不是可重试的

这条最不体面,但值钱。Android 14 之前 NsdManager 一次只处理一个 resolve,第二个请求回 FAILURE_ALREADY_ACTIVE;而 onServiceFound 对每个服务只回调一次。于是抢输的那次请求没有任何人再发起一次,那台设备在这台手机眼里就永久失联。实测场景:LIO-TL00(Android 12)与一台 Mac,同一个 /24,两台都在广播,互相也能直连,界面上就是「不可达」,直到重启 App。

修法是显式的队列 + 指数式退避重试:

if (attempt < RESOLVE_TRIES) resolveHandler.postDelayed(
    { resolveLater(next, attempt + 1) }, RESOLVE_RETRY_MS * (attempt + 1)
)

再加一条兜底:连上之后把 host:port 写进 store(PeerStore.setAddress),mDNS 完全不投递时照旧地址拨。这条兜底之所以安全,是因为「找到一个地址」和「被允许进来」是两件事 —— 地址过期了、换主人了,也过不了钉住公钥的握手。另外别忘了 MulticastLock,Android 的省电组播过滤会把 mDNS 回复直接丢掉。

四、代价

一、只能在同一个局域网。 发现靠 mDNS,拨号靠局域网地址,跨网络没有实现,README 的「还没做的」里写着需要一台中转,连接层是按「以后加中转不改上层」写的。这不是疏忽,是范围。

二、重装等于换了一个人。 私钥在 Keystore 里(alias body_peer_key,独立 prefs 文件),恢复出厂或重装之后读不出来,之前配过的每台设备都会拒绝它。没有账号就没有找回 —— 同一枚硬币的两面。

三、六位数字本身不安全。 安全性全部来自 maxTries = 3 与 120_000 毫秒。谁要是在这条路上加一个「不限次数」的便捷入口,一百万种可能对离线爆破就是几分钟的事。

四、二维码比手打强,但强得很窄。 差别只在于 PeerQr 里带着出示方的密钥指纹,扫码方 expectFingerprint 只认这一把。它防的是「旁边看见六位数的人抢先冒充」,防不了「你扫的是别人贴的一张假码」。

五、这条通道的能力被刻意压得很小。 朋友交来的活跑在 GuestHost:screen.* 加 ui.ask / ui.alert / ui.show_html / device.launch / device.open_url,没有笔记本、情景、话题、App 架子,每次 dispatch 强制 untrusted。用白名单而不是黑名单,注释写的是「下个月加的方法对朋友默认是关的,直到有人决定该开」。代价是新能力默认对朋友不可用,得一条条放。

六、报告可能丢。 ask_friend 在对方「收下」时就返回,不等结果;报告走 outbox,20 秒重试一次,最多等 30 分钟,超时就放弃并写日志。也就是说,一个跑了四十分钟的活,结果可能永远回不来 —— 相比让说话那边卡四十分钟,这个取舍是划算的,但它确实是丢消息。

小结

四层判定里,没有一层需要第三方。身份是本地生成的,准入是主人这一侧给的,冒充由钉住的公钥挡,能力由白名单限。代价也就集中在这里:没有账号就没有找回,没有中转就出不了局域网,六位数字的强度全靠那两次限制。

如果要拿一句话概括这套东西和「一个账号登录所有设备」的区别:账号泄漏的后果是别人可以登录,私钥泄漏的后果是别人成为这台设备。