Skip to main content

ssh_key/
known_hosts.rs

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