跳转至

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.pyexamples/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 做跨设备恢复:

  1. 在新设备上用相同 cookies 调 xchat status —— 会发现 enrolled=truelocal_keystore_exists=falsekey_version_synced=false
  2. 新设备需要 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_keyscan_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、AddEncryptedConversationKeysMutation variables 等)仍等 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:HeaderItem × Nsuccess = 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" 感知风险。

参考