| // Copyright 2021 The Fuchsia Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| use {fidl_fuchsia_ssh::MAX_SSH_KEY_LENGTH, std::str::FromStr, thiserror::Error}; |
| |
| #[derive(Error, Debug, PartialEq)] |
| /// Errors that occur while parsing a key. |
| pub enum ParseKeyError { |
| #[error("Wrong number of fields in key")] |
| WrongNumberOfFields, |
| #[error("Key too long")] |
| KeyTooLong, |
| #[error("Invalid key type")] |
| InvalidKeyType, |
| #[error("Key is a comment or empty")] |
| InvalidKey, |
| } |
| |
| /// See //third_party/openssh-portable/sshd.8 |
| const VALID_KEY_TYPES: [&str; 8] = [ |
| "sk-ecdsa-sha2-nistp256@openssh.com", |
| "ecdsa-sha2-nistp256", |
| "ecdsa-sha2-nistp384", |
| "ecdsa-sha2-nistp521", |
| "sk-ssh-ed25519@openssh.com", |
| "ssh-ed25519", |
| "ssh-dss", |
| "ssh-rsa", |
| ]; |
| |
| fn is_valid_key_type(typ: &str) -> bool { |
| VALID_KEY_TYPES.iter().any(|v| *v == typ) |
| } |
| |
| #[derive(Debug, Clone)] |
| /// Represents a single SSH key. Some (minimal) validation occurs (e.g. ensuring the claimed key |
| /// type is supported), but otherwise a key is largely opaque. |
| /// Keys are equal so long as their "key type" and "key value" is valid. The comment and options |
| /// are disregarded when comparisons are done. |
| pub struct KeyEntry { |
| options: Option<String>, |
| key_type: String, |
| key: String, |
| comment: Option<String>, |
| } |
| |
| impl PartialEq for KeyEntry { |
| fn eq(&self, other: &KeyEntry) -> bool { |
| return self.key_type == other.key_type && self.key == other.key; |
| } |
| } |
| |
| impl FromStr for KeyEntry { |
| type Err = ParseKeyError; |
| |
| fn from_str(s: &str) -> Result<Self, Self::Err> { |
| if s.len() == 0 || s.starts_with('#') { |
| return Err(ParseKeyError::InvalidKey); |
| } |
| if s.len() > MAX_SSH_KEY_LENGTH as usize { |
| return Err(ParseKeyError::KeyTooLong); |
| } |
| |
| let parts: Vec<&str> = s.split(' ').collect(); |
| // The sshd docs say that authorized_keys fields are space-separated, but in practice the |
| // tools seem to accept comments with multiple spaces in them. |
| if parts.len() < 2 { |
| return Err(ParseKeyError::WrongNumberOfFields); |
| } |
| |
| // This is fairly naive. We simply try and find a field that looks like a valid key type, |
| // and base our assumption on the rest of the line off that. |
| // We don't attempt to parse any of the other fields. |
| let (options, key_type, key, comment_start) = if is_valid_key_type(parts[0]) { |
| // If the first field is a key type, the next field is key type, and the last field is |
| // comment. |
| (None, parts[0], parts[1], 2) |
| } else if is_valid_key_type(parts[1]) { |
| // If the second field is a key type, there should be at least 3 fields: options, |
| // key-type, and key. |
| if parts.len() < 3 { |
| return Err(ParseKeyError::WrongNumberOfFields); |
| } |
| (Some(parts[0]), parts[1], parts[2], 3) |
| } else { |
| return Err(ParseKeyError::InvalidKeyType); |
| }; |
| |
| let comment = |
| if parts.len() > comment_start { Some(parts[comment_start..].join(" ")) } else { None }; |
| |
| Ok(KeyEntry { |
| options: options.map(|v| v.to_string()), |
| key_type: key_type.to_string(), |
| key: key.to_string(), |
| comment: comment.map(|v| v.to_string()), |
| }) |
| } |
| } |
| |
| impl ToString for KeyEntry { |
| fn to_string(&self) -> String { |
| match (self.options.as_ref(), self.comment.as_ref()) { |
| (Some(a), Some(b)) => format!("{} {} {} {}", a, self.key_type, self.key, b), |
| (None, Some(b)) => format!("{} {} {}", self.key_type, self.key, b), |
| (Some(a), None) => format!("{} {} {}", a, self.key_type, self.key), |
| (None, None) => format!("{} {}", self.key_type, self.key), |
| } |
| } |
| } |
| |
| #[cfg(test)] |
| mod string_tests { |
| use super::*; |
| |
| #[test] |
| fn test_key_long_comment() { |
| let key = "options ssh-dss abcdefg comment and other text".parse::<KeyEntry>().unwrap(); |
| assert_eq!(key.options.as_deref(), Some("options")); |
| assert_eq!(key.key_type, "ssh-dss"); |
| assert_eq!(key.key, "abcdefg"); |
| assert_eq!(key.comment.as_deref(), Some("comment and other text")); |
| } |
| |
| #[test] |
| fn test_key_leading_fields() { |
| assert_eq!( |
| "options word ssh-dss abcdefg".parse::<KeyEntry>().unwrap_err(), |
| ParseKeyError::InvalidKeyType |
| ); |
| } |
| |
| #[test] |
| fn test_key_not_enough_fields() { |
| assert_eq!( |
| "options ssh-rsa".parse::<KeyEntry>().unwrap_err(), |
| ParseKeyError::WrongNumberOfFields |
| ); |
| } |
| |
| #[test] |
| fn test_key_empty() { |
| assert_eq!("".parse::<KeyEntry>().unwrap_err(), ParseKeyError::InvalidKey); |
| } |
| |
| #[test] |
| fn test_key_too_long() { |
| let line = "a".repeat(8193); |
| assert_eq!(line.parse::<KeyEntry>().unwrap_err(), ParseKeyError::KeyTooLong); |
| } |
| |
| const VALID_ECDSA_KEY: &str = "AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBKR7FCcS2e4OfqHi8h3HAPZhu1fvZUXaXjSDEUr0NPV49jKJSgCptu7YQq1DlfKXXw3aPGJdZAyk1fixQvYli8A="; |
| |
| #[test] |
| fn test_parse_valid_key_no_options_or_comment() { |
| let line = format!("ecdsa-sha2-nistp256 {}", VALID_ECDSA_KEY); |
| let key = line.parse::<KeyEntry>().expect("parse ok"); |
| assert_eq!(key.options, None); |
| assert_eq!(key.key_type, "ecdsa-sha2-nistp256"); |
| assert_eq!(key.key, VALID_ECDSA_KEY); |
| assert_eq!(key.comment, None); |
| assert_eq!(line, key.to_string()); |
| } |
| |
| #[test] |
| fn test_parse_valid_key_with_options() { |
| let line = format!("options ecdsa-sha2-nistp256 {}", VALID_ECDSA_KEY); |
| let key = line.parse::<KeyEntry>().expect("parse ok"); |
| assert_eq!(key.options.as_deref(), Some("options")); |
| assert_eq!(key.key_type, "ecdsa-sha2-nistp256"); |
| assert_eq!(key.key, VALID_ECDSA_KEY); |
| assert_eq!(key.comment, None); |
| assert_eq!(line, key.to_string()); |
| } |
| |
| #[test] |
| fn test_parse_valid_key_with_comment() { |
| let line = format!("ecdsa-sha2-nistp256 {} comment", VALID_ECDSA_KEY); |
| let key = line.parse::<KeyEntry>().expect("parse ok"); |
| assert_eq!(key.options, None); |
| assert_eq!(key.key_type, "ecdsa-sha2-nistp256"); |
| assert_eq!(key.key, VALID_ECDSA_KEY); |
| assert_eq!(key.comment.as_deref(), Some("comment")); |
| assert_eq!(line, key.to_string()); |
| } |
| |
| #[test] |
| fn test_parse_valid_key_with_comment_and_options() { |
| let line = format!("options ecdsa-sha2-nistp256 {} comment", VALID_ECDSA_KEY); |
| let key = line.parse::<KeyEntry>().expect("parse ok"); |
| assert_eq!(key.options.as_deref(), Some("options")); |
| assert_eq!(key.key_type, "ecdsa-sha2-nistp256"); |
| assert_eq!(key.key, VALID_ECDSA_KEY); |
| assert_eq!(key.comment.as_deref(), Some("comment")); |
| assert_eq!(line, key.to_string()); |
| } |
| |
| #[test] |
| fn test_parse_comment_line() { |
| assert_eq!("# commented".parse::<KeyEntry>().unwrap_err(), ParseKeyError::InvalidKey); |
| } |
| } |
| |
| #[cfg(test)] |
| mod eq_tests { |
| use super::*; |
| |
| #[test] |
| fn test_eq_not_identical() { |
| let a = KeyEntry { |
| options: None, |
| key_type: "ssh-rsa".to_owned(), |
| key: "abc".to_owned(), |
| comment: None, |
| }; |
| let b = KeyEntry { |
| options: None, |
| key_type: "ssh-dss".to_owned(), |
| key: "abc".to_owned(), |
| comment: None, |
| }; |
| |
| assert_ne!(a, b); |
| } |
| |
| #[test] |
| fn test_eq_ignores_options_and_comment() { |
| let a = KeyEntry { |
| options: None, |
| key_type: "ssh-rsa".to_owned(), |
| key: "abc".to_owned(), |
| comment: None, |
| }; |
| let b = KeyEntry { |
| options: Some("test".to_owned()), |
| key_type: "ssh-rsa".to_owned(), |
| key: "abc".to_owned(), |
| comment: Some("test".to_owned()), |
| }; |
| |
| assert_eq!(a, b); |
| } |
| } |