server/user: Add sign cert operation
dustin/sshca/pipeline/head There was a failure building this commit Details

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.
master
Dustin 2023-11-21 21:46:36 -06:00
parent b945d0f142
commit be40c05b56
3 changed files with 119 additions and 1 deletions

View File

@ -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<PathBuf>,
/// 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 /// CA configuration
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize)]
pub struct CaConfig { pub struct CaConfig {
/// Host CA configuration /// Host CA configuration
pub host: HostCaConfig, pub host: HostCaConfig,
/// User CA configuration
pub user: UserCaConfig,
} }
/// OpenID Connect configuration /// OpenID Connect configuration
@ -139,6 +163,14 @@ fn default_host_cert_duration() -> u64 {
86400 * 30 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 /// Load configuration from a TOML file
/// ///
/// If `path` is provided, the configuration will be loaded from the /// If `path` is provided, the configuration will be loaded from the

View File

@ -42,6 +42,7 @@ pub fn make_app(config: Configuration) -> Router {
.route("/", get(|| async { "UP" })) .route("/", get(|| async { "UP" }))
.route("/host/sign", post(host::sign_host_cert)) .route("/host/sign", post(host::sign_host_cert))
.route("/user/oidc-config", get(user::get_oidc_config)) .route("/user/oidc-config", get(user::get_oidc_config))
.route("/user/sign", post(user::sign_user_cert))
.with_state(ctx) .with_state(ctx)
} }

View File

@ -1,8 +1,10 @@
//! User CA operations
use std::str::FromStr; use std::str::FromStr;
use std::sync::Arc; use std::sync::Arc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use axum::async_trait; use axum::async_trait;
use axum::extract::multipart::Multipart;
use axum::extract::FromRequestParts; use axum::extract::FromRequestParts;
use axum::extract::State; use axum::extract::State;
use axum::headers::authorization::Bearer; use axum::headers::authorization::Bearer;
@ -17,9 +19,12 @@ use openidconnect::IssuerUrl;
use openidconnect::Nonce; use openidconnect::Nonce;
use openidconnect::{ClientId, ClientSecret}; use openidconnect::{ClientId, ClientSecret};
use serde::Serialize; 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 super::{AuthError, Context};
use crate::ca;
/// Response type for GET /user/openid-config /// Response type for GET /user/openid-config
/// ///
@ -116,6 +121,86 @@ pub(super) async fn get_oidc_config(
Json(res) Json(res)
} }
/// User SSH key signing request payload
#[derive(Default)]
struct SignKeyRequest {
/// Public keys to sign
pubkey: Vec<u8>,
}
/// 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<super::State>,
mut form: Multipart,
) -> Result<String, SignKeyError> {
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) /// Get OIDC provider metadata (possibly from cache)
/// ///
/// This function will return metadata for the configured OIDC identity /// This function will return metadata for the configured OIDC identity