From c204c4124cc1ab70c7e75f62761bc1b8d2738a0a Mon Sep 17 00:00:00 2001 From: Tony Arcieri Date: Mon, 28 Feb 2022 09:01:13 -0700 Subject: [PATCH] ssh-key: `AuthorizedKeys` parser Adds a parser for `authorized_keys` files which contain a list of SSH public keys each predicated by option flags. --- ssh-key/Cargo.toml | 4 +- ssh-key/README.md | 5 +- ssh-key/src/authorized_keys.rs | 145 +++++++++++++++++++++++++ ssh-key/src/error.rs | 15 +++ ssh-key/src/lib.rs | 2 + ssh-key/src/public.rs | 2 +- ssh-key/tests/authorized_keys.rs | 35 ++++++ ssh-key/tests/examples/authorized_keys | 28 +++++ 8 files changed, 232 insertions(+), 4 deletions(-) create mode 100644 ssh-key/src/authorized_keys.rs create mode 100644 ssh-key/tests/authorized_keys.rs create mode 100644 ssh-key/tests/examples/authorized_keys diff --git a/ssh-key/Cargo.toml b/ssh-key/Cargo.toml index 75d53dd49..12ac20a2a 100644 --- a/ssh-key/Cargo.toml +++ b/ssh-key/Cargo.toml @@ -3,8 +3,8 @@ name = "ssh-key" version = "0.3.0-pre" # Also update html_root_url in lib.rs when bumping this description = """ Pure Rust implementation of SSH key file format decoders/encoders as described -in RFC4253 and RFC4716 as well as the OpenSSH key formats. Supports "heapless" -`no_std` embedded targets with an optional `alloc` feature (Ed25519 and ECDSA only) +in RFC4253 and RFC4716 as well as the OpenSSH key formats and `authorized_keys`. +Supports "heapless" `no_std` embedded targets with an optional `alloc` feature. """ authors = ["RustCrypto Developers"] license = "Apache-2.0 OR MIT" diff --git a/ssh-key/README.md b/ssh-key/README.md index faa928f68..524c39a48 100644 --- a/ssh-key/README.md +++ b/ssh-key/README.md @@ -12,7 +12,8 @@ ## About Pure Rust implementation of SSH key file format decoders/encoders as described -in [RFC4253] and [RFC4716] as well as OpenSSH's [PROTOCOL.key] format specification. +in [RFC4253] and [RFC4716] as well as OpenSSH's [PROTOCOL.key] format specification +and `authorized_keys` files. ## Features @@ -23,6 +24,7 @@ in [RFC4253] and [RFC4716] as well as OpenSSH's [PROTOCOL.key] format specificat - [x] ECDSA (`no_std` "heapless") - [x] Ed25519 (`no_std` "heapless") - [x] RSA (`no_std` + `alloc`) +- [x] Parsing `autorized_keys` files - [x] Built-in zeroize support for private keys #### TODO: @@ -31,6 +33,7 @@ in [RFC4253] and [RFC4716] as well as OpenSSH's [PROTOCOL.key] format specificat - [ ] Encrypted private key support - [ ] Legacy SSH key (pre-OpenSSH) format support - [ ] Integrations with other RustCrypto crates (e.g. `ecdsa`, `ed25519`, `rsa`) +- [ ] FIDO2 key support ## Minimum Supported Rust Version diff --git a/ssh-key/src/authorized_keys.rs b/ssh-key/src/authorized_keys.rs new file mode 100644 index 000000000..0277f4c54 --- /dev/null +++ b/ssh-key/src/authorized_keys.rs @@ -0,0 +1,145 @@ +//! Parser for `AuthorizedKeysFile`-formatted data. + +use crate::{Error, PublicKey, Result}; + +#[cfg(feature = "std")] +use std::{fs, path::Path}; + +/// Character that begins a comment +const COMMENT_DELIMITER: char = '#'; + +/// Parser for `AuthorizedKeysFile`-formatted data, typically found in +/// `~/.ssh/authorized_keys`. +/// +/// For a full description of the format, see: +/// +/// +/// Each line of the file consists of a single public key. Blank lines are ignored. +/// +/// Public keys consist of the following space-separated fields: +/// +/// ```text +/// options, keytype, base64-encoded key, comment +/// ``` +/// +/// - The options field is optional. +/// - The keytype is `ecdsa-sha2-nistp256`, `ecdsa-sha2-nistp384`, `ecdsa-sha2-nistp521`, +/// `ssh-ed25519`, `ssh-dss` or `ssh-rsa` +/// - The comment field is not used for anything (but may be convenient for the user to identify +/// the key). +pub struct AuthorizedKeys<'a> { + /// Lines of the file being iterated over + lines: core::str::Lines<'a>, +} + +impl<'a> AuthorizedKeys<'a> { + /// Create a new parser for the given input buffer. + pub fn new(input: &'a str) -> Self { + Self { + lines: input.lines(), + } + } + + /// Read a file from the filesystem, calling the given closure with an + /// [`AuthorizedKeys`] parser which operates over a temporary buffer. + #[cfg(feature = "std")] + #[cfg_attr(docsrs, doc(cfg(feature = "std")))] + pub fn read_file(path: impl AsRef, f: F) -> Result + where + F: FnOnce(AuthorizedKeys<'_>) -> Result, + { + // TODO(tarcieri): permissions checks + let input = fs::read_to_string(path)?; + f(AuthorizedKeys::new(&input)) + } +} + +impl<'a> Iterator for AuthorizedKeys<'a> { + type Item = Result>; + + fn next(&mut self) -> Option>> { + loop { + let result = LineParser::new(self.lines.next()?); + + match result { + Ok(LineParser { + options_str: None, + public_key_str: None, + }) => (), + Ok(line) => return Some(line.try_into()), + Err(err) => return Some(Err(err)), + } + } + } +} + +/// Individual entry in an `authorized_keys` file containing a single public key. +#[derive(Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +pub struct Entry<'a> { + /// Options field, if present. + pub options: Option<&'a str>, + + /// Public key + pub public_key: PublicKey, +} + +impl<'a> TryFrom> for Entry<'a> { + type Error = Error; + + fn try_from(line: LineParser<'a>) -> Result> { + let public_key = line + .public_key_str + .ok_or(Error::FormatEncoding)? + .parse::()?; + + Ok(Self { + options: line.options_str, + public_key, + }) + } +} + +/// Parser for an individual line in an `authorized_keys` file. +#[derive(Debug)] +struct LineParser<'a> { + /// Options field, if present. + options_str: Option<&'a str>, + + /// Public key data, if present. + public_key_str: Option<&'a str>, +} + +impl<'a> LineParser<'a> { + /// Parse the given line. + pub fn new(mut line: &'a str) -> Result { + // Strip comment, if present + if let Some((l, _)) = line.split_once(COMMENT_DELIMITER) { + line = l; + } + + // Trim trailing whitespace + line = line.trim_end(); + + if line.is_empty() { + return Ok(Self { + options_str: None, + public_key_str: None, + }); + } + + match line.matches(' ').count() { + 1..=2 => Ok(Self { + options_str: None, + public_key_str: Some(line), + }), + 3 => match line.split_once(' ') { + Some((options_str, public_key_str)) => Ok(Self { + options_str: Some(options_str), + public_key_str: Some(public_key_str), + }), + _ => Err(Error::FormatEncoding), + }, + _ => Err(Error::FormatEncoding), + } + } +} diff --git a/ssh-key/src/error.rs b/ssh-key/src/error.rs index fe3dd121d..136f072fe 100644 --- a/ssh-key/src/error.rs +++ b/ssh-key/src/error.rs @@ -26,6 +26,11 @@ pub enum Error { /// Other format encoding errors. FormatEncoding, + /// Input/output errors. + #[cfg(feature = "std")] + #[cfg_attr(docsrs, doc(cfg(feature = "std")))] + Io(std::io::ErrorKind), + /// Invalid length. Length, @@ -45,6 +50,8 @@ impl fmt::Display for Error { #[cfg(feature = "ecdsa")] Error::Ecdsa(err) => write!(f, "ECDSA encoding error: {}", err), Error::FormatEncoding => f.write_str("format encoding error"), + #[cfg(feature = "std")] + Error::Io(err) => write!(f, "I/O error: {}", std::io::Error::from(*err)), Error::Length => f.write_str("length invalid"), Error::Overflow => f.write_str("internal overflow error"), Error::Pem => f.write_str("PEM encoding error"), @@ -106,3 +113,11 @@ impl From for Error { Error::Ecdsa(err) } } + +#[cfg(feature = "std")] +#[cfg_attr(docsrs, doc(cfg(feature = "std")))] +impl From for Error { + fn from(err: std::io::Error) -> Error { + Error::Io(err.kind()) + } +} diff --git a/ssh-key/src/lib.rs b/ssh-key/src/lib.rs index f04a959f5..17006496d 100644 --- a/ssh-key/src/lib.rs +++ b/ssh-key/src/lib.rs @@ -114,6 +114,7 @@ extern crate alloc; #[cfg(feature = "std")] extern crate std; +pub mod authorized_keys; pub mod private; pub mod public; @@ -126,6 +127,7 @@ mod mpint; pub use crate::{ algorithm::{Algorithm, CipherAlg, EcdsaCurve, KdfAlg, KdfOptions}, + authorized_keys::AuthorizedKeys, error::{Error, Result}, private::PrivateKey, public::PublicKey, diff --git a/ssh-key/src/public.rs b/ssh-key/src/public.rs index 9481e8241..7bdbd4ae2 100644 --- a/ssh-key/src/public.rs +++ b/ssh-key/src/public.rs @@ -30,7 +30,7 @@ use alloc::{ }; /// SSH public key. -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] pub struct PublicKey { /// Key data. pub key_data: KeyData, diff --git a/ssh-key/tests/authorized_keys.rs b/ssh-key/tests/authorized_keys.rs new file mode 100644 index 000000000..2067a7b54 --- /dev/null +++ b/ssh-key/tests/authorized_keys.rs @@ -0,0 +1,35 @@ +//! Tests for parsing `authorized_keys` files. + +#![cfg(all(feature = "ecdsa", feature = "std"))] + +use ssh_key::AuthorizedKeys; + +// TODO(tarcieri): test file permissions +#[test] +fn read_example_file() { + AuthorizedKeys::read_file("./tests/examples/authorized_keys", |mut authorized_keys| { + let entry1 = authorized_keys.next().unwrap()?; + assert_eq!(entry1.options, None); + assert_eq!(entry1.public_key.to_string(), "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILM+rvN+ot98qgEN796jTiQfZfG1KaT0PtFDJ/XFSqti user1@example.com"); + assert_eq!(entry1.public_key.comment, "user1@example.com"); + + let entry2 = authorized_keys.next().unwrap()?; + assert_eq!(entry2.options, Some("command=\"/usr/bin/date\"")); + assert_eq!(entry2.public_key.to_string(), "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBHwf2HMM5TRXvo2SQJjsNkiDD5KqiiNjrGVv3UUh+mMT5RHxiRtOnlqvjhQtBq0VpmpCV/PwUdhOig4vkbqAcEc= user2@example.com"); + assert_eq!(entry2.public_key.comment, "user2@example.com"); + + let entry3 = authorized_keys.next().unwrap()?; + assert_eq!(entry3.options, Some("environment=\"PATH=/bin:/usr/bin\"")); + assert_eq!(entry3.public_key.to_string(), "ssh-dss AAAAB3NzaC1kc3MAAACBANw9iSUO2UYhFMssjUgW46URqv8bBrDgHeF8HLBOWBvKuXF2Rx2J/XyhgX48SOLMuv0hcPaejlyLarabnF9F2V4dkpPpZSJ+7luHmxEjNxwhsdtg8UteXAWkeCzrQ6MvRJZHcDBjYh56KGvslbFnJsGLXlI4PQCyl6awNImwYGilAAAAFQCJGBU3hZf+QtP9Jh/nbfNlhFu7hwAAAIBHObOQioQVRm3HsVb7mOy3FVKhcLoLO3qoG9gTkd4KeuehtFAC3+rckiX7xSCnE/5BBKdL7VP9WRXac2Nlr9Pwl3e7zPut96wrCHt/TZX6vkfXKkbpUIj5zSqfvyNrWKaYJkfzwAQwrXNS1Hol676Ud/DDEn2oatdEhkS3beWHXAAAAIBgQqaz/YYTRMshzMzYcZ4lqgvgmA55y6v0h39e8HH2A5dwNS6sPUw2jyna+le0dceNRJifFld1J+WYM0vmquSr11DDavgEidOSaXwfMvPPPJqLmbzdtT16N+Gij9U9STQTHPQcQ3xnNNHgQAStzZJbhLOVbDDDo5BO7LMUALDfSA== user3@example.com"); + assert_eq!(entry3.public_key.comment, "user3@example.com"); + + let entry4 = authorized_keys.next().unwrap()?; + assert_eq!(entry4.options, Some("from=\"10.0.0.?,*.example.com\",no-X11-forwarding")); + assert_eq!(entry4.public_key.to_string(), "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQC0WRHtxuxefSJhpIxGq4ibGFgwYnESPm8C3JFM88A1JJLoprenklrd7VJ+VH3Ov/bQwZwLyRU5dRmfR/SWTtIPWs7tToJVayKKDB+/qoXmM5ui/0CU2U4rCdQ6PdaCJdC7yFgpPL8WexjWN06+eSIKYz1AAXbx9rRv1iasslK/KUqtsqzVliagI6jl7FPO2GhRZMcso6LsZGgSxuYf/Lp0D/FcBU8GkeOo1Sx5xEt8H8bJcErtCe4Blb8JxcW6EXO3sReb4z+zcR07gumPgFITZ6hDA8sSNuvo/AlWg0IKTeZSwHHVknWdQqDJ0uczE837caBxyTZllDNIGkBjCIIOFzuTT76HfYc/7CTTGk07uaNkUFXKN79xDiFOX8JQ1ZZMZvGOTwWjuT9CqgdTvQRORbRWwOYv3MH8re9ykw3Ip6lrPifY7s6hOaAKry/nkGPMt40m1TdiW98MTIpooE7W+WXu96ax2l2OJvxX8QR7l+LFlKnkIEEJd/ItF1G22UmOjkVwNASTwza/hlY+8DoVvEmwum/nMgH2TwQT3bTQzF9s9DOJkH4d8p4Mw4gEDjNx0EgUFA91ysCAeUMQQyIvuR8HXXa+VcvhOOO5mmBcVhxJ3qUOJTyDBsT0932Zb4mNtkxdigoVxu+iiwk0vwtvKwGVDYdyMP5EAQeEIP1t0w== user4@example.com"); + assert_eq!(entry4.public_key.comment, "user4@example.com"); + + assert_eq!(authorized_keys.next(), None); + Ok(()) + }) + .unwrap(); +} diff --git a/ssh-key/tests/examples/authorized_keys b/ssh-key/tests/examples/authorized_keys new file mode 100644 index 000000000..da10231b6 --- /dev/null +++ b/ssh-key/tests/examples/authorized_keys @@ -0,0 +1,28 @@ +# Example authorized keys file +# +# - Comments in these files begin with `#` +# - They can also contain blank lines +# - Lines which are not blank each contain a single public key +# - Maximum line length is 8 kilobytes +# +# Public keys consist of the following space-separated fields: +# +# options, keytype, base64-encoded key, comment +# +# - The options field is optional. +# - The keytype is `ecdsa-sha2-nistp256`, `ecdsa-sha2-nistp384`, `ecdsa-sha2-nistp521`, +# `ssh-ed25519`, `ssh-dss` or `ssh-rsa` +# - The comment field is not used for anything (but may be convenient for the user to +# identify the key). + +# Public key with no options +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILM+rvN+ot98qgEN796jTiQfZfG1KaT0PtFDJ/XFSqti user1@example.com + +# Public key which can only read the current date +command="/usr/bin/date" ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBHwf2HMM5TRXvo2SQJjsNkiDD5KqiiNjrGVv3UUh+mMT5RHxiRtOnlqvjhQtBq0VpmpCV/PwUdhOig4vkbqAcEc= user2@example.com + +# Public key which ensures a certain environment is set +environment="PATH=/bin:/usr/bin" ssh-dss AAAAB3NzaC1kc3MAAACBANw9iSUO2UYhFMssjUgW46URqv8bBrDgHeF8HLBOWBvKuXF2Rx2J/XyhgX48SOLMuv0hcPaejlyLarabnF9F2V4dkpPpZSJ+7luHmxEjNxwhsdtg8UteXAWkeCzrQ6MvRJZHcDBjYh56KGvslbFnJsGLXlI4PQCyl6awNImwYGilAAAAFQCJGBU3hZf+QtP9Jh/nbfNlhFu7hwAAAIBHObOQioQVRm3HsVb7mOy3FVKhcLoLO3qoG9gTkd4KeuehtFAC3+rckiX7xSCnE/5BBKdL7VP9WRXac2Nlr9Pwl3e7zPut96wrCHt/TZX6vkfXKkbpUIj5zSqfvyNrWKaYJkfzwAQwrXNS1Hol676Ud/DDEn2oatdEhkS3beWHXAAAAIBgQqaz/YYTRMshzMzYcZ4lqgvgmA55y6v0h39e8HH2A5dwNS6sPUw2jyna+le0dceNRJifFld1J+WYM0vmquSr11DDavgEidOSaXwfMvPPPJqLmbzdtT16N+Gij9U9STQTHPQcQ3xnNNHgQAStzZJbhLOVbDDDo5BO7LMUALDfSA== user3@example.com + +# Public key which can only be used from certain source addresses and disallows X11 forwarding +from="10.0.0.?,*.example.com",no-X11-forwarding ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQC0WRHtxuxefSJhpIxGq4ibGFgwYnESPm8C3JFM88A1JJLoprenklrd7VJ+VH3Ov/bQwZwLyRU5dRmfR/SWTtIPWs7tToJVayKKDB+/qoXmM5ui/0CU2U4rCdQ6PdaCJdC7yFgpPL8WexjWN06+eSIKYz1AAXbx9rRv1iasslK/KUqtsqzVliagI6jl7FPO2GhRZMcso6LsZGgSxuYf/Lp0D/FcBU8GkeOo1Sx5xEt8H8bJcErtCe4Blb8JxcW6EXO3sReb4z+zcR07gumPgFITZ6hDA8sSNuvo/AlWg0IKTeZSwHHVknWdQqDJ0uczE837caBxyTZllDNIGkBjCIIOFzuTT76HfYc/7CTTGk07uaNkUFXKN79xDiFOX8JQ1ZZMZvGOTwWjuT9CqgdTvQRORbRWwOYv3MH8re9ykw3Ip6lrPifY7s6hOaAKry/nkGPMt40m1TdiW98MTIpooE7W+WXu96ax2l2OJvxX8QR7l+LFlKnkIEEJd/ItF1G22UmOjkVwNASTwza/hlY+8DoVvEmwum/nMgH2TwQT3bTQzF9s9DOJkH4d8p4Mw4gEDjNx0EgUFA91ysCAeUMQQyIvuR8HXXa+VcvhOOO5mmBcVhxJ3qUOJTyDBsT0932Zb4mNtkxdigoVxu+iiwk0vwtvKwGVDYdyMP5EAQeEIP1t0w== user4@example.com