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/// A deterministic-but-opaque SYN cookie derived from the caller's socket id.
94/// A production listener would mix in the peer address and a per-boot secret;
95/// for an unencrypted loss-tolerant ingest the anti-spoofing value is modest, so
96/// a stable derivation keeps the responder dependency-free.
97fn syn_cookie(socket_id: u32) -> u32 {
98    socket_id
99        .rotate_left(13)
100        .wrapping_mul(0x9E37_79B1)
101        .wrapping_add(0x5247_5421)
102}
103
104/// Build a listener response to a caller's handshake datagram, or `None` if the
105/// datagram is not a parseable handshake or requests encryption.
106///
107/// For an `INDUCTION` request the response echoes the body with a freshly
108/// derived SYN cookie installed; for `CONCLUSION` it echoes the body to confirm
109/// agreement. The returned bytes are ready to send back to the caller.
110pub fn respond(datagram: &[u8]) -> Option<Vec<u8>> {
111    let hs = SrtHandshake::parse(datagram)?;
112    if hs.wants_encryption() {
113        return None; // encrypted SRT is out of scope; drop the handshake
114    }
115    let mut reply = datagram.to_vec();
116    let cookie = match hs.handshake_type {
117        HandshakeType::Induction => syn_cookie(hs.socket_id),
118        HandshakeType::Conclusion => hs.cookie,
119        _ => return None,
120    };
121    // Install the cookie at its offset (payload + 28).
122    let at = SrtHandshake::PAYLOAD + 28;
123    reply
124        .get_mut(at..at + 4)?
125        .copy_from_slice(&cookie.to_be_bytes());
126    Some(reply)
127}
128
129/// Build a caller-side handshake control datagram (the **egress relay** uses
130/// this to dial an SRT listener). Mirrors the 32-byte HSv5 body this crate's
131/// [`respond`] listener understands.
132///
133/// Note: this is the minimal handshake subset (no `HSREQ`/`KMREQ` extension
134/// blocks), so it interoperates with this crate's own listener; full extension
135/// interop with third-party SRT stacks is a follow-up.
136pub fn caller_handshake(
137    req_type: HandshakeType,
138    socket_id: u32,
139    initial_sequence: u32,
140    cookie: u32,
141) -> Vec<u8> {
142    let mut d = vec![0u8; 16]; // control header: control flag set, type 0x0000
143    d[0] = 0x80;
144    let mut body = vec![0u8; 32];
145    body[0..4].copy_from_slice(&5u32.to_be_bytes()); // version HSv5
146                                                     // bytes 4..8: encryption(0) + extension(0)
147    body[8..12].copy_from_slice(&initial_sequence.to_be_bytes());
148    body[12..16].copy_from_slice(&1500u32.to_be_bytes()); // MTU
149    body[16..20].copy_from_slice(&8192u32.to_be_bytes()); // flow-window
150    body[20..24].copy_from_slice(&req_type.to_u32().to_be_bytes());
151    body[24..28].copy_from_slice(&socket_id.to_be_bytes());
152    body[28..32].copy_from_slice(&cookie.to_be_bytes());
153    d.extend_from_slice(&body);
154    d
155}
156
157/// The caller's first handshake (`INDUCTION`, cookie 0).
158pub fn caller_induction(socket_id: u32, initial_sequence: u32) -> Vec<u8> {
159    caller_handshake(HandshakeType::Induction, socket_id, initial_sequence, 0)
160}
161
162/// The caller's second handshake (`CONCLUSION`, echoing the listener's cookie).
163pub fn caller_conclusion(socket_id: u32, initial_sequence: u32, cookie: u32) -> Vec<u8> {
164    caller_handshake(
165        HandshakeType::Conclusion,
166        socket_id,
167        initial_sequence,
168        cookie,
169    )
170}
171
172// ---------------------------------------------------------------------------
173// HSv5 KMREQ/KMRSP extension blocks (feature `srt-encrypt`).
174//
175// After the 32-byte HSv5 body, extension blocks follow as
176// `ExtType(16) | ExtLen(16 words) | ExtContents`. The encrypted handshake
177// carries the Key Material message in a KMREQ block (caller → listener); the
178// listener echoes it as KMRSP to confirm.
179// ---------------------------------------------------------------------------
180
181/// SRT `SRT_CMD_KMREQ` extension type.
182#[cfg(feature = "srt-encrypt")]
183const EXT_KMREQ: u16 = 3;
184/// SRT `SRT_CMD_KMRSP` extension type.
185#[cfg(feature = "srt-encrypt")]
186const EXT_KMRSP: u16 = 4;
187/// Extension-field flag advertising a KMREQ block follows.
188#[cfg(feature = "srt-encrypt")]
189const HS_EXT_KM_FLAG: u16 = 0x0002;
190
191/// The HSv5 "Encryption Field" code advertising the SEK length (2/3/4 for
192/// AES-128/192/256), i.e. `key_len / 8`.
193#[cfg(feature = "srt-encrypt")]
194pub fn encryption_field(key_len: usize) -> u16 {
195    (key_len / 8) as u16
196}
197
198/// Locate the KMREQ/KMRSP extension block's offset (the start of its `ExtType`
199/// field) within a handshake datagram, if present.
200#[cfg(feature = "srt-encrypt")]
201fn km_ext_offset(datagram: &[u8]) -> Option<usize> {
202    let mut p = SrtHandshake::PAYLOAD + 32; // past header + 32-byte body
203    while p + 4 <= datagram.len() {
204        let ext_type = u16::from_be_bytes([datagram[p], datagram[p + 1]]);
205        let words = u16::from_be_bytes([datagram[p + 2], datagram[p + 3]]) as usize;
206        let end = p + 4 + words * 4;
207        if end > datagram.len() {
208            break;
209        }
210        if ext_type == EXT_KMREQ || ext_type == EXT_KMRSP {
211            return Some(p);
212        }
213        p = end;
214    }
215    None
216}
217
218/// The Key Material message bytes carried in a KMREQ/KMRSP block, if any.
219#[cfg(feature = "srt-encrypt")]
220pub fn km_extension(datagram: &[u8]) -> Option<&[u8]> {
221    let p = km_ext_offset(datagram)?;
222    let words = u16::from_be_bytes([datagram[p + 2], datagram[p + 3]]) as usize;
223    datagram.get(p + 4..p + 4 + words * 4)
224}
225
226/// Build a caller `CONCLUSION` that requests encryption: it advertises the
227/// cipher in the Encryption Field, flags a KMREQ extension, and appends the
228/// Key Material message `km`.
229#[cfg(feature = "srt-encrypt")]
230pub fn caller_conclusion_encrypted(
231    socket_id: u32,
232    initial_sequence: u32,
233    cookie: u32,
234    key_len: usize,
235    km: &[u8],
236) -> Vec<u8> {
237    let mut d = caller_conclusion(socket_id, initial_sequence, cookie);
238    let body = SrtHandshake::PAYLOAD;
239    // Encryption Field (body bytes 4..6) and Extension Field (6..8).
240    d[body + 4..body + 6].copy_from_slice(&encryption_field(key_len).to_be_bytes());
241    d[body + 6..body + 8].copy_from_slice(&HS_EXT_KM_FLAG.to_be_bytes());
242    // KMREQ extension block: type | length-in-words | contents.
243    d.extend_from_slice(&EXT_KMREQ.to_be_bytes());
244    d.extend_from_slice(&((km.len() / 4) as u16).to_be_bytes());
245    d.extend_from_slice(km);
246    d
247}
248
249/// Listener response that also processes encryption. For `INDUCTION` it installs
250/// the SYN cookie (encrypted or not). For an encrypted `CONCLUSION` it unwraps
251/// the KMREQ with `passphrase`, returns the recovered key material, and echoes
252/// the handshake as a KMRSP; an unencrypted conclusion behaves like [`respond`].
253/// Returns `None` if the handshake is malformed or the passphrase is wrong.
254#[cfg(feature = "srt-encrypt")]
255pub fn respond_with_km(
256    datagram: &[u8],
257    passphrase: &[u8],
258) -> Option<(Vec<u8>, Option<super::keymaterial::KeyMaterial>)> {
259    let hs = SrtHandshake::parse(datagram)?;
260    match hs.handshake_type {
261        HandshakeType::Induction => {
262            let mut reply = datagram.to_vec();
263            let at = SrtHandshake::PAYLOAD + 28;
264            reply
265                .get_mut(at..at + 4)?
266                .copy_from_slice(&syn_cookie(hs.socket_id).to_be_bytes());
267            Some((reply, None))
268        }
269        HandshakeType::Conclusion if hs.wants_encryption() => {
270            let km_bytes = km_extension(datagram)?;
271            let km = super::keymaterial::KeyMaterial::parse(passphrase, km_bytes)?;
272            // Echo as KMRSP: flip the extension type in place to confirm.
273            let mut reply = datagram.to_vec();
274            let off = km_ext_offset(&reply)?;
275            reply[off..off + 2].copy_from_slice(&EXT_KMRSP.to_be_bytes());
276            Some((reply, Some(km)))
277        }
278        HandshakeType::Conclusion => Some((datagram.to_vec(), None)),
279        _ => None,
280    }
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286
287    /// Build a control datagram with a handshake body for the given type/enc.
288    fn handshake_datagram(req_type: u32, encryption: u16) -> Vec<u8> {
289        let mut d = vec![0u8; 16]; // control header
290        d[0] = 0x80; // control flag
291        let mut body = vec![0u8; 32];
292        body[0..4].copy_from_slice(&5u32.to_be_bytes()); // version 5
293        body[4..6].copy_from_slice(&encryption.to_be_bytes());
294        body[20..24].copy_from_slice(&req_type.to_be_bytes());
295        body[24..28].copy_from_slice(&0xABCD_1234u32.to_be_bytes()); // socket id
296        d.extend_from_slice(&body);
297        d
298    }
299
300    #[test]
301    fn parses_induction_handshake() {
302        let d = handshake_datagram(1, 0);
303        let hs = SrtHandshake::parse(&d).unwrap();
304        assert_eq!(hs.version, 5);
305        assert_eq!(hs.handshake_type, HandshakeType::Induction);
306        assert_eq!(hs.socket_id, 0xABCD_1234);
307        assert!(!hs.wants_encryption());
308    }
309
310    #[test]
311    fn induction_response_installs_nonzero_cookie() {
312        let d = handshake_datagram(1, 0);
313        let reply = respond(&d).unwrap();
314        let parsed = SrtHandshake::parse(&reply).unwrap();
315        assert_ne!(parsed.cookie, 0, "cookie installed in induction response");
316    }
317
318    #[test]
319    fn encrypted_handshake_is_rejected() {
320        let d = handshake_datagram(1, 0x0002);
321        assert!(respond(&d).is_none());
322    }
323
324    #[test]
325    fn non_handshake_request_type_has_no_response() {
326        let d = handshake_datagram(0, 0); // WAVEAHAND
327        assert!(respond(&d).is_none());
328    }
329
330    #[test]
331    fn caller_handshake_loops_through_listener() {
332        // Full in-process loopback: caller INDUCTION → listener cookie →
333        // caller CONCLUSION → listener agreement, all over the wire format.
334        let induction = caller_induction(0x0BAD_F00D, 42);
335        let hs = SrtHandshake::parse(&induction).unwrap();
336        assert_eq!(hs.handshake_type, HandshakeType::Induction);
337        assert_eq!(hs.version, 5);
338        assert_eq!(hs.socket_id, 0x0BAD_F00D);
339
340        let resp = respond(&induction).expect("listener induction reply");
341        let cookie = SrtHandshake::parse(&resp).unwrap().cookie;
342        assert_ne!(cookie, 0, "listener installed a cookie");
343
344        let conclusion = caller_conclusion(0x0BAD_F00D, 42, cookie);
345        let chs = SrtHandshake::parse(&conclusion).unwrap();
346        assert_eq!(chs.handshake_type, HandshakeType::Conclusion);
347        assert_eq!(chs.cookie, cookie, "caller echoes the cookie");
348
349        let agree = respond(&conclusion).expect("listener conclusion reply");
350        assert_eq!(SrtHandshake::parse(&agree).unwrap().cookie, cookie);
351    }
352}