From be40c05b565088ab75c3b8d940c6505c420c7e82 Mon Sep 17 00:00:00 2001 From: "Dustin C. Hatch" Date: Tue, 21 Nov 2023 21:46:36 -0600 Subject: [PATCH] server/user: Add sign cert operation The *POST /user/sign* operation issues SSH user certificates for the public keys provided. The request must include a valid OpenID Connect Identity token in the `Authorization` request header, which will be used to populate the valid principals in the signed certificate. User certificates are typically issued for a very short duration (one hour by default). This precludes the need for revoking certificates that are no longer trusted; users must reauthenticate frequently and obtain a new certificate. --- src/config.rs | 32 +++++++++++++++++ src/server/mod.rs | 1 + src/server/user.rs | 87 +++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 119 insertions(+), 1 deletion(-) diff --git a/src/config.rs b/src/config.rs index e3ebc40..e3ed190 100644 --- a/src/config.rs +++ b/src/config.rs @@ -65,11 +65,35 @@ impl Default for HostCaConfig { } } +#[derive(Debug, Deserialize)] +pub struct UserCaConfig { + /// Path to the User CA private key file + #[serde(default = "default_user_ca_key")] + pub private_key_file: PathBuf, + pub private_key_passphrase_file: Option, + + /// Duration of issued user certificates + #[serde(default = "default_user_cert_duration")] + pub cert_duration: u64, +} + +impl Default for UserCaConfig { + fn default() -> Self { + Self { + private_key_file: default_user_ca_key(), + private_key_passphrase_file: None, + cert_duration: default_user_cert_duration(), + } + } +} + /// CA configuration #[derive(Debug, Default, Deserialize)] pub struct CaConfig { /// Host CA configuration pub host: HostCaConfig, + /// User CA configuration + pub user: UserCaConfig, } /// OpenID Connect configuration @@ -139,6 +163,14 @@ fn default_host_cert_duration() -> u64 { 86400 * 30 } +fn default_user_ca_key() -> PathBuf { + default_config_path("user-ca.key") +} + +fn default_user_cert_duration() -> u64 { + 3600 +} + /// Load configuration from a TOML file /// /// If `path` is provided, the configuration will be loaded from the diff --git a/src/server/mod.rs b/src/server/mod.rs index f93c6fe..db8c7e0 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -42,6 +42,7 @@ pub fn make_app(config: Configuration) -> Router { .route("/", get(|| async { "UP" })) .route("/host/sign", post(host::sign_host_cert)) .route("/user/oidc-config", get(user::get_oidc_config)) + .route("/user/sign", post(user::sign_user_cert)) .with_state(ctx) } diff --git a/src/server/user.rs b/src/server/user.rs index 13fd578..68a093e 100644 --- a/src/server/user.rs +++ b/src/server/user.rs @@ -1,8 +1,10 @@ +//! User CA operations use std::str::FromStr; use std::sync::Arc; use std::time::{Duration, Instant}; use axum::async_trait; +use axum::extract::multipart::Multipart; use axum::extract::FromRequestParts; use axum::extract::State; use axum::headers::authorization::Bearer; @@ -17,9 +19,12 @@ use openidconnect::IssuerUrl; use openidconnect::Nonce; use openidconnect::{ClientId, ClientSecret}; use serde::Serialize; -use tracing::{debug, error, trace, warn}; +use ssh_key::Algorithm; +use tracing::{debug, error, info, trace, warn}; +use super::error::SignKeyError; use super::{AuthError, Context}; +use crate::ca; /// Response type for GET /user/openid-config /// @@ -116,6 +121,86 @@ pub(super) async fn get_oidc_config( Json(res) } +/// User SSH key signing request payload +#[derive(Default)] +struct SignKeyRequest { + /// Public keys to sign + pubkey: Vec, +} + +/// Handler for user certificate signing requests +/// +/// An SSH user certificate will be signed for each submitted public +/// key. The valid principals on the certificates will be taken from +/// the OpenID Connect Identity Token in the Authorization header, via +/// the `sub`, `perferred_username`, and `email` claims (if present). +pub(super) async fn sign_user_cert( + Claims(claims): Claims, + State(ctx): State, + mut form: Multipart, +) -> Result { + let username = claims.subject().as_str(); + let mut body = SignKeyRequest::default(); + + while let Some(field) = form.next_field().await? { + match field.name() { + Some("pubkey") => { + body.pubkey = field.bytes().await?.into(); + } + Some(n) => { + warn!("Client request included unsupported field {:?}", n); + } + None => {} + } + } + if body.pubkey.is_empty() { + return Err(SignKeyError::NoKey); + } + let mut alias = vec![]; + if let Some(username) = claims.preferred_username() { + alias.push(username.as_str()); + } + if let Some(email) = claims.email() { + if claims.email_verified() == Some(true) { + alias.push(email.as_str()); + } + } + + let config = &ctx.config; + let duration = Duration::from_secs(config.ca.user.cert_duration); + let privkey = ca::load_private_key( + &config.ca.user.private_key_file, + config.ca.user.private_key_passphrase_file.as_ref(), + ) + .await + .map_err(SignKeyError::LoadPrivateKey)?; + + let pubkey = ca::parse_public_key(&body.pubkey) + .map_err(SignKeyError::ParsePublicKey)?; + match pubkey.algorithm() { + Algorithm::Ecdsa { .. } => (), + Algorithm::Ed25519 => (), + Algorithm::Rsa { .. } => (), + _ => { + return Err(SignKeyError::UnsupportedAlgorithm( + pubkey.algorithm().as_str().into(), + )); + } + }; + debug!( + "Signing {} key for {}", + pubkey.algorithm().as_str(), + username + ); + let cert = ca::sign_user_cert(username, &pubkey, duration, &privkey, &alias)?; + info!( + "Signed {} key for {}", + pubkey.algorithm().as_str(), + username + ); + Ok(cert.to_openssh().map_err(ca::CertError::from)?) +} + /// Get OIDC provider metadata (possibly from cache) /// /// This function will return metadata for the configured OIDC identity