diff --git a/Cargo.lock b/Cargo.lock index 6c793af..010dd43 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1663,8 +1663,10 @@ dependencies = [ "async-trait", "axum", "axum-extra", + "chrono", "hex", "serde", + "shell-escape", "ssh-key", "tempfile", "thiserror", @@ -1686,7 +1688,6 @@ dependencies = [ "jwt-compact", "rand", "serde", - "shell-escape", "ssh-cert-dist-common", "ssh-key", "tempfile", diff --git a/client/src/client.rs b/client/src/client.rs index 00c2a3c..20018e5 100644 --- a/client/src/client.rs +++ b/client/src/client.rs @@ -1,9 +1,10 @@ -use anyhow::bail; +use anyhow::{bail, Context}; use axum_extra::routing::TypedPath; use clap::{Parser, Subcommand}; use reqwest::{Client, StatusCode}; use ssh_key::Certificate; use std::path::PathBuf; +use std::process; use std::time::{Duration, SystemTime}; use tokio::fs; use tokio::io::{stdin, AsyncBufReadExt, BufReader}; @@ -45,6 +46,19 @@ pub struct UploadArgs { files: Vec, } +#[derive(Parser)] +pub struct RenewCommandArgs { + /// Execute the renew command + #[clap(short = 'x')] + execute: bool, + /// Path to the CA private key + #[clap(long="ca", env = env_key!("CA_KEY"))] + ca_key: Option, + /// Certificates to generate commands for + #[clap(env = env_key!("FILES"))] + files: Vec, +} + #[derive(Parser)] pub struct ClientCommand { #[clap(subcommand)] @@ -55,12 +69,14 @@ pub struct ClientCommand { pub enum ClientCommands { Fetch(FetchArgs), Upload(UploadArgs), + RenewCommand(RenewCommandArgs), } pub async fn run(ClientCommand { cmd }: ClientCommand) -> anyhow::Result<()> { match cmd { ClientCommands::Fetch(args) => fetch(args).await, ClientCommands::Upload(args) => upload(args).await, + ClientCommands::RenewCommand(args) => renew(args).await, } } @@ -170,6 +186,40 @@ async fn fetch( Ok(()) } +async fn renew( + RenewCommandArgs { + files, + ca_key, + execute, + }: RenewCommandArgs, +) -> anyhow::Result<()> { + for file in files.iter() { + let cert = load_cert(&file).await?; + if let Some(cert) = cert { + let command = renew_command( + &cert, + ca_key + .as_deref() + .map(|path| path.to_str()) + .flatten() + .unwrap_or("ca"), + file.to_str(), + ); + println!("{}", command); + if execute { + process::Command::new("sh") + .arg("-c") + .arg(&command) + .spawn() + .with_context(|| format!("{command}"))?; + } + } else { + bail!("{file:?} doesn't exist"); + } + } + Ok(()) +} + #[instrument(skip(client, current))] async fn fetch_cert( client: Client, diff --git a/common/Cargo.toml b/common/Cargo.toml index 548a0c6..5c0aaf6 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -11,6 +11,7 @@ anyhow = "1.0.66" async-trait = "0.1.59" axum = { version = "0.6.1" } axum-extra = { version = "0.4.1", features = ["typed-routing"] } +chrono = "0.4.26" hex = { version = "0.4.3", features = ["serde"] } serde = { version = "1.0.148", features = ["derive"] } ssh-key = { version = "0.6.0-pre.0", features = ["ed25519", "p256", "p384", "rsa"] } @@ -18,6 +19,7 @@ thiserror = "1.0.37" tokio = { version = "1.22.0", features = ["io-std", "test-util", "tracing", "macros", "fs"] } tracing = { version = "0.1.37", features = ["release_max_level_debug"] } tracing-subscriber = "0.3.16" +shell-escape = "0.1.5" [dev-dependencies] tempfile = "3.3.0" diff --git a/common/src/lib.rs b/common/src/lib.rs index af4e7dc..de2f951 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -1,6 +1,8 @@ mod certs; +mod renew; mod routes; mod util; pub use certs::*; +pub use renew::*; pub use routes::*; diff --git a/common/src/renew.rs b/common/src/renew.rs new file mode 100644 index 0000000..8e23e79 --- /dev/null +++ b/common/src/renew.rs @@ -0,0 +1,49 @@ +use std::borrow::Cow; +use std::time::UNIX_EPOCH; + +use chrono::Duration; +use shell_escape::escape; +use ssh_key::Certificate; + +/// Generates an command to renew the given certs +pub fn renew_command(cert: &Certificate, ca_path: &str, file_name: Option<&str>) -> String { + let validity = cert + .valid_before_time() + .duration_since(cert.valid_after_time()) + .unwrap_or(Duration::zero().to_std().unwrap()); + let expiry = cert.valid_before_time().checked_add(validity).unwrap(); + let expiry_date = expiry.duration_since(UNIX_EPOCH).unwrap(); + let host_key = if cert.cert_type().is_host() { + " -h" + } else { + "" + }; + let opts = cert + .critical_options() + .iter() + .map(|(opt, val)| { + if val.is_empty() { + opt.clone() + } else { + format!("{opt}={val}") + } + }) + .map(|arg| format!("-O {}", escape(arg.into()))) + .collect::>() + .join(" "); + let opts = opts.trim(); + let renew_command = format!( + "ssh-keygen -s {ca_path} {host_key} -I {} -n {} -z {} -V {:#x}:{:#x} {opts} {}", + escape(cert.key_id().into()), + escape(cert.valid_principals().join(",").into()), + cert.serial() + 1, + cert.valid_after(), + expiry_date.as_secs(), + escape( + file_name + .map(Cow::Borrowed) + .unwrap_or_else(|| escape(format!("{}.pub", cert.key_id()).into())) + ) + ); + renew_command +} diff --git a/server/Cargo.toml b/server/Cargo.toml index 2a87afe..728a9d0 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -35,7 +35,6 @@ tower-http = { version = "0.3.4", features = ["map-request-body", "trace", "util tracing = { version = "0.1.37", features = ["release_max_level_debug"] } tracing-subscriber = "0.3.16" ssh-cert-dist-common = { path = "../common" } -shell-escape = "0.1.5" [dev-dependencies] tempfile = "3.3.0" diff --git a/server/src/api.rs b/server/src/api.rs index 576633b..e539ede 100644 --- a/server/src/api.rs +++ b/server/src/api.rs @@ -15,14 +15,13 @@ use axum::extract::rejection::QueryRejection; use axum::extract::{Query, State}; use chrono::Duration; -use shell_escape::escape; use ssh_cert_dist_common::*; use axum::{http::StatusCode, response::IntoResponse, Json, Router}; use axum_extra::routing::RouterExt; use clap::{Args, Parser}; use jwt_compact::alg::{Hs256, Hs256Key}; -use jwt_compact::{AlgorithmExt}; +use jwt_compact::AlgorithmExt; use rand::{thread_rng, Rng}; use serde::{Deserialize, Serialize}; use ssh_key::{Certificate, Fingerprint, PublicKey}; @@ -31,7 +30,7 @@ use tower::ServiceBuilder; use tower_http::{trace::TraceLayer, ServiceBuilderExt}; use tracing::{debug, info, trace}; -use self::extract::{CertificateBody, SignatureBody, JWTAuthenticated, JWTString}; +use self::extract::{CertificateBody, JWTAuthenticated, JWTString, SignatureBody}; #[derive(Parser)] pub struct ApiArgs { @@ -309,40 +308,6 @@ struct CertInfo { impl From<&Certificate> for CertInfo { fn from(cert: &Certificate) -> Self { - let validity = cert - .valid_before_time() - .duration_since(cert.valid_after_time()) - .unwrap_or(Duration::zero().to_std().unwrap()); - let expiry = cert.valid_before_time().checked_add(validity).unwrap(); - let expiry_date = expiry.duration_since(UNIX_EPOCH).unwrap(); - let host_key = if cert.cert_type().is_host() { - " -h" - } else { - "" - }; - let opts = cert - .critical_options() - .iter() - .map(|(opt, val)| { - if val.is_empty() { - opt.clone() - } else { - format!("{opt}={val}") - } - }) - .map(|arg| format!("-O {}", escape(arg.into()))) - .collect::>() - .join(" "); - let opts = opts.trim(); - let renew_command = format!( - "ssh-keygen -s ./ca_key {host_key} -I {} -n {} -z {} -V {:#x}:{:#x} {opts} {}.pub", - escape(cert.key_id().into()), - escape(cert.valid_principals().join(",").into()), - cert.serial() + 1, - cert.valid_after(), - expiry_date.as_secs(), - escape(cert.key_id().into()) - ); CertInfo { principals: cert.valid_principals().to_vec(), ca: cert.signature_key().clone().into(), @@ -351,7 +316,7 @@ impl From<&Certificate> for CertInfo { identity_hash: cert.public_key().fingerprint(ssh_key::HashAlg::Sha256), key_id: cert.key_id().to_string(), expiry: cert.valid_before_time(), - renew_command, + renew_command: renew_command(cert, "./ca", None), } } } @@ -387,7 +352,6 @@ impl From> for JWTString { } } - /// POST with signed challenge async fn post_certs_identifier( PostCertInfo { identifier }: PostCertInfo,