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