From 355a499aa0c99dbf695bef8defc9e52527fed7e8 Mon Sep 17 00:00:00 2001 From: "Dustin C. Hatch" Date: Wed, 22 Nov 2023 07:20:12 -0600 Subject: [PATCH] wip: user/login: request signed cert --- Cargo.lock | 78 ++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 2 ++ src/user/cert.rs | 74 +++++++++++++++++++++++++++++++++++++++++++++ src/user/mod.rs | 26 ++++++++++++++-- 4 files changed, 178 insertions(+), 2 deletions(-) create mode 100644 src/user/cert.rs diff --git a/Cargo.lock b/Cargo.lock index c428262..8795f3b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -223,6 +223,16 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "combine" version = "4.6.6" @@ -871,6 +881,15 @@ dependencies = [ "serde", ] +[[package]] +name = "inout" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5" +dependencies = [ + "generic-array", +] + [[package]] name = "ipnet" version = "2.9.0" @@ -1279,6 +1298,20 @@ dependencies = [ "sha2", ] +[[package]] +name = "p521" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" +dependencies = [ + "base16ct", + "ecdsa", + "elliptic-curve", + "primeorder", + "rand_core", + "sha2", +] + [[package]] name = "password-hash" version = "0.5.0" @@ -1600,6 +1633,7 @@ dependencies = [ "pkcs1", "pkcs8", "rand_core", + "sha2", "signature", "spki", "subtle", @@ -1889,6 +1923,48 @@ dependencies = [ "der", ] +[[package]] +name = "ssh-cipher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f" +dependencies = [ + "cipher", + "ssh-encoding", +] + +[[package]] +name = "ssh-encoding" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15" +dependencies = [ + "base64ct", + "pem-rfc7468", + "sha2", +] + +[[package]] +name = "ssh-key" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01f8f4ea73476c0aa5d5e6a75ce1e8634e2c3f82005ef3bbed21547ac57f2bf7" +dependencies = [ + "ed25519-dalek", + "p256", + "p384", + "p521", + "rand_core", + "rsa", + "sec1", + "sha2", + "signature", + "ssh-cipher", + "ssh-encoding", + "subtle", + "zeroize", +] + [[package]] name = "sshca-cli" version = "0.1.1" @@ -1903,6 +1979,8 @@ dependencies = [ "reqwest", "serde", "serde_json", + "ssh-encoding", + "ssh-key", "tera", "thiserror", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 7609160..0db92af 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,8 @@ openidconnect = { version = "3.4.0", default-features = false, features = ["reqw reqwest = { version = "0.11.22", features = ["multipart"] } serde = { version = "1.0.190", features = ["derive"] } serde_json = "1.0.108" +ssh-encoding = "0.2.0" +ssh-key = { version = "0.6.4", features = ["ed25519"] } tera = { version = "1.19.1", default-features = false } thiserror = "1.0.50" tokio = { version = "1.33.0", features = ["rt", "macros"] } diff --git a/src/user/cert.rs b/src/user/cert.rs new file mode 100644 index 0000000..2deefa3 --- /dev/null +++ b/src/user/cert.rs @@ -0,0 +1,74 @@ +//! SSHCA User Certificates +//! +//! The SSHCA server will sign SSH certificates for a user given a +//! supported public key and a valid OIDC Identity token. +use reqwest::multipart::{Form, Part}; +use reqwest::{StatusCode, Url}; +use ssh_key::{Certificate, PublicKey}; +use tracing::{debug, error, info}; + +/// Error type for issues requesting a signed SSH certificate +#[derive(Debug, thiserror::Error)] +pub enum SignError { + #[error("{0}")] + BadUrl(String), + #[error("SSH key error: {0}")] + SshKey(#[from] ssh_key::Error), + #[error("HTTP error: {0}")] + Http(#[from] reqwest::Error), + #[error("Bad request: {0}")] + BadRequest(String), +} + +/// Request a signed SSH certificate for a given public key +/// +/// This function requests the SSHCA server to sign a certificate for +/// the specified SSH public key. The server will use the provided +/// identity token to authorize the request and return a certificate +/// for the user based on the token subject. +/// +/// If an error occurs while requesting the certificate, [`SignError`] +/// is returned. +pub async fn sign_key( + token: &str, + pubkey: &PublicKey, +) -> Result { + let url = crate::get_sshca_server_url().map_err(SignError::BadUrl)?; + + let mut url = + Url::parse(&url).map_err(|e| SignError::BadUrl(e.to_string()))?; + url.path_segments_mut() + .map_err(|_| SignError::BadUrl("Invalid URL: missing host".into()))? + .pop_if_empty() + .push("user") + .push("sign"); + + let key_str = pubkey.to_openssh()?; + let form = Form::new().part("pubkey", Part::text(key_str)); + + let client = reqwest::Client::new(); + info!("Requesting SSH user certificate"); + debug!("Request: POST {}", url); + let res = client + .post(url) + .header("Authorization", format!("Bearer {}", token)) + .multipart(form) + .send() + .await?; + debug!("Response: {:?} {}", &res.version(), &res.status()); + match res.error_for_status_ref() { + Ok(_) => (), + Err(e) if e.status() == Some(StatusCode::BAD_REQUEST) => { + let msg = res.text().await.unwrap_or_else(|e| e.to_string()); + error!("{}: {}", e, msg); + return Err(SignError::BadRequest(format!("{}\n{}", e, msg))); + } + Err(e) => { + error!("{}", e); + return Err(e.into()); + } + }; + let cert = Certificate::from_openssh(&res.text().await?)?; + + Ok(cert) +} diff --git a/src/user/mod.rs b/src/user/mod.rs index 24a3cd5..0d8a927 100644 --- a/src/user/mod.rs +++ b/src/user/mod.rs @@ -2,11 +2,15 @@ //! //! The `sshca user` sub-command handles user-based operations, such //! as signing an SSH user certificate. +mod cert; mod login; use std::time::Duration; use argh::FromArgs; +use ssh_key::rand_core::OsRng; +use ssh_key::{Algorithm, PrivateKey}; +use tracing::debug; use crate::MainResult; @@ -33,8 +37,16 @@ struct LoginArgs { callback_listen_address: Option, /// oidc callback timeout, in seconds (default: 300) - #[argh(option, short = 't')] + #[argh(option, short = 'T')] callback_timeout: Option, + + /// ssh key type + #[argh(option, short = 't', default = "default_key_type()")] + key_type: String, +} + +fn default_key_type() -> String { + "ssh-ed25519".into() } /// Main entry point for `sshca user` @@ -46,14 +58,24 @@ pub(crate) async fn main(args: Args) -> MainResult { /// Entry point for `sshca user login` async fn login(args: LoginArgs) -> MainResult { + let algo = Algorithm::new(&args.key_type)?; let listen = match args.callback_listen_address { Some(s) => Some(s.parse()?), None => None, }; let timeout = args.callback_timeout.map(Duration::from_secs); + let url = super::get_sshca_server_url()?; let config = login::get_oidc_config(&url).await?; let token = login::login(config, listen, timeout).await?; - println!("{}", token); + + debug!("Generatring new {} key pair", algo); + let privkey = PrivateKey::random(&mut OsRng, algo) + .map_err(|e| format!("Error generating key pair: {0}", e))?; + let pubkey = privkey.public_key(); + let cert = cert::sign_key(&token, pubkey).await?; + if let Ok(c) = cert.to_openssh() { + println!("{}", c); + } Ok(()) }