Bot-as-Client E2E Contract
Status: Authoritative spec. Implements ADR-002 Option B (bot-as-client) (
adr-002-chatalot-bot-e2e-messaging). Audience: the Hivemind bot crypto client (HIVE-55/56/57). This page is written so a Hivemind developer can build the client without reading chatalot server source. Tickets: ADR-002 · CHAT-160 (bot membership, shipped) · CHAT-162 (WS bot auth, shipped) · CHAT-163 (bot-removal rotation) · CHAT-165 (key-registration verification).
Model
A bot is a normal users row with is_bot = true, authenticating with a bot token cb_<64 hex> (CHAT-10). Under Option B 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. The server never sees plaintext or any private key. 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 CHAT-162 (WS bot auth) and CHAT-163 (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":"..."}(CHAT-162). 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 CHAT-160; 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.
CHAT-163 extends this rotation 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 (CHAT-160).
- 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 — HIVE-55/56/57)
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.
References
- ADR-002 — E2E messaging for chatalot bots (
adr-002-chatalot-bot-e2e-messaging) - WebSocket protocol · Crypto implementation · API reference
- AI workspace setup runbook