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 gbp_mls::MlsContext;
72
73/// An SFrame session bound to one MLS epoch.
74///
75/// A new session must be created whenever the MLS group commits (epoch
76/// changes) — the old base key becomes unreachable and all per-sender keys
77/// are rotated automatically.
78pub struct SFrameSession {
79    base_key: [u8; 32],
80    epoch: u64,
81    suite: CipherSuite,
82}
83
84impl SFrameSession {
85    /// Creates a session from a raw 32-byte base key.
86    ///
87    /// Prefer [`from_mls`](Self::from_mls) when an [`MlsContext`] is
88    /// available; this constructor is mainly for testing.
89    pub fn new(base_key: [u8; 32], epoch: u64, suite: CipherSuite) -> Self {
90        Self {
91            base_key,
92            epoch,
93            suite,
94        }
95    }
96
97    /// Derives a session from the current MLS group state.
98    ///
99    /// Calls `MLS.ExportSecret(label, context=epoch_be8, length=32)` to
100    /// obtain the base key, then stores it alongside the current epoch and
101    /// ciphersuite.
102    ///
103    /// `label` is application-defined (e.g. `"gbp/sframe v1"`).
104    pub fn from_mls(
105        mls: &MlsContext,
106        label: &str,
107        suite: CipherSuite,
108    ) -> Result<Self, SFrameError> {
109        let epoch = mls.epoch();
110        let base_key = derive_base_key(mls, label, epoch)?;
111        Ok(Self::new(base_key, epoch, suite))
112    }
113
114    /// Returns the MLS epoch this session was created for.
115    pub fn epoch(&self) -> u64 {
116        self.epoch
117    }
118
119    /// Returns the active ciphersuite.
120    pub fn suite(&self) -> CipherSuite {
121        self.suite
122    }
123
124    /// Creates a sender-side encryptor for `leaf_index`.
125    ///
126    /// The returned [`SFrameEncryptor`] owns the derived key+salt for this
127    /// sender and maintains an internal counter.  Create one per sender; do
128    /// **not** share an encryptor across multiple goroutines/threads.
129    pub fn encryptor(&self, leaf_index: u32) -> SFrameEncryptor {
130        let kid = SFrameHeader::kid_from(self.epoch, leaf_index);
131        SFrameEncryptor::new(&self.base_key, kid, self.suite)
132    }
133
134    /// Creates a receiver-side decryptor for this epoch.
135    ///
136    /// The [`SFrameDecryptor`] lazily derives per-sender keys as new KIDs
137    /// arrive, and maintains an independent 1024-entry replay window per sender.
138    pub fn decryptor(&self) -> SFrameDecryptor {
139        SFrameDecryptor::new(self.base_key, self.epoch, self.suite)
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    fn test_session(epoch: u64) -> SFrameSession {
148        SFrameSession::new([0x42u8; 32], epoch, CipherSuite::Aes128Gcm)
149    }
150
151    #[test]
152    fn encrypt_decrypt_roundtrip_128() {
153        let session = test_session(1);
154        let mut enc = session.encryptor(0);
155        let mut dec = session.decryptor();
156
157        let frame = b"hello sframe";
158        let payload = enc.encrypt(frame, b"").unwrap();
159        let (plain, leaf) = dec.decrypt(&payload, b"").unwrap();
160        assert_eq!(plain, frame);
161        assert_eq!(leaf, 0);
162    }
163
164    #[test]
165    fn encrypt_decrypt_roundtrip_256() {
166        let session = SFrameSession::new([0x11u8; 32], 5, CipherSuite::Aes256Gcm);
167        let mut enc = session.encryptor(3);
168        let mut dec = session.decryptor();
169
170        let frame = b"audio payload aes256";
171        let payload = enc.encrypt(frame, b"rtp-header").unwrap();
172        let (plain, leaf) = dec.decrypt(&payload, b"rtp-header").unwrap();
173        assert_eq!(plain, frame);
174        assert_eq!(leaf, 3);
175    }
176
177    #[test]
178    fn wrong_aad_fails_decryption() {
179        let session = test_session(0);
180        let mut enc = session.encryptor(0);
181        let mut dec = session.decryptor();
182
183        let payload = enc.encrypt(b"data", b"correct-aad").unwrap();
184        assert!(dec.decrypt(&payload, b"wrong-aad").is_err());
185    }
186
187    #[test]
188    fn replay_rejected() {
189        let session = test_session(0);
190        let mut enc = session.encryptor(1);
191        let mut dec = session.decryptor();
192
193        let payload = enc.encrypt(b"frame", b"").unwrap();
194        dec.decrypt(&payload, b"").unwrap();
195        assert!(dec.decrypt(&payload, b"").is_err());
196    }
197
198    #[test]
199    fn multi_sender() {
200        let session = test_session(2);
201        let mut enc0 = session.encryptor(0);
202        let mut enc1 = session.encryptor(1);
203        let mut dec = session.decryptor();
204
205        let p0 = enc0.encrypt(b"from-0", b"").unwrap();
206        let p1 = enc1.encrypt(b"from-1", b"").unwrap();
207
208        let (msg0, leaf0) = dec.decrypt(&p0, b"").unwrap();
209        let (msg1, leaf1) = dec.decrypt(&p1, b"").unwrap();
210
211        assert_eq!(msg0, b"from-0");
212        assert_eq!(leaf0, 0);
213        assert_eq!(msg1, b"from-1");
214        assert_eq!(leaf1, 1);
215    }
216
217    #[test]
218    fn epoch_mismatch_rejected() {
219        let session_a = test_session(1);
220        let session_b = test_session(2); // different epoch
221
222        let mut enc = session_a.encryptor(0);
223        let mut dec = session_b.decryptor();
224
225        let payload = enc.encrypt(b"stale", b"").unwrap();
226        assert!(dec.decrypt(&payload, b"").is_err());
227    }
228}