Skip to main content

gbp_sframe/
cipher.rs

1use std::collections::HashMap;
2use std::sync::{Arc, Mutex};
3
4use sframe::frame::{
5    EncryptedFrameView, FrameValidation, MediaFrameView, MonotonicCounter,
6    ReplayAttackProtectionStore,
7};
8use sframe::header::KeyId;
9use sframe::key::{DecryptionKey, EncryptionKey};
10
11use crate::error::SFrameError;
12use crate::header::SFrameHeader;
13use crate::kdf::CipherSuite;
14
15/// Replay-window tolerance in frames (gbp's historical fixed 1024-frame window).
16const REPLAY_WINDOW: u64 = 1024;
17
18// ─── SFrameEncryptor ─────────────────────────────────────────────────────────
19
20/// The key + counter for one `(epoch, leaf_index)` KID.
21///
22/// Shared behind an [`Arc<Mutex<_>>`] by every [`SFrameEncryptor`] handle for
23/// that KID, so cloning a handle - or obtaining a new one from
24/// [`crate::SFrameSession::encryptor`] - can never produce a second,
25/// independent counter that would reuse a `(key, KID, CTR)` nonce.
26struct EncryptorState {
27    key: EncryptionKey,
28    counter: MonotonicCounter,
29}
30
31/// Stateful per-sender SFrame encryptor handle.
32///
33/// Cloning a handle, or requesting another one for the same `(epoch,
34/// leaf_index)` via [`crate::SFrameSession::encryptor`], shares the same
35/// underlying counter - it does **not** create an independent one. This
36/// makes it safe to hold multiple handles (e.g. one per thread) for the same
37/// sender without risking AEAD nonce reuse.
38#[derive(Clone)]
39pub struct SFrameEncryptor {
40    state: Arc<Mutex<EncryptorState>>,
41    kid: KeyId,
42}
43
44impl SFrameEncryptor {
45    pub(crate) fn new(base_key: &[u8; 32], kid: u64, suite: CipherSuite) -> Self {
46        let key = EncryptionKey::derive_from(suite.to_sframe(), kid, base_key)
47            .expect("key derivation from a 32-byte base key never fails");
48        Self {
49            state: Arc::new(Mutex::new(EncryptorState {
50                key,
51                counter: MonotonicCounter::default(),
52            })),
53            kid,
54        }
55    }
56
57    /// Encrypts `plaintext` and returns the complete SFrame payload:
58    /// `header ‖ ciphertext ‖ GCM-tag`.
59    ///
60    /// `extra_aad` is bound into the AEAD tag (e.g. an RTP header) but is **not**
61    /// carried in the returned payload; the receiver supplies the same slice to
62    /// [`SFrameDecryptor::decrypt`].
63    ///
64    /// Safe to call concurrently from multiple handles sharing this sender's
65    /// state: the counter is allocated under a lock, so concurrent calls
66    /// never allocate the same value twice.
67    pub fn encrypt(&mut self, plaintext: &[u8], extra_aad: &[u8]) -> Result<Vec<u8>, SFrameError> {
68        let mut state = self
69            .state
70            .lock()
71            .expect("sframe encryptor state mutex poisoned");
72        // next() panics once exhausted, which would poison this mutex.
73        if state.counter.is_exhausted() {
74            return Err(SFrameError::CounterExhausted(self.kid));
75        }
76        let frame = MediaFrameView::with_meta_data(&mut state.counter, plaintext, extra_aad);
77        let encrypted = frame
78            .encrypt(&state.key)
79            .map_err(|_| SFrameError::Encrypt)?;
80
81        // sframe serialises `meta_data ‖ header ‖ ciphertext`; strip the
82        // metadata prefix so `extra_aad` stays off the wire.
83        Ok(encrypted.as_ref()[extra_aad.len()..].to_vec())
84    }
85
86    /// Starts the counter at `start`, to reach exhaustion without 2^64 frames.
87    #[cfg(test)]
88    fn with_counter_at(base_key: &[u8; 32], kid: u64, suite: CipherSuite, start: u64) -> Self {
89        let encryptor = Self::new(base_key, kid, suite);
90        encryptor.state.lock().unwrap().counter =
91            MonotonicCounter::with_start_value(start, u64::MAX);
92        encryptor
93    }
94
95    /// Current counter value (number of frames encrypted so far).
96    pub fn counter(&self) -> u64 {
97        self.state
98            .lock()
99            .expect("sframe encryptor state mutex poisoned")
100            .counter
101            .current()
102    }
103
104    /// KID this encryptor was created for.
105    pub fn kid(&self) -> u64 {
106        self.kid
107    }
108}
109
110// ─── SFrameDecryptor ─────────────────────────────────────────────────────────
111
112/// Multi-sender SFrame decryptor for one epoch.
113///
114/// Lazily derives per-sender key material and replay state from the epoch's
115/// base key as new `KID`s are encountered.
116///
117/// Obtain via [`crate::SFrameSession::decryptor`].
118pub struct SFrameDecryptor {
119    base_key: [u8; 32],
120    epoch: u64,
121    suite: CipherSuite,
122    /// Per-sender keys, keyed by KID.
123    keys: HashMap<KeyId, DecryptionKey>,
124    /// Replay windows, one per KID (sframe's own per-key-id store).
125    replay: ReplayAttackProtectionStore,
126}
127
128impl SFrameDecryptor {
129    pub(crate) fn new(base_key: [u8; 32], epoch: u64, suite: CipherSuite) -> Self {
130        Self {
131            base_key,
132            epoch,
133            suite,
134            keys: HashMap::new(),
135            replay: ReplayAttackProtectionStore::with_tolerance(REPLAY_WINDOW),
136        }
137    }
138
139    /// Decrypts an SFrame `payload` and returns `(plaintext, sender_leaf)`.
140    ///
141    /// `extra_aad` must be the same slice passed on the encrypting side.
142    pub fn decrypt(
143        &mut self,
144        payload: &[u8],
145        extra_aad: &[u8],
146    ) -> Result<(Vec<u8>, u32), SFrameError> {
147        let view = EncryptedFrameView::try_with_meta_data(&payload, &extra_aad)
148            .map_err(|e| SFrameError::Header(e.to_string()))?;
149
150        let kid = view.header().key_id();
151        let ctr = view.header().counter();
152
153        if SFrameHeader::epoch_from_kid(kid) != SFrameHeader::epoch_lsb(self.epoch) {
154            return Err(SFrameError::UnknownKid(kid));
155        }
156        let leaf = SFrameHeader::leaf_from_kid(kid);
157
158        // Screen without recording: the header is not authenticated yet.
159        self.replay
160            .inspect(view.header())
161            .map_err(|_| SFrameError::Replay { kid, ctr })?;
162
163        let suite = self.suite;
164        let base_key = self.base_key;
165        let key = self.keys.entry(kid).or_insert_with(|| {
166            DecryptionKey::derive_from(suite.to_sframe(), kid, base_key)
167                .expect("key derivation from a 32-byte base key never fails")
168        });
169
170        let media = view.decrypt(key).map_err(|_| SFrameError::Decrypt)?;
171
172        // Authenticated - record the counter.
173        self.replay
174            .validate(view.header())
175            .map_err(|_| SFrameError::Replay { kid, ctr })?;
176
177        Ok((media.payload().to_vec(), leaf))
178    }
179
180    /// Drops all per-sender key + replay state (call on epoch change).
181    pub fn reset(&mut self) {
182        self.keys.clear();
183        self.replay = ReplayAttackProtectionStore::with_tolerance(REPLAY_WINDOW);
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    const KID: u64 = 0xabc;
192
193    #[test]
194    fn last_counter_value_still_encrypts() {
195        let mut enc =
196            SFrameEncryptor::with_counter_at(&[7u8; 32], KID, CipherSuite::Aes128Gcm, u64::MAX);
197
198        assert!(enc.encrypt(b"last frame", b"").is_ok());
199    }
200
201    #[test]
202    fn exhausted_counter_errors_instead_of_panicking() {
203        // A panic here would poison the state mutex shared by every handle.
204        let mut enc =
205            SFrameEncryptor::with_counter_at(&[7u8; 32], KID, CipherSuite::Aes128Gcm, u64::MAX);
206        enc.encrypt(b"last frame", b"").unwrap();
207
208        assert!(matches!(
209            enc.encrypt(b"one too many", b""),
210            Err(SFrameError::CounterExhausted(KID))
211        ));
212
213        // Still usable, not poisoned.
214        assert!(enc.encrypt(b"and another", b"").is_err());
215    }
216}