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 dac32c8..87c4aed 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, @@ -375,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() 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,