Wire Format
This page gives the byte layout of everything a device and a relay send each other, as the Developer Preview decides it. The Protocol page explains the sessions these bytes travel in. The Sync-level test vectors are the final word on exact bytes; where this page and the vectors differ, the vectors win.
Connection start
Before the Noise handshake, the device sends a short plaintext prefix with its Transport Version. The same prefix is bound into the Noise prologue, so a connection that tampers with it fails the handshake.
A relay that doesn’t speak that Transport Version replies with the plaintext unsupported min..max, naming the versions it speaks, and closes the connection. In the preview min equals max.
Noise framing
Every Noise message on the TCP connection, the handshake messages included, is a 2-byte big-endian length followed by that many bytes of ciphertext. A Noise message is at most 65,535 bytes. Its last 16 bytes are the authentication tag, so it carries at most 65,519 bytes of plaintext.
Each Noise message carries exactly one TrueSeal frame. A frame is never split across Noise messages.
Frames
Every frame has the same layout:
[type: u8][len: u32 BE][body: len bytes]
A frame is at least 5 bytes, with an empty body. The relay answers a frame of unknown type with an Error frame and a generic code, and echoes none of the bytes it received.
| Type | Name | Sent by | Session | Body |
|---|---|---|---|---|
0x01 | Push | Device | Push | [recipient_pub: 32][envelope] |
0x02 | Deliver | Relay | Receive | [blob_id: u64 BE][envelope] |
0x03 | Heartbeat | Device, echoed by the relay | Receive | Empty |
0x04 | Ack | Relay | Push | Empty |
0x05 | Error | Relay | Push, or either for an unknown frame | A reason code |
0x06 | DeliverAck | Device | Receive | [blob_id: u64 BE] |
Push
[recipient_pub: 32 bytes][envelope: bytes]
recipient_pub is the recipient device’s X25519 public key. The relay reads these 32 bytes to choose the Inbox and never parses the rest. envelope is the sealed Envelope, stored and later delivered unchanged. A body shorter than 32 bytes is malformed.
Ack
The relay sends Ack once it has written the Envelope to the recipient’s Inbox and synced it to disk. It tells the sender that the relay holds the message. The recipient may still be offline. A Push Session can carry several Push frames, and the relay answers each in order with one Ack or one Error.
Error
The relay sends Error instead of Ack when it refuses a Push. It stores nothing, and the session stays open for the next Push. The body is a reason code that says why and carries no content:
| Reason | Kind | What the device does |
|---|---|---|
| Too large | Permanent | Removes the message and reports it as failed |
| Malformed | Permanent | Removes the message and reports it as failed |
| Inbox full | Temporary | Keeps the message and retries with backoff |
| Rate limited | Temporary | Keeps the message and retries with backoff |
| Unavailable or internal error | Temporary | Keeps the message and retries with backoff |
A message refused as too large is reported as sendFailed(messageId, tooLarge), and one refused as malformed as sendFailed(messageId, malformed). Delivery issues says what your app does with each.
Deliver
[blob_id: u64 BE][envelope: bytes]
blob_id is a number the relay picks for this message. The device must not interpret it and only sends it back in DeliverAck. It is not the Message ID, and an app never sees it. envelope is the bytes from the Push, unchanged.
The relay sends each message once per Receive Session, in the order it arrived in the Inbox.
DeliverAck
[blob_id: u64 BE]
The device sends DeliverAck after the app’s handler has finished with the message, or once the library has decided it can’t use the message. It doesn’t send one for a message in a newer End-to-End Version, which waits on the relay until the app upgrades. Once 100 Blobs per device are waiting, it acknowledges and drops the oldest. The relay deletes the message only if blob_id belongs to the Inbox of the device that sent the DeliverAck.
DeliverAck carries no outcome. The relay can’t tell a handled message from a dropped one. It can see how long the handler took, because the ack waits for it.
Heartbeat
The device sends Heartbeat on its Receive Session every 25 seconds, and the relay echoes it. The relay closes a Receive Session after 75 seconds with no frame from the device.
Size limits
Every message fits in one frame. The public number is the Protocol Size Limit: 61,440 bytes (60 KiB) of Sync body, the bytes an app passes to send(). The other limits follow from the Noise framing:
| Limit | Value | Where it comes from |
|---|---|---|
| Noise message | 65,535 bytes | The 2-byte length prefix |
| Frame | 65,519 bytes | The Noise message minus its 16-byte tag |
| Envelope | about 65,482 bytes | The frame minus the 5-byte header and the 32-byte recipient_pub |
Sync body | 61,440 bytes (60 KiB) | Set below the Envelope limit, so Envelope fields can grow without changing it |
send() checks the Protocol Size Limit before anything is written to the outbox, and fails with payloadTooLarge{max}. The relay checks the Envelope size again on every Push and refuses an oversize one as too large. An Operator may set a lower Relay Size Limit but never a higher one, and a relay configured above the protocol ceiling refuses to start. Every message type, a full Group Manifest at the Maximum Group Size included, has to fit in one frame.
Test vectors
Two sets of test vectors fix the bytes.
- Noise. trueseal-noise checks one
Noise_XX_25519_ChaChaPoly_BLAKE2sand oneNoise_NK_25519_ChaChaPoly_BLAKE2svector from the cacophony set, kept intestdata/cacophony.json, byte for byte, intests/cacophony.rs. - Sync. JSON vectors cover the signing inputs, addressed encryption, the Message ID, every frame, the version prefix, and the rejection cases. The Rust core produces and checks them, the relay checks them in its Go tests, and each SDK checks them through its bindings. A release that fails them doesn’t ship.
Where today’s code differs
The code in the sibling repos still implements the format from before the preview decisions. The differences, each tracked by an open issue:
- There is no version prefix and no
unsupportedreply (trueseal-relay#42, trueseal-sync#93). - The
Errorbody is empty, with no reason code, and the relay drops an unknown or malformed frame without replying (trueseal-relay#42). send()checks a far larger envelope limit (MAX_ENVELOPE_BYTES) instead of the Protocol Size Limit, so it accepts a message the relay will refuse, and the device retries it forever (trueseal-sync#100). The relay accepts a configured size limit above the protocol ceiling (trueseal-relay#45).- The device echoes the relay’s heartbeats but doesn’t send its own every 25 seconds (trueseal-sync#108).
- The device sends
DeliverAckas soon as aDeliverframe arrives, before decrypting it (trueseal-sync#97). - The relay resends every message in the Inbox each time a new one arrives, instead of once per session (trueseal-relay#44).
- The relay deletes a message on any
DeliverAckwith itsblob_id, without checking which Inbox it belongs to (trueseal-relay#38). - The Envelope still carries the sequence, parent hashes and signature in plaintext (trueseal-sync#104). Envelopes and Blobs describes the sealed format that replaces it.
- The Sync test vectors don’t exist yet (trueseal-sync#94).