PacketRelayDocs

Invariants & tripwires#

Rules in the PacketRelay codebase that read like tidy-up opportunities and are not. Each entry states the rule, why breaking it looks reasonable, what actually goes wrong, and the test that fails when you try. If you are about to simplify one of these, read its entry first.

Important

Two of these are not enforceable from inside this repository at all. The jarvis-room-hello-v1 canonicalization is reproduced byte-for-byte by four clients that live elsewhere, and the wire fixtures in testdata/ are compiled into the binary. Their tripwires catch a local change; nothing here catches a client that has already shipped against the old bytes.

Every unregister path checks same_channel#

Rule. A connection may only remove its own registration. unregister (session.rs:383-451) and unregister_member (session.rs:299-331) both take the caller's mpsc::Sender and compare it against the stored one with Sender::same_channel before touching anything. A mismatch returns false and mutates nothing.

Why breaking it looks safe. The handler knows its session_id, its Role and its member_id. session.members.retain(|m| m.member_id != member_id) is one line, obviously correct, and does not need the sender threaded through four call sites. Passing a channel handle around purely to compare it against another channel handle reads like defensive noise.

What actually goes wrong. Three of the five registration paths replace rather than reject on reconnect. A desktop reconnect silently overwrites desktop_tx (session.rs:169-175), a host reconnect overwrites host_tx (session.rs:202-206), and a room member reusing its member_id has its tx replaced in place (session.rs:227-256). The old socket is still open at that moment; its task discovers the close a moment later and runs cleanup.

Without the ownership check, that late cleanup evicts the successor — the connection that just reconnected and is working. The session then has no desktop, no host, or one fewer member than it should, and the surviving peers receive a peer_disconnected, host_disconnected or member_left for a client that is still connected. Worse, when the eviction empties the last slot the whole session is removed (session.rs:412-414, session.rs:439-441), and for a room that also drops every TOFU pin through forget_session. The reconnecting client's next frame goes nowhere.

The failure is timing-dependent, invisible in logs beyond an ordinary disconnect line, and reproduces only when the old and new connections overlap — which is exactly what a flaky network produces.

Pinned by. Three tests in session.rs, one per session kind, each of which asserts a negative:

  • unregister_member_after_reconnect_is_noop (session.rs:587) — registers a member, reconnects it with a new channel, then runs the first connection's cleanup and asserts the member is still there.
  • stale_bridge_cleanup_does_not_remove_reconnected_desktop (session.rs:618).
  • stale_broadcast_cleanup_does_not_remove_reconnected_host (session.rs:650).

A test that pins a deliberate no-op is not redundant. It is the only thing standing between the one-line simplification and a class of bug that only appears under reconnect load.

jarvis-room-hello-v1 is a cross-repository contract#

Rule. ROOM_HELLO_SIG_DOMAIN = "jarvis-room-hello-v1" (protocol.rs:13), ROOM_HELLO_SEP = 0x1F (protocol.rs:18), and the field order domain ‹0x1F› session_id ‹0x1F› member_id ‹0x1F› pubkey ‹0x1F› nonce (protocol.rs:42-61) are frozen. So is the outer wrapping: what is actually signed is STANDARD base64(canonical_bytes) as an ASCII string, not the raw bytes (protocol.rs:63-73).

Why breaking it looks safe. Every part of it looks like an implementation detail with no external reader. The domain string embeds a product name this project no longer uses. 0x1F is an unusual choice where a colon or a newline would be more readable. Signing the base64 of the bytes rather than the bytes themselves is a wasted encode. Reordering the fields to match the struct definition is a two-minute change that leaves every test in protocol.rs passing, because those three tests cover response serialisation, not the signing payload.

What actually goes wrong. Four Room clients in other repositories construct these bytes themselves before signing — protocol.rs:11-12 names them: desktop pair, desktop presence, desktop chat JS, mobile chat JS. They are not built from this crate and they do not import anything from it. A change here does not break them; it makes every signature they produce fail to verify.

The relay's only symptom is "invalid room hello signature" on every room join — a message that is deliberately coarse (see below) and therefore names neither the cause nor which client is affected. Nothing logs "the canonical bytes changed", because from the relay's point of view nothing did.

Each element earns its place. The domain separator exists so a room-hello signature cannot be cross-presented to any other signer sharing the same ECDSA identity key; pair frames use the disjoint domain jarvis-pair-sig-v1 (protocol.rs:9). 0x1F is chosen because it is disjoint from the base64 alphabet, from hostnames and from the member_id charset, so the delimited fields cannot be made ambiguous by a crafted field value. The base64 wrapping exists so the payload round-trips losslessly through the clients' &str-based crypto APIs — CryptoService::{sign,verify} on desktop and Web Crypto on mobile — which cannot take arbitrary bytes.

Pinned by. golden_signed_hello_payload_vector (room_auth.rs:807), which asserts one literal:

signed_hello_payload("sid", "m1", "pk", 42)
    == "amFydmlzLXJvb20taGVsbG8tdjEfc2lkH20xH3BrHzQy"

Any change to the domain, the separator, the field order or the outer encoding moves that string. It is the only thing in this repository that catches the change, and it catches it as a one-line diff rather than as a client outage.

Warning

The vector proves the relay still produces the historical bytes. It proves nothing about the four clients, which live in other repositories and were not inspected. Whether each of them emits this canonicalization correctly is unverified here.

Verify is pure; commit mutates#

Rule. RoomAuthStore::verify (room_auth.rs:157) takes a read lock and leaves no state behind. RoomAuthStore::commit (room_auth.rs:214) takes the write lock and is the only thing that inserts or updates a Pin { pubkey, last_nonce }. The connection handler calls commit only after the slot is actually registered (connection.rs:217-242).

Why breaking it looks safe. The two functions re-check the same two conditions — pubkey equality and nonce monotonicity. Folding the pin write into verify removes the duplicated check, removes a second lock acquisition, and removes the whole verify-then-commit dance from the caller. One function, one lock, one pass.

What actually goes wrong. Between verify and commit the connection can still be refused, and there are four ways it happens: the room is at --max-room-members, ensure_session refuses on a kind clash or the global session cap, register_room fails, or the first send to the new client errors. If verifying had already written the pin, every one of those paths would leave a pin and a nonce high-water mark behind for a member that was never admitted.

That poisons the slot. The nonce high-water is now set to a value the legitimate client has already used, so its next hello — which will carry a nonce from the same millisecond range — fails the strict monotonic check and is rejected as a replay. A capacity rejection would have converted itself into a lockout that persists until the room empties and forget_session drops the pins (room_auth.rs:248). The client sees "invalid room hello signature" and has no way to distinguish it from a real crypto failure.

The split has a second job. commit re-checks under the write lock rather than trusting the verify that preceded it, so two concurrent valid hellos for the same slot cannot race a replay through the read-lock window. The loser is torn back down: connection.rs:227-241 unregisters the just-registered slot, calls forget_session if that emptied the room, and sends the reject message — so a lost race never leaves a slot held without a pin behind it.

Pinned by. verify_alone_leaves_no_pin (room_auth.rs:688) verifies a valid hello and asserts both pinned(…) and last_nonce(…) are still None. commit_advances_high_water_only_after_register (room_auth.rs:705) pins the other direction.

Nonce monotonicity is strict, not >=#

Rule. A hello is accepted only when its nonce is strictly greater than the last nonce accepted for that (session_id, member_id) slot. Both the read-side check (room_auth.rs:198) and the write-side re-check (room_auth.rs:228) are if nonce <= pin.last_nonce { return Err(StaleNonce) }.

Why breaking it looks safe. nonce is unix-epoch milliseconds (room_auth.rs:313-319). Two hellos from the same client in the same millisecond are not an attack, they are a fast reconnect, and rejecting the second one looks like an off-by-one that will bite a real user on a fast network. Relaxing <= to < — accepting an equal nonce — makes that case work and appears to cost nothing, because the signature still has to verify and the ±30 s freshness window still applies.

What actually goes wrong. Relaxing it removes the entire anti-replay guarantee. The freshness window (NONCE_WINDOW_MS = 30_000, room_auth.rs:77) is a sanity bound, not a defence: it is explicitly documented as such, and it means a captured hello stays fresh for up to thirty seconds either side of the relay's clock. A hello is a complete, self-contained, replayable credential during that window.

The strict comparison is what closes it. Once a hello has been committed, its own nonce is the high-water mark, so replaying that exact hello is rejected — the replay carries the same nonce, and the same nonce is no longer greater. Since room registration replaces the stored tx for a matching member_id (session.rs:227-256), a successful replay would evict the live connection holding the slot and hand routing for that member to whoever captured the frame. Under >= the credential becomes reusable for thirty seconds, which is long enough to be practical.

A genuine reconnect is unaffected because it signs a fresh hello with the current clock, which is strictly later than the one it sent before. Two hellos inside the same millisecond are the price, and they are the correct price.

Pinned by. Three tests in room_auth.rs, covering the equal, older and newer cases separately: replayed_same_nonce_rejected (room_auth.rs:619), older_nonce_rejected (room_auth.rs:640) and strictly_newer_reconnect_accepted (room_auth.rs:663). The first is the one that fails when <= becomes <; the third is what stops someone "fixing" it by tightening the check instead.

The session cap is enforced under the creation write lock#

Rule. ensure_session (session.rs:112) takes the write lock once and does the existence check, the kind check, the map.len() >= max_total_sessions test and the insert inside it (session.rs:117-132). The count is never read outside that lock and then acted on.

Why breaking it looks safe. A count() helper already exists on the store. Checking capacity with a cheap read lock before taking the expensive write lock is the textbook optimisation, and the window between them is microseconds.

What actually goes wrong. The window is exactly where the attack lives. Every inbound connection that reaches a hello can create a session — a desktop_hello creates a bridge, a spectator_hello auto-creates a broadcast (connection.rs:99-107), and a room_hello that passes verification creates a room. With the check outside the lock, N connections arriving together all read a count below the cap and all proceed to insert, so the map overshoots by as much as the concurrency. The cap is the only bound on the session map, and the map is unbounded process memory (session.rs:93).

The kind check has the same shape and the same reason. Reading the existing session's kind under a read lock and then inserting under a write lock lets two hellos of different kinds for the same session_id both observe "no session here" and both create one, with the loser's participants silently routed into a session of the wrong shape.

Pinned by. session_cap_is_atomic_under_concurrent_creation (session.rs:750) spawns 32 concurrent ensure_session calls against a store capped at 4, and asserts exactly 4 returned Ok(true) and exactly 28 returned Err("server at capacity"). The equality assertions are the point — a test that merely checked "at most 4 exist afterwards" would pass against several broken implementations.

Reject reasons stay coarse at the boundary and precise in the log#

Rule. RoomHelloRejectReason has five variants (room_auth.rs:82-99) and message() (room_auth.rs:106) collapses them to two client-facing strings:

Variants Client sees
MalformedCrypto, BadSignature, StaleNonce "invalid room hello signature"
PubkeyMismatch, FingerprintMismatch "member id bound to a different identity"

The precise variant is logged server-side with reason = ?reason (connection.rs:130).

Why breaking it looks safe. This is the single most tempting change in the codebase. Five distinct failure modes are collapsed into two opaque strings, and a client developer debugging a room join cannot tell a clock-skew problem from a bad base64 encoding from a genuine key conflict. Returning the variant name costs one line and would save hours of support.

What actually goes wrong. The two groups are drawn along a disclosure line, not a convenience line. Splitting StaleNonce out of the first group tells an attacker that the signature verified and only the nonce was wrong, which converts a captured hello from "rejected" into "rejected, keep trying with a different timestamp" — a working oracle for replay timing. Splitting MalformedCrypto from BadSignature distinguishes an encoding error from a cryptographic failure, which is a probe for the exact encodings the verifier accepts.

The second group leaks slot state. PubkeyMismatch means the slot is already pinned to a different key; FingerprintMismatch means it is not pinned yet and the id embeds a fingerprint that does not match. Distinguishing them tells an enumerator which member_ids are live in a room whose session_id they hold — and the room session_id is a capability secret, so anything that turns possession of it into a roster is a real escalation.

room_auth.rs:103-105 states the rule in the source: coarse enough not to leak which check failed beyond what an attacker can already infer. The debugging need is answered on the other side — the server log has the exact variant, which is why the enum exists as an enum rather than as two strings.

Note the same principle applied one level up: an absent credential yields "signed room hello required" (connection.rs:150), which is distinct because a client that sent no signature already knows it sent no signature.

Pinned by. The five reject paths each have their own test — bad_signature_rejected (room_auth.rs:478), presented_pubkey_not_signing_key_rejected (room_auth.rs:491), squatter_with_different_key_rejected (room_auth.rs:455), malformed_pubkey_rejected (room_auth.rs:592) and fingerprinted_member_id_wrong_key_rejected_pre_pin (room_auth.rs:754) — which assert the variant, so the mapping to messages can be reviewed as a single function rather than inferred from scattered string comparisons.

testdata/ is a compile-time input, not test data#

Rule. protocol.rs:161-169 pulls both fixture files into the binary with include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/testdata/…")). Any build tree that does not contain testdata/ fails to compile.

Why breaking it looks safe. The directory is named testdata, the only references to it are inside a #[cfg(test)] module, and Docker build contexts are usually trimmed to what the runtime needs. Dropping COPY testdata ./testdata from Dockerfile:7 looks like removing test material from a production image.

What actually goes wrong. include_str! is a macro expanded during compilation, and macro expansion happens before cfg stripping has any bearing on whether the file must exist. The build fails at cargo build --locked --release (Dockerfile:9) with a macro error naming a path — not a test failure, and not anything that mentions Docker. The image never builds.

The fixtures are compiled in on purpose: they are the same bytes the clients deserialise, so session_ready_json_matches_fixture and room_ready_json_matches_fixture compare what RelayResponse serialises against the shared file rather than against a copy pasted into a test.

Pinned by. Nothing in CI. .github/workflows/ci.yml does not run a Docker build, so this one is caught by cargo test --locked locally only if the file is missing from the source tree — a Dockerfile that stops copying it is caught at deploy time. That is the gap; treat Dockerfile:5-7 as load-bearing.

Shorter tripwires#

Same class, less to say about each.

The keepalive envelope is matched exactly. is_relay_keepalive_ping (connection.rs:480-488) requires length under 64, valid JSON, an object with exactly one key, that key "type", that value "ping". Loosening it to a substring or a two-key tolerance would let real application frames be swallowed as keepalives and never forwarded — a silent data loss with no error anywhere. Pinned by keepalive_detection_requires_exact_envelope (connection.rs:610), which asserts {"type":"ping","id":1} is not a keepalive.

Room session ids are redacted in logs; other ids are not. log_session (connection.rs:552-559) emits six characters plus a length for Role::Member and the full id for everyone else. This is not an oversight in either direction: a room session_id is the room's capability secret, so logging it in full hands room access to anyone with log read. The asymmetry is itself flagged as open work — bridge and broadcast ids also control admission and are logged in full (BACKLOG.md:145-148).

member_frame.member_id is relay-authenticated; the payload is not. protocol.rs:146-150 states it: consumers must treat the envelope's member_id as the sender identity, and any user_id inside payload is self-asserted. The relay stamps the envelope from the registered slot (connection.rs:367-388) and never reads the payload. Any client that trusts the inner field has undone the room's entire authentication.

A member never receives its own frame. Fan-out target selection excludes the sender (session.rs:260-275), pinned by room_targets_exclude_sender_include_others (session.rs:514). Clients rely on this for local echo; sending the sender its own frame would double every message in every room client at once.

Error message literals are asserted. ensure_session and the register functions return &'static str and the tests match on the exact text — session.rs:772 matches Err("server at capacity"). Rewording a message for readability is a test-visible change on purpose, because those strings cross the wire to clients.

Next#