#![warn(missing_docs)]
use std::net::IpAddr;
use std::path::Path;
use std::time::Duration;
use biscuit_ssh_core::{Authorization, Mode, Name, Network};
use ssh_key::certificate::{Builder, CertType};
use ssh_key::rand_core::{OsRng, RngCore};
use ssh_key::{Algorithm, Certificate, HashAlg, PrivateKey, PublicKey};
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("unusable CA key: {0}")]
Key(String),
#[error("unsupported public key: {0}")]
PublicKey(String),
#[error("refused: {0}")]
Mismatch(&'static str),
#[error("cannot sign certificate: {0}")]
Signing(#[from] ssh_key::Error),
}
pub fn principal(server: &Name, user: &Name) -> String {
format!("{server}:{user}")
}
pub fn parse_public_key(openssh: &str) -> Result<PublicKey, Error> {
let key = PublicKey::from_openssh(openssh.trim()).map_err(|e| Error::PublicKey(e.to_string()))?;
if matches!(key.algorithm(), Algorithm::Dsa | Algorithm::Other(_)) {
return Err(Error::PublicKey(format!("{} keys are not accepted", key.algorithm())));
}
Ok(key)
}
pub fn fingerprint(key: &PublicKey) -> String {
key.fingerprint(HashAlg::Sha256).to_string()
}
pub struct CertificateAuthority {
key: PrivateKey,
}
impl CertificateAuthority {
pub fn load(path: &Path) -> Result<Self, Error> {
let error = |reason: String| Error::Key(format!("{}: {reason}", path.display()));
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(path)
.map_err(|e| error(e.to_string()))?
.permissions()
.mode();
if mode & 0o077 != 0 {
return Err(error(format!("permissions {:o} are too open, use 600", mode & 0o777)));
}
}
let key = PrivateKey::read_openssh_file(path).map_err(|e| error(e.to_string()))?;
Self::new(key)
}
pub fn new(key: PrivateKey) -> Result<Self, Error> {
if key.is_encrypted() {
return Err(Error::Key("encrypted keys are not supported".to_owned()));
}
Ok(Self { key })
}
pub fn public_key(&self) -> &PublicKey {
self.key.public_key()
}
pub fn sign(&self, key: &PublicKey, authorization: &Authorization, skew: Duration) -> Result<Certificate, Error> {
let request = authorization.request();
if fingerprint(key) != request.public_key {
return Err(Error::Mismatch("the key differs from the authorized one"));
}
if request.mode != Mode::Certificate {
return Err(Error::Mismatch("the authorization is not for a certificate"));
}
self.certify(key, authorization, request.source_ip, skew)
}
pub fn sign_relayed(
&self,
key: &PublicKey,
authorization: &Authorization,
bastion_ip: IpAddr,
skew: Duration,
) -> Result<Certificate, Error> {
if authorization.request().mode != Mode::Bastion {
return Err(Error::Mismatch("the authorization is not for the bastion"));
}
self.certify(key, authorization, bastion_ip, skew)
}
fn certify(
&self,
key: &PublicKey,
authorization: &Authorization,
source: IpAddr,
skew: Duration,
) -> Result<Certificate, Error> {
let request = authorization.request();
let valid_after = authorization.valid_from().saturating_sub(skew.as_secs());
let mut builder = Builder::new_with_random_nonce(
&mut OsRng,
key.key_data().clone(),
valid_after,
authorization.valid_until(),
)?;
builder
.serial(OsRng.next_u64())?
.cert_type(CertType::User)?
.key_id(authorization.token_id())?
.valid_principal(principal(&request.server, &request.user))?
.critical_option("source-address", Network::host(source).to_string())?
.extension("permit-pty", "")?;
if let Some(command) = &request.command {
builder.critical_option("force-command", command.as_str())?;
}
Ok(builder.sign(&self.key)?)
}
}
#[cfg(test)]
mod tests {
use std::time::SystemTime;
use biscuit_ssh_core::{AccessRequest, DEFAULT_POLICY, KeyPair, RevocationList, Verifier, mint};
use super::*;
const ENCRYPTED_KEY: &str = "-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABDcX8mjpz
vbLHXF5VmOt5yiAAAAGAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAAILFBpn0wxSMAohR1
nm+PR1EGu2mpjijaYFs4uUrU7LvzAAAAkJyPFegX9Q7hyYmeBFx49pzZTCosBLh0hJ3gbq
sPNmfiQx5EI1/1YmkkugdNhmtOLWDGUhRfQAs5ZbzWaCpeR8iv2YcTRSbV+0rtkzZc3fDG
ghr/lfWXu/ve8q2bHvIM1Wwl2+ITmZ4BO7cNQnACP5jkUwTNeFucHx5MkrGOsf2SvRAw6a
k0rB3JgoZjw/ealQ==
-----END OPENSSH PRIVATE KEY-----
";
fn random_key() -> PrivateKey {
PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap()
}
fn ca() -> CertificateAuthority {
CertificateAuthority::new(random_key()).unwrap()
}
fn authorize(key: &PublicKey, command: Option<&str>, mode: Mode) -> Authorization {
let root = KeyPair::new();
let token = mint(&root, &["web-1:deploy".parse().unwrap()], &[]).unwrap();
let request = AccessRequest {
time: SystemTime::now(),
source_ip: "::ffff:192.0.2.7".parse().unwrap(),
server: "web-1".parse().unwrap(),
user: "deploy".parse().unwrap(),
command: command.map(|c| c.parse().unwrap()),
public_key: fingerprint(key),
mode,
};
let verifier = Verifier::new(root.public(), DEFAULT_POLICY).unwrap();
verifier
.authorize(&token, &request, &RevocationList::default(), Duration::from_secs(60))
.unwrap()
}
#[test]
fn certificate_carries_every_constraint() {
let ca = ca();
let key = random_key();
let auth = authorize(key.public_key(), Some("uptime"), Mode::Certificate);
let cert = ca.sign(key.public_key(), &auth, Duration::from_secs(30)).unwrap();
cert.validate([&ca.public_key().fingerprint(HashAlg::Sha256)]).unwrap();
assert_eq!(cert.cert_type(), CertType::User);
assert_eq!(cert.valid_principals(), ["web-1:deploy"]);
assert_eq!(cert.key_id(), auth.token_id());
assert_eq!(cert.critical_options().get("source-address").unwrap(), "192.0.2.7/32");
assert_eq!(cert.critical_options().get("force-command").unwrap(), "uptime");
assert_eq!(cert.extensions().keys().collect::<Vec<_>>(), ["permit-pty"]);
assert_eq!(cert.valid_after(), auth.valid_from() - 30);
assert_eq!(cert.valid_before(), auth.valid_until());
}
#[test]
fn interactive_certificate_has_no_forced_command() {
let key = random_key();
let auth = authorize(key.public_key(), None, Mode::Certificate);
let cert = ca().sign(key.public_key(), &auth, Duration::ZERO).unwrap();
assert!(cert.critical_options().get("force-command").is_none());
}
#[test]
fn signs_only_what_was_authorized() {
let ca = ca();
let key = random_key();
let auth = authorize(key.public_key(), None, Mode::Certificate);
let other = random_key();
assert!(matches!(
ca.sign(other.public_key(), &auth, Duration::ZERO),
Err(Error::Mismatch(_))
));
let bastion = authorize(key.public_key(), None, Mode::Bastion);
assert!(matches!(
ca.sign(key.public_key(), &bastion, Duration::ZERO),
Err(Error::Mismatch(_))
));
let bastion_ip = "10.0.0.1".parse().unwrap();
assert!(matches!(
ca.sign_relayed(key.public_key(), &auth, bastion_ip, Duration::ZERO),
Err(Error::Mismatch(_))
));
}
#[test]
fn relayed_certificate_is_pinned_to_the_bastion() {
let ca = ca();
let client_key = random_key();
let auth = authorize(client_key.public_key(), None, Mode::Bastion);
let bastion_key = random_key();
let bastion_ip = "10.0.0.1".parse().unwrap();
let cert = ca
.sign_relayed(bastion_key.public_key(), &auth, bastion_ip, Duration::ZERO)
.unwrap();
assert_eq!(cert.public_key(), bastion_key.public_key().key_data());
assert_eq!(cert.valid_principals(), ["web-1:deploy"]);
assert_eq!(cert.critical_options().get("source-address").unwrap(), "10.0.0.1/32");
assert_eq!(cert.valid_before(), auth.valid_until());
}
#[test]
fn refuses_certificates_as_public_keys() {
let key = random_key();
let auth = authorize(key.public_key(), None, Mode::Certificate);
let cert = ca().sign(key.public_key(), &auth, Duration::ZERO).unwrap();
assert!(parse_public_key(&cert.to_openssh().unwrap()).is_err());
assert!(parse_public_key(&key.public_key().to_openssh().unwrap()).is_ok());
assert!(parse_public_key("ssh-ed25519 garbage").is_err());
}
#[cfg(unix)]
#[test]
fn loads_only_private_unencrypted_keys() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("biscuit-ssh-ca-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let write = |name: &str, content: &str, mode: u32| {
let path = dir.join(name);
std::fs::write(&path, content).unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)).unwrap();
path
};
let pem = random_key().to_openssh(ssh_key::LineEnding::LF).unwrap();
assert!(CertificateAuthority::load(&write("ok", &pem, 0o600)).is_ok());
for (name, content, mode) in [
("open", pem.as_str(), 0o644),
("malformed", "not a key", 0o600),
("encrypted", ENCRYPTED_KEY, 0o600),
] {
let result = CertificateAuthority::load(&write(name, content, mode));
assert!(matches!(result, Err(Error::Key(_))), "{name}");
}
assert!(CertificateAuthority::load(&dir.join("missing")).is_err());
std::fs::remove_dir_all(dir).unwrap();
}
}