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_base64and Web CryptoexportKey('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-v1canonicalization 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).
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#
- Choose a
session_idand amember_id. Both are validated identically: non-empty, at most--max-id-bytesbytes, 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. - 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 decimalu64, separated by the single byte0x1F(src/protocol.rs:42-61). - 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&strcrypto APIs, so the payload has to survive a string round-trip unchanged. - Sign that ASCII string with the member's P-256 key and base64 the
64-byte
r||sresult. - Open the WebSocket. Any path that is not
/health,/healthz,/readyor/readyzupgrades 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 overwss://through its hosting edge. - 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). - 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):
- Verify.
RoomAuthStore::verifyis 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 beforeensure_sessionruns, so a forged hello cannot auto-create a room and cannot consume a session slot (src/connection.rs:124-141). - 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". - Register.
register_roomappends the member, or replaces the stored sender when the samemember_idreconnects — a reconnect takes over the existing slot rather than opening a second one. Past--max-room-membersthe answer is"room at capacity"(src/session.rs:227-256). - Commit.
RoomAuthStore::commitpins 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). - 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.
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.
- The first client receives
{"type":"room_ready","session_id":"<id>"}and then{"type":"member_count","count":1}. - A second client joining the same id receives
room_ready, onemember_joinednaming the first member, thenmember_countwithcountof 2 — in that order. - The first client receives exactly one
member_joinednaming the second member, and nomember_count. - 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. - Closing the second client delivers
member_leftthenmember_countwithcountof 1 to the first. - Reconnecting the same
member_idwith 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
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.
Related#
- Room authentication — the four checks, the signing payload and the golden vector.
- Message reference — every hello and response envelope, field by field.
- Core concepts — sessions, roles and how the three protocols differ.
- Limits & capacity — member caps, message budgets and backpressure.
- Security model — what the relay authenticates and what it does not.
- Troubleshooting — the failures shared across all three protocols.