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.
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.
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 s — not 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.
Related#
- Authenticated rooms — the join procedure, event ordering and frame attribution.
- Message reference — the full envelope catalogue for all three protocols.
- Security model — what the other three hellos do not authenticate.
- Troubleshooting — mapping an error frame back to a cause.
- Invariants & tripwires — the rules a change here must not break.
- Build & test — running the suite and the local gate ladder.