ssh_key/
known_hosts.rs

1//! Parser for `KnownHostsFile`-formatted data.
2
3use base64ct::{Base64, Encoding};
4
5use crate::{Error, PublicKey, Result};
6use core::str;
7
8use {
9    alloc::string::{String, ToString},
10    alloc::vec::Vec,
11    core::fmt,
12};
13
14#[cfg(feature = "std")]
15use std::{fs, path::Path};
16
17/// Character that begins a comment
18const COMMENT_DELIMITER: char = '#';
19/// The magic string prefix of a hashed hostname
20const MAGIC_HASH_PREFIX: &str = "|1|";
21
22/// Parser for `KnownHostsFile`-formatted data, typically found in
23/// `~/.ssh/known_hosts`.
24///
25/// For a full description of the format, see:
26/// <https://man7.org/linux/man-pages/man8/sshd.8.html#SSH_KNOWN_HOSTS_FILE_FORMAT>
27///
28/// Each line of the file consists of a single public key tied to one or more hosts.
29/// Blank lines are ignored.
30///
31/// Public keys consist of the following space-separated fields:
32///
33/// ```text
34/// marker, hostnames, keytype, base64-encoded key, comment
35/// ```
36///
37/// - The marker field is optional, but if present begins with an `@`. Known markers are `@cert-authority`
38///   and `@revoked`.
39/// - The hostnames is a comma-separated list of patterns (with `*` and '?' as glob-style wildcards)
40///   against which hosts are matched. If it begins with a `!` it is a negation of the pattern. If the
41///   pattern starts with `[` and ends with `]`, it contains a hostname pattern and a port number separated
42///   by a `:`. If it begins with `|1|`, the hostname is hashed. In that case, there can only be one exact
43///   hostname and it can't also be negated (ie. `!|1|x|y` is not legal and you can't hash `*.example.org`).
44/// - The keytype is `ecdsa-sha2-nistp256`, `ecdsa-sha2-nistp384`, `ecdsa-sha2-nistp521`,
45///   `ssh-ed25519`, `ssh-dss` or `ssh-rsa`
46/// - The comment field is not used for anything (but may be convenient for the user to identify
47///   the key).
48pub struct KnownHosts<'a> {
49    /// Lines of the file being iterated over
50    lines: core::str::Lines<'a>,
51}
52
53impl<'a> KnownHosts<'a> {
54    /// Create a new parser for the given input buffer.
55    pub fn new(input: &'a str) -> Self {
56        Self {
57            lines: input.lines(),
58        }
59    }
60
61    /// Read a [`KnownHosts`] file from the filesystem, returning an
62    /// [`Entry`] vector on success.
63    #[cfg(feature = "std")]
64    #[cfg_attr(docsrs, doc(cfg(feature = "std")))]
65    pub fn read_file(path: impl AsRef<Path>) -> Result<Vec<Entry>> {
66        // TODO(tarcieri): permissions checks
67        let input = fs::read_to_string(path)?;
68        KnownHosts::new(&input).collect()
69    }
70
71    /// Get the next line, trimming any comments and trailing whitespace.
72    ///
73    /// Ignores empty lines.
74    fn next_line_trimmed(&mut self) -> Option<&'a str> {
75        loop {
76            let mut line = self.lines.next()?;
77
78            // Strip comment if present
79            if let Some((l, _)) = line.split_once(COMMENT_DELIMITER) {
80                line = l;
81            }
82
83            // Trim trailing whitespace
84            line = line.trim_end();
85
86            if !line.is_empty() {
87                return Some(line);
88            }
89        }
90    }
91}
92
93impl Iterator for KnownHosts<'_> {
94    type Item = Result<Entry>;
95
96    fn next(&mut self) -> Option<Result<Entry>> {
97        self.next_line_trimmed().map(|line| line.parse())
98    }
99}
100
101/// Individual entry in an `known_hosts` file containing a single public key.
102#[derive(Clone, Debug, Eq, PartialEq)]
103pub struct Entry {
104    /// Marker field, if present.
105    marker: Option<Marker>,
106
107    /// Host patterns
108    host_patterns: HostPatterns,
109
110    /// Public key
111    public_key: PublicKey,
112}
113
114impl Entry {
115    /// Get the marker for this entry, if present.
116    pub fn marker(&self) -> Option<&Marker> {
117        self.marker.as_ref()
118    }
119
120    /// Get the host pattern enumerator for this entry
121    pub fn host_patterns(&self) -> &HostPatterns {
122        &self.host_patterns
123    }
124
125    /// Get public key for this entry.
126    pub fn public_key(&self) -> &PublicKey {
127        &self.public_key
128    }
129}
130impl From<Entry> for Option<Marker> {
131    fn from(entry: Entry) -> Option<Marker> {
132        entry.marker
133    }
134}
135impl From<Entry> for HostPatterns {
136    fn from(entry: Entry) -> HostPatterns {
137        entry.host_patterns
138    }
139}
140impl From<Entry> for PublicKey {
141    fn from(entry: Entry) -> PublicKey {
142        entry.public_key
143    }
144}
145
146impl str::FromStr for Entry {
147    type Err = Error;
148
149    fn from_str(line: &str) -> Result<Self> {
150        // Unlike authorized_keys, in known_hosts it's pretty common
151        // to not include a key comment, so the number of spaces is
152        // not a reliable indicator of the fields in the line. Instead,
153        // the optional marker field starts with an @, so look for that
154        // and act accordingly.
155        let (marker, line) = if line.starts_with('@') {
156            let (marker_str, line) = line.split_once(' ').ok_or(Error::FormatEncoding)?;
157            (Some(marker_str.parse()?), line)
158        } else {
159            (None, line)
160        };
161        let (hosts_str, public_key_str) = line.split_once(' ').ok_or(Error::FormatEncoding)?;
162
163        let host_patterns = hosts_str.parse()?;
164        let public_key = public_key_str.parse()?;
165
166        Ok(Self {
167            marker,
168            host_patterns,
169            public_key,
170        })
171    }
172}
173
174impl ToString for Entry {
175    fn to_string(&self) -> String {
176        let mut s = String::new();
177
178        if let Some(marker) = &self.marker {
179            s.push_str(marker.as_str());
180            s.push(' ');
181        }
182
183        s.push_str(&self.host_patterns.to_string());
184        s.push(' ');
185
186        s.push_str(&self.public_key.to_string());
187        s
188    }
189}
190
191/// Markers associated with this host key entry.
192///
193/// There can only be one of these per host key entry.
194#[derive(Clone, Debug, Eq, PartialEq)]
195pub enum Marker {
196    /// This host entry's public key is for a certificate authority's private key
197    CertAuthority,
198    /// This host entry's public key has been revoked, and should not be allowed to connect
199    /// regardless of any other entry.
200    Revoked,
201}
202
203impl Marker {
204    /// Get the string form of the marker
205    pub fn as_str(&self) -> &str {
206        match self {
207            Self::CertAuthority => "@cert-authority",
208            Self::Revoked => "@revoked",
209        }
210    }
211}
212
213impl AsRef<str> for Marker {
214    fn as_ref(&self) -> &str {
215        self.as_str()
216    }
217}
218
219impl str::FromStr for Marker {
220    type Err = Error;
221
222    fn from_str(s: &str) -> Result<Self> {
223        Ok(match s {
224            "@cert-authority" => Marker::CertAuthority,
225            "@revoked" => Marker::Revoked,
226            _ => return Err(Error::FormatEncoding),
227        })
228    }
229}
230
231impl fmt::Display for Marker {
232    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
233        f.write_str(self.as_str())
234    }
235}
236
237/// The host pattern(s) for this host entry.
238///
239/// The host patterns can either be a comma separated list of host patterns
240/// (which may include glob patterns (`*` and `?`), negations (a `!` prefix),
241/// or `pattern:port` pairs inside square brackets), or a single hashed
242/// hostname prefixed with `|1|`.
243#[derive(Clone, Debug, Eq, PartialEq)]
244pub enum HostPatterns {
245    /// A comma separated list of hostname patterns.
246    Patterns(Vec<String>),
247    /// A single hashed hostname
248    HashedName {
249        /// The salt used for the hash
250        salt: Vec<u8>,
251        /// An SHA-1 hash of the hostname along with the salt
252        hash: [u8; 20],
253    },
254}
255
256impl str::FromStr for HostPatterns {
257    type Err = Error;
258
259    fn from_str(s: &str) -> Result<Self> {
260        if let Some(s) = s.strip_prefix(MAGIC_HASH_PREFIX) {
261            let mut hash = [0; 20];
262            let (salt, hash_str) = s.split_once('|').ok_or(Error::FormatEncoding)?;
263
264            let salt = Base64::decode_vec(salt)?;
265            Base64::decode(hash_str, &mut hash)?;
266
267            Ok(HostPatterns::HashedName { salt, hash })
268        } else if !s.is_empty() {
269            Ok(HostPatterns::Patterns(
270                s.split_terminator(',').map(str::to_string).collect(),
271            ))
272        } else {
273            Err(Error::FormatEncoding)
274        }
275    }
276}
277
278impl ToString for HostPatterns {
279    fn to_string(&self) -> String {
280        match &self {
281            HostPatterns::Patterns(patterns) => patterns.join(","),
282            HostPatterns::HashedName { salt, hash } => {
283                let salt = Base64::encode_string(salt);
284                let hash = Base64::encode_string(hash);
285                format!("|1|{}|{}", salt, hash)
286            }
287        }
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use alloc::string::ToString;
294    use core::str::FromStr;
295
296    use super::Entry;
297    use super::HostPatterns;
298    use super::Marker;
299
300    #[test]
301    fn simple_markers() {
302        assert_eq!(Ok(Marker::CertAuthority), "@cert-authority".parse());
303        assert_eq!(Ok(Marker::Revoked), "@revoked".parse());
304        assert!(Marker::from_str("@gibberish").is_err());
305    }
306
307    #[test]
308    fn empty_host_patterns() {
309        assert!(HostPatterns::from_str("").is_err());
310    }
311
312    // Note: The sshd man page has this completely incomprehensible 'example known_hosts entry':
313    // closenet,...,192.0.2.53 1024 37 159...93 closenet.example.net
314    // I'm not sure how this one is supposed to work or what it means.
315
316    #[test]
317    fn single_host_pattern() {
318        assert_eq!(
319            Ok(HostPatterns::Patterns(vec!["cvs.example.net".to_string()])),
320            "cvs.example.net".parse()
321        );
322    }
323    #[test]
324    fn multiple_host_patterns() {
325        assert_eq!(
326            Ok(HostPatterns::Patterns(vec![
327                "cvs.example.net".to_string(),
328                "!test.example.???".to_string(),
329                "[*.example.net]:999".to_string(),
330            ])),
331            "cvs.example.net,!test.example.???,[*.example.net]:999".parse()
332        );
333    }
334    #[test]
335    fn single_hashed_host() {
336        assert_eq!(
337            Ok(HostPatterns::HashedName {
338                salt: vec![
339                    37, 242, 147, 116, 24, 123, 172, 214, 215, 145, 80, 16, 9, 26, 120, 57, 10, 15,
340                    126, 98
341                ],
342                hash: [
343                    81, 33, 2, 175, 116, 150, 127, 82, 84, 62, 201, 172, 228, 10, 159, 15, 148, 31,
344                    198, 67
345                ],
346            }),
347            "|1|JfKTdBh7rNbXkVAQCRp4OQoPfmI=|USECr3SWf1JUPsms5AqfD5QfxkM=".parse()
348        );
349    }
350
351    #[test]
352    fn full_line_hashed() {
353        let line = "@revoked |1|lcY/In3lsGnkJikLENb0DM70B/I=|Qs4e9Nr7mM6avuEv02fw2uFnwQo= ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIB9dG4kjRhQTtWTVzd2t27+t0DEHBPW7iOD23TUiYLio comment";
354        let entry = Entry::from_str(line).expect("Valid entry");
355        assert_eq!(entry.marker(), Some(&Marker::Revoked));
356        assert_eq!(
357            entry.host_patterns(),
358            &HostPatterns::HashedName {
359                salt: vec![
360                    149, 198, 63, 34, 125, 229, 176, 105, 228, 38, 41, 11, 16, 214, 244, 12, 206,
361                    244, 7, 242
362                ],
363                hash: [
364                    66, 206, 30, 244, 218, 251, 152, 206, 154, 190, 225, 47, 211, 103, 240, 218,
365                    225, 103, 193, 10
366                ],
367            }
368        );
369        // key parsing is tested elsewhere
370    }
371}