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 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}