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
6 changes: 6 additions & 0 deletions rust/src/channel/actor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -778,13 +778,19 @@ async fn handle_command_local(
}
let (dtls_tx, dtls_rx) = mpsc::channel::<Vec<u8>>(64);
*state.dtls_inbound_tx.lock() = Some(dtls_tx);
// Authenticate the peer against the fingerprint promised in SDP
// (RFC 5763). `None` when none was supplied → unverified, as
// before. A mismatch fails the handshake, so no SRTP keys are
// derived and the channel never carries secure media.
let expected_fp = super::dtls_session::PeerFingerprint::parse(&dtls.fingerprint);
let h = super::dtls_session::spawn_handshake(
dtls.setup,
state.local_addr,
state.rtp_sock.clone(),
dtls_rx,
crate::dtls::get_certificate(),
state.remote_addr.clone(),
expected_fp,
);
state.dtls_result_rx = Some(h.result_rx);
state.dtls_handshake_abort = Some(h.abort);
Expand Down
3 changes: 2 additions & 1 deletion rust/src/channel/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ pub struct RemoteConfig {

#[derive(Debug, Clone)]
pub struct RemoteDtls {
#[allow(dead_code)]
/// The peer's SDP `a=fingerprint` value. Verified against the certificate
/// presented in the DTLS handshake (see `dtls_session::PeerFingerprint`).
pub fingerprint: String,
pub setup: DtlsSetup,
}
Expand Down
227 changes: 216 additions & 11 deletions rust/src/channel/dtls_session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,20 +19,61 @@ use async_trait::async_trait;
use parking_lot::Mutex as PLMutex;
use tokio::sync::mpsc;
use webrtc_dtls::config::Config as DtlsConfig;
use webrtc_dtls::config::ExtendedMasterSecretType;
use webrtc_dtls::config::{ClientAuthType, ExtendedMasterSecretType};
use webrtc_dtls::conn::DTLSConn;
use webrtc_dtls::crypto::Certificate;
use webrtc_srtp::protection_profile::ProtectionProfile;

use crate::channel::commands::DtlsSetup;

/// The peer's certificate fingerprint as promised out-of-band in SDP
/// (`a=fingerprint`). DTLS-SRTP has no CA: the peer's self-signed cert is
/// authenticated by matching the fingerprint of the cert seen in the handshake
/// against this value (RFC 5763 §5).
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct PeerFingerprint {
/// Hash algorithm, lowercased (e.g. `sha-256`). Only `sha-256` is
/// supported for matching — any other value fails closed.
pub algorithm: String,
/// Colon-separated uppercase hex of the digest (e.g. `A1:B2:…`).
pub hex_colon: String,
}

impl PeerFingerprint {
/// Parse an SDP fingerprint value. Accepts either the full `a=fingerprint`
/// form `"<algorithm> <hex>"` or a bare colon-hex string (algorithm assumed
/// `sha-256`, matching what `crate::dtls::fingerprint` advertises and what
/// the JS layer currently forwards). Returns `None` for empty/whitespace,
/// which the caller treats as "no fingerprint supplied → skip verification".
pub fn parse(s: &str) -> Option<Self> {
let s = s.trim();
if s.is_empty() {
return None;
}
match s.split_once(char::is_whitespace) {
Some((algo, hex)) => Some(Self {
algorithm: algo.trim().to_ascii_lowercase(),
hex_colon: hex.trim().to_ascii_uppercase(),
}),
None => Some(Self {
algorithm: "sha-256".to_string(),
hex_colon: s.to_ascii_uppercase(),
}),
}
}

/// True iff `der` (a peer certificate in DER form) hashes to this
/// fingerprint. Only sha-256 is honoured; any other algorithm returns
/// false so an unknown/downgraded hash fails the handshake rather than
/// silently passing.
pub fn matches_der(&self, der: &[u8]) -> bool {
if self.algorithm != "sha-256" {
return false;
}
crate::dtls::sha256_fingerprint(der).eq_ignore_ascii_case(&self.hex_colon)
}
}

/// Keying material extracted from a completed DTLS handshake.
#[derive(Debug, Clone)]
pub struct SrtpKeyingMaterial {
Expand Down Expand Up @@ -164,6 +205,7 @@ pub fn spawn_handshake(
inbound_rx: mpsc::Receiver<Vec<u8>>,
certificate: Certificate,
remote_addr: Arc<PLMutex<Option<SocketAddr>>>,
expected_fingerprint: Option<PeerFingerprint>,
) -> HandshakeHandle {
let (result_tx, result_rx) = tokio::sync::oneshot::channel();

Expand All @@ -180,14 +222,37 @@ pub fn spawn_handshake(
];

let join = tokio::spawn(async move {
let config = DtlsConfig {
let mut config = DtlsConfig {
certificates: vec![certificate],
srtp_protection_profiles: srtp_profiles,
// DTLS-SRTP uses self-signed certs with no CA, so skip the built-in
// chain/name verification; the peer is instead authenticated by its
// SDP fingerprint via `verify_peer_certificate` below.
insecure_skip_verify: true,
// Make the *server* (passive) role send a CertificateRequest and
// require the peer to present a cert — otherwise it never receives
// one to fingerprint. Ignored for the client role. We do the actual
// authentication in `verify_peer_certificate`, not via a CA, so no
// client_cert_verifier is needed (RequireAnyClientCert < the
// VerifyClientCertIfGiven threshold that would demand one).
client_auth: ClientAuthType::RequireAnyClientCert,
extended_master_secret: ExtendedMasterSecretType::Require,
..Default::default()
};

// When SDP supplied a fingerprint, enforce it: the handshake fails
// (BadCertificate alert) unless the peer's leaf cert hashes to it. With
// no fingerprint we fall back to the prior unauthenticated behaviour.
if let Some(fp) = expected_fingerprint {
config.verify_peer_certificate =
Some(Arc::new(move |certs, _chains| match certs.first() {
Some(der) if fp.matches_der(der) => Ok(()),
_ => Err(webrtc_dtls::Error::Other(
"dtls peer certificate fingerprint mismatch".to_owned(),
)),
}));
}

let outcome = tokio::time::timeout(
HANDSHAKE_TIMEOUT,
DTLSConn::new(transport, config, is_client, None),
Expand Down Expand Up @@ -281,14 +346,30 @@ mod tests {
use std::time::Duration;
use tokio::net::UdpSocket;

// Multi-thread flavor: DTLSConn spawns internal tasks (and the relay
// task above is a tokio::select! loop), so the handshake will
// deadlock on the default single-threaded runtime.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn dtls_handshake_between_two_peers() {
let server_cert = Certificate::generate_self_signed(vec!["server".into()]).unwrap();
let client_cert = Certificate::generate_self_signed(vec!["client".into()]).unwrap();
/// The sha-256 `PeerFingerprint` of a certificate's leaf DER — what the
/// peer would advertise in SDP.
fn fp_of(cert: &Certificate) -> PeerFingerprint {
let der = cert.certificate.first().unwrap().as_ref();
PeerFingerprint {
algorithm: "sha-256".to_string(),
hex_colon: crate::dtls::sha256_fingerprint(der),
}
}

/// Drive a full DTLS handshake between an active (client) and passive
/// (server) peer over loopback, each optionally verifying the other's
/// certificate fingerprint. Returns the two handshake outcomes (`None` =
/// the handshake failed — e.g. a fingerprint mismatch).
///
/// Multi-thread flavor is required by callers: DTLSConn spawns internal
/// tasks and the relay below is a `tokio::select!` loop, so a
/// single-threaded runtime would deadlock.
async fn handshake_pair(
server_cert: Certificate,
client_cert: Certificate,
server_expects: Option<PeerFingerprint>,
client_expects: Option<PeerFingerprint>,
) -> (Option<HandshakeResult>, Option<HandshakeResult>) {
let server_sock = Arc::new(UdpSocket::bind("127.0.0.1:0").await.unwrap());
let client_sock = Arc::new(UdpSocket::bind("127.0.0.1:0").await.unwrap());
let server_addr = server_sock.local_addr().unwrap();
Expand Down Expand Up @@ -334,6 +415,7 @@ mod tests {
server_dtls_rx,
server_cert,
server_remote,
server_expects,
);
let client_h = spawn_handshake(
DtlsSetup::Active,
Expand All @@ -342,8 +424,11 @@ mod tests {
client_dtls_rx,
client_cert,
client_remote,
client_expects,
);

// A failed handshake still resolves the oneshot (with `None`); only a
// dropped sender or true timeout should panic here.
let server_result = tokio::time::timeout(Duration::from_secs(5), server_h.result_rx)
.await
.expect("server handshake timeout")
Expand All @@ -353,6 +438,18 @@ mod tests {
.expect("client handshake timeout")
.expect("client oneshot dropped");

relay.abort();
(server_result, client_result)
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn dtls_handshake_between_two_peers() {
let server_cert = Certificate::generate_self_signed(vec!["server".into()]).unwrap();
let client_cert = Certificate::generate_self_signed(vec!["client".into()]).unwrap();

let (server_result, client_result) =
handshake_pair(server_cert, client_cert, None, None).await;

assert!(server_result.is_some(), "server handshake failed");
assert!(client_result.is_some(), "client handshake failed");

Expand Down Expand Up @@ -409,7 +506,115 @@ mod tests {

let decrypted = decrypt_ctx.decrypt_rtp(&encrypted).expect("decrypt");
assert_eq!(&decrypted[..], &rtp_pkt[..], "round-trip should match");
}

relay.abort();
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn dtls_handshake_succeeds_when_fingerprints_match() {
let server_cert = Certificate::generate_self_signed(vec!["server".into()]).unwrap();
let client_cert = Certificate::generate_self_signed(vec!["client".into()]).unwrap();
// Each side is given the *other's* real fingerprint, as SDP would carry.
let server_expects = fp_of(&client_cert);
let client_expects = fp_of(&server_cert);

let (server_result, client_result) = handshake_pair(
server_cert,
client_cert,
Some(server_expects),
Some(client_expects),
)
.await;

assert!(
server_result.is_some() && client_result.is_some(),
"matching fingerprints must complete the handshake"
);
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn dtls_handshake_rejects_a_mismatched_peer_fingerprint() {
let server_cert = Certificate::generate_self_signed(vec!["server".into()]).unwrap();
let client_cert = Certificate::generate_self_signed(vec!["client".into()]).unwrap();
// The server is told to expect a fingerprint that is NOT the client's —
// a stand-in for a MITM presenting a different cert. The server must
// abort, so no keying material is exported on either side.
let wrong = PeerFingerprint {
algorithm: "sha-256".to_string(),
hex_colon: "00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:\
00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF"
.to_string(),
};

let (server_result, client_result) =
handshake_pair(server_cert, client_cert, Some(wrong), None).await;

assert!(
server_result.is_none(),
"server must reject a mismatched client fingerprint"
);
assert!(
client_result.is_none(),
"the aborted handshake must also fail the peer"
);
}

#[test]
fn fingerprint_parse_bare_hex_assumes_sha256() {
let fp = PeerFingerprint::parse("a1:b2:c3").unwrap();
assert_eq!(fp.algorithm, "sha-256");
assert_eq!(fp.hex_colon, "A1:B2:C3");
}

#[test]
fn fingerprint_parse_algorithm_prefixed() {
let fp = PeerFingerprint::parse("SHA-256 a1:b2:c3").unwrap();
assert_eq!(fp.algorithm, "sha-256");
assert_eq!(fp.hex_colon, "A1:B2:C3");
}

#[test]
fn fingerprint_parse_empty_is_none() {
assert!(PeerFingerprint::parse("").is_none());
assert!(PeerFingerprint::parse(" ").is_none());
}

#[test]
fn fingerprint_matches_der_of_own_cert() {
let cert = Certificate::generate_self_signed(vec!["peer".into()]).unwrap();
let der = cert.certificate.first().unwrap().as_ref();
let fp = fp_of(&cert);
assert!(fp.matches_der(der), "a cert must match its own fingerprint");

// Case-insensitive on the hex.
let lower = PeerFingerprint {
algorithm: "sha-256".to_string(),
hex_colon: fp.hex_colon.to_ascii_lowercase(),
};
assert!(lower.matches_der(der));
}

#[test]
fn fingerprint_rejects_wrong_hash_and_unknown_algorithm() {
let cert = Certificate::generate_self_signed(vec!["peer".into()]).unwrap();
let der = cert.certificate.first().unwrap().as_ref();

let mut wrong = fp_of(&cert);
// Flip the first hex nibble.
let first = if wrong.hex_colon.starts_with('0') {
'1'
} else {
'0'
};
wrong.hex_colon.replace_range(0..1, &first.to_string());
assert!(!wrong.matches_der(der), "a flipped digit must not match");

// A correct digest under an unsupported algorithm must fail closed.
let unknown = PeerFingerprint {
algorithm: "sha-1".to_string(),
hex_colon: crate::dtls::sha256_fingerprint(der),
};
assert!(
!unknown.matches_der(der),
"unsupported algorithm must fail closed"
);
}
}
10 changes: 10 additions & 0 deletions rust/src/channel/mixer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,11 @@ async fn send_leg(src: &mut Member, dst: &mut Member) {
let Some(dest_addr) = dst.state.get_remote_addr() else {
return;
};
// Withhold media on a DTLS channel until SRTP keys exist — never downgrade
// to plaintext on a failed/in-progress handshake.
if dst.state.secure_not_ready() {
return;
}
let peer_pt = dst.state.remote_pt;

let Some(wire) = dst.state.codecx.encode_from(peer_pt, &mut src.state.codecx) else {
Expand Down Expand Up @@ -1119,6 +1124,11 @@ async fn feed_recorders(
}

async fn send_rtp(state: &mut ChannelState, pkt: &RtpPacket, remote: SocketAddr) {
// Never emit plaintext on a channel that negotiated DTLS but has no keys
// yet — a failed/in-progress handshake must not downgrade to cleartext.
if state.secure_not_ready() {
return;
}
// Payload octets (excludes the RTP header) — the RTCP SR octet count.
let octets = pkt.payload_len() as u64;
if let Some(ref mut ctx) = state.srtp_encrypt {
Expand Down
Loading