Skip to content

feat(dtls): verify peer certificate fingerprint (RFC 5763) + no-downgrade gate - #164

Merged
tinpotnick merged 2 commits into
babblevoice:mainfrom
16KnighT:feat/dtls-fingerprint-verify
Jul 22, 2026
Merged

feat(dtls): verify peer certificate fingerprint (RFC 5763) + no-downgrade gate#164
tinpotnick merged 2 commits into
babblevoice:mainfrom
16KnighT:feat/dtls-fingerprint-verify

Conversation

@16KnighT

Copy link
Copy Markdown
Contributor

Peer-certificate fingerprint verification (RFC 5763) + no-downgrade gate

Security fix. DTLS-SRTP channels previously accepted any peer certificate: insecure_skip_verify: true was set and the SDP a=fingerprint was parsed into RemoteDtls.fingerprint but 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.

Stacking: branched off feat/tier2-rtcp (PR #163, itself stacked on Tier 1 #162) because the RTCP send path (rtcp_tx::send_compound) is one of the plaintext sites the no-downgrade gate must cover. Merge after #162 and #163; until they land the diff here also shows those commits.

Verification

  • New dtls_session::PeerFingerprint: parse accepts the full "<algorithm> <hex>" SDP form or a bare colon-hex string (sha-256 assumed, matching what the JS layer forwards today); matches_der computes 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_handshake now takes Option<PeerFingerprint>. When present it installs a verify_peer_certificate callback that aborts the handshake (BadCertificate alert) on mismatch. insecure_skip_verify stays true — self-signed WebRTC certs have no CA, so the fingerprint is the authentication.
  • Sets client_auth = RequireAnyClientCert so the passive/server role sends a CertificateRequest and actually receives the peer cert — without it the server would never see a certificate to fingerprint.
  • Policy: verify when a fingerprint is supplied (all real WebRTC/SIP traffic carries one); a channel that opts into DTLS with an empty fingerprint stays unverified, as before — backward-compatible, and it keeps the fingerprint-less internal handshake test working.

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 — both send_rtp paths (tick.rs, mixer.rs), the mixer send_leg inline send, and rtcp_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

  • Rust (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 --lib 115 passing; clippy --all-targets + rustfmt clean.
  • JS (test/interface/projectrtpdtls.js): new "rejects a peer whose certificate fingerprint does not match SDP" — feeds a corrupted fingerprint and asserts stats.in.count === 0 on 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.
  • Full npm test: 150 passing / 6 pending / 0 failing.

Out of scope / follow-ups

  • Inbound plaintext injection isn't explicitly rejected (a DTLS channel with no srtp_decrypt simply fails to decrypt SRTP, so injected cleartext is dropped at parse — but there's no explicit reject/close).
  • insecure_verification stays default; only sha-256 fingerprints are honoured.

🤖 Generated with Claude Code

Nick Knight and others added 2 commits July 22, 2026 19:23
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
tinpotnick force-pushed the feat/dtls-fingerprint-verify branch from 301ea8e to 4e0f26d Compare July 22, 2026 18:24
@tinpotnick
tinpotnick merged commit ea48588 into babblevoice:main Jul 22, 2026
2 checks passed
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