The protocol

This is what a feeder, relay or client must speak to interoperate. The reference is the wire crate in the Tally repository, the only code there that encodes or decodes the protocol, and every binary uses it. Integers are big-endian throughout.

Transport

Every hop is Noise_IK_25519_ChaChaPoly_BLAKE2s over TCP. Each Noise message is sent behind a two-byte length, at most 65,535 bytes with its tag, and carries whole records only.

Identity is one Ed25519 key. The X25519 key Noise authenticates is derived from it (libsodium’s sk_to_curve25519), and the initiator’s first handshake message carries its 32-byte Ed25519 public key, which the responder checks against the key the handshake proved. One key names an antenna everywhere.

The preamble

Before the handshake the initiator sends 16 plaintext bytes. They are also the Noise prologue, so a changed byte fails the handshake, and they let the handshake itself change later, and a relay hold an old and a new key while it rotates.

BytesFieldMeaning
0–3MagicThe ASCII letters TALY.
4Preamble version1.
5Handshake1: the Noise pattern above.
6–7ReservedZero.
8–15Key hintWhich of the responder’s keys the connection is addressed to: the first 8 bytes of BLAKE2s-256 over its X25519 key.

Versions

Then the initiator sends Hello with the lowest and highest wire versions it speaks and its role. The responder answers Welcome with the version both will speak from then on, or Goodbye with a reason and how long to wait. From the first byte to Welcome takes at most 15 seconds, and every send after it has a deadline of 30. Wire version 1 is the only version so far.

A relay answers every wire version a feeder ever shipped, forever. Installed feeders do not update, and the rest of the protocol rests on this rule.

Records

A record is a kind byte, a two-byte body length, and the body. These are the kinds wire version 1 knows:

KindRecordBody
0x01HelloLowest version, highest version (two bytes each), role (one byte: 1 feeder, 2 subscriber, 3 relay, 4 MLAT, a relay publishing positions).
0x02WelcomeThe version both speak from here on (two bytes).
0x03BatchA receiver’s signed batch, below. Relays forward it byte for byte.
0x04ReceiverInfoA receiver’s signed description of itself: location and its precision, clock class, software, and the relays it chose.
0x05HeartbeatEmpty. Liveness.
0x06RedirectSeconds to wait (four bytes) and relays as hints. It means only “leave me”; the feeder still chooses by its own rules.
0x07RelayListRelays this relay knows of, offered as hints.
0x08RateLimitBytes a second and burst (four bytes each): the most the relay wants. A feeder may go lower, never above its owner’s ceiling.
0x09GoodbyeA reason (one byte) and seconds to wait (four bytes): the relay is closing.
0x0aSubscribeStreams (one byte of flags: 1 raw, 2 deduplicated, 4 positions), a count (two bytes) and that many H3 cells (eight bytes each), at most 64. It replaces the subscriber’s previous Subscribe.
0x0bRelayBatchA batch the relay signed itself: its deduplicated stream.
0x0cPositionsPositions a relay’s MLAT solved, signed by that relay.

Rules for change

Feeders never update, so these are the rules that let the protocol change without them:

  • An unknown kind is skipped, unless its high bit (0x80) marks it critical, which ends the session cleanly. New records are therefore not critical unless ignoring them would be wrong.
  • A body may carry bytes after the fields a reader knows, and they are ignored. Fields are added by appending them.
  • A reader keeps an enum value it does not know as the value Other, never an error.
  • A signed form keeps the same prefix in every format version and ends with its signature over everything before it, so a relay can verify, route and deduplicate a format it does not otherwise read.
  • An observation’s format is BEAST’s own type byte for 1090 MHz. 978 MHz UAT takes new values and needs no new record, and relays forward formats they do not know.

The signed batch

A batch is about a second of one receiver’s receptions. Format 1 is laid out as:

BytesFieldMeaning
1Format1.
32KeyThe receiver’s Ed25519 public key.
8Boot idRandom each time the feeder starts.
8Sequence0, 1, 2 and on within one boot. A gap downstream is data some hop did not deliver.
4Counter epochBumped whenever the receiver’s counter may have restarted, so counters from before and after never meet.
8Wall clockThe feeder’s clock in Unix milliseconds. Informational: it can be wrong.
4DroppedReceptions the feeder’s own ceiling dropped since the previous batch.
32PreviousThe hash of this boot’s previous batch, zeros for the first, so a receiver cannot sign two histories.
4CountHow many observations follow.
10 + n eachObservationsEach a format (one byte), the receiver’s raw 48-bit counter (six), the signal level (one), a length (two) and that many bytes of the message as the decoder emitted it.
64Ed25519 signatureOver everything before it.

Observation formats: 1 is Mode A/C (2 bytes), 2 Mode-S short (7), 3 Mode-S long (14), D a 978 MHz UAT downlink, U a UAT ground uplink, each written as its ASCII character.

The signature covers the ASCII text tally batch, a zero byte, and every byte of the batch before the signature. A relay’s own deduplicated batches sign tally relay batch instead, so one can never pass for the other.

A batch’s identity is the BLAKE2s-256 hash of all its bytes, signature included. Relays deduplicate on it, and the next batch’s Previous field names it.

The first 49 bytes (format, key, boot id and sequence) keep their place in every future format, and the signature always comes last.

Counters are never calibrated before MLAT, by anyone: a relay’s MLAT needs the raw counter to solve.