设备独立消息:为什么我淘汰了 multi-device,以及指纹哈希如何强制实现
Source: Dev.to

大多数即时通讯应用允许你在手机、笔记本、iDevice 和浏览器上登录,所有消息都会同步。这被包装成便利,却也是一个攻击面。
在设计我的聊天工具时,我做了一个刻意不受欢迎的决定:**每个账户只能使用一台设备,且在服务器端强制执行。**本文将介绍我如何实现这一点,为什么执行力度比政策本身更重要,以及当用户的设备损坏时,恢复的过程是怎样的。
为什么只用一个设备?
多设备的宣传口号是:“我想在我拥有的每个屏幕上都能看到我的聊天记录。”其代价:
- 密钥分发问题。 每新增一台设备都需要会话密钥。要么你从中心密钥重新派生密钥,失去每台设备的前向保密性,要么在设备之间分发密钥,这就需要额外的同步协议来进行审计。
- 妥协的冲击半径。 一台登录了 Signal 桌面版的被盗笔记本电脑就等同于你的聊天记录全部被泄露。在单设备模型中,攻击者只能通过对唯一设备的物理访问来进行攻击,而不是对 N 台设备中的任意一台进行访问。
- 账户恢复的社会工程。 “嗨,我是 Bob,我买了新的 iPad,能把它加到我的账户吗?”这是最老套的手段。如果账户只能拥有一台设备,答案永远是:“不行,请走恢复流程。”
对于一个端到端加密的应用,我无法看到内容时,多设备意味着我在维护一个我无法观察其失效模式的协议。单设备则让服务器的工作变得简单:跟踪哪个安装是规范的,并拒绝其他所有安装。
指纹哈希
每次安装应用时,首次启动会生成一个随机的 32 字节安装 ID,并将其持久化在安全存储中(iOS 上的 Keychain,Android 上的 EncryptedSharedPreferences)。服务器从不直接看到这个原始 ID。它看到的是一个哈希:
fun computeFingerprintHash(installId: ByteArray, platform: String): String {
val digest = MessageDigest.getInstance("SHA-256")
digest.update(installId)
digest.update(0x00)
digest.update(platform.toByteArray())
return digest.digest().toHexString()
}
在注册时,客户端发送此哈希。服务器将其写入 installs 表:
CREATE TABLE installs (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
active_fingerprint_hash TEXT NOT NULL,
recovery_count INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id)
);
注意 UNIQUE (user_id) 约束——每个用户只能有一行记录。在模式层面上不可能实现多设备。如果以后想要添加多设备支持,就必须修改表结构,这正是设计的要点。
8 秒轮询
每个客户端发出的已认证请求都会在认证信封中包含其指纹哈希。服务器会将其与存储的 active_fingerprint_hash 进行比对。如果匹配,请求继续执行;如果不匹配:
route("/users/me") {
get {
val auth = call.principal()!!
val install = installRepo.findByUserId(auth.userId)
?: throw NotFoundException()
if (install.activeFingerprintHash != auth.fingerprintHash) {
call.respond(
HttpStatusCode.OK,
mapOf(
"error" to "device_deactivated",
"recoveredAt" to install.recoveredAt?.toString()
)
)
return@get
}
call.respond(HttpStatusCode.OK, install.toDto())
}
}
客户端会在后台启动一个轮询循环,每 8 秒 请求一次 /users/me:
private fun startDeviceCheckLoop(scope: CoroutineScope) {
scope.launch {
while (isActive) {
delay(8_000)
val result = apiClient.getMe()
if (result.error == "device_deactivated") {
handleDeactivation(result.recoveredAt)
break
}
}
}
}
为什么是 8 秒?
- 足够短,确保被停用的设备不会长时间存活。 如果有人偷走你的手机并在新设备上恢复,旧设备将在 8 秒内检测到并自行锁定。
- 足够长,避免对服务器造成过大压力。 以 8 秒的间隔,一个活跃用户每天大约会产生 ~10,800 次请求——目前还能接受,但仍然是一笔需要监控的成本。
- 给用户一种即时感受。 用户点击 “我有新手机”,完成恢复流程后,8 秒后旧设备会明显失效。这种反馈循环对用户的心理模型非常重要。
恢复 = 全新起点
当用户在新设备上恢复时:
- 新设备生成一个全新的 install ID 并计算新的指纹哈希。
- 它使用用户的恢复码请求恢复接口。
- 服务器将
active_fingerprint_hash更新为新的哈希,递增recovery_count,并将recovered_at = NOW()。 - 旧设备的下一次轮询会返回
device_deactivated,客户端随后强制重置。
全新起点的关键在于:在 recovered_at 之前发送的消息不会同步到新设备。 这是有意的端到端设计。新设备…
Ice 并不拥有旧房间的密钥。 它们已经不存在。即使服务器返回了密文,新设备也无法解密。显示“不可解密的消息”行的用户体验会比直接假装这些消息不存在更差。
对话从恢复时间戳开始重新计数,这符合安全模型:窃取旧设备的攻击者仍然可以在该设备上看到恢复前的旧消息,直到它在轮询时被停用;而从恢复时刻起的所有内容仅在新设备上可见。
对构建者的成本
单设备强制并非免费:
-
你必须解释它。
来自 WhatsApp 或 Telegram 的用户习惯了多设备支持。引导流程必须在不显得防御的前提下说明“你只能使用一个设备,原因如下”。我学会把它框定为一种功能——你的账户受到实体持有的保护——而不是一种限制。 -
桌面/网页更难实现。
不能拥有在手机关机后仍然持久的网页会话。于是我构建了一个独立的、短暂的网页聊天,使用临时密钥且不算作“设备”。这是一套不同的产品界面,拥有自己的信任体系。 -
测试很尴尬。
每一次手动 QA 都会消耗掉安装 ID,因为每次恢复都是一次真实的恢复。我编写了一个服务器端的开发端点,用于为测试用户重置指纹状态,并通过APP_ENV=dev进行限制,显而易见。
什么可以改进?
我认为总有提升的空间,寻找最佳方案是一场持续的斗争。在我看来,这正是大语言模型(LLMs)最能发挥作用的地方。不过,了解业界人士认为这里还能有哪些改进也很有价值。
如果你有想法,请直接联系我或在评论中留言。
这是关于 Quldra 背后技术的短系列的第 2 篇文章,Quldra 是一个使用 Kotlin Multiplatform 构建的后量子单设备即时通讯工具。上一篇文章是 My road to ML‑KEM‑768 over X25519 for my messaging app。
