1use std::net::IpAddr;
8
9use crate::answer::{fingerprint_of, negotiate_direction, setup_of};
10use crate::fingerprint::{Fingerprint, HashFunc, Setup, SetupCapabilities};
11use crate::ice::{Candidate, CandidateType, ComponentId, Credentials, ICE2};
12use crate::{Attribute, Connection, Direction, MediaDescription, SessionDescription};
13
14const PROTOCOL: &str = "UDP/TLS/RTP/SAVPF";
15const LOCAL_FORMATS: [&str; 5] = ["111", "0", "8", "13", "101"];
16const MAX_CANDIDATES: usize = 32;
17const MAX_CANDIDATE_LINE: usize = 512;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum BrowserAudioRole {
22 Offerer,
24 Answerer,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub struct BrowserAudioPayloads {
31 pub opus: u8,
33 pub pcmu: u8,
35 pub pcma: u8,
37 pub comfort_noise: u8,
39 pub telephone_event: u8,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct BrowserAudioDescription {
46 pub role: BrowserAudioRole,
48 pub payloads: BrowserAudioPayloads,
50 pub selected_audio_payload: u8,
52 pub direction: Direction,
54 pub ice: Credentials,
56 pub candidates: Vec<Candidate>,
58 pub fingerprint: Fingerprint,
60 pub setup: Setup,
62 pub address: IpAddr,
64 pub port: u16,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct BrowserAudioAnswer {
71 pub description: BrowserAudioDescription,
73 pub local_setup: Setup,
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum IceChange {
80 Unchanged,
82 Restart,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct BrowserAudioRenegotiation {
89 pub description: BrowserAudioDescription,
91 pub ice_change: IceChange,
93}
94
95#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct BrowserAudioLocal {
98 pub address: IpAddr,
100 pub port: u16,
102 pub session_id: u64,
104 pub session_version: u64,
106 pub direction: Direction,
108 pub ice: Credentials,
110 pub candidates: Vec<Candidate>,
112 pub fingerprint: Fingerprint,
114 pub setup: SetupCapabilities,
116}
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
120#[non_exhaustive]
121pub enum ProfileError {
122 #[error("Opus is required by the browser-audio profile")]
124 OpusUnavailable,
125 #[error("the browser-audio profile requires authenticated SIP over WSS")]
127 InsecureSignalling,
128 #[error("the browser-audio profile requires exactly one active audio section")]
130 MediaSectionCount,
131 #[error("the media protocol is not UDP/TLS/RTP/SAVPF")]
133 WrongProtocol,
134 #[error("the browser-audio profile requires multiplexed RTCP on component one")]
136 RtcpMuxRequired,
137 #[error("the browser-audio profile requires a complete usable ICE generation")]
139 IceRequired,
140 #[error("the DTLS setup role is incompatible")]
142 SetupRole,
143 #[error("a SHA-256 certificate fingerprint is required")]
145 FingerprintRequired,
146 #[error("the required browser-audio codec set is incomplete")]
148 CodecSetIncomplete,
149 #[error("the description attempts to weaken the selected media profile")]
151 WeakerMedia,
152 #[error("a subsequent description removed the browser-audio profile")]
154 ProfileRemoved,
155 #[error("ICE produced no nominated component-one pair")]
157 NoNominatedPair,
158 #[error("the DTLS handshake timed out")]
160 DtlsTimeout,
161 #[error("the DTLS certificate fingerprint did not match signalling")]
163 FingerprintMismatch,
164 #[error("DTLS negotiated no supported SRTP profile")]
166 NoSrtpProfile,
167 #[error("browser-audio setup was cancelled")]
169 Cancelled,
170}
171
172pub fn offer(local: &BrowserAudioLocal) -> Result<SessionDescription, ProfileError> {
174 validate_local(local)?;
175 build(
176 local,
177 Setup::ActPass,
178 &LOCAL_FORMATS,
179 BrowserAudioPayloads {
180 opus: 111,
181 pcmu: 0,
182 pcma: 8,
183 comfort_noise: 13,
184 telephone_event: 101,
185 },
186 local.direction,
187 )
188}
189
190pub fn answer(
192 offered: &SessionDescription,
193 local: &BrowserAudioLocal,
194) -> Result<SessionDescription, ProfileError> {
195 let remote = validate(offered, BrowserAudioRole::Offerer)?;
196 validate_local(local)?;
197 let setup = local
198 .setup
199 .answer_to(remote.setup)
200 .map_err(|_| ProfileError::SetupRole)?;
201 let required = [
202 remote.payloads.opus,
203 remote.payloads.pcmu,
204 remote.payloads.pcma,
205 remote.payloads.comfort_noise,
206 remote.payloads.telephone_event,
207 ];
208 let offered_media = offered
209 .media
210 .first()
211 .ok_or(ProfileError::MediaSectionCount)?;
212 let formats: Vec<&str> = offered_media
213 .formats
214 .iter()
215 .filter(|format| {
216 format
217 .parse::<u8>()
218 .is_ok_and(|payload| required.contains(&payload))
219 })
220 .map(String::as_str)
221 .collect();
222 build(
223 local,
224 setup,
225 &formats,
226 remote.payloads,
227 negotiate_direction(remote.direction, local.direction),
228 )
229}
230
231pub fn validate(
233 description: &SessionDescription,
234 role: BrowserAudioRole,
235) -> Result<BrowserAudioDescription, ProfileError> {
236 let media = match description.media.as_slice() {
237 [media] if media.media == "audio" && !media.is_rejected() => media,
238 _ => return Err(ProfileError::MediaSectionCount),
239 };
240 if has_attribute(description, media, "crypto") {
241 return Err(ProfileError::WeakerMedia);
242 }
243 if media.protocol != PROTOCOL {
244 return match role {
245 BrowserAudioRole::Answerer => Err(ProfileError::WeakerMedia),
246 BrowserAudioRole::Offerer if is_weaker_protocol(&media.protocol) => {
247 Err(ProfileError::WeakerMedia)
248 }
249 BrowserAudioRole::Offerer => Err(ProfileError::WrongProtocol),
250 };
251 }
252 if !media.rtcp_mux()
253 || media
254 .attribute("rtcp")
255 .is_some_and(|attribute| !is_mux_placeholder(attribute.value.as_deref()))
256 || raw_candidates(media)
257 .filter_map(raw_candidate_component)
258 .any(|component| component != ComponentId::RTP)
259 {
260 return Err(ProfileError::RtcpMuxRequired);
261 }
262 if media.ice_mismatch()
263 || !description
264 .ice_options_for(media)
265 .any(|option| matches!(option, ICE2 | "trickle"))
266 {
267 return Err(ProfileError::IceRequired);
268 }
269 let ice = description
270 .ice_credentials_for(media)
271 .ok_or(ProfileError::IceRequired)?;
272 let raw_candidates: Vec<&str> = raw_candidates(media).collect();
273 if raw_candidates.is_empty()
274 || raw_candidates.len() > MAX_CANDIDATES
275 || raw_candidates
276 .iter()
277 .any(|value| value.len() > MAX_CANDIDATE_LINE)
278 {
279 return Err(ProfileError::IceRequired);
280 }
281 let candidates: Vec<Candidate> = raw_candidates
282 .into_iter()
283 .map(Candidate::parse)
284 .collect::<Option<_>>()
285 .ok_or(ProfileError::IceRequired)?;
286 if candidates.iter().any(|candidate| {
287 candidate.component != ComponentId::RTP
288 || !matches!(
289 candidate.kind,
290 CandidateType::Host | CandidateType::ServerReflexive
291 )
292 }) {
293 return Err(ProfileError::IceRequired);
294 }
295 let setup = setup_of(description, media).ok_or(ProfileError::SetupRole)?;
296 let setup_ok = match role {
297 BrowserAudioRole::Offerer => setup == Setup::ActPass,
298 BrowserAudioRole::Answerer => matches!(setup, Setup::Active | Setup::Passive),
299 };
300 if !setup_ok {
301 return Err(ProfileError::SetupRole);
302 }
303 let fingerprint = fingerprint_of(description, media)
304 .filter(|value| value.func == HashFunc::Sha256)
305 .ok_or(ProfileError::FingerprintRequired)?;
306 let payloads = payloads(media)?;
307 let address = description
308 .address_for(media)
309 .filter(|value| !value.is_unspecified())
310 .ok_or(ProfileError::IceRequired)?;
311 let selected_audio_payload = media
312 .formats
313 .iter()
314 .filter_map(|format| format.parse::<u8>().ok())
315 .find(|payload| [payloads.opus, payloads.pcmu, payloads.pcma].contains(payload))
316 .ok_or(ProfileError::CodecSetIncomplete)?;
317 Ok(BrowserAudioDescription {
318 role,
319 payloads,
320 selected_audio_payload,
321 direction: media
322 .declared_direction()
323 .unwrap_or_else(|| description.direction()),
324 ice,
325 candidates,
326 fingerprint,
327 setup,
328 address,
329 port: media.port,
330 })
331}
332
333pub fn validate_answer(
335 offered: &SessionDescription,
336 answered: &SessionDescription,
337 local_setup: SetupCapabilities,
338) -> Result<BrowserAudioAnswer, ProfileError> {
339 let offered_profile = validate(offered, BrowserAudioRole::Offerer)?;
340 let description = validate(answered, BrowserAudioRole::Answerer)?;
341 let required = [
342 offered_profile.payloads.opus,
343 offered_profile.payloads.pcmu,
344 offered_profile.payloads.pcma,
345 offered_profile.payloads.comfort_noise,
346 offered_profile.payloads.telephone_event,
347 ];
348 let offered_media = offered
349 .media
350 .first()
351 .ok_or(ProfileError::MediaSectionCount)?;
352 let answered_media = answered
353 .media
354 .first()
355 .ok_or(ProfileError::MediaSectionCount)?;
356 let expected: Vec<&str> = offered_media
357 .formats
358 .iter()
359 .filter(|format| {
360 format
361 .parse::<u8>()
362 .is_ok_and(|payload| required.contains(&payload))
363 })
364 .map(String::as_str)
365 .collect();
366 if expected != answered_media.formats || offered_profile.payloads != description.payloads {
367 return Err(ProfileError::CodecSetIncomplete);
368 }
369 let local_setup = local_setup
370 .from_answer(Some(description.setup))
371 .map_err(|_| ProfileError::SetupRole)?;
372 Ok(BrowserAudioAnswer {
373 description,
374 local_setup,
375 })
376}
377
378pub fn validate_reoffer(
380 current: &SessionDescription,
381 next: &SessionDescription,
382 role: BrowserAudioRole,
383) -> Result<BrowserAudioRenegotiation, ProfileError> {
384 let current = validate(current, role)?;
385 let description = validate(next, role).map_err(|error| match error {
386 ProfileError::IceRequired => ProfileError::IceRequired,
387 ProfileError::MediaSectionCount
388 | ProfileError::WrongProtocol
389 | ProfileError::RtcpMuxRequired
390 | ProfileError::SetupRole
391 | ProfileError::FingerprintRequired
392 | ProfileError::CodecSetIncomplete
393 | ProfileError::WeakerMedia => ProfileError::ProfileRemoved,
394 other => other,
395 })?;
396 let ufrag_changed = current.ice.ufrag() != description.ice.ufrag();
397 let pwd_changed = current.ice.pwd() != description.ice.pwd();
398 let ice_change = match (ufrag_changed, pwd_changed) {
399 (false, false) => IceChange::Unchanged,
400 (true, true) => IceChange::Restart,
401 _ => return Err(ProfileError::IceRequired),
402 };
403 if current.payloads != description.payloads
404 || ice_change == IceChange::Unchanged
405 && (current.fingerprint != description.fingerprint
406 || current.setup != description.setup)
407 {
408 return Err(ProfileError::ProfileRemoved);
409 }
410 Ok(BrowserAudioRenegotiation {
411 description,
412 ice_change,
413 })
414}
415
416fn validate_local(local: &BrowserAudioLocal) -> Result<(), ProfileError> {
417 if local.port == 0 || local.address.is_unspecified() {
418 return Err(ProfileError::IceRequired);
419 }
420 if local.fingerprint.func != HashFunc::Sha256 {
421 return Err(ProfileError::FingerprintRequired);
422 }
423 if local.candidates.is_empty()
424 || local.candidates.len() > MAX_CANDIDATES
425 || local.candidates.iter().any(|candidate| {
426 candidate.component != ComponentId::RTP
427 || !matches!(
428 candidate.kind,
429 CandidateType::Host | CandidateType::ServerReflexive
430 )
431 || candidate.to_value().len() > MAX_CANDIDATE_LINE
432 })
433 {
434 return Err(ProfileError::IceRequired);
435 }
436 local
437 .setup
438 .answer_to(Setup::ActPass)
439 .map_err(|_| ProfileError::SetupRole)?;
440 Ok(())
441}
442
443fn build(
444 local: &BrowserAudioLocal,
445 setup: Setup,
446 formats: &[&str],
447 payloads: BrowserAudioPayloads,
448 direction: Direction,
449) -> Result<SessionDescription, ProfileError> {
450 let mut description =
451 SessionDescription::new(local.address, local.session_id, local.session_version);
452 description.connection = None;
454 description
455 .attributes
456 .push(Attribute::valued("ice-options", ICE2));
457 let mut media = MediaDescription {
458 media: "audio".to_owned(),
459 port: local.port,
460 protocol: PROTOCOL.to_owned(),
461 formats: formats.iter().map(|value| (*value).to_owned()).collect(),
462 connection: Some(Connection::new(local.address)),
463 attributes: vec![
464 Attribute::flag(direction.as_str()),
465 Attribute::flag("rtcp-mux"),
466 Attribute::valued("ice-ufrag", local.ice.ufrag()),
467 Attribute::valued("ice-pwd", local.ice.pwd()),
468 ],
469 other: Vec::new(),
470 };
471 media.attributes.extend(
472 local
473 .candidates
474 .iter()
475 .map(|candidate| Attribute::valued("candidate", candidate.to_value())),
476 );
477 media.attributes.extend([
478 Attribute::valued("fingerprint", local.fingerprint.to_value()),
479 Attribute::valued("setup", setup.as_str()),
480 Attribute::valued("rtpmap", format!("{} opus/48000/2", payloads.opus)),
481 Attribute::valued("rtpmap", format!("{} PCMU/8000", payloads.pcmu)),
482 Attribute::valued("rtpmap", format!("{} PCMA/8000", payloads.pcma)),
483 Attribute::valued("rtpmap", format!("{} CN/8000", payloads.comfort_noise)),
484 Attribute::valued(
485 "rtpmap",
486 format!("{} telephone-event/8000", payloads.telephone_event),
487 ),
488 Attribute::valued("fmtp", format!("{} 0-16", payloads.telephone_event)),
489 ]);
490 description.media.push(media);
491 let role = if setup == Setup::ActPass {
492 BrowserAudioRole::Offerer
493 } else {
494 BrowserAudioRole::Answerer
495 };
496 validate(&description, role)?;
497 Ok(description)
498}
499
500fn payloads(media: &MediaDescription) -> Result<BrowserAudioPayloads, ProfileError> {
501 if media.formats.len() < 5 {
502 return Err(ProfileError::CodecSetIncomplete);
503 }
504 let parsed: Vec<u8> = media
505 .formats
506 .iter()
507 .map(|format| format.parse::<u8>())
508 .collect::<Result<_, _>>()
509 .map_err(|_| ProfileError::CodecSetIncomplete)?;
510 let mut unique_formats = parsed.clone();
511 unique_formats.sort_unstable();
512 unique_formats.dedup();
513 if unique_formats.len() != parsed.len()
514 || parsed.iter().any(|payload| (64..=95).contains(payload))
515 {
516 return Err(ProfileError::CodecSetIncomplete);
517 }
518 let opus = find_mapping(media, "opus", 48_000, Some(2))?;
519 let mu_law = static_or_mapping(media, 0, "PCMU", 8_000)?;
520 let a_law = static_or_mapping(media, 8, "PCMA", 8_000)?;
521 let comfort_noise = exact_mapping(media, 13, "CN", 8_000, None)?;
522 let telephone_event = find_mapping(media, "telephone-event", 8_000, None)?;
523 if !telephone_events_cover_dtmf(media, telephone_event) {
524 return Err(ProfileError::CodecSetIncomplete);
525 }
526 let required = [opus, mu_law, a_law, comfort_noise, telephone_event];
527 if required.iter().any(|payload| !parsed.contains(payload)) {
528 return Err(ProfileError::CodecSetIncomplete);
529 }
530 let mut unique = required.to_vec();
531 unique.sort_unstable();
532 unique.dedup();
533 if unique.len() != 5 {
534 return Err(ProfileError::CodecSetIncomplete);
535 }
536 Ok(BrowserAudioPayloads {
537 opus,
538 pcmu: mu_law,
539 pcma: a_law,
540 comfort_noise,
541 telephone_event,
542 })
543}
544
545fn find_mapping(
546 media: &MediaDescription,
547 encoding: &str,
548 clock: u32,
549 channels: Option<u8>,
550) -> Result<u8, ProfileError> {
551 let matches: Vec<u8> = media
552 .formats
553 .iter()
554 .filter_map(|format| {
555 let mapping = media.rtpmap(format)?;
556 mapping_matches(mapping, encoding, clock, channels)
557 .then(|| format.parse().ok())
558 .flatten()
559 })
560 .collect();
561 match matches.as_slice() {
562 [payload] => Ok(*payload),
563 _ => Err(ProfileError::CodecSetIncomplete),
564 }
565}
566
567fn static_or_mapping(
568 media: &MediaDescription,
569 payload: u8,
570 encoding: &str,
571 clock: u32,
572) -> Result<u8, ProfileError> {
573 let format = payload.to_string();
574 if !media.formats.contains(&format) {
575 return Err(ProfileError::CodecSetIncomplete);
576 }
577 match media.rtpmap(&format) {
578 None => Ok(payload),
579 Some(mapping) if mapping_matches(mapping, encoding, clock, None) => Ok(payload),
580 Some(_) => Err(ProfileError::CodecSetIncomplete),
581 }
582}
583
584fn exact_mapping(
585 media: &MediaDescription,
586 payload: u8,
587 encoding: &str,
588 clock: u32,
589 channels: Option<u8>,
590) -> Result<u8, ProfileError> {
591 let format = payload.to_string();
592 match media.rtpmap(&format) {
593 Some(mapping)
594 if media.formats.contains(&format)
595 && mapping_matches(mapping, encoding, clock, channels) =>
596 {
597 Ok(payload)
598 }
599 _ => Err(ProfileError::CodecSetIncomplete),
600 }
601}
602
603fn mapping_matches(mapping: &str, encoding: &str, clock: u32, channels: Option<u8>) -> bool {
604 let mut parts = mapping.split('/');
605 let matches = parts
606 .next()
607 .is_some_and(|actual| actual.eq_ignore_ascii_case(encoding))
608 && parts.next().and_then(|actual| actual.parse::<u32>().ok()) == Some(clock);
609 let actual_channels = parts.next().and_then(|actual| actual.parse::<u8>().ok());
610 matches && actual_channels == channels && parts.next().is_none()
611}
612
613fn telephone_events_cover_dtmf(media: &MediaDescription, payload: u8) -> bool {
614 let format = payload.to_string();
615 let parameters = media.attributes.iter().find_map(|attribute| {
616 if attribute.name != "fmtp" {
617 return None;
618 }
619 let value = attribute.value.as_deref()?;
620 let (actual, parameters) = value.split_once(' ')?;
621 (actual == format).then_some(parameters)
622 });
623 let Some(parameters) = parameters else {
624 return true;
626 };
627 (0_u8..=15).all(|wanted| {
628 parameters.split(',').any(|part| {
629 let (start, end) = part
630 .split_once('-')
631 .map_or((part, part), |(start, end)| (start, end));
632 let Some(start) = start.parse::<u8>().ok() else {
633 return false;
634 };
635 let Some(end) = end.parse::<u8>().ok() else {
636 return false;
637 };
638 (start..=end).contains(&wanted)
639 })
640 })
641}
642
643fn has_attribute(description: &SessionDescription, media: &MediaDescription, name: &str) -> bool {
644 description
645 .attributes
646 .iter()
647 .chain(media.attributes.iter())
648 .any(|attribute| attribute.name == name)
649}
650
651fn raw_candidates(media: &MediaDescription) -> impl Iterator<Item = &str> {
652 media
653 .attributes
654 .iter()
655 .filter(|attribute| attribute.name == "candidate")
656 .filter_map(|attribute| attribute.value.as_deref())
657}
658
659fn raw_candidate_component(value: &str) -> Option<ComponentId> {
660 let component = value.split_whitespace().nth(1)?.parse::<u16>().ok()?;
661 ComponentId::new(component)
662}
663
664fn is_weaker_protocol(protocol: &str) -> bool {
665 matches!(protocol, "RTP/AVP" | "RTP/SAVP" | "UDP/TLS/RTP/SAVP")
666}
667
668fn is_mux_placeholder(value: Option<&str>) -> bool {
669 matches!(
670 value,
671 Some("9 IN IP4 0.0.0.0" | "9 IN IP6 ::" | "9 IN IP6 0:0:0:0:0:0:0:0")
672 )
673}