feat(dtls): verify peer certificate fingerprint (RFC 5763) + no-downgrade gate - #164
Merged
tinpotnick merged 2 commits intoJul 22, 2026
Merged
Conversation
DTLS-SRTP channels previously accepted any peer certificate (`insecure_skip_verify: true`, and the SDP `a=fingerprint` was parsed but never used), so a man-in-the-middle on the media path could complete the handshake and read the media. This authenticates the peer against the fingerprint promised in SDP (RFC 5763) and refuses to fall back to plaintext when authentication fails. Verification (dtls_session.rs, dtls.rs, actor.rs, commands.rs): - New `PeerFingerprint` (parse "<algo> <hex>" or bare hex; `matches_der` = SHA-256 of the peer leaf DER vs the expected hash; unknown algorithms fail closed). `spawn_handshake` takes `Option<PeerFingerprint>` and, when present, installs a `verify_peer_certificate` callback that aborts the handshake on mismatch. `insecure_skip_verify` stays — self-signed WebRTC certs have no CA, the fingerprint is the auth. - Set `client_auth = RequireAnyClientCert` so the passive/server role requests the peer cert (otherwise it never sees one to check). - Policy: verify when a fingerprint is supplied (all real WebRTC/SIP traffic); unverified only if a caller opts into DTLS with an empty fingerprint — backward-compatible. No-downgrade gate (state.rs, tick.rs, mixer.rs, rtcp_tx.rs): - `ChannelState::secure_not_ready()` (DTLS negotiated, no SRTP keys) guards every plaintext send site (both `send_rtp` paths, the mixer `send_leg` inline send, and `rtcp_tx::send_compound`), so a failed/incomplete handshake withholds media rather than downgrading to cleartext. Without this the fingerprint check would be cosmetic. No effect on non-DTLS channels. Tests: Rust dtls_session — fingerprint parse/match units + full-handshake match-succeeds / mismatch-rejects (both sides fail). JS projectrtpdtls.js — a mismatched fingerprint yields in.count == 0 both ways. cargo test --lib 115 pass; clippy --all-targets + rustfmt clean; full npm test 150 passing / 6 pending / 0 failing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bring the RTCP feature files into line with the established house style:
- JS tests: all-lowercase-concatenated names (statsa/chanb vs statsA/chanB),
quoted object keys, function( d ) openchannel callbacks with multi-line
bodies, yoda loops, no trailing commas, plain assertions (no chai message
args) — matching projectrtprtcp.js / projectrtpdtls.js / projectrtprecord.js.
- Rust: ungrouped hex literals (0xDEADBEEF) and positional format args
({:?}, x) in tests — matching rtp.rs / dtmf.rs.
No behaviour change. cargo fmt/clippy clean, rtcp lib tests pass, and the
rtcp/rtcpsecure/dtls interface tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
tinpotnick
force-pushed
the
feat/dtls-fingerprint-verify
branch
from
July 22, 2026 18:24
301ea8e to
4e0f26d
Compare
tinpotnick
approved these changes
Jul 22, 2026
Merged
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.
Peer-certificate fingerprint verification (RFC 5763) + no-downgrade gate
Security fix. DTLS-SRTP channels previously accepted any peer certificate:
insecure_skip_verify: truewas set and the SDPa=fingerprintwas parsed intoRemoteDtls.fingerprintbut never used. A man-in-the-middle on the media path could therefore complete the DTLS handshake with its own certificate and read/modify the media. This PR authenticates the peer against the SDP fingerprint and refuses to fall back to plaintext when that fails.Verification
dtls_session::PeerFingerprint:parseaccepts the full"<algorithm> <hex>"SDP form or a bare colon-hex string (sha-256 assumed, matching what the JS layer forwards today);matches_dercomputes the SHA-256 of the peer's leaf certificate DER and compares it (case-insensitive) to the expected value. Unknown algorithms fail closed (only sha-256 is honoured — projectrtp's and modern WebRTC's hash).spawn_handshakenow takesOption<PeerFingerprint>. When present it installs averify_peer_certificatecallback that aborts the handshake (BadCertificate alert) on mismatch.insecure_skip_verifystaystrue— self-signed WebRTC certs have no CA, so the fingerprint is the authentication.client_auth = RequireAnyClientCertso the passive/server role sends aCertificateRequestand actually receives the peer cert — without it the server would never see a certificate to fingerprint.No-downgrade gate (the part that gives verification teeth)
Without this, a rejected handshake silently fell back to plaintext RTP/RTCP — so a MITM whose cert failed the check would still receive the media in the clear, making the fingerprint check cosmetic.
ChannelState::secure_not_ready()= "DTLS negotiated but no SRTP keys yet". It guards every plaintext send site — bothsend_rtppaths (tick.rs,mixer.rs), the mixersend_leginline send, andrtcp_tx::send_compound— so a failed/incomplete handshake withholds media instead of downgrading. No effect on non-DTLS (plain SIP) channels:secure_not_ready()is always false there.Tests / verification
dtls_session::tests): fingerprint parse (bare hex / algorithm-prefixed / empty→None),matches_der(own cert, case-insensitive, flipped digit rejected, unknown algorithm fails closed), and two full loopback handshakes — matching fingerprints complete, a mismatch aborts both sides.cargo test --lib115 passing; clippy--all-targets+ rustfmt clean.test/interface/projectrtpdtls.js): new "rejects a peer whose certificate fingerprint does not match SDP" — feeds a corrupted fingerprint and assertsstats.in.count === 0on both channels (the gate withholds all media). The existing DTLS/secure-RTCP tests (which feed the correct fingerprint) now prove the happy path end-to-end, since fingerprints are actually enforced.npm test: 150 passing / 6 pending / 0 failing.Out of scope / follow-ups
srtp_decryptsimply fails to decrypt SRTP, so injected cleartext is dropped at parse — but there's no explicit reject/close).insecure_verificationstays default; only sha-256 fingerprints are honoured.🤖 Generated with Claude Code