This document describes the on-wire message formats used by Murmur.
Messages are wrapped in three layers:
- Application payload (plaintext before encryption)
- Protocol message (Double Ratchet output, JSON)
- Server envelope (blob + signature)
Current payload JSON:
{
"text": "Hello there",
"profileSecretKey": "base64url-no-padding",
"attachments": {
"report.pdf": {
"hash": "sha256-hex-of-encrypted-bytes",
"iv": "base64",
"key": "base64"
}
}
}Notes:
textis required.profileSecretKeyis the sender's profile secret key (base64url, no padding), included so a recipient can resolve the sender profile if not already known.attachmentsis optional. Each entry maps a filename (no path) to the AES-GCM parameters for the encrypted attachment bytes:hash: SHA-256 of the encrypted bytes (hex).iv: AES-GCM IV (base64).key: AES-GCM key (base64).
- Legacy payloads may be a raw UTF-8 string; if JSON parsing fails, the client treats the plaintext as text.
The application payload is encrypted via Double Ratchet. The resulting protocol message is JSON-encoded and then base64-encoded for transport.
Protocol messages always use type: "message". Pre-key fields live under
init when establishing a new session.
{
"type": "message",
"init": {
"ephemeralKey": "base64",
"preKey": "base64",
"oneTimePreKey": "base64"
},
"attachments": {
"sha256-hex": "base64(encrypted-bytes)"
},
"ratchetKey": "base64",
"previousChainLength": 0,
"messageNumber": 0,
"ciphertext": "base64"
}{
"type": "message",
"attachments": {
"sha256-hex": "base64(encrypted-bytes)"
},
"ratchetKey": "base64",
"previousChainLength": 0,
"messageNumber": 0,
"ciphertext": "base64"
}Field details:
init.ephemeralKey: X25519 public key (base64).- The sender identity DH public key is derived by the receiver from the sender identity signing key (
ed25519.utils.toMontgomery). init.preKey: recipient prekey public key used for X3DH (required for pre-key messages).init.oneTimePreKey: recipient one-time prekey public key used for X3DH (optional).- The presence of
initindicates a pre-key message. ratchetKey: current Double Ratchet public key (X25519, base64).previousChainLength,messageNumber: chain counters for the Double Ratchet.ciphertext: base64-encoded ChaCha20-Poly1305 ciphertext + auth tag.attachments: optional map ofsha256(encrypted_bytes)(hex) to the AES-GCM encrypted bytes (base64). Filenames are only present inside the encrypted payload.
- Fetch recipient prekey bundle from server and verify the bundle signature.
- The receiver will derive the sender identity DH key from the sender Ed25519 public key.
- Run X3DH (sender side) using:
- Alice identity DH keypair (derived from her Ed25519 identity key)
- Bob prekey (from bundle)
- Optional Bob one-time prekey (from bundle)
- Initialize Double Ratchet (Alice) with the X3DH shared secret and Bob's prekey public key.
- Ratchet encrypt the plaintext to get:
- Header fields:
ratchetKey,previousChainLength,messageNumber - Ciphertext bytes
- Header fields:
- Build protocol message JSON with:
type: "message"initcontainingephemeralKey,preKey, and optionaloneTimePreKey(from bundle)attachmentsmap (if any)ratchetKey,previousChainLength,messageNumberciphertext(base64)
- Base64-encode the JSON to create the server
blob. - Sign
blobBytes || messageIdByteswith Alice's Ed25519 identity key.
- Verify the server signature over
blobBytes || messageIdBytesusing the sender identity key. - Decode and parse the protocol message JSON from the
blob. - Look up
init.preKey(and optionalinit.oneTimePreKey) in the local key store to find the matching private keys. - Derive Alice identity DH public key from her Ed25519 public key.
- Run X3DH (receiver side) using:
- Bob identity DH keypair
- Bob prekey private key
- Optional Bob one-time prekey private key
- Alice derived identity DH key and
init.ephemeralKey
- Initialize Double Ratchet (Bob) with the X3DH shared secret and Bob's prekey keypair.
- Build the internal header from
ratchetKey,previousChainLength,messageNumber. - Ratchet decrypt the ciphertext to recover plaintext.
- Use the existing Double Ratchet session for the recipient.
- Ratchet encrypt the plaintext to get:
ratchetKey,previousChainLength,messageNumber- Ciphertext bytes
- Build protocol message JSON with:
type: "message"attachmentsmap (if any)ratchetKey,previousChainLength,messageNumberciphertext(base64)
- Base64-encode the JSON to create the server
blob. - Sign
blobBytes || messageIdByteswith the sender identity key.
- Verify the server signature over
blobBytes || messageIdBytesusing the sender identity key. - Decode and parse the protocol message JSON from the
blob. - Use the existing Double Ratchet session for the sender.
- Build the internal header from
ratchetKey,previousChainLength,messageNumber. - Ratchet decrypt the ciphertext to recover plaintext.
The header is 44 bytes:
- 32 bytes: DH ratchet public key (X25519)
- 4 bytes: previous chain length (uint32, big-endian)
- 4 bytes: message number (uint32, big-endian)
- 4 bytes: reserved (zero)
The header bytes are derived from the ratchet fields and authenticated as associated data for the ciphertext.
Ciphertext is produced by ChaCha20-Poly1305. The 12-byte nonce is derived from the message key via HKDF-SHA-256, so no nonce is transmitted.
To send, the client builds:
{
"messageId": "cuid",
"recipientId": "base64-identity-key",
"blob": "base64(protocol_message_json)",
"signature": "base64"
}Signature:
- Sign Ed25519 over:
blobBytes || messageIdBytes blobBytes: base64-decodedblobmessageIdBytes: UTF-8 bytes of the message ID- Signature is base64-encoded
Inbox messages returned by the server include:
{
"id": "messageId",
"senderId": "base64-identity-key",
"blob": "base64(protocol_message_json)",
"signature": "base64",
"createdAt": 1710000000000,
"expiresAt": 1712592000000
}