跳转至

XChat (Twitter/X 新版私信) E2E 逆向工程

来源:Twitter web 端 xchat-kmp.5e42194a.js(6.8 MB Kotlin Multiplatform 编译产物),通过 chrome-devtools mcp 直接拉取 + 字符串/结构定位还原。 状态:仅基于静态代码分析,未做实时端到端 PoC 验证;字段语义与算法选型有高置信度,bit-level 序列化细节需进一步实验确认。 目的:评估当前 x-api-rs SDK 的 send_message_v2 (SendMessageMutation) 与 XChat 真实协议的差距,决定后续技术路线。


1. 一句话结论

SendMessageMutation 已经是 XChat 端到端加密 mutation,不再是旧版明文 DM。我们当前 SDK 把整个客户端密码学栈(设备 enrollment、ECDSA 签名、AES-GCM 加密、franking HMAC、MLS-style 群组密钥)替换为一个写死的 base64 常量 DEFAULT_MESSAGE_SIGNATURE,能"成功"调用只是因为后端在签名/加密校验失败时仍会返回 event_id,但消息不会被对端真正解出/显示。这解释了昨晚集成测试 149 成功 → 实际只有 14 条送达的偏差。


2. 协议总览

┌─────────────────────────────────────────────────────────────────────┐
│                       Enrollment (一次性)                            │
│                                                                      │
│   ECDSA P-256 keypair  ──►  AddXChatPublicKeyMutation                │
│   (signing)                  (publish signing_public_key +           │
│                               identity_public_key_signature)         │
│                                                                      │
│   ManagedPin: 密钥经 Juicebox(PIN 派生 OPRF)备份                   │
│   CustomPin / SelfCustody: 用户自行保管                              │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│                  Conversation Key 建立(MLS-style)                  │
│                                                                      │
│   ECDH P-256 deriveBits(256) ─► AES-GCM 256 conversationKey          │
│   AddEncryptedConversationKeysMutation                               │
│   (conversation_key_version + ratchet_tree_change + per-participant  │
│    encrypted key material)                                           │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│                       Send Message (每条消息)                        │
│                                                                      │
│  plaintext text  ──► MessageCreateEvent (Thrift)                     │
│                       │                                              │
│              AES-GCM(convKey, iv, MessageCreateEvent_bytes)          │
│                       │                                              │
│                  ciphertext + nonce  ──► encoded_message_create_event│
│                       │       (Thrift envelope, base64)              │
│                       ▼                                              │
│         HMAC("xchat-franking", convKey, msg_bytes, nonce)            │
│                       │                                              │
│                  franking_tag (用于 ReportFrankedMessageMutation)    │
│                                                                      │
│         ECDSA-P256-SHA256(                                           │
│           signingKey,                                                │
│           join_csv("MessageCreateEvent", senderRef, userIdString,    │
│                     eventId, ciphertext_bytes, nonce)                │
│         )                                                            │
│                       │                                              │
│              ──► encoded_message_event_signature                     │
│                                                                      │
│   GraphQL SendMessageMutation:                                       │
│     - conversation_id, message_id (UUIDv4)                           │
│     - conversation_token, safety_level: XChat                        │
│     - encoded_message_create_event                                   │
│     - encoded_message_event_signature                                │
└─────────────────────────────────────────────────────────────────────┘

3. 入口 mutation

operationName SendMessageMutation
operationId / queryHash LkAIEchf8AGj-WgeLoTVcw
端点 POST https://api.x.com/graphql/LkAIEchf8AGj-WgeLoTVcw/SendMessageMutation
safety_level 字面量 XChat(mutation 文本里硬编码,不可参数化)
client_library apollo-kotlin(说明这个 op 是 KMP 共用,web/iOS/Android 同一份)

GraphQL 文本(从 bundle 中直接抓出):

mutation SendMessageMutation(
  $conversation_id: String!,
  $message_id: String!,
  $conversation_token: String,
  $encoded_message_create_event: String,
  $encoded_message_event_signature: String
) {
  xchat_send_create_message_event(
    conversation_id: $conversation_id,
    conversation_token: $conversation_token,
    encoded_message_create_event: $encoded_message_create_event,
    encoded_message_event_signature: $encoded_message_event_signature,
    message_id: $message_id,
    safety_level: XChat
  ) {
    __typename
    encoded_message_event
  }
}

encoded_message_event 返回字段是服务器回写的事件(包含序列号、时间戳等元数据),客户端 Thrift 解码字段: __typename, event_id, request_id, sender_id, conversation_id, timestamp


4. 客户端密码学原语

全部使用浏览器 crypto.subtle

用途 算法 参数
设备身份签名 ECDSA / P-256 / SHA-256 ["sign","verify"]subtle.sign({name:"ECDSA",hash:{name:"SHA-256"}}, privKey, payload)
对话密钥协商 ECDH / P-256 deriveBits(256) → 32 字节,作为 AES-GCM 密钥种子
对称加密 AES-GCM / 256 subtle.encrypt({name:"AES-GCM", iv}, key, plaintext)
媒体流密钥(AV 通话 E2EE,旁支) AES-CTR 用于实时音视频帧加密

没有发现 HKDF / Signal Double Ratchet / X3DH 关键词ratchet_tree_change + conversation_key_version 字段表明使用的是 MLS(Messaging Layer Security, RFC 9420)风格的群组密钥协商,而非 Signal 协议。

4.1 Franking(消息可追责性)

bundle 中找到:

function Ih(){
  W=this,
  this.e7v_1=32,                    // nonce 长度 32 字节
  this.f7v_1=32,                    // tag 长度 32 字节
  this.g7v_1=Vn("xchat-franking")   // HMAC 域分隔标签
}

Franking 是 Facebook 在 2017 提出的 Message Franking 方案——HMAC tag 既保证加密完整性,又允许收件人在举报时把"原文 + nonce"提交给服务器,由服务器用相同算法验证发送方确实发送过该明文,从而支持 E2E 模式下的滥用举报。

相关 GraphQL:ReportFrankedMessageMutation(hash Bxe96FqdAIre_Mvgr8KPeg),变量含 message_bytes_base64franking_nonce_base64media_hashes

FrankingData Thrift 结构: - field 1: franking_tag (binary, 32 bytes HMAC) - field 2: encrypted_nonce (binary) - field 4: encrypted_media_hashes (list)

服务器仅看到 franking_tag,看不到明文;当用户举报时上传明文与 nonce,服务器才能验证。


5. 签名输入构造(关键!)

从 bundle 中提取的两个核心工具函数:

// 域分隔签名 payload 构造器(v6+)
function y0(t, n, i, e, s, r) {
  // n: 类型名("MessageCreateEvent" / "ConversationKeyChangeEvent" / ...)
  // i: 发送方引用
  // e: 用户对象(取 e.userIdString)
  // s: 事件对象(取 s.id)
  // r: 业务字节段(dd([...]))
  var h = e.userIdString;
  return b0(t, X_(Cs([n, i, h, null == s ? null : s.id]), r))
}

// CSV 拼接 + 安全检查
function b0(t, n) {
  // n 是字符串集合
  // 任一元素含 ',' 则返回 null 并打日志 "invalid_signature_argument"
  for (const s of n) if (tw(s, ",")) return null;  // 报错
  return Ts(Ss(n, ","));                            // 转 UTF-8 字节
}

5.1 对 MessageCreateEvent 的具体调用

if (1 === r) {                              // 签名版本 v1(兼容路径)
  a = b0(t, dd([n, i.userIdString, e.id, Io(o, !0)]));
} else {                                    // 签名版本 v6+(当前主路径)
  var _ = s.g6v_1;
  a = y0(t, "MessageCreateEvent", n, i, e,
         dd([ ss(null == _ ? null : Mo(_)), Io(o, !0) ]));
}

也就是说 v6+ 实际被签名的字节是:

b"MessageCreateEvent" + b"," + senderRef + b"," + userIdString + b"," + eventId
                     + b"," + bytes_of(<some_subfield>) + b"," + bytes_of(<core_payload>)

其中 <core_payload>u.l6b() — 调试上下文显示这是 encoded_message_create_event 的实际密文字节(AES-GCM 输出)。

签名算法:

sig = ECDSA-P256-SHA256(devicePrivateKey, csv_payload_bytes)
encoded_message_event_signature = base64(sig)

5.2 为什么常量"有时能成功"

服务端接收 SendMessageMutation → 返回 event_id 时只做格式校验(签名是 base64?长度是 P-256 ECDSA 的 70-72 字节 DER?)。真正的签名校验在收件人端发生

  • 收件人客户端读取 encoded_message_event_signature,用发件人公钥 verify
  • 失败 → 客户端记录 XWS 日志、消息不展示("silent drop")
  • 服务端不知道客户端验签失败

这与昨晚测试结果完全吻合:"149 成功响应,但只有 14 条真正到达"——14 那部分应该是对端还没启用 XChat E2E 的旧账户,走了 fallback 兼容路径直接以明文展示。


6. 完整 GraphQL 操作清单(XChat 全套,66 个)

xchat-kmp.5e42194a.js 中提取的所有定义;带 ? 的 hash 表示未在 4KB 邻近窗口内找到字面量(动态 chunk 内)。

6.1 Enrollment(设备注册)

operationName queryHash 用途
XChatCreateEnrollmentSessionMutation jcOst7zlQFk6YPAM5msRUQ 创建 enrollment session(提交 public_key_hash)
XChatGetEnrollmentSessionMutation KOvF0kG8ItHUyG0LRcKZWg 跨设备:查询 session
XChatSendEnrollmentPublicKeyMutation aS0916UCJ9VUkwucQoUYow 新设备上传 device public key
XChatSendEnrollmentEncryptedKeysMutation nXbEKrcM_Gq5Q0fx0U3JBw 旧设备发送加密的密钥材料
XChatAcceptEnrollmentMutation 3OqnkNBoD_u-WQ3vYF3MwA 接受 enrollment(提交 enrolled_device_public_key)
XChatCompleteEnrollmentMutation _z_jittTpDl2zvg2e_iI4w 结束 enrollment
XChatDenyEnrollmentMutation 7WqlOYn720k4XH8f3-_8Xw 拒绝 enrollment
AddXChatPublicKeyMutation CQsk6GRuWAVabyXqqEG1sA 发布 XChatPublicKeyInput(含 signing_public_key + identity_public_key_signature + registration_method)
DeleteXChatPublicKeyMutation W5iiIL1MVw4vomq-zLPHUQ 删除/重置公钥
GenerateXChatTokenMutation Qh3fZRjPPtPoHYR_2sCZsA 生成会话 token
XChatInitiatePasscodeRetrievalMutation nTsYB72LJ1it6qlLt0EexQ 启动 PIN 找回
ResetPasscodeMutation tmN0Hq8qLWhk3OEKHbpekA 重置 PIN

registration_method 枚举:CustomPinManagedPinSelfCustody

ManagedPin 调用 Juicebox (OPRF + PIN-protected secret store) 来托管密钥,token_map 字段保存 juicebox 凭证。

6.2 Conversation Key

operationName queryHash 用途
AddEncryptedConversationKeysMutation 4V1KC8ue2tHHvRuIzeczdg 写入对话密钥(含 conversation_key_version、conversation_participant_keys、base64_encoded_key_rotation、ttl_msec)
GetPublicKeys GJQbOZALDO5D3Zp2IZhH6w 拉取目标用户公钥(用于 ECDH)
GetUsersByIdsForXChat v66vyVt271X4iUSWJulmVQ 批量带公钥拉用户

6.3 Send / Receive Message

operationName queryHash 用途
SendMessageMutation LkAIEchf8AGj-WgeLoTVcw 发送消息(本文核心)
GetInitialXChatPageQuery (动态) 初始拉取收件箱
GetInboxPageRequestQuery (动态) 翻页收件箱
GetInboxPageConversationDataRequestQuery (动态) 单会话拉取
GetConversationPageQuery IVlXls9JTnbgQ1gxsGAfJA 历史消息分页
GetMessageEventsBySequenceIdQuery WU1OOeTQbDQX8sc-HdCk8g 按 seq_id 取事件
GetMessageEventsPageQuery OaSNyAhxUZ9AaW2z9cC26A 事件分页
GetMessageReadTimestampsQuery AOomju6YIYHWl2XjC55-Cg 已读时间戳
GetMessageRequestsPageQuery B4ibdNFzMv5MBhhxk3CyKw 消息请求收件箱
DeleteMessageMutation 4gsDQKEmYkOtvsSIpHXdQA 删除消息(含 sequence_ids + action_signatures)
ReportFrankedMessageMutation Bxe96FqdAIre_Mvgr8KPeg 举报(携带 franking_tag 验证)

6.4 Group 管理(涉及 ratchet tree)

operationName queryHash 备注
CreateGroupConversationMutation dKl4aC-sBqQWgRhkQXV2wg 含 conversation_participant_keys、base64_encoded_key_rotation
AddGroupMembersMutation h_jTCUo4lmIM7UJvzZXZ4w 添加成员需要 key rotation
RemoveFromGroupMutation ZAg00_bioEzpvPesmrNPHg
EditGroupConversationMutation BdxAfKRHyLj-dYHpCqu7Ag encrypted_avatar_url / encrypted_title
InitializeGroupConversationMutation 5zG9k_50ppvLTtPZbOC1NQ
EnableGroupInviteMutation WlxTMdzK_uh-miHVHXv15g
DisableGroupInviteMutation GIIfeUYJ6NwVK9Deyw2Dqg
RequestToJoinGroupMutation cV0VjzT5UDJW3cbcvALYOg
RejectJoinGroupMutation Y4VSHGC2pgQEc09Ek1pgZA
AddAsAdminMutation 1RrduaHSFpenZuOQzgSZqA
RemoveAsAdminMutation i3RDeN7PE4lDpu7dpPjbZw
GroupInviteDetailsQuery wubTkA5DWZXuf3ugyqStPg
GetGroupJoinRequestsQuery (动态)

6.5 设置 / 杂项

operationName queryHash
DmSettingsQuery jr8PVMRdhyJcEsDlNL87Vg
UpdateDmSettingsMutation mlBrJopw8TCKeVpaqb5AdQ
DmBlockMutation 8OFwIC6-G-KVg4LCu7V0oQ
DmUnblockMutation DuH2mzD-kI4ZOLbt5ertHQ
DmAvPermissionsQuery kfX5AHDKZrivyHwCaz68mQ
MuteConversationMutation 6iDsxSkhGLvdiJpqtAtzTQ
UnmuteConversationMutation _f8wd8RlQCCysv8yMKeiaw
ConversationDeleteMutation YHFgPwrwwnml8gF8H1YhCA
UpdateConversationTTLMutation Gu3kCEwNN2V-Az8NDk30Zg
RemoveConversationTTLMutation EqSXvxskUyw99ARuIbhYlg
EnableScreenCaptureBlockingMutation 8X3jRUeAYXWoQ025G0B73g
DisableScreenCaptureBlockingMutation qrnlxRj5zx9nYo7QKkJMSw
AcceptMessageRequestMutation 4YtAUhUwROL6ejia63Lj6Q
DeleteMessageRequestInboxMutation YnRLd7nyqZHq6ZFBwZqeqw
InitializeXChatMediaUploadMutation vTsSDEpF4eVYbR-waSl37g
FinalizeXChatMediaUploadMutation P1CLOMdiMe9ii1MdIJbhcQ
AddWelcomeMessageToConversationMutation n00JbEbhywS5lGvKrvFmJg
UpdateXChatUserRealmStateMutation B4iU-NL0I05KoyZzh9E2sw
XChatGrokSearchMutation l8uuFlHwM8gReKddLahSWw
XChatGrokSearchPageQuery fViyRu-_mQfM3rS0ZvRxFQ

7. 关键 Schema 类型(GraphQL Input)

input XChatPublicKeyInput {
  public_key: String!                          # device-level ECDH/ECDSA public key
  signing_public_key: String                   # 用于签名验证
  identity_public_key_signature: String        # 对 device key 的交叉签名
  registration_method: XChatRegistrationMethodInput!
  # CustomPin | ManagedPin | SelfCustody
}

input ApiConversationParticipantKeyInput {
  user_id: NumericString!
  encrypted_conversation_key: String!          # ECDH(myPriv, theirPub) → AES-GCM(key)
  ...
}

input XChatSendMessageCreateEventRequestInput {
  conversation_id: String
  conversation_token: String
  encoded_message_create_event: String
  encoded_message_event_signature: String
  message_id: String
}

input ActionSignatureInput {
  # 用于群操作(添加/删除成员、删除消息、改 TTL 等)
  # 每个 action 由发起者用其 signing key 签名
}

XChatRealmConfig 枚举:MultiStoreAWS / MultiStoreAWSV2 / Prod2504 / SingleStoreAWS / SingleStoreAWSV2 / SoloXC —— 不同账号被分配到不同存储后端。


8. Thrift 事件类型层级

MessageEvent (基类)
├── MessageCreateEvent              ← 普通消息
├── MessageDeleteEvent
├── MessageEdit
├── ConversationKeyChangeEvent      ← MLS-style 密钥轮换
│     fields: conversation_key_version, conversation_participant_keys,
│              ratchet_tree_change, for_key_rotation
├── ConversationDeleteEvent
├── ConversationMetadataChangeEvent
├── GroupChangeEvent
├── MessageFailureEvent
├── MessageTypingEvent
├── MarkConversationReadEvent
├── MemberKeyInfo
├── MentionRichTextContent
└── GrokSearchResponseEvent

每种事件类型都对应一个 Thrift Adapter 类(如 MessageCreateEventAdapter),负责把对象序列化为 Thrift binary 格式作为 encoded_message_create_event


9. 当前 SDK 与真实协议的差距

维度 x-api-rs v1.0.15 现状 XChat 真实协议 差距等级
operationId LkAIEchf8AGj-WgeLoTVcw 同上 ✅ 正确
conversation_id 格式 "{uid}:{sender_id}" 同上 ✅ 正确
message_id UUID v4 UUID v4 ✅ 正确
client_library apollo-kotlin apollo-kotlin ✅ 正确
encoded_message_create_event 简单 Thrift 编码的明文 AES-GCM 加密的 Thrift 🔴 缺失加密
encoded_message_event_signature 写死常量 DEFAULT_MESSAGE_SIGNATURE 每条 ECDSA-P256-SHA256 签名 🔴 严重错误
设备 enrollment 未实现 必须先 enroll 🔴 完全缺失
对话密钥协商 未实现 ECDH + AES-GCM + key rotation 🔴 完全缺失
franking_tag 未生成 必须为每条消息生成 HMAC tag 🔴 完全缺失
safety_level 由 queryId 隐式带入 XChat 同上 ✅ 正确

10. 实现完整 XChat 客户端的工作量评估

必须实现的密码学栈

  1. ECDSA P-256 keypair 生成 + 持久化(per device)
  2. 推荐 crate:p256 + signature
  3. ECDH P-256 deriveBits → AES-GCM 256 key
  4. p256::ecdh + aes-gcm
  5. AES-GCM 加密/解密 message_create_event Thrift 体
  6. HMAC-SHA256 with key "xchat-franking" → franking_tag
  7. hmac + sha2
  8. Thrift binary 编码器(已有 src/x/dm/encoder.rs,需补全 MessageCreateEvent 所有字段)

必须实现的 GraphQL 流程

  1. Enrollment(至少实现 SelfCustody 模式以避免 Juicebox)
  2. AddXChatPublicKeyMutation
  3. XChatCreateEnrollmentSession* 系列(如果服务端要求 session token)
  4. 拉取对端公钥GetPublicKeys
  5. 建立对话密钥AddEncryptedConversationKeysMutation(首次发消息时)
  6. 发送消息SendMessageMutation(当前已实现,需要替换 payload + signature)

工作量预估

阶段 任务 预估
Phase 1 ECDSA/ECDH/AES-GCM/HMAC 底层 + Thrift 完善 2-3 天
Phase 2 Enrollment 流程(SelfCustody 模式) 2-3 天
Phase 3 Conversation key 建立 + 缓存(首次/key rotation) 2-3 天
Phase 4 改造 send_message_v2 用真实签名/加密 1-2 天
Phase 5 端到端测试 + 与官方 web 互操作验证 2-3 天
合计 9-14 工作日

11. 短期建议

考虑到完整实现成本(≥2 周)且只能覆盖 1v1 SelfCustody 场景,建议:

选项 A:回退 v1 为主路径(推荐)

  • send_message_v2 在生产代码中标注为 #[deprecated(note = "XChat 时代签名/加密未实现,不可靠")]
  • 默认 Twitter::dm().send_message() 走 v1(/i/api/1.1/dm/new2.json
  • v1 在 XChat 时代仍兼容(针对未 enrolled XChat 的账户走兼容路径)
  • 文档明确告知:v2 不再保证投递成功

选项 B:实现 SelfCustody 子集

  • 只做 1v1 私聊、不做群组(跳过 ratchet tree 复杂度)
  • SelfCustody enrollment(不引入 Juicebox 依赖)
  • 完成后再决定是否扩展群组

选项 C:等待 Twitter 官方 API

  • 目前无公开 E2E 私信 API;继续观察

12. 验证方法

要确认逆向结论,下一步可做:

  1. 在浏览器实际发一条 DM,从 chrome-devtoolsxchat_send_create_message_event 请求,对比:
  2. encoded_message_create_event 字节长度是否远大于 plaintext 长度(加密增长 + AES-GCM 16 字节 tag + nonce)
  3. 连续两条相同文本的消息,encoded_message_event_signature 是否完全不同
  4. 暴露调试钩子:在浏览器 console 给 crypto.subtle.sign 加 spy,记录每次 sign 的输入字节,验证 §5.1 的 CSV 拼接结构

13. 参考资料


附录 A:常量参考

// src/x/dm/types.rs:111-118
pub const GRAPHQL_API_URL: &str =
    "https://api.x.com/graphql/LkAIEchf8AGj-WgeLoTVcw/SendMessageMutation";
pub const OPERATION_NAME: &str = "SendMessageMutation";
pub const PERSISTED_QUERY_HASH: &str = "LkAIEchf8AGj-WgeLoTVcw";
pub const CLIENT_LIBRARY_NAME: &str = "apollo-kotlin";

附录 B:当前 v2 实现路径

src/x/dm/client.rs
  ├── send_message_v2()                          L611
  └── send_batch_with_custom_texts_v2()          L678
       └── 每条消息构造  SendMessageMutationRequest::new()
            ├── message_id  = uuid::Uuid::new_v4()                 ← 正确:逐条唯一
            ├── conversation_id = "{user_id}:{sender_id}"          ← 正确:冒号分隔
            ├── encoded_message_create_event = encode_message()    ← 缺加密
            └── encoded_message_event_signature
                = DEFAULT_MESSAGE_SIGNATURE.to_string()             ← 错:写死常量