Skip to content

Messaging (Server Channels)

Partly out of date

Rendered from ProjectInfo/client/messaging.md on docs/encryption-modes-spec. Its protocol content is still useful, but parts of it describe an older client or an earlier API. Where it disagrees with the code, the code wins — see the spec errata.

Message behaviour depends on the channel's encryption_mode. The client must handle all three modes. For DM messages, see direct-messages.md.

Mode Content you send Content you receive
server_encrypted Plaintext Server decrypts — plaintext returned
rolling_key base64(nonce ∥ ciphertext) Ciphertext — client decrypts with epoch key
peer_provided Encrypted blob Opaque ciphertext — client decrypts with P2P key

Sending a Message

POST /api/channels/{channelID}/messages
Authorization: Bearer <token>

{
  "content": "string",        // plaintext or ciphertext depending on mode
  "epoch_id": "uuid",         // required for rolling_key; omit otherwise
  "reply_to_id": "uuid",      // optional
  "attachment_ids": ["uuid"]  // optional; IDs from completed media uploads
}
→ 201 MessageResponse

Sending in a rolling_key channel

1. GET /api/channels/{channelID}/epoch
   → { epoch_id, epoch_num, encrypted_key }

2. Open epoch key:
   dek = crypto_box_seal_open(encrypted_key, ownIKX25519Pub, ownIKX25519Priv)

3. Encrypt message:
   nonce      = random(24 bytes)
   ciphertext = crypto_secretbox_easy(plaintext_utf8, nonce, dek)
   content    = base64(nonce ∥ ciphertext)

4. POST the message with content + epoch_id

Epoch mismatch (409): The channel was rotated between your epoch fetch and your send. Re-fetch the epoch, re-encrypt with the new key, and retry once.

Sending in a server_encrypted channel

Send plaintext. The server encrypts with Vault KEK transparently.

Sending in a peer_provided channel

Encrypt with the locally held P2P key and send the ciphertext. The epoch_id field is not used.


Fetching Message History

GET /api/channels/{channelID}/messages?limit=50&before={messageID}
→ MessageResponse[]
  • limit: 1–100, default 50
  • before: UUID of the oldest message in the current view (omit for latest page)
  • Results are newest-first within the returned page

For rolling_key channels: batch-decrypt all messages on arrival. Each message carries its own epoch_id. Look up the key for each distinct epoch_id in the batch; fetch from the server any that aren't cached.

MessageResponse shape

{
  id: string;
  channel_id: string;
  author_id: string;
  author_name: string;
  author_avatar: string | null;
  content: string;
  encryption_mode: "server_encrypted" | "rolling_key" | "peer_provided";
  epoch_id: string | null;
  type: number;             // 0 = DEFAULT
  reply_to_id: string | null;
  pinned: boolean;
  edited: boolean;
  edited_at: string | null;
  created_at: string;
  attachments: Attachment[];
}

Editing a Message

PATCH /api/channels/{channelID}/messages/{messageID}
{ "content": "new text" }
→ 200 MessageResponse

For rolling_key channels: encrypt the new content with the current epoch key (same flow as send). Author only, unless the user has PermManageMessages.


Deleting a Message

DELETE /api/channels/{channelID}/messages/{messageID}
→ 204

Soft delete — the message is not returned in future history fetches. Fires MESSAGE_DELETE on the gateway. Author or PermManageMessages.


Rate Limit

Message send: 5 messages per 5 seconds per user.


Typing Indicators

Send via gateway (not REST):

{ "op": "typing", "channel_id": "uuid" }

Receive as TYPING_START event:

{ "channel_id": "uuid", "user_id": "uuid" }

Show a typing indicator for 8 seconds after each event. Refresh the timer on subsequent events from the same user.


Epoch Key Management (rolling_key channels)

Fetching the current epoch key

GET /api/channels/{channelID}/epoch
→ { epoch_id, epoch_num, encrypted_key }

encrypted_key is the 32-byte epoch key sealed with your device's IK X25519 public key via crypto_box_seal. Open it with crypto_box_seal_open:

const dek = sodium.crypto_box_seal_open(
  fromBase64(response.encrypted_key),
  ownIKX25519Pub,    // derive from Ed25519 IK pub
  ownIKX25519Priv    // derive from Ed25519 IK priv
);

Converting Ed25519 IK to X25519:

const x25519Pub  = sodium.crypto_sign_ed25519_pk_to_curve25519(ed25519IKPub);
const x25519Priv = sodium.crypto_sign_ed25519_sk_to_curve25519(ed25519IKPriv);

Store the decrypted key in memory indexed by epoch_id. Keep it for the session lifetime — older epoch keys are needed to decrypt historical messages from that epoch.

Errors: - 400 — channel is not rolling_key - 404 — no epoch exists yet, or no grant for this device yet

Key Rotation

Rotate the epoch key when a member joins or leaves, and when the channel is first created.

1. GET /api/channels/{channelID}  → check current ck_version

2. Generate new epoch key:
   newKey = random(32 bytes)

3. Fetch current member list:
   GET /api/servers/{serverID}/members

4. For each member device:
   a. GET /api/keys/bundle/{userID} (use cached bundles where possible)
   b. ikX25519 = ed25519PubToX25519(device.identity_key)
   c. encryptedKey = base64(crypto_box_seal(newKey, ikX25519))

5. Compute member_hash:
   sorted_ids = sort(all_member_user_ids)
   member_hash = sha256hex(sorted_ids.join(""))

6. POST /api/channels/{channelID}/epoch
   {
     "epoch_num": ck_version + 1,
     "member_hash": member_hash,
     "grants": [{ user_id, device_id, encrypted_key_b64 }, ...]
   }
→ 201 ChannelEpoch

409 Conflict: Another client rotated concurrently. Re-fetch the channel, re-check ck_version, and retry.

422: Your grants don't cover all server members. The server computes its own member_hash and compares. Ensure you include every member.

Initial epoch (new channel)

After creating a rolling_key channel, immediately POST an epoch (epoch_num: 1). The channel is unusable for messaging until the first epoch exists.

Adding grants for a new member

When MEMBER_JOIN fires and you hold the current epoch key, issue a grant for the new member's devices without creating a new epoch:

POST /api/channels/{channelID}/epoch/{epochID}/grants
{
  "grants": [{ user_id, device_id, encrypted_key_b64 }]
}
→ 204

You can only call this if you have an existing grant for epochID (the server verifies this).


Decrypting rolling_key Messages

function decryptMessage(base64Wire: string, epochKey: Uint8Array): string {
  const wire = fromBase64(base64Wire);
  const nonce = wire.slice(0, 24);
  const ciphertext = wire.slice(24);
  const plaintext = sodium.crypto_secretbox_open_easy(ciphertext, nonce, epochKey);
  return new TextDecoder().decode(plaintext);
}

If the epoch key is unavailable (e.g. historical message from before this device joined), display: "[encrypted — key not available]".