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** — `HKDF-Expand(base_key, "gbp sframe key " ‖ leaf_be4, L)`.
28//! 3. **Per-sender salt** — `HKDF-Expand(base_key, "gbp sframe salt " ‖ leaf_be4, 12)`.
29//! 4. **Frame nonce** — `salt XOR (CTR_LE64 ‖ 0x00_00_00_00)`.
30//!
31//! The `label` passed to [`SFrameSession::from_mls`] is application-defined
32//! (e.g. `"gbp/sframe v1"`); this lets different deployments use distinct
33//! key universes without changing any other parameter.
34//!
35//! # Quick start
36//!
37//! ```
38//! use gbp_sframe::{SFrameSession, CipherSuite};
39//!
40//! // Both sides derive a session from the same base key (in production this
41//! // comes from SFrameSession::from_mls).
42//! let session = SFrameSession::new([0x42u8; 32], 1, CipherSuite::Aes128Gcm);
43//!
44//! let mut enc = session.encryptor(0);
45//! let payload = enc.encrypt(b"hello audio", b"")?;
46//!
47//! let mut dec = session.decryptor();
48//! let (plaintext, sender_leaf) = dec.decrypt(&payload, b"")?;
49//! assert_eq!(plaintext, b"hello audio");
50//! assert_eq!(sender_leaf, 0);
51//! # Ok::<(), gbp_sframe::SFrameError>(())
52//! ```
53//!
54//! [draft-ietf-sframe-enc]: https://datatracker.ietf.org/doc/draft-ietf-sframe-enc/
55
56#![deny(missing_docs)]
57
58/// AEAD encrypt/decrypt and the stateful encryptor/decryptor types.
59pub mod cipher;
60/// Error type for SFrame operations.
61pub mod error;
62/// SFrame header wire format.
63pub mod header;
64/// Key derivation from MLS export secret.
65pub mod kdf;
66/// Sliding-window replay protection.
67pub mod replay;
68
69pub use cipher::{SFrameDecryptor, SFrameEncryptor};
70pub use error::SFrameError;
71pub use header::SFrameHeader;
72pub use kdf::{CipherSuite, derive_base_key};
73
74use gbp_mls::MlsContext;
75use kdf::derive_participant;
76
77/// An SFrame session bound to one MLS epoch.
78///
79/// A new session must be created whenever the MLS group commits (epoch
80/// changes) — the old base key becomes unreachable and all per-sender keys
81/// are rotated automatically.
82pub struct SFrameSession {
83 base_key: [u8; 32],
84 epoch: u64,
85 suite: CipherSuite,
86}
87
88impl SFrameSession {
89 /// Creates a session from a raw 32-byte base key.
90 ///
91 /// Prefer [`from_mls`](Self::from_mls) when an [`MlsContext`] is
92 /// available; this constructor is mainly for testing.
93 pub fn new(base_key: [u8; 32], epoch: u64, suite: CipherSuite) -> Self {
94 Self {
95 base_key,
96 epoch,
97 suite,
98 }
99 }
100
101 /// Derives a session from the current MLS group state.
102 ///
103 /// Calls `MLS.ExportSecret(label, context=epoch_be8, length=32)` to
104 /// obtain the base key, then stores it alongside the current epoch and
105 /// ciphersuite.
106 ///
107 /// `label` is application-defined (e.g. `"gbp/sframe v1"`).
108 pub fn from_mls(
109 mls: &MlsContext,
110 label: &str,
111 suite: CipherSuite,
112 ) -> Result<Self, SFrameError> {
113 let epoch = mls.epoch();
114 let base_key = derive_base_key(mls, label, epoch)?;
115 Ok(Self::new(base_key, epoch, suite))
116 }
117
118 /// Returns the MLS epoch this session was created for.
119 pub fn epoch(&self) -> u64 {
120 self.epoch
121 }
122
123 /// Returns the active ciphersuite.
124 pub fn suite(&self) -> CipherSuite {
125 self.suite
126 }
127
128 /// Creates a sender-side encryptor for `leaf_index`.
129 ///
130 /// The returned [`SFrameEncryptor`] owns the derived key+salt for this
131 /// sender and maintains an internal counter. Create one per sender; do
132 /// **not** share an encryptor across multiple goroutines/threads.
133 pub fn encryptor(&self, leaf_index: u32) -> SFrameEncryptor {
134 let kid = SFrameHeader::kid_from(self.epoch, leaf_index);
135 let keys = derive_participant(&self.base_key, leaf_index, self.suite);
136 SFrameEncryptor::new(keys, kid, self.suite)
137 }
138
139 /// Creates a receiver-side decryptor for this epoch.
140 ///
141 /// The [`SFrameDecryptor`] lazily derives per-sender keys as new KIDs
142 /// arrive, and maintains an independent 1024-entry replay window per sender.
143 pub fn decryptor(&self) -> SFrameDecryptor {
144 SFrameDecryptor::new(self.base_key, self.epoch, self.suite)
145 }
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151
152 fn test_session(epoch: u64) -> SFrameSession {
153 SFrameSession::new([0x42u8; 32], epoch, CipherSuite::Aes128Gcm)
154 }
155
156 #[test]
157 fn encrypt_decrypt_roundtrip_128() {
158 let session = test_session(1);
159 let mut enc = session.encryptor(0);
160 let mut dec = session.decryptor();
161
162 let frame = b"hello sframe";
163 let payload = enc.encrypt(frame, b"").unwrap();
164 let (plain, leaf) = dec.decrypt(&payload, b"").unwrap();
165 assert_eq!(plain, frame);
166 assert_eq!(leaf, 0);
167 }
168
169 #[test]
170 fn encrypt_decrypt_roundtrip_256() {
171 let session = SFrameSession::new([0x11u8; 32], 5, CipherSuite::Aes256Gcm);
172 let mut enc = session.encryptor(3);
173 let mut dec = session.decryptor();
174
175 let frame = b"audio payload aes256";
176 let payload = enc.encrypt(frame, b"rtp-header").unwrap();
177 let (plain, leaf) = dec.decrypt(&payload, b"rtp-header").unwrap();
178 assert_eq!(plain, frame);
179 assert_eq!(leaf, 3);
180 }
181
182 #[test]
183 fn wrong_aad_fails_decryption() {
184 let session = test_session(0);
185 let mut enc = session.encryptor(0);
186 let mut dec = session.decryptor();
187
188 let payload = enc.encrypt(b"data", b"correct-aad").unwrap();
189 assert!(dec.decrypt(&payload, b"wrong-aad").is_err());
190 }
191
192 #[test]
193 fn replay_rejected() {
194 let session = test_session(0);
195 let mut enc = session.encryptor(1);
196 let mut dec = session.decryptor();
197
198 let payload = enc.encrypt(b"frame", b"").unwrap();
199 dec.decrypt(&payload, b"").unwrap();
200 assert!(dec.decrypt(&payload, b"").is_err());
201 }
202
203 #[test]
204 fn multi_sender() {
205 let session = test_session(2);
206 let mut enc0 = session.encryptor(0);
207 let mut enc1 = session.encryptor(1);
208 let mut dec = session.decryptor();
209
210 let p0 = enc0.encrypt(b"from-0", b"").unwrap();
211 let p1 = enc1.encrypt(b"from-1", b"").unwrap();
212
213 let (msg0, leaf0) = dec.decrypt(&p0, b"").unwrap();
214 let (msg1, leaf1) = dec.decrypt(&p1, b"").unwrap();
215
216 assert_eq!(msg0, b"from-0");
217 assert_eq!(leaf0, 0);
218 assert_eq!(msg1, b"from-1");
219 assert_eq!(leaf1, 1);
220 }
221
222 #[test]
223 fn epoch_mismatch_rejected() {
224 let session_a = test_session(1);
225 let session_b = test_session(2); // different epoch
226
227 let mut enc = session_a.encryptor(0);
228 let mut dec = session_b.decryptor();
229
230 let payload = enc.encrypt(b"stale", b"").unwrap();
231 assert!(dec.decrypt(&payload, b"").is_err());
232 }
233}