XChat enrollment — 设备注册指南¶
XChat 是 X (Twitter) 在 2025 年正式推出的端到端加密私信协议(替代原有 DM)。 本 SDK 自 v2.0.0 起提供完整 Rust / Python / CLI 三种接入方式。
协议概览¶
| 维度 | 说明 |
|---|---|
| 密钥协商 | 每设备一对 ECDH P-256 keypair(用于派生对话密钥) |
| 消息签名 | 每设备一对 ECDSA P-256 keypair(签发 identity_public_key_signature + 消息签名) |
| 对称加密 | AES-GCM 256(Phase 3 落地) |
| 密钥备份 | Juicebox v0.3.4 协议,PIN 派生加密,3 realms 分片存储(recover_threshold=2) |
| 注册方式 | CustomPin —— 用户自己选 PIN |
前置条件¶
- 一个已登录的 Twitter 账号 cookies(
ct0+auth_token+twid) - 至少 4 字符的 PIN(任意 unicode 字符串)
流程图¶
本地 X 服务端 Juicebox realms
│
├─ 1) generate ECDH + ECDSA keypair
├─ 2) sign(ECDH_pub_SPKI) → identity_signature
│
├─ 3) AddXChatPublicKey ─────────────► 记录 user_id 的两个公钥 + signature
│ 签发 key_version
│ 签发 3 个 realm 的 JWT token
│ ◄──────── token_map JSON ──────────┤
│
├─ 4) Juicebox register(PIN, secret) ─┐
│ ├──► realm-b.x.com (软件 realm)
│ ├──► realm-east1.x.com (Noise NK)
│ └──► realm-west1.x.com (Noise NK)
│
├─ 5) 写本地 keystore.json (0600)
│
▼
完成 enroll,account.can_dm_on_xchat = true
任何一步失败,SDK 都会尝试回滚到「未 enroll」状态;只有第 4 步若 SDK 同时回滚服务端公钥失败,会返回
XChatError::EnrollRollbackFailed —— 此时需要手动调 delete-account 清理。
三种接入方式¶
A. Rust¶
use x_api_rs::{Twitter, XChatService};
# async fn run() -> Result<(), Box<dyn std::error::Error>> {
let cookies = std::env::var("TWITTER_COOKIES")?;
let pin = std::env::var("XCHAT_PIN")?;
let twitter = Twitter::create(cookies, None, true).await?
.with_xchat_keystore("/path/to/keys.json".into());
if let Some(xchat) = twitter.xchat() {
let result = xchat.enroll(&pin).await?;
println!("enrolled at key_version={}", result.key_version);
}
# Ok(()) }
B. Python¶
import os, asyncio
from x_api_rs import Twitter
async def main():
cookies = os.environ["TWITTER_COOKIES"]
pin = os.environ["XCHAT_PIN"]
client = await Twitter.create(cookies)
xchat = client.xchat("/path/to/keys.json") # 方法,不是属性
result = await xchat.enroll(pin)
print(f"enrolled at key_version={result.key_version}")
asyncio.run(main())
完整示例:examples/python/xchat/enroll.py、
examples/python/xchat/status.py。
C. CLI¶
# 设置 cookies + PIN 环境变量(强烈推荐而非命令行明文)
export TWITTER_COOKIES="ct0=...; auth_token=...; twid=u%3D123"
export XCHAT_PIN="8527"
# 首次启用
twitter-cli xchat enroll \
--pin "$XCHAT_PIN" \
--keystore ~/.config/x-api-rs/xchat.json
# 查询状态
twitter-cli xchat status --keystore ~/.config/x-api-rs/xchat.json
# 修改 PIN(用独立环境变量名避免冲突)
export XCHAT_OLD_PIN="8527"
export XCHAT_NEW_PIN="1234"
twitter-cli xchat reset-pin \
--old-pin "$XCHAT_OLD_PIN" \
--new-pin "$XCHAT_NEW_PIN" \
--keystore ~/.config/x-api-rs/xchat.json
# 注销(不可逆,需独立 XCHAT_CONFIRM_PIN + --yes)
export XCHAT_CONFIRM_PIN="$XCHAT_OLD_PIN"
twitter-cli xchat delete-account \
--pin "$XCHAT_CONFIRM_PIN" \
--keystore ~/.config/x-api-rs/xchat.json \
--yes
所有 --pin / --old-pin / --new-pin 都标记 hide_env_values=true,
不会出现在 --help 输出里。
PIN 安全准则¶
| ✅ 推荐 | ❌ 反模式 |
|---|---|
| 通过环境变量传入 | 写死在代码 / git 里 |
用 XCHAT_CONFIRM_PIN(独立变量)做 delete-account |
export 一次 XCHAT_PIN 复用到 delete-account |
| 4-6 位数字 PIN(与官方 X 客户端一致) | 太短(< 4)或字典词 |
| 跨设备同账号用相同 PIN | 不同设备同账号用不同 PIN(互相不可恢复) |
密钥文件 (keystore.json)¶
{
"version": 1,
"user_id": "1973098228334960640",
"key_version": "1778485658072",
"registration_method": "CustomPin",
"created_at_ms": 1778485658000,
"ecdh_public_spki_b64": "MFkw...",
"ecdh_private_pkcs8_b64": "MIGH...", // 敏感,0600
"signing_public_spki_b64": "MFkw...",
"signing_private_pkcs8_b64":"MIGH...", // 敏感,0600
"identity_signature_b64": "IHQv..."
}
- Unix 上自动
0600权限,原子写入(temp + rename) - Windows 上没有 ACL 保护——请放到只有当前用户可读的目录
- 字段含 PKCS#8 私钥,视同账号凭据——不要 commit 进 git,不要日志输出
跨设备恢复¶
XChat 用 Juicebox PIN-protected secret store 做跨设备恢复:
- 在新设备上用相同 cookies 调
xchat status—— 会发现enrolled=true、local_keystore_exists=false、key_version_synced=false - 新设备需要 Phase 3 recover 流程(v2.0.0 暂未实现)—— 当前 v2.0.0 Phase 2 仅支持「单设备 enroll + 使用」,跨设备恢复留 Phase 3+
错误码(CLI)¶
| 错误码 | 退出码 | 含义 | 推荐处理 |
|---|---|---|---|
AUTH_FAILED |
4 | cookies 失效 / PIN 错 / 未 enroll | 重新登录或 enroll |
DUPLICATE |
9 | 已 enroll | 先 delete-account 再 enroll |
NETWORK |
5 | Juicebox / HTTP 通信失败 | 检查代理 / 重试 |
RATE_LIMIT |
6 | realm 速率限制 | 等待后重试 |
SERVER |
7 | 服务端状态与本地不一致 | 调 xchat status 排查 |
UNKNOWN |
1 | 复合错误(如 EnrollRollbackFailed)/ 解码失败 |
看 message + 调 delete-account --yes 清理 |
INVALID_ARGS |
2 | PIN 为空 / 未传 --yes |
修正参数 |
CONFIG_PARSE_ERROR |
12 | keystore 文件损坏 | 删除文件重新 enroll |
Phase 3 — 发送与接收消息¶
enroll 完成后即可走完整 send/receive 流程。三种接入方式(Rust / Python / CLI)的 API 都基于同一组业务方法。
发送消息(send_message)¶
收件人状态决定 wire 路径,SDK 自动选路(has_keys 和 can_dm_on_xchat 两个独立判定,2026-05-12 抓包确认):
has_keys |
can_dm_on_xchat(sender 视角) |
wire 行为 | XChatSendResult.warning |
|---|---|---|---|
| 否 | — | 明文 Thrift + ECDSA 签名(与官方 web 同场景一致) | "recipient_not_enrolled" |
| 是 | false | 同上,明文兜底 | "recipient_disabled_xchat" ⚠️ |
| 是 | true | AES-GCM-256 加密 + ECDSA 签名 | "encryption_implemented_unverified" ⚠️ |
can_dm_on_xchat 是双边字段:sender 视角看 recipient 是否「当前接受我的加密 DM」。不等于「对方是否已 enroll」。即便双方都 enrolled,社交图/隐私设置仍可能让此字段为 false → 走明文兜底。
⚠️ 加密路径目前仍是推测式实现:Twitter web 端不暴露启动加密 chat 的 UI 入口(composer textbox 始终标 "Unencrypted message",无 lock toggle)。2026-05-12 浏览器抓包确认:即便双方都 enrolled,web 客户端仍只发送明文 SendMessage。已矫正的 wire 细节包括 AES-GCM IV=16B(不是 12B 默认值);其余 ~12 处
TODO(phase3.x-verify)(HKDF salt/info、wrap AAD、AddEncryptedConversationKeysMutationvariables 等)仍等 iOS/Android 抓包矫正。当前加密路径可以发送(服务端接受 + 返回 event_id),但对端能否解密未知。
Rust¶
use x_api_rs::{Twitter, XChatService};
let twitter = Twitter::create(cookies, None, true).await?
.with_xchat_keystore("/path/to/keys.json".into());
if let Some(xchat) = twitter.xchat() {
let result = xchat.send_message("1234567890", "hello e2e").await?;
println!("event_id={:?} warning={:?}", result.event_id, result.warning);
// 批量统一文案
let batch = xchat.send_batch(
vec!["111".into(), "222".into()],
"broadcast",
).await?;
println!("成功 {}/{}", batch.success, batch.total);
// 批量自定义文案
let batch = xchat.send_batch_with_custom_texts(
vec!["111".into(), "222".into()],
vec!["Hi Alice".into(), "Hi Bob".into()],
).await?;
}
Python¶
result = await xchat.send_message("1234567890", "hello e2e")
print(result.event_id, result.warning)
batch = await xchat.send_batch(["111", "222"], "broadcast")
print(f"{batch.success}/{batch.total}")
batch = await xchat.send_batch_with_custom_texts(
["111", "222"], ["Hi Alice", "Hi Bob"]
)
参数校验:text.trim().is_empty() 抛 ValueError,每条消息最长 10000 字符(业务层 validate_message_input)。
CLI¶
twitter-cli xchat send \
--user-id 1234567890 --text "hello e2e" \
--keystore ~/.config/x-api-rs/xchat.json
twitter-cli xchat send-batch \
--user-ids 111,222,333 --text "broadcast" \
--keystore ~/.config/x-api-rs/xchat.json
# 文案文件每行严格对应一个 user_id;空行/纯空白会被业务层拒绝
twitter-cli xchat send-batch-custom \
--user-ids 111,222 --texts-file ./texts.txt \
--keystore ~/.config/x-api-rs/xchat.json
接收消息(receive_messages)¶
从单个对话拉取最近的消息,自动解码、解密(如果加密)、验签。
容错策略:解密失败 / 验签失败 / conv_key miss 等都不抛异常,转为返回值上的 warning + Option 字段,调用方按消息维度处理。
DecryptedMessage 字段语义¶
| 字段 | 含义 |
|---|---|
event_id |
服务端事件 ID |
message_id |
客户端生成的 UUID 或纯数字 ID |
sender_id / conversation_id / timestamp_ms / sequence_id |
元数据 |
text: Option<String> |
文本内容;解密/解码失败时 None |
encrypted: bool |
是否走了加密路径 |
signature_valid: Option<bool> |
Some(true) 验签通过;Some(false) 验签失败;None 无法验签 |
warning: Option<String> |
见下表 |
warning 取值¶
| 取值 | 含义 |
|---|---|
"conversation_key_unknown" |
无本地对话密钥(先发一条触发协商) |
"decrypt_failed" |
AES-GCM 解密失败 |
"signature_input_unknown" |
inner_payload 字节形态不明,跳过验签 |
"sender_not_enrolled" |
拉不到发送方公钥,无法验签 |
Rust¶
let page = xchat.receive_messages("984485658860208128:1973098228334960640", 20).await?;
for msg in &page.messages {
println!(
"[{:?}] {} -> {}: {:?} (sig_valid={:?})",
msg.sequence_id, msg.sender_id, msg.conversation_id,
msg.text, msg.signature_valid,
);
}
Python¶
page = await xchat.receive_messages("984485658860208128:1973098228334960640", limit=20)
for msg in page.messages:
print(msg.sender_id, msg.text, msg.signature_valid, msg.warning)
print("has_more:", page.has_more)
CLI¶
twitter-cli xchat receive \
--conv-id 984485658860208128:1973098228334960640 \
--limit 20 \
--keystore ~/.config/x-api-rs/xchat.json
输出走三段式 BatchLine:Header → Item × N(success = warning is None)→ Summary。
Phase 3 已知限制 & 范围声明¶
v2.0.0 范围:web 端协议完整支持,已通过 2026-05-12 真实抓包矫正可见的 wire 细节。
已在 web 抓包验证 ✅(无 TODO 残留):
- AES-GCM IV = 16B
- can_dm_on_xchat 双边语义
- encoded_message_create_event trailer 字段(f102/f105/f106)
- 签名 CSV 内层字节来源(inner sub-struct)
- 签名 envelope Thrift 字段顺序
- participants 列表仅含 sender
Web 端已知限制(影响有限):
| 限制 | 影响 |
|---|---|
| 加密 wire 协议层细节未对账 | 双方都 enrolled 时 SDK 会发加密 SendMessage,服务端返回 event_id,但对端能否解密未在真实环境验证 |
| 接收侧验签字节选取(40B vs 63B) | 当前仅在 inner_payload.len() == 63 时尝试验签;其他形态返回 signature_valid=None(保守不误判) |
明确不在 v2.0.0 范围(不阻碍 web 端使用):
| 项目 | 原因 |
|---|---|
| iOS/Android 加密 wire 字节级矫正 | Twitter web 客户端不暴露启动加密 chat 入口(composer 始终标 "Unencrypted message"),无法通过 web 抓到真正的加密 SendMessage payload。剩余 ~10 处 TODO(phase3.x-verify)(HKDF salt/info、wrap AAD、AddEncryptedConversationKeysMutation variables、ttl_msec、key rotation 等)需要 iOS/Android 装机 + mitmproxy 证书 + xchat-kmp 反混淆,工作量大且依赖物理设备,留作 v2.1+ 待办 |
群组消息事件、ConversationKeyChangeEvent、媒体附件 |
Phase 4 |
源码中的
TODO(phase3.x-verify)注释保留作为未来矫正点的明确标记;调用方可通过XChatSendResult.warning == "encryption_implemented_unverified"感知风险。
参考¶
- 协议逆向:
xchat-e2e-reverse-engineering.md - 实施笔记:
xchat-implementation.md - Juicebox spike:
xchat-phase2-juicebox-spike.md - Juicebox 白皮书:https://juicebox.xyz/assets/whitepapers/juiceboxprotocol_revision7_20230807.pdf