Skip to content

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 accepts X-Bot-Token (bot) or Authorization: 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_key must be exactly 32 bytes; signed_prekey.public_key 32; signature 64. The server verifies the signed-prekey signature against the identity key and rejects on mismatch (HTTP 400 Validation).
  • 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_prekeys is 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}/bundleKeyBundleResponse:

{
  "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 keyPOST /api/channels/{id}/sender-keys, body UploadSenderKeyRequest:

{ "chain_id": 1, "distribution": { /* chatalot-crypto SenderKeyDistributionMessage as JSON */ } }

Returns SenderKeyDistributionResponse and broadcasts sender_key_updated to the channel. Re-POST with a new chain_id to rotate.

Fetch members' sender keysGET /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

  1. POST /api/keys/register (once).
  2. Operator adds the bot to a community/channel (CHAT-160).
  3. Open wss://…/ws, send authenticate with the cb_ token → authenticated.
  4. GET /api/channels/{id}/sender-keys, import each distribution.
  5. Generate + POST your own sender key.
  6. Decrypt incoming new_message; to post, encrypt + send a send_message frame.
  7. On sender_key_updated import the new distribution; on sender_key_rotation_required rotate your own key.
  8. On keys_low replenish OTPs.

Server-enforced invariants / what the server never sees

  • Enforces: X-Bot-Token validity (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