Skip to content

Direct Messages

Partly out of date

Rendered from ProjectInfo/client/direct-messages.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.

DM messages are true E2EE — the server stores only ciphertext blobs and never decrypts. This file covers the API layer and session lifecycle. For the full cryptographic protocol, see e2ee-implementation.md.

DM Channels

Open or find a 1:1 DM

POST /api/dm
{ "user_id": "uuid" }
→ 200 DMChannel (existing) or 201 DMChannel (new)

Idempotent. Errors: 400 (self-DM).

List all DM channels

GET /api/dm
→ DMChannel[]   (ordered by most recent activity)

Fetch one DM channel

GET /api/dm/{channelID}
→ DMChannel

Leave a DM channel

DELETE /api/dm/{channelID}
→ 204

Your departure is private — the channel and messages remain for the other participant.


Sending a DM Message

POST /api/dm/{channelID}/messages
{
  "ciphertext": "base64",
  "ratchet_header": "base64",
  "x3dh_header": "base64 | null",       // only on first message in a new session
  "device_ciphertexts": [
    { "device_id": "string", "ciphertext": "base64" }
  ],
  "reply_to_id": "uuid"                 // optional
}
→ 201 DMMessage
  • ciphertext — Double Ratchet ciphertext for the recipient's current device
  • ratchet_header — base64 of JSON { dh: pubKey, pn: int, n: int }
  • x3dh_header — base64 of JSON { eph, spk_id, otp_id, did, ik } — only on first message; omit after
  • device_ciphertexts — one entry per recipient device + one entry per own other device (for read-back) + one entry for own current device (self-encrypted copy for Sent view)

Fires MESSAGE_CREATE on the gateway for all channel members.


Receiving DM Messages

Fetch history

GET /api/dm/{channelID}/messages?limit=50&before={messageID}
→ DMMessage[]   (newest first)

DMMessage shape

{
  id: string;
  channel_id: string;
  sender_id: string;
  sender_device: string;
  ciphertext: string;             // base64 — recipient's ciphertext
  ratchet_header: string;         // base64
  x3dh_header: string | null;    // base64 — only on session-opening message
  device_ciphertexts: Array<{
    device_id: string;
    ciphertext: string;           // base64
  }>;
  reply_to_id: string | null;
  edited: boolean;
  edited_at: string | null;
  created_at: string;
}

Decryption decision tree

On receiving a DMMessage:

if msg.sender_id === myUserId:
  // This is a sent message (from another device or self-sync)
  // Find own entry in device_ciphertexts:
  selfEntry = msg.device_ciphertexts.find(e => e.device_id === myDeviceId)
  if selfEntry:
    plaintext = selfDecrypt(selfEntry.ciphertext, channelId)
  else:
    plaintext = "[sent from another device — cannot decrypt]"

else:
  // Incoming message from another user
  if msg.x3dh_header !== null:
    // First message in a new session — perform X3DH receive
    session = x3dhReceive(msg)
  else:
    session = loadSession(channelId)

  plaintext = ratchetDecrypt(session, msg.ciphertext, msg.ratchet_header)
  saveSession(channelId, session)

Deleting a DM Message

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

Sender only. Fires MESSAGE_DELETE on the gateway.


Session Lifecycle

First message to a new contact

  1. GET /api/keys/bundle/{recipientUserID} — fetches all their devices' public keys (consumes one OTP per device)
  2. Verify SPK signature for each bundle: Ed25519.verify(identity_key, signed_prekey, signed_prekey_signature). Abort if invalid — never proceed with an unverified bundle.
  3. Perform X3DH for each device → derive shared secret → initialize Double Ratchet sender session
  4. Encrypt the message once per recipient device, producing one ciphertext per device
  5. Also encrypt for your own other devices and produce a self-encrypted copy
  6. POST with all ciphertexts in device_ciphertexts

Subsequent messages

Load the ratchet session from local storage, call ratchetEncrypt, save the updated session.

Receiving the first message from someone

Parse x3dh_header from the message, look up the referenced SPK by spk_id, perform X3DH receive to derive the same shared secret, initialize the receiver ratchet session, decrypt the first message.

Session storage

Persist the full ratchet session state to secure local storage after every encrypt or decrypt. If the session state is lost (e.g. device reset), future messages from that contact cannot be decrypted without a new X3DH exchange (they must send you a new first message).


Multi-Device Delivery

When the recipient has multiple devices (and/or you have multiple devices):

  1. GET /api/keys/bundle/{recipientUserID} → one bundle per device (each with OTP consumed)
  2. GET /api/keys/bundle/{myUserID} → your other devices (excluding current)
  3. For each bundle: check for an existing ratchet session or start X3DH
  4. Encrypt to each device separately → one entry in device_ciphertexts per device
  5. Add self-encrypted copy for the current device's entry

The message body contains ciphertext (for the primary recipient device) and device_ciphertexts covering all devices.


Real-Time Receipt

DM messages arrive via the gateway as MESSAGE_CREATE events on the channel subject. The event includes enough data to display the message, but you must decrypt before rendering. On receipt:

  1. Match against device_ciphertexts for your device_id
  2. If this is from yourself: use self-decrypt
  3. If from another user with x3dh_header: X3DH receive → establish session → decrypt
  4. Otherwise: ratchet-decrypt with existing session