Skip to main content

arcly_stream/protocol/srt/
handshake.rs

1//! SRT handshake parsing and a minimal listener-side responder.
2//!
3//! The handshake control payload follows the 16-byte packet header and carries
4//! the version, encryption/extension fields, sequence number, MTU/window sizes,
5//! the [request type][HandshakeType], the caller's socket id, and a SYN cookie
6//! (draft-sharabayko-srt §3.2.1).
7
8/// SRT handshake request type (the 32-bit `Handshake Type` field).
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10#[non_exhaustive]
11pub enum HandshakeType {
12    /// `INDUCTION` (1) — caller's first packet; listener replies with a cookie.
13    Induction,
14    /// `CONCLUSION` (0xFFFFFFFF) — caller echoes the cookie to finish setup.
15    Conclusion,
16    /// `WAVEAHAND` (0) — rendezvous probe.
17    WaveAHand,
18    /// `AGREEMENT` (0xFFFFFFFE) — rendezvous agreement.
19    Agreement,
20    /// Any other / error code.
21    Other(u32),
22}
23
24impl HandshakeType {
25    fn from_u32(v: u32) -> HandshakeType {
26        match v {
27            1 => HandshakeType::Induction,
28            0xFFFF_FFFF => HandshakeType::Conclusion,
29            0 => HandshakeType::WaveAHand,
30            0xFFFF_FFFE => HandshakeType::Agreement,
31            other => HandshakeType::Other(other),
32        }
33    }
34
35    fn to_u32(self) -> u32 {
36        match self {
37            HandshakeType::Induction => 1,
38            HandshakeType::Conclusion => 0xFFFF_FFFF,
39            HandshakeType::WaveAHand => 0,
40            HandshakeType::Agreement => 0xFFFF_FFFE,
41            HandshakeType::Other(v) => v,
42        }
43    }
44}
45
46/// The fields of an SRT handshake this listener inspects.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub struct SrtHandshake {
49    /// Protocol version (4 for HSv4, 5 for HSv5).
50    pub version: u32,
51    /// Encryption field — non-zero means the caller wants an encrypted link,
52    /// which this build rejects (see the module-level scope note).
53    pub encryption: u16,
54    /// Initial packet sequence number.
55    pub initial_sequence: u32,
56    /// The handshake request type.
57    pub handshake_type: HandshakeType,
58    /// The caller's SRT socket id.
59    pub socket_id: u32,
60    /// SYN cookie (0 on the caller's induction request).
61    pub cookie: u32,
62}
63
64impl SrtHandshake {
65    /// Offset of the handshake payload within a control datagram (past the
66    /// 16-byte SRT packet header).
67    const PAYLOAD: usize = 16;
68
69    /// Parse the handshake from a full control datagram. Returns `None` if the
70    /// datagram is too short to contain a handshake body.
71    pub fn parse(datagram: &[u8]) -> Option<SrtHandshake> {
72        let b = datagram.get(Self::PAYLOAD..)?;
73        if b.len() < 32 {
74            return None;
75        }
76        let w = |i: usize| u32::from_be_bytes([b[i], b[i + 1], b[i + 2], b[i + 3]]);
77        Some(SrtHandshake {
78            version: w(0),
79            encryption: u16::from_be_bytes([b[4], b[5]]),
80            initial_sequence: w(8),
81            handshake_type: HandshakeType::from_u32(w(20)),
82            socket_id: w(24),
83            cookie: w(28),
84        })
85    }
86
87    /// Whether the caller requested an encrypted link (unsupported here).
88    pub fn wants_encryption(&self) -> bool {
89        self.encryption != 0
90    }
91}
92
93/// SRT `SRT_CMD_SID` (Stream ID) HSv5 extension type.
94const EXT_SID: u16 = 5;
95
96/// The Stream ID a caller advertised in its HSv5 `CONCLUSION` handshake, decoded
97/// from the `SRT_CMD_SID` extension block, or `None` if absent.
98///
99/// `streamid` is libsrt's standard way for an SRT caller to select which stream
100/// it publishes (a plain name, or the `#!::r=…,m=…` access-control form). It is
101/// carried as 32-bit little-endian words with the characters byte-swapped within
102/// each word; this reverses that encoding and trims the trailing NUL padding back
103/// to the original string. Walking the post-body extension blocks mirrors the
104/// `srt-encrypt` KMREQ locator, so it interoperates with libsrt callers that send
105/// a Stream ID rather than only this crate's own caller.
106pub fn stream_id(datagram: &[u8]) -> Option<String> {
107    let mut p = SrtHandshake::PAYLOAD + 32; // past header + 32-byte body
108    while p + 4 <= datagram.len() {
109        let ext_type = u16::from_be_bytes([datagram[p], datagram[p + 1]]);
110        let words = u16::from_be_bytes([datagram[p + 2], datagram[p + 3]]) as usize;
111        let end = p + 4 + words * 4;
112        if end > datagram.len() {
113            break;
114        }
115        if ext_type == EXT_SID {
116            let sid = decode_sid(&datagram[p + 4..end]);
117            return (!sid.is_empty()).then_some(sid);
118        }
119        p = end;
120    }
121    None
122}
123
124/// Decode libsrt's byte-swapped-per-word Stream ID encoding into a string. The
125/// contents are word-aligned (NUL-padded), so each 4-byte group is reversed and
126/// the trailing NUL padding is trimmed.
127fn decode_sid(contents: &[u8]) -> String {
128    let mut out = Vec::with_capacity(contents.len());
129    for word in contents.chunks(4) {
130        out.extend(word.iter().rev());
131    }
132    while out.last() == Some(&0) {
133        out.pop();
134    }
135    String::from_utf8_lossy(&out).into_owned()
136}
137
138/// A deterministic-but-opaque SYN cookie derived from the caller's socket id.
139/// A production listener would mix in the peer address and a per-boot secret;
140/// for an unencrypted loss-tolerant ingest the anti-spoofing value is modest, so
141/// a stable derivation keeps the responder dependency-free.
142fn syn_cookie(socket_id: u32) -> u32 {
143    socket_id
144        .rotate_left(13)
145        .wrapping_mul(0x9E37_79B1)
146        .wrapping_add(0x5247_5421)
147}
148
149/// Build a listener response to a caller's handshake datagram, or `None` if the
150/// datagram is not a parseable handshake or requests encryption.
151///
152/// For an `INDUCTION` request the response echoes the body with a freshly
153/// derived SYN cookie installed; for `CONCLUSION` it echoes the body to confirm
154/// agreement. The returned bytes are ready to send back to the caller.
155pub fn respond(datagram: &[u8]) -> Option<Vec<u8>> {
156    let hs = SrtHandshake::parse(datagram)?;
157    if hs.wants_encryption() {
158        return None; // encrypted SRT is out of scope; drop the handshake
159    }
160    let mut reply = datagram.to_vec();
161    let cookie = match hs.handshake_type {
162        HandshakeType::Induction => syn_cookie(hs.socket_id),
163        HandshakeType::Conclusion => hs.cookie,
164        _ => return None,
165    };
166    // Install the cookie at its offset (payload + 28).
167    let at = SrtHandshake::PAYLOAD + 28;
168    reply
169        .get_mut(at..at + 4)?
170        .copy_from_slice(&cookie.to_be_bytes());
171    Some(reply)
172}
173
174/// Build a caller-side handshake control datagram (the **egress relay** uses
175/// this to dial an SRT listener). Mirrors the 32-byte HSv5 body this crate's
176/// [`respond`] listener understands.
177///
178/// Note: this is the minimal handshake subset (no `HSREQ`/`KMREQ` extension
179/// blocks), so it interoperates with this crate's own listener; full extension
180/// interop with third-party SRT stacks is a follow-up.
181pub fn caller_handshake(
182    req_type: HandshakeType,
183    socket_id: u32,
184    initial_sequence: u32,
185    cookie: u32,
186) -> Vec<u8> {
187    let mut d = vec![0u8; 16]; // control header: control flag set, type 0x0000
188    d[0] = 0x80;
189    let mut body = vec![0u8; 32];
190    body[0..4].copy_from_slice(&5u32.to_be_bytes()); // version HSv5
191                                                     // bytes 4..8: encryption(0) + extension(0)
192    body[8..12].copy_from_slice(&initial_sequence.to_be_bytes());
193    body[12..16].copy_from_slice(&1500u32.to_be_bytes()); // MTU
194    body[16..20].copy_from_slice(&8192u32.to_be_bytes()); // flow-window
195    body[20..24].copy_from_slice(&req_type.to_u32().to_be_bytes());
196    body[24..28].copy_from_slice(&socket_id.to_be_bytes());
197    body[28..32].copy_from_slice(&cookie.to_be_bytes());
198    d.extend_from_slice(&body);
199    d
200}
201
202/// The caller's first handshake (`INDUCTION`, cookie 0).
203pub fn caller_induction(socket_id: u32, initial_sequence: u32) -> Vec<u8> {
204    caller_handshake(HandshakeType::Induction, socket_id, initial_sequence, 0)
205}
206
207/// The caller's second handshake (`CONCLUSION`, echoing the listener's cookie).
208pub fn caller_conclusion(socket_id: u32, initial_sequence: u32, cookie: u32) -> Vec<u8> {
209    caller_handshake(
210        HandshakeType::Conclusion,
211        socket_id,
212        initial_sequence,
213        cookie,
214    )
215}
216
217// ---------------------------------------------------------------------------
218// HSv5 KMREQ/KMRSP extension blocks (feature `srt-encrypt`).
219//
220// After the 32-byte HSv5 body, extension blocks follow as
221// `ExtType(16) | ExtLen(16 words) | ExtContents`. The encrypted handshake
222// carries the Key Material message in a KMREQ block (caller → listener); the
223// listener echoes it as KMRSP to confirm.
224// ---------------------------------------------------------------------------
225
226/// SRT `SRT_CMD_KMREQ` extension type.
227#[cfg(feature = "srt-encrypt")]
228const EXT_KMREQ: u16 = 3;
229/// SRT `SRT_CMD_KMRSP` extension type.
230#[cfg(feature = "srt-encrypt")]
231const EXT_KMRSP: u16 = 4;
232/// Extension-field flag advertising a KMREQ block follows.
233#[cfg(feature = "srt-encrypt")]
234const HS_EXT_KM_FLAG: u16 = 0x0002;
235
236/// The HSv5 "Encryption Field" code advertising the SEK length (2/3/4 for
237/// AES-128/192/256), i.e. `key_len / 8`.
238#[cfg(feature = "srt-encrypt")]
239pub fn encryption_field(key_len: usize) -> u16 {
240    (key_len / 8) as u16
241}
242
243/// Locate the KMREQ/KMRSP extension block's offset (the start of its `ExtType`
244/// field) within a handshake datagram, if present.
245#[cfg(feature = "srt-encrypt")]
246fn km_ext_offset(datagram: &[u8]) -> Option<usize> {
247    let mut p = SrtHandshake::PAYLOAD + 32; // past header + 32-byte body
248    while p + 4 <= datagram.len() {
249        let ext_type = u16::from_be_bytes([datagram[p], datagram[p + 1]]);
250        let words = u16::from_be_bytes([datagram[p + 2], datagram[p + 3]]) as usize;
251        let end = p + 4 + words * 4;
252        if end > datagram.len() {
253            break;
254        }
255        if ext_type == EXT_KMREQ || ext_type == EXT_KMRSP {
256            return Some(p);
257        }
258        p = end;
259    }
260    None
261}
262
263/// The Key Material message bytes carried in a KMREQ/KMRSP block, if any.
264#[cfg(feature = "srt-encrypt")]
265pub fn km_extension(datagram: &[u8]) -> Option<&[u8]> {
266    let p = km_ext_offset(datagram)?;
267    let words = u16::from_be_bytes([datagram[p + 2], datagram[p + 3]]) as usize;
268    datagram.get(p + 4..p + 4 + words * 4)
269}
270
271/// Build a caller `CONCLUSION` that requests encryption: it advertises the
272/// cipher in the Encryption Field, flags a KMREQ extension, and appends the
273/// Key Material message `km`.
274#[cfg(feature = "srt-encrypt")]
275pub fn caller_conclusion_encrypted(
276    socket_id: u32,
277    initial_sequence: u32,
278    cookie: u32,
279    key_len: usize,
280    km: &[u8],
281) -> Vec<u8> {
282    let mut d = caller_conclusion(socket_id, initial_sequence, cookie);
283    let body = SrtHandshake::PAYLOAD;
284    // Encryption Field (body bytes 4..6) and Extension Field (6..8).
285    d[body + 4..body + 6].copy_from_slice(&encryption_field(key_len).to_be_bytes());
286    d[body + 6..body + 8].copy_from_slice(&HS_EXT_KM_FLAG.to_be_bytes());
287    // KMREQ extension block: type | length-in-words | contents.
288    d.extend_from_slice(&EXT_KMREQ.to_be_bytes());
289    d.extend_from_slice(&((km.len() / 4) as u16).to_be_bytes());
290    d.extend_from_slice(km);
291    d
292}
293
294/// Listener response that also processes encryption. For `INDUCTION` it installs
295/// the SYN cookie (encrypted or not). For an encrypted `CONCLUSION` it unwraps
296/// the KMREQ with `passphrase`, returns the recovered key material, and echoes
297/// the handshake as a KMRSP; an unencrypted conclusion behaves like [`respond`].
298/// Returns `None` if the handshake is malformed or the passphrase is wrong.
299#[cfg(feature = "srt-encrypt")]
300pub fn respond_with_km(
301    datagram: &[u8],
302    passphrase: &[u8],
303) -> Option<(Vec<u8>, Option<super::keymaterial::KeyMaterial>)> {
304    let hs = SrtHandshake::parse(datagram)?;
305    match hs.handshake_type {
306        HandshakeType::Induction => {
307            let mut reply = datagram.to_vec();
308            let at = SrtHandshake::PAYLOAD + 28;
309            reply
310                .get_mut(at..at + 4)?
311                .copy_from_slice(&syn_cookie(hs.socket_id).to_be_bytes());
312            Some((reply, None))
313        }
314        HandshakeType::Conclusion if hs.wants_encryption() => {
315            let km_bytes = km_extension(datagram)?;
316            let km = super::keymaterial::KeyMaterial::parse(passphrase, km_bytes)?;
317            // Echo as KMRSP: flip the extension type in place to confirm.
318            let mut reply = datagram.to_vec();
319            let off = km_ext_offset(&reply)?;
320            reply[off..off + 2].copy_from_slice(&EXT_KMRSP.to_be_bytes());
321            Some((reply, Some(km)))
322        }
323        HandshakeType::Conclusion => Some((datagram.to_vec(), None)),
324        _ => None,
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331
332    /// Build a control datagram with a handshake body for the given type/enc.
333    fn handshake_datagram(req_type: u32, encryption: u16) -> Vec<u8> {
334        let mut d = vec![0u8; 16]; // control header
335        d[0] = 0x80; // control flag
336        let mut body = vec![0u8; 32];
337        body[0..4].copy_from_slice(&5u32.to_be_bytes()); // version 5
338        body[4..6].copy_from_slice(&encryption.to_be_bytes());
339        body[20..24].copy_from_slice(&req_type.to_be_bytes());
340        body[24..28].copy_from_slice(&0xABCD_1234u32.to_be_bytes()); // socket id
341        d.extend_from_slice(&body);
342        d
343    }
344
345    #[test]
346    fn parses_induction_handshake() {
347        let d = handshake_datagram(1, 0);
348        let hs = SrtHandshake::parse(&d).unwrap();
349        assert_eq!(hs.version, 5);
350        assert_eq!(hs.handshake_type, HandshakeType::Induction);
351        assert_eq!(hs.socket_id, 0xABCD_1234);
352        assert!(!hs.wants_encryption());
353    }
354
355    #[test]
356    fn induction_response_installs_nonzero_cookie() {
357        let d = handshake_datagram(1, 0);
358        let reply = respond(&d).unwrap();
359        let parsed = SrtHandshake::parse(&reply).unwrap();
360        assert_ne!(parsed.cookie, 0, "cookie installed in induction response");
361    }
362
363    #[test]
364    fn encrypted_handshake_is_rejected() {
365        let d = handshake_datagram(1, 0x0002);
366        assert!(respond(&d).is_none());
367    }
368
369    #[test]
370    fn non_handshake_request_type_has_no_response() {
371        let d = handshake_datagram(0, 0); // WAVEAHAND
372        assert!(respond(&d).is_none());
373    }
374
375    #[test]
376    fn caller_handshake_loops_through_listener() {
377        // Full in-process loopback: caller INDUCTION → listener cookie →
378        // caller CONCLUSION → listener agreement, all over the wire format.
379        let induction = caller_induction(0x0BAD_F00D, 42);
380        let hs = SrtHandshake::parse(&induction).unwrap();
381        assert_eq!(hs.handshake_type, HandshakeType::Induction);
382        assert_eq!(hs.version, 5);
383        assert_eq!(hs.socket_id, 0x0BAD_F00D);
384
385        let resp = respond(&induction).expect("listener induction reply");
386        let cookie = SrtHandshake::parse(&resp).unwrap().cookie;
387        assert_ne!(cookie, 0, "listener installed a cookie");
388
389        let conclusion = caller_conclusion(0x0BAD_F00D, 42, cookie);
390        let chs = SrtHandshake::parse(&conclusion).unwrap();
391        assert_eq!(chs.handshake_type, HandshakeType::Conclusion);
392        assert_eq!(chs.cookie, cookie, "caller echoes the cookie");
393
394        let agree = respond(&conclusion).expect("listener conclusion reply");
395        assert_eq!(SrtHandshake::parse(&agree).unwrap().cookie, cookie);
396    }
397
398    /// Encode `s` the way libsrt frames a Stream ID: NUL-pad to a 4-byte boundary,
399    /// then byte-swap each 32-bit word, and append it as an `SRT_CMD_SID` block.
400    fn datagram_with_sid(s: &str) -> Vec<u8> {
401        let mut bytes = s.as_bytes().to_vec();
402        while bytes.len() % 4 != 0 {
403            bytes.push(0);
404        }
405        let mut swapped = Vec::with_capacity(bytes.len());
406        for word in bytes.chunks(4) {
407            swapped.extend(word.iter().rev());
408        }
409        let mut d = handshake_datagram(0xFFFF_FFFF, 0); // CONCLUSION
410        d.extend_from_slice(&EXT_SID.to_be_bytes());
411        d.extend_from_slice(&((swapped.len() / 4) as u16).to_be_bytes());
412        d.extend_from_slice(&swapped);
413        d
414    }
415
416    #[test]
417    fn decodes_stream_id_extension() {
418        // Word-aligned and non-aligned (padded) lengths both round-trip.
419        let d = datagram_with_sid("live/cam01");
420        assert_eq!(stream_id(&d).as_deref(), Some("live/cam01"));
421
422        let d = datagram_with_sid("abcd");
423        assert_eq!(stream_id(&d).as_deref(), Some("abcd"));
424
425        // A libsrt access-control streamid form survives verbatim.
426        let d = datagram_with_sid("#!::r=live/feed,m=publish");
427        assert_eq!(stream_id(&d).as_deref(), Some("#!::r=live/feed,m=publish"));
428    }
429
430    #[test]
431    fn absent_stream_id_is_none() {
432        // A plain handshake with no extension blocks has no Stream ID.
433        let d = handshake_datagram(0xFFFF_FFFF, 0);
434        assert_eq!(stream_id(&d), None);
435        // A truncated extension header is ignored, not a panic.
436        let mut d = handshake_datagram(0xFFFF_FFFF, 0);
437        d.extend_from_slice(&EXT_SID.to_be_bytes()); // type but no length/contents
438        assert_eq!(stream_id(&d), None);
439    }
440}