Skip to content
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

Merged
merged 16 commits into from
May 9, 2024
218 changes: 218 additions & 0 deletions src/ssh/allowed_signer.rs
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 {
Copy link
Owner

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.

Copy link
Collaborator Author

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>)

/// 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();
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we enforce a format for principals?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like this is not done in other APIs (cert.rs) so maybe we don't need this?

Copy link
Owner

Choose a reason for hiding this comment

The 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 (","?)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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()
);
},
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO: Rework the option parsing logic:

  • Support spaces in double quotes. Not sure how this works for a namespace. Need to find the specs for namespace.
  • Might need to enforce a format for namespace.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The key used for signing is specified using the -f option and may refer to either a private key, or a public key with the private half available via ssh-agent(1). An additional signature namespace, used to prevent signature confusion across different domains of use (e.g. file signing vs email signing) must be provided via the -n flag. Namespaces are arbitrary strings, and may include: “file” for file signing, “email” for email signing. For custom uses, it is recommended to use names following a NAMESPACE@YOUR.DOMAIN pattern to generate unambiguous namespaces.

From https://man.openbsd.org/ssh-keygen.1

"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)?)
}
2 changes: 2 additions & 0 deletions src/ssh/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -23,6 +24,7 @@ pub trait SSHCertificateSigner {
fn sign(&self, buffer: &[u8]) -> Option<Vec<u8>>;
}

pub use self::allowed_signer::{AllowedSigner, AllowedSigners};
pub use self::cert::{CertType, Certificate};
pub use self::keytype::{Curve, CurveKind, KeyType, KeyTypeKind};
pub use self::privkey::{
Expand Down
83 changes: 83 additions & 0 deletions tests/allowed-signer.rs
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());
}
34 changes: 34 additions & 0 deletions tests/allowed-signers.rs
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));
}
5 changes: 5 additions & 0 deletions tests/allowed_signers/good_allowed_signers
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
Loading