-
Notifications
You must be signed in to change notification settings - Fork 4
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add AllowedSigners support #23
Changes from 1 commit
0bc7611
057d53b
cd8c80f
f529293
b9b2e2e
77e9be3
bbc2cb5
31b1e14
59bb199
7833486
bfa3feb
eee2dac
ab90d81
e655af2
1bc0163
2447d5c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,218 @@ | ||
use std::collections::VecDeque; | ||
use std::fmt; | ||
use std::fs::File; | ||
use std::io::Read; | ||
use std::path::Path; | ||
|
||
use super::pubkey::PublicKey; | ||
use crate::{error::Error, Result}; | ||
|
||
/// 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<String>, | ||
|
||
/// Indicates that this key is accepted as a CA. | ||
/// This is a standard option. | ||
pub cert_authority: bool, | ||
|
||
/// Specifies a list of namespaces that are accepted for this key. | ||
/// This is a standard option. | ||
/// | ||
/// Note: The specification allows spaces inside double quotes. However, this is not supported. | ||
pub namespaces: Option<Vec<String>>, | ||
|
||
/// Time at or after which the key is valid. | ||
/// This is a standard option. | ||
pub valid_after: Option<u64>, | ||
|
||
/// Time at or before which the key is valid. | ||
/// This is a standard option. | ||
pub valid_before: Option<u64>, | ||
|
||
/// 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 { | ||
/// A collection of allowed signers | ||
pub allowed_signers: Vec<AllowedSigner>, | ||
} | ||
|
||
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<AllowedSigner> { | ||
let mut parts: VecDeque<&str> = s.split_whitespace().collect(); | ||
|
||
// An allowed signer must contian at least 3 parts: principals, key type and pubkey data | ||
if parts.len() < 3 { | ||
return Err(Error::InvalidFormat); | ||
} | ||
|
||
let principals = parts.pop_front().ok_or(Error::InvalidFormat)?; | ||
let principals: Vec<&str> = principals.split(',').collect(); | ||
let principals = principals.iter().map(|s| s.to_string()).collect(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we enforce a format for principals? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Seems like this is not done in other APIs ( There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think principals are pretty open. There might be characters which cannot be in principals (","?) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not enforcing format on principals
obelisk marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
let key_data = parts.pop_back().ok_or(Error::InvalidFormat)?; | ||
let kt = parts.pop_back().ok_or(Error::InvalidFormat)?; | ||
let key = PublicKey::from_string(format!("{} {}", kt, key_data).as_str())?; | ||
|
||
let mut cert_authority = false; | ||
let mut namespaces = None; | ||
let mut valid_after = None; | ||
let mut valid_before = None; | ||
|
||
for option in parts { | ||
let option = option.to_lowercase(); | ||
let (key, value) = match option.split_once('=') { | ||
Some(v) => v, | ||
None => (option.as_str(), ""), | ||
}; | ||
match key { | ||
"cert-authority" => cert_authority = true, | ||
obelisk marked this conversation as resolved.
Show resolved
Hide resolved
|
||
"namespaces" => { | ||
if namespaces.is_some() { | ||
return Err(Error::InvalidFormat); | ||
} | ||
let namespaces_inner: Vec<&str> = value.trim_matches('"') | ||
.split(',') | ||
.collect(); | ||
namespaces = Some( | ||
namespaces_inner.iter() | ||
.map(|s| s.to_string()) | ||
.collect() | ||
); | ||
}, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. TODO: Rework the option parsing logic:
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
"valid-after" => { | ||
if valid_after.is_some() { | ||
return Err(Error::InvalidFormat); | ||
} | ||
valid_after = Some(parse_timestamp(value)?); | ||
}, | ||
"valid-before" => { | ||
if valid_before.is_some() { | ||
return Err(Error::InvalidFormat); | ||
} | ||
valid_before = Some(parse_timestamp(value)?); | ||
}, | ||
_ => return Err(Error::InvalidFormat), | ||
}; | ||
} | ||
|
||
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(valid_after) = self.valid_after { | ||
output.push_str(&format!(" valid-after={}", valid_after)); | ||
} | ||
|
||
if let Some(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<P: AsRef<Path>>(path: P) -> Result<AllowedSigners> { | ||
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<AllowedSigners> { | ||
let mut allowed_signers = Vec::new(); | ||
|
||
for line in s.split('\n') { | ||
let line = line.trim(); | ||
if line.is_empty() || line.starts_with("#") { | ||
continue; | ||
} | ||
let allowed_signer = AllowedSigner::from_string(line)?; | ||
allowed_signers.push(allowed_signer); | ||
} | ||
|
||
Ok(AllowedSigners{allowed_signers}) | ||
} | ||
} | ||
|
||
/// Parse a string into a u64 representing a timestamp. | ||
/// The timestamp can be enclosed by quotation marks. | ||
fn parse_timestamp(s: &str) -> Result<u64> { | ||
let s = s.trim_matches('"'); | ||
Ok(s.parse::<u64>().map_err(|_| Error::InvalidFormat)?) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,83 @@ | ||
use sshcerts::ssh::AllowedSigner; | ||
|
||
#[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_options() { | ||
let allowed_signer = | ||
"mitchell@confurious.io,mitchel2@confurious.io cert-authority namespaces=\"thanh,mitchell\" valid-before=\"123\" 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(123u64)); | ||
} | ||
|
||
#[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_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_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_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()); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,34 @@ | ||
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 allowed_signers = allowed_signers.unwrap().allowed_signers; | ||
assert_eq!(allowed_signers.len(), 2); | ||
|
||
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(123u64)); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
# Comments are ignored | ||
mitchell@confurious.io ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDO0VQD9TIdICZLWFWwtf7s8/aENve8twGTEmNV0myh5 | ||
obelisk marked this conversation as resolved.
Show resolved
Hide resolved
|
||
# Empty lines are also ignored | ||
|
||
mitchell@confurious.io,mitchel2@confurious.io cert-authority namespaces="thanh,mitchell" valid-before="123" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDO0VQD9TIdICZLWFWwtf7s8/aENve8twGTEmNV0myh5 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You might just want to use a typedef here (I don't think there would ever be additional fields in this struct.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I can't implement foreign traits on typedef since typedef is a weak symlink. So I chose to wrap
AllowedSigners(Vec<AllowedSigner>)