Skip to main content

oxpulse_sfu_kit/
sframe.rs

1//! SFrame (RFC 9605) key-epoch (KID) RTP header-extension forwarding.
2//!
3//! The SFU does not encrypt or decrypt payloads — SFrame encryption is
4//! frame-level and end-to-end (publisher ↔ subscriber). The SFrame ciphertext,
5//! including its in-payload header, is forwarded opaquely as part of the RTP
6//! payload.
7//!
8//! Some deployments additionally signal the current key epoch (KID) in a
9//! dedicated **RTP header extension** so a subscriber can pick the right
10//! decryption key without waiting for the in-payload header. This crate
11//! forwards that header extension on the media path: [`KeyEpoch`] captured off
12//! an inbound packet is re-attached to every fanned-out packet
13//! (`SfuMediaPayload::key_epoch` → `Writer::user_extension_value`).
14//!
15//! # Enabling it
16//!
17//! str0m only parses/serializes a header extension whose URI is registered on
18//! the [`Rtc`][str0m::Rtc] and negotiated in SDP — exactly like `audio-level`
19//! or `mid`. Because the SFrame-KID extension has no standard URI, the URI is
20//! **yours to choose** (it must match what your clients put in their SDP
21//! `a=extmap`). Register [`sframe_key_id_extension`] on the raw str0m config:
22//!
23//! ```no_run
24//! use oxpulse_sfu_kit::{raw, sframe, SfuRtc};
25//!
26//! // Pick the URI your clients negotiate for the SFrame KID extension.
27//! const SFRAME_KID_URI: &str = "urn:example:rtp-hdrext:sframe-kid";
28//!
29//! let cfg = raw::rtc_config()
30//!     .set_extension(8, sframe::sframe_key_id_extension(SFRAME_KID_URI));
31//! let rtc = SfuRtc::from_raw(cfg.build(std::time::Instant::now()));
32//! ```
33//!
34//! Register it on **every** peer's `Rtc` (the publisher side parses inbound,
35//! the subscriber side serializes outbound). Once registered + negotiated, the
36//! SFU forwards the KID transparently and never inspects the encrypted payload.
37//!
38//! [`sframe_key_id_extension`] uses [`KeyEpochSerializer`], a reference wire
39//! format (see its docs). If your clients encode the KID differently, supply
40//! your own [`ExtensionSerializer`] that parses
41//! into / writes from a [`KeyEpoch`] user value — the fanout path keys on the
42//! `KeyEpoch` **type**, not on the wire bytes.
43//!
44//! Key distribution (e.g. MLS, RFC 9420) remains your signalling layer's job.
45//!
46//! # Trust
47//!
48//! The forwarded header-extension KID is a **non-authoritative hint**. The SFU
49//! is untrusted and never validates it; a malicious or buggy publisher can set
50//! any value, including one that disagrees with the KID in the opaque
51//! in-payload SFrame header. Receivers MUST still select the key from the
52//! in-payload header and let the AEAD fail closed on a mismatch — a wrong hint
53//! costs at most a dropped frame, never confidentiality.
54
55use str0m::rtp::{Extension, ExtensionSerializer, ExtensionValues};
56
57/// An SFrame key-epoch (KID) value.
58///
59/// Maps to the `KID` (key identifier) field in SFrame (RFC 9605 §4.2).
60/// Increment on each group key rotation; receivers use it to select the correct
61/// decryption key. When an SFrame-KID RTP header extension is registered (see
62/// the [module docs][crate::sframe]), the SFU parses this value off inbound
63/// packets and re-attaches it to fanned-out packets.
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
65pub struct KeyEpoch(pub u64);
66
67impl KeyEpoch {
68    /// Create from a raw `u64` KID value.
69    #[must_use]
70    pub fn new(kid: u64) -> Self {
71        Self(kid)
72    }
73
74    /// Raw KID value.
75    #[must_use]
76    pub fn as_u64(self) -> u64 {
77        self.0
78    }
79}
80
81/// On-the-wire width of an RFC 9605 §6.1 (v1) KID: a fixed 4-byte, big-endian
82/// 32-bit value. [`KeyEpochSerializer`] uses this width; a consumer writing its
83/// own serializer for a non-standard layout can reference it.
84pub const SFRAME_KID_WIDTH: usize = 4;
85
86/// Reference [`ExtensionSerializer`] for the SFrame key-epoch (KID) RTP header
87/// extension.
88///
89/// # Wire format
90///
91/// A **fixed 4-byte, big-endian 32-bit KID**, matching RFC 9605 §6.1 (the v1
92/// KID width) and the `sframe-ratchet` browser client. This is the
93/// interoperable default; if your clients encode the KID differently, implement
94/// your own serializer that parses into / writes from a [`KeyEpoch`] user value
95/// — the fanout path keys on the `KeyEpoch` **type**, not these bytes.
96///
97/// [`KeyEpoch`] holds a `u64` for generality, but the RFC v1 KID is 32-bit: a
98/// value exceeding `u32::MAX` cannot be represented in the 4-byte form and is
99/// **not written** (str0m then omits the extension), rather than silently
100/// truncated. Supply your own serializer for wider KIDs.
101///
102/// The serializer applies to **both** audio and video media (SFrame protects
103/// either). Its 4-byte value never *requires* the two-byte extension form
104/// (str0m may still pick that form globally if another registered extension
105/// needs it — a full `u8` length encodes 4 bytes fine).
106#[derive(Debug, Clone, Copy, Default)]
107pub struct KeyEpochSerializer;
108
109impl ExtensionSerializer for KeyEpochSerializer {
110    fn write_to(&self, buf: &mut [u8], ev: &ExtensionValues) -> usize {
111        let Some(ke) = ev.user_values.get::<KeyEpoch>() else {
112            return 0;
113        };
114        // RFC 9605 §6.1 v1 KID is 32-bit; a wider value is unrepresentable in
115        // the 4-byte form — omit rather than truncate to a wrong KID.
116        let Ok(kid32) = u32::try_from(ke.as_u64()) else {
117            return 0;
118        };
119        if buf.len() < SFRAME_KID_WIDTH {
120            return 0;
121        }
122        buf[..SFRAME_KID_WIDTH].copy_from_slice(&kid32.to_be_bytes());
123        SFRAME_KID_WIDTH
124    }
125
126    fn parse_value(&self, buf: &[u8], ev: &mut ExtensionValues) -> bool {
127        // Fixed-width: accept exactly the 4-byte RFC v1 KID, nothing else.
128        let Ok(bytes) = <[u8; SFRAME_KID_WIDTH]>::try_from(buf) else {
129            return false;
130        };
131        ev.user_values
132            .set(KeyEpoch::new(u64::from(u32::from_be_bytes(bytes))));
133        true
134    }
135
136    fn is_video(&self) -> bool {
137        true
138    }
139
140    fn is_audio(&self) -> bool {
141        true
142    }
143}
144
145/// Build a str0m [`Extension`] for the SFrame key-epoch (KID) header extension,
146/// bound to the given `uri`.
147///
148/// Register the result on the raw str0m config so str0m parses the extension on
149/// ingest and serializes it on egress; see the [module docs][crate::sframe].
150/// The `uri` must match the `a=extmap` URI your clients negotiate.
151#[must_use]
152pub fn sframe_key_id_extension(uri: &str) -> Extension {
153    Extension::with_serializer(uri, KeyEpochSerializer)
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    #[test]
161    fn key_epoch_newtype_roundtrip() {
162        let k = KeyEpoch::new(42);
163        assert_eq!(k.as_u64(), 42);
164        assert_eq!(KeyEpoch::new(0).as_u64(), 0);
165    }
166
167    /// The reference serializer round-trips a 32-bit KID through the fixed
168    /// 4-byte RFC 9605 §6.1 wire format: what a subscriber's str0m parses out
169    /// must equal what the SFU's str0m serialized in. Exercises the exact
170    /// `write_to` / `parse_value` calls str0m makes on fanout write and ingest.
171    #[test]
172    fn key_epoch_serializer_wire_roundtrip() {
173        let ser = KeyEpochSerializer;
174        for kid in [0u64, 1, 7, 255, 256, 65_535, u64::from(u32::MAX)] {
175            let mut out = ExtensionValues::default();
176            out.user_values.set(KeyEpoch::new(kid));
177
178            let mut buf = [0u8; 16];
179            let n = ser.write_to(&mut buf, &out);
180            assert_eq!(n, SFRAME_KID_WIDTH, "kid={kid} must write a fixed 4 bytes");
181
182            let mut parsed = ExtensionValues::default();
183            assert!(ser.parse_value(&buf[..n], &mut parsed), "kid={kid} parse");
184            assert_eq!(
185                parsed.user_values.get::<KeyEpoch>().copied(),
186                Some(KeyEpoch::new(kid)),
187                "kid={kid} did not round-trip on the wire"
188            );
189        }
190    }
191
192    /// A KID exceeding the RFC v1 32-bit width is unrepresentable in the 4-byte
193    /// form: the serializer omits it (writes 0) rather than truncating to a
194    /// wrong KID. Consumers with wider KIDs must supply their own serializer.
195    #[test]
196    fn key_epoch_serializer_omits_kid_above_u32() {
197        let ser = KeyEpochSerializer;
198        let mut out = ExtensionValues::default();
199        out.user_values.set(KeyEpoch::new(u64::from(u32::MAX) + 1));
200        let mut buf = [0u8; 16];
201        assert_eq!(ser.write_to(&mut buf, &out), 0);
202    }
203
204    /// A destination buffer smaller than the 4-byte KID width is refused rather
205    /// than writing a partial/truncated KID.
206    #[test]
207    fn key_epoch_serializer_refuses_short_buffer() {
208        let ser = KeyEpochSerializer;
209        let mut out = ExtensionValues::default();
210        out.user_values.set(KeyEpoch::new(7));
211        let mut buf = [0u8; SFRAME_KID_WIDTH - 1];
212        assert_eq!(ser.write_to(&mut buf, &out), 0);
213    }
214
215    /// With no `KeyEpoch` user value present, the serializer writes nothing so
216    /// str0m omits the extension entirely (no spurious zero-length extension).
217    #[test]
218    fn key_epoch_serializer_absent_writes_nothing() {
219        let ser = KeyEpochSerializer;
220        let ev = ExtensionValues::default();
221        let mut buf = [0u8; 16];
222        assert_eq!(ser.write_to(&mut buf, &ev), 0);
223    }
224
225    /// Fixed-width parse: only an exactly-4-byte buffer is accepted; a short,
226    /// long, or empty buffer is rejected rather than mis-parsed.
227    #[test]
228    fn key_epoch_serializer_rejects_wrong_width() {
229        let ser = KeyEpochSerializer;
230        let mut parsed = ExtensionValues::default();
231        assert!(!ser.parse_value(&[], &mut parsed));
232        assert!(!ser.parse_value(&[0u8; 3], &mut parsed));
233        assert!(!ser.parse_value(&[0u8; 5], &mut parsed));
234        assert!(!ser.parse_value(&[0u8; 9], &mut parsed));
235        assert!(ser.parse_value(&[0u8; SFRAME_KID_WIDTH], &mut parsed));
236    }
237}