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