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 once,
when it is created, and keeps the answer for its lifetime; chats created while
a request is in flight share it.
The request carries no credentials and never delays or fails an SDK call.
Until it answers, and for the chat's lifetime if it fails or is blocked, 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 makes it and
hands the chat the answer (set_feature_switches,
chat_xdk_set_feature_switches), and 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.