Sframe SDP negotiation support. This PR implements SDP negotiation of the sframe support Bug: webrtc:479862368 Change-Id: I3bd2176e9044508e87dc861e436cc54020c84944 Reviewed-on: https://webrtc-review.googlesource.com/c/src/+/448041 Reviewed-by: Philip Eliasson <philipel@webrtc.org> Reviewed-by: Harald Alvestrand <hta@webrtc.org> Commit-Queue: Harald Alvestrand <hta@webrtc.org> Cr-Commit-Position: refs/heads/main@{#47395}
diff --git a/api/rtp_transceiver_interface.h b/api/rtp_transceiver_interface.h index 021b215..288fcf4 100644 --- a/api/rtp_transceiver_interface.h +++ b/api/rtp_transceiver_interface.h
@@ -25,6 +25,7 @@ #include "api/rtp_sender_interface.h" #include "api/rtp_transceiver_direction.h" #include "api/scoped_refptr.h" +#include "rtc_base/checks.h" #include "rtc_base/system/rtc_export.h" namespace webrtc { @@ -174,6 +175,18 @@ virtual RTCError SetHeaderExtensionsToNegotiate( std::span<const RtpHeaderExtensionCapability> header_extensions) = 0; + // Returns the negotiated SFrame state for this transceiver. + // - nullopt: SFrame state has not yet been decided (no negotiation). + // - true: SFrame is enabled. + // - false: SFrame is disabled (locked after negotiation without SFrame). + // Default implementation of SframeEnabled. + // TODO: bugs.webrtc.org/479862368 - remove when all implementations are + // updated + virtual std::optional<bool> SframeEnabled() const { + RTC_DCHECK_NOTREACHED(); + return std::nullopt; + } + protected: ~RtpTransceiverInterface() override = default; };
diff --git a/api/test/mock_rtp_transceiver.h b/api/test/mock_rtp_transceiver.h index 25a5c89..c34c121 100644 --- a/api/test/mock_rtp_transceiver.h +++ b/api/test/mock_rtp_transceiver.h
@@ -86,6 +86,7 @@ SetHeaderExtensionsToNegotiate, (std::span<const RtpHeaderExtensionCapability> header_extensions), (override)); + MOCK_METHOD(std::optional<bool>, SframeEnabled, (), (const, override)); }; } // namespace webrtc
diff --git a/api/uma_metrics.h b/api/uma_metrics.h index 10cac0c..503f8ab 100644 --- a/api/uma_metrics.h +++ b/api/uma_metrics.h
@@ -203,6 +203,7 @@ kIceCandidateCount = 32, kBundle = 33, kBandwidth = 34, + kSframe = 35, // RTP header extension munging. kRtpHeaderExtensionRemoved = 40, kRtpHeaderExtensionAdded = 41,
diff --git a/api/webrtc_sdp.cc b/api/webrtc_sdp.cc index 2407da7..56dc7b6 100644 --- a/api/webrtc_sdp.cc +++ b/api/webrtc_sdp.cc
@@ -146,6 +146,7 @@ const char kValueConference[] = "conference"; const char kAttributeRtcpRemoteEstimate[] = "remote-net-estimate"; +const char kAttributeSframe[] = "sframe"; // StringBuilder doesn't have a << overload for chars, while // split and tokenize_first both take a char delimiter. To @@ -1307,6 +1308,13 @@ AddLine(os.str(), message); } + // draft-ietf-avtcore-rtp-sframe-02 §6 + // a=sframe + if (media_desc->sframe_enabled()) { + InitAttrLine(kAttributeSframe, &os); + AddLine(os.str(), message); + } + // RFC 4566 // a=rtpmap:<payload type> <encoding name>/<clock rate> // [/<encodingparameters>] @@ -2700,6 +2708,8 @@ media_desc->set_rtcp_reduced_size(true); } else if (HasAttribute(*line, kAttributeRtcpRemoteEstimate)) { media_desc->set_remote_estimate(true); + } else if (HasAttribute(*line, kAttributeSframe)) { + media_desc->set_sframe_enabled(true); } else if (HasAttribute(*line, kAttributeSsrcGroup)) { if (!ParseSsrcGroupAttribute(*line, &ssrc_groups, error)) { return false;
diff --git a/api/webrtc_sdp_unittest.cc b/api/webrtc_sdp_unittest.cc index d121d8d..7a4d0a9 100644 --- a/api/webrtc_sdp_unittest.cc +++ b/api/webrtc_sdp_unittest.cc
@@ -5111,5 +5111,63 @@ EXPECT_TRUE(SdpDeserialize(sdp_with_audio_codec_1)); } +TEST_F(WebRtcSdpTest, DeserializeSframeAttribute) { + std::string sdp = kSdpSessionString; + sdp += kSdpVideoString; + sdp += "a=sframe\r\n"; + + auto jdesc = SdpDeserialize(sdp); + ASSERT_THAT(jdesc, NotNull()); + ASSERT_EQ(1u, jdesc->description()->contents().size()); + EXPECT_TRUE(jdesc->description() + ->contents()[0] + .media_description() + ->sframe_enabled()); +} + +TEST_F(WebRtcSdpTest, DeserializeWithoutSframeAttribute) { + std::string sdp = kSdpSessionString; + sdp += kSdpVideoString; + + auto jdesc = SdpDeserialize(sdp); + ASSERT_THAT(jdesc, NotNull()); + ASSERT_EQ(1u, jdesc->description()->contents().size()); + EXPECT_FALSE(jdesc->description() + ->contents()[0] + .media_description() + ->sframe_enabled()); +} + +TEST_F(WebRtcSdpTest, SerializeSframeAttribute) { + video_desc_->set_sframe_enabled(true); + std::string message = SdpSerialize(MakeDescriptionWithoutCandidates()); + EXPECT_NE(std::string::npos, message.find("a=sframe\r\n")); +} + +TEST_F(WebRtcSdpTest, SerializeWithoutSframeAttribute) { + video_desc_->set_sframe_enabled(false); + std::string message = SdpSerialize(MakeDescriptionWithoutCandidates()); + EXPECT_EQ(std::string::npos, message.find("a=sframe")); +} + +TEST_F(WebRtcSdpTest, SframeAttributeRoundTrip) { + video_desc_->set_sframe_enabled(true); + std::string message = SdpSerialize(MakeDescriptionWithoutCandidates()); + EXPECT_NE(std::string::npos, message.find("a=sframe\r\n")); + + auto jdesc = SdpDeserialize(message); + ASSERT_THAT(jdesc, NotNull()); + ASSERT_EQ(2u, jdesc->description()->contents().size()); + // audio (index 0) should not have sframe, video (index 1) should. + EXPECT_FALSE(jdesc->description() + ->contents()[0] + .media_description() + ->sframe_enabled()); + EXPECT_TRUE(jdesc->description() + ->contents()[1] + .media_description() + ->sframe_enabled()); +} + } // namespace } // namespace webrtc
diff --git a/pc/media_options.h b/pc/media_options.h index 64d4b9f..93a8a59 100644 --- a/pc/media_options.h +++ b/pc/media_options.h
@@ -75,6 +75,8 @@ // Codecs to include in a generated offer or answer. // If this is used, session-level codec lists MUST be ignored. std::vector<Codec> codecs_to_include; + // Whether Sframe encryption is requested for this media section. + bool sframe_enabled = false; private: // Doesn't DCHECK on `type`.
diff --git a/pc/media_session.cc b/pc/media_session.cc index 79bc2f4..5e2139e 100644 --- a/pc/media_session.cc +++ b/pc/media_session.cc
@@ -544,6 +544,13 @@ return true; } +// Negotiates Sframe support between the offer and the local answerer options. +bool NegotiateSframeUsage( + const MediaContentDescription* offer, + const MediaDescriptionOptions& media_description_options) { + return offer->sframe_enabled() && media_description_options.sframe_enabled; +} + // Create a media content to be answered for the given `sender_options` // according to the given session_options.rtcp_mux, session_options.streams, // codecs, crypto, and current_streams. If we don't currently have crypto (in @@ -598,6 +605,8 @@ answer->set_direction(NegotiateRtpTransceiverDirection( offer->direction(), media_description_options.direction)); + answer->set_sframe_enabled( + NegotiateSframeUsage(offer, media_description_options)); return true; } @@ -1206,6 +1215,8 @@ SetMediaProtocol(secure_transport, content_description.get()); content_description->set_direction(media_description_options.direction); + content_description->set_sframe_enabled( + media_description_options.sframe_enabled); bool has_codecs = !content_description->codecs().empty(); session_description->AddContent(
diff --git a/pc/media_session_unittest.cc b/pc/media_session_unittest.cc index 87aac0d..6b64697 100644 --- a/pc/media_session_unittest.cc +++ b/pc/media_session_unittest.cc
@@ -4503,6 +4503,182 @@ EXPECT_EQ(vcd1->codecs()[0].id, vcd2->codecs()[0].id); } +// Parameterized Sframe tests over MediaType::AUDIO and MediaType::VIDEO. +// The offer/answer Sframe logic is identical for both media types. +class SframeMediaTypeTest : public MediaSessionDescriptionFactoryTest, + public ::testing::WithParamInterface<MediaType> { + protected: + static constexpr auto kTestMid = "sframe_test"; + + // Creates MediaSessionOptions with a single recv-only media section + // for the parameterized media type. + MediaSessionOptions CreateMediaSession() { + MediaSessionOptions opts; + AddMediaDescriptionOptions(GetParam(), kTestMid, + RtpTransceiverDirection::kRecvOnly, kActive, + &opts); + return opts; + } +}; + +TEST_P(SframeMediaTypeTest, OfferWithoutSframeHasSframeEnabledFalse) { + MediaSessionOptions opts = CreateMediaSession(); + std::unique_ptr<SessionDescription> offer = + f1_.CreateOfferOrError(opts, nullptr).MoveValue(); + ASSERT_THAT(offer, NotNull()); + ASSERT_EQ(offer->contents().size(), 1u); + EXPECT_FALSE(offer->contents()[0].media_description()->sframe_enabled()); +} + +TEST_P(SframeMediaTypeTest, OfferWithSframeHasSframeEnabledTrue) { + MediaSessionOptions opts = CreateMediaSession(); + opts.media_description_options[0].sframe_enabled = true; + std::unique_ptr<SessionDescription> offer = + f1_.CreateOfferOrError(opts, nullptr).MoveValue(); + ASSERT_THAT(offer, NotNull()); + ASSERT_EQ(offer->contents().size(), 1u); + EXPECT_TRUE(offer->contents()[0].media_description()->sframe_enabled()); +} + +TEST_P(SframeMediaTypeTest, AnswerWithoutSframeHasSframeEnabledFalse) { + MediaSessionOptions opts = CreateMediaSession(); + std::unique_ptr<SessionDescription> offer = + f1_.CreateOfferOrError(opts, nullptr).MoveValue(); + ASSERT_THAT(offer, NotNull()); + std::unique_ptr<SessionDescription> answer = + f2_.CreateAnswerOrError(offer.get(), opts, nullptr).MoveValue(); + ASSERT_THAT(answer, NotNull()); + ASSERT_EQ(answer->contents().size(), 1u); + EXPECT_FALSE(answer->contents()[0].media_description()->sframe_enabled()); +} + +TEST_P(SframeMediaTypeTest, AnswerWithSframeHasSframeEnabledTrue) { + MediaSessionOptions offer_opts = CreateMediaSession(); + offer_opts.media_description_options[0].sframe_enabled = true; + std::unique_ptr<SessionDescription> offer = + f1_.CreateOfferOrError(offer_opts, nullptr).MoveValue(); + ASSERT_THAT(offer, NotNull()); + MediaSessionOptions answer_opts = CreateMediaSession(); + answer_opts.media_description_options[0].sframe_enabled = true; + std::unique_ptr<SessionDescription> answer = + f2_.CreateAnswerOrError(offer.get(), answer_opts, nullptr).MoveValue(); + ASSERT_THAT(answer, NotNull()); + ASSERT_EQ(answer->contents().size(), 1u); + EXPECT_TRUE(answer->contents()[0].media_description()->sframe_enabled()); +} + +INSTANTIATE_TEST_SUITE_P(Sframe, + SframeMediaTypeTest, + ::testing::Values(MediaType::AUDIO, MediaType::VIDEO), + [](const ::testing::TestParamInfo<MediaType>& info) { + return MediaTypeToString(info.param); + }); + +TEST_F(MediaSessionDescriptionFactoryTest, + AudioVideoOfferWithoutSframeHasSframeEnabledFalse) { + MediaSessionOptions opts; + AddAudioVideoSections(RtpTransceiverDirection::kRecvOnly, &opts); + std::unique_ptr<SessionDescription> offer = + f1_.CreateOfferOrError(opts, nullptr).MoveValue(); + ASSERT_THAT(offer, NotNull()); + ASSERT_EQ(offer->contents().size(), 2u); + EXPECT_FALSE(offer->contents()[0].media_description()->sframe_enabled()); + EXPECT_FALSE(offer->contents()[1].media_description()->sframe_enabled()); +} + +// Verify that in an audio+video offer, Sframe on video does not affect audio. +TEST_F(MediaSessionDescriptionFactoryTest, + AudioVideoOfferWithSframeHasSframeEnabledTrue) { + MediaSessionOptions opts; + AddAudioVideoSections(RtpTransceiverDirection::kRecvOnly, &opts); + opts.media_description_options[1].sframe_enabled = true; + std::unique_ptr<SessionDescription> offer = + f1_.CreateOfferOrError(opts, nullptr).MoveValue(); + ASSERT_THAT(offer, NotNull()); + ASSERT_EQ(offer->contents().size(), 2u); + // Audio should not have Sframe since we only set it on the video section. + EXPECT_FALSE(offer->contents()[0].media_description()->sframe_enabled()); + EXPECT_TRUE(offer->contents()[1].media_description()->sframe_enabled()); +} + +// Verify that when the offer has Sframe but the answerer does not, +// answer creation succeeds and the answer has sframe_enabled=false. +// The offerer is responsible for handling the mismatch (per +// draft-ietf-avtcore-rtp-sframe-02 §6). +TEST_F(MediaSessionDescriptionFactoryTest, + AnswerWithoutSframeSucceedsWhenOfferHasSframe) { + MediaSessionOptions offer_opts = CreateAudioMediaSession(); + offer_opts.media_description_options[0].sframe_enabled = true; + std::unique_ptr<SessionDescription> offer = + f1_.CreateOfferOrError(offer_opts, nullptr).MoveValue(); + ASSERT_THAT(offer, NotNull()); + ASSERT_EQ(offer->contents().size(), 1u); + EXPECT_TRUE(offer->contents()[0].media_description()->sframe_enabled()); + // Answerer does NOT set sframe_enabled — answer creation should succeed. + MediaSessionOptions answer_opts = CreateAudioMediaSession(); + auto result = f2_.CreateAnswerOrError(offer.get(), answer_opts, nullptr); + ASSERT_TRUE(result.ok()); + ASSERT_EQ(result.value()->contents().size(), 1u); + EXPECT_FALSE( + result.value()->contents()[0].media_description()->sframe_enabled()); +} + +// Verify that when the answerer wants Sframe but the offer does not have it, +// the answer must NOT include a=sframe (RFC 3264: answer is constrained by +// the offer). +TEST_F(MediaSessionDescriptionFactoryTest, + AnswerDoesNotInjectSframeWhenOfferLacksIt) { + MediaSessionOptions offer_opts = CreateAudioMediaSession(); + std::unique_ptr<SessionDescription> offer = + f1_.CreateOfferOrError(offer_opts, nullptr).MoveValue(); + ASSERT_THAT(offer, NotNull()); + ASSERT_EQ(offer->contents().size(), 1u); + EXPECT_FALSE(offer->contents()[0].media_description()->sframe_enabled()); + MediaSessionOptions answer_opts = CreateAudioMediaSession(); + answer_opts.media_description_options[0].sframe_enabled = true; + auto result = f2_.CreateAnswerOrError(offer.get(), answer_opts, nullptr); + ASSERT_TRUE(result.ok()); + ASSERT_EQ(result.value()->contents().size(), 1u); + EXPECT_FALSE( + result.value()->contents()[0].media_description()->sframe_enabled()); +} + +TEST_F(MediaSessionDescriptionFactoryTest, OfferSframeIsPerMediaSection) { + MediaSessionOptions opts; + AddAudioVideoSections(RtpTransceiverDirection::kRecvOnly, &opts); + // Enable Sframe on audio only. + opts.media_description_options[0].sframe_enabled = true; + opts.media_description_options[1].sframe_enabled = false; + std::unique_ptr<SessionDescription> offer = + f1_.CreateOfferOrError(opts, nullptr).MoveValue(); + ASSERT_THAT(offer, NotNull()); + ASSERT_EQ(offer->contents().size(), 2u); + EXPECT_TRUE(offer->contents()[0].media_description()->sframe_enabled()); + EXPECT_FALSE(offer->contents()[1].media_description()->sframe_enabled()); +} + +TEST_F(MediaSessionDescriptionFactoryTest, AnswerSframeIsPerMediaSection) { + MediaSessionOptions opts; + AddAudioVideoSections(RtpTransceiverDirection::kRecvOnly, &opts); + // Only audio has Sframe in the offer. + opts.media_description_options[0].sframe_enabled = true; + opts.media_description_options[1].sframe_enabled = false; + std::unique_ptr<SessionDescription> offer = + f1_.CreateOfferOrError(opts, nullptr).MoveValue(); + ASSERT_THAT(offer, NotNull()); + // The answerer's options must agree with the offer per section. + MediaSessionOptions answer_opts; + AddAudioVideoSections(RtpTransceiverDirection::kRecvOnly, &answer_opts); + answer_opts.media_description_options[0].sframe_enabled = true; + answer_opts.media_description_options[1].sframe_enabled = false; + std::unique_ptr<SessionDescription> answer = + f2_.CreateAnswerOrError(offer.get(), answer_opts, nullptr).MoveValue(); + ASSERT_THAT(answer, NotNull()); + ASSERT_EQ(answer->contents().size(), 2u); + EXPECT_TRUE(answer->contents()[0].media_description()->sframe_enabled()); + EXPECT_FALSE(answer->contents()[1].media_description()->sframe_enabled()); +} + #ifdef RTC_ENABLE_H265 // Test verifying that negotiating codecs with the same tx-mode retains the // tx-mode value.
diff --git a/pc/rtp_transceiver.cc b/pc/rtp_transceiver.cc index ea34fa9..38adf50 100644 --- a/pc/rtp_transceiver.cc +++ b/pc/rtp_transceiver.cc
@@ -952,6 +952,40 @@ return fired_direction_; } +RTCError RtpTransceiver::TryToEnableSframe() { + RTC_DCHECK_RUN_ON(thread_); + + if (sframe_enabled_.has_value() && sframe_enabled_.value() == false) { + return LOG_ERROR(RTCError::InvalidModification() + << "Cannot enable Sframe after it has been " + "disabled by a completed negotiation."); + } + + sframe_enabled_ = true; + + on_negotiation_needed_(); + + return RTCError::OK(); +} + +void RtpTransceiver::ApplySframeEnabled(bool sframe_enabled) { + RTC_DCHECK_RUN_ON(thread_); + // Cannot re-enable Sframe after it has been negotiated to disabled. + RTC_DCHECK(!(sframe_enabled_ == false && sframe_enabled == true)); + + sframe_enabled_ = sframe_enabled; + + if (sframe_enabled && channel_) { + // TODO(bugs.webrtc.org/479862368): Enable Sframe on the media send and + // receive channels when the encryption pipeline is implemented. + } +} + +std::optional<bool> RtpTransceiver::SframeEnabled() const { + RTC_DCHECK_RUN_ON(thread_); + return sframe_enabled_; +} + bool RtpTransceiver::receptive() const { RTC_DCHECK_RUN_ON(thread_); return receptive_;
diff --git a/pc/rtp_transceiver.h b/pc/rtp_transceiver.h index d115396..abe0893 100644 --- a/pc/rtp_transceiver.h +++ b/pc/rtp_transceiver.h
@@ -340,6 +340,19 @@ RtpTransceiverDirection new_direction) override; std::optional<RtpTransceiverDirection> current_direction() const override; std::optional<RtpTransceiverDirection> fired_direction() const override; + // Records the user's intent to use Sframe and fires negotiation needed. + // Triggered by the sender/receiver when + // CreateSframeEncrypterOrError/CreateSframeDecrypterOrError is called. + // Returns an error if Sframe has already been locked to false by a + // completed negotiation. + RTCError TryToEnableSframe(); + // Called from SdpOfferAnswerHandler to apply the Sframe state derived + // from the media description. Used when applying local descriptions + // (offers and answers) to sync the transceiver, and when associating + // new transceivers created from remote offers. + void ApplySframeEnabled(bool sframe_enabled); + // Returns the current Sframe state. + std::optional<bool> SframeEnabled() const override; bool receptive() const override; RTCError StopStandard() override; void StopInternal() override; @@ -490,6 +503,7 @@ RtpTransceiverDirection direction_ = RtpTransceiverDirection::kInactive; std::optional<RtpTransceiverDirection> current_direction_; std::optional<RtpTransceiverDirection> fired_direction_; + std::optional<bool> sframe_enabled_ RTC_GUARDED_BY(thread_) = std::nullopt; std::optional<std::string> mid_; std::optional<std::string> transport_name_ RTC_GUARDED_BY(thread_) = std::nullopt; @@ -556,6 +570,7 @@ PROXY_METHOD1(RTCError, SetHeaderExtensionsToNegotiate, std::span<const RtpHeaderExtensionCapability>) +PROXY_CONSTMETHOD0(std::optional<bool>, SframeEnabled) END_PROXY_MAP(RtpTransceiver) } // namespace webrtc
diff --git a/pc/rtp_transceiver_unittest.cc b/pc/rtp_transceiver_unittest.cc index 8b28a7d..58850fb 100644 --- a/pc/rtp_transceiver_unittest.cc +++ b/pc/rtp_transceiver_unittest.cc
@@ -1047,6 +1047,65 @@ transceiver->StopStandard(); } +// Sframe tests + +TEST_F(RtpTransceiverUnifiedPlanTest, SframeEnabledIsNulloptByDefault) { + scoped_refptr<RtpTransceiver> transceiver = CreateTransceiver( + MockSender(MediaType::AUDIO), MockReceiver(MediaType::AUDIO)); + + EXPECT_EQ(transceiver->SframeEnabled(), std::nullopt); +} + +TEST_F(RtpTransceiverUnifiedPlanTest, TryToEnableSframeSetsValueToTrue) { + scoped_refptr<RtpTransceiver> transceiver = CreateTransceiver( + MockSender(MediaType::AUDIO), MockReceiver(MediaType::AUDIO)); + + EXPECT_TRUE(transceiver->TryToEnableSframe().ok()); + EXPECT_THAT(transceiver->SframeEnabled(), Optional(true)); +} + +TEST_F(RtpTransceiverUnifiedPlanTest, + TryToEnableSframeCanBeCalledMultipleTimes) { + scoped_refptr<RtpTransceiver> transceiver = CreateTransceiver( + MockSender(MediaType::AUDIO), MockReceiver(MediaType::AUDIO)); + + EXPECT_TRUE(transceiver->TryToEnableSframe().ok()); + EXPECT_TRUE(transceiver->TryToEnableSframe().ok()); + EXPECT_THAT(transceiver->SframeEnabled(), Optional(true)); +} + +TEST_F(RtpTransceiverUnifiedPlanTest, + TryToEnableSframeFailsAfterExplicitlySetToFalse) { + scoped_refptr<RtpTransceiver> transceiver = CreateTransceiver( + MockSender(MediaType::AUDIO), MockReceiver(MediaType::AUDIO)); + + // Simulate the transceiver having been explicitly set to false (e.g. via + // ApplySframeEnabled during SDP application). + transceiver->ApplySframeEnabled(false); + EXPECT_THAT(transceiver->SframeEnabled(), Optional(false)); + + RTCError error = transceiver->TryToEnableSframe(); + EXPECT_FALSE(error.ok()); + EXPECT_EQ(error.type(), RTCErrorType::INVALID_MODIFICATION); + EXPECT_THAT(transceiver->SframeEnabled(), Optional(false)); +} + +TEST_F(RtpTransceiverUnifiedPlanTest, ApplySframeEnabledTrueSetsState) { + scoped_refptr<RtpTransceiver> transceiver = CreateTransceiver( + MockSender(MediaType::AUDIO), MockReceiver(MediaType::AUDIO)); + + transceiver->ApplySframeEnabled(true); + EXPECT_THAT(transceiver->SframeEnabled(), Optional(true)); +} + +TEST_F(RtpTransceiverUnifiedPlanTest, ApplySframeEnabledFalseSetsState) { + scoped_refptr<RtpTransceiver> transceiver = CreateTransceiver( + MockSender(MediaType::AUDIO), MockReceiver(MediaType::AUDIO)); + + transceiver->ApplySframeEnabled(false); + EXPECT_THAT(transceiver->SframeEnabled(), Optional(false)); +} + } // namespace } // namespace webrtc
diff --git a/pc/sdp_munging_detector.cc b/pc/sdp_munging_detector.cc index 2b838d9..c0a5083 100644 --- a/pc/sdp_munging_detector.cc +++ b/pc/sdp_munging_detector.cc
@@ -544,6 +544,13 @@ return SdpMungingType::kDirection; } + // Validate Sframe attribute. + if (last_created_media_description->sframe_enabled() != + media_description_to_set->sframe_enabled()) { + RTC_LOG(LS_ERROR) << "SDP munging: sframe attribute modified."; + return SdpMungingType::kSframe; + } + // Validate media streams. if (last_created_media_description->streams().size() != media_description_to_set->streams().size()) { @@ -732,7 +739,9 @@ return true; case SdpMungingType::kNumberOfContents: return false; - case kDataChannelSctpInit: + case SdpMungingType::kSframe: + return false; + case SdpMungingType::kDataChannelSctpInit: return false; default: // Handled below.
diff --git a/pc/sdp_munging_detector_unittest.cc b/pc/sdp_munging_detector_unittest.cc index 369837d..5d7b294 100644 --- a/pc/sdp_munging_detector_unittest.cc +++ b/pc/sdp_munging_detector_unittest.cc
@@ -54,6 +54,7 @@ #include "p2p/base/transport_description.h" #include "pc/peer_connection.h" #include "pc/peer_connection_wrapper.h" +#include "pc/rtp_transceiver.h" #include "pc/session_description.h" #include "pc/test/fake_audio_capture_module.h" #include "pc/test/fake_rtc_certificate_generator.h" @@ -1587,4 +1588,75 @@ ElementsAre(Pair(SdpMungingType::kBundle, 1))); } +TEST_F(SdpMungingTest, SframeAttributeAdded) { + auto pc = CreatePeerConnection(); + pc->AddAudioTrack("audio_track", {}); + + std::unique_ptr<SessionDescriptionInterface> offer = pc->CreateOffer(); + + auto& contents = offer->description()->contents(); + ASSERT_THAT(contents, SizeIs(1)); + auto* media_description = contents[0].media_description(); + ASSERT_THAT(media_description, Not(IsNull())); + EXPECT_FALSE(media_description->sframe_enabled()); + media_description->set_sframe_enabled(true); + + RTCError error; + EXPECT_FALSE(pc->SetLocalDescription(std::move(offer), &error)); + EXPECT_THAT( + metrics::Samples("WebRTC.PeerConnection.SdpMunging.Offer.Initial"), + ElementsAre(Pair(SdpMungingType::kSframe, 1))); + EXPECT_THAT( + metrics::Samples("WebRTC.PeerConnection.SdpMunging.SdpOutcome.Rejected"), + ElementsAre(Pair(SdpMungingType::kSframe, 1))); + EXPECT_THAT( + metrics::Samples("WebRTC.PeerConnection.SdpMunging.Outcome"), + ElementsAre(Pair(static_cast<int>(SdpMungingOutcome::kRejected), 1))); +} + +TEST_F(SdpMungingTest, SframeAttributeRemoved) { + auto pc = CreatePeerConnection(); + auto transceiver = pc->AddTransceiver(MediaType::AUDIO); + signaling_thread_->BlockingCall([&]() { + static_cast<RtpTransceiverProxyWithInternal<RtpTransceiver>*>( + transceiver.get()) + ->internal() + ->ApplySframeEnabled(true); + }); + + std::unique_ptr<SessionDescriptionInterface> offer = pc->CreateOffer(); + + auto& contents = offer->description()->contents(); + ASSERT_THAT(contents, SizeIs(1)); + auto* media_description = contents[0].media_description(); + ASSERT_THAT(media_description, Not(IsNull())); + EXPECT_TRUE(media_description->sframe_enabled()); + media_description->set_sframe_enabled(false); + + RTCError error; + EXPECT_FALSE(pc->SetLocalDescription(std::move(offer), &error)); + EXPECT_THAT( + metrics::Samples("WebRTC.PeerConnection.SdpMunging.Offer.Initial"), + ElementsAre(Pair(SdpMungingType::kSframe, 1))); + EXPECT_THAT( + metrics::Samples("WebRTC.PeerConnection.SdpMunging.SdpOutcome.Rejected"), + ElementsAre(Pair(SdpMungingType::kSframe, 1))); + EXPECT_THAT( + metrics::Samples("WebRTC.PeerConnection.SdpMunging.Outcome"), + ElementsAre(Pair(static_cast<int>(SdpMungingOutcome::kRejected), 1))); +} + +TEST_F(SdpMungingTest, SframeMungingIsAlwaysRejected) { + EXPECT_FALSE( + IsSdpMungingAllowed(SdpMungingType::kSframe, CreateTestFieldTrials())); + // Even with deny list, Sframe munging is always rejected. + EXPECT_FALSE(IsSdpMungingAllowed( + SdpMungingType::kSframe, + CreateTestFieldTrials("WebRTC-NoSdpMangleReject/Enabled/"))); + // Even with allow list for testing, Sframe munging is always rejected. + EXPECT_FALSE(IsSdpMungingAllowed( + SdpMungingType::kSframe, + CreateTestFieldTrials("WebRTC-NoSdpMangleAllowForTesting/Enabled,35/"))); +} + } // namespace webrtc
diff --git a/pc/sdp_offer_answer.cc b/pc/sdp_offer_answer.cc index 00ec069..1b1c29c 100644 --- a/pc/sdp_offer_answer.cc +++ b/pc/sdp_offer_answer.cc
@@ -140,6 +140,9 @@ const char kSdpWithoutDtlsFingerprint[] = "Called with SDP without DTLS fingerprint."; const char kSdpWithoutCrypto[] = "Called with SDP without crypto setup."; +const char kSframeNotInOffer[] = + "Remote answer has a=sframe for a media section but the corresponding " + "offer did not include a=sframe."; const char kSessionError[] = "Session error code: "; const char kSessionErrorDesc[] = "Session error description: "; @@ -384,6 +387,33 @@ return RTCError::OK(); } +// Checks that no answer m-section introduces a=sframe that was not present in +// the corresponding offer m-section. +RTCError VerifySframeInAnswer(const SessionDescription* local_offer, + const SessionDescription* remote_answer) { + RTC_DCHECK(local_offer); + RTC_DCHECK(remote_answer); + + const ContentInfos& offer_contents = local_offer->contents(); + const ContentInfos& answer_contents = remote_answer->contents(); + + for (size_t i = 0; i < answer_contents.size() && i < offer_contents.size(); + ++i) { + if (answer_contents[i].rejected) { + continue; + } + const MediaContentDescription* answer_media = + answer_contents[i].media_description(); + const MediaContentDescription* offer_media = + offer_contents[i].media_description(); + if (answer_media->sframe_enabled() && !offer_media->sframe_enabled()) { + return LOG_ERROR(RTCError(RTCErrorType::INVALID_PARAMETER) + << kSframeNotInOffer); + } + } + return RTCError::OK(); +} + // Checks that each non-rejected content has a DTLS // fingerprint, unless it's in a BUNDLE group, in which case only the // BUNDLE-tag section (first media section/description in the BUNDLE group) @@ -861,6 +891,8 @@ transceiver->filtered_codec_preferences(); media_description_options.header_extensions = transceiver->GetHeaderExtensionsToNegotiate(); + media_description_options.sframe_enabled = + transceiver->SframeEnabled().value_or(false); // This behavior is specified in JSEP. The gist is that: // 1. The MSID is included if the RtpTransceiver's direction is sendonly or // sendrecv. @@ -1959,6 +1991,7 @@ } transceiver->set_receptive( RtpTransceiverDirectionHasRecv(media_desc->direction())); + transceiver->ApplySframeEnabled(media_desc->sframe_enabled()); } pc_->RunWithObserver([&](auto observer) { for (const auto& transceiver : remove_list) { @@ -2418,6 +2451,20 @@ << " since the media section was rejected."; worker_tasks.Add(transceiver->GetStopTransceiverProcedure()); } + // If the local offer included Sframe but the remote answer does not, + // stop the transceiver since Sframe cannot be downgraded. + if (sdp_type == SdpType::kPrAnswer || sdp_type == SdpType::kAnswer) { + const ContentInfo* local_content = + FindMediaSectionForTransceiver(transceiver, local_description()); + if (local_content && !content->rejected && + local_content->media_description()->sframe_enabled() && + !media_desc->sframe_enabled() && !transceiver->stopped()) { + RTC_LOG(LS_INFO) << "Stopping transceiver for MID=" << content->mid() + << " since the remote answer does not include Sframe."; + transceiver->ClearChannel(); + worker_tasks.Add(transceiver->GetStopTransceiverProcedure()); + } + } if (!content->rejected && RtpTransceiverDirectionHasRecv(local_direction)) { if (!media_desc->streams().empty() && media_desc->streams()[0].has_ssrcs()) { @@ -3773,6 +3820,14 @@ // 5.3 If transceiver isn't stopped and is associated with an m= section // in description then perform the following checks: + // If the transceiver's Sframe state differs from the negotiated state + // in the current local description, negotiation is needed. + if (transceiver->SframeEnabled().has_value() && + transceiver->SframeEnabled().value() != + current_local_media_description->sframe_enabled()) { + return true; + } + // 5.3.1 If transceiver.[[Direction]] is "sendrecv" or "sendonly", and the // associated m= section in description either doesn't contain a single // "a=msid" line, or the number of MSIDs from the "a=msid" lines in this @@ -4006,6 +4061,18 @@ } } + // Validate Sframe consistency: reject remote answers that introduce + // a=sframe for media sections where the local offer did not include it. + if (source == CS_REMOTE && + (type == SdpType::kPrAnswer || type == SdpType::kAnswer)) { + RTC_DCHECK(local_description()); + error = VerifySframeInAnswer(local_description()->description(), + sdesc->description()); + if (!error.ok()) { + return error; + } + } + return RTCError::OK(); } @@ -4203,6 +4270,7 @@ /*header_extensions_to_negotiate=*/{}, sender_id, receiver_id); transceiver->internal()->set_direction( RtpTransceiverDirection::kRecvOnly); + transceiver->internal()->ApplySframeEnabled(media_desc->sframe_enabled()); if (type == SdpType::kOffer) { transceivers()->StableState(transceiver)->set_newly_created(); }
diff --git a/pc/sdp_offer_answer_unittest.cc b/pc/sdp_offer_answer_unittest.cc index 0749978..239ee62 100644 --- a/pc/sdp_offer_answer_unittest.cc +++ b/pc/sdp_offer_answer_unittest.cc
@@ -48,6 +48,7 @@ #include "media/base/media_constants.h" #include "media/base/stream_params.h" #include "pc/peer_connection_wrapper.h" +#include "pc/rtp_transceiver.h" #include "pc/session_description.h" #include "pc/test/fake_audio_capture_module.h" #include "pc/test/integration_test_helpers.h" @@ -71,6 +72,7 @@ using ::testing::Eq; using ::testing::IsTrue; using ::testing::NotNull; +using ::testing::Optional; using ::testing::Pair; using ::testing::SizeIs; @@ -84,6 +86,19 @@ return thread; } +// Helper to enable SFrame directly on a transceiver's internal state. +// Must be called with the signaling thread to ensure thread safety. +void EnableSframeOnTransceiver( + Thread* signaling_thread, + const scoped_refptr<RtpTransceiverInterface>& transceiver) { + signaling_thread->BlockingCall([&]() { + static_cast<RtpTransceiverProxyWithInternal<RtpTransceiver>*>( + transceiver.get()) + ->internal() + ->TryToEnableSframe(); + }); +} + } // namespace class SdpOfferAnswerTest : public ::testing::Test { @@ -2582,4 +2597,361 @@ EXPECT_TRUE(found_transport_cc); } +TEST_F(SdpOfferAnswerTest, StopsTransceiverWhenAnswerLacksSframe) { + auto caller = CreatePeerConnection(); + auto callee = CreatePeerConnection(); + + auto transceiver = caller->AddTransceiver(MediaType::AUDIO); + EnableSframeOnTransceiver(signaling_thread_.get(), transceiver); + + auto offer = caller->CreateOfferAndSetAsLocal(); + ASSERT_THAT(offer->description()->contents(), SizeIs(1)); + EXPECT_TRUE(offer->description() + ->contents()[0] + .media_description() + ->sframe_enabled()); + + EXPECT_TRUE(callee->SetRemoteDescription(std::move(offer))); + auto answer = callee->CreateAnswerAndSetAsLocal(); + ASSERT_THAT(answer->description()->contents(), SizeIs(1)); + EXPECT_TRUE(answer->description() + ->contents()[0] + .media_description() + ->sframe_enabled()); + + // Simulate a remote peer that strips sframe from the answer. + answer->description()->contents()[0].media_description()->set_sframe_enabled( + false); + + // SetRemoteDescription should succeed, but the transceiver should be stopped. + EXPECT_TRUE(caller->SetRemoteDescription(std::move(answer))); + EXPECT_TRUE(transceiver->stopped()); +} + +TEST_F(SdpOfferAnswerTest, AcceptsAnswerWithSframeWhenOfferedWithSframe) { + auto caller = CreatePeerConnection(); + auto callee = CreatePeerConnection(); + + auto transceiver = caller->AddTransceiver(MediaType::AUDIO); + EnableSframeOnTransceiver(signaling_thread_.get(), transceiver); + + auto offer = caller->CreateOfferAndSetAsLocal(); + ASSERT_THAT(offer->description()->contents(), SizeIs(1)); + EXPECT_TRUE(offer->description() + ->contents()[0] + .media_description() + ->sframe_enabled()); + + EXPECT_TRUE(callee->SetRemoteDescription(std::move(offer))); + auto answer = callee->CreateAnswerAndSetAsLocal(); + ASSERT_THAT(answer->description()->contents(), SizeIs(1)); + EXPECT_TRUE(answer->description() + ->contents()[0] + .media_description() + ->sframe_enabled()); + + EXPECT_TRUE(caller->SetRemoteDescription(std::move(answer))); + EXPECT_FALSE(transceiver->stopped()); +} + +TEST_F(SdpOfferAnswerTest, AcceptsAnswerWithoutSframeWhenOfferedWithoutSframe) { + auto caller = CreatePeerConnection(); + auto callee = CreatePeerConnection(); + + caller->AddTransceiver(MediaType::AUDIO); + + auto offer = caller->CreateOfferAndSetAsLocal(); + ASSERT_THAT(offer->description()->contents(), SizeIs(1)); + EXPECT_FALSE(offer->description() + ->contents()[0] + .media_description() + ->sframe_enabled()); + + EXPECT_TRUE(callee->SetRemoteDescription(std::move(offer))); + auto answer = callee->CreateAnswerAndSetAsLocal(); + + ASSERT_THAT(answer->description()->contents(), SizeIs(1)); + EXPECT_FALSE(answer->description() + ->contents()[0] + .media_description() + ->sframe_enabled()); + + EXPECT_TRUE(caller->SetRemoteDescription(std::move(answer))); +} + +TEST_F(SdpOfferAnswerTest, + SetLocalOfferSyncsTransceiverSframeFromNulloptToFalse) { + auto caller = CreatePeerConnection(); + + auto transceiver = caller->AddTransceiver(MediaType::AUDIO); + EXPECT_EQ(transceiver->SframeEnabled(), std::nullopt); + + auto offer = caller->CreateOfferAndSetAsLocal(); + ASSERT_THAT(offer->description()->contents(), SizeIs(1)); + EXPECT_FALSE(offer->description() + ->contents()[0] + .media_description() + ->sframe_enabled()); + + EXPECT_THAT(transceiver->SframeEnabled(), Optional(false)); +} + +TEST_F(SdpOfferAnswerTest, SetLocalOfferPreservesSframeTrueOnTransceiver) { + auto caller = CreatePeerConnection(); + + auto transceiver = caller->AddTransceiver(MediaType::AUDIO); + EnableSframeOnTransceiver(signaling_thread_.get(), transceiver); + EXPECT_THAT(transceiver->SframeEnabled(), Optional(true)); + + auto offer = caller->CreateOfferAndSetAsLocal(); + ASSERT_THAT(offer->description()->contents(), SizeIs(1)); + EXPECT_TRUE(offer->description() + ->contents()[0] + .media_description() + ->sframe_enabled()); + + EXPECT_THAT(transceiver->SframeEnabled(), Optional(true)); +} + +TEST_F(SdpOfferAnswerTest, + RemoteOfferWithoutSframeCreatesTransceiverWithSframeFalse) { + auto caller = CreatePeerConnection(); + auto callee = CreatePeerConnection(); + + caller->AddTransceiver(MediaType::AUDIO); + + auto offer = caller->CreateOfferAndSetAsLocal(); + ASSERT_THAT(offer->description()->contents(), SizeIs(1)); + EXPECT_FALSE(offer->description() + ->contents()[0] + .media_description() + ->sframe_enabled()); + + EXPECT_TRUE(callee->SetRemoteDescription(std::move(offer))); + + // Callee's transceiver was created by the remote offer. Since the offer + // did not contain the sframe attribute, SframeEnabled should be false. + auto callee_transceivers = callee->pc()->GetTransceivers(); + ASSERT_THAT(callee_transceivers, SizeIs(1)); + EXPECT_THAT(callee_transceivers[0]->SframeEnabled(), Optional(false)); +} + +TEST_F(SdpOfferAnswerTest, + RemoteOfferWithSframeCreatesTransceiverWithSframeTrue) { + auto caller = CreatePeerConnection(); + auto callee = CreatePeerConnection(); + + auto transceiver = caller->AddTransceiver(MediaType::AUDIO); + EnableSframeOnTransceiver(signaling_thread_.get(), transceiver); + + auto offer = caller->CreateOfferAndSetAsLocal(); + ASSERT_THAT(offer->description()->contents(), SizeIs(1)); + EXPECT_TRUE(offer->description() + ->contents()[0] + .media_description() + ->sframe_enabled()); + + EXPECT_TRUE(callee->SetRemoteDescription(std::move(offer))); + + // Callee's transceiver was created by the remote offer. Since the offer + // contained the sframe attribute, SframeEnabled should be true. + auto callee_transceivers = callee->pc()->GetTransceivers(); + ASSERT_THAT(callee_transceivers, SizeIs(1)); + EXPECT_THAT(callee_transceivers[0]->SframeEnabled(), Optional(true)); +} + +// Verify that enabling SFrame before first negotiation produces +// an offer with a=sframe, and that the SFrame state is reflected +// in the local description after SetLocalDescription. +TEST_F(SdpOfferAnswerTest, + EnableSframeBeforeNegotiationProducesOfferWithSframe) { + auto caller = CreatePeerConnection(); + auto callee = CreatePeerConnection(); + + auto transceiver = caller->AddTransceiver(MediaType::AUDIO); + EXPECT_EQ(transceiver->SframeEnabled(), std::nullopt); + + EnableSframeOnTransceiver(signaling_thread_.get(), transceiver); + EXPECT_THAT(transceiver->SframeEnabled(), Optional(true)); + + auto offer = caller->CreateOfferAndSetAsLocal(); + ASSERT_THAT(offer->description()->contents(), SizeIs(1)); + EXPECT_TRUE(offer->description() + ->contents()[0] + .media_description() + ->sframe_enabled()); +} + +// Verify that after a full negotiation with SFrame enabled, the state +// is consistent on the caller side. +TEST_F(SdpOfferAnswerTest, SframeTrueConsistentAfterFullNegotiation) { + auto caller = CreatePeerConnection(); + auto callee = CreatePeerConnection(); + + auto transceiver = caller->AddTransceiver(MediaType::AUDIO); + EnableSframeOnTransceiver(signaling_thread_.get(), transceiver); + + auto offer = caller->CreateOfferAndSetAsLocal(); + ASSERT_TRUE(callee->SetRemoteDescription(std::move(offer))); + auto answer = callee->CreateAnswerAndSetAsLocal(); + ASSERT_TRUE(caller->SetRemoteDescription(std::move(answer))); + + EXPECT_THAT(transceiver->SframeEnabled(), Optional(true)); +} + +TEST_F(SdpOfferAnswerTest, + RejectsAnswerWithSframeWhenOfferDidNotIncludeSframe) { + auto caller = CreatePeerConnection(); + auto callee = CreatePeerConnection(); + + caller->AddTransceiver(MediaType::AUDIO); + + auto offer = caller->CreateOfferAndSetAsLocal(); + ASSERT_THAT(offer->description()->contents(), SizeIs(1)); + EXPECT_FALSE(offer->description() + ->contents()[0] + .media_description() + ->sframe_enabled()); + + EXPECT_TRUE(callee->SetRemoteDescription(std::move(offer))); + auto answer = callee->CreateAnswerAndSetAsLocal(); + ASSERT_THAT(answer->description()->contents(), SizeIs(1)); + EXPECT_FALSE(answer->description() + ->contents()[0] + .media_description() + ->sframe_enabled()); + + // Simulate a malicious/buggy answerer adding a=sframe to the answer. + answer->description()->contents()[0].media_description()->set_sframe_enabled( + true); + + RTCError error; + EXPECT_FALSE(caller->SetRemoteDescription(std::move(answer), &error)); + EXPECT_EQ(error.type(), RTCErrorType::INVALID_PARAMETER); + EXPECT_THAT(error.message(), + ::testing::HasSubstr("offer did not include a=sframe")); +} + +// Verify that a rejected m-section with spurious a=sframe in +// the answer is tolerated (rejected sections are skipped). +TEST_F(SdpOfferAnswerTest, ToleratesRejectedAnswerSectionWithSpuriousSframe) { + auto caller = CreatePeerConnection(); + auto callee = CreatePeerConnection(); + + caller->AddTransceiver(MediaType::AUDIO); + + auto offer = caller->CreateOfferAndSetAsLocal(); + EXPECT_TRUE(callee->SetRemoteDescription(std::move(offer))); + auto answer = callee->CreateAnswerAndSetAsLocal(); + + // Mark the section as rejected (port 0) AND add spurious a=sframe. + answer->description()->contents()[0].rejected = true; + answer->description()->contents()[0].media_description()->set_sframe_enabled( + true); + + EXPECT_TRUE(caller->SetRemoteDescription(std::move(answer))); +} + +// Sframe state is locked to false after negotiation without Sframe. +TEST_F(SdpOfferAnswerTest, SframeLockedToFalseAfterNegotiationWithoutSframe) { + auto caller = CreatePeerConnection(); + auto callee = CreatePeerConnection(); + + auto transceiver = caller->AddTransceiver(MediaType::AUDIO); + EXPECT_EQ(transceiver->SframeEnabled(), std::nullopt); + + auto offer = caller->CreateOfferAndSetAsLocal(); + EXPECT_TRUE(callee->SetRemoteDescription(std::move(offer))); + auto answer = callee->CreateAnswerAndSetAsLocal(); + EXPECT_TRUE(caller->SetRemoteDescription(std::move(answer))); + + EXPECT_THAT(transceiver->SframeEnabled(), Optional(false)); +} + +// Full end-to-end: Both sides enable Sframe, complete negotiation, verify +// Sframe is active on both transceivers. +TEST_F(SdpOfferAnswerTest, BothSidesEnableSframeFullNegotiation) { + auto caller = CreatePeerConnection(); + auto callee = CreatePeerConnection(); + + auto caller_transceiver = caller->AddTransceiver(MediaType::AUDIO); + EnableSframeOnTransceiver(signaling_thread_.get(), caller_transceiver); + + // Callee adds a track via addTrack (so it's matched per JSEP §5.10) + // and also explicitly enables Sframe. + callee->AddAudioTrack("audio", {}); + auto callee_transceivers = callee->pc()->GetTransceivers(); + ASSERT_THAT(callee_transceivers, SizeIs(1)); + EnableSframeOnTransceiver(signaling_thread_.get(), callee_transceivers[0]); + + auto offer = caller->CreateOfferAndSetAsLocal(); + ASSERT_THAT(offer->description()->contents(), SizeIs(1)); + EXPECT_TRUE(offer->description() + ->contents()[0] + .media_description() + ->sframe_enabled()); + + EXPECT_TRUE(callee->SetRemoteDescription(std::move(offer))); + + auto answer = callee->CreateAnswerAndSetAsLocal(); + ASSERT_THAT(answer->description()->contents(), SizeIs(1)); + EXPECT_TRUE(answer->description() + ->contents()[0] + .media_description() + ->sframe_enabled()); + + EXPECT_TRUE(caller->SetRemoteDescription(std::move(answer))); + + EXPECT_THAT(caller_transceiver->SframeEnabled(), Optional(true)); + EXPECT_FALSE(caller_transceiver->stopped()); + + callee_transceivers = callee->pc()->GetTransceivers(); + ASSERT_THAT(callee_transceivers, SizeIs(1)); + EXPECT_THAT(callee_transceivers[0]->SframeEnabled(), Optional(true)); + EXPECT_FALSE(callee_transceivers[0]->stopped()); +} + +// Callee has a transceiver created via addTrack (without Sframe). +// Caller offers with a=sframe. The offer should be accepted (standard O/A +// model), the answer should lack a=sframe, and the caller should stop the +// transceiver via downgrade protection. +TEST_F(SdpOfferAnswerTest, + AddTrackTransceiverWithoutSframeAcceptsOfferWithSframe) { + auto caller = CreatePeerConnection(); + auto callee = CreatePeerConnection(); + + auto caller_transceiver = caller->AddTransceiver(MediaType::AUDIO); + EnableSframeOnTransceiver(signaling_thread_.get(), caller_transceiver); + + // Callee uses addTrack (creates a transceiver via FindAvailableToReceive + // path) but does NOT enable Sframe. + callee->AddAudioTrack("audio", {}); + auto callee_transceivers = callee->pc()->GetTransceivers(); + ASSERT_THAT(callee_transceivers, SizeIs(1)); + EXPECT_EQ(callee_transceivers[0]->SframeEnabled(), std::nullopt); + + auto offer = caller->CreateOfferAndSetAsLocal(); + ASSERT_THAT(offer->description()->contents(), SizeIs(1)); + EXPECT_TRUE(offer->description() + ->contents()[0] + .media_description() + ->sframe_enabled()); + + EXPECT_TRUE(callee->SetRemoteDescription(std::move(offer))); + + callee_transceivers = callee->pc()->GetTransceivers(); + ASSERT_THAT(callee_transceivers, SizeIs(1)); + + auto answer = callee->CreateAnswerAndSetAsLocal(); + ASSERT_THAT(answer->description()->contents(), SizeIs(1)); + EXPECT_FALSE(answer->description() + ->contents()[0] + .media_description() + ->sframe_enabled()); + + // Caller receives answer without a=sframe → downgrade protection kicks in. + EXPECT_TRUE(caller->SetRemoteDescription(std::move(answer))); + EXPECT_TRUE(caller_transceiver->stopped()); +} + } // namespace webrtc
diff --git a/pc/session_description.h b/pc/session_description.h index f72cfba..c42f040 100644 --- a/pc/session_description.h +++ b/pc/session_description.h
@@ -249,6 +249,12 @@ receive_rids_ = rids; } + // Whether Sframe encryption is enabled for this media section. + bool sframe_enabled() const { return sframe_enabled_; } + void set_sframe_enabled(bool sframe_enabled) { + sframe_enabled_ = sframe_enabled; + } + // Codecs should be in preference order (most preferred codec first). const std::vector<Codec>& codecs() const { return codecs_; } void set_codecs(const std::vector<Codec>& codecs) { codecs_ = codecs; } @@ -297,6 +303,7 @@ SimulcastDescription simulcast_; std::vector<RidDescription> receive_rids_; + bool sframe_enabled_ = false; // Copy function that returns a raw pointer. Caller will assert ownership. // Should only be called by the Clone() function. Must be implemented