srt_runtime/km_refresh.rs
1//! SEK-rotation ("KM Refresh") driver — `draft-sharabayko-srt-01` §6.1.6 (KM
2//! Refresh), curated at `specs/rules/srt-crypto.md` ("KM Refresh — §6.1.6").
3//!
4//! Sans-IO: [`KmRefreshDriver::on_packet_sent`] / [`KmRefreshDriver::tick`]
5//! take a caller-supplied packet count — this crate never reads a wall clock
6//! or a socket (the same contract as [`crate::handshake_sm`] / [`crate::arq`]
7//! / [`crate::tsbpd`]). The driver only tracks *when* to rotate and *which*
8//! parity is active; it does not generate, wrap, or send key material
9//! itself — the actual SEK PRNG/wrap/Key-Material-message send is the
10//! caller's job (mirroring [`crate::handshake_sm::CryptoConfig`]'s design:
11//! this crate's sans-IO core never owns a CSPRNG), triggered by
12//! [`KmRefreshEvent::PreAnnounce`].
13//!
14//! # Thresholds (§6.1.6, "Recommended values")
15//!
16//! - **KM Refresh Period = `2^25` packets**: how long a key stays active
17//! before switchover.
18//! - **KM Pre-Announcement Period = `4000` packets**: how long *before*
19//! switchover the new key is announced, and — symmetrically — how long
20//! *after* switchover the old key stays valid before decommission. "Both
21//! keys are valid in parallel for `2 * Pre-Announcement Period`", to
22//! tolerate late/retransmitted packets that were encrypted under the old
23//! key.
24//!
25//! All three thresholds are measured from the start of the *current* active
26//! key's epoch (packet 0 of that key):
27//!
28//! ```text
29//! 0 ── refresh - pre_announce ── refresh ── refresh + pre_announce
30//! PreAnnounce fires Switchover Decommission fires
31//! (generate + wrap + (old key (old key dropped)
32//! ready next key) still valid)
33//! ```
34//!
35//! [`KmRefreshThresholds::RECOMMENDED`] is the spec's `2^25`/`4000` pair;
36//! `tests/km_refresh.rs` drives the same state machine with a scaled-down
37//! threshold pair so the test suite does not need `2^25` real iterations —
38//! the state machine logic is identical, only the threshold constants
39//! differ.
40
41use alloc::vec::Vec;
42
43/// Which of the two alternating SEKs (`draft-sharabayko-srt-01` §3.1's data
44/// packet `KK` field / §6.1.6's odd/even alternation) is meant.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
46#[cfg_attr(feature = "serde", derive(serde::Serialize))]
47#[non_exhaustive]
48pub enum KeyParity {
49 /// The even-numbered SEK.
50 Even,
51 /// The odd-numbered SEK.
52 Odd,
53}
54
55impl KeyParity {
56 /// The other parity — every rotation alternates (§6.1.6).
57 pub fn other(self) -> Self {
58 match self {
59 KeyParity::Even => KeyParity::Odd,
60 KeyParity::Odd => KeyParity::Even,
61 }
62 }
63
64 /// Spec label.
65 pub fn name(&self) -> &'static str {
66 match self {
67 KeyParity::Even => "even",
68 KeyParity::Odd => "odd",
69 }
70 }
71}
72
73broadcast_common::impl_spec_display!(KeyParity);
74
75/// KM Refresh packet-count thresholds (`draft-sharabayko-srt-01` §6.1.6).
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub struct KmRefreshThresholds {
78 /// Packets an active key is used for before switchover (spec-recommended
79 /// `2^25`).
80 pub refresh_period: u64,
81 /// Packets before switchover the next key is announced, and after
82 /// switchover the old key stays valid (spec-recommended `4000`).
83 pub pre_announcement_period: u64,
84}
85
86impl KmRefreshThresholds {
87 /// The draft's own recommended values (§6.1.6): `2^25` packets refresh
88 /// period, `4000` packets pre-announcement period.
89 pub const RECOMMENDED: KmRefreshThresholds = KmRefreshThresholds {
90 refresh_period: 1 << 25,
91 pre_announcement_period: 4000,
92 };
93}
94
95/// One state transition [`KmRefreshDriver::on_packet_sent`] /
96/// [`KmRefreshDriver::tick`] can fire (`draft-sharabayko-srt-01` §6.1.6).
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98#[non_exhaustive]
99pub enum KmRefreshEvent {
100 /// `refresh_period - pre_announcement_period` packets sent under the
101 /// active key: generate, wrap, and send a fresh SEK for `next_parity`
102 /// now (§6.1.5's Key Material exchange, driven out-of-band of this
103 /// driver — see the module doc).
104 PreAnnounce {
105 /// The parity the newly-generated key will use.
106 next_parity: KeyParity,
107 },
108 /// `refresh_period` packets sent under the active key: start encrypting
109 /// *new* outgoing packets with `new_active` from now on. The previous
110 /// key remains valid for decrypting late/retransmitted packets until
111 /// [`KmRefreshEvent::Decommission`] fires for it.
112 Switchover {
113 /// The parity now active for new packets.
114 new_active: KeyParity,
115 },
116 /// `pre_announcement_period` packets after switchover: the previous key
117 /// may be dropped — no more retransmits will need it (§6.1.6, "both keys
118 /// valid in parallel for `2 * Pre-Announcement Period`").
119 Decommission {
120 /// The parity being retired.
121 retired: KeyParity,
122 },
123}
124
125/// Sans-IO SEK-rotation state machine (`draft-sharabayko-srt-01` §6.1.6).
126///
127/// Tracks which [`KeyParity`] is currently active and fires
128/// [`KmRefreshEvent`]s as the packet count crosses the configured
129/// [`KmRefreshThresholds`]. Does not hold key material itself — see the
130/// module doc. Rotates indefinitely: once a cycle's [`KmRefreshEvent::Decommission`]
131/// fires, the next cycle's thresholds are armed again against the new active
132/// key's epoch.
133#[derive(Debug, Clone, PartialEq)]
134pub struct KmRefreshDriver {
135 thresholds: KmRefreshThresholds,
136 active: KeyParity,
137 /// Packet count at which `active` most recently became active (`0` for
138 /// the initial key negotiated at handshake time).
139 epoch_start: u64,
140 /// Total packets sent so far (monotonic).
141 total_sent: u64,
142 pre_announced: bool,
143 switched_over: bool,
144 decommissioned: bool,
145}
146
147impl KmRefreshDriver {
148 /// A fresh driver. `initial_parity` is the SEK negotiated at handshake
149 /// time (this crate's handshake convention is [`KeyParity::Even`] — see
150 /// `crate::handshake_sm::build_key_material_extension`, `crypto` feature
151 /// only).
152 pub fn new(thresholds: KmRefreshThresholds, initial_parity: KeyParity) -> Self {
153 KmRefreshDriver {
154 thresholds,
155 active: initial_parity,
156 epoch_start: 0,
157 total_sent: 0,
158 pre_announced: false,
159 switched_over: false,
160 decommissioned: false,
161 }
162 }
163
164 /// The currently-active parity for *new* outgoing packets.
165 pub fn active_parity(&self) -> KeyParity {
166 self.active
167 }
168
169 /// Total packets recorded via [`Self::on_packet_sent`]/[`Self::tick`].
170 pub fn total_sent(&self) -> u64 {
171 self.total_sent
172 }
173
174 /// Whether `parity`'s key should still be considered held/valid. The
175 /// active key always is; the just-retired key is too, from switchover
176 /// until decommission (§6.1.6's "both keys valid in parallel" transition
177 /// window) — a data packet under either may legitimately arrive during
178 /// that window (in-flight or retransmitted).
179 pub fn is_key_valid(&self, parity: KeyParity) -> bool {
180 if parity == self.active {
181 return true;
182 }
183 self.switched_over && !self.decommissioned
184 }
185
186 /// Record `n` more packets sent under the active key and return any
187 /// [`KmRefreshEvent`]s newly crossed, in spec order (PreAnnounce,
188 /// Switchover, Decommission). A single call can fire more than one event
189 /// if `n` is large enough to cross multiple thresholds at once.
190 pub fn on_packet_sent(&mut self, n: u64) -> Vec<KmRefreshEvent> {
191 self.total_sent = self.total_sent.saturating_add(n);
192 let mut events = Vec::new();
193 let since_epoch = self.total_sent.saturating_sub(self.epoch_start);
194
195 let pre_announce_at = self
196 .thresholds
197 .refresh_period
198 .saturating_sub(self.thresholds.pre_announcement_period);
199 if !self.pre_announced && since_epoch >= pre_announce_at {
200 self.pre_announced = true;
201 events.push(KmRefreshEvent::PreAnnounce {
202 next_parity: self.active.other(),
203 });
204 }
205
206 if !self.switched_over && since_epoch >= self.thresholds.refresh_period {
207 self.switched_over = true;
208 let new_active = self.active.other();
209 self.active = new_active;
210 // The new epoch starts exactly at the switchover point, so the
211 // decommission threshold below (measured from `epoch_start`) is
212 // `pre_announcement_period` packets *after* switchover — §6.1.6's
213 // `refresh_period + pre_announcement_period`, not
214 // `2 * refresh_period + pre_announcement_period`.
215 self.epoch_start += self.thresholds.refresh_period;
216 events.push(KmRefreshEvent::Switchover { new_active });
217 }
218
219 if self.switched_over && !self.decommissioned {
220 let since_switchover = self.total_sent.saturating_sub(self.epoch_start);
221 if since_switchover >= self.thresholds.pre_announcement_period {
222 self.decommissioned = true;
223 events.push(KmRefreshEvent::Decommission {
224 retired: self.active.other(),
225 });
226 // Arm the next cycle: `epoch_start` already marks the
227 // current key's start, so the next PreAnnounce/Switchover
228 // are correctly measured from here.
229 self.pre_announced = false;
230 self.switched_over = false;
231 self.decommissioned = false;
232 }
233 }
234
235 events
236 }
237
238 /// Record exactly one packet sent — convenience wrapper over
239 /// [`Self::on_packet_sent`].
240 pub fn tick(&mut self) -> Vec<KmRefreshEvent> {
241 self.on_packet_sent(1)
242 }
243}
244
245#[cfg(test)]
246mod tests {
247 use super::*;
248
249 const SCALED: KmRefreshThresholds = KmRefreshThresholds {
250 refresh_period: 100,
251 pre_announcement_period: 10,
252 };
253
254 #[test]
255 fn recommended_thresholds_match_spec_values() {
256 assert_eq!(KmRefreshThresholds::RECOMMENDED.refresh_period, 1 << 25);
257 assert_eq!(
258 KmRefreshThresholds::RECOMMENDED.pre_announcement_period,
259 4000
260 );
261 }
262
263 #[test]
264 fn key_parity_alternates_and_labels() {
265 assert_eq!(KeyParity::Even.other(), KeyParity::Odd);
266 assert_eq!(KeyParity::Odd.other(), KeyParity::Even);
267 assert_eq!(KeyParity::Even.to_string(), "even");
268 assert_eq!(KeyParity::Odd.to_string(), "odd");
269 }
270
271 #[test]
272 fn fires_pre_announce_switchover_decommission_in_order() {
273 let mut d = KmRefreshDriver::new(SCALED, KeyParity::Even);
274 assert_eq!(d.active_parity(), KeyParity::Even);
275 assert!(d.is_key_valid(KeyParity::Even));
276 assert!(!d.is_key_valid(KeyParity::Odd));
277
278 // Just before pre-announce: nothing fires.
279 assert_eq!(d.on_packet_sent(89), Vec::new());
280 assert_eq!(d.active_parity(), KeyParity::Even);
281
282 // Crosses 90 (100 - 10): PreAnnounce for the *other* (Odd) parity.
283 assert_eq!(
284 d.on_packet_sent(1),
285 alloc::vec![KmRefreshEvent::PreAnnounce {
286 next_parity: KeyParity::Odd
287 }]
288 );
289 // Active key hasn't changed yet.
290 assert_eq!(d.active_parity(), KeyParity::Even);
291 assert!(d.is_key_valid(KeyParity::Even));
292 assert!(!d.is_key_valid(KeyParity::Odd));
293
294 // Re-crossing the same threshold does not re-fire. (total: 91)
295 assert_eq!(d.on_packet_sent(1), Vec::new());
296
297 // Crosses 100: Switchover to Odd.
298 assert_eq!(d.on_packet_sent(8), Vec::new()); // total 99, not yet
299 assert_eq!(
300 d.on_packet_sent(1), // total 100
301 alloc::vec![KmRefreshEvent::Switchover {
302 new_active: KeyParity::Odd
303 }]
304 );
305 assert_eq!(d.active_parity(), KeyParity::Odd);
306 // Both keys valid during the transition window.
307 assert!(d.is_key_valid(KeyParity::Odd));
308 assert!(d.is_key_valid(KeyParity::Even));
309
310 // Crosses 110 (100 + 10): Decommission the old (Even) key.
311 assert_eq!(d.on_packet_sent(9), Vec::new()); // total 109
312 assert_eq!(
313 d.on_packet_sent(1), // total 110
314 alloc::vec![KmRefreshEvent::Decommission {
315 retired: KeyParity::Even
316 }]
317 );
318 assert_eq!(d.active_parity(), KeyParity::Odd);
319 assert!(d.is_key_valid(KeyParity::Odd));
320 assert!(!d.is_key_valid(KeyParity::Even), "old key must be dropped");
321 }
322
323 #[test]
324 fn large_jump_fires_all_three_events_in_one_call() {
325 let mut d = KmRefreshDriver::new(SCALED, KeyParity::Even);
326 let events = d.on_packet_sent(115);
327 assert_eq!(
328 events,
329 alloc::vec![
330 KmRefreshEvent::PreAnnounce {
331 next_parity: KeyParity::Odd
332 },
333 KmRefreshEvent::Switchover {
334 new_active: KeyParity::Odd
335 },
336 KmRefreshEvent::Decommission {
337 retired: KeyParity::Even
338 },
339 ]
340 );
341 assert_eq!(d.active_parity(), KeyParity::Odd);
342 assert!(!d.is_key_valid(KeyParity::Even));
343 }
344
345 #[test]
346 fn tick_is_a_single_packet_and_rotation_repeats_forever() {
347 let mut d = KmRefreshDriver::new(SCALED, KeyParity::Even);
348 let mut all = Vec::new();
349 for _ in 0..250 {
350 all.extend(d.tick());
351 }
352 assert_eq!(d.total_sent(), 250);
353 // Two full cycles of 100 packets each fit in 250 ticks: expect two
354 // full PreAnnounce/Switchover/Decommission triples, alternating
355 // parity, plus one more PreAnnounce (at 90 packets into the third
356 // 100-packet cycle, i.e. total 290 — not reached at 250) which does
357 // NOT fire yet.
358 let switchovers: Vec<_> = all
359 .iter()
360 .filter(|e| matches!(e, KmRefreshEvent::Switchover { .. }))
361 .collect();
362 assert_eq!(switchovers.len(), 2);
363 assert_eq!(
364 switchovers[0],
365 &KmRefreshEvent::Switchover {
366 new_active: KeyParity::Odd
367 }
368 );
369 assert_eq!(
370 switchovers[1],
371 &KmRefreshEvent::Switchover {
372 new_active: KeyParity::Even
373 }
374 );
375 assert_eq!(d.active_parity(), KeyParity::Even);
376 }
377}