Harden the handshake/admission boundary: DTLS channel binding + pre-auth dispatch gate#104
Merged
Merged
Conversation
mrjeeves
force-pushed
the
claude/dtls-sdp-mitm-analysis-0xxnlt
branch
from
July 23, 2026 16:49
541b384 to
46829d1
Compare
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J3oMk6B7NDM2SeQZUSz2ku
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J3oMk6B7NDM2SeQZUSz2ku
mrjeeves
force-pushed
the
claude/dtls-sdp-mitm-analysis-0xxnlt
branch
from
July 23, 2026 17:56
6668b60 to
ea7adc8
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two security fixes on the ed25519 handshake / admission boundary, both in
myownmesh-core.Fix 1 — Bind the DTLS channel into the ed25519 handshake
The mutual-auth handshake proved each peer's identity but never bound that proof to the DTLS channel carrying it, and the signaling that delivers the DTLS fingerprint (Nostr / mDNS) is unauthenticated. An attacker on the signaling path could inject its own fingerprint + candidates, terminate DTLS on each leg, and relay the ed25519 handshake between the two real endpoints unchanged — a transparent MITM.
Fold a channel-binding value into the signed handshake payload: the signer binds the fingerprint of the certificate it presents (
local_fingerprint), and the verifier reconstructs with the fingerprint it observes on its end (remote_fingerprint). WebRTC verifies the presented cert against the SDP fingerprint, so an interceptor that terminates DTLS on each leg presents its own cert → the verifier's observed fingerprint no longer matches what the peer signed → the signature fails →AuthFailed. No new wire field (rides the existingauth_responsesignature, so signaling can't strip it); fail closed if a fingerprint is unavailable.Fix 2 — Gate application traffic behind peer admission
An endpoint with a live data channel but an unfinished handshake + approval could drive real handlers.
handle_inbound_framedispatched 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 checkingauthenticatedor peer status. Andon_approvecould reachActivewithout authentication (an earlyApprovelatchesremote_approve_seen;roster_approvesetslocal_approve_sentwith 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_framereads 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/keepalive 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.Activetransition now requiresauthenticated, so an earlyApprovecan'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:
RouteControlrides the data channel, dropped by the gate above), and the embedder matches an inbound sample to an authorized route.The gate sits upstream of every subscriber and daemon-IPC emission, so the daemon (and any embedder) is protected by construction.
Testing
cargo test -p myownmesh-core— 277 unit + all real-WebRTC integration tests green, including deterministic pre-admission tests (channel/reliable/RPC drop, theis_admittedpredicate over every status, early-Approvenot reaching Active, and the positive control that admitted traffic flows) andhandshake_payload_binds_channel. The real-handshake integration suite confirms normal admission → Active → traffic is unaffected.cargo test -p myownmesh— daemon compiles and passes against the gated core.🤖 Generated with Claude Code
https://claude.ai/code/session_01J3oMk6B7NDM2SeQZUSz2ku