Relay Security Model
What the relay does and does not protect, stated as claims you can check against the code rather than as intentions. It covers pairing and routing integrity at the rendezvous layer. The end-to-end encryption itself lives in @kangentic/protocol and in the desktop and mobile apps, not here: see Security & Encryption for that half.
The short version: the relay pairs two connections that present an identical slot identifier and forwards bytes between them. The slot is the only credential it has. It is a high-entropy value generated by the clients, and everything below is about what that does and does not buy you.
The question this answers
Section titled “The question this answers”Can an unauthenticated third party cause their connection to be paired into a slot intended for two specific peers, or disrupt, observe, or deny that pairing?
- Observe: no. Not the payload, and not the pairing without the slot.
- Pair in: only by holding the slot. There is no way to widen a match, guess cheaply, or slip into a slot already in use. And holding the slot is not enough to impersonate a peer, because the handshake that runs on top of the relay needs key material the relay never sees.
- Disrupt or deny: yes, if you hold the slot, and to a limited extent without it. That is the honest weak spot, covered under Accepted risks.
What the relay guarantees
Section titled “What the relay guarantees”Each of these is enforced by code and pinned by a test. test/pairingIntegrity.test.ts exists specifically so this section cannot quietly become false.
Pairing requires an exact match of the entire slot. The slot table is keyed on the full string. No prefix, fuzzy, or normalized matching exists anywhere. The slot is never lowercased, trimmed, or truncated on any path, and uppercase hex is rejected outright rather than folded to match.
Exactly the first two connections pair, and the decision cannot race. Nothing yields between reading and mutating the slot entry, so on Node’s single thread the read-modify-write is atomic. Two connections arriving together can never both believe they are the second arrival.
A third connection is rejected, never queued. A slot that already holds a pair rejects further arrivals immediately. There is no waiting list, no takeover, and no promotion when a paired peer later disconnects.
A connection cannot be misrouted to the wrong peer. When one half of a pair closes, the slot is freed while the survivor’s socket is still completing its close handshake, so another connection holding the same slot can claim it during that window. It still cannot reach the survivor: forwarding checks that the partner socket is open. Every teardown path also checks connection identity first, so a late close from a torn-down pair cannot tear down a new pair that has since taken the same slot.
Forwarding is byte-for-byte and content-blind. Frames are handed to the partner socket exactly as received. Nothing parses, decodes, or branches on message content; only byte length is read, for the accounting caps. Buffers are per connection, and compression is explicitly disabled, which rules out shared-compression-state attacks.
No slot reaches a log line. Not raw, not hashed. No logging call site passes one at all, and a test enforces it.
Nothing security-relevant depends on a browser-enforced check. There is no Origin, Referer, or User-Agent check anywhere, deliberately. Those defend against a browser attaching ambient authority such as cookies to a cross-site request. This relay has no cookie, session, or ambient credential of any kind, and an allowlist would break the native clients while adding nothing.
Why slot ids cannot be guessed
Section titled “Why slot ids cannot be guessed”The default SLOT_ID_PATTERN accepts 32 or 64 lowercase hex characters, which is 128 or 256 bits. Clients generate them from a cryptographically secure source, freshly per pairing ceremony, and the pairing token behind them is single-use with a short expiry.
Entropy is what makes guessing hopeless, not the rate limits. This is worth stating plainly because it is easy to assume otherwise. The rate limiters key on the value being limited, and a previously unseen key starts with a full burst, so an attacker trying a different slot on every attempt never trips the per-slot limiter at all. Even granting unlimited source addresses and removing rate limiting entirely, the search space is far beyond reach. The per-IP limiter and the connection caps are cost and abuse controls. They are not what stands between an attacker and your slot.
Two consequences follow, and both matter more than tuning any limit:
SLOT_ID_PATTERNis load-bearing. The guarantee is a property of the default. An operator who narrows it to a short or low-entropy shape makes slots enumerable no matter how tight everything else is.- Slot secrecy is the client’s job. The relay accepts whatever matches the pattern. It is blind by design and cannot verify that a slot was generated well.
The relay also declines to help an attacker confirm a guess. An occupied slot and a slot that fails a cap check are rejected with the same close code and the same reason string, and the rate-limit rejections do not distinguish which limiter fired.
Why holding a slot id is not enough to impersonate a peer
Section titled “Why holding a slot id is not enough to impersonate a peer”This is the part the relay cannot enforce and does not try to, so it is stated as a dependency rather than a guarantee.
Peers run their own Noise handshake end to end, through the relay, over bytes the relay never interprets. Winning a slot puts an attacker in the position of a peer on the wire, but the handshake then requires key material that does not travel through the relay at all. For first-time pairing that is the desktop’s static public key and the single-use pairing token, both of which reach the phone out of band, in the QR code. Impersonating the other direction requires a static secret key.
So the outcome of an attacker winning a slot is denial of that pairing, not compromise of it.
Accepted risks
Section titled “Accepted risks”Things this design does not prevent, stated so nobody has to discover them the hard way.
Anyone holding a slot can end that session. A blind byte-forwarder cannot distinguish a legitimate peer from an attacker who knows the slot, so whoever wins it can occupy it or trip a guard and tear the tunnel down. They cannot read anything or impersonate anyone; they can stop the pairing from completing. Pairing slots are short-lived and single-use, which bounds the window, and both clients recover by starting a new ceremony.
An operator sees connection metadata. IP addresses of both peers, connection and disconnection timing, frame sizes and frequency, and which two connections were paired together. This is inherent to running any relay. Self-hosting removes Kangentic from that picture entirely; The Hosted Relay states it for the instance Kangentic runs.
On the hosted instance, TLS is terminated at Cloudflare, which therefore sees the full request URL including the slot. Because the slot is a derived routing label rather than key material, that does not let Cloudflare read a session or impersonate a peer. What it does give is the same pairing-graph visibility the relay itself has, one layer earlier. Self-hosting without Cloudflare removes that party.
The reconnect slot is stable for the life of a pairing, so an operator can correlate one device’s reconnects over time. This is a property of how the clients derive it, noted here because it is exactly the kind of metadata this section is about.
Availability is bounded by the connection caps, on a single instance. A client with many source addresses can occupy capacity and cause legitimate pairings to be refused. The per-IP cap forces address diversity, the park timeout bounds how long an unpaired connection holds a slot, and MAX_UNPAIRED_CONNECTIONS keeps parking pressure from consuming the whole budget. Because the slot table lives in process memory, two peers must reach the same instance to pair at all, so this does not scale horizontally without slot-aware routing.
Rate limit and cap state is per process. A restart resets every budget, and separate replicas do not share state.
Reporting a vulnerability
Section titled “Reporting a vulnerability”Use GitHub’s private vulnerability reporting on the relay repository, or email hello@kangentic.com with “SECURITY” in the subject line. Issues in the end-to-end crypto layer belong on the main repository instead, and issues in Kangentic’s hosted deployment go to Kangentic directly.
Next steps
Section titled “Next steps”See also:
- The Hosted Relay - how these risks read for the instance Kangentic operates
- Self-Hosting the Relay - moving them onto your own infrastructure
- Relay Reference - the settings named above, with their defaults
- Security & Encryption - the handshake this page depends on
Kangentic is free and open source. A star helps other people find it.
Star on GitHub