Skip to content

Wire Format

Cryptid uses a double-encrypted envelope design that provides strong privacy guarantees. This document specifies the wire format — what servers see, what clients see, and how messages are protected.

  1. Server ignorance: Servers learn nothing about message content, type, or participants
  2. End-to-end encryption: Only recipients can decrypt message content
  3. Metadata privacy: Delivery addresses are the only visible routing information

Every structure in this specification is encoded using the TLS presentation language (RFC 8446) as implemented by tls_codec. MLS already uses this encoding, so KeyPackages, Welcomes, and Commits arrive in the same format, this way an implementation just needs one codec and not two.

This matters beyond convenience. ContactRequest and federated requests carry signatures, and a signature verifies only if both parties serialize the signed bytes identically.

  • Integers are big-endian and fixed-width (uint8, uint16, uint32, uint64)
  • Fixed-length byte arrays are written raw, with no length prefix; [u8; 32] is exactly 32 bytes
  • Variable-length vectors use the MLS <V> variable-length prefix (RFC 9420), followed by contents
  • Structs serialize their fields in declaration order, with no padding and no field names
  • Strings are UTF-8 with a <V> length prefix, and MUST NOT be normalized or case-folded

TLS presentation language has no native optional type. Option<T> is encoded as MLS optional<T>: a single uint8 presence flag - 0x00 absent, 0x01 present - followed by the value when present.

Two structures on the wire depend on this:

  • InnerEnvelope.group_id: Option<GroupId> - 0x00 marks a ContactRequest
  • AddressRotation.old_addresses: Option<Vec<DeliveryAddress>> - 0x00 marks a first announcement

Implementations MUST NOT substitute a zero-length vector for an absent value. The two are distinct, and conflating them changes the meaning of both messages above.

A signature covers the serialization of every field preceding it, and never itself.

ContactRequest.signature covers:

request_id || sender || timestamp || user_identity || devices

i.e., the struct serialized with the signature field omitted.

Federation X-Origin-Signature covers a canonical request:

"cryptid-federation-v1" || method || path || origin_server || transaction_id || body

Each component length-prefixed as a <V> string, with body being the exact bytes transmitted. The leading domain separator prevents a signature produced in one context from verifying in another.

The outer envelope seals with the routing string as AAD: the exact UTF-8 bytes of prefix@server, with no length prefix and no trailing null. These are the same bytes that appear in CryptidEnvelope.recipient_address.

When a message is sent, servers only ever see the outer envelope:

CryptidEnvelope Structure
struct CryptidEnvelope {
// Routing string only, "prefix@server". The server needs nothing more.
// Mailbox public key is verified once at registration, not per message.
recipient_address: String,
// HPKE Base mode (ephemeral-static) to the recipient's mailbox public key,
// with recipient_address bound as AAD so a captured envelope cannot be
// re-injected at a different mailbox. No sender key is involved, so the
// sender stays anonymous even if the mailbox key is compromised.
encrypted_blob: Vec<u8>,
}
  • recipient_address: The routing string prefix@server, where to deliver
  • Nothing else: No message type, no sender, no group, no content
  • No timestamp. The envelope carries no timestamp; servers stamp arrival time themselves for queue ordering and expiry, and that stamp never leaves the server’s own state
  • Servers MUST assign receipt timestamps themselves and MUST NOT accept a sender-supplied timestamp on an envelope.

Servers cannot:

  • Know what type of message it is (CryptidMessage, SystemOperation, ContactRequest)
  • Know who sent the message
  • Know which group it belongs to
  • Read or modify the message content
  • Correlate messages to specific users or groups from envelope contents
  • Track communication patterns beyond delivery addresses

When the recipient device receives the message, it:

  1. Decrypts the outer layer using mailbox private key for the address it arrived at
  2. Extracts the InnerEnvelope
  3. Uses the group_id to route to the correct MLS group
  4. Decrypts the MLS ciphertext to get the actual message
InnerEnvelope Structure
struct InnerEnvelope {
// Sender's device (from MLS authenticated data)
sender_device_id: DeviceId,
// Group ID (None for ContactRequest)
group_id: Option<GroupId>,
// MLS-encrypted message content
mls_ciphertext: Vec<u8>,
}

Handling group_id = None:

When group_id is None, this indicates a ContactRequest (first contact). The client:

  1. Creates a new DirectMessageGroup with deterministic ID (Blake3 of sorted user IDs)
  2. Stores the sender’s UserIdentity in the contact store
  3. Adds the sender’s devices to the new group
  4. Establishes the MLS group state via MLSWelcome

Inside the MLS ciphertext (inside InnerEnvelope), messages are distinguished by the MessageType enum:

MessageType Enum
enum MessageType {
// Application-level messages (user content)
CryptidMessage,
// Protocol/MLS operations (group state changes)
SystemOperation,
// Contact establishment (new contacts/DM groups)
ContactRequest,
}

MLS encrypts once for the whole group; the outer layer encrypts once per recipient device.

Send Path
fn send(group: &MlsGroup, msg: MessageInner, book: &AddressBook) -> Vec<Outbound> {
// One MLS ciphertext, identical bytes for every member
let mls_ciphertext = group.encrypt(serialize(msg));
let inner = InnerEnvelope {
sender_device_id: my_device_id(),
group_id: Some(group.id()),
mls_ciphertext,
};
let inner_bytes = serialize(inner);
group.member_devices()
.filter(|d| *d != my_device_id())
.map(|device| {
// Ordered by the owner. Index 0 is the primary and
// the rest are failover targets, not additional recipients
let addrs = book.addresses(device, group.id());
seal_for(&addrs[0], &inner_bytes)
})
.collect()
}
fn seal_for(addr: &DeliveryAddress, inner_bytes: &[u8]) -> Outbound {
Outbound {
server: addr.server.clone(),
envelope: CryptidEnvelope {
recipient_address: addr.full_address(),
encrypted_blob: hpke_seal(
addr.mailbox_public,
inner_bytes,
aad = addr.full_address().as_bytes(),
)
}
}
}

Outbound envelopes are grouped by server; the home server delivers locally and federates the rest.

On receipt, the server reports which mailbox an envelope arrived at, so the client resolves the correct key directly rather than trial-decrypting its whole keyset.

A device MAY hold several mailboxes for one group on different servers, published in preference order via AddressRotation.

Failover is a delivery-time behavior, not a fan-out one. A sender seals one envelope per delivery attempt, never one per mailbox - a secondary address is a fallback, not an extra recipient.

  • Senders MUST attempt the primary address first
  • Senders SHOULD fail over to the next address on connection failure, timeout, or a hard rejection such as an unknown mailbox
  • Senders MUST NOT fail over on transient conditions such as rate limiting or server overload, and SHOULD retry the primary instead
  • Each attempt requires re-sealing, since both the AAD and the mailbox key differ per address
  • Servers cannot read message content
  • Servers cannot determine message type
  • Server cannot identify sender
  • Server cannot determine group membership from message content or envelope structure
  • Ephemeral delivery addresses prevent long-term tracking
  • Message type and content hidden
  • Fixed-size envelopes possible
  • MLS provides forward secrecy and post compromise security
  • Individual devices can be removed
  • Algorithm: HPKE Base mode (ephemeral-static X25519)
  • Key: The recipient’s per-group mailbox public key
  • AAD: The routing string prefix@server, so a captured envelope cannot be re-injected at a different mailbox
  • Purpose: Deliver the envelope to one mailbox without revealing the sender
  • Who sees: Only the device holding that mailbox secret

The sender’s own keys are never involved in this layer. A fully compromised mailbox key therefore reveals a routing label and the envelope contents, but nothing that identifies who sent it.

HPKE provides no replay protection. A captured envelope can be redelivered and will open again. Replay is instead rejected at Layer 2: MLS consumes and deletes per-generation message keys, so a replayed message fails to decrypt. The exception is ContactRequest, which has no group state yet and therefore carries its own anti-replay.

  • Algorithm: MLS cipher suite (ChaCha20-Poly1305)
  • Key: MLS group key derived from group state
  • Purpose: Group confidentiality and authentication
  • Who sees: All group members
DeliveryAddress Structure
struct DeliveryAddress {
// Base32 identifier, 24 chars, derived from mailbox_public
prefix: String,
// Server domain (e.g., "chat.example.com")
server: String,
// Senders encrypt the outer envelope layer to this key. Reaches contacts
// inside MLS via AddressRotation, never via server.
mailbox_public: X25519PublicKey,
// Timestamp of when the address was generated
created_at: u64,
// Kept live through the rotation overlap window, so a contact who missed an
// AddressRotation still reaches a real mailbox
active: bool,
}
impl DeliveryAddress {
fn full_address(&self) -> String {
format!("{}@{}", self.prefix, self.server)
}
// Anyone holding the address can check if the prefix binds to the key
fn verify_binding(&self) -> bool {
let hash = blake3::hash(self.mailbox_public.as_bytes());
self.prefix == base32::encode(&hash.as_bytes()[..15])
}
}

The server is intentionally limited:

  • Does NOT store group state
  • Does NOT track message history
  • Does NOT know group membership
  • Does NOT verify message authenticity
  • Does NOT enforce permissions
  • Does NOT validate message content

The server is a dumb pipe: receive blob, deliver blob, forget.

The wire format may evolve to add:

  • Padding: Fixed-size envelopes to prevent traffic analysis
  • Padding schemes: Constant-time padding algorithms
  • Additional metadata: For advanced routing scenarios

Any changes will maintain the core property: servers learn nothing about message content.