Skip to main content

stackforge_core/layer/ssh/
mod.rs

1//! SSH (Secure Shell) protocol layer.
2//!
3//! Implements parsing for the SSH Binary Packet Protocol (RFC 4253)
4//! and the SSH Version Exchange.
5//!
6//! ## Binary Packet Format
7//!
8//! ```text
9//! uint32    packet_length
10//! byte      padding_length
11//! byte[n1]  payload; n1 = packet_length - padding_length - 1
12//! byte[n2]  random padding; n2 = padding_length
13//! byte[m]   mac (Message Authentication Code - MAC); m = mac_length
14//! ```
15
16pub mod builder;
17
18pub use builder::SshBuilder;
19
20use crate::layer::field::{FieldError, FieldValue};
21use crate::layer::{Layer, LayerIndex, LayerKind};
22
23/// Minimum SSH binary packet header: 4 (packet_length) + 1 (padding_length).
24pub const SSH_BINARY_HEADER_LEN: usize = 5;
25
26/// Standard SSH port.
27pub const SSH_PORT: u16 = 22;
28
29/// SSH message type constants (RFC 4250/4253/4252/4254).
30pub mod msg_types {
31    // Transport layer (RFC 4253)
32    pub const DISCONNECT: u8 = 1;
33    pub const IGNORE: u8 = 2;
34    pub const UNIMPLEMENTED: u8 = 3;
35    pub const DEBUG: u8 = 4;
36    pub const SERVICE_REQUEST: u8 = 5;
37    pub const SERVICE_ACCEPT: u8 = 6;
38    pub const EXT_INFO: u8 = 7; // RFC 8308
39    pub const NEWCOMPRESS: u8 = 8;
40    pub const KEXINIT: u8 = 20;
41    pub const NEWKEYS: u8 = 21;
42    // Key exchange (RFC 4253 errata 152)
43    pub const KEXDH_INIT: u8 = 30;
44    pub const KEXDH_REPLY: u8 = 31;
45    // User authentication (RFC 4252)
46    pub const USERAUTH_REQUEST: u8 = 50;
47    pub const USERAUTH_FAILURE: u8 = 51;
48    pub const USERAUTH_SUCCESS: u8 = 52;
49    pub const USERAUTH_BANNER: u8 = 53;
50    // Connection protocol (RFC 4254)
51    pub const CHANNEL_OPEN: u8 = 90;
52    pub const CHANNEL_OPEN_CONFIRMATION: u8 = 91;
53    pub const CHANNEL_OPEN_FAILURE: u8 = 92;
54    pub const CHANNEL_WINDOW_ADJUST: u8 = 93;
55    pub const CHANNEL_DATA: u8 = 94;
56    pub const CHANNEL_EXTENDED_DATA: u8 = 95;
57    pub const CHANNEL_EOF: u8 = 96;
58    pub const CHANNEL_CLOSE: u8 = 97;
59    pub const CHANNEL_REQUEST: u8 = 98;
60    pub const CHANNEL_SUCCESS: u8 = 99;
61    pub const CHANNEL_FAILURE: u8 = 100;
62    pub const GLOBAL_REQUEST: u8 = 80;
63    pub const REQUEST_SUCCESS: u8 = 81;
64    pub const REQUEST_FAILURE: u8 = 82;
65
66    /// Get a human-readable name for an SSH message type.
67    pub fn name(msg_type: u8) -> &'static str {
68        match msg_type {
69            DISCONNECT => "DISCONNECT",
70            IGNORE => "IGNORE",
71            UNIMPLEMENTED => "UNIMPLEMENTED",
72            DEBUG => "DEBUG",
73            SERVICE_REQUEST => "SERVICE_REQUEST",
74            SERVICE_ACCEPT => "SERVICE_ACCEPT",
75            EXT_INFO => "EXT_INFO",
76            NEWCOMPRESS => "NEWCOMPRESS",
77            KEXINIT => "KEXINIT",
78            NEWKEYS => "NEWKEYS",
79            KEXDH_INIT => "KEXDH_INIT",
80            KEXDH_REPLY => "KEXDH_REPLY",
81            USERAUTH_REQUEST => "USERAUTH_REQUEST",
82            USERAUTH_FAILURE => "USERAUTH_FAILURE",
83            USERAUTH_SUCCESS => "USERAUTH_SUCCESS",
84            USERAUTH_BANNER => "USERAUTH_BANNER",
85            GLOBAL_REQUEST => "GLOBAL_REQUEST",
86            REQUEST_SUCCESS => "REQUEST_SUCCESS",
87            REQUEST_FAILURE => "REQUEST_FAILURE",
88            CHANNEL_OPEN => "CHANNEL_OPEN",
89            CHANNEL_OPEN_CONFIRMATION => "CHANNEL_OPEN_CONFIRMATION",
90            CHANNEL_OPEN_FAILURE => "CHANNEL_OPEN_FAILURE",
91            CHANNEL_WINDOW_ADJUST => "CHANNEL_WINDOW_ADJUST",
92            CHANNEL_DATA => "CHANNEL_DATA",
93            CHANNEL_EXTENDED_DATA => "CHANNEL_EXTENDED_DATA",
94            CHANNEL_EOF => "CHANNEL_EOF",
95            CHANNEL_CLOSE => "CHANNEL_CLOSE",
96            CHANNEL_REQUEST => "CHANNEL_REQUEST",
97            CHANNEL_SUCCESS => "CHANNEL_SUCCESS",
98            CHANNEL_FAILURE => "CHANNEL_FAILURE",
99            _ => "UNKNOWN",
100        }
101    }
102}
103
104/// SSH protocol layer view into a packet buffer.
105#[derive(Debug, Clone)]
106pub struct SshLayer {
107    pub index: LayerIndex,
108}
109
110/// Field names for SSH layer.
111pub static SSH_FIELDS: &[&str] = &[
112    "packet_length",
113    "padding_length",
114    "message_type",
115    "version_string",
116];
117
118impl SshLayer {
119    /// Check if this SSH data is a version exchange (starts with "SSH-").
120    pub fn is_version_exchange(&self, buf: &[u8]) -> bool {
121        let slice = self.index.slice(buf);
122        slice.len() >= 4 && &slice[..4] == b"SSH-"
123    }
124
125    /// Extract the version string from a version exchange message.
126    ///
127    /// Returns the version string without the trailing CRLF.
128    pub fn version_string<'a>(&self, buf: &'a [u8]) -> Option<&'a str> {
129        let slice = self.index.slice(buf);
130        if slice.len() < 4 || &slice[..4] != b"SSH-" {
131            return None;
132        }
133        // Find CRLF or end of data
134        let end = slice
135            .windows(2)
136            .position(|w| w == b"\r\n")
137            .unwrap_or(slice.len());
138        std::str::from_utf8(&slice[..end]).ok()
139    }
140
141    /// Read the packet_length field (first 4 bytes of binary packet).
142    pub fn packet_length(&self, buf: &[u8]) -> Result<u32, FieldError> {
143        let slice = self.index.slice(buf);
144        if self.is_version_exchange(buf) {
145            return Err(FieldError::BufferTooShort {
146                offset: self.index.start,
147                need: 4,
148                have: 0,
149            });
150        }
151        if slice.len() < 4 {
152            return Err(FieldError::BufferTooShort {
153                offset: self.index.start,
154                need: 4,
155                have: slice.len(),
156            });
157        }
158        Ok(u32::from_be_bytes([slice[0], slice[1], slice[2], slice[3]]))
159    }
160
161    /// Read the padding_length field (byte at offset 4 of binary packet).
162    pub fn padding_length(&self, buf: &[u8]) -> Result<u8, FieldError> {
163        let slice = self.index.slice(buf);
164        if self.is_version_exchange(buf) {
165            return Err(FieldError::BufferTooShort {
166                offset: self.index.start,
167                need: 5,
168                have: 0,
169            });
170        }
171        if slice.len() < 5 {
172            return Err(FieldError::BufferTooShort {
173                offset: self.index.start,
174                need: 5,
175                have: slice.len(),
176            });
177        }
178        Ok(slice[4])
179    }
180
181    /// Read the message type (first byte of payload, at offset 5).
182    pub fn message_type(&self, buf: &[u8]) -> Result<Option<u8>, FieldError> {
183        if self.is_version_exchange(buf) {
184            return Ok(None);
185        }
186        let slice = self.index.slice(buf);
187        if slice.len() < 6 {
188            return Ok(None);
189        }
190        Ok(Some(slice[5]))
191    }
192
193    /// Get the payload data (after padding_length byte, before random_padding).
194    pub fn payload_data<'a>(&self, buf: &'a [u8]) -> &'a [u8] {
195        let slice = self.index.slice(buf);
196        if self.is_version_exchange(buf) {
197            return slice;
198        }
199        if slice.len() < SSH_BINARY_HEADER_LEN {
200            return &[];
201        }
202        let padding_len = slice[4] as usize;
203        let pkt_len = u32::from_be_bytes([slice[0], slice[1], slice[2], slice[3]]) as usize;
204        // payload starts at offset 5, ends before random_padding
205        let payload_len = pkt_len.saturating_sub(padding_len).saturating_sub(1);
206        let payload_end = (5 + payload_len).min(slice.len());
207        &slice[5..payload_end]
208    }
209
210    /// Get a field value by name.
211    pub fn get_field(&self, buf: &[u8], name: &str) -> Option<Result<FieldValue, FieldError>> {
212        match name {
213            "packet_length" => {
214                if self.is_version_exchange(buf) {
215                    return Some(Ok(FieldValue::U32(0)));
216                }
217                Some(self.packet_length(buf).map(FieldValue::U32))
218            },
219            "padding_length" => {
220                if self.is_version_exchange(buf) {
221                    return Some(Ok(FieldValue::U8(0)));
222                }
223                Some(self.padding_length(buf).map(FieldValue::U8))
224            },
225            "message_type" => match self.message_type(buf) {
226                Ok(Some(t)) => Some(Ok(FieldValue::U8(t))),
227                Ok(None) => Some(Ok(FieldValue::U8(0))),
228                Err(e) => Some(Err(e)),
229            },
230            "version_string" => {
231                if let Some(vs) = self.version_string(buf) {
232                    Some(Ok(FieldValue::Bytes(vs.as_bytes().to_vec())))
233                } else {
234                    Some(Ok(FieldValue::Bytes(vec![])))
235                }
236            },
237            _ => None,
238        }
239    }
240
241    /// Set a field value by name (limited support for SSH).
242    pub fn set_field(
243        &self,
244        _buf: &mut [u8],
245        _name: &str,
246        _value: FieldValue,
247    ) -> Option<Result<(), FieldError>> {
248        // SSH fields are read-only for now (complex protocol)
249        None
250    }
251
252    /// Get field names.
253    pub fn field_names(&self) -> &'static [&'static str] {
254        SSH_FIELDS
255    }
256}
257
258impl Layer for SshLayer {
259    fn kind(&self) -> LayerKind {
260        LayerKind::Ssh
261    }
262
263    fn summary(&self, data: &[u8]) -> String {
264        let buf = self.index.slice(data);
265        if self.is_version_exchange(data) {
266            if let Some(vs) = self.version_string(data) {
267                return format!("SSH Version Exchange {}", vs);
268            }
269            return "SSH Version Exchange".to_string();
270        }
271        if buf.len() >= 6 {
272            let msg_type = buf[5];
273            let name = msg_types::name(msg_type);
274            format!("SSH {}", name)
275        } else {
276            "SSH".to_string()
277        }
278    }
279
280    fn header_len(&self, data: &[u8]) -> usize {
281        if self.is_version_exchange(data) {
282            return self.index.len();
283        }
284        let slice = self.index.slice(data);
285        if slice.len() >= 4 {
286            let pkt_len = u32::from_be_bytes([slice[0], slice[1], slice[2], slice[3]]) as usize;
287            // Total SSH binary packet = 4 (packet_length field) + packet_length
288            (4 + pkt_len).min(slice.len())
289        } else {
290            slice.len()
291        }
292    }
293
294    fn hashret(&self, _data: &[u8]) -> Vec<u8> {
295        vec![]
296    }
297
298    fn field_names(&self) -> &'static [&'static str] {
299        SSH_FIELDS
300    }
301}
302
303/// Check if a TCP payload looks like SSH traffic.
304///
305/// Returns true if the data starts with "SSH-" (version exchange)
306/// or looks like a valid SSH binary packet.
307pub fn is_ssh_payload(data: &[u8]) -> bool {
308    if data.len() >= 4 && &data[..4] == b"SSH-" {
309        return true;
310    }
311    if data.len() >= SSH_BINARY_HEADER_LEN {
312        let pkt_len = u32::from_be_bytes([data[0], data[1], data[2], data[3]]) as usize;
313        let padding_len = data[4] as usize;
314        // Sanity checks for SSH binary packet
315        pkt_len >= 12 && pkt_len <= 35000 && padding_len < pkt_len && padding_len >= 4
316    } else {
317        false
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    #[test]
326    fn test_version_exchange() {
327        let data = b"SSH-2.0-OpenSSH_9.2p1 Debian-2+deb12u1\r\n";
328        let layer = SshLayer {
329            index: LayerIndex::new(LayerKind::Ssh, 0, data.len()),
330        };
331        assert!(layer.is_version_exchange(data));
332        assert_eq!(
333            layer.version_string(data),
334            Some("SSH-2.0-OpenSSH_9.2p1 Debian-2+deb12u1")
335        );
336        assert!(layer.summary(data).contains("SSH-2.0-OpenSSH_9.2p1"));
337    }
338
339    #[test]
340    fn test_binary_packet_kexinit() {
341        // Minimal SSH binary packet: KEXINIT (type 20)
342        let mut data = vec![0u8; 32];
343        // packet_length = 24 (covers padding_length + payload + padding)
344        data[0..4].copy_from_slice(&24u32.to_be_bytes());
345        data[4] = 8; // padding_length = 8
346        data[5] = 20; // message_type = KEXINIT
347        // rest is payload + padding
348
349        let layer = SshLayer {
350            index: LayerIndex::new(LayerKind::Ssh, 0, data.len()),
351        };
352        assert!(!layer.is_version_exchange(&data));
353        assert_eq!(layer.packet_length(&data).unwrap(), 24);
354        assert_eq!(layer.padding_length(&data).unwrap(), 8);
355        assert_eq!(layer.message_type(&data).unwrap(), Some(20));
356        assert!(layer.summary(&data).contains("KEXINIT"));
357    }
358
359    #[test]
360    fn test_binary_packet_newkeys() {
361        let mut data = vec![0u8; 16];
362        data[0..4].copy_from_slice(&12u32.to_be_bytes());
363        data[4] = 10; // padding
364        data[5] = 21; // NEWKEYS
365
366        let layer = SshLayer {
367            index: LayerIndex::new(LayerKind::Ssh, 0, data.len()),
368        };
369        assert_eq!(layer.message_type(&data).unwrap(), Some(21));
370        assert!(layer.summary(&data).contains("NEWKEYS"));
371    }
372
373    #[test]
374    fn test_is_ssh_payload() {
375        assert!(is_ssh_payload(b"SSH-2.0-OpenSSH\r\n"));
376        assert!(!is_ssh_payload(b"HTTP/1.1"));
377        assert!(!is_ssh_payload(b"SH"));
378    }
379}