Sframe encryptor implementation

This CL introduces the concrete Sframe encryptor that sits between the
public key-management API and the media pipeline, implementing the
sender-side wiring for Sframe end-to-end encryption.

# High-level design
The implementation follows a three-layer split that matches the existing
WebRTC layering and respects the api/ visibility rules: 1. Public API
layer (api/sframe/) - A narrow, stable interface that exposes only key
management to applications: setting an encryption key on the sender
side.

2. Internal media interface layer (modules/sframe/) - Extends the public
key-management interface with the Encrypt and "max ciphertext size"
entry points used by the media pipeline. This is the surface the media
channel speaks to; application code never sees it.

3. Concrete impl layer (modules/sframe/) - Implements the media
interface on top of the cisco/sframe library.

Splitting key management from frame protection lets the public handle
expose only the small surface applications need, while the media
pipeline talks to a richer interface internally.

Bug: webrtc:479862368
Change-Id: Id93dba5874b6f5b1f32723755dd404af202f6ecf
Reviewed-on: https://webrtc-review.googlesource.com/c/src/+/481301
Reviewed-by: Harald Alvestrand <hta@webrtc.org>
Reviewed-by: Tomas Gunnarsson <tommi@webrtc.org>
Commit-Queue: Tomas Gunnarsson <tommi@webrtc.org>
Cr-Commit-Position: refs/heads/main@{#48065}
diff --git a/modules/BUILD.gn b/modules/BUILD.gn
index f0a150f..95e1af6 100644
--- a/modules/BUILD.gn
+++ b/modules/BUILD.gn
@@ -84,6 +84,7 @@
       "pacing:pacing_unittests",
       "remote_bitrate_estimator:remote_bitrate_estimator_unittests",
       "rtp_rtcp:rtp_rtcp_unittests",
+      "sframe:sframe_unittests",
       "video_coding:video_coding_unittests",
       "video_coding/timing:timing_unittests",
     ]
diff --git a/modules/sframe/BUILD.gn b/modules/sframe/BUILD.gn
new file mode 100644
index 0000000..91708f5
--- /dev/null
+++ b/modules/sframe/BUILD.gn
@@ -0,0 +1,53 @@
+# Copyright 2026 The WebRTC project authors. All Rights Reserved.
+#
+# Use of this source code is governed by a BSD-style license
+# that can be found in the LICENSE file in the root of the source
+# tree. An additional intellectual property rights grant can be found
+# in the file PATENTS.  All contributing project authors may
+# be found in the AUTHORS file in the root of the source tree.
+
+import("../../webrtc.gni")
+
+rtc_library("sframe_media_encryptor_interface") {
+  visibility = [ "*" ]
+  sources = [ "sframe_media_encryptor_interface.h" ]
+  deps = [
+    "../../api:rtc_error",
+    "../../api:sframe_encryptor_interface",
+    "../../api:sframe_types",
+  ]
+}
+
+rtc_library("sframe_encryptor") {
+  visibility = [ "*" ]
+  sources = [
+    "sframe_encryptor.cc",
+    "sframe_encryptor.h",
+  ]
+  deps = [
+    ":sframe_media_encryptor_interface",
+    "../../api:make_ref_counted",
+    "../../api:rtc_error",
+    "../../api:scoped_refptr",
+    "../../api:sequence_checker",
+    "../../api:sframe_types",
+    "../../rtc_base:macromagic",
+    "../../rtc_base/system:no_unique_address",
+    "//third_party/abseil-cpp/absl/base:nullability",
+    "//third_party/sframe",
+  ]
+}
+
+if (rtc_include_tests) {
+  rtc_library("sframe_unittests") {
+    testonly = true
+    sources = [ "sframe_encryptor_decryptor_unittest.cc" ]
+    deps = [
+      ":sframe_encryptor",
+      "../../api:rtc_error",
+      "../../api:scoped_refptr",
+      "../../api:sframe_types",
+      "../../test:test_support",
+    ]
+  }
+}
diff --git a/modules/sframe/DEPS b/modules/sframe/DEPS
new file mode 100644
index 0000000..927f4d2
--- /dev/null
+++ b/modules/sframe/DEPS
@@ -0,0 +1,3 @@
+include_rules = [
+  "+third_party/sframe",
+]
\ No newline at end of file
diff --git a/modules/sframe/sframe_encryptor.cc b/modules/sframe/sframe_encryptor.cc
new file mode 100644
index 0000000..495c0fc
--- /dev/null
+++ b/modules/sframe/sframe_encryptor.cc
@@ -0,0 +1,113 @@
+/*
+ *  Copyright 2026 The WebRTC project authors. All Rights Reserved.
+ *
+ *  Use of this source code is governed by a BSD-style license
+ *  that can be found in the LICENSE file in the root of the source
+ *  tree. An additional intellectual property rights grant can be found
+ *  in the file PATENTS.  All contributing project authors may
+ *  be found in the AUTHORS file in the root of the source tree.
+ */
+
+#include "modules/sframe/sframe_encryptor.h"
+
+#include <cstddef>
+#include <cstdint>
+#include <memory>
+#include <optional>
+#include <span>
+
+#include "api/make_ref_counted.h"
+#include "api/rtc_error.h"
+#include "api/scoped_refptr.h"
+#include "api/sequence_checker.h"
+#include "api/sframe/sframe_types.h"
+#include "third_party/sframe/src/include/sframe/result.h"
+#include "third_party/sframe/src/include/sframe/sframe.h"
+
+namespace webrtc {
+
+namespace {
+sframe::CipherSuite ToSframeCipherSuite(SframeCipherSuite suite) {
+  switch (suite) {
+    case SframeCipherSuite::kAes128CtrHmacSha256_80:
+      return sframe::CipherSuite::AES_128_CTR_HMAC_SHA256_80;
+    case SframeCipherSuite::kAes128CtrHmacSha256_64:
+      return sframe::CipherSuite::AES_128_CTR_HMAC_SHA256_64;
+    case SframeCipherSuite::kAes128CtrHmacSha256_32:
+      return sframe::CipherSuite::AES_128_CTR_HMAC_SHA256_32;
+    case SframeCipherSuite::kAes128GcmSha256_128:
+      return sframe::CipherSuite::AES_GCM_128_SHA256;
+    case SframeCipherSuite::kAes256GcmSha512_128:
+      return sframe::CipherSuite::AES_GCM_256_SHA512;
+  }
+}
+}  // namespace
+
+scoped_refptr<SframeEncryptor> SframeEncryptor::Create(
+    SframeMode mode,
+    SframeCipherSuite cipher_suite) {
+  return make_ref_counted<SframeEncryptor>(mode, cipher_suite);
+}
+
+SframeEncryptor::SframeEncryptor(SframeMode mode,
+                                 SframeCipherSuite cipher_suite)
+    : sequence_checker_(SequenceChecker::kDetached),
+      mode_(mode),
+      context_(std::make_unique<sframe::Context>(
+          ToSframeCipherSuite(cipher_suite))) {}
+
+SframeEncryptor::~SframeEncryptor() = default;
+
+RTCError SframeEncryptor::SetEncryptionKey(
+    uint64_t key_id,
+    std::span<const uint8_t> key_material) {
+  RTC_DCHECK_RUN_ON(&sequence_checker_);
+  sframe::Result<void> result = context_->add_key(
+      key_id, sframe::KeyUsage::protect,
+      sframe::input_bytes(key_material.data(), key_material.size()));
+  if (result.is_err()) {
+    RTCError error = RTCError::InternalError("Failed to set encryption key");
+    if (const char* message = result.error().message()) {
+      error.string_builder() << ": " << message;
+    }
+    return error;
+  }
+
+  if (active_key_id_ && *active_key_id_ != key_id) {
+    context_->remove_key(*active_key_id_);
+  }
+
+  active_key_id_ = key_id;
+
+  return RTCError::OK();
+}
+
+RTCErrorOr<size_t> SframeEncryptor::Encrypt(
+    std::span<const uint8_t> frame,
+    std::span<const uint8_t> additional_data,
+    std::span<uint8_t> encrypted_frame) {
+  RTC_DCHECK_RUN_ON(&sequence_checker_);
+  if (!active_key_id_) {
+    return RTCError::InvalidState("Sframe encryption key not set");
+  }
+
+  auto result = context_->protect(
+      *active_key_id_,
+      sframe::output_bytes(encrypted_frame.data(), encrypted_frame.size()),
+      sframe::input_bytes(frame.data(), frame.size()),
+      sframe::input_bytes(additional_data.data(), additional_data.size()));
+  if (result.is_err()) {
+    RTCError error = RTCError::InternalError("Sframe encryption failed");
+    if (const char* message = result.error().message()) {
+      error.string_builder() << ": " << message;
+    }
+    return error;
+  }
+  return result.value().size();
+}
+
+size_t SframeEncryptor::GetMaxCiphertextByteSize(size_t frame_size) {
+  return frame_size + sframe::Context::max_overhead;
+}
+
+}  // namespace webrtc
diff --git a/modules/sframe/sframe_encryptor.h b/modules/sframe/sframe_encryptor.h
new file mode 100644
index 0000000..8993d80
--- /dev/null
+++ b/modules/sframe/sframe_encryptor.h
@@ -0,0 +1,70 @@
+/*
+ *  Copyright 2026 The WebRTC project authors. All Rights Reserved.
+ *
+ *  Use of this source code is governed by a BSD-style license
+ *  that can be found in the LICENSE file in the root of the source
+ *  tree. An additional intellectual property rights grant can be found
+ *  in the file PATENTS.  All contributing project authors may
+ *  be found in the AUTHORS file in the root of the source tree.
+ */
+
+#ifndef MODULES_SFRAME_SFRAME_ENCRYPTOR_H_
+#define MODULES_SFRAME_SFRAME_ENCRYPTOR_H_
+
+#include <cstddef>
+#include <cstdint>
+#include <memory>
+#include <optional>
+#include <span>
+
+#include "absl/base/nullability.h"
+#include "api/rtc_error.h"
+#include "api/scoped_refptr.h"
+#include "api/sequence_checker.h"
+#include "api/sframe/sframe_types.h"
+#include "modules/sframe/sframe_media_encryptor_interface.h"
+#include "rtc_base/system/no_unique_address.h"
+#include "rtc_base/thread_annotations.h"
+
+namespace sframe {
+class Context;
+}  // namespace sframe
+
+namespace webrtc {
+
+class SframeEncryptor : public SframeMediaEncryptorInterface {
+ public:
+  // Creates a new SframeEncryptor. This factory method never fails.
+  static absl_nonnull scoped_refptr<SframeEncryptor> Create(
+      SframeMode mode,
+      SframeCipherSuite cipher_suite);
+
+  SframeEncryptor(SframeMode mode, SframeCipherSuite cipher_suite);
+  ~SframeEncryptor() override;
+
+  // SframeEncryptorInterface implementation.
+  RTCError SetEncryptionKey(uint64_t key_id,
+                            std::span<const uint8_t> key_material) override;
+
+  // SframeMediaEncryptorInterface implementation.
+  RTCErrorOr<size_t> Encrypt(std::span<const uint8_t> frame,
+                             std::span<const uint8_t> additional_data,
+                             std::span<uint8_t> encrypted_frame) override;
+
+  size_t GetMaxCiphertextByteSize(size_t frame_size) override;
+
+  SframeMode mode() const override { return mode_; }
+
+ private:
+  // Callers must use this object from a single sequence. Today that sequence
+  // is the media-pipeline (worker) thread reached via the signaling thread.
+  RTC_NO_UNIQUE_ADDRESS SequenceChecker sequence_checker_;
+
+  const SframeMode mode_;
+  std::unique_ptr<sframe::Context> context_ RTC_GUARDED_BY(sequence_checker_);
+  std::optional<uint64_t> active_key_id_ RTC_GUARDED_BY(sequence_checker_);
+};
+
+}  // namespace webrtc
+
+#endif  // MODULES_SFRAME_SFRAME_ENCRYPTOR_H_
diff --git a/modules/sframe/sframe_encryptor_decryptor_unittest.cc b/modules/sframe/sframe_encryptor_decryptor_unittest.cc
new file mode 100644
index 0000000..f0b1d9f
--- /dev/null
+++ b/modules/sframe/sframe_encryptor_decryptor_unittest.cc
@@ -0,0 +1,98 @@
+/*
+ *  Copyright 2026 The WebRTC project authors. All Rights Reserved.
+ *
+ *  Use of this source code is governed by a BSD-style license
+ *  that can be found in the LICENSE file in the root of the source
+ *  tree. An additional intellectual property rights grant can be found
+ *  in the file PATENTS.  All contributing project authors may
+ *  be found in the AUTHORS file in the root of the source tree.
+ */
+
+#include <cstddef>
+#include <cstdint>
+#include <span>
+#include <vector>
+
+#include "api/rtc_error.h"
+#include "api/scoped_refptr.h"
+#include "api/sframe/sframe_types.h"
+#include "modules/sframe/sframe_encryptor.h"
+#include "test/gtest.h"
+
+namespace webrtc {
+namespace {
+
+constexpr uint64_t kKeyId = 7;
+const std::vector<uint8_t> kKeyMaterial = {0x00, 0x01, 0x02, 0x03, 0x04, 0x05,
+                                           0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b,
+                                           0x0c, 0x0d, 0x0e, 0x0f};
+const std::vector<uint8_t> kPlaintext = {0xde, 0xad, 0xbe, 0xef, 0x01,
+                                         0x02, 0x03, 0x04, 0x05, 0x06};
+
+// TODO(webrtc:479862368): Add SframeDecryptor and couple it with the encryptor.
+class SframeEncryptorDecryptorTest : public ::testing::Test {
+ protected:
+  SframeEncryptorDecryptorTest()
+      : encryptor_(
+            SframeEncryptor::Create(SframeMode::kPerFrame,
+                                    SframeCipherSuite::kAes128GcmSha256_128)) {}
+
+  scoped_refptr<SframeEncryptor> encryptor_;
+};
+
+TEST_F(SframeEncryptorDecryptorTest, SetEncryptionKeySucceeds) {
+  EXPECT_TRUE(encryptor_->SetEncryptionKey(kKeyId, kKeyMaterial).ok());
+}
+
+TEST_F(SframeEncryptorDecryptorTest, EncryptProducesCiphertext) {
+  ASSERT_TRUE(encryptor_->SetEncryptionKey(kKeyId, kKeyMaterial).ok());
+
+  size_t max_ct_size = encryptor_->GetMaxCiphertextByteSize(kPlaintext.size());
+  std::vector<uint8_t> ciphertext(max_ct_size);
+
+  auto result = encryptor_->Encrypt(kPlaintext, /*additional_data=*/{},
+                                    std::span<uint8_t>(ciphertext));
+  ASSERT_TRUE(result.ok());
+  EXPECT_GT(result.value(), kPlaintext.size());
+}
+
+TEST_F(SframeEncryptorDecryptorTest, EncryptFailsWithoutKey) {
+  // No key set — encryption should fail with INVALID_STATE.
+  size_t max_ct_size = encryptor_->GetMaxCiphertextByteSize(kPlaintext.size());
+  std::vector<uint8_t> ciphertext(max_ct_size);
+  auto result = encryptor_->Encrypt(kPlaintext, /*additional_data=*/{},
+                                    std::span<uint8_t>(ciphertext));
+  ASSERT_FALSE(result.ok());
+  EXPECT_EQ(result.error().type(), RTCErrorType::INVALID_STATE);
+}
+
+TEST_F(SframeEncryptorDecryptorTest, MultipleKeyRotation) {
+  constexpr uint64_t kKeyId2 = 42;
+  const std::vector<uint8_t> key2 = {0x10, 0x11, 0x12, 0x13, 0x14, 0x15,
+                                     0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
+                                     0x1c, 0x1d, 0x1e, 0x1f};
+
+  ASSERT_TRUE(encryptor_->SetEncryptionKey(kKeyId, kKeyMaterial).ok());
+  size_t max_ct_size = encryptor_->GetMaxCiphertextByteSize(kPlaintext.size());
+  std::vector<uint8_t> ct1(max_ct_size);
+  ASSERT_TRUE(
+      encryptor_
+          ->Encrypt(kPlaintext, /*additional_data=*/{}, std::span<uint8_t>(ct1))
+          .ok());
+
+  // Rotate to second key and encrypt again.
+  ASSERT_TRUE(encryptor_->SetEncryptionKey(kKeyId2, key2).ok());
+  std::vector<uint8_t> ct2(max_ct_size);
+  ASSERT_TRUE(
+      encryptor_
+          ->Encrypt(kPlaintext, /*additional_data=*/{}, std::span<uint8_t>(ct2))
+          .ok());
+  EXPECT_NE(ct1, ct2);
+}
+
+TEST_F(SframeEncryptorDecryptorTest, GetMaxCiphertextByteSizeIsLarger) {
+  EXPECT_GT(encryptor_->GetMaxCiphertextByteSize(100), 100u);
+}
+
+}  // namespace
+}  // namespace webrtc
diff --git a/modules/sframe/sframe_media_encryptor_interface.h b/modules/sframe/sframe_media_encryptor_interface.h
new file mode 100644
index 0000000..ce0f9d6
--- /dev/null
+++ b/modules/sframe/sframe_media_encryptor_interface.h
@@ -0,0 +1,42 @@
+/*
+ *  Copyright 2026 The WebRTC project authors. All Rights Reserved.
+ *
+ *  Use of this source code is governed by a BSD-style license
+ *  that can be found in the LICENSE file in the root of the source
+ *  tree. An additional intellectual property rights grant can be found
+ *  in the file PATENTS.  All contributing project authors may
+ *  be found in the AUTHORS file in the root of the source tree.
+ */
+
+#ifndef MODULES_SFRAME_SFRAME_MEDIA_ENCRYPTOR_INTERFACE_H_
+#define MODULES_SFRAME_SFRAME_MEDIA_ENCRYPTOR_INTERFACE_H_
+
+#include <cstddef>
+#include <cstdint>
+#include <span>
+
+#include "api/rtc_error.h"
+#include "api/sframe/sframe_encryptor_interface.h"
+#include "api/sframe/sframe_types.h"
+
+namespace webrtc {
+
+// Internal media pipeline interface that extends the public key management
+// interface with the actual encrypt operation.
+class SframeMediaEncryptorInterface : public SframeEncryptorInterface {
+ public:
+  virtual SframeMode mode() const = 0;
+
+  virtual RTCErrorOr<size_t> Encrypt(std::span<const uint8_t> frame,
+                                     std::span<const uint8_t> additional_data,
+                                     std::span<uint8_t> encrypted_frame) = 0;
+
+  virtual size_t GetMaxCiphertextByteSize(size_t frame_size) = 0;
+
+ protected:
+  ~SframeMediaEncryptorInterface() override = default;
+};
+
+}  // namespace webrtc
+
+#endif  // MODULES_SFRAME_SFRAME_MEDIA_ENCRYPTOR_INTERFACE_H_