All articles

Relay

The Envelope: Minimizing What the Relay Must See

6 min read

We cannot blind the relay completely. To decide whether to make a phone ring like a call or nudge it silently in the background, it has to read something about the message. The whole game is making that "something" as coarse and uninformative as physics allows — and then proving the relay can't cheat to learn more.

The last two articles hid the sender. This one is about the header: the small set of routing and handling fields the relay genuinely needs, and the discipline of keeping that set from becoming a metadata channel.


1. Why the relay needs to read anything at all

An incoming voice call must make the phone ring — full-screen, override silent mode, wake the CPU hard — within a second, or the feature is broken. A "someone reacted to your post" should, at most, nudge quietly, and often shouldn't wake the phone at all. A background sync between your own devices should be invisible. These are wildly different handling decisions, and the relay is the thing that has to make them, because it is the thing deciding whether and how to spend the one scarce, Apple-mediated resource: a wake.

So the relay must read how to handle each message. The danger is that "how to handle" usually correlates tightly with "what kind of message this is," and the kind of message is exactly the metadata we are trying to hide. If the header said type = reaction or type = typing_indicator or type = call_offer, the relay would learn the texture of every conversation — who reacts to whom, who is typing, when calls happen — without opening a single seal. Message type is a leak, and precise timing is another.


2. Two headers: a public coarse one, a sealed fine one

The design splits the header in two.

Inside the seal rides the true type, one of 27 fine-grained kindsmessage, reaction, post, call_offer, sync_request, sync_delta, device_list, huddle turns, receipts, and so on. Only Bob's phone, after decrypting, ever sees this. It is what the app actually acts on.

On the outside, where the relay reads it, rides a single coarse field: the WakeClass, with just six values:

WakeClass Handling Example inner types (hidden)
voip ring hard, never suppressed a call offer
alert user-visible notification a direct message
sync wake quietly for device sync a sync request
ack tiny, non-waking receipt a delivery/read receipt
call_tail never wakes (call teardown) hang-up housekeeping
silent never wakes on its own almost everything else

The last row is where the privacy lives. The mapping from 27 fine types to 6 coarse classes is a fixed, many-to-one projection, and it is built so that most types collapse into silent: posts, reactions, huddle turns, sync deltas, device-list changes — all of it lands on the same coarse value, indistinguishable from one another to the relay. The relay learns "this is a background thing, don't wake for it" and nothing more granular. It cannot tell a reaction from a post from a group update, because on the wire they are the identical class.

This is exactly the pattern modern protocols converged on. In MLS (the group-messaging standard, RFC 9420), the encrypted PrivateMessage hides the content type from the server; in TLS, Encrypted Client Hello hides which site you're connecting to behind a shared front. The relay's WakeClass is the same idea: reveal the minimum the transport needs to function, seal the rest.

The timestamp gets the same treatment: the created_at the relay sees is rounded to a 5-minute bucket, so it can enforce message expiry without learning the precise instant you hit send — blurring the fine timing that traffic analysis feeds on.


3. The attack you have to think about: a lying relay

Splitting the header raises an immediate question. The WakeClass is outside the seal, so the relay — or anyone who tampers with the envelope in flight — can change it. What stops a malicious relay from rewriting silent to voip to make your phone ring at 3am, or, more insidiously, using the WakeClass as a covert channel?

Here is the elegant part: the recipient does not trust the outer WakeClass — it re-derives it. After decrypting, Bob's phone reads the true inner type, computes what the WakeClass should be (the same fixed projection), and checks it against the outer value the relay used. If they disagree, the message is rejected outright.

Follow the consequences. The outer WakeClass can only ever affect how the phone was woken — a physical, best-effort hint. It can never affect what the message is or whether it is accepted, because that is governed by the sealed inner type Bob verifies. So the worst a malicious relay can do by lying about the class is degrade the notification — fail to ring for a real call, or ring for something that shouldn't — which is a denial of service the user notices, not a confidentiality break. The relay cannot learn anything by lying (the class it invents tells it nothing it didn't already send), and it cannot inject anything (the recipient re-checks). The confidentiality of the type rests entirely on the seal, and the seal is not the relay's to open.

This "the untrusted field is only a hint, and the trusted field is re-derived and checked" shape is worth internalizing — it recurs whenever you must expose a routing hint to an adversary without letting the hint become authority.


4. A machine-checked guarantee

Claims like "two different message types are indistinguishable on the wire" are exactly the kind of thing that is easy to assert and hard to actually have. So this one is not just asserted — it is proved by machine. The property "two messages that share a WakeClass are indistinguishable to the relay" (that the envelope leaks nothing beyond the coarse class) is stated and checked in the formal corpus, in both a symbolic model (ProVerif) and a computational one (EasyCrypt). The point of the projection isn't just to feel private; it is to be a theorem about what the outer bytes reveal.


5. The honest residual

The relay is not blind, and we don't pretend it is. From the envelope it still sees:

  • the recipient routing key (to) — unavoidable; the message has to go somewhere,
  • the coarse WakeClass — six buckets, and yes, a voip is distinguishable from a silent. A ringing call is inherently different from a background sync, and no amount of cleverness hides "a call is happening" from the machine that has to make the phone ring. What is hidden is everything finer: which of the many silent things this is, whether a message is a reaction or a document, the exact type.
  • a 5-minute-bucketed time — enough for expiry, too coarse for precise timing analysis,
  • and the per-conversation ticket and token from the previous articles.

So the residual is: the relay knows a message of some coarse urgency class is going to this recipient in this five-minute window. That is the irreducible minimum for a transport that must route and must wake — and every finer fact about the message is sealed, verified end to end, and provably absent from what the relay reads.

Next we take away the last thing you might assume a relay has: a place to keep the message. A Relay That Holds Nothing.


References & further reading

  • MLS, RFC 9420PrivateMessage hides content type from the delivery service; the direct analogue of WakeClass.
  • TLS, "Encrypted Client Hello" and the ECH design — hiding the fine routing target behind a coarse public one.
  • "Traffic analysis" — why message type and precise timing are worth hiding at all.
  • Sealing the Envelope — the exact wire layout of the inner and outer headers.