Encryption SDK for X Chat (encrypted Direct Messages). A single Rust core
(chat-xdk-core) handles key management, message encryption, signing, and the
Thrift wire protocol, exposed through idiomatic bindings for six languages.
| Language | Import | Backed by | Guide |
|---|---|---|---|
| Rust | chat-xdk-core |
— | docs/API.md |
| Python | chat_xdk |
PyO3 | crates/pyo3/README.md |
| JavaScript / WASM | @xdevplatform/chat-xdk |
wasm-bindgen | crates/wasm/js/README.md |
| Go | github.com/xdevplatform/chat-xdk/go/chatxdk |
cgo | go/chatxdk/README.md |
| .NET | ChatXdk |
P/Invoke | crates/dotnet/README.md |
| JVM | com.x.chatxdk |
JNA | crates/jvm/README.md |
The fastest way in: examples/ has one small, clone-and-run
encrypted chat bot per binding. Each imports the real binding, talks to the
X Chat API, and shows the full flow (key load → decrypt → reply → encrypt →
send). Start there:
cd examples && make test-all # offline crypto tests for every binding (no creds)See examples/README.md for setup, getting an access
token, and running a bot live.
Every binding follows the same shape (names are camelCase in JS, snake_case in Python/Rust, PascalCase in .NET/Go):
- Load keys — import a private-key blob, or recover from Juicebox (below).
- Set identity —
setIdentity({ userId, signingKeyVersion })once per instance, wheresigningKeyVersionis thepublic_key_versionreturned when your keys were registered; every signed action defaults its sender and signing-key version to these values. OptionallysetCacheKeys(true)andsetSigningKeys(signingKeys)so the decrypt/encrypt calls below need no per-call key plumbing. - Initial load —
decryptEvents({events}): batch-decrypts a backlog, extracting conversation keys fromKeyChangeevents and matching signing keys by user automatically. Per-event errors are collected in the result; only invalid params throw. The events endpoint returns theKeyChangeevents inmeta.conversation_key_events, separate fromdata— pass both together. - Each new event —
decryptEvent({eventB64, conversationKeys}): decrypts one event (webhook / poll). Throws on failure. - Reply —
encryptMessage({conversationId, text})orencryptReply({conversationId, text, replyToEvent})→ POST the result to the X Chat API. - Files — a message with files is built first: start each upload, list
its media hash key in the message's attachments, then
encryptFile({file, eventB64, mediaHashKey})with the payload'seventB64(plusconversationKeyandconversationKeyVersionunless the key cache holds them, as for the message).decryptFileopens a received file from its message's event. A file no message carries, such as a group's avatar image under the conversation key, goes throughencryptFileWithKey({file, key})anddecryptFileWithKey({encrypted, key}).
| Method | Use case |
|---|---|
decryptEvents |
Batch (initial load, pagination). Self-extracts keys; collects errors. |
decryptEvent |
Single event (webhook, real-time) with cached keys. Throws on error. |
The conversation id is part of the message signature. The SDK signs it in its canonical (colon-separated) form, so you can pass either the colon or the hyphen form used in REST URL paths. Every derived value (sender, signing-key version, conversation key) still takes an explicit per-call override.
from chat_xdk import (
Chat,
DecryptEventParams,
DecryptEventsParams,
EncryptMessageParams,
ImportKeysParams,
SetIdentityParams,
)
chat = Chat()
chat.import_keys(ImportKeysParams(private_key_blob, signing_key_version=key_version)) # or Chat(config).unlock(pin)
chat.set_identity(SetIdentityParams(my_user_id, key_version))
chat.set_cache_keys(True)
chat.set_signing_keys(signing_keys)
result = chat.decrypt_events(DecryptEventsParams(raw_events))
for dm in result["events"]:
if dm["event"]["type"] == "Message":
print(dm["event"]["sender_id"], dm["event"]["content"]["text"])
event = chat.decrypt_event(DecryptEventParams(new_event_b64, {}))
payload = chat.encrypt_message(EncryptMessageParams(conversation_id, "hello")) # POST to the X APIimport { createChat } from "@xdevplatform/chat-xdk";
const chat = await createChat({ juiceboxConfig, getAuthToken });
await chat.unlock("2580");
chat.setIdentity({ userId: myUserId, signingKeyVersion });
chat.setCacheKeys(true);
chat.setSigningKeys(signingKeys);
const result = chat.decryptEvents({ events: rawEvents });
for (const dm of result.events) {
if (dm.event.type === "message") console.log(dm.event.senderId, dm.event.content.text);
}
const event = chat.decryptEvent({ eventB64: newEventB64 });
const payload = chat.encryptMessage({ conversationId, text: "hello" });Full per-language API (params + return types) is in docs/API.md;
runnable code for all six is in examples/.
Private keys can be held two ways:
- Local blob —
exportKeys()/importKeys()move raw private-key bytes yourself, in every binding. Simplest; the bytes are unencrypted identity material — never commit them or log them, and zero them once stored. - Juicebox (production) —
generate_keypairs()creates the keys, thensetup(pin)stores the generated keys in Juicebox distributed PIN-protected storage;unlock(pin)recovers them on any device, so nothing secret touches disk. The Juicebox config comes from the X API public-keys response. See the optional path inexamples/python.
Registering your public key with the X API is a separate, one-time provisioning step in both modes.
The SDK makes one kind of request itself: an anonymous
GET https://api.x.com/2/chat/features, which reads the X API's feature
switches. They pick the format the SDK sends in. Each chat reads them when
it is created, again once set_identity gives it a sender, and again 5
minutes after each answer, so a change reaches a running chat without a
restart. The sender's request carries its bucket, the last four digits of
the user id (?bucket=1234), so every chat of one sender gets the same
answer; before that, the X API buckets the request by its address. Chats
that read the same URL share one request.
The request carries no credentials and never delays or fails an SDK call.
One that fails is retried in the background, 1 second later, then after
twice the previous wait, at most 5 minutes apart. Each answer replaces the
one before; until an answer for the chat's URL arrives, every switch stays
off and sends stay in v0, the format 0.5.x sends. The Rust crate and the C
ABI never make the request: a Rust app or a C caller requests the URL
feature_switches_url (chat_xdk_feature_switches_url) returns, again every
5 minutes, and hands the chat each answer (set_feature_switches,
chat_xdk_set_feature_switches); a chat that gets none sends v0.
| Purpose | Algorithm |
|---|---|
| Conversation key exchange | ECIES — ECDH P-256 + AES-128-GCM |
| Message content | XSalsa20-Poly1305 (256-bit key, random 192-bit nonce) |
| Media files | crypto_secretstream_xchacha20poly1305 |
| Message authorship | ECDSA P-256 over the encrypted content |
| Key derivation | HKDF-SHA256 |
Private key types are zeroized on drop (zeroize) and redact to [REDACTED] in
debug output; all randomness comes from the OS CSPRNG. Details and the full
encrypt/decrypt walkthroughs are in docs/CRYPTO.md.
flowchart TD
subgraph bindings["Language bindings"]
py["Python (PyO3)"]
js["JS / WASM"]
go["Go (cgo)"]
net[".NET / JVM (FFI)"]
rs["Rust"]
end
bindings --> core["chat-xdk-core<br/>(ChatCore: keys, crypto, Thrift)"]
core --> jb["Juicebox SDK<br/>(optional key storage)"]
core --> api["X Chat API (your code)"]
ChatCore is the platform-agnostic engine; Chat adds the Juicebox lifecycle.
Bindings are thin FFI wrappers — the crypto lives in one place. Module structure
and data flows: docs/ARCHITECTURE.md.
chat-xdk/
├── crates/
│ ├── core/ # chat-xdk-core: ChatCore engine, crypto, keys, Thrift types
│ ├── pyo3/ # Python binding (chat_xdk)
│ ├── wasm/ # JS/WASM binding (+ js/ wrapper and tests)
│ ├── ffi/ # C ABI (chat_xdk.h): Go's static archive, .NET's and the JVM's shared library
│ ├── dotnet/ # .NET binding (P/Invoke)
│ └── jvm/ # JVM binding (JNA)
├── go/chatxdk/ # Go binding (cgo + prebuilt static archives)
├── examples/ # one clone-and-run bot per binding (+ Makefile, CI)
├── docs/ # API.md, ARCHITECTURE.md, CRYPTO.md
└── tests/ # cross-binding fixtures (sdk_vectors.json)
make build # Rust workspace
make test # Rust tests
make wheel-dev # Python: maturin develop
make wasm # WASM: wasm-pack build --target web
make test-go # Go: build staticlib + go test
make dotnet-test # .NET: native cdylib + xUnit
make jvm-test # JVM: native cdylib + mvn test (JDK 17+)The repo depends on the Juicebox SDK
via a path dependency at ../juicebox-sdk (CI checks it out automatically). For
JS/WASM with Juicebox key storage you also build juicebox-sdk's wasm crate — see
crates/wasm/js/README.md.
- docs/API.md — full API reference for every binding
- docs/ARCHITECTURE.md — modules and data flows
- docs/CRYPTO.md — algorithms, key lifecycle, wire formats
MIT — see LICENSE.
Third-party open-source components and their notices are listed in
THIRD_PARTY_NOTICES.md.