npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 zod

1. 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:

  1. 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.
  2. 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.
  3. 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.vs is 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.