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

发布: (2026年5月2日 GMT+8 05:18)
9 分钟阅读
原文: Dev.to

Source: Dev.to

设备专属消息:为何我淘汰多设备以及指纹哈希如何强制实现。

Martin Kambla

大多数即时通讯应用允许你在手机、笔记本、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 秒后旧设备会明显失效。这种反馈循环对用户的心理模型非常重要。

恢复 = 全新起点

当用户在新设备上恢复时:

  1. 新设备生成一个全新的 install ID 并计算新的指纹哈希。
  2. 它使用用户的恢复码请求恢复接口。
  3. 服务器将 active_fingerprint_hash 更新为新的哈希,递增 recovery_count,并将 recovered_at = NOW()
  4. 旧设备的下一次轮询会返回 device_deactivated,客户端随后强制重置。

全新起点的关键在于:recovered_at 之前发送的消息不会同步到新设备。 这是有意的端到端设计。新设备…

Ice 并不拥有旧房间的密钥。 它们已经不存在。即使服务器返回了密文,新设备也无法解密。显示“不可解密的消息”行的用户体验会比直接假装这些消息不存在更差。

对话从恢复时间戳开始重新计数,这符合安全模型:窃取旧设备的攻击者仍然可以在该设备上看到恢复前的旧消息,直到它在轮询时被停用;而从恢复时刻起的所有内容仅在新设备上可见。

对构建者的成本

单设备强制并非免费:

  1. 你必须解释它。
    来自 WhatsApp 或 Telegram 的用户习惯了多设备支持。引导流程必须在不显得防御的前提下说明“你只能使用一个设备,原因如下”。我学会把它框定为一种功能——你的账户受到实体持有的保护——而不是一种限制。

  2. 桌面/网页更难实现。
    不能拥有在手机关机后仍然持久的网页会话。于是我构建了一个独立的、短暂的网页聊天,使用临时密钥且不算作“设备”。这是一套不同的产品界面,拥有自己的信任体系。

  3. 测试很尴尬。
    每一次手动 QA 都会消耗掉安装 ID,因为每次恢复都是一次真实的恢复。我编写了一个服务器端的开发端点,用于为测试用户重置指纹状态,并通过 APP_ENV=dev 进行限制,显而易见。

什么可以改进?

我认为总有提升的空间,寻找最佳方案是一场持续的斗争。在我看来,这正是大语言模型(LLMs)最能发挥作用的地方。不过,了解业界人士认为这里还能有哪些改进也很有价值。

如果你有想法,请直接联系我或在评论中留言。

这是关于 Quldra 背后技术的短系列的第 2 篇文章,Quldra 是一个使用 Kotlin Multiplatform 构建的后量子单设备即时通讯工具。上一篇文章是 My road to ML‑KEM‑768 over X25519 for my messaging app

0 浏览
Back to Blog

相关文章

阅读更多 »

5 分钟搞懂 VPN 与代理

基本概念:VPN 和代理都充当你与互联网之间的中间人。你的设备不是直接与网站通信,而是通过它们发送请求。

消息与通知工具比较 (2026)

PostgreSQL LISTEN/NOTIFY ✅ 优点 - 内置于数据库,无需额外安装。 - 严格事务化:只有在数据满足条件时才会发送消息。