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