buenomini

Messages from the future

My sync protocol keeps messages it can't read yet, so anyone could fill a device's inbox with junk labelled version 99.

I was deciding how trueseal-sync handles protocol versions since this is a big need for a sync protocol. Devices don't upgrade at the same time. Someone updates the app on their phone, the laptop is still on the old one, and now the phone sends messages the laptop can't read.

TrueSeal is store and forward. Device A pushes an encrypted blob to the relay, and the relay keeps it in device B's inbox until B connects and acknowledges it. A can't ask B which version it speaks, B might be offline for a week. So there's no negotiation at all. Each device speaks one version, and the version is bound into the crypto (key derivation, AEAD associated data, signing input), which means a message from another version doesn't even decrypt.

That leaves what B does with a message from a newer version than it knows. I saw three options:

  1. Drop it. Acknowledge it so the relay deletes it, and move on. The person who updates late loses those messages.
  2. Refuse it explicitly. From B's side that's dropping with an error attached, so the message is still gone.
  3. Hold it. Don't acknowledge it, so it stays on the relay. The relay resends the whole unacknowledged inbox on every connect until the messages expire, and once B upgrades they decrypt and nothing is lost.

Older or malformed messages get dropped, that one was easy. For newer ones I went with 3.

# Anyone can send version 99

Push in TrueSeal is unauthenticated on purpose. Anyone who knows a device's public key can push to its inbox, and the relay doesn't check who's sending (to check that it would have to know who trusts who, and that's the thing it shouldn't know). Normally that's fine because junk doesn't decrypt, the device drops it, and the relay has quotas for floods.

A message from a newer version doesn't decrypt either tho. B can't check a format it doesn't know, so junk labelled "version 99" looks the same as a real message from the future, and B keeps all of it, resent on every connect until it expires.

# The errors were fine

What got me looking at this area in the first place was errors. For unknown frame types the plan was that the relay answers with an explicit error, and my reply was "only if it does not open an attack vector for being explicit at what is failing or something like that". I had padding oracles in the back of my head, where telling an attacker why something failed is all they need.

An oracle is an error the attacker can trigger over and over, where the answer depends on something secret (the padding of a plaintext they can't read, in that case). None of these errors depend on anything secret. The relay's error only goes back to the connection that sent the bad frame, inside its encrypted session. It's a generic code with no echoed bytes, and the relay is open source, so "unknown frame type" is public knowledge anyway. A device never sends an error to another device. When it drops or holds something it raises an event that stays inside the app on that device, and whoever sent the message gets nothing back.

# A cap

A device now holds at most 100 newer-version messages it can't read

pub const MAX_HELD_BLOBS: usize = 100;

Past that, the oldest held one gets acknowledged and dropped, and the app gets a droppedWhileHeld event so it knows. The relay's per-inbox quotas (10,000 blobs or 256 MiB) are the second limit. A real late upgrader with more than 100 messages from the future loses the oldest ones. I'm ok with that, at least for the preview.

The relay can still see which messages a device leaves unacknowledged, so it can guess which version that device runs. I didn't fix that one. It's documented as a metadata limitation, next to the size and timing stuff the relay already sees.

Magic number? yep, but there is still too much to do and I always can come back to this.