Menu 0.6.0 preview

Reference

Agent Docs: Protocol and wire format

Envelopes and Blobs

Every message one device sends to another travels as an Envelope. The sender seals one Envelope per recipient device, and the relay stores and forwards it as an opaque Blob. The relay reads only what it needs to route the Envelope, and the recipient alone can open the rest.

This page describes the sealed Envelope as the Developer Preview decides it. The Sync-level test vectors fix the exact byte layouts.

What the relay can read

An Envelope carries three things in plaintext:

FieldWhy it’s in plaintext
End-to-End VersionSo the recipient can reject a version before decrypting
Recipient public keySo the relay can put the Envelope in the right Inbox
Sealed payloadThe ephemeral public key and the AEAD ciphertext; opaque to the relay

Nothing else is in plaintext. Protobuf fields the recipient doesn’t know are ignored and never carry meaning; a change that needs a new field bumps the End-to-End Version.

Two sends from the same device share no relay-readable value except the End-to-End Version, and the recipient key when both go to the same device. The relay can still link Envelopes by their size, their timing and which devices receive them together, and it can often tell which device pushed them from the timing of the connection, which the server it runs on can match to an IP address. Each of those is a documented limitation, listed on How it works.

What’s sealed inside

The sealed plaintext holds:

  • author_pub, the sender’s Ed25519 signing key;
  • the sequence, the sender’s counter, one higher for each send() and the same in every recipient’s copy;
  • the parent hashes;
  • the Sender Timestamp, in Unix milliseconds;
  • the message type and its body;
  • the signature.

Because the sequence, author_pub and the signature are sealed, the relay can’t use them to link a sender’s Envelopes or to test which device signed one.

Addressed encryption

The sender encrypts each Envelope for one recipient using only that recipient’s public key, with no session between them.

  1. Generate a fresh X25519 keypair for this Envelope only.
  2. Run X25519 between the fresh private key and the recipient’s X25519 public key.
  3. Derive a symmetric key from the result with HKDF-SHA256. The HKDF info binds the End-to-End Version, the fresh public key and the recipient’s public key.
  4. Encrypt the sealed plaintext with ChaCha20-Poly1305. The associated data binds the same three values.
  5. Send the fresh public key and the ciphertext. Throw the fresh private key away.

The recipient runs X25519 between its own private key and the fresh public key, derives the same key, and decrypts. A relay that moves the ciphertext to another recipient, or swaps the fresh public key, makes decryption fail.

The recipient’s key is long-lived, so this layer has no forward secrecy. Anyone who later steals a device’s private key can decrypt every Envelope addressed to that device that they recorded. The Noise sessions to the relay do have forward secrecy, but it covers only the connection. An Envelope recorded on the relay or the network can still be opened later with the recipient’s key.

Signature

The sender signs a domain-separated input with its Ed25519 key. The input covers the End-to-End Version, the recipient public key, the fresh public key and every sealed field except the signature itself.

After decrypting, the recipient checks that:

  1. The signature verifies against the author_pub found inside the payload.
  2. author_pub is allowed to send this message type. A Sync needs a current member. A GroupManifest needs a current member, or the token’s key during a Pending Join. A Revoke needs any device in any Group Manifest the recipient has held. A Pair comes from a device that isn’t a member yet, so it needs proof of the Pairing Secret while the pairing window is open.
  3. The Sender Timestamp is inside the Replay Window.
  4. For a Sync message, the Message ID isn’t one it has already handled.

An Envelope that fails is acknowledged, dropped and reported on the recipient as a delivery issue. A group member who gets hold of an Envelope addressed to someone else can’t tell who sent it, because the signature is sealed to the recipient.

Message types

Inside the sealed plaintext, a message type says what the body is. The relay never sees it.

TypeBodyHandled by
PairThe joining device’s keys and a proof that it knows the Pairing SecretThe library, on the admitting device
SyncThe message body the app sentThe app’s message handler
GroupManifestA signed Group ManifestThe library
RevokeEnds the group, as Destroy Group describesThe library

A device leaving a group sends a GroupManifest that leaves it out. Every type, a Group Manifest at the Maximum Group Size included, has to fit in one frame.

Message ID

The Message ID identifies one message from one sender. Every delivery of it, and every copy re-sealed after a retry or an upgrade, has the same Message ID. It is derived from author_pub and the sequence, both read from inside the sealed payload:

"tsm1_" + base64url_nopad(SHA-256("trueseal-message-id-v1\0" || author_pub[32] || sequence_u64_be))

Apps treat it as an opaque string and never parse or rebuild it. The relay’s blob_id is a different number and never reaches the app.

Replay Window

A recipient drops any message whose Sender Timestamp is more than 60 days older than its own clock, acknowledges it so the relay deletes it, and reports unreadable(expired). A timestamp in the future is accepted, so a sender whose clock runs fast still gets through.

A recipient keeps each handled Message ID until 60 days after its Sender Timestamp or after the time it handled the message, whichever is later. A copy of a Sync message can’t reach the handler after its Message ID has been forgotten, whatever the relay does with it.

The Sender Timestamp is set differently by type:

  • A Sync message is stamped once, at send(). Every re-seal keeps that timestamp, so every copy ages out of the window together.
  • A control message (Pair, GroupManifest, Revoke) is stamped each time it’s sealed, because it never expires from the outbox. Replaying an old control message changes nothing. An old manifest loses to the current one, a Pair for a closed pairing window is dropped, and a Revoke for a destroyed group finds a fresh identity.

The window is 60 days because a Sync message can wait up to 30 days in the sender’s outbox and then up to 30 days on the relay. That’s why the relay refuses to start with a TTL over 30 days.

Where today’s code differs

Today’s Envelope has a plaintext sequence, parent hashes and signature, and the signature covers only the sequence, parents, recipient key and ciphertext. The format carries no End-to-End Version, the encryption binds neither key, there is no Sender Timestamp, and nothing enforces a Replay Window. The sealed format above is decided in sync ADR-0031 and isn’t built yet.