From 2b5106e5b04414d7013e71d1bab6cb7b02bb742f Mon Sep 17 00:00:00 2001 From: Thanh Nguyen Date: Thu, 9 May 2024 02:31:14 -0400 Subject: [PATCH] Add AllowedSigners support (#23) * Add allowed_signers * Add comment * Address comments * Update parsing * Update namespaces parsing * Update parsing logic * Clean up * Fix timestamp parsing * Add comment * Fix unwrap * Change in self to self * Refactor * Remove all local timezone in tests * Remove useless pub(self) --------- Co-authored-by: Mitchell Grenier --- Cargo.toml | 1 + src/error.rs | 7 + src/ssh/allowed_signer.rs | 469 +++++++++++++++++++++ src/ssh/mod.rs | 2 + tests/allowed-signer.rs | 352 ++++++++++++++++ tests/allowed-signers.rs | 52 +++ tests/allowed_signers/good_allowed_signers | 6 + 7 files changed, 889 insertions(+) create mode 100644 src/ssh/allowed_signer.rs create mode 100644 tests/allowed-signer.rs create mode 100644 tests/allowed-signers.rs create mode 100644 tests/allowed_signers/good_allowed_signers diff --git a/Cargo.toml b/Cargo.toml index 9fbfb34..6cc0653 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,6 +52,7 @@ yubikey-lite = ["x509-support"] [dependencies] base64 = "0.13" +chrono = "0.4" ring = "0.17" zeroize = { version = "1", features = ["zeroize_derive"] } diff --git a/src/error.rs b/src/error.rs index f5a23d5..123494b 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,4 +1,5 @@ use std::{fmt, io, string}; +use crate::ssh::AllowedSignerParsingError; /// A type to represent the different kinds of errors. #[derive(Debug)] @@ -22,6 +23,10 @@ pub enum Error { KeyTypeMismatch, /// The certificate is not signed correctly and invalid InvalidSignature, + /// A parsing error for one allowed signer + InvalidAllowedSigner(AllowedSignerParsingError), + /// A parsing error for a collection/file of allowed signers + InvalidAllowedSigners(AllowedSignerParsingError, usize), /// A cryptographic operation failed. SigningError, /// An encrypted private key was provided with no decryption key @@ -58,6 +63,8 @@ impl fmt::Display for Error { Error::NotCertificate => write!(f, "Not a certificate"), Error::KeyTypeMismatch => write!(f, "Key type mismatch"), Error::InvalidSignature => write!(f, "Data is improperly signed"), + Error::InvalidAllowedSigner(ref v) => write!(f, "Invalid allowed signer format: {}", v), + Error::InvalidAllowedSigners(ref v, line) => write!(f, "Invalid allowed signer format on line {}: {}", line, v), Error::SigningError => write!(f, "Could not sign data"), Error::EncryptedPrivateKey => write!(f, "Encountered encrypted private key with no decryption key"), Error::EncryptedPrivateKeyNotSupported => write!(f, "This method of private key encryption is not supported or sshcerts was not compiled with encrypted private key support"), diff --git a/src/ssh/allowed_signer.rs b/src/ssh/allowed_signer.rs new file mode 100644 index 0000000..96d4bab --- /dev/null +++ b/src/ssh/allowed_signer.rs @@ -0,0 +1,469 @@ +use std::fmt; +use std::fs::File; +use std::io::Read; +use std::path::Path; + +use chrono::prelude::Local; +use chrono::{NaiveDate, NaiveDateTime, NaiveTime}; + +use super::pubkey::PublicKey; +use crate::{error::Error, Result}; + +/// A type to represent the different kinds of errors. +#[derive(Debug)] +pub enum AllowedSignerParsingError { + /// Parsing failed because of double quotes + InvalidQuotes, + /// Parsing failed because principals was missing + MissingPrincipals, + /// Principals is invalid + InvalidPrincipals, + /// Public key data is missing + MissingKey, + /// Some option was specified twice + DuplicateOptions(String), + /// An option has invalid format + InvalidOption(String), + /// Invalid key + InvalidKey, + /// Invalid timestamp + InvalidTimestamp, + /// valid-before and valid-after are conflicting + InvalidTimestamps, + /// Unexpected end of allowed signer + UnexpectedEnd, +} + +impl fmt::Display for AllowedSignerParsingError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + AllowedSignerParsingError::InvalidQuotes => write!(f, "error parsing quotes"), + AllowedSignerParsingError::MissingPrincipals => write!(f, "missing principals"), + AllowedSignerParsingError::InvalidPrincipals => write!(f, "invalid principals"), + AllowedSignerParsingError::MissingKey => write!(f, "missing public key data"), + AllowedSignerParsingError::DuplicateOptions(ref v) => write!(f, "option {} specified more than once", v), + AllowedSignerParsingError::InvalidOption(ref v) => write!(f, "invalid option {}", v), + AllowedSignerParsingError::InvalidKey => write!(f, "invalid public key"), + AllowedSignerParsingError::InvalidTimestamp => write!(f, "invalid timestamp"), + AllowedSignerParsingError::InvalidTimestamps => write!(f, "conflicting valid-before and valid-after options"), + AllowedSignerParsingError::UnexpectedEnd => write!(f, "unexpected data at the end"), + } + } +} + +/// A type which represents an allowed signer entry. +/// Please refer to [ssh-keygen-1.ALLOWED_SIGNERS] for more details about the format. +/// [ssh-keygen-1.ALLOWED_SIGNERS]: https://man.openbsd.org/ssh-keygen.1#ALLOWED_SIGNERS +#[derive(Debug, PartialEq, Eq)] +pub struct AllowedSigner { + /// A list of principals, each in the format USER@DOMAIN. + pub principals: Vec, + + /// Indicates that this key is accepted as a CA. + pub cert_authority: bool, + + /// Specifies a list of namespaces that are accepted for this key. + pub namespaces: Option>, + + /// UNIX timestamp at or after which the key is valid. + pub valid_after: Option, + + /// UNIX timestamp at or before which the key is valid. + pub valid_before: Option, + + /// Public key of the entry. + pub key: PublicKey, +} + +/// A type which represents a collection of allowed signer entries. +/// Please refer to [ssh-keygen-1.ALLOWED_SIGNERS] for more details about the format. +/// [ssh-keygen-1.ALLOWED_SIGNERS]: https://man.openbsd.org/ssh-keygen.1#ALLOWED_SIGNERS +#[derive(Debug, PartialEq, Eq)] +pub struct AllowedSigners(pub Vec); + +impl AllowedSigner { + /// Parse an allowed signer entry from a given string. + /// + /// # Example + /// + /// ```rust + /// use sshcerts::ssh::AllowedSigner; + /// + /// let allowed_signer = AllowedSigner::from_string(concat!( + /// "user@domain.tld ecdsa-sha2-nistp384 AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBGJe", + /// "+IDGRhlQdDp+/AIsTXGaVhWaQUbHJwqLDlQIh7V4xatO6E/4Uva+f70WzxgM7xHPGUqafqNAcxxVBP4jkx3HVDRSr7C3", + /// "NVBpr0ZaKXu/hFiCo/4kry4H5MGMEvKATA==" + /// )).unwrap(); + /// println!("{:?}", allowed_signer); + /// ``` + pub fn from_string(s: &str) -> Result { + let mut tokenizer = AllowedSignerSplitter::new(s); + + let principals = tokenizer.next(true)? + .ok_or(Error::InvalidAllowedSigner(AllowedSignerParsingError::MissingPrincipals))?; + let principals = principals.trim_matches('"'); + let principals: Vec = principals.split(',') + .map(|s| s.to_string()) + .collect(); + if principals.iter().any(|p| p.is_empty()) { + return Err(Error::InvalidAllowedSigner(AllowedSignerParsingError::InvalidPrincipals)); + } + + let mut cert_authority = false; + let mut namespaces = None; + let mut valid_after = None; + let mut valid_before = None; + + let kt = loop { + let option = tokenizer.next(false)? + .ok_or(Error::InvalidAllowedSigner(AllowedSignerParsingError::MissingKey))?; + + let (option_key, option_value) = match option.split_once('=') { + Some(v) => v, + None => (option.as_str(), ""), + }; + let option_value = option_value.trim_matches('"'); + if option_value.contains("\"") { + return Err(Error::InvalidAllowedSigner(AllowedSignerParsingError::InvalidQuotes)); + } + + match option_key.to_lowercase().as_str() { + "cert-authority" => cert_authority = true, + "namespaces" => { + if namespaces.is_some() { + return Err( + Error::InvalidAllowedSigner(AllowedSignerParsingError::DuplicateOptions("namespaces".to_string())) + ); + } + + let namespaces_value: Vec<&str> = option_value.split(',') + .filter(|e| !e.is_empty()) + .collect(); + namespaces = Some( + namespaces_value.iter() + .map(|s| s.to_string()) + .collect() + ); + }, + "valid-after" => { + if valid_after.is_some() { + return Err( + Error::InvalidAllowedSigner(AllowedSignerParsingError::DuplicateOptions("valid-after".to_string())) + ); + } + valid_after = Some(parse_timestamp(option_value) + .map_err( + |_| Error::InvalidAllowedSigner(AllowedSignerParsingError::InvalidOption("valid-after".to_string())))? + ); + }, + "valid-before" => { + if valid_before.is_some() { + return Err( + Error::InvalidAllowedSigner(AllowedSignerParsingError::DuplicateOptions("valid-before".to_string())) + ); + } + valid_before = Some(parse_timestamp(option_value) + .map_err( + |_| Error::InvalidAllowedSigner(AllowedSignerParsingError::InvalidOption("valid-before".to_string())))? + ); + }, + // If option_key does not match any valid option, we test if it's the key data + _ => break option, + }; + }; + + let key_data = tokenizer.next(false)? + .ok_or(Error::InvalidAllowedSigner(AllowedSignerParsingError::InvalidKey))?; + + let key = PublicKey::from_string(format!("{} {}", kt, key_data).as_str()) + .map_err(|_| Error::InvalidAllowedSigner(AllowedSignerParsingError::InvalidKey))?; + + // Timestamp sanity check + if let (Some(valid_before), Some(valid_after)) = (&valid_before, &valid_after) { + if valid_before <= valid_after { + return Err( + Error::InvalidAllowedSigner(AllowedSignerParsingError::InvalidTimestamps), + ); + } + } + + // After key data, there must be only comment or nothing + if !tokenizer.is_empty_after_trim() { + return Err( + Error::InvalidAllowedSigner(AllowedSignerParsingError::UnexpectedEnd), + ); + } + + Ok(AllowedSigner{ + principals, + cert_authority, + namespaces, + valid_after, + valid_before, + key, + }) + } +} + +impl fmt::Display for AllowedSigner { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut output = String::new(); + + output.push_str(&self.principals.join(",")); + + if self.cert_authority { + output.push_str(" cert-authority"); + } + + if let Some(ref namespaces) = self.namespaces { + output.push_str(&format!(" namespaces={}", namespaces.join(","))); + } + + if let Some(ref valid_after) = self.valid_after { + output.push_str(&format!(" valid-after={}", valid_after)); + } + + if let Some(ref valid_before) = self.valid_before { + output.push_str(&format!(" valid-before={}", valid_before)); + } + + output.push_str(&format!(" {}", self.key)); + + write!(f, "{}", output) + } +} + +impl AllowedSigners { + /// Reads AllowedSigners from a given path. + /// + /// # Example + /// + /// ```rust + /// use sshcerts::ssh::AllowedSigners; + /// fn example() { + /// let allowed_signers = AllowedSigners::from_path("/path/to/allowed_signers").unwrap(); + /// println!("{:?}", allowed_signers); + /// } + /// ``` + pub fn from_path>(path: P) -> Result { + let mut contents = String::new(); + File::open(path)?.read_to_string(&mut contents)?; + + AllowedSigners::from_string(&contents) + } + + /// Parse a collection of allowed signers from a given string. + /// + /// # Example + /// + /// ```rust + /// use sshcerts::ssh::AllowedSigners; + /// + /// let allowed_signers = AllowedSigners::from_string(concat!( + /// "user@domain.tld ecdsa-sha2-nistp384 AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBGJe", + /// "+IDGRhlQdDp+/AIsTXGaVhWaQUbHJwqLDlQIh7V4xatO6E/4Uva+f70WzxgM7xHPGUqafqNAcxxVBP4jkx3HVDRSr7C3", + /// "NVBpr0ZaKXu/hFiCo/4kry4H5MGMEvKATA==\n", + /// "user@domain.tld ecdsa-sha2-nistp384 AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBGJe", + /// "+IDGRhlQdDp+/AIsTXGaVhWaQUbHJwqLDlQIh7V4xatO6E/4Uva+f70WzxgM7xHPGUqafqNAcxxVBP4jkx3HVDRSr7C3", + /// "NVBpr0ZaKXu/hFiCo/4kry4H5MGMEvKATA==\n" + /// )).unwrap(); + /// println!("{:?}", allowed_signers); + /// ``` + pub fn from_string(s: &str) -> Result { + let mut allowed_signers = Vec::new(); + + for (line_number, line) in s.lines().enumerate() { + let line = line.trim(); + if line.is_empty() || line.starts_with("#") { + continue; + } + let allowed_signer = match AllowedSigner::from_string(line) { + Ok(v) => v, + Err(Error::InvalidAllowedSigner(e)) => { + return Err(Error::InvalidAllowedSigners(e, line_number)); + }, + Err(_) => { + return Err(Error::ParsingError); + }, + }; + allowed_signers.push(allowed_signer); + } + + Ok(AllowedSigners(allowed_signers)) + } +} + +/// A type used to split the allowed signer segments, abstracting out the handling of double quotes. +/// The splitter is highly aware of the allowed_signer format and will catch certain invalid +/// formats. +/// +/// For example: "principals option1=\"value1 value2\" option2 option3=value kt key_data" is split into +/// ["principals", "option1=\"value1 value2\"", "option2", "option3=value", "kt", "key_data"] +struct AllowedSignerSplitter { + /// A buffer of remaining tokens in reverse order. + buffer: Vec, +} + +impl AllowedSignerSplitter { + /// Split the string by delimiters but keep the delimiters. + fn new(s: &str) -> Self { + let mut buffer = Vec::new(); + let mut last = 0; + + for (index, matched) in s.match_indices([' ', '"', '#']) { + // Push the new text before the delimiter + if last != index { + buffer.push(s[last..index].to_owned()); + } + // Push the delimiter + buffer.push(matched.to_owned()); + last = index + matched.len(); + } + + // Push the remaining text + if last < s.len() { + buffer.push(s[last..].to_owned()); + } + + // We parse from left to right so reversing allow us to use Vec's last() and pop() + buffer.reverse(); + + Self { buffer } + } + + fn is_empty_after_trim(&mut self) -> bool { + self.trim(); + return self.buffer.is_empty(); + } + + /// Get the next part that is not an option (principals, key) + /// If opening_quotes_allowed is set to false, we reject the next token if it starts with ". + fn next(&mut self, opening_quotes_allowed: bool) -> Result> { + if self.is_empty_after_trim() { + return Ok(None); + } + + // If the next token starts with a double quote, then the closing double quote is also + // the end of the token + if self.buffer[0] == "\"" { + if opening_quotes_allowed { + return self.split_quote().map(|v| Some(v)); + } else { + return Err(Error::InvalidAllowedSigner(AllowedSignerParsingError::InvalidQuotes)); + } + } + + // If the next token doesn't start with a double quote, the token can represent an option. + // Only an option token can contain double quotes in the middle (e.g. a="b c"). + // If we don't see any double quote in the token, we greedily parse the token until the + // next whitespace. + let mut s = String::new(); + while let Some(last) = self.buffer.pop() { + if [" ", "\"", "#"].contains(&last.as_str()) { + self.buffer.push(last); + break; + } + + s.push_str(&last); + } + + // This should only apply to options + if let Some(last) = self.buffer.last() { + if last == "\"" { + s.push_str(self.split_quote()?.as_str()); + + // After the double quotes in the option token, there can only be nothing, a + // whitespace, or a pound + if let Some(last) = self.buffer.last() { + if ![" ", "#"].contains(&last.as_str()) { + return Err(Error::InvalidAllowedSigner(AllowedSignerParsingError::InvalidQuotes)); + } + } + } + } + + Ok(Some(s)) + } + + /// Trim comment and whitespaces + fn trim(&mut self) { + while let Some(last) = self.buffer.last(){ + match last.as_str() { + " " => { + self.buffer.pop(); + }, + // Comment detected + "#" => { + self.buffer.clear() + }, + _ => break, + }; + } + } + + /// Extract content inside the double quotes. + /// This function assumes buffer starst with a ". + fn split_quote(&mut self) -> Result { + match self.buffer.pop() { + Some(v) => { + if v != "\"" { + return Err(Error::InvalidAllowedSigner(AllowedSignerParsingError::InvalidQuotes)); + } + }, + None => return Err(Error::InvalidAllowedSigner(AllowedSignerParsingError::InvalidQuotes)), + } + + let mut s = String::from("\""); + loop { + let token = self.buffer.pop() + .ok_or(Error::InvalidAllowedSigner(AllowedSignerParsingError::InvalidQuotes))?; + s.push_str(&token); + if token == "\"" { + break; + } + } + + Ok(s) + } +} + +/// Parse a string into a u64 representing a timestamp. +/// The timestamp has format YYYYMMDD[HHMM[SS]][Z] +/// The timestamp can be enclosed by quotation marks. +fn parse_timestamp(s: &str) -> Result { + let mut s = s.trim_matches('"'); + let is_utc = s.ends_with('Z'); + if s.len() % 2 == 1 && !is_utc { + return Err(Error::InvalidAllowedSigner(AllowedSignerParsingError::InvalidTimestamp)); + } + if is_utc { + s = s.trim_end_matches('Z'); + } + let datetime = match s.len() { + 8 => { + let date = NaiveDate::parse_from_str(s, "%Y%m%d") + .map_err(|_| Error::InvalidAllowedSigner(AllowedSignerParsingError::InvalidTimestamp))?; + date.and_time(NaiveTime::from_hms_opt(0, 0, 0).expect("initializing NaiveTime from constants should not fail")) + }, + 12 => { + NaiveDateTime::parse_from_str(s, "%Y%m%d%H%M") + .map_err(|_| Error::InvalidAllowedSigner(AllowedSignerParsingError::InvalidTimestamp))? + }, + 14 => { + NaiveDateTime::parse_from_str(s, "%Y%m%d%H%M%S") + .map_err(|_| Error::InvalidAllowedSigner(AllowedSignerParsingError::InvalidTimestamp))? + }, + _ => return Err(Error::InvalidAllowedSigner(AllowedSignerParsingError::InvalidTimestamp)), + }; + + let timestamp = if is_utc { + datetime.and_utc() + .timestamp() + } else { + datetime.and_local_timezone(Local) + .unwrap() + .timestamp() + }; + + Ok(timestamp) +} diff --git a/src/ssh/mod.rs b/src/ssh/mod.rs index 832d296..ef2283a 100644 --- a/src/ssh/mod.rs +++ b/src/ssh/mod.rs @@ -8,6 +8,7 @@ All rights reserved. //! support that. The original licence for the code is in the source //! code provided +mod allowed_signer; mod cert; mod keytype; mod privkey; @@ -23,6 +24,7 @@ pub trait SSHCertificateSigner { fn sign(&self, buffer: &[u8]) -> Option>; } +pub use self::allowed_signer::{AllowedSigner, AllowedSigners, AllowedSignerParsingError}; pub use self::cert::{CertType, Certificate}; pub use self::keytype::{Curve, CurveKind, KeyType, KeyTypeKind}; pub use self::privkey::{ diff --git a/tests/allowed-signer.rs b/tests/allowed-signer.rs new file mode 100644 index 0000000..6a23ce2 --- /dev/null +++ b/tests/allowed-signer.rs @@ -0,0 +1,352 @@ +use sshcerts::error::Error; +use sshcerts::ssh::{AllowedSigner, AllowedSignerParsingError}; + +#[test] +fn parse_good_allowed_signer() { + let allowed_signer = + "mitchell@confurious.io ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDO0VQD9TIdICZLWFWwtf7s8/aENve8twGTEmNV0myh5"; + let allowed_signer = AllowedSigner::from_string(allowed_signer); + assert!(allowed_signer.is_ok()); + let allowed_signer = allowed_signer.unwrap(); + assert_eq!( + allowed_signer.key.fingerprint().to_string(), + "SHA256:QAtqtvvCePelMMUNPP7madH2zNa1ATxX1nt9L/0C5+M", + ); + assert_eq!( + allowed_signer.principals, + vec!["mitchell@confurious.io".to_string()], + ); + assert!(!allowed_signer.cert_authority); + assert!(allowed_signer.namespaces.is_none()); + assert!(allowed_signer.valid_after.is_none()); + assert!(allowed_signer.valid_before.is_none()); +} + +#[test] +fn parse_good_allowed_signer_with_quoted_principals() { + let allowed_signer = + "\"mitchell@confurious.io,mitchell\" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDO0VQD9TIdICZLWFWwtf7s8/aENve8twGTEmNV0myh5"; + let allowed_signer = AllowedSigner::from_string(allowed_signer); + assert!(allowed_signer.is_ok()); + let allowed_signer = allowed_signer.unwrap(); + assert_eq!( + allowed_signer.key.fingerprint().to_string(), + "SHA256:QAtqtvvCePelMMUNPP7madH2zNa1ATxX1nt9L/0C5+M", + ); + assert_eq!( + allowed_signer.principals, + vec!["mitchell@confurious.io".to_string(), "mitchell".to_string()], + ); + assert!(!allowed_signer.cert_authority); + assert!(allowed_signer.namespaces.is_none()); + assert!(allowed_signer.valid_after.is_none()); + assert!(allowed_signer.valid_before.is_none()); +} + +#[test] +fn parse_good_allowed_signer_with_options() { + let allowed_signer = + "mitchell@confurious.io,mitchel2@confurious.io cert-authority namespaces=\"thanh,mitchell\" valid-before=\"20240505Z\" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDO0VQD9TIdICZLWFWwtf7s8/aENve8twGTEmNV0myh5"; + let allowed_signer = AllowedSigner::from_string(allowed_signer); + assert!(allowed_signer.is_ok()); + let allowed_signer = allowed_signer.unwrap(); + assert_eq!(allowed_signer.key.fingerprint().to_string(), "SHA256:QAtqtvvCePelMMUNPP7madH2zNa1ATxX1nt9L/0C5+M"); + assert_eq!( + allowed_signer.principals, + vec!["mitchell@confurious.io".to_string(), "mitchel2@confurious.io".to_string()], + ); + assert!(allowed_signer.cert_authority); + assert_eq!( + allowed_signer.namespaces, + Some(vec!["thanh".to_string(), "mitchell".to_string()]) + ); + assert!(allowed_signer.valid_after.is_none()); + assert_eq!(allowed_signer.valid_before, Some(1714867200i64)); +} + +#[test] +fn parse_good_allowed_signer_with_utc_timestamp() { + let allowed_signer = + "mitchell@confurious.io,mitchel2@confurious.io cert-authority namespaces=\"thanh,mitchell\" valid-after=20240505Z ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDO0VQD9TIdICZLWFWwtf7s8/aENve8twGTEmNV0myh5"; + let allowed_signer = AllowedSigner::from_string(allowed_signer); + assert!(allowed_signer.is_ok()); + let allowed_signer = allowed_signer.unwrap(); + assert_eq!(allowed_signer.key.fingerprint().to_string(), "SHA256:QAtqtvvCePelMMUNPP7madH2zNa1ATxX1nt9L/0C5+M"); + assert_eq!( + allowed_signer.principals, + vec!["mitchell@confurious.io".to_string(), "mitchel2@confurious.io".to_string()], + ); + assert!(allowed_signer.cert_authority); + assert_eq!( + allowed_signer.namespaces, + Some(vec!["thanh".to_string(), "mitchell".to_string()]) + ); + assert_eq!(allowed_signer.valid_after, Some(1714867200)); + assert!(allowed_signer.valid_before.is_none()); +} + +#[test] +fn parse_good_allowed_signer_with_hm_timestamp() { + let allowed_signer = + "mitchell@confurious.io,mitchel2@confurious.io cert-authority namespaces=\"thanh,mitchell\" valid-after=202405050102Z ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDO0VQD9TIdICZLWFWwtf7s8/aENve8twGTEmNV0myh5"; + let allowed_signer = AllowedSigner::from_string(allowed_signer); + assert!(allowed_signer.is_ok()); + let allowed_signer = allowed_signer.unwrap(); + assert_eq!(allowed_signer.key.fingerprint().to_string(), "SHA256:QAtqtvvCePelMMUNPP7madH2zNa1ATxX1nt9L/0C5+M"); + assert_eq!( + allowed_signer.principals, + vec!["mitchell@confurious.io".to_string(), "mitchel2@confurious.io".to_string()], + ); + assert!(allowed_signer.cert_authority); + assert_eq!( + allowed_signer.namespaces, + Some(vec!["thanh".to_string(), "mitchell".to_string()]) + ); + assert_eq!(allowed_signer.valid_after, Some(1714870920i64)); + assert!(allowed_signer.valid_before.is_none()); +} + +#[test] +fn parse_good_allowed_signer_with_hms_timestamp() { + let allowed_signer = + "mitchell@confurious.io,mitchel2@confurious.io cert-authority namespaces=\"thanh,mitchell\" valid-after=20240505010230Z ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDO0VQD9TIdICZLWFWwtf7s8/aENve8twGTEmNV0myh5"; + let allowed_signer = AllowedSigner::from_string(allowed_signer); + assert!(allowed_signer.is_ok()); + let allowed_signer = allowed_signer.unwrap(); + assert_eq!(allowed_signer.key.fingerprint().to_string(), "SHA256:QAtqtvvCePelMMUNPP7madH2zNa1ATxX1nt9L/0C5+M"); + assert_eq!( + allowed_signer.principals, + vec!["mitchell@confurious.io".to_string(), "mitchel2@confurious.io".to_string()], + ); + assert!(allowed_signer.cert_authority); + assert_eq!( + allowed_signer.namespaces, + Some(vec!["thanh".to_string(), "mitchell".to_string()]) + ); + assert_eq!(allowed_signer.valid_after, Some(1714870950i64)); + assert!(allowed_signer.valid_before.is_none()); +} + +#[test] +fn parse_good_allowed_signer_with_consecutive_spaces() { + let allowed_signer = + "mitchell@confurious.io,mitchel2@confurious.io cert-authority namespaces=\"thanh,#mitchell\" valid-before=\"20240505Z\" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDO0VQD9TIdICZLWFWwtf7s8/aENve8twGTEmNV0myh5 "; + let allowed_signer = AllowedSigner::from_string(allowed_signer); + assert!(allowed_signer.is_ok()); + let allowed_signer = allowed_signer.unwrap(); + assert_eq!(allowed_signer.key.fingerprint().to_string(), "SHA256:QAtqtvvCePelMMUNPP7madH2zNa1ATxX1nt9L/0C5+M"); + assert_eq!( + allowed_signer.principals, + vec!["mitchell@confurious.io".to_string(), "mitchel2@confurious.io".to_string()], + ); + assert!(allowed_signer.cert_authority); + assert_eq!( + allowed_signer.namespaces, + Some(vec!["thanh".to_string(), "#mitchell".to_string()]) + ); + assert!(allowed_signer.valid_after.is_none()); + assert_eq!(allowed_signer.valid_before, Some(1714867200i64)); +} + +#[test] +fn parse_good_allowed_signer_with_empty_namespaces() { + let allowed_signer = + "mitchell@confurious.io,mitchel2@confurious.io cert-authority namespaces=\"thanh,,mitchell\" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDO0VQD9TIdICZLWFWwtf7s8/aENve8twGTEmNV0myh5"; + let allowed_signer = AllowedSigner::from_string(allowed_signer); + assert!(allowed_signer.is_ok()); + let allowed_signer = allowed_signer.unwrap(); + assert_eq!(allowed_signer.key.fingerprint().to_string(), "SHA256:QAtqtvvCePelMMUNPP7madH2zNa1ATxX1nt9L/0C5+M"); + assert_eq!( + allowed_signer.principals, + vec!["mitchell@confurious.io".to_string(), "mitchel2@confurious.io".to_string()], + ); + assert!(allowed_signer.cert_authority); + assert_eq!( + allowed_signer.namespaces, + Some(vec!["thanh".to_string(), "mitchell".to_string()]) + ); + assert!(allowed_signer.valid_after.is_none()); + assert!(allowed_signer.valid_before.is_none()); +} + +#[test] +fn parse_good_allowed_signer_with_space_in_namespaces() { + let allowed_signer = + "mitchell@confurious.io,mitchel2@confurious.io cert-authority namespaces=\"thanh,mitchell tech\" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDO0VQD9TIdICZLWFWwtf7s8/aENve8twGTEmNV0myh5"; + let allowed_signer = AllowedSigner::from_string(allowed_signer); + assert!(allowed_signer.is_ok()); + let allowed_signer = allowed_signer.unwrap(); + assert_eq!(allowed_signer.key.fingerprint().to_string(), "SHA256:QAtqtvvCePelMMUNPP7madH2zNa1ATxX1nt9L/0C5+M"); + assert_eq!( + allowed_signer.principals, + vec!["mitchell@confurious.io".to_string(), "mitchel2@confurious.io".to_string()], + ); + assert!(allowed_signer.cert_authority); + assert_eq!( + allowed_signer.namespaces, + Some(vec!["thanh".to_string(), "mitchell tech".to_string()]) + ); + assert!(allowed_signer.valid_after.is_none()); + assert!(allowed_signer.valid_before.is_none()); +} + +#[test] +fn parse_good_allowed_signer_with_unquoted_namespaces() { + let allowed_signer = + "mitchell@confurious.io,mitchel2@confurious.io cert-authority namespaces=thanh,mitchell ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDO0VQD9TIdICZLWFWwtf7s8/aENve8twGTEmNV0myh5"; + let allowed_signer = AllowedSigner::from_string(allowed_signer); + assert!(allowed_signer.is_ok()); + let allowed_signer = allowed_signer.unwrap(); + assert_eq!(allowed_signer.key.fingerprint().to_string(), "SHA256:QAtqtvvCePelMMUNPP7madH2zNa1ATxX1nt9L/0C5+M"); + assert_eq!( + allowed_signer.principals, + vec!["mitchell@confurious.io".to_string(), "mitchel2@confurious.io".to_string()], + ); + assert!(allowed_signer.cert_authority); + assert_eq!( + allowed_signer.namespaces, + Some(vec!["thanh".to_string(), "mitchell".to_string()]) + ); + assert!(allowed_signer.valid_after.is_none()); + assert!(allowed_signer.valid_before.is_none()); +} + +#[test] +fn parse_bad_allowed_signer_with_wrong_key_type() { + let allowed_signer = + "mitchell@confurious.io ecdsa-sha2-nistp384 AAAAC3NzaC1lZDI1NTE5AAAAIDO0VQD9TIdICZLWFWwtf7s8/aENve8twGTEmNV0myh5"; + let allowed_signer = AllowedSigner::from_string(allowed_signer); + assert!(allowed_signer.is_err()); +} + +#[test] +fn parse_bad_allowed_signer_with_invalid_option() { + let allowed_signer = + "mitchell@confurious.io option=test ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDO0VQD9TIdICZLWFWwtf7s8/aENve8twGTEmNV0myh5"; + let allowed_signer = AllowedSigner::from_string(allowed_signer); + assert!(allowed_signer.is_err()); +} + +#[test] +fn parse_bad_allowed_signer_with_invalid_namespaces() { + let allowed_signer = + "mitchell@confurious.io namespaces=a\"test\" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDO0VQD9TIdICZLWFWwtf7s8/aENve8twGTEmNV0myh5"; + let allowed_signer = AllowedSigner::from_string(allowed_signer); + assert!(allowed_signer.is_err()); + + let allowed_signer = + "mitchell@confurious.io namespaces=\"tester,thanh\"\" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDO0VQD9TIdICZLWFWwtf7s8/aENve8twGTEmNV0myh5"; + let allowed_signer = AllowedSigner::from_string(allowed_signer); + assert!(allowed_signer.is_err()); +} + +#[test] +fn parse_bad_allowed_signer_with_invalid_principals() { + let allowed_signer = + "mitchell@confurious.io ,thanh@timweri.me option=test ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDO0VQD9TIdICZLWFWwtf7s8/aENve8twGTEmNV0myh5"; + let allowed_signer = AllowedSigner::from_string(allowed_signer); + assert!(allowed_signer.is_err()); +} + +#[test] +fn parse_bad_allowed_signer_with_empty_principal() { + let allowed_signer = + "mitchell@confurious.io,,thanh@timweri.me option=test ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDO0VQD9TIdICZLWFWwtf7s8/aENve8twGTEmNV0myh5"; + let allowed_signer = AllowedSigner::from_string(allowed_signer); + assert!(allowed_signer.is_err()); +} + +#[test] +fn parse_bad_allowed_signer_with_timestamp_option() { + let allowed_signer = + "mitchell@confurious.io valid-before=-143 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDO0VQD9TIdICZLWFWwtf7s8/aENve8twGTEmNV0myh5"; + let allowed_signer = AllowedSigner::from_string(allowed_signer); + assert!(allowed_signer.is_err()); +} + +#[test] +fn parse_bad_allowed_signer_with_conflicting_timestamps() { + let allowed_signer = + "mitchell@confurious.io valid-before=20240505 valid-after=20240505 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDO0VQD9TIdICZLWFWwtf7s8/aENve8twGTEmNV0myh5"; + let allowed_signer = AllowedSigner::from_string(allowed_signer); + assert!(allowed_signer.is_err()); + assert!(matches!(allowed_signer, Err(Error::InvalidAllowedSigner(AllowedSignerParsingError::InvalidTimestamps)))); +} + +#[test] +fn parse_bad_allowed_signer_with_duplicate_option() { + let allowed_signer = + "mitchell@confurious.io namespaces=thanh namespaces=mitchell ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDO0VQD9TIdICZLWFWwtf7s8/aENve8twGTEmNV0myh5"; + let allowed_signer = AllowedSigner::from_string(allowed_signer); + assert!(allowed_signer.is_err()); + assert!( + matches!( + allowed_signer, + Err(Error::InvalidAllowedSigner(AllowedSignerParsingError::DuplicateOptions(_))), + ) + ); +} + +#[test] +fn parse_bad_allowed_signer_with_quoted_key() { + let allowed_signer = + "mitchell@confurious.io \"ssh-ed25519\" AAAAC3NzaC1lZDI1NTE5AAAAIDO0VQD9TIdICZLWFWwtf7s8/aENve8twGTEmNV0myh5"; + let allowed_signer = AllowedSigner::from_string(allowed_signer); + assert!(allowed_signer.is_err()); + assert!( + matches!( + allowed_signer, + Err(Error::InvalidAllowedSigner(AllowedSignerParsingError::InvalidKey)), + ) + ); + + let allowed_signer = + "mitchell@confurious.io \"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDO0VQD9TIdICZLWFWwtf7s8/aENve8twGTEmNV0myh5\""; + let allowed_signer = AllowedSigner::from_string(allowed_signer); + assert!(allowed_signer.is_err()); + assert!( + matches!( + allowed_signer, + Err(Error::InvalidAllowedSigner(AllowedSignerParsingError::InvalidQuotes)), + ) + ); + + let allowed_signer = + "mitchell@confurious.io ssh-ed25519 \"AAAAC3NzaC1lZDI1NTE5AAAAIDO0VQD9TIdICZLWFWwtf7s8/aENve8twGTEmNV0myh5\""; + let allowed_signer = AllowedSigner::from_string(allowed_signer); + assert!(allowed_signer.is_err()); + assert!( + matches!( + allowed_signer, + Err(Error::InvalidAllowedSigner(AllowedSignerParsingError::InvalidQuotes)), + ) + ); +} + +#[test] +fn parse_bad_allowed_signer_with_invalid_timestamp() { + let allowed_signer = + "mitchell@confurious.io valid-before=1941 \"ssh-ed25519\" AAAAC3NzaC1lZDI1NTE5AAAAIDO0VQD9TIdICZLWFWwtf7s8/aENve8twGTEmNV0myh5"; + let allowed_signer = AllowedSigner::from_string(allowed_signer); + assert!(allowed_signer.is_err()); + + let allowed_signer = + "mitchell@confurious.io valid-before=\"1941\" \"ssh-ed25519\" AAAAC3NzaC1lZDI1NTE5AAAAIDO0VQD9TIdICZLWFWwtf7s8/aENve8twGTEmNV0myh5"; + let allowed_signer = AllowedSigner::from_string(allowed_signer); + assert!(allowed_signer.is_err()); + + let allowed_signer = + "mitchell@confurious.io valid-before=19411293 \"ssh-ed25519\" AAAAC3NzaC1lZDI1NTE5AAAAIDO0VQD9TIdICZLWFWwtf7s8/aENve8twGTEmNV0myh5"; + let allowed_signer = AllowedSigner::from_string(allowed_signer); + assert!(allowed_signer.is_err()); + + let allowed_signer = + "mitchell@confurious.io valid-before=1941293 \"ssh-ed25519\" AAAAC3NzaC1lZDI1NTE5AAAAIDO0VQD9TIdICZLWFWwtf7s8/aENve8twGTEmNV0myh5"; + let allowed_signer = AllowedSigner::from_string(allowed_signer); + assert!(allowed_signer.is_err()); + + let allowed_signer = + "mitchell@confurious.io valid-before=19411293Z \"ssh-ed25519\" AAAAC3NzaC1lZDI1NTE5AAAAIDO0VQD9TIdICZLWFWwtf7s8/aENve8twGTEmNV0myh5"; + let allowed_signer = AllowedSigner::from_string(allowed_signer); + assert!(allowed_signer.is_err()); +} diff --git a/tests/allowed-signers.rs b/tests/allowed-signers.rs new file mode 100644 index 0000000..361825e --- /dev/null +++ b/tests/allowed-signers.rs @@ -0,0 +1,52 @@ +use sshcerts::ssh::AllowedSigners; + +#[test] +fn parse_good_allowed_signers() { + let allowed_signers = AllowedSigners::from_path("tests/allowed_signers/good_allowed_signers"); + assert!(allowed_signers.is_ok()); + let AllowedSigners(allowed_signers) = allowed_signers.unwrap(); + assert_eq!(allowed_signers.len(), 3); + + assert_eq!( + allowed_signers[0].key.fingerprint().to_string(), + "SHA256:QAtqtvvCePelMMUNPP7madH2zNa1ATxX1nt9L/0C5+M", + ); + assert_eq!( + allowed_signers[0].principals, + vec!["mitchell@confurious.io".to_string()], + ); + assert!(!allowed_signers[0].cert_authority); + assert!(allowed_signers[0].namespaces.is_none()); + assert!(allowed_signers[0].valid_after.is_none()); + assert!(allowed_signers[0].valid_before.is_none()); + + assert_eq!( + allowed_signers[1].principals, + vec!["mitchell@confurious.io".to_string(), "mitchel2@confurious.io".to_string()], + ); + assert!(allowed_signers[1].cert_authority); + assert_eq!( + allowed_signers[1].namespaces, + Some(vec!["thanh".to_string(), "#mitchell".to_string()]) + ); + assert!(allowed_signers[1].valid_after.is_none()); + assert_eq!( + allowed_signers[1].valid_before, + Some(1714867200i64), + ); + + assert_eq!( + allowed_signers[2].namespaces, + Some(vec![ + "thanh".to_string(), + " ".to_string(), + "mitchell mitchell".to_string(), + " andrew andrew".to_string(), + ]), + ); + assert!(allowed_signers[2].valid_after.is_none()); + assert_eq!( + allowed_signers[2].valid_before, + Some(1714867200i64), + ); +} diff --git a/tests/allowed_signers/good_allowed_signers b/tests/allowed_signers/good_allowed_signers new file mode 100644 index 0000000..14e9848 --- /dev/null +++ b/tests/allowed_signers/good_allowed_signers @@ -0,0 +1,6 @@ +# Comments are ignored +mitchell@confurious.io ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDO0VQD9TIdICZLWFWwtf7s8/aENve8twGTEmNV0myh5 +# Empty lines are also ignored + +mitchell@confurious.io,mitchel2@confurious.io cert-authority namespaces="thanh,#mitchell" valid-before="20240505Z" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDO0VQD9TIdICZLWFWwtf7s8/aENve8twGTEmNV0myh5 # End of line comment is ignored +mitchell@confurious.io,mitchel2@confurious.io cert-authority namespaces="thanh, ,mitchell mitchell, andrew andrew" valid-before="20240505Z" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDO0VQD9TIdICZLWFWwtf7s8/aENve8twGTEmNV0myh5