From 7d18841e1b668cf26a78834d5a2d23d6be6c75fa Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 17:55:35 +0000 Subject: [PATCH 1/2] fix(handshake): bind the DTLS channel into the ed25519 auth handshake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mutual-auth handshake proved each peer's ed25519 identity but never tied that proof to the DTLS channel carrying it, and the signaling that delivers the DTLS fingerprint (Nostr / mDNS) is unauthenticated. A man-in-the-middle on the signaling path could inject its own fingerprint and candidates, terminate DTLS on each leg, and relay the ed25519 handshake between the two real endpoints unmodified: both sides authenticate each other correctly while the attacker sits in the middle reading the channel. The handshake couldn't detect it because it never asserted "the peer I authenticated is the peer terminating my DTLS." Fold a channel-binding value into the signed handshake payload. The signer includes the fingerprint of the certificate it presents on its end of the DTLS channel (its local `a=fingerprint:`); the verifier reconstructs the payload with the fingerprint it observes on its end (its remote `a=fingerprint:`). WebRTC verifies a presented certificate against the fingerprint in the remote SDP, so on an un-intercepted connection the signer's local fingerprint equals the verifier's observed remote fingerprint and the signature checks out. An interceptor that terminates DTLS on each leg must present its own certificate to each side, so the verifier's observed fingerprint no longer matches what the peer signed: the signature fails and the peer is dropped (AuthFailed). Fail closed if the transport can't surface a fingerprint. No new wire field — the binding rides the existing auth_response signature, so a downgrade isn't possible. Adds PeerSession::local_fingerprint (mirror of remote_fingerprint) and a channel_binding argument to signing::handshake_payload. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01J3oMk6B7NDM2SeQZUSz2ku --- crates/myownmesh-core/src/engine/handshake.rs | 44 ++++++++++++++++ crates/myownmesh-core/src/lib.rs | 10 ++-- crates/myownmesh-core/src/signing.rs | 52 ++++++++++++++++--- crates/myownmesh-core/src/transport/webrtc.rs | 15 ++++++ 4 files changed, 111 insertions(+), 10 deletions(-) diff --git a/crates/myownmesh-core/src/engine/handshake.rs b/crates/myownmesh-core/src/engine/handshake.rs index dac32c8..69a15fd 100644 --- a/crates/myownmesh-core/src/engine/handshake.rs +++ b/crates/myownmesh-core/src/engine/handshake.rs @@ -226,11 +226,34 @@ pub async fn on_hello(state: &Arc, device_id: &str, hello: HelloMe }), ); + // Bind the signed handshake to this DTLS channel: fold in the fingerprint + // of the certificate we present here. The initiator verifies it against + // the fingerprint it observes on its end of the channel, so a + // signaling-path MITM that terminates DTLS on each leg (presenting its own + // cert) makes the two disagree and the signature fails. Fail closed if the + // transport can't surface it — dropping is safer than sending an unbound + // signature an interceptor could relay unmodified. + let Some(session) = state + .peers + .get(device_id) + .and_then(|p| p.session.lock().clone()) + else { + warn!(peer = %device_id, "no transport session at hello — cannot channel-bind, dropping"); + super::drop_peer(state, device_id, DropReason::AuthFailed).await; + return; + }; + let Some(channel_binding) = session.local_fingerprint().await else { + warn!(peer = %device_id, "no local DTLS fingerprint — refusing to send an unbound auth response"); + super::drop_peer(state, device_id, DropReason::AuthFailed).await; + return; + }; + // Build the signed payload and reply. let payload = signing::handshake_payload( &hello.nonce, state.identity.public_id(), signing::pubkey_part(device_id), + &channel_binding, ); let signature = signing::sign_with(state.identity.signing_key(), &payload); if let Err(e) = send_to_peer( @@ -279,10 +302,31 @@ pub async fn on_auth_response( warn!(peer = %device_id, "received auth_response without having sent hello"); return; }; + // Reconstruct the peer's channel binding: the DTLS fingerprint we observe + // on our end of the channel. The peer signed the fingerprint of the cert + // it presented; WebRTC guarantees that equals what we observe here unless + // DTLS was re-terminated in the middle — in which case the fingerprints + // differ and the signature won't verify below. Fail closed if the + // transport can't surface it. + let Some(session) = state + .peers + .get(device_id) + .and_then(|p| p.session.lock().clone()) + else { + warn!(peer = %device_id, "no transport session at auth_response — cannot verify channel binding, dropping"); + super::drop_peer(state, device_id, DropReason::AuthFailed).await; + return; + }; + let Some(channel_binding) = session.remote_fingerprint().await else { + warn!(peer = %device_id, "no remote DTLS fingerprint — cannot verify channel binding, dropping"); + super::drop_peer(state, device_id, DropReason::AuthFailed).await; + return; + }; let payload = signing::handshake_payload( &my_nonce, signing::pubkey_part(device_id), state.identity.public_id(), + &channel_binding, ); let ok = match signing::verify(device_id, &payload, &resp.signature) { Ok(v) => v, diff --git a/crates/myownmesh-core/src/lib.rs b/crates/myownmesh-core/src/lib.rs index 8f3bb14..77411f5 100644 --- a/crates/myownmesh-core/src/lib.rs +++ b/crates/myownmesh-core/src/lib.rs @@ -68,9 +68,13 @@ //! Each device owns a long-lived ed25519 keypair. The `hello` //! handshake commits both sides to a shared nonce; the //! `auth_response` is an ed25519 signature over -//! `SIGN_DOMAIN_TAG || nonce || my_device_id || their_device_id`. -//! Domain separation prevents a signature obtained for one -//! protocol step from being replayed in another. +//! `SIGN_DOMAIN_TAG || nonce || my_device_id || their_device_id || +//! channel_binding`. Domain separation prevents a signature obtained +//! for one protocol step from being replayed in another, and the +//! `channel_binding` — the DTLS certificate fingerprint of the channel +//! the handshake runs over — ties the proven identity to *this* +//! transport, so a man-in-the-middle on the (unauthenticated) signaling +//! path can't relay the handshake across two DTLS legs it terminates. //! //! A user-visible 6-char verification code lets a human //! eyeball-confirm the handshake over voice/video at first-meeting diff --git a/crates/myownmesh-core/src/signing.rs b/crates/myownmesh-core/src/signing.rs index a46ed0c..554da99 100644 --- a/crates/myownmesh-core/src/signing.rs +++ b/crates/myownmesh-core/src/signing.rs @@ -25,13 +25,34 @@ use crate::identity; /// no `nonce` can be reinterpreted as part of a device id when the /// concatenation is parsed back — defense in depth on top of the /// domain tag itself. -pub fn handshake_payload(nonce: &str, my_device_id: &str, their_device_id: &str) -> Vec { +/// +/// `channel_binding` ties the signature to the DTLS channel the +/// handshake runs over: the *signer* passes the fingerprint of the +/// certificate it presents on that channel (its local `a=fingerprint:`), +/// and the *verifier* passes the fingerprint it observes on its end (its +/// remote `a=fingerprint:`). WebRTC verifies the presented certificate +/// against the fingerprint in the remote SDP, so on an un-intercepted +/// connection the signer's local fingerprint equals the verifier's remote +/// fingerprint and the signature checks out. A signaling-path +/// man-in-the-middle that terminates DTLS on each leg must present its own +/// certificate to each side, so the verifier's observed fingerprint no +/// longer matches what the peer signed — the signature fails and the peer +/// is dropped. Without this, the proven ed25519 identity was never bound +/// to the transport carrying it, so the handshake could be relayed +/// unmodified across an interceptor. +pub fn handshake_payload( + nonce: &str, + my_device_id: &str, + their_device_id: &str, + channel_binding: &str, +) -> Vec { format!( - "{}{nonce}|{my}|{their}", + "{}{nonce}|{my}|{their}|{cb}", crate::SIGN_DOMAIN_TAG, nonce = nonce, my = my_device_id, their = their_device_id, + cb = channel_binding, ) .into_bytes() } @@ -175,12 +196,13 @@ mod tests { #[test] fn handshake_payload_includes_domain_tag() { - let payload = handshake_payload("nonce123", "deviceA", "deviceB"); + let payload = handshake_payload("nonce123", "deviceA", "deviceB", "sha-256 ab:cd"); let s = String::from_utf8(payload).unwrap(); assert!(s.starts_with(crate::SIGN_DOMAIN_TAG)); assert!(s.contains("nonce123")); assert!(s.contains("deviceA")); assert!(s.contains("deviceB")); + assert!(s.contains("sha-256 ab:cd")); } #[test] @@ -188,19 +210,35 @@ mod tests { // Swapping my/their device ids produces a different payload, so // a peer can't reuse a signature from the opposite direction of // the handshake. - let a = handshake_payload("n", "alice", "bob"); - let b = handshake_payload("n", "bob", "alice"); + let a = handshake_payload("n", "alice", "bob", "fp"); + let b = handshake_payload("n", "bob", "alice", "fp"); assert_ne!(a, b); } + #[test] + fn handshake_payload_binds_channel() { + // The channel-binding fingerprint is part of the signed bytes, so a + // signature made over one DTLS channel does not verify over another — + // this is what a signaling-path MITM cannot forge, because the + // fingerprint the victim observes is the interceptor's cert, not the + // one the real peer signed. + let (sk, pubkey) = fixture_key(); + let honest = handshake_payload("n", &pubkey, "peer", "sha-256 aa:aa"); + let sig = sign_with(&sk, &honest); + assert!(verify(&pubkey, &honest, &sig).unwrap()); + // Same nonce and ids, different observed channel fingerprint → reject. + let intercepted = handshake_payload("n", &pubkey, "peer", "sha-256 bb:bb"); + assert!(!verify(&pubkey, &intercepted, &sig).unwrap()); + } + #[test] fn round_trip_with_handshake_payload() { let (sk, pubkey) = fixture_key(); - let payload = handshake_payload("noncexyz", &pubkey, "peerXyz"); + let payload = handshake_payload("noncexyz", &pubkey, "peerXyz", "sha-256 aa:bb"); let sig = sign_with(&sk, &payload); assert!(verify(&pubkey, &payload, &sig).unwrap()); // Tampering with any field invalidates the signature. - let other = handshake_payload("noncexyy", &pubkey, "peerXyz"); + let other = handshake_payload("noncexyy", &pubkey, "peerXyz", "sha-256 aa:bb"); assert!(!verify(&pubkey, &other, &sig).unwrap()); } } diff --git a/crates/myownmesh-core/src/transport/webrtc.rs b/crates/myownmesh-core/src/transport/webrtc.rs index 293b0b0..3d48d71 100644 --- a/crates/myownmesh-core/src/transport/webrtc.rs +++ b/crates/myownmesh-core/src/transport/webrtc.rs @@ -1104,6 +1104,21 @@ impl PeerSession { sdp_fingerprint(&self.pc.remote_description().await?.sdp) } + /// DTLS fingerprint of our *local* description — the fingerprint of the + /// certificate THIS side presents on the DTLS channel. WebRTC verifies a + /// peer's presented certificate against the `a=fingerprint:` in the SDP it + /// received, so on an un-intercepted channel a peer's + /// [`Self::remote_fingerprint`] equals its counterpart's + /// `local_fingerprint`. The auth handshake folds this value into the signed + /// ed25519 payload (see [`crate::signing::handshake_payload`]) so a + /// signaling-path man-in-the-middle — which must present its own + /// certificate on each leg it terminates — is detected: the victim's + /// observed remote fingerprint no longer matches the one the real peer + /// signed. `None` before the local description is set. + pub async fn local_fingerprint(&self) -> Option { + sdp_fingerprint(&self.pc.local_description().await?.sdp) + } + /// True when the peer connection is awaiting a remote Answer — i.e. we /// have a local offer outstanding (`have-local-offer`). An Answer that /// arrives in any other state is stale (a duplicate from relay redundancy, From ea7adc8ce8d82029f5f9d38c4090c949954e5228 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 17:54:12 +0000 Subject: [PATCH 2/2] fix(engine): gate application traffic behind peer admission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An endpoint with a live WebRTC data channel but an unfinished ed25519 handshake + approval could drive real handlers. `handle_inbound_frame` dispatched every message kind — application channels, RPC, reliable delivery (advancing the receive high-water and acking), capabilities, shelve, governance/roster gossip — and moved liveness/recovery state, all before checking `authenticated` or peer status. And `on_approve` could reach `Active` without authentication (an early `Approve` latches `remote_approve_seen`; `roster_approve` sets `local_approve_sent` with no auth check). A live DTLS data channel is not authorization — approval is. Enforcement is a per-connection property, not per-frame work: - `PeerStateData::is_admitted` (authenticated && Active/Shelved) is the one predicate. Admission flips only at the handshake/approval (and topology-shelve) transitions — it is read, never recomputed. - `handle_inbound_frame` reads it inside the peer lock it *already* takes each frame for liveness — no extra lookup or lock. Only handshake/ approval protocol frames are processed pre-admission; application, RPC, reliable, governance, capabilities, shelve, and keepalive frames are dropped before any state moves. This one check is synchronous rather than swept because a periodic revalidation could only tear a peer down *after* its frames were already dispatched — it can't un-run an RPC handler or un-mutate governance state, and a never-admitted peer must get zero application processing. Rejects are counted per peer and logged on a power-of-two throttle so a flood can't amplify the log. - The `Active` transition now requires `authenticated`, so an early `Approve` can't promote an unauthenticated peer (the latch is harmless — it completes once auth lands). Paths that already have an outer authorization layer are left to it rather than carrying a second per-frame/per-sample check: - Inbound media: an unadmitted peer can't establish a media route in the first place (its `RouteControl` rides the data channel and is dropped by the gate above), and the embedder matches an inbound sample to an authorized route. - Outbound sends: the embedder drives sends off approval/route state (Active peers only), and the receiver's own inbound gate enforces admission on delivery. - Routed envelopes: the carrier's relay frame is itself a data-channel frame, already dropped pre-admission by the gate above. The gate sits upstream of every subscriber and daemon-IPC emission, so the daemon (and any embedder) is protected by construction. Adds deterministic regression tests for the pre-admission interval (channel/reliable/RPC drop, the `is_admitted` predicate over every status, early `Approve` not reaching Active) and the positive control that an admitted peer's traffic flows; the existing real-handshake integration tests confirm normal admission → Active → traffic is unaffected. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01J3oMk6B7NDM2SeQZUSz2ku --- .../myownmesh-core/src/engine/connection.rs | 23 ++ crates/myownmesh-core/src/engine/handshake.rs | 10 +- crates/myownmesh-core/src/engine/mod.rs | 275 +++++++++++++++++- crates/myownmesh-core/src/engine/routing.rs | 4 + 4 files changed, 304 insertions(+), 8 deletions(-) diff --git a/crates/myownmesh-core/src/engine/connection.rs b/crates/myownmesh-core/src/engine/connection.rs index ab5f89e..5427b55 100644 --- a/crates/myownmesh-core/src/engine/connection.rs +++ b/crates/myownmesh-core/src/engine/connection.rs @@ -154,9 +154,31 @@ pub struct PeerStateData { /// peer's SDP. Drained and applied after the first successful /// `set_remote_description`; see [`remote_description_set`]. pub pending_remote_candidates: Vec, + /// Count of inbound frames the admission gate dropped because the peer + /// hadn't reached the phase they require (an application/RPC/reliable/ + /// governance/media frame arriving before the ed25519 handshake + approval + /// finished). Drives a power-of-two-throttled warn so a pre-admission + /// flood can't be turned into a log-amplification primitive. + pub admission_rejected: u64, pub diag: PeerDiag, } +impl PeerStateData { + /// The admission boundary: `true` once this peer has proven its ed25519 + /// identity (`authenticated`) **and** both sides have approved (`Active`, + /// or `Shelved` — an admitted `Active` peer parked by the topology + /// selector). This is the single predicate every inbound application/RPC/ + /// reliable/governance/routing/media path — and the application-level + /// outbound senders — gate on. A merely-connected peer (`Sighted`/ + /// `Handshaking`/`PendingApproval`) is **not** admitted: a live DTLS data + /// channel is not authorization. The `authenticated` conjunct is defence in + /// depth — even if some path reached `Active` without authenticating, no + /// traffic flows. + pub fn is_admitted(&self) -> bool { + self.authenticated && matches!(self.status, PeerStatus::Active | PeerStatus::Shelved) + } +} + impl Default for PeerStateData { fn default() -> Self { Self { @@ -194,6 +216,7 @@ impl Default for PeerStateData { selected_pair: None, remote_description_set: false, pending_remote_candidates: Vec::new(), + admission_rejected: 0, diag: PeerDiag::default(), } } diff --git a/crates/myownmesh-core/src/engine/handshake.rs b/crates/myownmesh-core/src/engine/handshake.rs index 69a15fd..87c4aed 100644 --- a/crates/myownmesh-core/src/engine/handshake.rs +++ b/crates/myownmesh-core/src/engine/handshake.rs @@ -419,7 +419,15 @@ pub async fn on_approve(state: &Arc, device_id: &str) { // we're already ACTIVE shouldn't re-fire the on-active side // effects (roster persist, gossip, Approved event). let was_active = matches!(data.status, PeerStatus::Active); - let active = data.local_approve_sent && data.remote_approve_seen; + // A peer reaches ACTIVE only once it has proven its ed25519 identity. + // `remote_approve_seen` can be latched by an `Approve` that arrives + // before authentication (protocol frames pass the admission gate), and + // the external `roster_approve` path sets `local_approve_sent` without + // an auth check — so without this `authenticated` conjunct an + // unauthenticated peer could be promoted to ACTIVE and gain the run of + // every application and control plane. The early latch is harmless: the + // transition simply completes the moment authentication lands. + let active = data.authenticated && data.local_approve_sent && data.remote_approve_seen; if active && !was_active { data.status = PeerStatus::Active; data.tier = ConnectionTier::Steady; diff --git a/crates/myownmesh-core/src/engine/mod.rs b/crates/myownmesh-core/src/engine/mod.rs index 609fb3c..75673ec 100644 --- a/crates/myownmesh-core/src/engine/mod.rs +++ b/crates/myownmesh-core/src/engine/mod.rs @@ -1835,13 +1835,15 @@ async fn handle_transport_event( handle_inbound_frame(state, &device_id, bytes).await; } TransportEvent::VideoSample(sample) => { - // Same gate as channel frames: the connection's existence - // (DTLS identity + roster approval) is the authorization; - // app layers add their own policy on top. + // No per-sample admission check on this hot path: an unadmitted peer + // can't establish a media route to begin with — its `RouteControl` + // rides the data channel, which `handle_inbound_frame` drops + // pre-admission — and the embedder matches an inbound sample to an + // authorized route. Admission is enforced at the route layer (the + // outer layer), not per frame here. state.dispatch_video(&device_id, sample); } TransportEvent::AudioSample(sample) => { - // Identical gate to video. state.dispatch_audio(&device_id, sample); } } @@ -2230,6 +2232,34 @@ fn frame_within_cap(len: usize) -> bool { len <= MAX_INBOUND_FRAME_BYTES } +/// Which admission phase an inbound frame requires before it may move peer +/// state or reach a handler. Enforced by the gate in [`handle_inbound_frame`]. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Admission { + /// Handshake + approval protocol frames (`Hello`, `AuthResponse`, + /// `Approve`, `Deny`). Always processed: they only advance or tear down the + /// handshake and grant no application access. Reaching `Active` still + /// requires `authenticated` (see [`handshake::on_approve`]), so an early + /// `Approve` cannot promote an unauthenticated peer. + Protocol, + /// Application, RPC, reliable, governance/roster, capabilities, shelve, and + /// keepalive traffic — processed only once the peer is admitted. + Application, +} + +/// Classify an inbound frame's admission phase. Only the four handshake/ +/// approval frames are `Protocol`; everything else — including any future +/// variant — is `Application` and requires an admitted peer (fail closed). +fn inbound_admission(msg: &MeshMessage) -> Admission { + match msg { + MeshMessage::Hello(_) + | MeshMessage::AuthResponse(_) + | MeshMessage::Approve(_) + | MeshMessage::Deny(_) => Admission::Protocol, + _ => Admission::Application, + } +} + async fn handle_inbound_frame(state: &Arc, device_id: &str, bytes: Bytes) { // Reject an oversize frame before the deserializer allocates for it. if !frame_within_cap(bytes.len()) { @@ -2247,11 +2277,40 @@ async fn handle_inbound_frame(state: &Arc, device_id: &str, bytes: return; } }; - state - .traffic - .record_rx(traffic::class_of(&msg), bytes.len()); + // Admission gate, folded into the per-frame liveness touch below so it + // costs no extra lookup or lock. Admission is a per-connection property + // that flips only at the handshake/approval (and topology-shelve) + // transitions — each frame just reads it. An endpoint with a live data + // channel but an unfinished handshake + approval may drive only the + // handshake protocol itself (`Hello`/`AuthResponse`/`Approve`/`Deny`); + // application, RPC, reliable, governance/roster, capabilities, shelve, and + // keepalive frames are dropped here — before liveness, recovery-tier, or + // traffic state moves — so a pre-admission frame is a true no-op it can't + // use to fake liveness, clear a recovery, or reach a handler. Reaching + // `Active` itself additionally requires `authenticated` (see + // `handshake::on_approve`). This check is synchronous, not swept: a + // never-admitted peer must get *zero* application processing, so there is + // no grace window a periodic revalidation could open. + let application = matches!(inbound_admission(&msg), Admission::Application); if let Some(peer) = state.peers.get(device_id) { let mut data = peer.state.write(); + if application && !data.is_admitted() { + data.admission_rejected = data.admission_rejected.saturating_add(1); + let count = data.admission_rejected; + drop(data); + // Power-of-two throttle so a pre-admission flood can't be turned + // into a log-amplification primitive; the running total stays + // visible for diagnostics. + if count.is_power_of_two() { + warn!( + peer = %device_id, + class = ?traffic::class_of(&msg), + count, + "dropping pre-admission frame from a peer that has not finished authenticating and approving" + ); + } + return; + } data.last_recv_at = Some(Instant::now()); data.diag.bytes_in += bytes.len() as u64; data.diag.frames_in += 1; @@ -2270,7 +2329,15 @@ async fn handle_inbound_frame(state: &Arc, device_id: &str, bytes: data.tier = ConnectionTier::Steady; data.ice_disconnected_since = None; } + } else if application { + // No peer entry means nothing can admit an application frame — drop it. + // Protocol frames fall through; their handlers look the peer up and + // handle its absence themselves. + return; } + state + .traffic + .record_rx(traffic::class_of(&msg), bytes.len()); match msg { MeshMessage::Hello(hello) => handshake::on_hello(state, device_id, hello).await, MeshMessage::AuthResponse(resp) => { @@ -3177,6 +3244,200 @@ mod tests { ); } + // ---- admission gate: pre-authentication application dispatch ---- + // + // The bug these guard: an endpoint with a live DTLS data channel but an + // unfinished ed25519 handshake + approval could drive application, RPC, + // reliable, governance, and media handlers. The gate admits only handshake + // protocol frames until the peer is authenticated + Active/Shelved. + + fn frame_bytes(msg: &MeshMessage) -> Bytes { + Bytes::from(serde_json::to_vec(msg).expect("serialize test frame")) + } + + fn set_admission(state: &NetworkState, peer: &str, authenticated: bool, status: PeerStatus) { + let p = state.peers.get(peer).expect("peer present"); + let mut d = p.state.write(); + d.authenticated = authenticated; + d.status = status; + } + + #[test] + fn is_admitted_only_authenticated_active_or_shelved() { + use PeerStatus::*; + for status in [ + Sighted, + Handshaking, + PendingApproval, + Reconnecting, + Offline, + Error, + ] { + let d = connection::PeerStateData { + authenticated: true, + status, + ..Default::default() + }; + assert!(!d.is_admitted(), "{status:?} is not an admitted status"); + } + for status in [Active, Shelved] { + let ok = connection::PeerStateData { + authenticated: true, + status, + ..Default::default() + }; + assert!( + ok.is_admitted(), + "authenticated {status:?} must be admitted" + ); + let no_auth = connection::PeerStateData { + authenticated: false, + status, + ..Default::default() + }; + assert!( + !no_auth.is_admitted(), + "{status:?} without authentication is never admitted" + ); + } + } + + #[tokio::test] + async fn admission_gate_drops_application_traffic_before_authentication() { + // Report cases 1,2,5,6: a Handshaking peer's application / reliable / + // RPC / governance frame is dropped before it counts as received, + // refreshes liveness, or reaches a handler. + use crate::protocol::RosterRequestMessage; + let cases: Vec<(&str, MeshMessage)> = vec![ + ( + "channel", + MeshMessage::Channel { + channel: "secret".into(), + payload: serde_json::json!({ "steal": true }), + }, + ), + ( + "reliable", + MeshMessage::ChannelSeq { + stream: 1, + seq: 1, + channel: "secret".into(), + payload: serde_json::json!(1), + }, + ), + ( + "rpc", + MeshMessage::RpcRequest(RpcRequestMessage { + request_id: "r1".into(), + method: "drain".into(), + payload: serde_json::json!(1), + streaming: false, + }), + ), + ( + "governance", + MeshMessage::RosterRequest(RosterRequestMessage::default()), + ), + ]; + for (name, msg) in cases { + let state = build_test_state(&format!("admit-drop-{name}")); + insert_session_less_peer(&state, "attacker", None); + set_admission(&state, "attacker", false, PeerStatus::Handshaking); + + handle_inbound_frame(&state, "attacker", frame_bytes(&msg)).await; + + let p = state.peers.get("attacker").expect("peer present"); + let d = p.state.read(); + assert_eq!( + d.diag.frames_in, 0, + "{name}: a pre-admission frame must not count as received" + ); + assert!( + d.last_recv_at.is_none(), + "{name}: a pre-admission frame must not refresh liveness" + ); + assert_eq!( + d.admission_rejected, 1, + "{name}: the pre-admission drop must be recorded" + ); + } + } + + #[tokio::test] + async fn admission_gate_admits_application_traffic_from_active_peer() { + // Report case 9: an admitted peer's application frame flows normally — + // the gate must not break legitimate traffic. + let state = build_test_state("admit-active-ok"); + insert_session_less_peer(&state, "member", None); + set_admission(&state, "member", true, PeerStatus::Active); + + handle_inbound_frame( + &state, + "member", + frame_bytes(&MeshMessage::Channel { + channel: "chat".into(), + payload: serde_json::json!("hi"), + }), + ) + .await; + + let p = state.peers.get("member").expect("peer present"); + let d = p.state.read(); + assert_eq!(d.diag.frames_in, 1, "an admitted peer's frame is processed"); + assert_eq!(d.admission_rejected, 0); + assert!(d.last_recv_at.is_some()); + } + + #[tokio::test] + async fn admission_gate_lets_protocol_frames_through_while_handshaking() { + // Report case 4: handshake/approval frames pass even while the peer is + // unauthenticated, so the handshake can actually complete. + use crate::protocol::ApproveMessage; + let state = build_test_state("admit-protocol-pass"); + insert_session_less_peer(&state, "peer", None); + set_admission(&state, "peer", false, PeerStatus::Handshaking); + + handle_inbound_frame( + &state, + "peer", + frame_bytes(&MeshMessage::Approve(ApproveMessage {})), + ) + .await; + + let p = state.peers.get("peer").expect("peer present"); + assert_eq!( + p.state.read().admission_rejected, + 0, + "a handshake/approval frame must not be gated" + ); + } + + #[tokio::test] + async fn early_approve_cannot_activate_unauthenticated_peer() { + // Report case 7: an `Approve` that arrives before authentication (and a + // `roster_approve` that latched `local_approve_sent`) must NOT promote + // the peer to Active. The latch is harmless; the transition now requires + // `authenticated` (the full handshake→Active path is covered by the + // two_peer_handshake / governance integration tests). + let state = build_test_state("admit-early-approve"); + insert_session_less_peer(&state, "peer", None); + set_admission(&state, "peer", false, PeerStatus::Handshaking); + { + let p = state.peers.get("peer").expect("peer present"); + p.state.write().local_approve_sent = true; + } + + handshake::on_approve(&state, "peer").await; + + let p = state.peers.get("peer").expect("peer present"); + let d = p.state.read(); + assert!(d.remote_approve_seen, "the approve is recorded"); + assert!( + !matches!(d.status, PeerStatus::Active), + "an unauthenticated peer must never reach Active" + ); + } + #[tokio::test] async fn failed_approve_send_unlatches_so_a_later_trigger_can_resend() { // The one-way trust wedge: a roster-driven approve can fire before diff --git a/crates/myownmesh-core/src/engine/routing.rs b/crates/myownmesh-core/src/engine/routing.rs index 28c00df..b162f8f 100644 --- a/crates/myownmesh-core/src/engine/routing.rs +++ b/crates/myownmesh-core/src/engine/routing.rs @@ -128,6 +128,10 @@ pub(crate) async fn on_relay_frame(state: &Arc, from: &str, payloa let Some(w) = parse_wrapper(&env.payload) else { return false; }; + // The carrier is guaranteed admitted here: `on_relay_frame` is only reached + // via `on_channel_frame` for an inbound `Channel` frame, which the + // admission gate in `handle_inbound_frame` already drops from an unadmitted + // peer — so no separate carrier check is needed (or wanted per-frame). let me = state.identity.public_id().to_string(); let origin = if env.src.is_empty() { from.to_string()