server/user: Add sign cert operation
dustin/sshca/pipeline/head There was a failure building this commit
Details
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
parent
b945d0f142
commit
be40c05b56
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in New Issue