1use crate::GapPayload;
4use gbp::CodecError;
5use gbp_core::{GbpFlags, MemberId, PayloadCodec, StreamType, timeouts};
6use gbp_node::{GroupNode, NodeError, OutboundFrame, Sealer};
7use std::collections::HashMap;
8use std::time::Duration;
9#[cfg(not(target_arch = "wasm32"))]
10use std::time::Instant;
11#[cfg(target_arch = "wasm32")]
12use web_time::Instant;
13
14#[derive(Debug, thiserror::Error)]
16pub enum GapError {
17 #[error("decode: {0}")]
19 Decode(#[from] CodecError),
20 #[error("epoch stale: kp={kp}, expected={expected}")]
22 EpochStale {
23 kp: u32,
25 expected: u32,
27 },
28 #[error("rtp replay: src={src}, seq={seq}, hw={hw}")]
30 RtpReplay {
31 src: u32,
33 seq: u32,
35 hw: u32,
37 },
38 #[error("node: {0}")]
40 Node(#[from] NodeError),
41}
42
43#[derive(Debug)]
45pub enum GapAccept {
46 New(GapPayload),
48 Late(GapPayload),
51}
52
53struct OldEpochWindow {
56 epoch: u64,
57 in_hw: HashMap<u32, u32>,
58 expires: Instant,
59}
60
61#[derive(Default)]
75pub struct GapClient {
76 out_rtp_seq: HashMap<u32, u32>,
77 in_hw: HashMap<u32, u32>,
78 current_epoch: Option<u64>,
79 old_windows: Vec<OldEpochWindow>,
81}
82
83impl GapClient {
84 pub fn new() -> Self {
86 Self::default()
87 }
88
89 #[allow(clippy::too_many_arguments)]
100 pub fn send<S: Sealer>(
101 &mut self,
102 node: &mut GroupNode,
103 seal: &mut S,
104 target: MemberId,
105 media_source_id: u32,
106 rtp_timestamp: u64,
107 opus: Vec<u8>,
108 codec: PayloadCodec,
109 ) -> Result<OutboundFrame, GapError> {
110 self.sync_epoch(node.current_epoch);
111 let seq = self.out_rtp_seq.entry(media_source_id).or_insert(0);
112 *seq = seq.wrapping_add(1) & 0xFFFF;
114 let payload = GapPayload {
115 media_source_id,
116 rtp_sequence: *seq,
117 rtp_timestamp,
118 key_phase: node.current_epoch as u32,
119 opus_frame: serde_bytes::ByteBuf::from(opus),
120 };
121 let stream_id = node.member_stream_id(2);
122 Ok(node.send_payload(
123 seal,
124 target,
125 StreamType::Audio,
126 stream_id,
127 GbpFlags::ordered_only(),
128 &payload.to_bytes(codec),
129 codec,
130 )?)
131 }
132
133 pub fn accept(
142 &mut self,
143 plaintext: &[u8],
144 current_epoch: u64,
145 codec: PayloadCodec,
146 ) -> Result<GapAccept, GapError> {
147 self.sync_epoch(current_epoch);
148 let p = GapPayload::from_bytes(plaintext, codec)?;
149 if p.key_phase == current_epoch as u32 {
150 let hw = self.in_hw.get(&p.media_source_id).copied().unwrap_or(0);
152 if p.rtp_sequence <= hw && hw.wrapping_sub(p.rtp_sequence) <= 0x7FFF {
153 return Ok(GapAccept::Late(p));
154 }
155 self.in_hw.insert(p.media_source_id, p.rtp_sequence);
156 return Ok(GapAccept::New(p));
157 }
158 let now = Instant::now();
160 if let Some(old) = self
161 .old_windows
162 .iter_mut()
163 .find(|w| w.epoch == p.key_phase as u64 && w.expires > now)
164 {
165 let hw = old.in_hw.get(&p.media_source_id).copied().unwrap_or(0);
166 if p.rtp_sequence <= hw && hw.wrapping_sub(p.rtp_sequence) <= 0x7FFF {
167 return Ok(GapAccept::Late(p));
168 }
169 old.in_hw.insert(p.media_source_id, p.rtp_sequence);
170 return Ok(GapAccept::New(p));
171 }
172 Err(GapError::EpochStale {
173 kp: p.key_phase,
174 expected: current_epoch as u32,
175 })
176 }
177
178 pub fn sync_epoch(&mut self, epoch: u64) {
186 let now = Instant::now();
188 self.old_windows.retain(|w| w.expires > now);
189
190 if Some(epoch) != self.current_epoch {
191 if let Some(old_epoch) = self.current_epoch
193 && !self.in_hw.is_empty()
194 {
195 self.old_windows.push(OldEpochWindow {
196 epoch: old_epoch,
197 in_hw: std::mem::take(&mut self.in_hw),
198 expires: now + Duration::from_millis(timeouts::T_GAP_KEY_OVERLAP_MS),
199 });
200 }
201 self.out_rtp_seq.clear();
202 self.in_hw.clear();
203 self.current_epoch = Some(epoch);
204 }
205 }
206
207 pub fn reset(&mut self) {
210 self.out_rtp_seq.clear();
211 self.in_hw.clear();
212 self.old_windows.clear();
213 self.current_epoch = None;
214 }
215}
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220
221 fn make_payload(seq: u32, key_phase: u32) -> Vec<u8> {
222 crate::GapPayload {
223 media_source_id: 1,
224 rtp_sequence: seq,
225 rtp_timestamp: 960,
226 key_phase,
227 opus_frame: serde_bytes::ByteBuf::from(b"opus-data".to_vec()),
228 }
229 .to_bytes(PayloadCodec::Cbor)
230 }
231
232 #[test]
233 fn wraparound_after_ffff_is_accepted() {
234 let mut client = GapClient::new();
235 let _ = client
237 .accept(&make_payload(0xFFFE, 1), 1, PayloadCodec::Cbor)
238 .unwrap();
239 let _ = client
240 .accept(&make_payload(0xFFFF, 1), 1, PayloadCodec::Cbor)
241 .unwrap();
242 let result = client
244 .accept(&make_payload(0x0000, 1), 1, PayloadCodec::Cbor)
245 .unwrap();
246 assert!(
247 matches!(result, GapAccept::New(_)),
248 "seq=0 after 0xFFFF must be New"
249 );
250 }
251
252 #[test]
253 fn strict_replay_within_window_is_late() {
254 let mut client = GapClient::new();
255 let _ = client
256 .accept(&make_payload(100, 1), 1, PayloadCodec::Cbor)
257 .unwrap();
258 let result = client
259 .accept(&make_payload(100, 1), 1, PayloadCodec::Cbor)
260 .unwrap();
261 assert!(
262 matches!(result, GapAccept::Late(_)),
263 "exact dup must be Late"
264 );
265 }
266
267 #[test]
268 fn epoch_change_clears_window() {
269 let mut client = GapClient::new();
270 let _ = client
271 .accept(&make_payload(1, 1), 1, PayloadCodec::Cbor)
272 .unwrap();
273 let result = client
275 .accept(&make_payload(1, 2), 2, PayloadCodec::Cbor)
276 .unwrap();
277 assert!(
278 matches!(result, GapAccept::New(_)),
279 "new epoch resets window"
280 );
281 }
282
283 #[test]
286 fn old_epoch_frame_accepted_within_overlap() {
287 let mut client = GapClient::new();
288 let _ = client
290 .accept(&make_payload(5, 1), 1, PayloadCodec::Cbor)
291 .unwrap();
292 let _ = client
294 .accept(&make_payload(1, 2), 2, PayloadCodec::Cbor)
295 .unwrap();
296 let result = client
298 .accept(&make_payload(6, 1), 2, PayloadCodec::Cbor)
299 .unwrap();
300 assert!(
301 matches!(result, GapAccept::New(_)),
302 "late epoch-1 frame accepted within T_overlap"
303 );
304 }
305
306 #[test]
307 fn old_epoch_replay_is_late_within_overlap() {
308 let mut client = GapClient::new();
309 let _ = client
310 .accept(&make_payload(5, 1), 1, PayloadCodec::Cbor)
311 .unwrap();
312 let _ = client
314 .accept(&make_payload(1, 2), 2, PayloadCodec::Cbor)
315 .unwrap();
316 let result = client
318 .accept(&make_payload(5, 1), 2, PayloadCodec::Cbor)
319 .unwrap();
320 assert!(
321 matches!(result, GapAccept::Late(_)),
322 "duplicate from old epoch is Late"
323 );
324 }
325
326 #[test]
327 fn expired_old_epoch_frame_is_stale() {
328 let mut client = GapClient::new();
329 let _ = client
330 .accept(&make_payload(5, 1), 1, PayloadCodec::Cbor)
331 .unwrap();
332 let _ = client
334 .accept(&make_payload(1, 2), 2, PayloadCodec::Cbor)
335 .unwrap();
336 for w in &mut client.old_windows {
338 w.expires = Instant::now() - Duration::from_millis(1);
339 }
340 let result = client.accept(&make_payload(6, 1), 2, PayloadCodec::Cbor);
342 assert!(
343 matches!(result, Err(GapError::EpochStale { .. })),
344 "expired epoch is Stale"
345 );
346 }
347
348 #[test]
349 fn reset_clears_overlap_buffer() {
350 let mut client = GapClient::new();
351 let _ = client
352 .accept(&make_payload(1, 1), 1, PayloadCodec::Cbor)
353 .unwrap();
354 let _ = client
355 .accept(&make_payload(1, 2), 2, PayloadCodec::Cbor)
356 .unwrap();
357 assert!(!client.old_windows.is_empty(), "overlap buffer populated");
358 client.reset();
359 assert!(
360 client.old_windows.is_empty(),
361 "overlap buffer cleared after reset"
362 );
363 }
364}