PacketRelayDocs

Room authentication#

room_hello is the only message PacketRelay authenticates. A member signs a canonical byte string with an ECDSA P-256 identity key, the relay verifies that signature, binds the member_id to the key on first use, and refuses any later join for that slot that presents a different key or a nonce it has already seen. This page enumerates every rule that governs that exchange: the four checks and their order, the canonicalization contract, the wire encodings, the reject reasons and the residual risks.

Rows follow source order in src/room_auth.rs and src/protocol.rs, not alphabetical order, because the checks are order-dependent and reading them in any other sequence misrepresents them.

The four checks, in order#

RoomAuthStore::verify is a pure read — it mutates nothing, so a rejected hello leaves no pin and no nonce high-water behind (src/room_auth.rs:157-203). The checks short-circuit: the first failure returns and nothing after it runs.

# Check Rule Reject reason Source
1 Nonce freshness nonce.abs_diff(now_millis()) <= 30_000 — within ±30 s of the relay clock in either direction StaleNonce src/room_auth.rs:167-169, src/room_auth.rs:321-325
2 Signature ECDSA P-256 verify of sig over signed_hello_payload(session_id, member_id, pubkey, nonce) against the carried pubkey BadSignature if well-formed but wrong; MalformedCrypto if any encoding fails to decode src/room_auth.rs:171-177, src/room_auth.rs:335-349
3 Fingerprint binding Applied only when member_id matches <16-lowercase-hex>.<rest>: the prefix must equal SHA-256(SPKI DER)[..8] in lowercase hex FingerprintMismatch; MalformedCrypto if the pubkey is not valid base64 src/room_auth.rs:181-188, src/room_auth.rs:281-311
4 TOFU pin and monotonic nonce If a pin exists for (session_id, member_id): pubkey must equal the pinned key, and nonce must be strictly greater than last_nonce PubkeyMismatch; StaleNonce src/room_auth.rs:190-201

Only check 4 takes a lock. The first three are pure computation over the hello's own fields, so a hello that fails any of them is refused without touching shared state at all. Check 3 is skipped entirely for member ids that carry no fingerprint prefix, which is where the residual risk documented at the end of this page comes from.

Important

Verification happens before ensure_session. A hello that fails any of the four checks is refused and the socket closed before the room can be auto-created, so a forged hello cannot create a session or consume a session slot (src/connection.rs:124-141).

The signing payload#

The canonicalization is a cross-repository contract. Four Room clients — the desktop pair client, desktop presence, desktop chat JS and mobile chat JS — must produce identical bytes, and the relay verifier must reproduce them exactly (src/protocol.rs:11-12).

# Field Value Encoding
1 Domain separator jarvis-room-hello-v1 — the constant ROOM_HELLO_SIG_DOMAIN ASCII, literal (src/protocol.rs:13)
2 session_id The room id from the hello UTF-8 bytes, verbatim
3 member_id The member id from the hello UTF-8 bytes, verbatim
4 pubkey The hello's pubkey field as the base64 string, not the decoded DER ASCII, verbatim
5 nonce The hello's nonce Decimal u64, no padding, no sign

Fields are joined by the single byte 0x1F (ASCII Unit Separator), the constant ROOM_HELLO_SEP (src/protocol.rs:18). There is no separator before the domain and none after the nonce, and no field is length-prefixed, escaped or trimmed. The separator was chosen because it is disjoint from the base64 alphabet, from hostnames and from the member_id charset, so the delimited fields are unambiguous without escaping.

The layout, as the source states it (src/protocol.rs:22-25):

ROOM_HELLO_SIG_DOMAIN ‹0x1F› session_id ‹0x1F› member_id ‹0x1F› pubkey ‹0x1F› nonce(decimal)

The string actually fed to sign and verify is not those bytes. It is their standard base64 encoding, as an ASCII string (src/protocol.rs:63-73). That extra hop exists because the clients sign through &str crypto APIs — CryptoService::{sign,verify} on desktop, Web Crypto on mobile — and the canonical bytes contain 0x1F, which does not survive a string round-trip intact. Base64 makes the payload printable ASCII, so it passes through those APIs unchanged.

Pair frames use a different domain, jarvis-pair-sig-v1 (src/protocol.rs:9). The two spaces are disjoint, so a room-hello signature cannot be cross-presented to a pair-frame verifier that shares the same identity key.

Golden vector#

This constant is the conformance anchor. The JS conformance tests in the mobile and desktop client repositories assert that btoa(canonical("sid","m1","pk",42)) equals the same string, so any drift in the domain separator or the field separator fails the build on at least one side (src/room_auth.rs:799-812):

const GOLDEN_PAYLOAD_SID_M1_PK_42: &str = "amFydmlzLXJvb20taGVsbG8tdjEfc2lkH20xH3BrHzQy";
Input Value
session_id sid
member_id m1
pubkey pk
nonce 42
Canonical bytes jarvis-room-hello-v1 0x1F sid 0x1F m1 0x1F pk 0x1F 42
signed_hello_payload(…) amFydmlzLXJvb20taGVsbG8tdjEfc2lkH20xH3BrHzQy

The vector deliberately uses pk, which is not a real base64 SPKI key, because it pins the string canonicalization — a stage that runs before any key is decoded. A client whose canonicalizer passes this vector has the field order, the separator and the encoding right, whatever key it goes on to use.

Note

Whether the four external Room clients actually emit this canonicalization correctly is unverified here. Those clients live in other repositories; the relay side of the contract is what this documentation covers, and the golden vector is the only shared artefact that proves the two sides agree.

Wire formats#

Field JSON type Encoding Decoded by Failure
pubkey string Standard base64 (padded) of ECDSA P-256 SPKI DER base64 STANDARD, then p256::PublicKey::from_public_key_der MalformedCrypto (src/room_auth.rs:336-343)
sig string Standard base64 (padded) of a 64-byte IEEE-P1363 signature, r concatenated with snot DER base64 STANDARD, then p256::ecdsa::Signature::from_slice MalformedCrypto (src/room_auth.rs:346-347)
nonce number Unix-epoch milliseconds as a u64 serde Deserialization failure (src/protocol.rs:102-104)
session_id string Non-empty, ≤ --max-id-bytes, charset [A-Za-z0-9._-] src/connection.rs:472-478 "invalid session ID"
member_id string Same rules as session_id src/connection.rs:472-478 "invalid member ID"

All five fields are required. There is no deny_unknown_fields, so unknown extra fields in a hello are silently ignored, but a hello missing pubkey, nonce or sig fails deserialization outright and the connection is dropped with no error frame (src/protocol.rs:76-109, src/connection.rs:491-546).

Base64 appears in exactly three places — src/protocol.rs:68-72, src/room_auth.rs:300-304 and src/room_auth.rs:336-346 — and every one of them uses the standard alphabet with padding. There is no URL-safe variant and no unpadded variant anywhere in the relay, so a client that emits URL-safe or unpadded base64 fails with MalformedCrypto.

The SHA-256 prehash is internal to p256::ecdsa: verify is called with the raw payload bytes and the crate hashes them (src/room_auth.rs:348). A client must not pre-hash the payload itself.

Fingerprint binding#

Item Value
Trigger member_id splits at the first . and the head is exactly 16 characters of lowercase ASCII hex (src/room_auth.rs:281-292)
Computed as SHA-256(SPKI DER)[..8], lowercase hex, 16 characters, no : separators (src/room_auth.rs:299-311)
Mirrors jarvis_platform::crypto::compute_fingerprint with : stripped
Applies to The two chat clients, whose ids are <fingerprint>.<userId>
Does not apply to The pair client (random_alnum(16)) and presence (a raw UUIDv4 — UUIDs contain -, not a leading 16-hex . segment)
Effect An attacker cannot be the first to pin a fingerprinted id without holding the key whose fingerprint the id embeds

The check is narrow on purpose: it fires only for ids that actually embed a fingerprint, because making member_id == fingerprint(pubkey) for every client would change all four id formats and sever the member_id to user_id linkage that presence rosters, DM channel names and pair-frame from fields depend on (src/room_auth.rs:20-33).

Pins and nonce monotonicity#

Item Value Source
Pin map shape session_id -> member_id -> Pin { pubkey, last_nonce } src/room_auth.rs:121-123, src/room_auth.rs:140-142
Storage Process memory, behind a tokio::sync::RwLock. Not persisted src/room_auth.rs:141
Written by commit only, never verify src/room_auth.rs:214-244
Commit re-checks Pubkey equality and strict nonce monotonicity, under the write lock src/room_auth.rs:225-230
First join Inserts {pubkey, last_nonce: nonce} src/room_auth.rs:233-241
Reconnect Updates last_nonce; the pubkey must be unchanged src/room_auth.rs:224-232
Pruned by forget_session(session_id) on room teardown, removing every pin for that room src/room_auth.rs:248-250
Lifetime Lost on process restart, along with all sessions and limiter state src/room_auth.rs:141

commit re-checks under the write lock what verify already checked under the read lock, because two concurrent valid hellos for the same slot could otherwise race a replay through the gap between the two. The loser's slot is torn down and the loser is given the reject message (src/connection.rs:217-242).

The freshness window and the monotonic check do different jobs. The ±30 s window is a sanity bound on how far in the past or future a first, slot-pinning hello may be dated; the real anti-replay guarantee is the strictly monotonic per-slot nonce, which is what stops a hello captured inside the window from evicting the live connection holding the slot (src/room_auth.rs:47-53). A genuine reconnect naturally carries a higher unix-millis nonce and is accepted.

Reject reasons#

Source order, as declared in RoomHelloRejectReason (src/room_auth.rs:82-99).

Reason Raised when Client message
MalformedCrypto pubkey or sig is not valid base64, the pubkey is not valid SPKI DER, or the signature is not a 64-byte P1363 pair invalid room hello signature
BadSignature Encodings are well-formed but the signature does not verify against the carried pubkey over the canonical payload invalid room hello signature
StaleNonce The nonce is outside ±30 s of the relay clock, or it is not strictly greater than the slot's last_nonce invalid room hello signature
PubkeyMismatch The slot is already pinned to a different pubkey member id bound to a different identity
FingerprintMismatch The member id embeds a fingerprint prefix that does not match fingerprint(carried pubkey) member id bound to a different identity

StaleNonce covers both the stale case and the replay case as a single coarse reason; the logs carry which one it was (src/room_auth.rs:86-92).

The two client-facing messages#

Message Conflates
invalid room hello signature MalformedCrypto, BadSignature, StaleNonce
member id bound to a different identity PubkeyMismatch, FingerprintMismatch

The messages are deliberately coarse so they do not leak which check failed beyond what an attacker can already infer (src/room_auth.rs:103-105). A prober who learns that a signature was structurally fine but the nonce was stale learns that the slot is live and pinned; a prober who learns the fingerprint check specifically failed learns the id's format. The precise reason is logged server-side instead (src/connection.rs:128-133).

This is a genuine cost, and it lands on legitimate clients: a member with a skewed clock and a member with a broken canonicalizer receive the same sentence. Debugging one of these needs relay logs, not the error frame.

The relay never signs anything#

Signature verification is one-directional. The relay holds no private key, issues no credential and produces no signed response.

Primitive Where Status
p256::ecdsa::{Signature, VerifyingKey} and signature::Verifier src/room_auth.rs:338-341 Implemented — the only crypto on the request path
p256::pkcs8::DecodePublicKey src/room_auth.rs:341 Implemented — SPKI DER parsing
sha2::Sha256 src/room_auth.rs:299-311 Implemented, and used only for the member-id fingerprint. It is not part of signature verification, which prehashes inside p256::ecdsa
base64 STANDARD src/protocol.rs:68-72, src/room_auth.rs:300-304, src/room_auth.rs:336-346 Implemented — three call sites, all padded standard alphabet
p256::ecdsa::SigningKey, p256::pkcs8::EncodePublicKey src/room_auth.rs:356-357 Present only under #[cfg(test)], where the suite forges and signs hellos the way real clients do
p256 ecdh feature Cargo.toml:20 Compiled in, no code path uses it. Only p256::ecdsa and p256::pkcs8 are imported anywhere in src/

The ecdh feature is dead weight in the dependency graph, not a hint of an unimplemented key-exchange path. Nothing in the relay derives a shared secret, and the relay does not encrypt payloads at all — end-to-end encryption is a client responsibility (src/main.rs:4-5).

Residual first-mover risk on unfingerprinted ids#

Two of the four client id formats carry no relationship to the signing key: the pair client's random_alnum(16) and presence's raw UUIDv4 (src/room_auth.rs:20-33). For those, check 3 does not fire, and the only binding is trust on first use.

Property Fingerprinted id (<fp>.<userId>) Unfingerprinted id
First join by a non-owner of the key Refused — FingerprintMismatch Accepted, and pins the slot
Later join with a different key Refused — PubkeyMismatch Refused — PubkeyMismatch
Squatting a specific id Requires the matching private key Requires only being first
Recovery after a wrong pin Room teardown, which drops the pins Room teardown, which drops the pins

The consequence is narrow but real: for an unfingerprinted id, the first valid signer to present it owns it for the life of the room, and the legitimate holder is then refused with member id bound to a different identity. The attacker still needs the room session_id, which is a capability secret, and still needs a valid signature over the canonical payload — but not the member's key. The risk is recorded as open in the repository's roadmap and backlog (BACKLOG.md:124-131).

Because pins are dropped when a room empties (src/room_auth.rs:248-250) and lost entirely on restart, the window resets every time the last member leaves. That bounds the damage and also means a pin is never a durable identity claim.

What the tests pin#

Twenty unit tests in src/room_auth.rs:351 cover the rules on this page: the nonce window boundaries at exactly ±30 s, first pin, same-key reconnect, a squatter presenting a different key, a bad signature, a forgery where the presented pubkey is not the signing key, reconnect with a different key, independent pins per member, a stale nonce, a replayed identical nonce, an older nonce, a strictly newer reconnect, a malformed pubkey, forget_session, the guarantee that verify leaves no pin, both commit-race guards, a fingerprinted id matching, a fingerprinted id mismatching before any pin exists, fingerprint_prefix recognition, and the golden payload vector.

cargo test --locked    # the whole suite; 53 tests, none in a tests/ directory

Nothing here is covered by an end-to-end listener test — the auth tests drive RoomAuthStore directly, and the acknowledged coverage gap for socket-level room behaviour is at BACKLOG.md:101-111.