PacketRelayDocs

Security model#

PacketRelay authenticates one thing: a room_hello. Every other admission decision is made on possession of a session id and the role the client picks for itself. The relay does not encrypt payloads, holds no accounts, and cannot revoke anything.

This page states exactly what is checked, what is not, and what a deployment has to supply to make up the difference.

What is authenticated#

Only room_hello carries credentials — a public key, a nonce and a signature — and only room_hello is verified. RoomAuthStore::verify is pure and runs four checks in order — room_auth.rs:157-203:

Check Rule Failure
Nonce freshness The nonce is unix-epoch milliseconds and must be within ±30 s of the relay's clock — room_auth.rs:77, 167-169 Rejected
Signature ECDSA P-256 over the canonical signing payload, verified against the pubkey carried in the hello — room_auth.rs:171-177 BadSignature
Fingerprint binding Applied only when member_id matches <16-lowercase-hex>.<rest>: the prefix must equal the first 8 bytes of SHA-256(SPKI-DER) in lowercase hex — room_auth.rs:181-188, 281-311 FingerprintMismatch
Trust on first use An existing pin for this session_id + member_id must carry the same pubkey, and the nonce must be strictly greater than the stored high-water mark — room_auth.rs:190-201 PubkeyMismatch, StaleNonce

Verification mutates nothing. The pin and the nonce high-water mark are written only by commit, which re-checks pubkey equality and nonce monotonicity under the write lock before inserting — room_auth.rs:214-244. A hello that fails verification is rejected before ensure_session runs — connection.rs:124-141 — so an unauthenticated caller cannot auto-create a room and cannot consume the global session ceiling by trying.

Pins are keyed session_id -> member_id -> Pin and live only as long as the room does. When the last member leaves, the session is removed and forget_session drops every pin it held — room_auth.rs:248-250. Emptying a room is therefore the only revocation mechanism the relay has, and it is indiscriminate.

The full signing payload, the domain separator and the golden vector are in Room authentication.

The relay never signs anything#

p256::ecdsa::SigningKey appears only under #[cfg(test)]room_auth.rs:356-357. Verification is one-directional: a client can prove its identity to the relay, and the relay proves nothing to the client. There is no server key, no certificate of its own, and no channel binding. A client that needs to know it is talking to the right relay gets that from TLS at the edge or from nowhere.

The ecdh feature of p256 is compiled in and no ECDH code path exists — Cargo.toml:20. No key agreement happens anywhere in the process.

What is not authenticated#

Four of the five hellos carry a session_id and nothing else — protocol.rs:81-91. Possession of the id plus self-selection of a role is the entire admission boundary — BACKLOG.md:115-118.

Hello Proves Guards
desktop_hello Possession of the session id None. A second desktop replaces the first — session.rs:169-175
mobile_hello Possession of the session id The session must already exist, and a second mobile is refused with mobile already connectedsession.rs:176-181
host_hello Possession of the session id None. A second host replaces the first — session.rs:202-206
spectator_hello Possession of the session id The spectator cap only. The hello creates the broadcast session if it does not exist — connection.rs:99-107
room_hello A P-256 signature bound to a pinned key Nonce window, fingerprint binding where the id carries one, TOFU pin, strict nonce monotonicity

Two consequences deserve to be stated plainly.

A desktop or host reconnect silently evicts the incumbent. The stored sender is replaced with no proof that the newcomer is the same party — session.rs:169-175, 202-206. The evicted socket stays open, receives nothing further, and is told nothing. Cleanup is ownership-aware, so the evicted connection's later teardown cannot remove its successor or emit a false peer_disconnectedsession.rs:378-451, test session.rs:617-647 — which makes the takeover clean but does not make it authenticated.

Anyone holding a broadcast id can create the session. A spectator_hello calls ensure_session(Broadcast) rather than joining an existing one, so an id that has never hosted anything still produces an empty session that occupies a slot in the 256-session ceiling until the reaper takes it.

Warning

Bridge and broadcast session ids are bearer credentials. Anyone who learns one can take the desktop or host slot away from its current occupant, and the current occupant is given no signal that it happened.

Defining an explicit trust model for these roles is PlannedBACKLOG.md:115-122. Nothing about it exists in src/ today.

There is no Origin check#

The listener accepts the WebSocket upgrade without examining the Origin header — anywhere. A page served from any origin can open a connection to a reachable relay and, given an id, claim a role on it. Native clients omit Origin entirely, so there is nothing to allowlist against yet. Defining the policy is PlannedBACKLOG.md:151-158.

Path does not narrow this either. Any target that is not one of the four reserved health and readiness paths routes to the same WebSocket handler, and the protocol is chosen by the first application message rather than by the URL — main.rs:485, connection.rs:502-524. A firewall rule written against /ws/host or similar would match nothing; those paths do not exist.

No accounts, ACLs, tickets or revocation#

There is no user record, no authorisation list, no single-use ticket, and no way to invalidate a credential in flight — BACKLOG.md:41-51. To remove a party from a bridge or a broadcast you stop using that session id and distribute a new one. To remove a member from a room you empty the room, which drops every pin in it.

First-mover risk for unstructured member ids#

Fingerprint-prefixed member ids are cryptographically bound: the id itself contains the first 8 bytes of the SHA-256 of the SPKI DER, so a key that does not hash to the prefix is rejected before any pin exists. Unstructured member ids — random pair ids, raw presence UUIDs — carry no such binding, so the first valid signer to present one pins it and every later key for that id is refused as PubkeyMismatch. The code documents this as residual risk — room_auth.rs:41-45 — and binding every supported id form is PlannedBACKLOG.md:124-131.

The relay does not encrypt payloads#

Payload confidentiality is a client responsibility — main.rs:4-5. The relay holds no key material of its own and applies no transform to application data:

  • Bridge frames are forwarded verbatim to the peer — connection.rs:357-366.
  • Broadcast frames are fanned out from the host verbatim — connection.rs:389-394.
  • Room frames are wrapped as {"type":"member_frame","member_id":"<sender>","payload":"<raw text>"} and sent to every other member — connection.rs:367-388.

The member_id on that envelope is relay-authenticated: it is the id the sender signed for, not a value copied out of the payload. Anything inside payload, including a user_id field, is self-asserted and carries no more weight than the sender's honesty — protocol.rs:146-150.

The process speaks plain HTTP and plain WebSocket. There is no TLS in-process, so wss:// works only because something in front of the relay terminates it.

All state is in process memory#

Sessions, TOFU pins, nonce high-water marks and limiter state are held in in-memory maps — session.rs:93, room_auth.rs:141, rate_limit.rs:78. There is no database, no file, and no cache. A restart loses all of it.

Lost on restart Effect on clients
Every session Bridges, broadcasts and rooms cease to exist; a reconnecting mobile gets session not found and the pairing must be re-established
Every TOFU pin The next valid signer for a member id pins it again, first-mover rules and all
Every nonce high-water mark The strict-monotonicity guard for each member restarts from nothing
Limiter state Per-IP concurrency counts and attempt history reset to zero

The nonce consequence is worth being precise about. Replay protection has two independent parts: the ±30 s freshness window, which is stateless, and the strictly-monotonic high-water mark, which is not. After a restart only the freshness window is left, so a captured room_hello replayed inside its 30 second window is accepted where before it would have been refused as StaleNonce. That follows from the two documented checks; no test exercises a restart, so it is a derived consequence rather than an observed one.

There is no graceful shutdown#

main runs an unconditional accept loop and handles no termination signal — main.rs:357-381. SIGTERM ends the process where it stands: in-flight connections die mid-frame, no close is sent, and every session goes with them. On a platform that restarts on failure this is also a routine event rather than a rare one. Graceful drain is PlannedBACKLOG.md:161-166.

Logging redacts room ids only#

For Role::Member the session id is logged as its first six characters plus …(len=N), because a room id is a capability secret — connection.rs:247-248, 549-559. Every other role logs its session id in full, and those ids are admission credentials too. Deciding the credential status of every id and redacting consistently is PlannedBACKLOG.md:142-148.

Important

Read access to the relay's logs is equivalent to the ability to join any bridge or broadcast that appears in them. Treat the log stream as a secret store until that backlog item lands.

What this means for a deployment#

The relay is a routing component with one authenticated protocol. Everything below is the deployment's job, not a setting you can turn on.

Treat every session id as a secret. Generate them unguessably, hand them over out of band, use them once, and rotate them when a party leaves. The charset is [A-Za-z0-9._-] and the length ceiling is --max-id-bytes, 64 bytes by default, which leaves ample room for a random id. A short or predictable id is a takeover.

Terminate TLS at the edge. The process listens on plain HTTP and WebSocket on 0.0.0.0:$PORT. Never expose that port directly; put a TLS-terminating proxy or platform router in front of it so clients connect with wss://.

Do not scale horizontally without shared coordination. Two instances mean two session maps, two pin stores and two limiters. Two clients that land on different instances cannot see each other, a member reconnecting to the other instance faces an empty pin store, and the connection caps apply per instance rather than per service. Externalising state is PlannedBACKLOG.md:178-186. Until then, run one instance and treat that as a supported limit.

Do not rely on the relay for confidentiality. Encrypt end to end in the clients if the payloads matter. Every frame passes through the process in plaintext and sits in an outbound queue on its way out, and a takeover of a desktop or host slot delivers that stream to whoever performed it.

Do not rely on the relay for identity outside a room. Only member_id on a member_frame is relay-attested. In a bridge or a broadcast, nothing about the sender is verified at all.

Wire up your own liveness checks. /health and /ready exist in the code but railway.json declares no healthcheckPath, so nothing in this repository connects them to a platform probe. A wedged process is not detected until someone notices. Whether a health check is configured outside the repository is unverified.

Expect a restart to be visible. With no graceful shutdown and no persisted state, every deploy is a full disconnect. Clients need reconnect and re-pairing logic; the relay will not help them recover.