Skip to main content

ssh_key/
authorized_keys.rs

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