Bot-as-Client E2E Contract
Status: Authoritative spec. Implements the bot-as-client model. Audience: the Hivemind bot crypto client. This page is written so a Hivemind developer can build the client without reading chatalot server source.
Model
A bot is a normal users row with is_bot = true, authenticating with a bot token cb_<64 hex>. Under the bot-as-client model the bot runs a real crypto client — it holds its own X25519/Ed25519 keys off-server, registers public keys, establishes X3DH sessions, and participates in per-channel sender-key exchange exactly like a human device. Private keys never leave the bot process, and the server never sees plaintext DM content. Group and community channels are weaker by default, exactly as they are for human devices: in the default configuration the server IS given the sender key's chain key during group key distribution, so group confidentiality from the server is not provided unless the instance has sender_key_v2_enabled turned on. The plaintext boundary is the bot process, which the operator runs — identical trust boundary to a human's device, so E2E is preserved end-to-end.
The client reuses the chatalot-crypto crate natively (Rust, no WASM) — the same primitives (X3DH, Double Ratchet, sender keys, AEAD) the web/desktop clients use through WASM.
There are zero chatalot server changes required for this contract beyond WS bot auth and rotation on bot removal: the key-registration, prekey, bundle, and sender-key routes already accept a bot's X-Bot-Token (they carry no is_bot block). Bots have no keys today only because nothing generates them.
Authentication
- REST: every request carries
X-Bot-Token: cb_<64 hex>. All endpoints below sit behind the unified auth middleware, which acceptsX-Bot-Token(bot) orAuthorization: Bearer <JWT>(human) — never both in one request. - WebSocket: connect to
GET /ws, then send the first frame{"type":"authenticate","token":"cb_<64 hex>"}. On success the server replies{"type":"authenticated","user_id":"<bot-uuid>","server_version":"..."}. The same bot token is used; no JWT needed. Auth must arrive within 10 s or the socket is dropped.
All examples use https://chat.example.com as the instance base; the WS base is wss://chat.example.com.
Wire-format conventions — read this first
These are the non-obvious encodings that will silently break an implementation:
| Convention | Detail |
|---|---|
| Byte fields are JSON number arrays, NOT base64. | Every Vec<u8> (identity_key, public_key, signature, ciphertext, nonce) serializes as a JSON array of integers 0–255, e.g. [12, 255, 0, 9]. Do not base64-encode them. |
chain_id is i64 on the wire |
chatalot-crypto uses u32 internally. Cast at the boundary (values are small; they fit u32). |
distribution is an opaque JSON object |
The sender-key distribution is a serde_json::Value — whatever the chatalot-crypto SenderKeyDistributionMessage serializes to. The server stores and relays it verbatim; both clients must agree on its shape (it is a chatalot-crypto type). |
key_id is i32 |
Prekey identifiers. |
| Timestamps | created_at is an RFC3339 string. |
| Sending messages is WebSocket-only | POST /api/channels/{id}/messages does not exist. GET /api/channels/{id}/messages is history fetch only. To send, use the WS send_message frame (Phase 4). |
Phase 1 — Register identity keys (once per bot)
POST /api/keys/register — body KeyRegistrationRequest:
{
"identity_key": [ /* 32 bytes, X25519 public */ ],
"signed_prekey": {
"key_id": 1,
"public_key": [ /* 32 bytes */ ],
"signature": [ /* 64 bytes, Ed25519 over public_key, signed by the identity key */ ]
},
"one_time_prekeys": [
{ "key_id": 1, "public_key": [ /* 32 bytes */ ] }
/* ... up to 200 per call */
]
}
identity_keymust be exactly 32 bytes;signed_prekey.public_key32;signature64. The server verifies the signed-prekey signature against the identity key and rejects on mismatch (HTTP 400Validation).- The server stores public keys only. Max 200 one-time prekeys (OTPs) per call.
- This endpoint is a full (re)registration: it replaces the identity key, upserts the signed prekey, and (if
one_time_prekeysis non-empty) deletes stale OTPs before inserting the new batch.
Maintenance endpoints (same auth):
| Endpoint | Body | Purpose |
|---|---|---|
POST /api/keys/prekeys/one-time |
[OneTimePrekeyUpload, …] (≤200) |
Replenish OTPs |
POST /api/keys/prekeys/signed |
SignedPrekeyUpload |
Rotate the signed prekey |
GET /api/keys/prekeys/count |
— | { "count": <n> } remaining unused OTPs |
Replenish trigger: when another user fetches your key bundle and your unused OTP count is < 25, the server pushes a WS event {"type":"keys_low","remaining":<n>}. On receipt, generate more OTPs and POST /api/keys/prekeys/one-time.
Phase 2 — X3DH session with a peer
GET /api/keys/{user_id}/bundle → KeyBundleResponse:
{
"identity_key": [ /* 32 */ ],
"signed_prekey": { "key_id": 1, "public_key": [ /* 32 */ ], "signature": [ /* 64 */ ] },
"one_time_prekey": { "key_id": 7, "public_key": [ /* 32 */ ] } /* may be null if exhausted */
}
Feed the bundle to chatalot-crypto's X3DH to derive the pairwise session. one_time_prekey may be null (peer exhausted) — X3DH proceeds without it, per protocol. Fetching a bundle is what triggers the peer's keys_low (above), so it also keeps the network's OTP pools topped up.
Phase 3 — Channel sender keys (group messaging)
chatalot uses Signal-style sender keys per channel. The bot must already be a member of the channel (operator adds it via the admin bot-membership flow; non-members get HTTP 403 / WS forbidden).
Publish your sender key — POST /api/channels/{id}/sender-keys, body UploadSenderKeyRequest:
Returns SenderKeyDistributionResponse and broadcasts sender_key_updated to the channel. Re-POST with a new chain_id to rotate.
Fetch members' sender keys — GET /api/channels/{id}/sender-keys → array of:
{ "id": "<uuid>", "channel_id": "<uuid>", "user_id": "<uuid>",
"chain_id": 1, "distribution": { /* … */ }, "created_at": "2026-05-25T…Z" }
Import each member's distribution into chatalot-crypto so you can decrypt their messages. Do this once on join, then keep it current via the sender_key_updated WS event (below).
Phase 4 — Send and receive messages (WebSocket)
Send — client frame send_message (encrypt the plaintext with your channel sender key first):
{ "type": "send_message",
"channel_id": "<uuid>",
"ciphertext": [ /* bytes */ ],
"nonce": [ /* bytes */ ],
"message_type": "text", /* "text" | "file" | "system" | "webhook" */
"reply_to": null, /* uuid | null */
"sender_key_id": null, /* uuid | null */
"thread_id": null } /* uuid | null */
A non-member send returns {"type":"error","code":"forbidden","message":"not a member of this channel"}.
Receive — server frame new_message:
{ "type": "new_message",
"id": "<uuid>", "channel_id": "<uuid>", "sender_id": "<uuid>",
"ciphertext": [ /* bytes */ ], "nonce": [ /* bytes */ ],
"message_type": "text", "reply_to": null, "sender_key_id": null,
"created_at": "2026-05-25T…Z", "thread_id": null }
Decrypt with the sender's imported sender key (keyed by sender_id / sender_key_id). History (already-encrypted) is available over REST via GET /api/channels/{id}/messages if you need backfill on reconnect.
Phase 5 — Rotation and forward secrecy (must handle)
When a member is removed from a community/group/channel, the server:
1. deletes the removed member's sender-key distributions for the affected channels,
2. revokes the removed member's live WS subscriptions, and
3. broadcasts to the remaining members: {"type":"sender_key_rotation_required","channel_id":"<uuid>","reason":"member_removed"}.
On receipt of sender_key_rotation_required, a remaining bot MUST generate a fresh sender key (new chain_id) and POST /api/channels/{id}/sender-keys (which re-broadcasts sender_key_updated). Until it does, members who rekey can no longer decrypt its messages. The removed party keeps only pre-removal chain seeds and cannot decrypt anything new.
This rotation extends to the bot-removal path specifically, so removing an E2E-capable bot triggers the same forward-secrecy rotation a human removal does. Without it, a removed bot that held keys would keep decrypting future traffic — the gap this contract's rotation handling closes.
End-to-end happy path
POST /api/keys/register(once).- Operator adds the bot to a community/channel.
- Open
wss://…/ws, sendauthenticatewith thecb_token →authenticated. GET /api/channels/{id}/sender-keys, import each distribution.- Generate +
POSTyour own sender key. - Decrypt incoming
new_message; to post, encrypt + send asend_messageframe. - On
sender_key_updatedimport the new distribution; onsender_key_rotation_requiredrotate your own key. - On
keys_lowreplenish OTPs.
Server-enforced invariants / what the server never sees
- Enforces:
X-Bot-Tokenvalidity (active/unrevoked/unexpired,is_bot, not suspended, rate-limit) on every REST + WS auth; channel membership (is_member) on every channel operation; signed-prekey signature; byte-length checks (32/64). - Never sees: plaintext, private keys, or symmetric message keys. The server stores only public keys, opaque sender-key distributions, and ciphertext — identical to its view of a human client.
Out of scope (Hivemind side)
Key generation and secure at-rest storage of the bot's private keys; staying online to receive WS events; reconnect + backfill; mapping crypto identities to agent profiles. None of this is chatalot's concern — chatalot only ever holds the public material and ciphertext described above.