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¶
Idempotent. Errors: 400 (self-DM).
List all DM channels¶
Fetch one DM channel¶
Leave a DM channel¶
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 deviceratchet_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 afterdevice_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¶
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¶
Sender only. Fires MESSAGE_DELETE on the gateway.
Session Lifecycle¶
First message to a new contact¶
GET /api/keys/bundle/{recipientUserID}— fetches all their devices' public keys (consumes one OTP per device)- Verify SPK signature for each bundle:
Ed25519.verify(identity_key, signed_prekey, signed_prekey_signature). Abort if invalid — never proceed with an unverified bundle. - Perform X3DH for each device → derive shared secret → initialize Double Ratchet sender session
- Encrypt the message once per recipient device, producing one ciphertext per device
- Also encrypt for your own other devices and produce a self-encrypted copy
- 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):
GET /api/keys/bundle/{recipientUserID}→ one bundle per device (each with OTP consumed)GET /api/keys/bundle/{myUserID}→ your other devices (excluding current)- For each bundle: check for an existing ratchet session or start X3DH
- Encrypt to each device separately → one entry in
device_ciphertextsper device - 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:
- Match against
device_ciphertextsfor yourdevice_id - If this is from yourself: use self-decrypt
- If from another user with
x3dh_header: X3DH receive → establish session → decrypt - Otherwise: ratchet-decrypt with existing session