From 2e7c80d26ce31bfde73d51888df56ac77e59e83d Mon Sep 17 00:00:00 2001 From: shimun Date: Thu, 8 Dec 2022 14:01:48 +0100 Subject: [PATCH] added: client_auth --- Cargo.lock | 180 +++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 6 +- src/api.rs | 112 ++++++++++++++++++++++++++-- src/api/extract.rs | 24 +++++- 4 files changed, 312 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e357aeb..1defd8b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anyhow" version = "1.0.66" @@ -181,6 +190,21 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "chrono" +version = "0.4.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b0a3d9ed01224b22057780a37bb8c5dbfe1be8ba48678e7bf57ec4b385411f" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-integer", + "num-traits", + "time", + "wasm-bindgen", + "winapi", +] + [[package]] name = "clap" version = "4.0.29" @@ -218,6 +242,16 @@ dependencies = [ "os_str_bytes", ] +[[package]] +name = "codespan-reporting" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" +dependencies = [ + "termcolor", + "unicode-width", +] + [[package]] name = "const-oid" version = "0.9.1" @@ -284,6 +318,50 @@ dependencies = [ "zeroize", ] +[[package]] +name = "cxx" +version = "1.0.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdf07d07d6531bfcdbe9b8b739b104610c6508dcc4d63b410585faf338241daf" +dependencies = [ + "cc", + "cxxbridge-flags", + "cxxbridge-macro", + "link-cplusplus", +] + +[[package]] +name = "cxx-build" +version = "1.0.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2eb5b96ecdc99f72657332953d4d9c50135af1bac34277801cc3937906ebd39" +dependencies = [ + "cc", + "codespan-reporting", + "once_cell", + "proc-macro2", + "quote", + "scratch", + "syn", +] + +[[package]] +name = "cxxbridge-flags" +version = "1.0.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac040a39517fd1674e0f32177648334b0f4074625b5588a64519804ba0553b12" + +[[package]] +name = "cxxbridge-macro" +version = "1.0.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1362b0ddcfc4eb0a1f57b68bd77dd99f0e826958a96abd0ae9bd092e114ffed6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "der" version = "0.6.0" @@ -550,6 +628,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabb4a44450da02c90444cf74558da904edde8fb4e9035a9a6a4e15445af0bd7" + [[package]] name = "hashbrown" version = "0.12.3" @@ -657,6 +741,30 @@ dependencies = [ "tokio-native-tls", ] +[[package]] +name = "iana-time-zone" +version = "0.1.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64c122667b287044802d6ce17ee2ddf13207ed924c712de9a66a5814d5b64765" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "winapi", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0703ae284fc167426161c2e3f1da3ea71d94b21bedbcc9494e92b28e334e3dca" +dependencies = [ + "cxx", + "cxx-build", +] + [[package]] name = "idna" version = "0.3.0" @@ -729,6 +837,26 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jwt-compact" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bcc576baa96136028d34d45ab5c840d146235a4c37c87d96237d05fea222194" +dependencies = [ + "anyhow", + "base64ct", + "chrono", + "hmac", + "rand_core 0.6.4", + "serde", + "serde_cbor", + "serde_json", + "sha2 0.10.6", + "smallvec", + "subtle", + "zeroize", +] + [[package]] name = "lazy_static" version = "1.4.0" @@ -750,6 +878,15 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "348108ab3fba42ec82ff6e9564fc4ca0247bdccdc68dd8af9764bbc79c3c8ffb" +[[package]] +name = "link-cplusplus" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9272ab7b96c9046fbc5bc56c06c117cb639fe2d509df0c421cad82d2915cf369" +dependencies = [ + "cc", +] + [[package]] name = "linux-raw-sys" version = "0.1.3" @@ -1280,6 +1417,12 @@ dependencies = [ "windows-sys 0.36.1", ] +[[package]] +name = "scratch" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8132065adcfd6e02db789d9285a0deb2f3fcb04002865ab67d5fb103533898" + [[package]] name = "sec1" version = "0.3.0" @@ -1326,6 +1469,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde_cbor" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bef2ebfde456fb76bbcf9f59315333decc4fda0b2b44b420243c11e0f5ec1f5" +dependencies = [ + "half", + "serde", +] + [[package]] name = "serde_derive" version = "1.0.148" @@ -1461,10 +1614,14 @@ dependencies = [ "async-trait", "axum", "axum-extra", + "chrono", "clap", + "jwt-compact", + "rand 0.8.5", "reqwest", "serde", "ssh-key", + "tempfile", "thiserror", "tokio", "tower", @@ -1596,6 +1753,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "time" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b797afad3f312d1c66a56d11d0316f916356d11bd158fbc6ca6389ff6bf805a" +dependencies = [ + "libc", + "wasi 0.10.0+wasi-snapshot-preview1", + "winapi", +] + [[package]] name = "tinyvec" version = "1.6.0" @@ -1804,6 +1972,12 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-width" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b" + [[package]] name = "unicode-xid" version = "0.2.4" @@ -1855,6 +2029,12 @@ version = "0.9.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" +[[package]] +name = "wasi" +version = "0.10.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f" + [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" diff --git a/Cargo.toml b/Cargo.toml index 25f9051..21671a7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,8 +7,9 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [features] -default = [ "client", "reload", "info" ] +default = [ "client", "reload", "info", "authorized" ] reload = [] +authorized =[ "dep:jwt-compact" ] info = [ "axum/json", "ssh-key/serde" ] client = [ "dep:url", "dep:reqwest" ] @@ -18,7 +19,10 @@ anyhow = "1.0.66" async-trait = "0.1.59" axum = { version = "0.6.1", features = ["http2"] } axum-extra = { version = "0.4.1", features = ["typed-routing"] } +chrono = "0.4.23" clap = { version = "4.0.29", features = ["env", "derive"] } +jwt-compact = { version = "0.6.0", features = ["serde_cbor", "std", "clock"], optional = true } +rand = "0.8.5" reqwest = { version = "0.11.13", optional = true } serde = { version = "1.0.148", features = ["derive"] } ssh-key = { version = "0.5.1", features = ["ed25519", "p256", "p384", "rsa", "signature"] } diff --git a/src/api.rs b/src/api.rs index 24fb24e..960e96d 100644 --- a/src/api.rs +++ b/src/api.rs @@ -10,7 +10,7 @@ use crate::certs::{load_cert_by_id, read_certs, read_pubkey, store_cert}; use crate::env_key; use anyhow::Context; use axum::body; -use axum::extract::{Path, State}; +use axum::extract::{Path, Query, State}; use axum::{http::StatusCode, response::IntoResponse, Json, Router}; use axum_extra::routing::{ @@ -18,7 +18,10 @@ use axum_extra::routing::{ TypedPath, }; use clap::{Args, Parser}; -use serde::Deserialize; +use jwt_compact::alg::{Hs256, Hs256Key}; +use jwt_compact::{AlgorithmExt, Token, UntrustedToken}; +use rand::{thread_rng, Rng}; +use serde::{Deserialize, Serialize}; use ssh_key::private::Ed25519Keypair; use ssh_key::{certificate, Certificate, PrivateKey, PublicKey}; use tokio::sync::Mutex; @@ -26,7 +29,7 @@ use tower::ServiceBuilder; use tower_http::{trace::TraceLayer, ServiceBuilderExt}; use tracing::{debug, trace}; -use self::extract::CertificateBody; +use self::extract::{CertificateBody, SignatureBody}; #[derive(Parser)] pub struct ApiArgs { @@ -78,7 +81,9 @@ struct ApiState { certs: Arc>>, cert_dir: PathBuf, ca: PublicKey, + client_auth: bool, validation_args: CertificateValidationArgs, + jwt_key: Hs256Key, } impl ApiState { @@ -98,7 +103,9 @@ impl ApiState { )), cert_dir: cert_dir.as_ref().into(), ca, + client_auth: false, validation_args, + jwt_key: Hs256Key::new(thread_rng().gen::<[u8; 16]>()), }) } } @@ -163,6 +170,10 @@ pub enum ApiError { LowSerial(u64, u64), #[error("expiry date must be greater than {0:?}")] InsufficientValidity(SystemTime), + #[error("authentication required")] + AuthenticationRequired(String), + #[error("invalid ssh signature")] + InvalidSignature, } type ApiResult = Result; @@ -173,6 +184,9 @@ impl IntoResponse for ApiError { match self { Self::CertificateNotFound => StatusCode::NOT_FOUND, Self::LowSerial(_, _) | Self::InsufficientValidity(_) => StatusCode::BAD_REQUEST, + Self::AuthenticationRequired(challenge) => { + return (StatusCode::UNAUTHORIZED, challenge).into_response() + } _ => StatusCode::INTERNAL_SERVER_ERROR, }, self.to_string(), @@ -185,6 +199,12 @@ async fn fallback_404() -> ApiResult<()> { Err(ApiError::CertificateNotFound) } +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "aud", rename = "get")] +struct AuthClaims { + identifier: String, +} + #[derive(TypedPath, Deserialize)] #[typed_path("/certs/:identifier")] pub struct GetCert { @@ -198,8 +218,23 @@ pub struct GetCert { /// the challenge an issue an post request async fn get_certs_identifier( GetCert { identifier }: GetCert, - State(ApiState { certs, .. }): State, + State(ApiState { + certs, + jwt_key, + client_auth, + .. + }): State, ) -> ApiResult { + use jwt_compact::{AlgorithmExt, Claims, Header, TimeOptions}; + + if client_auth { + let claims = Claims::new(AuthClaims { identifier }) + .set_duration(&TimeOptions::default(), chrono::Duration::seconds(120)); + let challenge = Hs256 + .compact_token(Header::default(), &claims, &jwt_key) + .context("jwt sign")?; + return Err(ApiError::AuthenticationRequired(challenge)); + } let certs = certs.lock().await; let cert = certs .get(&identifier) @@ -239,13 +274,39 @@ pub struct PostCertInfo { pub identifier: String, } +#[derive(Debug, Deserialize)] +struct PostCertsQuery { + challenge: String, +} + /// POST with signed challenge async fn post_certs_identifier( - PostCertInfo { identifier: _ }: PostCertInfo, - State(ApiState { .. }): State, - Path(_identifier): Path, + PostCertInfo { identifier }: PostCertInfo, + State(ApiState { certs, jwt_key, .. }): State, + Query(PostCertsQuery { challenge }): Query, + SignatureBody(sig): SignatureBody, ) -> ApiResult { - unimplemented!() + let certs = certs.lock().await; + let cert = certs.get(&identifier).ok_or(ApiError::InvalidSignature)?; + let token: Token = Hs256 + .validate_integrity( + &UntrustedToken::new(&challenge).context("jwt parse")?, + &jwt_key, + ) + .map_err(|_| ApiError::InvalidSignature)?; + if token.claims().custom.identifier != identifier { + return Err(ApiError::InvalidSignature); + } + let pubkey: PublicKey = cert.public_key().clone().into(); + let verification = tokio::task::spawn_blocking(move || { + pubkey + .verify(&identifier, challenge.as_bytes(), &sig) + .map_err(|_| ApiError::InvalidSignature) + }) + .await + .context("tokio blocking")?; + verification?; + Ok(cert.to_openssh().context("to openssh")?) } #[derive(TypedPath)] @@ -304,6 +365,8 @@ async fn put_cert_update( mod tests { use std::env::temp_dir; + use ssh_key::SshSig; + use super::*; fn ca_key() -> Ed25519Keypair { @@ -354,6 +417,8 @@ mod tests { certs: Default::default(), cert_dir: dbg!(temp_dir()), validation_args: Default::default(), + client_auth: false, + jwt_key: Hs256Key::new(&[0u8; 16]), } } @@ -403,6 +468,37 @@ mod tests { ) .await; assert!(matches!(res, Err(ApiError::CertificateNotFound))); + + let state = ApiState { + client_auth: true, + ..state + }; + + let res = get_certs_identifier( + GetCert { + identifier: "test_cert".into(), + }, + State(state.clone()), + ) + .await; + + assert!(matches!(res, Err(ApiError::AuthenticationRequired(_)))); + + if let Err(ApiError::AuthenticationRequired(challenge)) = res { + let signing_key: PrivateKey = user_key().into(); + let sig = signing_key.sign("test_cert", Default::default(), challenge.as_bytes())?; + let cert = post_certs_identifier( + PostCertInfo { + identifier: "test_cert".into(), + }, + State(state.clone()), + Query(PostCertsQuery { challenge }), + SignatureBody(sig), + ) + .await?; + assert_eq!(cert, valid_cert.to_openssh()?); + } + Ok(()) } } diff --git a/src/api/extract.rs b/src/api/extract.rs index 3271d7a..333bc31 100644 --- a/src/api/extract.rs +++ b/src/api/extract.rs @@ -2,7 +2,7 @@ use anyhow::Context; use axum::{ async_trait, body::BoxBody, extract::FromRequest, http::Request, response::IntoResponse, }; -use ssh_key::Certificate; +use ssh_key::{Certificate, SshSig}; use super::ApiError; @@ -29,3 +29,25 @@ where Ok(Self(cert)) } } + +#[derive(Debug, Clone)] +pub struct SignatureBody(pub SshSig); + +#[async_trait] +impl FromRequest for SignatureBody +where + S: Send + Sync, +{ + type Rejection = ApiError; + + async fn from_request(req: Request, state: &S) -> Result { + let body = String::from_request(req, state) + .await + .map_err(|err| err.into_response()) + .unwrap(); //.context("failed to extract body")?; + + let sig = SshSig::from_pem(&body).with_context(|| format!("failed to parse '{}'", body))?; + + Ok(Self(sig)) + } +}