Implement cryptex header extension negotiation

Implements
  https://datatracker.ietf.org/doc/rfc9335/
with the necessary APIs for the W3C extension API described in
  https://w3c.github.io/webrtc-extensions/#rtp-header-extension-encryption

cryptex is declarative per
  https://datatracker.ietf.org/doc/html/rfc9335#section-4

cryptex is used as a session-level attribute by default with the exception of matching media-level-only offers at media level.

The per-transceiver flag from the W3C spec is split to a separate CL.

Bug: webrtc:455813732
Change-Id: I4a17bad40300112300c5d98d3d4d80cb3b6df848
Reviewed-on: https://webrtc-review.googlesource.com/c/src/+/451800
Commit-Queue: Philipp Hancke <philipp.hancke@googlemail.com>
Reviewed-by: Harald Alvestrand <hta@webrtc.org>
Reviewed-by: Erik Språng <sprang@webrtc.org>
Cr-Commit-Position: refs/heads/main@{#47813}
diff --git a/api/crypto/crypto_options.cc b/api/crypto/crypto_options.cc
index 6464728..1549e69 100644
--- a/api/crypto/crypto_options.cc
+++ b/api/crypto/crypto_options.cc
@@ -82,6 +82,7 @@
       bool enable_aes128_sha1_32_crypto_cipher;
       bool enable_aes128_sha1_80_crypto_cipher;
       bool enable_encrypted_rtp_header_extensions;
+      CryptoOptions::Srtp::CryptexPolicy cryptex_policy;
     } srtp;
     struct SFrame {
       bool require_frame_encryption;
@@ -100,6 +101,7 @@
              other.srtp.enable_aes128_sha1_80_crypto_cipher &&
          srtp.enable_encrypted_rtp_header_extensions ==
              other.srtp.enable_encrypted_rtp_header_extensions &&
+         srtp.cryptex_policy == other.srtp.cryptex_policy &&
          sframe.require_frame_encryption ==
              other.sframe.require_frame_encryption &&
          ephemeral_key_exchange_cipher_groups ==
diff --git a/api/crypto/crypto_options.h b/api/crypto/crypto_options.h
index 5308d59..5c0c15f 100644
--- a/api/crypto/crypto_options.h
+++ b/api/crypto/crypto_options.h
@@ -69,6 +69,13 @@
     // requested. For this to work the Chromium field trial
     // `kWebRtcEncryptedRtpHeaderExtensions` must be enabled.
     bool enable_encrypted_rtp_header_extensions = true;
+
+    // Enable cryptex, a modern take on encrypting header extensions:
+    // https://www.rfc-editor.org/rfc/rfc9335.html
+    // Supported as of libsrtp v2.8.0.
+    enum class CryptexPolicy { kDisabled, kNegotiate, kRequire };
+    // TODO: bugs.webrtc.org/455813732 - the default should be `negotiate`.
+    CryptexPolicy cryptex_policy = CryptexPolicy::kDisabled;
   } srtp;
 
   // Options to be used when the FrameEncryptor / FrameDecryptor APIs are used.
diff --git a/api/uma_metrics.h b/api/uma_metrics.h
index dfe7f1d..981736d 100644
--- a/api/uma_metrics.h
+++ b/api/uma_metrics.h
@@ -175,6 +175,17 @@
   kRtcpMuxPolicyUsageMax
 };
 
+// Metrics for RTCRtpHeaderEncryptionPolicy (which enables cryptex).
+// https://w3c.github.io/webrtc-extensions/#rtp-header-extension-encryption
+enum CryptexPolicyUsage {
+  kCryptexPolicyUsageDisabled = 0,  // Non-spec.
+  kCryptexPolicyUsageNegotiate = 1,
+  // Policy was `negotiate` and it was negotiated at session level.
+  kCryptexPolicyUsageNegotiated = 2,
+  kCryptexPolicyUsageRequire = 3,
+  kCryptexPolicyUsageMax
+};
+
 // Metrics for SDP munging.
 // These values are persisted to logs. Entries should not be renumbered and
 // numeric values should never be reused. Keep in (loose) sync with
@@ -210,6 +221,7 @@
   kRtpHeaderExtensionRemoved = 40,
   kRtpHeaderExtensionAdded = 41,
   kRtpHeaderExtensionModified = 42,
+  kCryptex = 43,
   // Audio-related munging.
   kAudioCodecsRemoved = 60,
   kAudioCodecsAdded = 61,
diff --git a/api/webrtc_sdp.cc b/api/webrtc_sdp.cc
index f4ba9a9..5647314 100644
--- a/api/webrtc_sdp.cc
+++ b/api/webrtc_sdp.cc
@@ -143,6 +143,9 @@
 constexpr absl::string_view kAttributeRid = "rid";
 const char kAttributePacketization[] = "packetization";
 
+// https://www.rfc-editor.org/rfc/rfc9335.html
+const char kAttributeCryptex[] = "cryptex";
+
 // Experimental flags
 const char kAttributeXGoogleFlag[] = "x-google-flag";
 const char kValueConference[] = "conference";
@@ -1268,6 +1271,11 @@
     InitAttrLine(kAttributeExtmapAllowMixed, &os);
     AddLine(os.str(), message);
   }
+  if (media_desc->cryptex_level() ==
+      MediaContentDescription::AttributeLevel::kMedia) {
+    InitAttrLine(kAttributeCryptex, &os);
+    AddLine(os.str(), message);
+  }
   BuildRtpHeaderExtensions(media_desc->rtp_header_extensions(), message);
 
   // RFC 3264
@@ -1659,6 +1667,8 @@
 
   desc->set_msid_signaling(kMsidSignalingNotUsed);
   desc->set_extmap_allow_mixed(false);
+  desc->set_cryptex(false);
+
   // RFC 4566
   // v=  (protocol version)
   line = GetLineWithType(message, pos, kLineTypeVersion);
@@ -1810,6 +1820,8 @@
         return false;
       }
       session_extmaps->push_back(extmap);
+    } else if (HasAttribute(*aline, kAttributeCryptex)) {
+      desc->set_cryptex(true);
     }
   }
   return true;
@@ -2871,6 +2883,9 @@
         // Ignore and do not log a=rtcp line.
         // JSEP  section 5.8.2 (media section parsing) says to ignore it.
         continue;
+      } else if (HasAttribute(*line, kAttributeCryptex)) {
+        media_desc->set_cryptex_level(
+            MediaContentDescription::AttributeLevel::kMedia);
       } else {
         // Unrecognized attribute in RTP protocol.
         RTC_LOG(LS_VERBOSE) << "Ignored line: " << *line;
@@ -3015,6 +3030,7 @@
 
   media_desc->set_extmap_allow_mixed_level(
       MediaContentDescription::AttributeLevel::kNone);
+  media_desc->set_cryptex_level(MediaContentDescription::AttributeLevel::kNone);
   if (!ParseContent(message, media_type, mline_index, protocol, payload_types,
                     pos, content_name, bundle_only, msid_signaling,
                     media_desc.get(), transport, candidates, error)) {
@@ -3326,6 +3342,10 @@
     InitAttrLine(kAttributeExtmapAllowMixed, &os);
     AddLine(os.str(), &message);
   }
+  if (desc->cryptex()) {
+    InitAttrLine(kAttributeCryptex, &os);
+    AddLine(os.str(), &message);
+  }
 
   // MediaStream semantics.
   // TODO(bugs.webrtc.org/10421): Change to & kMsidSignalingSemantic
diff --git a/p2p/base/transport_description.cc b/p2p/base/transport_description.cc
index 8758633..8d5ed4c 100644
--- a/p2p/base/transport_description.cc
+++ b/p2p/base/transport_description.cc
@@ -151,7 +151,9 @@
 }
 
 TransportDescription::TransportDescription()
-    : ice_mode(ICEMODE_FULL), connection_role(CONNECTIONROLE_NONE) {}
+    : ice_mode(ICEMODE_FULL),
+      connection_role(CONNECTIONROLE_NONE),
+      cryptex(false) {}
 
 TransportDescription::TransportDescription(
     const std::vector<std::string>& transport_options,
@@ -165,14 +167,16 @@
       ice_pwd(ice_pwd),
       ice_mode(ice_mode),
       connection_role(role),
-      identity_fingerprint(CopyFingerprint(identity_fingerprint)) {}
+      identity_fingerprint(CopyFingerprint(identity_fingerprint)),
+      cryptex(false) {}
 
 TransportDescription::TransportDescription(absl::string_view ice_ufrag,
                                            absl::string_view ice_pwd)
     : ice_ufrag(ice_ufrag),
       ice_pwd(ice_pwd),
       ice_mode(ICEMODE_FULL),
-      connection_role(CONNECTIONROLE_NONE) {}
+      connection_role(CONNECTIONROLE_NONE),
+      cryptex(false) {}
 
 TransportDescription::TransportDescription(const TransportDescription& from)
     : transport_options(from.transport_options),
@@ -180,7 +184,8 @@
       ice_pwd(from.ice_pwd),
       ice_mode(from.ice_mode),
       connection_role(from.connection_role),
-      identity_fingerprint(CopyFingerprint(from.identity_fingerprint.get())) {}
+      identity_fingerprint(CopyFingerprint(from.identity_fingerprint.get())),
+      cryptex(from.cryptex) {}
 
 TransportDescription::~TransportDescription() = default;
 
@@ -197,6 +202,7 @@
   connection_role = from.connection_role;
 
   identity_fingerprint.reset(CopyFingerprint(from.identity_fingerprint.get()));
+  cryptex = from.cryptex;
   return *this;
 }
 
diff --git a/p2p/base/transport_description.h b/p2p/base/transport_description.h
index d5592ef..b3c2fbf 100644
--- a/p2p/base/transport_description.h
+++ b/p2p/base/transport_description.h
@@ -147,6 +147,8 @@
   ConnectionRole connection_role;
 
   std::unique_ptr<SSLFingerprint> identity_fingerprint;
+
+  bool cryptex;
 };
 
 }  //  namespace webrtc
diff --git a/pc/BUILD.gn b/pc/BUILD.gn
index 85f01b0..7325d7c 100644
--- a/pc/BUILD.gn
+++ b/pc/BUILD.gn
@@ -211,6 +211,7 @@
     "../api:rtc_error",
     "../api:scoped_refptr",
     "../api:sequence_checker",
+    "../api/crypto:options",
     "../api/transport:datagram_transport_interface",
     "../media:rtc_data_sctp_transport_internal",
     "../p2p:dtls_transport_internal",
@@ -353,6 +354,7 @@
     "../api:rtp_parameters",
     "../api:rtp_transceiver_direction",
     "../api:sctp_transport_interface",
+    "../api/crypto:options",
     "../api/environment",
     "../api/transport:sctp_transport_factory_interface",
     "../call:payload_type",
@@ -1141,6 +1143,7 @@
     ":jsep_transport_controller",
     ":legacy_stats_collector",
     ":media_options",
+    ":media_protocol_names",
     ":media_session",
     ":media_stream",
     ":media_stream_observer",
@@ -3610,6 +3613,7 @@
       "../api/audio_codecs:audio_codecs_api",
       "../api/audio_codecs:builtin_audio_decoder_factory",
       "../api/audio_codecs:builtin_audio_encoder_factory",
+      "../api/crypto:options",
       "../api/video_codecs:video_codecs_api",
       "../api/video_codecs:video_decoder_factory_template",
       "../api/video_codecs:video_decoder_factory_template_dav1d_adapter",
@@ -3657,6 +3661,7 @@
       "../api:scoped_refptr",
       "../api/audio_codecs:builtin_audio_decoder_factory",
       "../api/audio_codecs:builtin_audio_encoder_factory",
+      "../api/crypto:options",
       "../api/video_codecs:video_codecs_api",
       "../api/video_codecs:video_decoder_factory_template",
       "../api/video_codecs:video_decoder_factory_template_dav1d_adapter",
@@ -3809,6 +3814,7 @@
       "../api:rtc_stats_api",
       "../api:scoped_refptr",
       "../api:sctp_transport_interface",
+      "../api/crypto:options",
       "../api/units:time_delta",
       "../p2p:p2p_test_utils",
       "../p2p:transport_description",
diff --git a/pc/jsep_transport.cc b/pc/jsep_transport.cc
index 4681431..0e4355e 100644
--- a/pc/jsep_transport.cc
+++ b/pc/jsep_transport.cc
@@ -21,6 +21,7 @@
 #include "absl/functional/any_invocable.h"
 #include "absl/strings/string_view.h"
 #include "api/candidate.h"
+#include "api/crypto/crypto_options.h"
 #include "api/dtls_transport_interface.h"
 #include "api/ice_transport_interface.h"
 #include "api/jsep.h"
@@ -87,7 +88,8 @@
     std::unique_ptr<RtpTransport> rtp_transport,
     scoped_refptr<DtlsTransport> rtp_dtls_transport,
     std::unique_ptr<SctpTransportInternal> sctp_transport,
-    absl::AnyInvocable<void()> rtcp_mux_active_callback)
+    absl::AnyInvocable<void()> rtcp_mux_active_callback,
+    CryptoOptions::Srtp::CryptexPolicy cryptex_policy)
     : local_certificate_(local_certificate),
       rtp_transport_(std::move(rtp_transport)),
       rtp_dtls_transport_(std::move(rtp_dtls_transport)),
@@ -96,7 +98,8 @@
                                 std::move(sctp_transport),
                                 rtp_dtls_transport_)
                           : nullptr),
-      rtcp_mux_active_callback_(std::move(rtcp_mux_active_callback)) {
+      rtcp_mux_active_callback_(std::move(rtcp_mux_active_callback)),
+      cryptex_policy_(cryptex_policy) {
   TRACE_EVENT0("webrtc", "JsepTransport::JsepTransport");
   RTC_DCHECK(rtp_dtls_transport_);
   RTC_DCHECK(rtp_transport_);
@@ -146,6 +149,11 @@
   if (auto* dtls_srtp_transport = rtp_transport_->AsDtlsSrtpTransport()) {
     dtls_srtp_transport->UpdateRecvEncryptedHeaderExtensionIds(
         jsep_description.encrypted_header_extension_ids);
+    dtls_srtp_transport->UseCryptex(
+        (cryptex_policy_ != CryptoOptions::Srtp::CryptexPolicy::kDisabled) &&
+            (remote_description_ != nullptr &&
+             remote_description_->transport_desc.cryptex),
+        cryptex_policy_ == CryptoOptions::Srtp::CryptexPolicy::kRequire);
   }
   bool ice_restarting =
       local_description_ != nullptr &&
@@ -218,6 +226,10 @@
   if (auto* dtls_srtp_transport = rtp_transport_->AsDtlsSrtpTransport()) {
     dtls_srtp_transport->UpdateSendEncryptedHeaderExtensionIds(
         jsep_description.encrypted_header_extension_ids);
+    dtls_srtp_transport->UseCryptex(
+        (cryptex_policy_ != CryptoOptions::Srtp::CryptexPolicy::kDisabled) &&
+            jsep_description.transport_desc.cryptex,
+        cryptex_policy_ == CryptoOptions::Srtp::CryptexPolicy::kRequire);
   }
 
   remote_description_.reset(new JsepTransportDescription(jsep_description));
diff --git a/pc/jsep_transport.h b/pc/jsep_transport.h
index 9ec919b..b87a072 100644
--- a/pc/jsep_transport.h
+++ b/pc/jsep_transport.h
@@ -17,6 +17,7 @@
 
 #include "absl/functional/any_invocable.h"
 #include "absl/strings/string_view.h"
+#include "api/crypto/crypto_options.h"
 #include "api/ice_transport_interface.h"
 #include "api/jsep.h"
 #include "api/rtc_error.h"
@@ -77,7 +78,8 @@
                 std::unique_ptr<RtpTransport> rtp_transport,
                 scoped_refptr<DtlsTransport> rtp_dtls_transport,
                 std::unique_ptr<SctpTransportInternal> sctp_transport,
-                absl::AnyInvocable<void()> rtcp_mux_active_callback);
+                absl::AnyInvocable<void()> rtcp_mux_active_callback,
+                CryptoOptions::Srtp::CryptexPolicy cryptex_policy);
 
   ~JsepTransport();
 
@@ -260,6 +262,8 @@
   // `rtcp_dtls_transport_` is destroyed. The JsepTransportController will
   // receive the callback and update the aggregate transport states.
   absl::AnyInvocable<void()> rtcp_mux_active_callback_;
+
+  const CryptoOptions::Srtp::CryptexPolicy cryptex_policy_;
 };
 
 }  //  namespace webrtc
diff --git a/pc/jsep_transport_controller.cc b/pc/jsep_transport_controller.cc
index e15a03c..0421f56 100644
--- a/pc/jsep_transport_controller.cc
+++ b/pc/jsep_transport_controller.cc
@@ -782,6 +782,8 @@
 
     JsepTransportDescription jsep_description = CreateJsepTransportDescription(
         content_info, transport_info, extension_ids);
+    jsep_description.transport_desc.cryptex =
+        content_info.media_description()->cryptex();
     if (local) {
       error =
           transport->SetLocalJsepTransportDescription(jsep_description, type);
@@ -1199,12 +1201,14 @@
       make_ref_counted<DtlsTransport>(rtp_dtls_transport_ptr);
 
   std::unique_ptr<JsepTransport> jsep_transport =
-      std::make_unique<JsepTransport>(certificate_, std::move(rtp_transport),
-                                      std::move(dtls_transport),
-                                      std::move(sctp_transport), [&]() {
-                                        RTC_DCHECK_RUN_ON(network_thread_);
-                                        UpdateAggregateStates_n();
-                                      });
+      std::make_unique<JsepTransport>(
+          certificate_, std::move(rtp_transport), std::move(dtls_transport),
+          std::move(sctp_transport),
+          [&]() {
+            RTC_DCHECK_RUN_ON(network_thread_);
+            UpdateAggregateStates_n();
+          },
+          config_.crypto_options.srtp.cryptex_policy);
 
   // Object hierarchy for objects injected into JsepTransport:
   //
diff --git a/pc/jsep_transport_unittest.cc b/pc/jsep_transport_unittest.cc
index a154b97..94882ad 100644
--- a/pc/jsep_transport_unittest.cc
+++ b/pc/jsep_transport_unittest.cc
@@ -22,6 +22,7 @@
 #include <vector>
 
 #include "api/candidate.h"
+#include "api/crypto/crypto_options.h"
 #include "api/field_trials.h"
 #include "api/ice_transport_interface.h"
 #include "api/jsep.h"
@@ -150,7 +151,8 @@
         /*local_certificate=*/nullptr, std::move(rtp_transport),
         std::move(rtp_dtls_transport_wrapper),
         /*sctp_transport=*/nullptr,
-        /*rtcp_mux_active_callback=*/[&]() { OnRtcpMuxActive(); });
+        /*rtcp_mux_active_callback=*/[&]() { OnRtcpMuxActive(); },
+        /*cryptex_policy=*/CryptoOptions::Srtp::CryptexPolicy::kDisabled);
 
     signal_rtcp_mux_active_received_ = false;
     return jsep_transport;
diff --git a/pc/media_session.cc b/pc/media_session.cc
index bfa645d..9f62643 100644
--- a/pc/media_session.cc
+++ b/pc/media_session.cc
@@ -21,6 +21,7 @@
 #include "absl/algorithm/container.h"
 #include "absl/strings/match.h"
 #include "absl/strings/string_view.h"
+#include "api/crypto/crypto_options.h"
 #include "api/environment/environment.h"
 #include "api/field_trials_view.h"
 #include "api/media_types.h"
@@ -630,6 +631,16 @@
       extensions_filter, &negotiated_rtp_extensions, suggester,
       media_description_options.mid, id_domain);
   answer->set_rtp_header_extensions(negotiated_rtp_extensions);
+  // Cryptex is declarative, i.e. does not depend on the offer.
+  // If present in the offer we match the level (session/media)
+  // and put it at session level otherwise.
+  if (session_options.crypto_options.srtp.cryptex_policy !=
+      CryptoOptions::Srtp::CryptexPolicy::kDisabled) {
+    answer->set_cryptex_level(
+        offer->cryptex_level() != MediaContentDescription::AttributeLevel::kNone
+            ? offer->cryptex_level()
+            : MediaContentDescription::AttributeLevel::kSession);
+  }
 
   answer->set_rtcp_mux(session_options.rtcp_mux_enabled && offer->rtcp_mux());
   answer->set_rtcp_reduced_size(offer->rtcp_reduced_size());
@@ -864,6 +875,8 @@
   }
 
   offer->set_extmap_allow_mixed(session_options.offer_extmap_allow_mixed);
+  offer->set_cryptex(session_options.crypto_options.srtp.cryptex_policy !=
+                     CryptoOptions::Srtp::CryptexPolicy::kDisabled);
 
   return offer;
 }
@@ -938,6 +951,17 @@
   }
 
   answer->set_extmap_allow_mixed(offer->extmap_allow_mixed());
+  // Cryptex is declarative: advertise support when policy allows. If the
+  // offer used cryptex at media level, answer at media level.
+  bool use_session_level_cryptex =
+      offer->cryptex() ||
+      absl::c_find_if(offer->contents(), [](const ContentInfo& content) {
+        return content.media_description()->cryptex_level() ==
+               MediaContentDescription::AttributeLevel::kMedia;
+      }) == offer->contents().end();
+  answer->set_cryptex(session_options.crypto_options.srtp.cryptex_policy !=
+                          CryptoOptions::Srtp::CryptexPolicy::kDisabled &&
+                      use_session_level_cryptex);
 
   // Iterate through the media description options, matching with existing
   // media descriptions in `current_description`.
diff --git a/pc/peer_connection.cc b/pc/peer_connection.cc
index fa5108e..f36189d 100644
--- a/pc/peer_connection.cc
+++ b/pc/peer_connection.cc
@@ -2043,7 +2043,9 @@
       // Rollback does not have SDP so can not be munged.
       break;
   }
-  bool negotiated_sctp_snap = false;
+
+  // Below this point are features where we check what was negotiated in
+  // SDP. `desc` will contain the answer, i.e. what was negotiated.
   const SessionDescription* desc = nullptr;
   if (local_description()->GetType() == SdpType::kAnswer ||
       local_description()->GetType() == SdpType::kPrAnswer) {
@@ -2058,7 +2060,8 @@
                      << ", remote=" << remote_description()->GetType();
     return;
   }
-  // Below this point, we assume that we have an answer in `desc`
+
+  bool negotiated_sctp_snap = false;
   const ContentInfo* sctp_content = GetFirstDataContent(desc);
   if (sctp_content && !sctp_content->rejected) {
     const SctpDataContentDescription* sctp_desc =
@@ -2069,8 +2072,9 @@
   }
   RTC_HISTOGRAM_BOOLEAN("WebRTC.PeerConnection.NegotiatedSctpSnap",
                         negotiated_sctp_snap);
+
   // Record congestion control mechanism in use, if any.
-  // The information is taken from the last seen Answer SDP.
+  // The information is taken from the last seen answer SDP.
   std::optional<RtcpFeedbackType> feedback_type;
   for (const auto& content : desc->contents()) {
     std::optional<RtcpFeedbackType> this_feedback_type =
@@ -2088,6 +2092,25 @@
   RTC_HISTOGRAM_ENUMERATION("WebRTC.PeerConnection.NegotiatedFeedbackType",
                             static_cast<int>(*feedback_type),
                             static_cast<int>(RtcpFeedbackType::MAX));
+
+  CryptexPolicyUsage cryptex = kCryptexPolicyUsageMax;
+  switch (configuration_.crypto_options.srtp.cryptex_policy) {
+    case CryptoOptions::Srtp::CryptexPolicy::kDisabled:
+      cryptex = kCryptexPolicyUsageDisabled;
+      break;
+    case CryptoOptions::Srtp::CryptexPolicy::kNegotiate:
+      cryptex = kCryptexPolicyUsageNegotiate;
+      // Special-case for when cryptex was not required but negotiated.
+      if (desc->cryptex()) {
+        cryptex = kCryptexPolicyUsageNegotiated;
+      }
+      break;
+    case CryptoOptions::Srtp::CryptexPolicy::kRequire:
+      cryptex = kCryptexPolicyUsageRequire;
+      break;
+  }
+  RTC_HISTOGRAM_ENUMERATION("WebRTC.PeerConnection.CryptexUsage", cryptex,
+                            kCryptexPolicyUsageMax);
 }
 
 void PeerConnection::ReportCloseUsageMetrics() {
diff --git a/pc/peer_connection_integrationtest.cc b/pc/peer_connection_integrationtest.cc
index be7934e..bebbd0c 100644
--- a/pc/peer_connection_integrationtest.cc
+++ b/pc/peer_connection_integrationtest.cc
@@ -5137,6 +5137,50 @@
   }
 }
 
+TEST_P(PeerConnectionIntegrationTest, CryptexRenegotiation) {
+  CryptoOptions crypto_options;
+  crypto_options.srtp.cryptex_policy =
+      CryptoOptions::Srtp::CryptexPolicy::kNegotiate;
+  PeerConnectionInterface::RTCConfiguration config;
+  config.crypto_options = crypto_options;
+
+  const bool create_media_engine = true;
+  SetCallerPcWrapperAndReturnCurrent(CreatePeerConnectionWrapper(
+      "caller", nullptr, &config, PeerConnectionDependencies(nullptr),
+      /* event_log_factory= */ nullptr,
+      /* reset_encoder_factory= */ false,
+      /* reset_decoder_factory= */ false, create_media_engine));
+  SetCalleePcWrapperAndReturnCurrent(CreatePeerConnectionWrapper(
+      "callee", nullptr, &config, PeerConnectionDependencies(nullptr),
+      /* event_log_factory= */ nullptr,
+      /* reset_encoder_factory= */ false,
+      /* reset_decoder_factory= */ false, create_media_engine));
+
+  ConnectFakeSignaling();
+
+  caller()->AddAudioVideoTracks();
+  // Strip cryptex from the answer the caller receives so the caller observes
+  // a peer that does not negotiate cryptex.
+  caller()->SetReceivedSdpMunger(
+      [](std::unique_ptr<SessionDescriptionInterface>& sdp) {
+        sdp->description()->set_cryptex(false);
+      });
+
+  caller()->CreateAndSetAndSignalOffer();
+  ASSERT_THAT(
+      WaitUntil(
+          [&] {
+            return PeerConnectionStateIs(
+                PeerConnectionInterface::PeerConnectionState::kConnected);
+          },
+          IsTrue()),
+      IsRtcOk());
+  caller()->SetReceivedSdpMunger(nullptr);
+  caller()->CreateAndSetAndSignalOffer();
+  ASSERT_THAT(WaitUntil([&] { return SignalingStateStable(); }, IsTrue()),
+              IsRtcOk());
+}
+
 #ifdef WEBRTC_HAVE_SCTP
 
 class DowngradeLogSink : public LogSink {
diff --git a/pc/peer_connection_interface_unittest.cc b/pc/peer_connection_interface_unittest.cc
index b06416b..c46ee38 100644
--- a/pc/peer_connection_interface_unittest.cc
+++ b/pc/peer_connection_interface_unittest.cc
@@ -2605,6 +2605,26 @@
     RTCError error = pc_->SetConfiguration(config);
     EXPECT_EQ(RTCErrorType::INVALID_MODIFICATION, error.type());
   }
+  {
+    RTCConfiguration config;
+    config.sdp_semantics = sdp_semantics_;
+    CryptoOptions options;
+    options.srtp.cryptex_policy =
+        CryptoOptions::Srtp::CryptexPolicy::kNegotiate;
+    config.crypto_options = options;
+
+    CreatePeerConnection(config);
+
+    std::unique_ptr<SessionDescriptionInterface> offer;
+    ASSERT_TRUE(DoCreateOffer(&offer, nullptr));
+    EXPECT_TRUE(DoSetLocalDescription(std::move(offer)));
+
+    options.srtp.cryptex_policy = CryptoOptions::Srtp::CryptexPolicy::kRequire;
+    config.crypto_options = options;
+
+    RTCError error = pc_->SetConfiguration(config);
+    EXPECT_EQ(RTCErrorType::INVALID_MODIFICATION, error.type());
+  }
 }
 
 // Test that PeerConnection::Close changes the states to closed and all remote
diff --git a/pc/sdp_munging_detector.cc b/pc/sdp_munging_detector.cc
index d4e52c5..83d8edb 100644
--- a/pc/sdp_munging_detector.cc
+++ b/pc/sdp_munging_detector.cc
@@ -601,6 +601,13 @@
       }
     }
 
+    // Validate media level cryptex.
+    if (last_created_media_description->cryptex() !=
+        media_description_to_set->cryptex()) {
+      RTC_LOG(LS_WARNING) << "SDP munging: cryptex changed at media level.";
+      return SdpMungingType::kCryptex;
+    }
+
     // Validate b= (which does not have an effect in the local description).
     if (last_created_media_description->bandwidth() !=
         media_description_to_set->bandwidth()) {
@@ -700,6 +707,13 @@
     }
   }
 
+  // Validate cryptex setting.
+  if (sdesc->description()->cryptex() !=
+      last_created_desc->description()->cryptex()) {
+    RTC_LOG(LS_WARNING) << "SDP munging: cryptex changed at session level.";
+    return SdpMungingType::kCryptex;
+  }
+
   // TODO: crbug.com/40567530 - this serializes the descriptions back to a SDP
   // string which is very complex and we not should be be forced to rely on
   // string equality.
@@ -757,6 +771,8 @@
       return false;
     case SdpMungingType::kDataChannelSctpInit:
       return false;
+    case SdpMungingType::kCryptex:
+      return false;
     default:
       // Handled below.
       break;
diff --git a/pc/sdp_munging_detector_unittest.cc b/pc/sdp_munging_detector_unittest.cc
index 813a6ad..d63fa74 100644
--- a/pc/sdp_munging_detector_unittest.cc
+++ b/pc/sdp_munging_detector_unittest.cc
@@ -27,6 +27,7 @@
 #include "api/audio_codecs/builtin_audio_encoder_factory.h"
 #include "api/candidate.h"
 #include "api/create_peerconnection_factory.h"
+#include "api/crypto/crypto_options.h"
 #include "api/jsep.h"
 #include "api/media_types.h"
 #include "api/peer_connection_interface.h"
@@ -1209,6 +1210,49 @@
       ElementsAre(Pair(SdpMungingType::kRtpHeaderExtensionModified, 1)));
 }
 
+TEST_F(SdpMungingTest, CryptexModifiedSession) {
+  RTCConfiguration config;
+  config.sdp_semantics = SdpSemantics::kUnifiedPlan;
+  config.crypto_options.srtp.cryptex_policy =
+      CryptoOptions::Srtp::CryptexPolicy::kNegotiate;
+  auto pc = CreatePeerConnection(config, "");
+  pc->AddVideoTrack("video_track", {});
+
+  std::unique_ptr<SessionDescriptionInterface> offer = pc->CreateOffer();
+  EXPECT_TRUE(offer->description()->cryptex());
+  offer->description()->set_cryptex(false);
+
+  RTCError error;
+  EXPECT_FALSE(pc->SetLocalDescription(std::move(offer), &error));
+  EXPECT_THAT(
+      metrics::Samples("WebRTC.PeerConnection.SdpMunging.Offer.Initial"),
+      ElementsAre(Pair(SdpMungingType::kCryptex, 1)));
+}
+
+TEST_F(SdpMungingTest, CryptexModifiedMedia) {
+  RTCConfiguration config;
+  config.sdp_semantics = SdpSemantics::kUnifiedPlan;
+  config.crypto_options.srtp.cryptex_policy =
+      CryptoOptions::Srtp::CryptexPolicy::kNegotiate;
+  auto pc = CreatePeerConnection(config, "");
+  pc->AddVideoTrack("video_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_TRUE(media_description->cryptex());
+  media_description->set_cryptex_level(
+      MediaContentDescription::AttributeLevel::kNone);
+
+  RTCError error;
+  EXPECT_FALSE(pc->SetLocalDescription(std::move(offer), &error));
+  EXPECT_THAT(
+      metrics::Samples("WebRTC.PeerConnection.SdpMunging.Offer.Initial"),
+      ElementsAre(Pair(SdpMungingType::kCryptex, 1)));
+}
+
 TEST_F(SdpMungingTest, PayloadTypeChanged) {
   auto pc = CreatePeerConnection();
   pc->AddAudioTrack("audio_track", {});
diff --git a/pc/sdp_offer_answer.cc b/pc/sdp_offer_answer.cc
index 41550e5f..41686f2 100644
--- a/pc/sdp_offer_answer.cc
+++ b/pc/sdp_offer_answer.cc
@@ -75,6 +75,7 @@
 #include "pc/jsep_transport_controller.h"
 #include "pc/legacy_stats_collector.h"
 #include "pc/media_options.h"
+#include "pc/media_protocol_names.h"
 #include "pc/media_session.h"
 #include "pc/media_stream.h"
 #include "pc/media_stream_observer.h"
@@ -720,6 +721,68 @@
   return RTCError::OK();
 }
 
+// Check that cryptex is supported (if required) and consistent within the
+// bundle groups.
+RTCError ValidateCryptex(
+    const SessionDescription* description,
+    const flat_map<std::string, const ContentGroup*>& bundle_groups_by_mid,
+    bool required_to_negotiate) {
+  RTC_DCHECK(description);
+
+  bool session_level_support = description->cryptex();
+  for (const ContentInfo& content_info : description->contents()) {
+    if (content_info.rejected ||
+        !IsRtpProtocol(content_info.media_description()->protocol())) {
+      continue;
+    }
+    const MediaContentDescription* media = content_info.media_description();
+    if (required_to_negotiate && !session_level_support && !media->cryptex()) {
+      return LOG_ERROR(RTCError::InvalidParameter()
+                       << "cryptex required but not negotiated.");
+    }
+
+    if (media->cryptex_level() ==
+        MediaContentDescription::AttributeLevel::kSession) {
+      // Session level attributes are implicitly consistent.
+      continue;
+    }
+
+    const auto mid = content_info.mid();
+    auto it = bundle_groups_by_mid.find(mid);
+    if (it == bundle_groups_by_mid.end() || it->second == nullptr) {
+      continue;
+    }
+    const ContentGroup* bundle = it->second;
+
+    // Find and compare cryptex at media level of the current content to
+    // cryptex of the first non-datachannel media content.
+    const ContentInfo* first_media_content = nullptr;
+    for (const std::string& name : bundle->content_names()) {
+      const ContentInfo* content = description->GetContentByName(name);
+      if (content && content->media_description()->type() != MediaType::DATA) {
+        first_media_content = content;
+        break;
+      }
+    }
+    if (!first_media_content) {
+      return LOG_ERROR(RTCError::InvalidParameter() << "Inconsistent bundle");
+    }
+    // If this content is the first non-datachannel media content in the
+    // bundle, this is implicitly consistent.
+    if (first_media_content->mid() == mid) {
+      continue;
+    }
+    if (first_media_content->media_description()->cryptex_level() !=
+        media->cryptex_level()) {
+      return LOG_ERROR(
+          RTCError::InvalidParameter()
+          << "The media section with MID='" << content_info.mid()
+          << "' is not consistent with the cryptex level of its bundle");
+    }
+  }
+  return RTCError::OK();
+}
+
 RTCError ValidatePayloadTypes(const SessionDescription& description) {
   for (const ContentInfo& content : description.contents()) {
     if (content.type != MediaProtocolType::kRtp) {
@@ -1046,7 +1109,7 @@
 }
 
 // From `rtc_options`, fill parts of `session_options` shared by all generated
-// m= sectionss (in other words, nothing that involves a map/array).
+// m= sections (in other words, nothing that involves a map/array).
 void ExtractSharedMediaSessionOptions(
     const PeerConnectionInterface::RTCOfferAnswerOptions& rtc_options,
     MediaSessionOptions* session_options) {
@@ -4065,6 +4128,13 @@
     }
   }
 
+  error = ValidateCryptex(sdesc->description(), bundle_groups_by_mid,
+                          pc_->GetCryptoOptions().srtp.cryptex_policy ==
+                              CryptoOptions::Srtp::CryptexPolicy::kRequire);
+  if (!error.ok()) {
+    return error;
+  }
+
   // Verify ice-ufrag and ice-pwd.
   if (!VerifyIceUfragPwdPresent(sdesc->description(), bundle_groups_by_mid)) {
     return LOG_ERROR(RTCError(RTCErrorType::INVALID_PARAMETER)
diff --git a/pc/sdp_offer_answer_unittest.cc b/pc/sdp_offer_answer_unittest.cc
index dc3765c..ff06d90 100644
--- a/pc/sdp_offer_answer_unittest.cc
+++ b/pc/sdp_offer_answer_unittest.cc
@@ -25,6 +25,7 @@
 #include "api/audio_codecs/builtin_audio_decoder_factory.h"
 #include "api/audio_codecs/builtin_audio_encoder_factory.h"
 #include "api/create_peerconnection_factory.h"
+#include "api/crypto/crypto_options.h"
 #include "api/jsep.h"
 #include "api/media_types.h"
 #include "api/peer_connection_interface.h"
@@ -3164,4 +3165,310 @@
   EXPECT_TRUE(caller_transceiver->stopped());
 }
 
+TEST_F(SdpOfferAnswerTest, CryptexOffInOffer) {
+  CryptoOptions crypto_options;
+  crypto_options.srtp.cryptex_policy =
+      CryptoOptions::Srtp::CryptexPolicy::kDisabled;
+  PeerConnectionInterface::RTCConfiguration config;
+  config.crypto_options = crypto_options;
+  auto pc = CreatePeerConnection(config, "");
+
+  auto offer = pc->CreateOfferAndSetAsLocal();
+  EXPECT_FALSE(offer->description()->cryptex());
+}
+
+TEST_F(SdpOfferAnswerTest, CryptexOffInAnswer) {
+  CryptoOptions crypto_options;
+  crypto_options.srtp.cryptex_policy =
+      CryptoOptions::Srtp::CryptexPolicy::kDisabled;
+  PeerConnectionInterface::RTCConfiguration config;
+  config.crypto_options = crypto_options;
+  auto pc = CreatePeerConnection(config, "");
+
+  std::string sdp_offer =
+      "v=0\r\n"
+      "o=- 0 0 IN IP4 127.0.0.1\r\n"
+      "s=-\r\n"
+      "t=0 0\r\n"
+      "a=group:BUNDLE 0\r\n"
+      "a=ice-ufrag:TESTUFRAG\r\n"
+      "a=ice-pwd:ThisIsATestIcePasswordThatIsLongEnough\r\n"
+      "a=fingerprint:sha-256 "
+      "AD:52:52:E0:B1:37:34:21:0E:15:8E:B7:56:56:7B:B4:39:0E:6D:1C:F5:84:A7:EE:"
+      "B5:27:3E:30:B1:7D:69:42\r\n"
+      "a=cryptex\r\n"
+      "m=audio 9 UDP/TLS/RTP/SAVPF 111\r\n"
+      "c=IN IP4 0.0.0.0\r\n"
+      "a=mid:0\r\n"
+      "a=sendrecv\r\n"
+      "a=rtcp-mux\r\n"
+      "a=rtpmap:111 opus/48000/2\r\n"
+      "a=rtcp-fb:111 transport-cc\r\n";
+  auto offer = CreateSessionDescription(SdpType::kOffer, sdp_offer);
+  ASSERT_THAT(offer, NotNull());
+  EXPECT_TRUE(pc->SetRemoteDescription(std::move(offer)));
+
+  auto answer = pc->CreateAnswerAndSetAsLocal();
+  ASSERT_THAT(answer, NotNull());
+  auto* desc = answer->description();
+  EXPECT_FALSE(desc->cryptex());
+}
+
+TEST_F(SdpOfferAnswerTest, CryptexNotOfferedAnswerDeclaresSupport) {
+  CryptoOptions crypto_options;
+  crypto_options.srtp.cryptex_policy =
+      CryptoOptions::Srtp::CryptexPolicy::kNegotiate;
+  PeerConnectionInterface::RTCConfiguration config;
+  config.crypto_options = crypto_options;
+  auto pc = CreatePeerConnection(config, "");
+
+  std::string sdp_offer =
+      "v=0\r\n"
+      "o=- 0 0 IN IP4 127.0.0.1\r\n"
+      "s=-\r\n"
+      "t=0 0\r\n"
+      "a=group:BUNDLE 0\r\n"
+      "a=ice-ufrag:TESTUFRAG\r\n"
+      "a=ice-pwd:ThisIsATestIcePasswordThatIsLongEnough\r\n"
+      "a=fingerprint:sha-256 "
+      "AD:52:52:E0:B1:37:34:21:0E:15:8E:B7:56:56:7B:B4:39:0E:6D:1C:F5:84:A7:EE:"
+      "B5:27:3E:30:B1:7D:69:42\r\n"
+      // No a=cryptex at session level.
+      "m=audio 9 UDP/TLS/RTP/SAVPF 111\r\n"
+      "c=IN IP4 0.0.0.0\r\n"
+      "a=mid:0\r\n"
+      "a=sendrecv\r\n"
+      "a=rtcp-mux\r\n"
+      // No a=cryptex at media level.
+      "a=rtpmap:111 opus/48000/2\r\n"
+      "a=rtcp-fb:111 transport-cc\r\n";
+  auto offer = CreateSessionDescription(SdpType::kOffer, sdp_offer);
+  ASSERT_THAT(offer, NotNull());
+  EXPECT_TRUE(pc->SetRemoteDescription(std::move(offer)));
+
+  auto answer = pc->CreateAnswerAndSetAsLocal();
+  ASSERT_THAT(answer, NotNull());
+  auto* desc = answer->description();
+  EXPECT_TRUE(desc->cryptex());
+
+  auto* content = desc->GetContentByName("0");
+  ASSERT_THAT(content, NotNull());
+  EXPECT_EQ(content->media_description()->cryptex_level(),
+            MediaContentDescription::AttributeLevel::kSession);
+}
+
+TEST_F(SdpOfferAnswerTest, CryptexSessionLevel) {
+  CryptoOptions crypto_options;
+  crypto_options.srtp.cryptex_policy =
+      CryptoOptions::Srtp::CryptexPolicy::kNegotiate;
+  PeerConnectionInterface::RTCConfiguration config;
+  config.crypto_options = crypto_options;
+  auto pc = CreatePeerConnection(config, "");
+
+  std::string sdp_offer =
+      "v=0\r\n"
+      "o=- 0 0 IN IP4 127.0.0.1\r\n"
+      "s=-\r\n"
+      "t=0 0\r\n"
+      "a=group:BUNDLE 0\r\n"
+      "a=ice-ufrag:TESTUFRAG\r\n"
+      "a=ice-pwd:ThisIsATestIcePasswordThatIsLongEnough\r\n"
+      "a=fingerprint:sha-256 "
+      "AD:52:52:E0:B1:37:34:21:0E:15:8E:B7:56:56:7B:B4:39:0E:6D:1C:F5:84:A7:EE:"
+      "B5:27:3E:30:B1:7D:69:42\r\n"
+      "a=cryptex\r\n"
+      "m=audio 9 UDP/TLS/RTP/SAVPF 111\r\n"
+      "c=IN IP4 0.0.0.0\r\n"
+      "a=mid:0\r\n"
+      "a=sendrecv\r\n"
+      "a=rtcp-mux\r\n"
+      "a=rtpmap:111 opus/48000/2\r\n"
+      "a=rtcp-fb:111 transport-cc\r\n";
+
+  auto offer = CreateSessionDescription(SdpType::kOffer, sdp_offer);
+  ASSERT_THAT(offer, NotNull());
+  EXPECT_TRUE(pc->SetRemoteDescription(std::move(offer)));
+
+  auto answer = pc->CreateAnswerAndSetAsLocal();
+  ASSERT_THAT(answer, NotNull());
+  auto* desc = answer->description();
+  EXPECT_TRUE(desc->cryptex());
+
+  auto* content = desc->GetContentByName("0");
+  ASSERT_THAT(content, NotNull());
+  EXPECT_TRUE(content->media_description()->cryptex());
+  EXPECT_EQ(content->media_description()->cryptex_level(),
+            MediaContentDescription::AttributeLevel::kSession);
+}
+
+TEST_F(SdpOfferAnswerTest, CryptexMediaLevel) {
+  CryptoOptions crypto_options;
+  crypto_options.srtp.cryptex_policy =
+      CryptoOptions::Srtp::CryptexPolicy::kNegotiate;
+  PeerConnectionInterface::RTCConfiguration config;
+  config.crypto_options = crypto_options;
+  auto pc = CreatePeerConnection(config, "");
+
+  std::string sdp_offer =
+      "v=0\r\n"
+      "o=- 0 0 IN IP4 127.0.0.1\r\n"
+      "s=-\r\n"
+      "t=0 0\r\n"
+      "a=group:BUNDLE 0 1\r\n"
+      "a=ice-ufrag:TESTUFRAG\r\n"
+      "a=ice-pwd:ThisIsATestIcePasswordThatIsLongEnough\r\n"
+      "a=fingerprint:sha-256 "
+      "AD:52:52:E0:B1:37:34:21:0E:15:8E:B7:56:56:7B:B4:39:0E:6D:1C:F5:84:A7:EE:"
+      "B5:27:3E:30:B1:7D:69:42\r\n"
+      "m=audio 9 UDP/TLS/RTP/SAVPF 111\r\n"
+      "c=IN IP4 0.0.0.0\r\n"
+      "a=mid:0\r\n"
+      "a=sendrecv\r\n"
+      "a=rtcp-mux\r\n"
+      "a=cryptex\r\n"
+      "a=rtpmap:111 opus/48000/2\r\n"
+      "a=rtcp-fb:111 transport-cc\r\n"
+      // cryptex is not required for non-rtp content.
+      "m=application 0 UDP/DTLS/SCTP webrtc-datachannel\r\n"
+      "c=IN IP4 0.0.0.0\r\n"
+      "a=sctp-port:5000\r\n"
+      "a=max-message-size:262144\r\n"
+      "a=mid:1\r\n";
+
+  auto offer = CreateSessionDescription(SdpType::kOffer, sdp_offer);
+  ASSERT_THAT(offer, NotNull());
+  EXPECT_TRUE(pc->SetRemoteDescription(std::move(offer)));
+
+  auto answer = pc->CreateAnswerAndSetAsLocal();
+  ASSERT_THAT(answer, NotNull());
+  auto* desc = answer->description();
+  EXPECT_FALSE(desc->cryptex());
+
+  auto* content = desc->GetContentByName("0");
+  ASSERT_THAT(content, NotNull());
+  EXPECT_TRUE(content->media_description()->cryptex());
+  EXPECT_EQ(content->media_description()->cryptex_level(),
+            MediaContentDescription::AttributeLevel::kMedia);
+}
+
+TEST_F(SdpOfferAnswerTest, CryptexBundleInconsistency) {
+  CryptoOptions crypto_options;
+  crypto_options.srtp.cryptex_policy =
+      CryptoOptions::Srtp::CryptexPolicy::kNegotiate;
+  PeerConnectionInterface::RTCConfiguration config;
+  config.crypto_options = crypto_options;
+  auto pc = CreatePeerConnection(config, "");
+
+  // First m-line in the BUNDLE has cryptex, second does not.
+  std::string sdp_offer =
+      "v=0\r\n"
+      "o=- 0 0 IN IP4 127.0.0.1\r\n"
+      "s=-\r\n"
+      "t=0 0\r\n"
+      "a=group:BUNDLE 0 1\r\n"
+      "a=ice-ufrag:TESTUFRAG\r\n"
+      "a=ice-pwd:ThisIsATestIcePasswordThatIsLongEnough\r\n"
+      "a=fingerprint:sha-256 "
+      "AD:52:52:E0:B1:37:34:21:0E:15:8E:B7:56:56:7B:B4:39:0E:6D:1C:F5:84:A7:EE:"
+      "B5:27:3E:30:B1:7D:69:42\r\n"
+      "m=audio 9 UDP/TLS/RTP/SAVPF 111\r\n"
+      "c=IN IP4 0.0.0.0\r\n"
+      "a=mid:0\r\n"
+      "a=sendrecv\r\n"
+      "a=rtcp-mux\r\n"
+      "a=cryptex\r\n"
+      "a=rtpmap:111 opus/48000/2\r\n"
+      "a=rtcp-fb:111 transport-cc\r\n"
+      "m=audio 9 UDP/TLS/RTP/SAVPF 111\r\n"
+      "c=IN IP4 0.0.0.0\r\n"
+      "a=mid:1\r\n"
+      "a=sendrecv\r\n"
+      "a=rtcp-mux\r\n"
+      // "a=cryptex\r\n"
+      "a=rtpmap:111 opus/48000/2\r\n"
+      "a=rtcp-fb:111 transport-cc\r\n";
+
+  auto offer = CreateSessionDescription(SdpType::kOffer, sdp_offer);
+  ASSERT_NE(offer, nullptr);
+  RTCError error;
+  pc->SetRemoteDescription(std::move(offer), &error);
+  EXPECT_FALSE(error.ok());
+  EXPECT_EQ(error.type(), RTCErrorType::INVALID_PARAMETER);
+}
+
+TEST_F(SdpOfferAnswerTest, CryptexNegotiated) {
+  CryptoOptions crypto_options;
+  crypto_options.srtp.cryptex_policy =
+      CryptoOptions::Srtp::CryptexPolicy::kNegotiate;
+  PeerConnectionInterface::RTCConfiguration config;
+  config.crypto_options = crypto_options;
+  auto pc1 = CreatePeerConnection(config, "");
+  pc1->AddAudioTrack("audio_track", {});
+  auto pc2 = CreatePeerConnection(config, "");
+
+  auto offer = pc1->CreateOfferAndSetAsLocal();
+  ASSERT_EQ(offer->description()->contents().size(), 1u);
+  EXPECT_TRUE(pc2->SetRemoteDescription(std::move(offer)));
+
+  auto answer = pc2->CreateAnswerAndSetAsLocal();
+  EXPECT_TRUE(answer->description()->cryptex());
+}
+
+TEST_F(SdpOfferAnswerTest, CryptexRequired) {
+  CryptoOptions crypto_options1;
+  crypto_options1.srtp.cryptex_policy =
+      CryptoOptions::Srtp::CryptexPolicy::kRequire;
+  PeerConnectionInterface::RTCConfiguration config1;
+  config1.crypto_options = crypto_options1;
+  auto pc1 = CreatePeerConnection(config1, "");
+  pc1->AddAudioTrack("audio_track", {});
+
+  CryptoOptions crypto_options2;
+  crypto_options2.srtp.cryptex_policy =
+      CryptoOptions::Srtp::CryptexPolicy::kDisabled;
+  PeerConnectionInterface::RTCConfiguration config2;
+  config2.crypto_options = crypto_options2;
+  auto pc2 = CreatePeerConnection(config2, "");
+
+  auto offer = pc1->CreateOfferAndSetAsLocal();
+  EXPECT_TRUE(pc2->SetRemoteDescription(std::move(offer)));
+
+  auto answer = pc2->CreateAnswerAndSetAsLocal();
+  EXPECT_FALSE(answer->description()->cryptex());
+
+  RTCError error;
+  pc1->SetRemoteDescription(std::move(answer), &error);
+  EXPECT_FALSE(error.ok());
+  EXPECT_EQ(error.type(), RTCErrorType::INVALID_PARAMETER);
+}
+
+#ifdef WEBRTC_HAVE_SCTP
+TEST_F(SdpOfferAnswerTest, CryptexRequiredDatachannelOnly) {
+  CryptoOptions crypto_options1;
+  crypto_options1.srtp.cryptex_policy =
+      CryptoOptions::Srtp::CryptexPolicy::kRequire;
+  PeerConnectionInterface::RTCConfiguration config1;
+  config1.crypto_options = crypto_options1;
+  auto pc1 = CreatePeerConnection(config1, "");
+  EXPECT_TRUE(pc1->pc()->CreateDataChannelOrError("dc", nullptr).ok());
+
+  CryptoOptions crypto_options2;
+  crypto_options2.srtp.cryptex_policy =
+      CryptoOptions::Srtp::CryptexPolicy::kDisabled;
+  PeerConnectionInterface::RTCConfiguration config2;
+  config2.crypto_options = crypto_options2;
+  auto pc2 = CreatePeerConnection(config2, "");
+
+  auto offer = pc1->CreateOfferAndSetAsLocal();
+  EXPECT_TRUE(pc2->SetRemoteDescription(std::move(offer)));
+
+  auto answer = pc2->CreateAnswerAndSetAsLocal();
+  EXPECT_FALSE(answer->description()->cryptex());
+
+  RTCError error;
+  pc1->SetRemoteDescription(std::move(answer), &error);
+  EXPECT_TRUE(error.ok());
+}
+#endif  // WEBRTC_HAVE_SCTP
+
 }  // namespace webrtc
diff --git a/pc/session_description.cc b/pc/session_description.cc
index 784c177..1dcb78f 100644
--- a/pc/session_description.cc
+++ b/pc/session_description.cc
@@ -177,11 +177,15 @@
 }
 
 void SessionDescription::AddContent(ContentInfo&& content) {
+  // Mixed support on session level overrides setting on media level.
   if (extmap_allow_mixed()) {
-    // Mixed support on session level overrides setting on media level.
     content.media_description()->set_extmap_allow_mixed_level(
         MediaContentDescription::AttributeLevel::kSession);
   }
+  if (cryptex()) {
+    content.media_description()->set_cryptex_level(
+        MediaContentDescription::AttributeLevel::kSession);
+  }
   contents_.push_back(std::move(content));
 }
 
diff --git a/pc/session_description.h b/pc/session_description.h
index bb37f3a..f8c44ec 100644
--- a/pc/session_description.h
+++ b/pc/session_description.h
@@ -321,6 +321,18 @@
     }
   }
 
+  // Determines if cryptex header extension encryption is supported.
+  void set_cryptex_level(AttributeLevel level) {
+    if (level == AttributeLevel::kMedia &&
+        cryptex_level_ == AttributeLevel::kSession) {
+      // Do not downgrade from session level to media level.
+      return;
+    }
+    cryptex_level_ = level;
+  }
+  AttributeLevel cryptex_level() const { return cryptex_level_; }
+  bool cryptex() const { return cryptex_level_ != AttributeLevel::kNone; }
+
  protected:
   // TODO(bugs.webrtc.org/15214): move all RTP related things to
   // RtpMediaDescription that the SCTP content description does
@@ -352,6 +364,8 @@
   virtual MediaContentDescription* CloneInternal() const = 0;
 
   std::vector<Codec> codecs_;
+
+  AttributeLevel cryptex_level_ = AttributeLevel::kNone;
 };
 
 class RtpMediaContentDescription : public MediaContentDescription {};
@@ -660,6 +674,21 @@
   }
   bool extmap_allow_mixed() const { return extmap_allow_mixed_; }
 
+  void set_cryptex(bool supported) {
+    cryptex_ = supported;
+    MediaContentDescription::AttributeLevel media_level_setting =
+        supported ? MediaContentDescription::AttributeLevel::kSession
+                  : MediaContentDescription::AttributeLevel::kNone;
+    for (auto& content : contents_) {
+      // Do not set to kNone if the current setting is kMedia.
+      if (supported || content.media_description()->cryptex_level() !=
+                           MediaContentDescription::AttributeLevel::kMedia) {
+        content.media_description()->set_cryptex_level(media_level_setting);
+      }
+    }
+  }
+  bool cryptex() const { return cryptex_; }
+
  private:
   SessionDescription(const SessionDescription&);
 
@@ -668,6 +697,7 @@
   ContentGroups content_groups_;
   int msid_signaling_ = kMsidSignalingMediaSection | kMsidSignalingSemantic;
   bool extmap_allow_mixed_ = true;
+  bool cryptex_ = false;
 };
 
 // Indicates whether a session description was sent by the local client or