Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions crates/myownmesh-core/src/engine/connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<LocalIceCandidate>,
/// 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 {
Expand Down Expand Up @@ -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(),
}
}
Expand Down
54 changes: 53 additions & 1 deletion crates/myownmesh-core/src/engine/handshake.rs
Original file line number Diff line number Diff line change
Expand Up @@ -226,11 +226,34 @@ pub async fn on_hello(state: &Arc<NetworkState>, 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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -375,7 +419,15 @@ pub async fn on_approve(state: &Arc<NetworkState>, 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;
Expand Down
Loading
Loading