Protocol specification
This specification is normative for the wire formats and behaviour of Freehop version 1. It is licensed under CC BY 4.0, © 2026 Jolyn Studios. See License and disclaimer.
Freehop connects small groups with audio, video and data. maxPeers defaults to eight other participants; larger supported sizes require benchmarks. Its signalling invariant: gates never carry media. Media goes peer-to-peer. When no direct route exists, it goes through a machine that belongs to the same session: a participant, a participant's own gateway or the session's host node. An application may also opt into its own TURN relay as a last resort (§7a). Volunteers may be added by configuration. Gates only introduce peers. An application operator may also run a session gateway and pay for its media bandwidth and compute.
The SDK reference covers usage, and the network test results cover measured behaviour.
A note on names. Freehop's codename is Peerlane. Several wire identifiers carry that name: the HKDF salt prefix peerlane/v1/, the envelope AAD prefix peerlane/v1|, the data channel label peerlane, the TURN realm peerlane and the tracker payload prefix pl1:. They are part of the protocol and are given here exactly as implementations must use them.
1. Roles
| Role | Runs on | Carries | Never carries |
|---|---|---|---|
| Gate | Any server (operator, community, anyone). Any number per room. | Sealed signalling envelopes (opaque bytes), STUN Binding answers | Media, plaintext SDP, candidates or credentials |
| Peer | Browser or desktop renderer (WebRTC) | Its own media, plus forwarded media when bridging (§9) | |
| Gateway | A peer's own machine (desktop app main process) or the session's host node; a TURN server plus router port mapping | Relayed packets of the session's peers, DTLS-SRTP encrypted end to end | Anything for peers outside its session (credentials are per room and per peer) |
| Gateway member | The session's host node (a participant's desktop app, or a community server hosting the session) | Joins the room through gates without media and offers its gateway | Media of its own |
Cost rule: a byte of media only crosses machines that belong to the call. These are the two endpoints, another participant, or the session's own host. The operator's gate traffic is bounded by construction (§4.6) and measured in every home-lab network test run. The opt-in application TURN rung (§7a) is the only exception, and only when the application configures it: that pair's media then also crosses the application's TURN server, still DTLS-SRTP encrypted end to end.
2. Identifiers and keys
- Room secret: at least 128 bits, generated by the application's room service when a room is created. It is handed to members together with the room information they already receive. The app need not display it, but members can extract and share it. A share link is a bearer credential.
salt = "peerlane/v1/" + app, whereappnamespaces applications.roomTag = base64url(HKDF-SHA256(secret, salt, info="room-tag", 32 bytes)). This is the only room identifier a gate sees.envelopeKey = HKDF-SHA256(secret, salt, info="envelope-key")as an AES-256-GCM key.- Peer id: 16 random bytes, base64url (22 characters), fresh for every join. Gateway members use ids that start with
gw_(22 characters in total). The prefix is a hint; their role is only accepted from sealed caps.
Kicking a member means the application issues a new room secret to the remaining members. Every remaining member closes old-key links and reconnects under the new secret; previous-key envelopes are rejected immediately. Host nodes and desktop gateways revoke the entire old room epoch, including aliases. Rotation briefly interrupts media. Update every remaining member and host; any machine still using the old secret can communicate with its holders.
3. Sealed envelopes
box = base64url( iv[12] || AES-256-GCM(envelopeKey, iv, plaintext, aad) )
aad = "peerlane/v1|" + roomTag + "|" + from + "|" + to
plaintext = JSON { kind, n, ...body }
tois the recipient's peer id. The AAD binds room, sender and recipient. A gate that re-labels or re-routes an envelope makes it fail authentication.nis a per-sender counter. Receivers keep a 1024-wide replay window per sender and drop duplicates. The same envelope legitimately arrives via several gates and the mesh.- Kinds:
caps,description,candidate,bye,restart-request,video-quality,bridge-request,bridge-offer,bridge-accept,bridge-confirm,bridge-ready,bridge-active,bridge-release,bridge-fail,forward-map,forward-unmap,app. Unknown kinds are dropped.appcarries application data (session.send): at most 4096 characters of JSON, and each receiver accepts at most 20 per second from one sender (burst 40). Tracker gates add a room-broadcasthello(to*). - Adaptive video request. An opted-in receiver sends
{kind:"video-quality", level:"normal"|"reduced"|"minimal"|"paused"}to the peer sending video. The sealed request applies to that peer link only, is ordered by the envelope counter and rate-limited to one accepted change per second. Audio is never changed. A peer that has not opted in ignores it; older implementations drop the unknown kind. - Admission. Gate rosters and arrival events are unauthenticated hints. A hint only makes a peer greet the hinted id with its sealed caps, at most once per 20 s and for at most 64 hints. A peer becomes a member, with
peerevents, a link and a slot, only after an envelope from it authenticates. A gate cannot authenticate an invented member, but can still prevent discovery or disrupt recovery by dropping traffic. Roster presence alone does not prove membership.
4. Gate protocol (WebSocket, UTF-8 JSON text frames)
4.1 Messages
| Direction | Message | Notes |
|---|---|---|
C→G | {"t":"hello","v":1,"auth"?:string} | Must be the first frame, within 5 s |
G→C | {"t":"welcome","v":1,"stun":[url],"limits":{...}} | Informational only; clients use application/ticket STUN configuration |
C→G | {"t":"join","room":tag,"peer":id} | At most 4 rooms per socket |
G→C | {"t":"peers","room":tag,"peers":[id]} | Current members |
G→C | {"t":"peer","room":tag,"peer":id,"on":bool} | Arrivals and departures |
C→G | {"t":"send","room":tag,"to":id or "*","box":string} | The box is opaque base64url |
G→C | {"t":"recv","room":tag,"from":id,"box":string} | from is the sending socket's own peer id |
C→G | {"t":"leave","room":tag} / {"t":"ping"} | |
G→C | {"t":"error","code":string} | schema, rate, auth, auth-expired, busy, room-full, room-not-allowed, no-such-peer, not-joined, … |
4.2 Admission
A gate is either open or token-gated. A token is base64url(JSON claims) "." base64url(HMAC-SHA256(gateSecret, body)) with claims {exp, room?, aud}. A token with room may only join that room tag. Gate tokens include aud, the exact public gate URL. Tickets map each gate URL to its own token; configure tokenAudience behind a proxy. Clients ignore welcome.stun; STUN comes from application or ticket configuration. Tokens require a future safe-integer exp, and both parts must be canonical unpadded base64url. An expired socket is closed at its next request, heartbeat or delivery, whichever comes first. A client renews its token for the same epoch with session.refresh(ticket); the new token is used at the next (re)connect.
4.3 Multiple gates and tracker gates
Clients connect to every gate in their list and announce on each. Peers that share no gate still meet through the mesh (§6, introduction). Established calls do not depend on any gate; home-lab browser tests show all gates shut down while media continues.
A gate URL bt+wss://… names a public WebTorrent tracker used as a gate.
info_hashis the first 20 ASCII characters of the hex-encoded, decoded room tag (80 bits of tag material), andpeer_idis a random 20-character ASCII string.- Hellos travel as announced offers,
"pl1:<peer id>:<sealed hello>". Envelopes travel as answers addressed byto_peer_id. - A peer's tracker address is bound only after its envelope authenticates. Hellos carry the sender's counter and fall under the replay window.
4.4 Liveness and proxies
- Clients send
{"t":"ping"}every 30 s, and the gate also pings at the WebSocket level. Each gate ping carries a fresh random nonce; only a pong that echoes it counts as activity. - Output the gate has handed to a socket but not yet flushed is bounded: 1 MiB per socket and 32 MiB for the whole gate. Over the gate-wide budget, the receiver that has been behind the longest is closed first.
- Behind a local reverse proxy (
trustProxy), per-address limits use the lastX-Forwarded-Forhop. - After a network change a join can briefly meet
peer-taken; clients retry with backoff.
4.5 STUN
A gate may answer RFC 8489 Binding requests on UDP, IPv4 and IPv6. It answers Binding only, rate-limits per source address and globally, and never relays.
4.6 Bounds
A gate frame is at most 64 KiB and a box at most 48 KiB. Each socket gets a token bucket over the bytes it makes the gate forward, fan-out included: 512 KiB burst, 2 KiB/s refill. Each socket is also limited to 40 messages/s (burst 400). Further limits: 16 peers per room, 32 sockets per source address (an IPv6 /64 counts as one address), a 90 s idle timeout and 1024 admitted sockets. Sockets that have not been admitted yet have their own budget (256 in total, 8 per address, 64 frames or 64 KiB queued while admission is pending), so silent sockets cannot lock admitted clients out; refused and timed-out sockets are torn down within 1 s. These limits make media tunnelling through a gate impractical, while one five-peer join needs about 100 KB.
5. Capabilities (caps envelope)
{ v:1, role?:"gateway", forward:bool, peers:[id], gateway: null |
{ urls:["turn:host:port?transport=udp", "turn:host:port?transport=tcp"],
username, credential, external:[ip], internal: ip|null },
nat?: { type, delta } }
peerslists the sender's connected links. It is used for mesh routing and introductions.forwardmeans the sender may bridge other pairs (§9).gatewayholds credentials minted for the recipient (§8.2). A gateway member setsrole:"gateway".nat({type:"eim"|"sequential"|"random", delta}) is present only when the sender enabled NAT classification (§7a);deltais the port step (1–16) of asequentialNAT and 0 otherwise. Receivers that did not opt in drop it.
A peer sends caps to every peer it discovers (through gates), on every new data channel, and when its link set changes. It also embeds caps in every description envelope.
6. Signalling transport
- Every link carries a pre-negotiated data channel (
negotiated:true, id:0, labelpeerlane). Once it is open, all envelopes for that peer go over it as{"t":"env","from","to","box"}. - Otherwise the envelope is sent through every gate on which the recipient is present.
- Otherwise it goes through a connected neighbour whose caps list the recipient:
{"t":"env",...,"hop":1}. The neighbour forwards it once ashop:2, limited to 600 per minute per link. This is how peers introduce each other without a common gate. - Otherwise it waits in an outbox: 32 envelopes per peer, for 20 s. The outbox is flushed when the peer appears on a gate or its link opens.
A data channel is trusted alone only while its link is connected. A dead connection's channel can read open for tens of seconds, so gates carry the envelope as well.
7. Path ladder (per pair)
Negotiation follows W3C perfect negotiation. The peer with the lexicographically greater id is polite. A link created to answer an incoming offer suppresses its own first offer. A suppressed negotiationneeded is re-checked once the state is stable again.
| Phase | ICE servers | Expected route | Leaves the phase when |
|---|---|---|---|
| 0 ENDPOINT | Application-approved STUN + own gateway (internal URL) + remote peer's gateway | host/LAN, IPv6, srflx/prflx, or one endpoint's own gateway | not connected 5 s after the description, with one grace period if checks receive answers |
| 1 SESSION | + gateways of connected participants and of gateway members (max 2 added) | relay through a session gateway | 7 s without connection, with one extra 7 s grace period if checks receive answers |
| 2 BRIDGED | unchanged | media forwarded by a connected participant (§9) | the direct or session route later succeeds |
| unreachable | unchanged | none (no bridge candidate) | ICE restart retries with exponential backoff (30 s … 300 s) |
- Escalation adds gateways with
setConfiguration. Only the impolite side restarts ICE. The polite side applies the same servers and takes over afterrestartFallbackMs(2.5 s) if no restart offer arrived. Simultaneous restarts collide, and a rolled-back restart offer was observed to leave Chromium senders silent in home-lab browser tests (§12). - Descriptions carry
phaseandgateways(the ids of extra gateways in use). The receiver adds those servers before answering, so both sides gather against the same relays. - Candidates for an unknown ICE generation are buffered until the matching description arrives: at most 128, oldest evicted first.
- Descriptions apply in envelope-counter order. An older one never overtakes a newer one, whatever route each took.
- An offer that receives no answer within 8 s is rolled back and sent again, and an ICE restart is re-requested, so a lost envelope cannot deadlock the pair.
- Media watchdog: if a connected link's live track sends no packets for two 5 s checks, it is restarted. The polite side sends
restart-requestinstead of restarting itself. - Departures: a member that has been absent from every gate for 8 s, and whose link is not connected, is dropped as
gone. Its replay window is kept, and it may return by authenticating again.byeand kicks are final. - On
disconnectedthe link waits 4 s, then restarts. Onfailedit restarts immediately. Both follow the single-initiator rule.
Path classification uses the selected candidate pair: direct (no relay on either side), gateway (relay owned by one endpoint), relay (relay owned by another session member, or with via: 'turn' the application's TURN relay, §7a), bridged (§9) or unreachable. A local peer-reflexive candidate whose relayProtocol is set still rides a relay.
7a. Opt-in rungs before unreachable
Three options, all off by default, add rungs exactly where §7 would report unreachable (no untried session gateway and no active bridge). With none set, signalling, timings, ICE servers and events are unchanged; tools/laddergate.mjs checks this against traces recorded before the rungs existed.
| Option | What it does | Budget |
|---|---|---|
classifyNat | Each ICE generation's srflx candidates from up to three stun: servers classify this endpoint's NAT: one port for every answering server is eim, ports a regular step of 1–16 apart are sequential, anything else is random. The verdict is shared in caps (§5) and reported as stats().nat. | none |
portPrediction (implies classifyNat) | Tried once per link when one side is sequential and the other eim or sequential. The link restarts ICE; the side whose peer is sequential then adds remote candidates for the peer's next ports above its highest srflx port (limits.predictPorts, 8 per srflx candidate by default, at most 16 per generation). The predicted candidates are added locally; nothing extra is signalled. | timing.predictMs, 10 s |
turn | The application's TURN servers join that link's ICE servers and the link restarts ICE. The path reports relay with via: 'turn'. | timing.turnMs, 10 s |
Order: prediction, then TURN, then unreachable with the usual backoff. Both rungs reuse the restart rules of §7 (impolite side first, polite side after restartFallbackMs). A rung that fails, for example a TURN URL the browser refuses, is counted (rungErrors, turnErrors) and the link falls through to unreachable in the same escalation. There is no new phase, envelope kind or path kind. Random NATs are never predicted: two of them need a relay.
The application relay stays the last resort after it connects a pair. While a link is carried by it, the impolite side restarts ICE after timing.turnUpgradeMs (60 s), then at doubling intervals up to maxRetryMs, and within seconds when a gateway the pair could use appears. The relay stays in the link's ICE servers during these restarts, so the pair keeps its route when nothing cheaper works. Once the pair has used another route for two media checks (mediaWatchMs), the relay leaves the link's ICE servers on both sides and the TURN rung counts as untried again; the impolite side restarts ICE once more so the relay's allocations end. A later failure runs the whole ladder again, TURN last. The polite side never restarts for this: it answers with the relay removed. Counted as turnProbes and turnReleases; again no new phase, envelope kind or path kind.
8. Gateways
8.1 Reachability
The gateway runs a TURN server (RFC 8656 subset: UDP relay allocations over UDP and TCP client transports) on the peer's LAN address, with the realm peerlane. It asks the router for mappings: PCP (RFC 6887), then NAT-PMP (RFC 6886), then UPnP IGD v1/v2. It requests the same external port as the internal one, for the listener (UDP and TCP) and for each relayed port on demand. A host with a public address skips mapping. If the router reports a private or CGNAT external address, the gateway is not offered.
8.2 Credentials (TURN REST convention, room-scoped)
username = "<unix expiry>:<room tag[0..8]>:<label>", credential = base64(HMAC-SHA1(secret, username)). label is the recipient's peer id, or self for the owner's own allocations via the internal URL.
- The signing key stays in the privileged gateway process.
info()contains only public metadata; the renderer requests bounded credentials through an origin- and room-checked broker. - The gateway accepts only rooms it was told to serve (
allowRoom). Individual peers can be revoked on kick or leave (revokePeer), which tears down their allocations. A whole room is revoked on key rotation or when the session ends, tearing down every old allocation. - Credentials are issued only to authenticated members, never in response to a hint.
- Default TTL is 2 h, renewed at half-life, configurable from 1 second to 24 hours. The server rejects correctly signed expiries beyond this horizon.
- Peer revocations are kept per room (up to 256) until previously minted credentials expire. A room that exceeds its bound is revoked as a whole; other rooms are never affected. A host member only revokes peers it issued credentials to.
- Revoking a room records a floor: credentials minted before it are refused even if the same room is allowed again later.
8.3 Policy and hairpinning
- A gateway relays only inside the session. With its default
relayScope: 'internal', a gateway relays only between allocations on itself and refuses every other destination (403). Every path in §7 meets on the same gateway, including the owner's ownselfallocation, so credentials cannot be used to send traffic to other internet hosts.relayScope: 'public'restores general-purpose TURN behaviour; use it only when every member is trusted. - The TURN server's general peer policy (
peerScope: 'public') refuses loopback, link-local, multicast and private peers, plus 6to4, Teredo, local-use NAT64, 192.0.0.0/24, 198.18.0.0/15 and site-local prefixes, and unwraps IPv4-mapped, IPv4-translated and NAT64 forms before deciding. - The gateway machine's own addresses are reachable only relay to relay. Services on the host stay unreachable.
- Traffic between two allocations on the same gateway is delivered internally. Many home routers lack NAT hairpinning, and the owner's own
selfallocation therefore reaches remote allocations without it. - The TURN server bounds unauthenticated UDP answers per source and globally to limit reflection. Binding answers and challenge or error answers have separate global budgets, and addresses that authenticated in the last 10 minutes are exempt from the global budgets (not from their per-source limit), so a flood cannot starve renewals.
- It bounds TCP connections per source address (16), with a 10 s absolute pre-authentication deadline, unaffected by incoming bytes. A TCP connection closes when its allocation ends.
- Media stays DTLS-SRTP encrypted end to end. The gateway relays ciphertext.
9. Participant bridging
When a pair (A, B) has no route, and no untried session gateway, the lower id asks connected participants that report a link to the other peer (bridge-request). The first bridge-offer wins (bridge-accept). Forwarder C then:
- asks the second endpoint with
bridge-confirmand waits forbridge-ready; that endpoint checks eligibility and that it still needs the route; - sends
bridge-activeto both endpoints to establish the forwarding relationship; - sends
forward-map {origin, stream}to each side before renegotiating; - adds the origin's received tracks to its link with the other side, which re-encodes them.
Receivers attribute tracks by stream id. When the direct route later connects, an endpoint sends bridge-release. C removes the forwarded tracks, stopping their transceivers so SDP does not grow, and sends forward-unmap. If C leaves or its link drops, the endpoints re-escalate.
Hardening:
- A forwarder accepts
bridge-acceptonly for an offer it made to that endpoint, within 30 s, while forwarding is enabled and under its bridge limit. - An endpoint accepts
bridge-activeonly from the eligible forwarder it already accepted. Unsolicited and conflicting activations are released. Pending consent expires after 30 seconds. Upgrade all three participants together for this handshake. forward-mapis accepted only for an origin the receiver cannot reach itself, and only from that pair's forwarder.
A forwarder is a participant of the same call, so it already receives both media streams. Bridging exposes nothing new to it. Forwarded video is capped at 200 kbit/s.
9a. SDK roles
The SDK describes how applications consume the protocol:
- an authority (application backend) issues per-member tickets
{v, app, roomId, epoch, gates, secret, auth?, stun?, expires, turn?}and rotates the room on kick; - clients
connect(ticket)andupdate(newTicket, {dropped}); - hosts run
hostSession(ticket)(gateway member); - desktop apps expose their gateway through the Electron preload (
window.freehopGateway, restricted by exact HTTPS origins (HTTP only on loopback) in both the preload and main-process IPC handlers; subframes are rejected).
10. Media profile (defaults)
- Audio: Opus, echo cancellation, noise suppression and AGC, max 32 kbit/s.
- Video: 640×360 at up to 24 fps, max 300 kbit/s per link.
- Muting the microphone disables the track (no renegotiation). Turning the camera off stops the track and replaces it with
null. - Mesh upstream at 5 participants with video ≈ 4 × 332 kbit/s before overhead.
11. Security considerations
- Gates learn: socket IP addresses, opaque room tags and peer ids, envelope sizes and timing. They cannot read SDP, ICE candidates, caps or credentials. They cannot forge or re-route envelopes without detection, and cannot inject peers into a room. A hostile gate can drop or delay traffic; using several gates mitigates that.
- Room members are mutually trusted for signalling: any member can forge envelopes in the room's name. Per-peer signatures are a possible v2 addition. Removal requires a secret rotation (§2). SDK ticket expiry does not erase copies of the shared secret or end established media; it is not a replacement for rotation.
- Gateways: credentials are per peer and short-lived. The peer policy prevents use as a proxy into private networks. Quotas: 6 allocations per username, 4 Mbit/s per allocation, 32 allocations and 20 Mbit/s per room, 40 Mbit/s in total, separately per direction; at most 64 allocations overall. Room quotas aggregate every alias in that room; global limits still apply. By default a gateway relays only between allocations on itself, so a member cannot use its credentials to reach other internet hosts (§8.3). Expiry blocks further authenticated requests; existing allocations may continue until their granted lifetime ends. Revocation ends them immediately, and a participant's desktop gateway releases a room when the participant leaves.
- Opt-in traversal aids (§7a): with
classifyNat, room members learn the sender's NAT type. Port prediction adds at most 16 predicted remote candidates per ICE generation, only for the peer that link already negotiates with. An application TURN relay sees the IP addresses, timing and DTLS-SRTP-encrypted packets of the pairs that use it. Application TURN servers come only from the ticket or the application's options, never from gates or caps. - IP privacy: direct paths expose network addresses to other participants. Gate and tracker operators also see connecting IP addresses. The SDK does not provide an IP-anonymity mode.
12. Known limits
- A network that permits traffic only to the gate host cannot carry media without the gate operator carrying it. Freehop reports
unreachable. A separate session gateway can help only if its media transport is reachable under the network’s rules. Hosting it beside a gate does not by itself bypass those rules; gates remain signalling-only. The same holds for an application TURN relay (§7a): it helps only if this network can reach it. - Two browser-only participants that are both behind hard NATs or UDP-blocking networks, with no IPv6, no gateway in the session and no third participant, cannot connect. A session host node reachable by both endpoints can provide a TURN relay path, and so can the application's own TURN relay (§7a, opt-in). Port prediction (§7a, opt-in) can connect such a pair directly when one NAT allocates ports in order and the other keeps one mapping (two sequential NATs: best effort); two random NATs always need a relay.
- NAT classification needs at least two
stun:servers at different addresses. Browsers report a repeated srflx address once, soeimis inferred from the absence of STUN errors and may be wrong in browsers that do not report them; a wrong verdict costs one extra ICE restart. - Chromium, home-lab browser finding: simultaneous ICE restarts (glare) intermittently left the polite side's RTP senders silent after its restart offer was rolled back. Freehop avoids simultaneous restarts (§7).
- Playwright 1.62's WebKit build rejects
?transport=in TURN URLs (WebKit bug 320931). The client detects this and degrades to UDP-only TURN URLs for that engine. - Desktop apps can alternatively pin WebRTC's UDP port range (
webContents.setWebRTCUDPPortRange, Electron 28 and newer) and map it directly. Freehop's gateway approach needs no Chromium cooperation.