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.
Design Goals
Section titled “Design Goals”- Server ignorance: Servers learn nothing about message content, type, or participants
- End-to-end encryption: Only recipients can decrypt message content
- Metadata privacy: Delivery addresses are the only visible routing information
Canonical Serialization
Section titled “Canonical Serialization”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.
Encoding Rules
Section titled “Encoding Rules”- 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
Optional Values
Section titled “Optional Values”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>-0x00marks a ContactRequestAddressRotation.old_addresses: Option<Vec<DeliveryAddress>>-0x00marks 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.
Signed Byte Ranges
Section titled “Signed Byte Ranges”A signature covers the serialization of every field preceding it, and never itself.
ContactRequest.signature covers:
request_id || sender || timestamp || user_identity || devicesi.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 || bodyEach 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.
HPKE Additional Authenticated Data
Section titled “HPKE Additional Authenticated Data”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.
Server View: CryptidEnvelope
Section titled “Server View: CryptidEnvelope”When a message is sent, servers only ever see the outer envelope:
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>,}What Servers Learn
Section titled “What Servers Learn”recipient_address: The routing stringprefix@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.
Server Limitations
Section titled “Server Limitations”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
Client View: InnerEnvelope
Section titled “Client View: InnerEnvelope”When the recipient device receives the message, it:
- Decrypts the outer layer using mailbox private key for the address it arrived at
- Extracts the InnerEnvelope
- Uses the group_id to route to the correct MLS group
- Decrypts the MLS ciphertext to get the actual message
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:
- Creates a new DirectMessageGroup with deterministic ID (Blake3 of sorted user IDs)
- Stores the sender’s UserIdentity in the contact store
- Adds the sender’s devices to the new group
- Establishes the MLS group state via MLSWelcome
Message Type Classification
Section titled “Message Type Classification”Inside the MLS ciphertext (inside InnerEnvelope), messages are distinguished by the MessageType enum:
enum MessageType { // Application-level messages (user content) CryptidMessage,
// Protocol/MLS operations (group state changes) SystemOperation,
// Contact establishment (new contacts/DM groups) ContactRequest,}Message Fan-Out
Section titled “Message Fan-Out”MLS encrypts once for the whole group; the outer layer encrypts once per recipient device.
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.
Failover
Section titled “Failover”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
Security Properties
Section titled “Security Properties”Against Servers
Section titled “Against Servers”- 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
Against Network Observers
Section titled “Against Network Observers”- Ephemeral delivery addresses prevent long-term tracking
- Message type and content hidden
- Fixed-size envelopes possible
Against Compromised Recipients
Section titled “Against Compromised Recipients”- MLS provides forward secrecy and post compromise security
- Individual devices can be removed
Encryption Layers
Section titled “Encryption Layers”Layer 1: Mailbox Encryption (Outer)
Section titled “Layer 1: Mailbox Encryption (Outer)”- 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.
Layer 2: MLS Encryption (Inner)
Section titled “Layer 2: MLS Encryption (Inner)”- Algorithm: MLS cipher suite (ChaCha20-Poly1305)
- Key: MLS group key derived from group state
- Purpose: Group confidentiality and authentication
- Who sees: All group members
Delivery Address Structure
Section titled “Delivery Address 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]) }}Not Server Responsibilities
Section titled “Not Server Responsibilities”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.
Future Considerations
Section titled “Future Considerations”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.