One Rust core (chat-xdk-core) plus thin bindings for Python, JavaScript/WASM,
Go, .NET, JVM. Three tenets govern every change: it is a cryptography
library, not a bot SDK; all bindings stay at parity; the public API stays
small and uniform. The rest of this file is the invariants that keep those true.
This SDK turns plaintext + keys into encrypted blobs + signatures, and back. It
does not do networking, messaging, or application logic. Per
crates/core/src/lib.rs and
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md §1), the SDK does not own:
- HTTP / REST calls to
/2/chat/*— the caller's code - webhook servers, polling loops, retries
- message persistence, ordering, and replay dedup (the caller dedups on the
signed
messageId;sequence_id/created_atare unsigned backend metadata) - key persistence
- OAuth / token management
Do not add an HTTP client, a transport, a "bot" helper, or anything that knows
about endpoints to any crate under crates/. That code belongs in the caller
(see examples/, which are demos — not part of the shipped SDK). If a task seems
to need networking inside the core, it is in the wrong layer.
chat-xdk-core is the single source of truth. Bindings marshal arguments
and delegate; they must not reimplement crypto, key handling, or protocol
logic. A change that lands in one binding but not the others is incomplete.
When you add or change SDK behavior:
- Implement it once in
crates/core(ChatCore, orChatfor Juicebox). - Surface it in every binding with matching behavior and signatures:
Python
crates/pyo3, JS/WASMcrates/wasm, Gocrates/go→go/chatxdk, .NET + JVM (both over thechat_xdk_dotnetcdylib incrates/dotnet,crates/jvm). - Use each language's idiom for naming only:
snake_case(Rust/Python),camelCase(JS/Java),PascalCasemethods (.NET/Go). - Update the per-binding tables in
docs/API.mdin lockstep. - Extend the parallel per-binding test suites so the new surface is covered everywhere (see §5).
Keep the public surface small and uniform. The flow every binding exposes:
key management (import/export, setup/unlock), conversation keys
(prepareConversationKeyChange / decryptConversationKey), encryptMessage/reply,
and two decrypt paths whose contracts are identical across bindings:
decryptEvents(events, signingKeys)— batch (initial load / pagination). Self-extracts conversation keys fromKeyChangeevents, never throws (per-event errors collected in the result).decryptEvent(event, conversationKeys, signingKeys)— single event with pre-cached keys. Throws on failure.
Don't add speculative configurability or one-off helpers.
- Verification is not optional.
decryptEventrejects unverified events by default; a below-floor key version must never verify; an invalid signature must never yield plaintext. Verify against caller-supplied signing keys — never a key carried inside the event. Verification covers every signed event type, not just messages. Signature mismatches are bugs to investigate, not checks to silence. - Key downgrade protection is automatic. Conversation-key versions move
forward only (monotonic high-water mark held for the
Chatlifetime); the ordering authority is the signed key version, not backend sequence numbers. Never accept an older version over a newer one, and never add an API that can lower the floor. - Wire formats are frozen. The bytes the SDK produces and consumes stay
compatible with other X Chat clients and with previously stored ciphertext and
keys. Do not change a wire format — nonce sizes, AAD, HKDF
info, key derivation, secretbox/secretstream layout, the signature payload, or JuiceboxUserInfo— even when the change looks more standards-correct; it breaks interop and decryption of stored data. A format change is valid only as a versioned, dual-read migration (e.g. a newsignature_version). - All wire parsing is bounded and runs before any crypto. Untrusted Thrift
goes through the bounded reader (
crates/core/src/protocol/safe_reader.rs): reject negative/oversized lengths and container counts, never panic on unknown union variants or field IDs. Don't route parsing around it, relax the caps, or replace the pinned patchedthriftfork with upstream. New parsing paths get a fuzz target. - Known protocol limitations are documented, not re-litigated. No forward
secrecy / PCS, message encryption without context-binding AAD, and
single-shared-key group conversations are intentional protocol-level
constraints recorded in
docs/CRYPTO.md(Known Limitations). Don't "fix" them in a binding or claim guarantees the protocol doesn't provide; closing them requires a versioned protocol change. - No forward secrecy / no post-compromise security — by protocol design. Don't claim otherwise in code or docs.
- Private key material is zeroized on drop and redacts to
[REDACTED]. Never log, print, persist, or commit raw private keys,.envfiles, or key blobs. - Crypto suite: ECIES (ECDH P-256 + AES-128-GCM) for conversation keys;
XSalsa20-Poly1305 for messages;
crypto_secretstream_xchacha20poly1305for media; ECDSA P-256 signatures; HKDF-SHA256. Seedocs/CRYPTO.md.
- Drive the real binding — never mock the SDK. A passing test must fail if the shipped code breaks: no hardcoded expected values, no re-implementing the code under test.
- Use
tests/fixtures/sdk_vectors.jsonfor deterministic cross-binding checks. Keep the parallel suites (crates/*/tests,crates/wasm/js/tests,go/chatxdk/*_test.go) in parity. make ci(fmt-check + clippy-D warnings+ Rust workspace tests) must pass before pushing..github/workflows/ci.ymladditionally runs the binding suites (Python, JS/WASM, Go, JVM, .NET); runmake test-sdks,make jvm-test, andmake dotnet-testto match it locally.
- Determinism comes from inputs, not ambient state. Any core function whose
result derives from the clock (e.g. timestamp-string key versions) exposes a
*_with_versionvariant taking the value as an argument.wasm32-unknown-unknownhas no system clock and panics onSystemTime::now(), so the WASM wrapper must call the explicit-version variant; native bindings may read the clock. conversation_idis signed in its canonical colon form (A:B). The core normalizes before signing; only I/O code may rehyphenate toA-Bfor URL paths. Never sign or verify the hyphen form.
crates/core/src/thrift/*.rsare generated bymake codegenfromthrift/*.thrift. Do not hand-edit — change the schema and regenerate.- Committed Go static libs (
go/chatxdk/libs/<os>_<arch>/*.a) go stale after anycrates/corechange; regenerate (make prebuilt-all) or call it out in the PR. CI rebuilds Python_native*.soand WASMpkg/fresh; those are gitignored.
- Comments explain this code's intent and gotchas — not lineage, tickets, ports, or "mirrors X" (see the code-comments skill). No commented-out code.
- Minimal complexity: no error handling for impossible states, no abstractions the task doesn't require.
juicebox_sdkis a path dependency at../juicebox-sdk(CI checks outjuicebox-systems/juicebox-sdkas a sibling).- One release version for every binding. The canonical version is
[workspace.package] versionin the rootCargo.toml;scripts/version.sh setstamps it into the npm/PyPI/NuGet/Maven manifests in lockstep, and the release workflow publishes all registries (and the Go module tags) from that single value. Never bump or publish a binding out of step with the others. - Registry auth is OIDC trusted publishing wherever the registry supports it (PyPI, npm, NuGet, crates.io); no long-lived publish tokens. Maven Central is the exception (Central Portal user token) until Sonatype ships OIDC.
crates/core/ chat-xdk-core: engine, crypto/, keys/, thrift/ (generated)
crates/pyo3/ Python crates/wasm/ WASM (+ js/ wrapper)
crates/go/ cdylib → go/chatxdk crates/dotnet/ + crates/jvm/ (over chat_xdk_dotnet)
docs/ API.md, ARCHITECTURE.md, CRYPTO.md
examples/ clone-and-run demo bots (NOT shipped SDK code)
tests/fixtures/ sdk_vectors.json — cross-binding vectors