XChat (Twitter/X 新版私信) E2E 逆向工程¶
来源:Twitter web 端
xchat-kmp.5e42194a.js(6.8 MB Kotlin Multiplatform 编译产物),通过 chrome-devtools mcp 直接拉取 + 字符串/结构定位还原。 状态:仅基于静态代码分析,未做实时端到端 PoC 验证;字段语义与算法选型有高置信度,bit-level 序列化细节需进一步实验确认。 目的:评估当前x-api-rsSDK 的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_base64、franking_nonce_base64、media_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 枚举:CustomPin、ManagedPin、SelfCustody。
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 客户端的工作量评估¶
必须实现的密码学栈¶
- ECDSA P-256 keypair 生成 + 持久化(per device)
- 推荐 crate:
p256+signature - ECDH P-256 deriveBits → AES-GCM 256 key
p256::ecdh+aes-gcm- AES-GCM 加密/解密 message_create_event Thrift 体
- HMAC-SHA256 with key
"xchat-franking"→ franking_tag hmac+sha2- Thrift binary 编码器(已有
src/x/dm/encoder.rs,需补全 MessageCreateEvent 所有字段)
必须实现的 GraphQL 流程¶
- Enrollment(至少实现
SelfCustody模式以避免 Juicebox) AddXChatPublicKeyMutationXChatCreateEnrollmentSession*系列(如果服务端要求 session token)- 拉取对端公钥:
GetPublicKeys - 建立对话密钥:
AddEncryptedConversationKeysMutation(首次发消息时) - 发送消息:
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 复杂度)
- 用
SelfCustodyenrollment(不引入 Juicebox 依赖) - 完成后再决定是否扩展群组
选项 C:等待 Twitter 官方 API¶
- 目前无公开 E2E 私信 API;继续观察
12. 验证方法¶
要确认逆向结论,下一步可做:
- 在浏览器实际发一条 DM,从
chrome-devtools抓xchat_send_create_message_event请求,对比: encoded_message_create_event字节长度是否远大于 plaintext 长度(加密增长 + AES-GCM 16 字节 tag + nonce)- 连续两条相同文本的消息,
encoded_message_event_signature是否完全不同 - 暴露调试钩子:在浏览器 console 给
crypto.subtle.sign加 spy,记录每次 sign 的输入字节,验证 §5.1 的 CSV 拼接结构
13. 参考资料¶
- RFC 9420 — Messaging Layer Security (MLS)
- Grubbs et al. — Message Franking via Committing AEAD (2017)
- Juicebox — PIN-protected key recovery
- bundle URL:
https://abs.twimg.com/responsive-web/client-web/xchat-kmp.5e42194a.js(hash 可能随时间变化) - 我们当前 SDK 的 v2 实现:
src/x/dm/client.rs:611-833、src/x/dm/types.rs:131-201、src/x/dm/encoder.rs:88(DEFAULT_MESSAGE_SIGNATURE)
附录 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() ← 错:写死常量