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