@peloruslabs/relay-protocol
v0.3.0
Published
The Aelos relay wire protocol — frame schemas, binary payload codec, and the end-to-end handshake state machine. Pure TypeScript, no Node/React Native dependencies
Readme
@peloruslabs/relay-protocol
The wire protocol spoken between a boat, an Aelos relay, and a remote viewer. This document is the specification; the package is its reference implementation.
Pure TypeScript — zod is the single runtime dependency, declared as a peer dependency —
so the same package runs inside a React Native app, a Signal K plugin, and the relay
itself. It performs no I/O and imports nothing from Node or a browser.
Pre-release and unpublished. Nothing here is on npm yet, and the public API is expected to change without notice. The protocol version below (
proto: 2, with 1 still accepted) is nonetheless meaningful: a change to any framing or crypto rule bumps it.
npm install @peloruslabs/relay-protocol zod1. What the protocol is for
A boat's instruments live on a network you cannot reach: marina Wi-Fi behind NAT, a phone hotspot, a cellular link behind carrier-grade NAT. Nothing on the boat can accept an inbound connection, and asking an owner to forward a port is both a support burden and a security liability.
So the boat connects out to a relay and stays connected. A viewer connects out to the same relay. The relay puts the two ends in touch and forwards bytes it cannot read.
boat ──outbound──▶ relay ◀──outbound── viewer
(WebSocket) (WebSocket)Three properties, in the order they matter:
- The relay cannot read the data. Payload frames are end-to-end encrypted between the boat and one specific viewer. The relay has no key and no way to acquire one.
- The boat publishes only while somebody is watching. The relay tells the boat how many viewers are attached; that count is the only thing that causes the boat to take a data subscription at all.
- What a viewer can command is a closed list. Not a permission check that could be misconfigured: proto 1 had no command frame at all, and proto 2's is a single binary type carrying a closed union of anchor-watch ops. See §3.
2. Transport and framing
One WebSocket per party. Two frame kinds:
| Kind | WebSocket opcode | Who parses it | | --- | --- | --- | | Control | text (JSON) | relay and both peers | | Payload | binary | boat and viewer only — the relay reads 4 routing bytes and forwards |
Limits the relay enforces: max payload frame 64 KB, max 8 viewers per boat, a
hello within 5 s of connect, and a WebSocket ping/pong liveness sweep every 30 s.
3. Control frames (proto 2)
Every frame is a JSON object with a t discriminator. Zod schemas for all of them are
exported from this package; parseControlFrame(text) returns the narrowed union or throws
RelayProtocolError.
type Hello = { t: 'hello'; proto: 1 | 2; role: 'boat' | 'viewer'; token: string };
type Welcome = { t: 'welcome'; proto: 1 | 2 };
type ErrorF = { t: 'error'; code: 'auth' | 'proto' | 'boat-offline' | 'limit'; msg?: string };
type Viewers = { t: 'viewers'; count: number; attach?: string[]; detach?: string[] };
type BoatStatus = { t: 'boat-status'; online: boolean };
type Hs1 = { t: 'hs1'; vs: string; e: string };
type Hs2 = { t: 'hs2'; vs: string; e: string; s: string };
type Result = { t: 'result'; id: 0; statusCode: 429 | 503; message: string };
type Ping = { t: 'ping' };
type Pong = { t: 'pong' };Who may send what:
| Frame | boat → relay | viewer → relay | relay → boat | relay → viewer |
| --- | :-: | :-: | :-: | :-: |
| hello | ✔ | ✔ | | |
| welcome / error | | | ✔ | ✔ |
| viewers | | | ✔ | |
| boat-status | | | | ✔ |
| hs1 | | ✔ | ✔ | |
| hs2 | ✔ | | | ✔ |
| result | | | | ✔ |
| ping / pong | ✔ | ✔ | ✔ | ✔ |
A frame sent on the wrong leg is a protocol error: the relay answers error { code:
'proto' } and closes.
In proto 1 there was no command frame at all, and the read-only guarantee for remote
viewing was that absence rather than a permission check. Proto 2 changes the guarantee's
shape rather than removing it: a command is a 0x02 binary frame (§4) whose plaintext is a
closed four-op anchor-watch union, the relay forwards it only from a controller-role
session, and the Station dispatches it through one switch whose default refuses. Pilot
paths stay unreachable from the relay by construction — there is no op for them.
The result control frame is the only answer the RELAY itself may give a command, and its
status code is pinned to 429 or 503, so a relay cannot tell an app that an anchor watch
did something it did not do.
viewers — the publish-on-demand signal and the session roster
Sent by the relay to the boat whenever the viewer set changes, including the transition
to zero. count is authoritative; attach/detach are the deltas since the last frame.
A boat that reads only count gets publish-on-demand right. A boat that also reads the
deltas knows which sessions to handshake and which session keys to forget.
Silence is never a signal here. A boat that missed a detach would publish to nobody
forever.
viewerSession tags
The relay assigns 4 random bytes per attachment, rendered as 8 lowercase hex
characters in JSON (viewers.attach, hs1.vs, hs2.vs) and as 4 raw bytes in the
binary payload frame.
The relay assigns it — not the boat — because it is the routing key, and a tag chosen by a client could collide with a live one or be forged by a viewer claiming to be another. Two consequences worth stating:
- A viewer's
hs1.vsis overwritten by the relay with the tag it assigned. There is nothing else to validate it against, and trusting it would let one viewer open a handshake in another's name. - A tag is per-attachment. Reconnecting produces a new one, which is also what makes it useless as a tracking identifier for a relay operator.
4. Binary payload frames
Two layouts, differing by exactly the four bytes the relay consumes, and four types:
0x01 snapshot boat → relay [0x01][tag ×4][counter u64 BE ×8][ciphertext…]
relay → viewer [0x01] [counter u64 BE ×8][ciphertext…]
0x03 result boat → relay [0x03][tag ×4][counter u64 BE ×8][ciphertext…]
relay → viewer [0x03] [counter u64 BE ×8][ciphertext…]
0x02 command viewer → relay [0x02] [counter u64 BE ×8][ciphertext…]
relay → boat [0x02][tag ×4][counter u64 BE ×8][ciphertext…]
0x04 identify viewer → relay [0x04] [counter u64 BE ×8][ciphertext…]
relay → boat [0x04][tag ×4][counter u64 BE ×8][ciphertext…]The type byte is the sender's statement about what the ciphertext is; the relay carries it
through untouched and edits only the tag. A viewer never writes a tag at all — the relay
splices its own on, which is hs1's Rule 1 with nothing left to discard.
The type byte is AEAD associated data for 0x02, 0x03 and 0x04. It has to travel in
the clear, because a receiver needs it to know what it is opening — but in the clear and
unauthenticated meant an attacker able to edit one byte in flight (a hostile or
compromised relay, most obviously) could relabel a 0x02 command as a 0x04 identify, or
the reverse, and the frame would open cleanly as the wrong thing. Binding it costs one byte
of AAD and closes the class: a relabelled frame fails Poly1305 and, because the counter
advances only on a successful open, burns nothing the genuine sender is about to use.
0x01 is exempt and must stay exempt: it is proto 1's frame, proto-1 peers are in the
field, and their AEAD takes no associated data at all, so a snapshot sealed with any would
fail on every boat and phone already shipped. A snapshot is therefore byte-identical to what
it always was. The three proto-2 types never existed before, so binding them breaks nothing.
Both proto-2 peers must implement it. A peer whose RelayCrypto.aeadEncrypt / aeadDecrypt
drops the aad argument goes on streaming snapshots perfectly and silently fails every
command, which is why this landed before proto 2 shipped rather than after. The relay itself
is unaffected: it holds no session key and never seals or opens a payload frame.
A counter is per session per direction and is shared across frame types: the boat's snapshots and results come off one increasing sequence, the viewer's identify and commands off the other.
The tag names which E2E session the frame is sealed to. The boat runs one session per attached viewer, so the same reading is encrypted once per viewer under a different key; the tag is how the relay knows which socket each copy belongs on. It is stripped on the way out, because it is the relay's routing metadata and leaving it on would place a relay-assigned identifier inside data the viewer authenticates.
Why the counter is outside the ciphertext: the receiver needs it to construct the nonce that decrypts the frame, so it cannot be inside. It is not secret and it is not trusted — altering it produces a decryption failure. Its integrity role is replay defence, which lives in the session (§5), not in the framing.
Why u64: at 1 Hz a u32 would last 136 years, so this is not about exhaustion. It is about never having to think about wraparound in a nonce. A repeated nonce under XChaCha20-Poly1305 is a total break of the session key, and "the counter cannot practically wrap" is a far easier property to keep true than "the rekey always happens first".
5. End-to-end crypto
Static X25519 keypairs are generated at pair time: the boat's in its Signal K plugin, the app device's on first sign-in. The backend is the introducer — each end fetches the peer's static public key from it, never from the relay. The relay carries no key material, not even as an echo, so a relay operator who substitutes one produces a handshake failure rather than a man in the middle.
Handshake (Noise-KK shaped)
App = initiator. Boat = responder. Both static pairs are X25519, minted with
crypto_box_keypair.
app boat
eph_app = crypto_box_keypair()
e = nonce24 ‖ crypto_box_easy(eph_app.pub, ──hs1──▶ crypto_box_open_easy → eph_app.pub
nonce24, boat_static_pub, eph_boat = crypto_box_keypair()
app_static_secret) seed = random(32)
◀──hs2── e = nonce24 ‖ box(eph_boat.pub,
crypto_box_open_easy(e) → eph_boat.pub nonce24, app_static_pub,
with (boat_static_pub, app_static_secret) boat_static_secret)
crypto_box_open_easy(s) → seed s = nonce24 ‖ box(seed,
with (eph_boat.pub, eph_app.secret) nonce24, eph_app.pub,
eph_boat.secret)
both ends, identical inputs:
salt = crypto_generichash(32, eph_app.pub ‖ eph_boat.pub
‖ app_static_pub ‖ boat_static_pub)
prk = crypto_kdf_hkdf_sha256_extract(ikm = seed, salt)
i2r = crypto_kdf_hkdf_sha256_expand(prk, "aelos-relay-p1 i2r", 32)
r2i = crypto_kdf_hkdf_sha256_expand(prk, "aelos-relay-p1 r2i", 32)The app sends under i2r and reads r2i; the boat is the mirror. In proto 1 only the boat
sent payloads, so r2i carried all the traffic and i2r was derived but idle. Proto 2 puts
commands on i2r. Nothing about the derivation changed — the second key was always there,
waiting for something to say.
The 24-byte box nonce is random and prepended to the ciphertext inside each of e and
s, so every field is self-contained — there is no separate nonce on the wire to fall out
of step with the blob it belongs to.
A crypto_box_open_easy failure on hs1.e or hs2.e means the blob was not produced by
the holder of the expected static secret. Tampering and impersonation are the same event,
and neither is retryable: drop the session.
Why a sealed seed rather than a key exchange
This was crypto_kx_client/server_session_keys over the two ephemerals — the textbook
shape, one line shorter. It was replaced because react-native-libsodium ships neither
crypto_kx_* nor crypto_scalarmult, so the app could not perform a raw Diffie-Hellman
at all. The protocol passed every test in this package (which runs against
libsodium-wrappers, where crypto_kx exists) and threw on the device at session start.
Every primitive in the diagram above exists in all three bindings; that intersection is
now a stated rule in crypto.ts.
Proto stayed at 1 through that change, because it had never run anywhere but a bench rig. It moves to 2 for R3, which is the first change a deployed peer can observe.
The security properties are unchanged, and come from different places than the shape suggests:
| Property | Where it comes from |
| --- | --- |
| Mutual authentication | The two static-key boxes (hs1.e, hs2.e) — never the key exchange. A relay that substitutes a key produces a handshake failure, not a man in the middle. |
| Forward secrecy | hs2.s is sealed ephemeral→ephemeral. The seed is the only input to the session keys, and recovering it needs an ephemeral secret that is discarded with the session — so compromising both static identity keys tomorrow does not decrypt frames recorded today. |
| Transcript binding | The HKDF salt hashes both ephemeral publics and both static publics, so a session key is valid only for the exact four-key conversation that produced it. Splicing an ephemeral from another handshake yields a dead session, not a subtle confusion. |
| Directional keys | Two expands with distinct info strings, replacing kx's rx/tx pair. Same property, explicit rather than inherited: no key is ever used by both ends. |
Not a property: key continuity — see below.
HKDF here is RFC 5869 HKDF-SHA256. The three hosts implement it three different ways (JSI
libsodium in the app, node:crypto in the Signal K plugin, @noble/hashes in these
tests), so each is pinned to the RFC's own Appendix-A vectors plus a recorded vector for
this protocol's two info literals — a typo in a domain separator fails a test rather than
shipping two peers that derive different keys and blame the network.
Payload sealing
crypto_aead_xchacha20poly1305_ietf, no additional data, with:
nonce (24 bytes) = 16 zero bytes ‖ counter (u64 big-endian)A counter as a nonce is safe exactly when a key has one sender for one session, and both halves hold here: session keys come from a fresh seed and fresh ephemerals every handshake, and each directional key is written by one end only.
Counter rules, enforced on both sides by this package:
| Rule | Where | Why |
| --- | --- | --- |
| A sender must never reuse or lower a counter | sealFrame | A repeated nonce is a total break, not a degradation. The sender is guarded as strictly as the receiver. |
| A receiver rejects counter <= lastSeen | openFrame | Replay defence. |
| Gaps are allowed | openFrame | A dropped 1 Hz snapshot must not wedge the stream. |
| The watermark advances only on successful decryption | openFrame | Otherwise a forged frame could burn a counter the real sender is about to use — a trivial denial of service. |
| Each direction has exactly one counter allocator | session.nextSealCounter() | Take every counter from the session; never keep a parallel tally. It reports (lastSealed ?? 0) + 1 and does not advance, so a seal that threw spends nothing. A second tally that drifts by one repeats a nonce. |
Long sessions rekey by re-handshaking.
What this does not give you
Key continuity. Nothing here detects "the boat's static key changed". That is the introducer's answer to give; key pinning and a fingerprint surface are deliberately deferred. A re-paired boat presents a new key and the app must be told by the backend, not by a silent success.
6. Admission tokens
HS256 JWTs signed with a secret shared between the token minter and the relay. The relay verifies them and makes no network call of its own.
boat: { sub: <boatId>, org: <orgId>, role: 'boat', exp: now + 24h }
viewer: { sub: <userId>, boat: <boatId>, org: <orgId>, role: 'viewer', exp: now + 1h }exp is mandatory: a token with no expiry is not an admission ticket, it is a key. A boat
token must not also carry a boat claim — a relay that guessed which field to trust could
route a boat into someone else's stream.
7. Snapshot payload
The plaintext inside a payload frame is JSON, shaped like a Signal K delta so a client can feed it to the same parser its LAN path already uses:
{
"ts": 1786000000000,
"updates": [
{
"$source": "ydwg.YD",
"timestamp": "2026-08-10T12:00:00.000Z",
"values": [{ "path": "environment.depth.belowTransducer", "value": 4.2 }]
}
]
}It is a snapshot, not a replay: the boat emits the latest value per path seen in each 1000 ms window and drops the rest. Remote data is sampled by design, and a viewer must not present it as a continuous feed.
ts is the boat's clock at window close; timestamp is the source's own. Do not compare
them — different sources, and here different machines.
updates: [] is a legitimate and meaningful frame: "the boat is here and quiet". Silence
means that too, and also means "the boat is gone", which is why silence cannot be the
signal.
8. Threat model
What the relay sees: that a boat id is connected, that some number of viewers are attached to it, the size and timing of each frame, and the IP addresses of both ends. It routes ciphertext by a 4-byte tag and forwards it.
What the relay cannot do:
- Read instrument data. No key exists at the relay, and payload frames are never parsed there.
- Sit in the middle. Static public keys come from the introducer; substituting one fails the handshake.
- Forge a snapshot. Poly1305 covers every payload frame.
- Replay a snapshot. Counters strictly increase per session.
- Deliver one viewer's frames to another. Each session has its own key; a mis-routed frame fails to open.
- Decrypt yesterday's recording with tomorrow's stolen identity keys. The session seed
travels sealed between two ephemeral keys (
hs2.s), never under a static one. An attacker who later obtains both the boat's and the app's static secrets can impersonate either end going forward, but cannot open captured frames — that would need an ephemeral secret, and both were discarded when the session ended.
What it can do, and what you accept by using a hosted one: observe traffic patterns (when a boat is connected, when someone is watching, roughly how much data), and deny service by dropping connections. Metadata resistance is not a goal of proto 1.
Outside the protocol's reach: a compromised boat, a compromised phone, and the introducer itself — the backend that hands out peer public keys is trusted to hand out the right ones. Pinning would reduce that trust and is deferred.
Revocation is bounded by token TTL at the relay (24 h boat, 1 h viewer) and is immediate at the crypto layer: delete a peer's key and the handshake fails even for someone holding a live token.
9. API sketch
import {
parseControlFrame, serializeControlFrame, // control frames
encodePayloadFrame, decodePayloadFrame, // boat → relay (tagged), pinned to 0x01
encodeViewerPayloadFrame, decodeViewerPayloadFrame,
encodeTaggedFrame, decodeTaggedFrame, // any frame type, tagged
encodeUntaggedFrame, decodeUntaggedFrame, // any frame type, untagged
stripViewerSessionTag, withViewerSessionTag, // the relay hop, and its inverse
createHandshake, // the state machine
encodeSnapshot, decodeSnapshot, // the AEAD plaintext
encodeIdentify, decodeIdentify, // the 0x04 plaintext
type RelayCrypto, // the primitives you supply
} from '@peloruslabs/relay-protocol';RelayCrypto is an interface this package does not implement. That is not a testing
convenience: one protocol implementation has to run in three places with three different
libsodium bindings — react-native-libsodium in the app, sodium-native in the Signal K
plugin, libsodium-wrappers in these tests. Every one of them would break the purity gate
if imported here, and each has a different call shape. The protocol decisions stay in one
place and are tested against a real implementation once.
Implementations must throw — never return empty or null — from boxOpen and aeadDecrypt
on authentication failure.
License
Apache-2.0. Copyright 2026 Pelorus Labs LLC.
