gbp_sframe/lib.rs
1//! **GBP-SFrame** — SFrame ([draft-ietf-sframe-enc]) E2EE for GAP audio
2//! streams in the Group Protocol Stack.
3//!
4//! # Overview
5//!
6//! SFrame sits *inside* SRTP (or any transport-level encryption) and provides
7//! **end-to-end** confidentiality for media payloads: the SFU can forward
8//! packets based on RTP headers without seeing the Opus frame content.
9//!
10//! ```text
11//! ┌──────────────────────────────────────────────────┐
12//! │ Transport encryption │ ← client ↔ SFU
13//! │ ┌────────────────────────────────────────────┐ │
14//! │ │ SFrame │ │ ← E2E client ↔ client
15//! │ │ ┌──────────────────────────────────────┐ │ │
16//! │ │ │ Encoded media (Opus / VP8 / VP9) │ │ │
17//! │ │ └──────────────────────────────────────┘ │ │
18//! │ └────────────────────────────────────────────┘ │
19//! └──────────────────────────────────────────────────┘
20//! ```
21//!
22//! # Key derivation
23//!
24//! After each MLS epoch change:
25//!
26//! 1. **Base key** — `MLS.ExportSecret(label, context=epoch_be8, length=32)`.
27//! 2. **Per-sender key/salt/nonce** — expanded from the base key by the
28//! [`sframe`] crate (RFC 9605 §5.2), keyed by the frame's KID.
29//!
30//! The `label` passed to [`SFrameSession::from_mls`] is application-defined
31//! (e.g. `"gbp/sframe v1"`); this lets different deployments use distinct
32//! key universes without changing any other parameter.
33//!
34//! # Quick start
35//!
36//! ```
37//! use gbp_sframe::{SFrameSession, CipherSuite};
38//!
39//! // Both sides derive a session from the same base key (in production this
40//! // comes from SFrameSession::from_mls).
41//! let session = SFrameSession::new([0x42u8; 32], 1, CipherSuite::Aes128Gcm);
42//!
43//! let mut enc = session.encryptor(0);
44//! let payload = enc.encrypt(b"hello audio", b"")?;
45//!
46//! let mut dec = session.decryptor();
47//! let (plaintext, sender_leaf) = dec.decrypt(&payload, b"")?;
48//! assert_eq!(plaintext, b"hello audio");
49//! assert_eq!(sender_leaf, 0);
50//! # Ok::<(), gbp_sframe::SFrameError>(())
51//! ```
52//!
53//! [draft-ietf-sframe-enc]: https://datatracker.ietf.org/doc/draft-ietf-sframe-enc/
54
55#![deny(missing_docs)]
56
57/// AEAD encrypt/decrypt and the stateful encryptor/decryptor types.
58pub mod cipher;
59/// Error type for SFrame operations.
60pub mod error;
61/// SFrame Key ID scheme.
62pub mod header;
63/// Key derivation from MLS export secret.
64pub mod kdf;
65
66pub use cipher::{SFrameDecryptor, SFrameEncryptor};
67pub use error::SFrameError;
68pub use header::SFrameHeader;
69pub use kdf::{CipherSuite, derive_base_key};
70
71use std::collections::HashMap;
72use std::sync::Mutex;
73
74use gbp_mls::MlsContext;
75
76/// An SFrame session bound to one MLS epoch.
77///
78/// A new session must be created whenever the MLS group commits (epoch
79/// changes) — the old base key becomes unreachable and all per-sender keys
80/// are rotated automatically.
81///
82/// Owns the per-leaf encryptor state for this epoch: repeated calls to
83/// [`encryptor`](Self::encryptor) for the same `leaf_index` return a handle
84/// to the *same* counter, never a fresh one, so callers cannot accidentally
85/// reuse a `(key, KID, CTR)` nonce by recreating a handle (e.g. one per
86/// thread, or on every request).
87pub struct SFrameSession {
88 base_key: [u8; 32],
89 epoch: u64,
90 suite: CipherSuite,
91 encryptors: Mutex<HashMap<u32, SFrameEncryptor>>,
92}
93
94impl SFrameSession {
95 /// Creates a session from a raw 32-byte base key.
96 ///
97 /// Prefer [`from_mls`](Self::from_mls) when an [`MlsContext`] is
98 /// available; this constructor is mainly for testing.
99 pub fn new(base_key: [u8; 32], epoch: u64, suite: CipherSuite) -> Self {
100 Self {
101 base_key,
102 epoch,
103 suite,
104 encryptors: Mutex::new(HashMap::new()),
105 }
106 }
107
108 /// Derives a session from the current MLS group state.
109 ///
110 /// Calls `MLS.ExportSecret(label, context=epoch_be8, length=32)` to
111 /// obtain the base key, then stores it alongside the current epoch and
112 /// ciphersuite.
113 ///
114 /// `label` is application-defined (e.g. `"gbp/sframe v1"`).
115 pub fn from_mls(
116 mls: &MlsContext,
117 label: &str,
118 suite: CipherSuite,
119 ) -> Result<Self, SFrameError> {
120 let epoch = mls.epoch();
121 let base_key = derive_base_key(mls, label, epoch)?;
122 Ok(Self::new(base_key, epoch, suite))
123 }
124
125 /// Returns the MLS epoch this session was created for.
126 pub fn epoch(&self) -> u64 {
127 self.epoch
128 }
129
130 /// Returns the active ciphersuite.
131 pub fn suite(&self) -> CipherSuite {
132 self.suite
133 }
134
135 /// Returns a sender-side encryptor handle for `leaf_index`.
136 ///
137 /// The returned [`SFrameEncryptor`] holds the derived key+salt for this
138 /// sender and shares its counter with every other handle returned for
139 /// the same `leaf_index` on this session - calling this repeatedly (one
140 /// per thread, one per request, ...) is safe and will never produce two
141 /// independent counters for the same KID.
142 pub fn encryptor(&self, leaf_index: u32) -> SFrameEncryptor {
143 let mut encryptors = self
144 .encryptors
145 .lock()
146 .expect("sframe session encryptor map mutex poisoned");
147 encryptors
148 .entry(leaf_index)
149 .or_insert_with(|| {
150 let kid = SFrameHeader::kid_from(self.epoch, leaf_index);
151 SFrameEncryptor::new(&self.base_key, kid, self.suite)
152 })
153 .clone()
154 }
155
156 /// Creates a receiver-side decryptor for this epoch.
157 ///
158 /// The [`SFrameDecryptor`] lazily derives per-sender keys as new KIDs
159 /// arrive, and maintains an independent 1024-entry replay window per sender.
160 pub fn decryptor(&self) -> SFrameDecryptor {
161 SFrameDecryptor::new(self.base_key, self.epoch, self.suite)
162 }
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168
169 fn test_session(epoch: u64) -> SFrameSession {
170 SFrameSession::new([0x42u8; 32], epoch, CipherSuite::Aes128Gcm)
171 }
172
173 #[test]
174 fn encrypt_decrypt_roundtrip_128() {
175 let session = test_session(1);
176 let mut enc = session.encryptor(0);
177 let mut dec = session.decryptor();
178
179 let frame = b"hello sframe";
180 let payload = enc.encrypt(frame, b"").unwrap();
181 let (plain, leaf) = dec.decrypt(&payload, b"").unwrap();
182 assert_eq!(plain, frame);
183 assert_eq!(leaf, 0);
184 }
185
186 #[test]
187 fn encrypt_decrypt_roundtrip_256() {
188 let session = SFrameSession::new([0x11u8; 32], 5, CipherSuite::Aes256Gcm);
189 let mut enc = session.encryptor(3);
190 let mut dec = session.decryptor();
191
192 let frame = b"audio payload aes256";
193 let payload = enc.encrypt(frame, b"rtp-header").unwrap();
194 let (plain, leaf) = dec.decrypt(&payload, b"rtp-header").unwrap();
195 assert_eq!(plain, frame);
196 assert_eq!(leaf, 3);
197 }
198
199 #[test]
200 fn wrong_aad_fails_decryption() {
201 let session = test_session(0);
202 let mut enc = session.encryptor(0);
203 let mut dec = session.decryptor();
204
205 let payload = enc.encrypt(b"data", b"correct-aad").unwrap();
206 assert!(dec.decrypt(&payload, b"wrong-aad").is_err());
207 }
208
209 #[test]
210 fn replay_rejected() {
211 let session = test_session(0);
212 let mut enc = session.encryptor(1);
213 let mut dec = session.decryptor();
214
215 let payload = enc.encrypt(b"frame", b"").unwrap();
216 dec.decrypt(&payload, b"").unwrap();
217 assert!(dec.decrypt(&payload, b"").is_err());
218 }
219
220 #[test]
221 fn multi_sender() {
222 let session = test_session(2);
223 let mut enc0 = session.encryptor(0);
224 let mut enc1 = session.encryptor(1);
225 let mut dec = session.decryptor();
226
227 let p0 = enc0.encrypt(b"from-0", b"").unwrap();
228 let p1 = enc1.encrypt(b"from-1", b"").unwrap();
229
230 let (msg0, leaf0) = dec.decrypt(&p0, b"").unwrap();
231 let (msg1, leaf1) = dec.decrypt(&p1, b"").unwrap();
232
233 assert_eq!(msg0, b"from-0");
234 assert_eq!(leaf0, 0);
235 assert_eq!(msg1, b"from-1");
236 assert_eq!(leaf1, 1);
237 }
238
239 #[test]
240 fn epoch_mismatch_rejected() {
241 let session_a = test_session(1);
242 let session_b = test_session(2); // different epoch
243
244 let mut enc = session_a.encryptor(0);
245 let mut dec = session_b.decryptor();
246
247 let payload = enc.encrypt(b"stale", b"").unwrap();
248 assert!(dec.decrypt(&payload, b"").is_err());
249 }
250
251 #[test]
252 fn repeated_encryptor_calls_share_one_counter_sequence() {
253 // Regression test for a critical nonce-reuse bug: session.encryptor(leaf) used to
254 // return a fresh MonotonicCounter starting at 0 on every call, so e.g. recreating a
255 // handle per request/thread could encrypt multiple frames under the same
256 // (key, KID, CTR).
257 let session = test_session(0);
258
259 let mut first = session.encryptor(0);
260 let mut second = session.encryptor(0); // must reconnect to the same state, not reset it
261
262 assert_eq!(first.counter(), 0);
263 assert_eq!(second.counter(), 0);
264
265 first.encrypt(b"one", b"").unwrap();
266 assert_eq!(first.counter(), 1);
267 // `second` observes the counter `first` already advanced - proof they share state.
268 assert_eq!(second.counter(), 1);
269
270 second.encrypt(b"two", b"").unwrap();
271 assert_eq!(first.counter(), 2);
272 }
273
274 #[test]
275 fn cloned_encryptor_handles_never_duplicate_a_counter_value() {
276 let session = test_session(0);
277 let enc = session.encryptor(0);
278 let mut dec = session.decryptor();
279
280 // Simulate "one encryptor per thread": clone the handle and encrypt concurrently.
281 let handles: Vec<_> = (0..8)
282 .map(|i| {
283 let mut enc = enc.clone();
284 std::thread::spawn(move || enc.encrypt(format!("frame-{i}").as_bytes(), b""))
285 })
286 .collect();
287
288 let payloads: Vec<Vec<u8>> = handles
289 .into_iter()
290 .map(|h| h.join().unwrap().unwrap())
291 .collect();
292
293 // Every counter value in [0, 8) must have been used exactly once: if two threads had
294 // allocated the same CTR, one of these decrypts would fail (duplicate nonce corrupts
295 // the AEAD tag input) - but more importantly, the exact same (key, KID, CTR) must never
296 // have produced two ciphertexts in the first place.
297 let mut counters: Vec<u64> = payloads
298 .iter()
299 .map(|payload| {
300 let (_, _leaf) = dec.decrypt(payload, b"").unwrap();
301 sframe::frame::EncryptedFrameView::try_new(payload.as_slice())
302 .unwrap()
303 .header()
304 .counter()
305 })
306 .collect();
307 counters.sort_unstable();
308 assert_eq!(counters, (0..8).collect::<Vec<_>>());
309 }
310}