Skip to content

Harden the handshake/admission boundary: DTLS channel binding + pre-auth dispatch gate#104

Merged
mrjeeves merged 2 commits into
mainfrom
claude/dtls-sdp-mitm-analysis-0xxnlt
Jul 23, 2026
Merged

Harden the handshake/admission boundary: DTLS channel binding + pre-auth dispatch gate#104
mrjeeves merged 2 commits into
mainfrom
claude/dtls-sdp-mitm-analysis-0xxnlt

Conversation

@mrjeeves

@mrjeeves mrjeeves commented Jul 18, 2026

Copy link
Copy Markdown
Owner

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 existing auth_response signature, 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_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/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.
  • 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 (its RouteControl rides the data channel, 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.

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-core277 unit + all real-WebRTC integration tests green, including deterministic pre-admission tests (channel/reliable/RPC drop, the is_admitted predicate over every status, early-Approve not reaching Active, and the positive control that admitted traffic flows) and handshake_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

@mrjeeves
mrjeeves force-pushed the claude/dtls-sdp-mitm-analysis-0xxnlt branch from 541b384 to 46829d1 Compare July 23, 2026 16:49
@mrjeeves mrjeeves changed the title fix(handshake): bind the DTLS channel into the ed25519 auth handshake Harden the handshake/admission boundary: DTLS channel binding + pre-auth dispatch gate Jul 23, 2026
claude added 2 commits July 23, 2026 17:54
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
mrjeeves force-pushed the claude/dtls-sdp-mitm-analysis-0xxnlt branch from 6668b60 to ea7adc8 Compare July 23, 2026 17:56
@mrjeeves
mrjeeves merged commit 2ee13bb into main Jul 23, 2026
10 checks passed
@mrjeeves
mrjeeves deleted the claude/dtls-sdp-mitm-analysis-0xxnlt branch July 23, 2026 18:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants