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