ssh_key/
known_hosts.rs

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