Telegram 安全方案解析 - 客户端到客户端的加密

客户端到客户端的加密

Telegram 对于端到端加密的官方名称为 Secret Chat,即秘密聊天。

Telegram 客户端自 4.6 版开始,均使用 MTProto 2.0 协议,本文阐述的也即 MTProto 2.0 协议。MTProto 1.0 正在逐步淘汰,不推荐使用。

与客户端到服务端的加密不同,客户端到客户端之间的加密,服务端无法解密,只有通信双方的客户端可以解密。我们日常所说的端到端加密就是指客户端到客户端的加密。

  1. 双方客户端通过 DH 算法协商一个对称密钥 secret_chat_key
  2. 后续双方客户端的通信,均使用 secret_chat_key 按照一定规则做对称加密

secret_chat_key 的协商

端到端加密的发起方会主动发起 DH 密钥协商,具体流程类似服务端到客户端的 auth_key 协商,这里不再赘述。

协商成功得到如下参数:

  • secret_chat_key: 唯一的对称密钥,用于后续客户端与客户端网络通信的加密
  • key_fingerprint: 由 secret_chat_key 派生,用于在客户端与客户端网络通信中标识所使用的 secret_chat_key

使用 secret_chat_key 加密通信

客户端到客户端的消息加密方案流程如下图所示。

  1. 发送端获取待发送的明文信息 message_data;
  2. 发送端计算生成消息序号 IN_seq_no, OUT_seq_no。长度 32 bits;
  3. 发送端计算 payload = message_data_length + payload_type + random_bytes + layer + IN_seq_no + OUT_seq_no + message_type + message_data + padding;
  4. 发送端计算 msg_key_large = SHA256(substr(secret_chat_key, 88+x, 32) + message_data + random_padding);
    • 对于发送端消息,x=0;对于接收端回复的消息,x=8;
  5. 发送端计算 msg_key = substr(msg_key_large, 8, 16);
  6. 发送端计算 sha256_a = SHA256(msg_key + substr(secret_chat_key, x, 36));
  7. 发送端计算 sha256_b = SHA256(substr(secret_chat_key, 40+x, 36) + msg_key);
  8. 发送端计算 aes_key = substr(sha256_a, 0, 8) + substr(sha256_b, 8, 16) + substr(sha256_a, 24, 8);
  9. 发送端计算 aes_iv = substr(sha256_b, 0, 8) + substr(sha256_a, 8, 16) + substr(sha256_b, 24, 8);
  10. 发送端计算 encrypted_payload = AES256_ige_encrypt(data, aes_key, aes_iv);
  11. 发送端计算 payload_new = key_fingerprint + msg_key + encrypted_payload;
  12. 发送端将 payload_new 作为新的 message_data,套用客户端到服务端的加密通信,发送至服务端,经由服务端发送至目标客户端;
  13. 目标客户端收到服务端发来的消息后,套用客户端到服务端的加密通信,实现外层服务端加密的消息解密,得到发送客户端的加密消息;
  14. 接收端,通过 key_fingerprint 获取 secret_chat_key,结合 msg_key,使用相同算法计算出 aes_key, aes_iv, 解密消息;
  15. 接收端做如下校验
    1. 校验 seq_no 是否正确,参考端到端加密中的 seq_no;
    2. 校验 message_data_length 是否正确;
    3. 通过 secret_chat_key, message_data 重新计算 msg_key,校验与发送端发送的 msg_key 是否相等;
    4. 校验 layer 是否支持,如果不支持,需要提醒用户尽快更新客户端。

完善的前向保密

为了保证过去通信的安全,Telegram 官方客户端的密钥一旦被用于解密和加密超过 100 条信息,或者使用时间超过一周,就会重新进行密钥协商,前提是该密钥至少被用于加密一条信息。随后,旧的钥匙会被安全地丢弃,即使能够获得目前使用的新钥匙,也无法重建旧密钥。

端到端加密中的 seq_no

必须按照原始顺序解释所有消息,以防止重排序、反射、重播、遗漏和其他操作。

Secret Chat 支持一种特殊的机制,独立于服务器处理 seq_no 计数。此外,Secret Chat 中的任何服务消息也必须递增 seq_no

对于 Layer 17 及更高版本的 Secret Chat 消息,均带有 seq_no 参数。其初始值为

1
(out_seq_no, in_seq_no) := (0, 0)

并且在任何消息被发送/接受后,严格的以 1 递增。

seq_no 被发送至其他客户端前,必须按照公式 2 * raw_seq_no + x 进行转换,以防止镜像。

x 的值遵循下表的规范

in_seq_no out_seq_no
Secret Chat 发起方 0 1
Secret Chat 接收方 1 0

这样一来,消息中包含的每一个 seq_no 字段的最小有效位对于接收和发送的消息是不同的。这样做是为了防止可能的攻击者对消息进行镜像。如果收到的 in_seq_noout_seq_no 中的任何一个在奇偶性方面不一致(见上表),客户端就需要立即中止 Secret Chat。

检查 out_seq_no

客户端必须检查它已经收到的每一条消息,其序列号为 out_seq_no,从 0 开始到当前的某个点C,然后它应该期望下一条消息的序列号为 out_seq_no = C + 1。如果收到的消息中的 out_seq_no 与此不符,则需要进行以下操作。

  • 如果收到的 out_seq_no <= C,本地客户端必须丢弃该消息(重复消息)。客户端不应该检查消息的内容,因为原来的消息可能已经被删除了(参见删除未确认的消息
  • 如果接收到的 out_seq_no > C+1,很可能意味着服务器由于技术故障或由于消息过时而遗漏了一些消息。一个临时的解决方法是直接中止密聊。但由于这可能会导致一些现有的旧密聊被中止,所以强烈建议客户端正确处理这种 seq_no 缺口。请注意,in_seq_no 并不是在收到这样的消息后才增加的,而是在所有前面的缺口被填满后才会增加。

适当处理缺口

为了在识别出缺口后正确处理接收到的消息(当接收到的 out_seq_no > C+1 时),有必要将接收到的带有错误 seq_no 的消息放入本地客户端的『等待队列』中,并重新请求丢失的消息。

每个缺口通常只需要一个请求就可以重新发送消息(如果远程客户端一直发送不同步的消息,就应该把它们放入队列中,而不需要发送新的请求)。在接收到缺失的消息后,本地客户端必须首先按照它们的 seq_no 的正确顺序来解析这些消息,而后客户端就可以按照正确的 seq_no 顺序继续解析队列中的消息。

特殊情况:

  • 同时出现两个缺口的情况非常罕见(前提是远程客户端和服务器正常运行),在这种情况下放弃 Secret Chat 是可以接受的。
  • 如果本地客户端收到返回的丢失的消息不满足请求规则,则必须中止 Secret Chat。

避免并发缺口

为了避免被两边的并发缺口卡住,在所有情况下,即使其 out_seq_no >= C+1,返回的丢失消息也必须在收到后立即进行解析。需要注意的是,每个返回的丢失消息必须只处理一次,当我们对队列中的消息进行解析时,就不再处理上述的返回的丢失消息。

检查及处理 in_seq_no

  • in_seq_no 必须是一个非递减的非负整数序列。
  • in_seq_no 必须在收到消息的那一刻是有效的,也就是说,如果 D 是我们发送的最后一条消息的 out_seq_no,那么收到的 in_seq_no 不应该大于 D+1。这样我们也可以将收到的消息插入到密聊中的正确位置。

如果 in_seq_no 与上述检查相异,本地客户端就需要立即中止 Secret Chat。只有在服务器或远程客户端出现恶意或错误行为时才会发生这种情况。

删除未确认的消息

如果本地客户端上的用户在服务器(或远程客户端)能够确认消息之前就已经删除了消息,出于安全考虑,将执行以下操作。

  • 安全地销毁消息的内容(就像其他被删除的 Secret Chat 消息一样)。
  • 将原始消息的本地副本改为另一种被删除的消息格式,其 random_id 等于原始消息的 random_id
  • 创建一个新的发出消息,删除原消息。

因为本地客户端不知道远程客户端是否真的收到了消息,所以必须按如上操作。如果消息已经收到了,它将被第二条消息删除;否则它必须以 “自删除” 消息的形式到达,以保持正确的 seq_no 序列。

Update Box 工作机制

Secret Chat 与设备(或者说与 auth_key)绑定,而不是与用户绑定。传统的 message box 使用 pts 来描述客户端的状态,并不适用于 Secret Chat,因为它是为长期的消息存储和不同设备的消息访问而设计的。

我们引入一个额外的临时消息队列,来作为这个问题的解决方案。当关于 Secret Chat 消息的更新被发送时,会发送一个新的 qts 值,如果连接中断了很久或者更新丢失的情况下,这个值有助于重建差异。

qts 值随每个新事件增加 1,初始值不为 0。

客户端通过调用 messages.receivedQueue 方法显式地承认临时队列中的事件已经被接收和存储的事实,或者通过调用 updates.getDifference 方法隐式地承认(传入 qts 值,而不是最终状态)。所有被客户端确认为已交付的消息,以及任何超过7天的消息,将从服务器上删除。

取消授权后,相应设备的事件队列将被强制清除,qts 的值将变得无关紧要。

更新至新的 layer

客户端始终存储 Secret Chat 另一侧客户端已知支持的最大 layer。当第一次创建 Secret Chat 时,这个值应该初始化为 46。在接收到任何包含上层信息的数据包后,这个远程 layer 值必须总是立即更新。

将本地客户端的 layer 告知远程客户端

本地客户端发送一个 decryptedMessageActionNotifyLayer 类型的消息。这个通知必须被包装在一个适当的 layer 的构造函数中。

如下两种情况,本地客户端必须将 layer 通知远程客户端。

  • 当一个新的 Secret Chat 被创建,secret_key 被成功交换后
  • 本地客户端更新为支持新的 layer 后,立即通知。在这种情况下,必须向所有当前存在的密聊发送通知

参考