Skip to main content

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 failed_decryption_does_not_consume_the_counter() {
241        // Window poisoning: a frame failing the AEAD check must not burn its counter.
242        let session = test_session(0);
243        let mut enc = session.encryptor(0);
244        let mut dec = session.decryptor();
245
246        let payload = enc.encrypt(b"genuine", b"rtp-header").unwrap();
247        assert!(dec.decrypt(&payload, b"forged-header").is_err());
248
249        assert_eq!(dec.decrypt(&payload, b"rtp-header").unwrap().0, b"genuine");
250    }
251
252    #[test]
253    fn senders_keep_independent_replay_windows() {
254        // With one shared window, leaf 1's first frame would come out "too old".
255        let session = test_session(2);
256        let mut busy = session.encryptor(0);
257        let mut quiet = session.encryptor(1);
258        let mut dec = session.decryptor();
259
260        let quiet_frame = quiet.encrypt(b"quiet", b"").unwrap();
261        for _ in 0..2000 {
262            let payload = busy.encrypt(b"busy", b"").unwrap();
263            dec.decrypt(&payload, b"").unwrap();
264        }
265
266        assert_eq!(dec.decrypt(&quiet_frame, b"").unwrap().1, 1);
267    }
268
269    #[test]
270    fn epoch_mismatch_rejected() {
271        let session_a = test_session(1);
272        let session_b = test_session(2); // different epoch
273
274        let mut enc = session_a.encryptor(0);
275        let mut dec = session_b.decryptor();
276
277        let payload = enc.encrypt(b"stale", b"").unwrap();
278        assert!(dec.decrypt(&payload, b"").is_err());
279    }
280
281    #[test]
282    fn repeated_encryptor_calls_share_one_counter_sequence() {
283        // Regression test for a critical nonce-reuse bug: session.encryptor(leaf) used to
284        // return a fresh MonotonicCounter starting at 0 on every call, so e.g. recreating a
285        // handle per request/thread could encrypt multiple frames under the same
286        // (key, KID, CTR).
287        let session = test_session(0);
288
289        let mut first = session.encryptor(0);
290        let mut second = session.encryptor(0); // must reconnect to the same state, not reset it
291
292        assert_eq!(first.counter(), 0);
293        assert_eq!(second.counter(), 0);
294
295        first.encrypt(b"one", b"").unwrap();
296        assert_eq!(first.counter(), 1);
297        // `second` observes the counter `first` already advanced - proof they share state.
298        assert_eq!(second.counter(), 1);
299
300        second.encrypt(b"two", b"").unwrap();
301        assert_eq!(first.counter(), 2);
302    }
303
304    #[test]
305    fn cloned_encryptor_handles_never_duplicate_a_counter_value() {
306        let session = test_session(0);
307        let enc = session.encryptor(0);
308        let mut dec = session.decryptor();
309
310        // Simulate "one encryptor per thread": clone the handle and encrypt concurrently.
311        let handles: Vec<_> = (0..8)
312            .map(|i| {
313                let mut enc = enc.clone();
314                std::thread::spawn(move || enc.encrypt(format!("frame-{i}").as_bytes(), b""))
315            })
316            .collect();
317
318        let payloads: Vec<Vec<u8>> = handles
319            .into_iter()
320            .map(|h| h.join().unwrap().unwrap())
321            .collect();
322
323        // Every counter value in [0, 8) must have been used exactly once: if two threads had
324        // allocated the same CTR, one of these decrypts would fail (duplicate nonce corrupts
325        // the AEAD tag input) - but more importantly, the exact same (key, KID, CTR) must never
326        // have produced two ciphertexts in the first place.
327        let mut counters: Vec<u64> = payloads
328            .iter()
329            .map(|payload| {
330                let (_, _leaf) = dec.decrypt(payload, b"").unwrap();
331                sframe::frame::EncryptedFrameView::try_new(payload.as_slice())
332                    .unwrap()
333                    .header()
334                    .counter()
335            })
336            .collect();
337        counters.sort_unstable();
338        assert_eq!(counters, (0..8).collect::<Vec<_>>());
339    }
340}