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
172#[allow(clippy::to_string_trait_impl)]
173impl ToString for Entry {
174    fn to_string(&self) -> String {
175        let mut s = String::new();
176
177        if let Some(marker) = &self.marker {
178            s.push_str(marker.as_str());
179            s.push(' ');
180        }
181
182        s.push_str(&self.host_patterns.to_string());
183        s.push(' ');
184
185        s.push_str(&self.public_key.to_string());
186        s
187    }
188}
189
190/// Markers associated with this host key entry.
191///
192/// There can only be one of these per host key entry.
193#[derive(Clone, Debug, Eq, PartialEq)]
194pub enum Marker {
195    /// This host entry's public key is for a certificate authority's private key
196    CertAuthority,
197    /// This host entry's public key has been revoked, and should not be allowed to connect
198    /// regardless of any other entry.
199    Revoked,
200}
201
202impl Marker {
203    /// Get the string form of the marker
204    pub fn as_str(&self) -> &str {
205        match self {
206            Self::CertAuthority => "@cert-authority",
207            Self::Revoked => "@revoked",
208        }
209    }
210}
211
212impl AsRef<str> for Marker {
213    fn as_ref(&self) -> &str {
214        self.as_str()
215    }
216}
217
218impl str::FromStr for Marker {
219    type Err = Error;
220
221    fn from_str(s: &str) -> Result<Self> {
222        Ok(match s {
223            "@cert-authority" => Marker::CertAuthority,
224            "@revoked" => Marker::Revoked,
225            _ => return Err(Error::FormatEncoding),
226        })
227    }
228}
229
230impl fmt::Display for Marker {
231    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
232        f.write_str(self.as_str())
233    }
234}
235
236/// The host pattern(s) for this host entry.
237///
238/// The host patterns can either be a comma separated list of host patterns
239/// (which may include glob patterns (`*` and `?`), negations (a `!` prefix),
240/// or `pattern:port` pairs inside square brackets), or a single hashed
241/// hostname prefixed with `|1|`.
242#[derive(Clone, Debug, Eq, PartialEq)]
243pub enum HostPatterns {
244    /// A comma separated list of hostname patterns.
245    Patterns(Vec<String>),
246    /// A single hashed hostname
247    HashedName {
248        /// The salt used for the hash
249        salt: Vec<u8>,
250        /// An SHA-1 hash of the hostname along with the salt
251        hash: [u8; 20],
252    },
253}
254
255impl str::FromStr for HostPatterns {
256    type Err = Error;
257
258    fn from_str(s: &str) -> Result<Self> {
259        if let Some(s) = s.strip_prefix(MAGIC_HASH_PREFIX) {
260            let mut hash = [0; 20];
261            let (salt, hash_str) = s.split_once('|').ok_or(Error::FormatEncoding)?;
262
263            let salt = Base64::decode_vec(salt)?;
264            Base64::decode(hash_str, &mut hash)?;
265
266            Ok(HostPatterns::HashedName { salt, hash })
267        } else if !s.is_empty() {
268            Ok(HostPatterns::Patterns(
269                s.split_terminator(',').map(str::to_string).collect(),
270            ))
271        } else {
272            Err(Error::FormatEncoding)
273        }
274    }
275}
276
277#[allow(clippy::to_string_trait_impl)]
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}