PacketRelayDocs

Authenticated rooms#

A room is PacketRelay's N:N session kind and the only one that authenticates anybody. Every member signs its room_hello with an ECDSA P-256 identity key, the relay verifies that signature before the room is allowed to exist, and every frame the room fans out carries a member_id the relay checked itself. This page walks through joining a room, gives the exact order of the events a joiner sees, and states what the attribution on a forwarded frame does and does not prove.

Before you start#

  • An ECDSA P-256 key pair per member, with the public key exported as SPKI DER and base64-encoded with the standard alphabet — the same encoding CryptoService::pubkey_base64 and Web Crypto exportKey('spki') produce (src/protocol.rs:96-101).
  • A signer that emits IEEE-P1363 signatures (r||s, 64 bytes), not DER (src/protocol.rs:105-108).
  • The jarvis-room-hello-v1 canonicalization implemented on the client side and checked against the golden vector. The full contract, field by field, is on Room authentication.
  • A clock within 30 seconds of the relay's. The nonce is unix-epoch milliseconds and is range-checked in both directions (src/room_auth.rs:77, src/room_auth.rs:167-169).
  • A running relay. See Run the relay; the room caps are --max-room-members (default 32) and --max-id-bytes (default 64).
Warning

Unsigned room hellos are refused. This was a breaking cutover — a client that still sends a room_hello carrying only session_id and member_id cannot join any relay built from the current source, and the failure is silent on the wire (see below).

Steps#

  1. Choose a session_id and a member_id. Both are validated identically: non-empty, at most --max-id-bytes bytes, and drawn from [A-Za-z0-9._-] (src/connection.rs:41-65, src/connection.rs:472-478). A bad session id returns {"type":"error","message":"invalid session ID"}; a bad member id returns "invalid member ID". Both then close the socket.
  2. Build the canonical bytes. Concatenate the domain separator jarvis-room-hello-v1, the session id, the member id, the base64 pubkey and the nonce as a decimal u64, separated by the single byte 0x1F (src/protocol.rs:42-61).
  3. Base64 the canonical bytes. The string that is signed is the standard base64 encoding of those bytes, not the bytes themselves (src/protocol.rs:63-73) — the clients sign through &str crypto APIs, so the payload has to survive a string round-trip unchanged.
  4. Sign that ASCII string with the member's P-256 key and base64 the 64-byte r||s result.
  5. Open the WebSocket. Any path that is not /health, /healthz, /ready or /readyz upgrades to the same handler; the path does not select the protocol (src/main.rs:485). There is no TLS in the process, so a deployed relay is reached over wss:// through its hosting edge.
  6. Send the hello as the first frame, and send it as text. A binary first frame is refused and the connection closed (src/connection.rs:530-533). The hello must arrive within --hello-timeout (default 5 seconds).
  7. Wait for room_ready. The envelope is {"type":"room_ready","session_id":"<your session id>"} (src/protocol.rs:134-135), and it is the signal that the slot is registered and the pin committed.

Verification happens before the room exists#

The relay runs the admission sequence in a fixed order, and the order is the security property rather than an implementation detail (src/connection.rs:108-163, src/connection.rs:204-244):

  1. Verify. RoomAuthStore::verify is a pure read that mutates nothing (src/room_auth.rs:157-203). On failure the client gets the coarse reject message and the socket closes before ensure_session runs, so a forged hello cannot auto-create a room and cannot consume a session slot (src/connection.rs:124-141).
  2. Ensure the session. ensure_session(Room) creates the room on the first member (src/connection.rs:158-162). If the id is already a bridge or a broadcast, admission fails with "session kind mismatch".
  3. Register. register_room appends the member, or replaces the stored sender when the same member_id reconnects — a reconnect takes over the existing slot rather than opening a second one. Past --max-room-members the answer is "room at capacity" (src/session.rs:227-256).
  4. Commit. RoomAuthStore::commit pins the pubkey and raises the nonce high-water mark under the write lock, re-checking both invariants (src/room_auth.rs:214-244). If a concurrent hello wins that race, the slot that was just registered is torn down again and the loser receives the reject message (src/connection.rs:217-242).
  5. Send room_ready (src/connection.rs:257-260).

The split exists so that no early return leaves state behind: a capacity refusal, a failed ensure_session, a failed register or a failed first send all happen after verify and before commit, and none of them writes a pin or advances a nonce.

The join notification order is exact#

Three kinds of message are emitted on a successful join, in this order and no other (src/connection.rs:299-329):

# Message Sent to
1 member_joined{member_id: <joiner>} Every other member, one message each.
2 member_joined{member_id: <other>} The joiner — one message per existing other member, as a roster snapshot.
3 member_count{count} The joiner only. The count includes the joiner (src/session.rs:291-297).

Step 2 is the reason a joining client needs no separate roster request: the same envelope that announces a live join is replayed once per incumbent to seed the newcomer's member list. A client that treats member_joined as "someone just arrived" and plays a sound will play one per existing member on every join. Treat it as an upsert into a set, not as an event.

The joiner is the only party told the count. Existing members receive the member_joined in step 1 and nothing else, so they maintain their own roster arithmetic from the join and leave events.

Frames are wrapped, attributed and never echoed#

After room_ready, every text frame a member sends is wrapped rather than forwarded verbatim (src/connection.rs:367-388):

{"type":"member_frame","member_id":"<sender>","payload":"<the raw text frame>"}

The payload is the sender's frame as an opaque string. The relay does not parse it, does not validate it as JSON, and does not encrypt it — payload encryption is a client responsibility (src/main.rs:4-5).

The member_id on that envelope is the one the relay authenticated at admission. Any user_id, from or author field inside payload is self-asserted by the sender and proves nothing on its own (src/protocol.rs:146-150). A consumer treats the envelope member_id as the authoritative sender identity and validates any in-payload claim against it, because the relay forwards the payload untouched and a member is free to write whatever it likes inside its own frame.

A member never receives its own frame. Fan-out skips the sender by comparing the stored channel (src/session.rs:260-275), so a client that renders its own outgoing messages does so locally rather than waiting for an echo.

Two shapes are not forwarded at all. The exact envelope {"type":"ping"} — a JSON object under 64 bytes with exactly one key, type, whose value is ping — is recognised as a keepalive and dropped (src/connection.rs:361, src/connection.rs:480-488); it is still charged to the per-connection message budget. Binary frames are silently discarded and are not charged (src/connection.rs:405).

Departure sends two messages#

When a member's socket closes or errors, the unregister path is ownership-aware: it removes the slot only if the stored channel is the one closing, so a stale connection cannot evict the member that already reconnected into the same slot (src/session.rs:299-331). Remaining members then receive, in order, member_left{member_id} followed by member_count{count} (src/connection.rs:441-464).

When the last member leaves, the session is removed and forget_session drops every pin for that room (src/room_auth.rs:248-250). Pins live in process memory and are not persisted, so a relay restart forgets every binding and the next valid signer to present a member id pins it afresh.

Important

A room_hello missing pubkey, nonce or sig fails deserialization outright — all five fields are required (src/protocol.rs:93-109) — and an unparseable hello is logged and dropped with no error frame at all (src/connection.rs:491-546). The client sees a closed socket and nothing else. No WebSocket Close frame with a status code is sent on any relay-side refusal; the sink is simply dropped.

Verify#

A room is working when all of the following hold.

  1. The first client receives {"type":"room_ready","session_id":"<id>"} and then {"type":"member_count","count":1}.
  2. A second client joining the same id receives room_ready, one member_joined naming the first member, then member_count with count of 2 — in that order.
  3. The first client receives exactly one member_joined naming the second member, and no member_count.
  4. A text frame sent by the second client arrives at the first as {"type":"member_frame","member_id":"<second>","payload":"…"}, and does not arrive back at the second.
  5. Closing the second client delivers member_left then member_count with count of 1 to the first.
  6. Reconnecting the same member_id with the same key and a higher nonce succeeds and replaces the slot; reconnecting with a different key is refused with "member id bound to a different identity".

The pinning, nonce and fingerprint rules are covered by 20 unit tests in src/room_auth.rs:351, and the roster and fan-out rules by 11 in src/session.rs:489. Run them with:

cargo test --locked
Note

There are no end-to-end listener tests for room forwarding, hello timeouts or reconnect races — the room tests exercise the auth store and the session map directly. The gap is recorded in the repository's own backlog (BACKLOG.md:101-111), so the six checks above are worth running by hand against a real relay rather than inferred from a green suite.

If it does not work#

What you see What happened
The socket closes immediately after the hello, with no frame The hello did not deserialize — a missing pubkey, nonce or sig, a non-u64 nonce, malformed JSON, or a binary first frame. Unparseable hellos are dropped without a reply.
Nothing happens, then the socket closes after about five seconds The hello did not arrive within --hello-timeout.
The TCP connection is dropped with no HTTP response at all The connection rate limiter refused the attempt at accept, before a byte was read. Only a warn! is logged.
{"type":"error","message":"invalid member ID"} The member_id is empty, longer than --max-id-bytes, or contains a character outside [A-Za-z0-9._-] — a space, a newline and an emoji all fail.
{"type":"error","message":"invalid room hello signature"} One of: nonce outside ±30 s of the relay clock, nonce not strictly greater than the last one accepted for the slot, a signature that does not verify, or malformed base64 or DER. The message is deliberately coarse.
{"type":"error","message":"member id bound to a different identity"} The slot is pinned to a different pubkey, or the member id carries a fingerprint prefix that does not match the presented key.
{"type":"error","message":"room at capacity"} The room already holds --max-room-members distinct members.
{"type":"error","message":"session kind mismatch"} The session_id is already in use as a bridge or a broadcast session. One id is one kind.
{"type":"error","message":"server at capacity"} The global --max-sessions ceiling was reached while creating the room.
{"type":"error","message":"message rate exceeded"} The per-connection message budget was spent. Keepalive pings count toward it.
Frames are accepted but never arrive anywhere The sender is the only member, or the frames are binary. Binary frames are discarded without a reply and without being charged.

The two crypto messages are coarse on purpose and do not say which check failed; the precise reason is logged server-side (src/connection.rs:128-133). Room authentication maps each message back to its reject reasons.

A room session_id is a capability secret — possession of it plus a valid signature is the whole admission boundary — which is why the relay redacts it in logs for Role::Member, printing the first six characters and the length instead of the whole id (src/connection.rs:549-559). Every other role logs its session id in full.