ssh_key/
authorized_keys.rs

1//! Parser for `AuthorizedKeysFile`-formatted data.
2
3use crate::{Error, PublicKey, Result};
4use core::str;
5
6#[cfg(feature = "alloc")]
7use {
8    alloc::string::{String, ToString},
9    core::fmt,
10};
11
12#[cfg(feature = "std")]
13use {
14    alloc::vec::Vec,
15    std::{fs, path::Path},
16};
17
18/// Character that begins a comment
19const COMMENT_DELIMITER: char = '#';
20
21/// Parser for `AuthorizedKeysFile`-formatted data, typically found in
22/// `~/.ssh/authorized_keys`.
23///
24/// For a full description of the format, see:
25/// <https://man7.org/linux/man-pages/man8/sshd.8.html#AUTHORIZED_KEYS_FILE_FORMAT>
26///
27/// Each line of the file consists of a single public key. Blank lines are ignored.
28///
29/// Public keys consist of the following space-separated fields:
30///
31/// ```text
32/// options, keytype, base64-encoded key, comment
33/// ```
34///
35/// - The options field is optional.
36/// - The keytype is `ecdsa-sha2-nistp256`, `ecdsa-sha2-nistp384`, `ecdsa-sha2-nistp521`,
37///   `ssh-ed25519`, `ssh-dss` or `ssh-rsa`
38/// - The comment field is not used for anything (but may be convenient for the user to identify
39///   the key).
40pub struct AuthorizedKeys<'a> {
41    /// Lines of the file being iterated over
42    lines: str::Lines<'a>,
43}
44
45impl<'a> AuthorizedKeys<'a> {
46    /// Create a new parser for the given input buffer.
47    pub fn new(input: &'a str) -> Self {
48        Self {
49            lines: input.lines(),
50        }
51    }
52
53    /// Read an [`AuthorizedKeys`] file from the filesystem, returning an
54    /// [`Entry`] vector on success.
55    #[cfg(feature = "std")]
56    pub fn read_file(path: impl AsRef<Path>) -> Result<Vec<Entry>> {
57        // TODO(tarcieri): permissions checks
58        let input = fs::read_to_string(path)?;
59        AuthorizedKeys::new(&input).collect()
60    }
61
62    /// Get the next line, trimming any comments and trailing whitespace.
63    ///
64    /// Ignores empty lines.
65    fn next_line_trimmed(&mut self) -> Option<&'a str> {
66        loop {
67            let mut line = self.lines.next()?;
68
69            // Strip comment if present
70            if let Some((l, _)) = line.split_once(COMMENT_DELIMITER) {
71                line = l;
72            }
73
74            // Trim trailing whitespace
75            line = line.trim_end();
76
77            if !line.is_empty() {
78                return Some(line);
79            }
80        }
81    }
82}
83
84impl Iterator for AuthorizedKeys<'_> {
85    type Item = Result<Entry>;
86
87    fn next(&mut self) -> Option<Result<Entry>> {
88        self.next_line_trimmed().map(|line| line.parse())
89    }
90}
91
92/// Individual entry in an `authorized_keys` file containing a single public key.
93#[derive(Clone, Debug, Eq, PartialEq)]
94pub struct Entry {
95    /// Configuration options field, if present.
96    #[cfg(feature = "alloc")]
97    config_opts: ConfigOpts,
98
99    /// Public key
100    public_key: PublicKey,
101}
102
103impl Entry {
104    /// Get configuration options for this entry.
105    #[cfg(feature = "alloc")]
106    pub fn config_opts(&self) -> &ConfigOpts {
107        &self.config_opts
108    }
109
110    /// Get public key for this entry.
111    pub fn public_key(&self) -> &PublicKey {
112        &self.public_key
113    }
114}
115
116#[cfg(feature = "alloc")]
117impl From<Entry> for ConfigOpts {
118    fn from(entry: Entry) -> ConfigOpts {
119        entry.config_opts
120    }
121}
122
123impl From<Entry> for PublicKey {
124    fn from(entry: Entry) -> PublicKey {
125        entry.public_key
126    }
127}
128
129impl From<PublicKey> for Entry {
130    fn from(public_key: PublicKey) -> Entry {
131        Entry {
132            #[cfg(feature = "alloc")]
133            config_opts: ConfigOpts::default(),
134            public_key,
135        }
136    }
137}
138
139impl str::FromStr for Entry {
140    type Err = Error;
141
142    fn from_str(line: &str) -> Result<Self> {
143        match line.matches(' ').count() {
144            1..=2 => Ok(Self {
145                #[cfg(feature = "alloc")]
146                config_opts: Default::default(),
147                public_key: line.parse()?,
148            }),
149            3.. => {
150                // Having >= 3 spaces is ambiguous: it's either a key preceded
151                // by options, or a key with spaces in its comment.  We'll try
152                // parsing as a single key first, then fall back to parsing as
153                // option + key.
154                match line.parse() {
155                    Ok(public_key) => Ok(Self {
156                        #[cfg(feature = "alloc")]
157                        config_opts: Default::default(),
158                        public_key,
159                    }),
160                    Err(_) => line
161                        .split_once(' ')
162                        .map(|(config_opts_str, public_key_str)| {
163                            ConfigOptsIter(config_opts_str).validate()?;
164
165                            Ok(Self {
166                                #[cfg(feature = "alloc")]
167                                config_opts: ConfigOpts(config_opts_str.to_string()),
168                                public_key: public_key_str.parse()?,
169                            })
170                        })
171                        .ok_or(Error::FormatEncoding)?,
172                }
173            }
174            _ => Err(Error::FormatEncoding),
175        }
176    }
177}
178
179#[cfg(feature = "alloc")]
180#[allow(clippy::to_string_trait_impl)]
181impl ToString for Entry {
182    fn to_string(&self) -> String {
183        let mut s = String::new();
184
185        if !self.config_opts.is_empty() {
186            s.push_str(self.config_opts.as_str());
187            s.push(' ');
188        }
189
190        s.push_str(&self.public_key.to_string());
191        s
192    }
193}
194
195/// Configuration options associated with a particular public key.
196///
197/// These options are a comma-separated list preceding each public key
198/// in the `authorized_keys` file.
199///
200/// The [`ConfigOpts::iter`] method can be used to iterate over each
201/// comma-separated value.
202#[cfg(feature = "alloc")]
203#[derive(Clone, Debug, Default, Eq, PartialEq)]
204pub struct ConfigOpts(String);
205
206#[cfg(feature = "alloc")]
207impl ConfigOpts {
208    /// Parse an options string.
209    pub fn new(string: impl Into<String>) -> Result<Self> {
210        let ret = Self(string.into());
211        ret.iter().validate()?;
212        Ok(ret)
213    }
214
215    /// Borrow the configuration options as a `str`.
216    pub fn as_str(&self) -> &str {
217        self.0.as_str()
218    }
219
220    /// Are there no configuration options?
221    pub fn is_empty(&self) -> bool {
222        self.0.is_empty()
223    }
224
225    /// Iterate over the comma-delimited configuration options.
226    pub fn iter(&self) -> ConfigOptsIter<'_> {
227        ConfigOptsIter(self.as_str())
228    }
229}
230
231#[cfg(feature = "alloc")]
232impl AsRef<str> for ConfigOpts {
233    fn as_ref(&self) -> &str {
234        self.as_str()
235    }
236}
237
238#[cfg(feature = "alloc")]
239impl str::FromStr for ConfigOpts {
240    type Err = Error;
241
242    fn from_str(s: &str) -> Result<Self> {
243        Self::new(s)
244    }
245}
246
247#[cfg(feature = "alloc")]
248impl fmt::Display for ConfigOpts {
249    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
250        f.write_str(&self.0)
251    }
252}
253
254/// Iterator over configuration options.
255#[derive(Clone, Debug)]
256pub struct ConfigOptsIter<'a>(&'a str);
257
258impl<'a> ConfigOptsIter<'a> {
259    /// Create new configuration options iterator.
260    ///
261    /// Validates that the options are well-formed.
262    pub fn new(s: &'a str) -> Result<Self> {
263        let ret = Self(s);
264        ret.clone().validate()?;
265        Ok(ret)
266    }
267
268    /// Validate that config options are well-formed.
269    fn validate(&mut self) -> Result<()> {
270        while self.try_next()?.is_some() {}
271        Ok(())
272    }
273
274    /// Attempt to parse the next comma-delimited option string.
275    fn try_next(&mut self) -> Result<Option<&'a str>> {
276        if self.0.is_empty() {
277            return Ok(None);
278        }
279
280        let mut quoted = false;
281        let mut index = 0;
282
283        while let Some(byte) = self.0.as_bytes().get(index).cloned() {
284            match byte {
285                b',' => {
286                    // Commas inside quoted text are ignored
287                    if !quoted {
288                        let (next, rest) = self.0.split_at(index);
289                        self.0 = &rest[1..]; // Strip comma
290                        return Ok(Some(next));
291                    }
292                }
293                // TODO(tarcieri): stricter handling of quotes
294                b'"' => {
295                    // Toggle quoted mode on-off
296                    quoted = !quoted;
297                }
298                // Valid characters
299                b'A'..=b'Z'
300                | b'a'..=b'z'
301                | b'0'..=b'9'
302                | b'!'..=b'/'
303                | b':'..=b'@'
304                | b'['..=b'_'
305                | b'{'
306                | b'}'
307                | b'|'
308                | b'~' => (),
309                _ => return Err(encoding::Error::CharacterEncoding.into()),
310            }
311
312            index = index.checked_add(1).ok_or(encoding::Error::Length)?;
313        }
314
315        let remaining = self.0;
316        self.0 = "";
317        Ok(Some(remaining))
318    }
319}
320
321impl<'a> Iterator for ConfigOptsIter<'a> {
322    type Item = &'a str;
323
324    fn next(&mut self) -> Option<&'a str> {
325        // Ensured valid by constructor
326        self.try_next().expect("malformed options string")
327    }
328}
329
330#[cfg(all(test, feature = "alloc"))]
331mod tests {
332    use super::ConfigOptsIter;
333
334    #[test]
335    fn options_empty() {
336        assert_eq!(ConfigOptsIter("").try_next(), Ok(None));
337    }
338
339    #[test]
340    fn options_no_comma() {
341        let mut opts = ConfigOptsIter("foo");
342        assert_eq!(opts.try_next(), Ok(Some("foo")));
343        assert_eq!(opts.try_next(), Ok(None));
344    }
345
346    #[test]
347    fn options_no_comma_quoted() {
348        let mut opts = ConfigOptsIter("foo=\"bar\"");
349        assert_eq!(opts.try_next(), Ok(Some("foo=\"bar\"")));
350        assert_eq!(opts.try_next(), Ok(None));
351
352        // Comma inside quoted section
353        let mut opts = ConfigOptsIter("foo=\"bar,baz\"");
354        assert_eq!(opts.try_next(), Ok(Some("foo=\"bar,baz\"")));
355        assert_eq!(opts.try_next(), Ok(None));
356    }
357
358    #[test]
359    fn options_comma_delimited() {
360        let mut opts = ConfigOptsIter("foo,bar");
361        assert_eq!(opts.try_next(), Ok(Some("foo")));
362        assert_eq!(opts.try_next(), Ok(Some("bar")));
363        assert_eq!(opts.try_next(), Ok(None));
364    }
365
366    #[test]
367    fn options_comma_delimited_quoted() {
368        let mut opts = ConfigOptsIter("foo=\"bar\",baz");
369        assert_eq!(opts.try_next(), Ok(Some("foo=\"bar\"")));
370        assert_eq!(opts.try_next(), Ok(Some("baz")));
371        assert_eq!(opts.try_next(), Ok(None));
372    }
373
374    #[test]
375    fn options_invalid_character() {
376        let mut opts = ConfigOptsIter("❌");
377        assert_eq!(
378            opts.try_next(),
379            Err(encoding::Error::CharacterEncoding.into())
380        );
381
382        let mut opts = ConfigOptsIter("x,❌");
383        assert_eq!(opts.try_next(), Ok(Some("x")));
384        assert_eq!(
385            opts.try_next(),
386            Err(encoding::Error::CharacterEncoding.into())
387        );
388    }
389}