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¶
limit: 1–100, default 50before: 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¶
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):
Receive as TYPING_START event:
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¶
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]".