From e0b8da08f2bc30b75a59fed0bf5cddf06e7f8a72 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Tue, 3 Mar 2026 17:36:22 -0500 Subject: [PATCH] Add mTLS authentication to arc-api MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Support mutual TLS as an authentication strategy alongside JWT. The server accepts both auth methods on the same port — mTLS if a client cert is presented, JWT via Bearer header otherwise. Config changes: - Replace `authentication_strategy` (singular) with `authentication_strategies` (list of "jwt" and/or "mtls") - Add `[api.tls]` section for cert, key, and CA paths New files: tls.rs (rustls ServerConfig builder) Modified: server_config.rs, jwt_auth.rs, serve.rs, lib.rs Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 107 +++++++ crates/arc-api/Cargo.toml | 10 + crates/arc-api/src/jwt_auth.rs | 429 ++++++++++++++++++++++------ crates/arc-api/src/lib.rs | 1 + crates/arc-api/src/serve.rs | 76 ++++- crates/arc-api/src/server_config.rs | 81 ++++-- crates/arc-api/src/tls.rs | 72 +++++ crates/arc-api/tests/integration.rs | 413 ++++++++++++++++++++++++++ 8 files changed, 1085 insertions(+), 104 deletions(-) create mode 100644 crates/arc-api/src/tls.rs diff --git a/Cargo.lock b/Cargo.lock index 134661496..6e9ad214b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -145,20 +145,29 @@ dependencies = [ "clap", "dirs", "http-body-util", + "hyper", + "hyper-util", "jsonwebtoken", "openapiv3", + "reqwest 0.12.28", + "rustls", + "rustls-pemfile", + "rustls-pki-types", "serde", "serde_json", "serde_yaml", "sqlx", "tempfile", "tokio", + "tokio-rustls", "tokio-stream", "toml", "tower", + "tower-service", "tracing", "ulid", "uuid", + "x509-parser", ] [[package]] @@ -329,6 +338,45 @@ dependencies = [ "uuid", ] +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "assert-json-diff" version = "2.0.2" @@ -980,6 +1028,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "deranged" version = "0.5.8" @@ -2454,6 +2516,15 @@ dependencies = [ "libm", ] +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -3240,6 +3311,15 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + [[package]] name = "rustix" version = "1.1.4" @@ -3260,6 +3340,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ "aws-lc-rs", + "log", "once_cell", "ring", "rustls-pki-types", @@ -3280,6 +3361,15 @@ dependencies = [ "security-framework", ] +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "rustls-pki-types" version = "1.14.0" @@ -5459,6 +5549,23 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + [[package]] name = "xattr" version = "1.6.1" diff --git a/crates/arc-api/Cargo.toml b/crates/arc-api/Cargo.toml index 855681795..17f918b68 100644 --- a/crates/arc-api/Cargo.toml +++ b/crates/arc-api/Cargo.toml @@ -23,6 +23,14 @@ tokio-stream = { workspace = true, features = ["sync"] } base64.workspace = true jsonwebtoken.workspace = true tokio.workspace = true +tokio-rustls = "0.26" +rustls = { version = "0.23", default-features = false, features = ["std", "ring"] } +rustls-pemfile = "2" +rustls-pki-types = "1" +hyper = "1" +hyper-util = { version = "0.1", features = ["tokio", "server-auto", "http1", "http2"] } +tower-service = "0.3" +x509-parser = "0.16" serde.workspace = true serde_json.workspace = true anyhow.workspace = true @@ -39,3 +47,5 @@ http-body-util = "0.1" tempfile = "3" openapiv3 = "2" serde_yaml = "0.9" +reqwest = { workspace = true } +rustls = { version = "0.23", default-features = false, features = ["std", "ring"] } diff --git a/crates/arc-api/src/jwt_auth.rs b/crates/arc-api/src/jwt_auth.rs index 3f3958838..d3236d272 100644 --- a/crates/arc-api/src/jwt_auth.rs +++ b/crates/arc-api/src/jwt_auth.rs @@ -4,6 +4,7 @@ use axum::extract::FromRequestParts; use axum::http::request::Parts; use axum::http::StatusCode; use jsonwebtoken::{Algorithm, DecodingKey, Validation}; +use rustls_pki_types::CertificateDer; use serde::Deserialize; use tracing::warn; @@ -19,18 +20,29 @@ struct Claims { sub: Option, } -/// Authentication mode resolved at startup. +/// A single authentication strategy resolved at startup. #[derive(Clone)] -pub enum AuthMode { - /// JWT verification is enabled with the given decoding key and allowed users. +pub enum AuthStrategy { Jwt { key: Arc, allowed_usernames: Vec, }, - /// Authentication is explicitly disabled (insecure, for development only). + Mtls, +} + +/// Authentication mode resolved at startup. +#[derive(Clone)] +pub enum AuthMode { + /// One or more strategies to try in order. + Strategies(Vec), + /// Authentication is explicitly disabled (--demo flag only). Disabled, } +/// Peer certificates extracted from the TLS connection, inserted as a request extension. +#[derive(Clone)] +pub struct PeerCertificates(pub Option>>); + /// Decode a PEM env var that may be raw PEM or base64-encoded PEM. fn decode_pem_env(name: &str, value: &str) -> String { if value.starts_with("-----") { @@ -44,41 +56,122 @@ fn decode_pem_env(name: &str, value: &str) -> String { /// Resolve the authentication mode from the API config section. /// /// Call this once at startup before serving requests. Panics if the -/// configuration is invalid (JWT strategy but no public key). +/// configuration is invalid (JWT strategy but no public key, or mTLS without TLS config). pub fn resolve_auth_mode( api_config: &crate::server_config::ApiConfig, allowed_usernames: Vec, ) -> AuthMode { - use crate::server_config::ApiAuthenticationStrategy; + use crate::server_config::ApiAuthStrategy; - match api_config.authentication_strategy { - ApiAuthenticationStrategy::InsecureDisabled => { - warn!("JWT authentication disabled"); - AuthMode::Disabled - } - ApiAuthenticationStrategy::Jwt => { - let raw = std::env::var("ARC_JWT_PUBLIC_KEY").unwrap_or_else(|_| { - panic!( - "ARC_JWT_PUBLIC_KEY is not set. Either provide an Ed25519 public key in PEM \ - format (or base64-encoded PEM) or set authentication_strategy = \ - \"insecure_disabled\" in ~/.arc/arc.toml to allow unauthenticated access \ - (development only)." - ) - }); - let pem = decode_pem_env("ARC_JWT_PUBLIC_KEY", &raw); - let key = DecodingKey::from_ed_pem(pem.as_bytes()) - .expect("ARC_JWT_PUBLIC_KEY contains an invalid Ed25519 PEM public key"); - AuthMode::Jwt { - key: Arc::new(key), - allowed_usernames, - } - } + if api_config.authentication_strategies.is_empty() { + warn!("No authentication strategies configured; all requests will be rejected"); } + + let strategies = api_config + .authentication_strategies + .iter() + .map(|s| match s { + ApiAuthStrategy::Jwt => { + let raw = std::env::var("ARC_JWT_PUBLIC_KEY").unwrap_or_else(|_| { + panic!( + "ARC_JWT_PUBLIC_KEY is not set. Provide an Ed25519 public key in PEM \ + format (or base64-encoded PEM) for JWT authentication." + ) + }); + let pem = decode_pem_env("ARC_JWT_PUBLIC_KEY", &raw); + let key = DecodingKey::from_ed_pem(pem.as_bytes()) + .expect("ARC_JWT_PUBLIC_KEY contains an invalid Ed25519 PEM public key"); + AuthStrategy::Jwt { + key: Arc::new(key), + allowed_usernames: allowed_usernames.clone(), + } + } + ApiAuthStrategy::Mtls => { + assert!( + api_config.tls.is_some(), + "mTLS authentication strategy requires [api.tls] configuration with cert, key, and ca" + ); + AuthStrategy::Mtls + } + }) + .collect(); + + AuthMode::Strategies(strategies) } -/// Axum extractor that enforces JWT authentication on a route. +/// Try to authenticate via JWT. Returns the subject (username) on success. +fn try_jwt( + parts: &Parts, + key: &DecodingKey, + allowed_usernames: &[String], +) -> Result { + let header = parts + .headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .ok_or(StatusCode::UNAUTHORIZED)?; + + let token = header + .strip_prefix("Bearer ") + .ok_or(StatusCode::UNAUTHORIZED)?; + + let mut validation = Validation::new(Algorithm::EdDSA); + validation.set_required_spec_claims(&["iss", "iat", "exp"]); + validation.set_issuer(&["arc-web"]); + + let token_data = jsonwebtoken::decode::(token, key, &validation) + .map_err(|_| StatusCode::UNAUTHORIZED)?; + + // Fail closed: if no usernames are allowed, reject all requests + if allowed_usernames.is_empty() { + return Err(StatusCode::FORBIDDEN); + } + + // Extract GitHub username from sub claim URL (last path segment) + let username = token_data + .claims + .sub + .as_deref() + .and_then(|s| s.rsplit('/').next()) + .ok_or(StatusCode::FORBIDDEN)?; + + if !allowed_usernames.iter().any(|u| u == username) { + return Err(StatusCode::FORBIDDEN); + } + + Ok(username.to_string()) +} + +/// Try to authenticate via mTLS peer certificates. Returns the CN on success. +fn try_mtls(parts: &Parts) -> Result { + let peer_certs = parts + .extensions + .get::() + .and_then(|pc| pc.0.as_ref()) + .ok_or(StatusCode::UNAUTHORIZED)?; + + if peer_certs.is_empty() { + return Err(StatusCode::UNAUTHORIZED); + } + + // Extract CN from the first (leaf) certificate + let cert = &peer_certs[0]; + let (_, parsed) = x509_parser::parse_x509_certificate(cert) + .map_err(|_| StatusCode::UNAUTHORIZED)?; + + let cn = parsed + .subject() + .iter_common_name() + .next() + .and_then(|cn| cn.as_str().ok()) + .ok_or(StatusCode::UNAUTHORIZED)?; + + Ok(cn.to_string()) +} + +/// Axum extractor that enforces authentication on a route. /// -/// Add this as a parameter to any handler to require a valid JWT. +/// Tries each configured strategy in order. The first successful match wins. /// The `AuthMode` must be added to the router as an Extension. /// When auth is disabled, the extractor accepts all requests. pub struct AuthenticatedService; @@ -92,49 +185,34 @@ impl FromRequestParts for AuthenticatedService { .get::() .expect("AuthMode extension must be added to the router"); - let (key, allowed_usernames) = match auth_mode { + let strategies = match auth_mode { AuthMode::Disabled => return Ok(AuthenticatedService), - AuthMode::Jwt { - key, - allowed_usernames, - } => (key, allowed_usernames), + AuthMode::Strategies(strategies) => strategies, }; - let header = parts - .headers - .get("authorization") - .and_then(|v| v.to_str().ok()) - .ok_or(StatusCode::UNAUTHORIZED)?; - - let token = header - .strip_prefix("Bearer ") - .ok_or(StatusCode::UNAUTHORIZED)?; - - let mut validation = Validation::new(Algorithm::EdDSA); - validation.set_required_spec_claims(&["iss", "iat", "exp"]); - validation.set_issuer(&["arc-web"]); - - let token_data = jsonwebtoken::decode::(token, key, &validation) - .map_err(|_| StatusCode::UNAUTHORIZED)?; - - // Fail closed: if no usernames are allowed, reject all requests - if allowed_usernames.is_empty() { - return Err(StatusCode::FORBIDDEN); + if strategies.is_empty() { + return Err(StatusCode::UNAUTHORIZED); } - // Extract GitHub username from sub claim URL (last path segment) - let username = token_data - .claims - .sub - .as_deref() - .and_then(|s| s.rsplit('/').next()) - .ok_or(StatusCode::FORBIDDEN)?; + let mut last_err = StatusCode::UNAUTHORIZED; - if !allowed_usernames.iter().any(|u| u == username) { - return Err(StatusCode::FORBIDDEN); + for strategy in strategies { + match strategy { + AuthStrategy::Mtls => match try_mtls(parts) { + Ok(_subject) => return Ok(AuthenticatedService), + Err(e) => last_err = e, + }, + AuthStrategy::Jwt { + key, + allowed_usernames, + } => match try_jwt(parts, key, allowed_usernames) { + Ok(_subject) => return Ok(AuthenticatedService), + Err(e) => last_err = e, + }, + } } - Ok(AuthenticatedService) + Err(last_err) } } @@ -207,12 +285,109 @@ mod tests { } fn jwt_mode(decoding: DecodingKey, allowed_usernames: Vec<&str>) -> AuthMode { - AuthMode::Jwt { + AuthMode::Strategies(vec![AuthStrategy::Jwt { key: Arc::new(decoding), allowed_usernames: allowed_usernames.into_iter().map(String::from).collect(), - } + }]) } + /// Build a test request with PeerCertificates extension pre-inserted. + fn request_with_peer_certs( + uri: &str, + certs: Option>>, + ) -> Request { + let mut req = Request::builder().uri(uri).body(Body::empty()).unwrap(); + req.extensions_mut().insert(PeerCertificates(certs)); + req + } + + /// Generate a self-signed CA + client cert for mTLS testing. + /// Returns (ca_cert_der, client_cert_der) where client_cert_der has the given CN. + fn generate_test_client_cert(cn: &str) -> CertificateDer<'static> { + use std::process::{Command, Stdio}; + + // Generate CA key + self-signed cert + let ca_key = Command::new("openssl") + .args(["genpkey", "-algorithm", "Ed25519"]) + .output() + .expect("openssl genpkey failed") + .stdout; + + let ca_cert = { + let mut child = Command::new("openssl") + .args([ + "req", "-new", "-x509", "-key", "/dev/stdin", "-days", "1", + "-subj", "/CN=TestCA", + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .expect("openssl req failed"); + std::io::Write::write_all(&mut child.stdin.take().unwrap(), &ca_key).unwrap(); + child.wait_with_output().unwrap().stdout + }; + + // Generate client key + let client_key = Command::new("openssl") + .args(["genpkey", "-algorithm", "Ed25519"]) + .output() + .expect("openssl genpkey failed") + .stdout; + + // Generate client CSR + let subj = format!("/CN={cn}"); + let client_csr = { + let mut child = Command::new("openssl") + .args([ + "req", "-new", "-key", "/dev/stdin", "-subj", &subj, + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .expect("openssl req failed"); + std::io::Write::write_all(&mut child.stdin.take().unwrap(), &client_key).unwrap(); + child.wait_with_output().unwrap().stdout + }; + + // Sign client cert with CA + let dir = tempfile::tempdir().unwrap(); + let ca_cert_path = dir.path().join("ca.crt"); + let ca_key_path = dir.path().join("ca.key"); + let csr_path = dir.path().join("client.csr"); + std::fs::write(&ca_cert_path, &ca_cert).unwrap(); + std::fs::write(&ca_key_path, &ca_key).unwrap(); + std::fs::write(&csr_path, &client_csr).unwrap(); + + let client_cert_pem = Command::new("openssl") + .args([ + "x509", "-req", + "-in", csr_path.to_str().unwrap(), + "-CA", ca_cert_path.to_str().unwrap(), + "-CAkey", ca_key_path.to_str().unwrap(), + "-CAcreateserial", + "-days", "1", + ]) + .output() + .expect("openssl x509 failed") + .stdout; + + // Convert PEM to DER + let client_cert_der = { + let mut child = Command::new("openssl") + .args(["x509", "-outform", "DER"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .expect("openssl x509 DER conversion failed"); + std::io::Write::write_all(&mut child.stdin.take().unwrap(), &client_cert_pem).unwrap(); + child.wait_with_output().unwrap().stdout + }; + + CertificateDer::from(client_cert_der) + } + + // --- JWT tests (updated for Strategies wrapper) --- + #[tokio::test] async fn rejects_missing_auth_header() { let (_, decoding) = generate_test_keypair(); @@ -372,20 +547,6 @@ mod tests { assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } - #[test] - fn resolve_auth_mode_insecure_disabled() { - use crate::server_config::{ApiAuthenticationStrategy, ApiConfig}; - - let config = ApiConfig { - authentication_strategy: ApiAuthenticationStrategy::InsecureDisabled, - ..ApiConfig::default() - }; - assert!(matches!( - resolve_auth_mode(&config, vec![]), - AuthMode::Disabled - )); - } - #[tokio::test] async fn disabled_mode_allows_all_requests() { let app = test_router(AuthMode::Disabled); @@ -395,4 +556,110 @@ mod tests { let response = app.oneshot(req).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); } + + #[tokio::test] + async fn empty_strategies_rejects() { + let app = test_router(AuthMode::Strategies(vec![])); + + let req = Request::builder().uri("/test").body(Body::empty()).unwrap(); + + let response = app.oneshot(req).await.unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + // --- mTLS tests --- + + #[tokio::test] + async fn mtls_accepts_valid_peer_cert() { + let app = test_router(AuthMode::Strategies(vec![AuthStrategy::Mtls])); + + let cert = generate_test_client_cert("testuser"); + let req = request_with_peer_certs("/test", Some(vec![cert])); + + let response = app.oneshot(req).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + } + + #[tokio::test] + async fn mtls_rejects_no_peer_certs() { + let app = test_router(AuthMode::Strategies(vec![AuthStrategy::Mtls])); + + let req = request_with_peer_certs("/test", None); + + let response = app.oneshot(req).await.unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn mtls_rejects_empty_peer_certs() { + let app = test_router(AuthMode::Strategies(vec![AuthStrategy::Mtls])); + + let req = request_with_peer_certs("/test", Some(vec![])); + + let response = app.oneshot(req).await.unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn mtls_rejects_when_no_peer_certs_extension() { + let app = test_router(AuthMode::Strategies(vec![AuthStrategy::Mtls])); + + // No PeerCertificates extension at all (plain HTTP path) + let req = Request::builder().uri("/test").body(Body::empty()).unwrap(); + + let response = app.oneshot(req).await.unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + // --- Multi-strategy tests --- + + #[tokio::test] + async fn jwt_and_mtls_accepts_valid_cert_no_jwt() { + let (_, decoding) = generate_test_keypair(); + let mode = AuthMode::Strategies(vec![ + AuthStrategy::Jwt { + key: Arc::new(decoding), + allowed_usernames: vec!["brynary".to_string()], + }, + AuthStrategy::Mtls, + ]); + let app = test_router(mode); + + let cert = generate_test_client_cert("brynary"); + let req = request_with_peer_certs("/test", Some(vec![cert])); + + let response = app.oneshot(req).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + } + + #[tokio::test] + async fn mtls_and_jwt_falls_back_to_jwt() { + let (encoding, decoding) = generate_test_keypair(); + let mode = AuthMode::Strategies(vec![ + AuthStrategy::Mtls, + AuthStrategy::Jwt { + key: Arc::new(decoding), + allowed_usernames: vec!["brynary".to_string()], + }, + ]); + let app = test_router(mode); + + let token = sign_token( + &encoding, + "arc-web", + 60, + Some("https://github.com/brynary"), + ); + + // No peer certs, but valid JWT + let mut req = Request::builder() + .uri("/test") + .header("authorization", format!("Bearer {token}")) + .body(Body::empty()) + .unwrap(); + req.extensions_mut().insert(PeerCertificates(None)); + + let response = app.oneshot(req).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + } } diff --git a/crates/arc-api/src/lib.rs b/crates/arc-api/src/lib.rs index a0678e802..f6f28521f 100644 --- a/crates/arc-api/src/lib.rs +++ b/crates/arc-api/src/lib.rs @@ -3,3 +3,4 @@ pub mod jwt_auth; pub mod serve; pub mod server; pub mod server_config; +pub mod tls; diff --git a/crates/arc-api/src/serve.rs b/crates/arc-api/src/serve.rs index d614faf03..52a00fd06 100644 --- a/crates/arc-api/src/serve.rs +++ b/crates/arc-api/src/serve.rs @@ -3,11 +3,13 @@ use std::sync::Arc; use arc_llm::provider::Provider; use arc_util::terminal::Styles; use tokio::net::TcpListener; -use tracing::info; +use tracing::{error, info}; use clap::Args; +use crate::jwt_auth::PeerCertificates; use crate::server::{build_router, create_app_state_with_options}; +use crate::server_config::ApiAuthStrategy; use arc_workflows::cli::backend::AgentApiBackend; use arc_workflows::cli::SandboxProvider; use arc_workflows::handler::default_registry; @@ -147,7 +149,77 @@ pub async fn serve_command(args: ServeArgs, styles: &'static Styles) -> anyhow:: eprintln!("{}", styles.dim.apply_to("(dry-run mode)")); } - axum::serve(listener, router).await?; + // Branch: TLS or plain HTTP + if let Some(ref tls_config) = server_config.api.tls { + let mtls_enabled = server_config + .api + .authentication_strategies + .contains(&ApiAuthStrategy::Mtls); + let mtls_optional = mtls_enabled && server_config.api.authentication_strategies.len() > 1; + + let rustls_config = + crate::tls::build_rustls_config(tls_config, mtls_enabled, mtls_optional); + let tls_acceptor = tokio_rustls::TlsAcceptor::from(rustls_config); + + info!("TLS enabled (mTLS {})", if mtls_enabled { "on" } else { "off" }); + + serve_tls(listener, tls_acceptor, router).await?; + } else { + axum::serve(listener, router).await?; + } Ok(()) } + +/// Serve requests over TLS, extracting peer certificates into request extensions. +async fn serve_tls( + listener: TcpListener, + tls_acceptor: tokio_rustls::TlsAcceptor, + router: axum::Router, +) -> anyhow::Result<()> { + use hyper_util::rt::{TokioExecutor, TokioIo}; + use hyper_util::server::conn::auto::Builder; + use tower_service::Service; + + let builder = Builder::new(TokioExecutor::new()); + + loop { + let (tcp_stream, remote_addr) = listener.accept().await?; + + let tls_acceptor = tls_acceptor.clone(); + let router = router.clone(); + let builder = builder.clone(); + + tokio::spawn(async move { + let tls_stream = match tls_acceptor.accept(tcp_stream).await { + Ok(s) => s, + Err(e) => { + error!(%remote_addr, "TLS handshake failed: {e}"); + return; + } + }; + + // Extract peer certificates from the TLS connection + let peer_certs = tls_stream + .get_ref() + .1 + .peer_certificates() + .map(|certs| certs.to_vec()); + + let io = TokioIo::new(tls_stream); + + let service = hyper::service::service_fn(move |mut req: hyper::Request| { + // Insert peer certificates into request extensions + req.extensions_mut() + .insert(PeerCertificates(peer_certs.clone())); + + let mut router = router.clone(); + async move { router.call(req).await } + }); + + if let Err(e) = builder.serve_connection(io, service).await { + error!(%remote_addr, "connection error: {e}"); + } + }); + } +} diff --git a/crates/arc-api/src/server_config.rs b/crates/arc-api/src/server_config.rs index b2ecf8bcb..e768191bc 100644 --- a/crates/arc-api/src/server_config.rs +++ b/crates/arc-api/src/server_config.rs @@ -19,12 +19,18 @@ pub struct AuthConfig { pub allowed_usernames: Vec, } -#[derive(Debug, Clone, Default, Deserialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, PartialEq)] #[serde(rename_all = "snake_case")] -pub enum ApiAuthenticationStrategy { - #[default] +pub enum ApiAuthStrategy { Jwt, - InsecureDisabled, + Mtls, +} + +#[derive(Debug, Clone, Deserialize, PartialEq)] +pub struct TlsConfig { + pub cert: PathBuf, + pub key: PathBuf, + pub ca: PathBuf, } #[derive(Debug, Clone, Deserialize, PartialEq)] @@ -32,7 +38,8 @@ pub struct ApiConfig { #[serde(default = "default_base_url")] pub base_url: String, #[serde(default)] - pub authentication_strategy: ApiAuthenticationStrategy, + pub authentication_strategies: Vec, + pub tls: Option, } fn default_base_url() -> String { @@ -43,7 +50,8 @@ impl Default for ApiConfig { fn default() -> Self { Self { base_url: default_base_url(), - authentication_strategy: ApiAuthenticationStrategy::default(), + authentication_strategies: Vec::new(), + tls: None, } } } @@ -171,7 +179,7 @@ allowed_usernames = ["brynary", "alice"] [api] base_url = "http://example.com:8080" -authentication_strategy = "jwt" +authentication_strategies = ["jwt"] [git] provider = "github" @@ -183,10 +191,7 @@ client_id = "Iv1.abc123" assert_eq!(config.web.auth.provider, AuthProvider::Github); assert_eq!(config.web.auth.allowed_usernames, vec!["brynary", "alice"]); assert_eq!(config.api.base_url, "http://example.com:8080"); - assert_eq!( - config.api.authentication_strategy, - ApiAuthenticationStrategy::Jwt - ); + assert_eq!(config.api.authentication_strategies, vec![ApiAuthStrategy::Jwt]); assert_eq!(config.git.provider, GitProvider::Github); assert_eq!(config.git.app_id.as_deref(), Some("12345")); assert_eq!(config.git.client_id.as_deref(), Some("Iv1.abc123")); @@ -206,10 +211,8 @@ client_id = "Iv1.abc123" let toml = ""; let config: ServerConfig = toml::from_str(toml).unwrap(); assert_eq!(config.api.base_url, "http://localhost:3000"); - assert_eq!( - config.api.authentication_strategy, - ApiAuthenticationStrategy::Jwt - ); + assert!(config.api.authentication_strategies.is_empty()); + assert!(config.api.tls.is_none()); } #[test] @@ -279,19 +282,55 @@ model = "gpt-4" } #[test] - fn parse_insecure_disabled_values() { + fn parse_insecure_disabled_auth_provider() { let toml = r#" [web.auth] provider = "insecure_disabled" - -[api] -authentication_strategy = "insecure_disabled" "#; let config: ServerConfig = toml::from_str(toml).unwrap(); assert_eq!(config.web.auth.provider, AuthProvider::InsecureDisabled); + } + + #[test] + fn parse_jwt_and_mtls_strategies() { + let toml = r#" +[api] +authentication_strategies = ["jwt", "mtls"] + +[api.tls] +cert = "~/.arc/certs/server.crt" +key = "~/.arc/certs/server.key" +ca = "~/.arc/certs/ca.crt" +"#; + let config: ServerConfig = toml::from_str(toml).unwrap(); assert_eq!( - config.api.authentication_strategy, - ApiAuthenticationStrategy::InsecureDisabled + config.api.authentication_strategies, + vec![ApiAuthStrategy::Jwt, ApiAuthStrategy::Mtls] ); + let tls = config.api.tls.unwrap(); + assert_eq!(tls.cert, PathBuf::from("~/.arc/certs/server.crt")); + assert_eq!(tls.key, PathBuf::from("~/.arc/certs/server.key")); + assert_eq!(tls.ca, PathBuf::from("~/.arc/certs/ca.crt")); + } + + #[test] + fn parse_empty_strategies() { + let toml = r#" +[api] +authentication_strategies = [] +"#; + let config: ServerConfig = toml::from_str(toml).unwrap(); + assert!(config.api.authentication_strategies.is_empty()); + } + + #[test] + fn parse_jwt_only_strategy() { + let toml = r#" +[api] +authentication_strategies = ["jwt"] +"#; + let config: ServerConfig = toml::from_str(toml).unwrap(); + assert_eq!(config.api.authentication_strategies, vec![ApiAuthStrategy::Jwt]); + assert!(config.api.tls.is_none()); } } diff --git a/crates/arc-api/src/tls.rs b/crates/arc-api/src/tls.rs new file mode 100644 index 000000000..18f67ca16 --- /dev/null +++ b/crates/arc-api/src/tls.rs @@ -0,0 +1,72 @@ +use std::path::Path; +use std::sync::Arc; + +use rustls::server::WebPkiClientVerifier; +use rustls::ServerConfig; +use rustls_pki_types::{CertificateDer, PrivateKeyDer}; + +use crate::server_config::TlsConfig; + +/// Build a rustls `ServerConfig` from the `[api.tls]` configuration. +/// +/// - `mtls_enabled`: whether mTLS is listed as an authentication strategy. +/// - `mtls_optional`: whether other strategies (e.g. JWT) are also present, +/// meaning client certs should be requested but not required. +pub fn build_rustls_config( + tls_config: &TlsConfig, + mtls_enabled: bool, + mtls_optional: bool, +) -> Arc { + let certs = load_certs(&tls_config.cert); + let key = load_private_key(&tls_config.key); + + let config = if mtls_enabled { + let ca_certs = load_certs(&tls_config.ca); + let mut root_store = rustls::RootCertStore::empty(); + for cert in ca_certs { + root_store.add(cert).expect("failed to add CA certificate to root store"); + } + + let verifier = if mtls_optional { + WebPkiClientVerifier::builder(Arc::new(root_store)) + .allow_unauthenticated() + .build() + .expect("failed to build optional client verifier") + } else { + WebPkiClientVerifier::builder(Arc::new(root_store)) + .build() + .expect("failed to build required client verifier") + }; + + ServerConfig::builder() + .with_client_cert_verifier(verifier) + .with_single_cert(certs, key) + .expect("invalid server certificate or key") + } else { + // TLS for encryption only, no client cert verification + ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(certs, key) + .expect("invalid server certificate or key") + }; + + Arc::new(config) +} + +fn load_certs(path: &Path) -> Vec> { + let file = std::fs::File::open(path) + .unwrap_or_else(|e| panic!("failed to open certificate file {}: {e}", path.display())); + let mut reader = std::io::BufReader::new(file); + rustls_pemfile::certs(&mut reader) + .collect::, _>>() + .unwrap_or_else(|e| panic!("failed to parse certificates from {}: {e}", path.display())) +} + +fn load_private_key(path: &Path) -> PrivateKeyDer<'static> { + let file = std::fs::File::open(path) + .unwrap_or_else(|e| panic!("failed to open private key file {}: {e}", path.display())); + let mut reader = std::io::BufReader::new(file); + rustls_pemfile::private_key(&mut reader) + .unwrap_or_else(|e| panic!("failed to parse private key from {}: {e}", path.display())) + .unwrap_or_else(|| panic!("no private key found in {}", path.display())) +} diff --git a/crates/arc-api/tests/integration.rs b/crates/arc-api/tests/integration.rs index 438e5d99a..1c1b86fed 100644 --- a/crates/arc-api/tests/integration.rs +++ b/crates/arc-api/tests/integration.rs @@ -1,3 +1,416 @@ +// =========================================================================== +// mTLS end-to-end tests +// =========================================================================== + +mod mtls_e2e { + use std::path::Path; + use std::process::{Command, Stdio}; + use std::sync::Arc; + + use arc_api::jwt_auth::{AuthMode, AuthStrategy, PeerCertificates}; + use arc_api::server::{build_router, create_app_state}; + use arc_api::server_config::TlsConfig; + use arc_api::tls::build_rustls_config; + use arc_workflows::handler::codergen::CodergenHandler; + use arc_workflows::handler::exit::ExitHandler; + use arc_workflows::handler::start::StartHandler; + use arc_workflows::handler::HandlerRegistry; + use arc_workflows::interviewer::Interviewer; + use rustls; + use tokio::net::TcpListener; + + fn simple_registry(_interviewer: Arc) -> HandlerRegistry { + let mut registry = HandlerRegistry::new(Box::new(CodergenHandler::new(None))); + registry.register("start", Box::new(StartHandler)); + registry.register("exit", Box::new(ExitHandler)); + registry.register("codergen", Box::new(CodergenHandler::new(None))); + registry + } + + async fn test_db() -> sqlx::SqlitePool { + let pool = arc_db::connect_memory().await.unwrap(); + arc_db::initialize_db(&pool).await.unwrap(); + pool + } + + /// Generate a complete CA + server cert + client cert PKI in `dir`. + /// Returns paths: (ca_cert, server_cert, server_key, client_cert_pem, client_key_pem) + fn generate_pki(dir: &Path, ca_cn: &str, server_cn: &str, client_cn: &str) -> PkiPaths { + // CA key + let ca_key_path = dir.join("ca.key"); + let ca_cert_path = dir.join("ca.crt"); + run_openssl(&["genpkey", "-algorithm", "Ed25519", "-out", ca_key_path.to_str().unwrap()]); + run_openssl(&[ + "req", "-new", "-x509", + "-key", ca_key_path.to_str().unwrap(), + "-out", ca_cert_path.to_str().unwrap(), + "-days", "1", + "-subj", &format!("/CN={ca_cn}"), + ]); + + // Server key + cert signed by CA + let server_key_path = dir.join("server.key"); + let server_csr_path = dir.join("server.csr"); + let server_cert_path = dir.join("server.crt"); + run_openssl(&["genpkey", "-algorithm", "Ed25519", "-out", server_key_path.to_str().unwrap()]); + run_openssl(&[ + "req", "-new", + "-key", server_key_path.to_str().unwrap(), + "-out", server_csr_path.to_str().unwrap(), + "-subj", &format!("/CN={server_cn}"), + ]); + + // Create extension file for SAN (reqwest validates server cert hostname) + let ext_path = dir.join("server.ext"); + std::fs::write(&ext_path, "subjectAltName=IP:127.0.0.1").unwrap(); + + run_openssl(&[ + "x509", "-req", + "-in", server_csr_path.to_str().unwrap(), + "-CA", ca_cert_path.to_str().unwrap(), + "-CAkey", ca_key_path.to_str().unwrap(), + "-CAcreateserial", + "-out", server_cert_path.to_str().unwrap(), + "-days", "1", + "-extfile", ext_path.to_str().unwrap(), + ]); + + // Client key + cert signed by CA + let client_key_path = dir.join("client.key"); + let client_csr_path = dir.join("client.csr"); + let client_cert_path = dir.join("client.crt"); + run_openssl(&["genpkey", "-algorithm", "Ed25519", "-out", client_key_path.to_str().unwrap()]); + run_openssl(&[ + "req", "-new", + "-key", client_key_path.to_str().unwrap(), + "-out", client_csr_path.to_str().unwrap(), + "-subj", &format!("/CN={client_cn}"), + ]); + run_openssl(&[ + "x509", "-req", + "-in", client_csr_path.to_str().unwrap(), + "-CA", ca_cert_path.to_str().unwrap(), + "-CAkey", ca_key_path.to_str().unwrap(), + "-CAcreateserial", + "-out", client_cert_path.to_str().unwrap(), + "-days", "1", + ]); + + PkiPaths { + ca_cert: ca_cert_path, + server_cert: server_cert_path, + server_key: server_key_path, + client_cert: client_cert_path, + client_key: client_key_path, + } + } + + struct PkiPaths { + ca_cert: std::path::PathBuf, + server_cert: std::path::PathBuf, + server_key: std::path::PathBuf, + client_cert: std::path::PathBuf, + client_key: std::path::PathBuf, + } + + fn run_openssl(args: &[&str]) { + let output = Command::new("openssl") + .args(args) + .stdin(Stdio::null()) + .output() + .expect("openssl command failed to execute"); + assert!( + output.status.success(), + "openssl {} failed: {}", + args[0], + String::from_utf8_lossy(&output.stderr) + ); + } + + /// Start a TLS server on a random port, returning the bound address. + /// `mtls_optional`: if true, client certs are requested but not required (for multi-strategy). + /// `auth_mode`: the authentication mode to use for the router. + async fn start_tls_server( + tls_config: &TlsConfig, + mtls_optional: bool, + auth_mode: AuthMode, + ) -> std::net::SocketAddr { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + let rustls_config = build_rustls_config(tls_config, true, mtls_optional); + let tls_acceptor = tokio_rustls::TlsAcceptor::from(rustls_config); + + let state = create_app_state(test_db().await, simple_registry); + let router = build_router(state, auth_mode); + + tokio::spawn(async move { + use hyper_util::rt::{TokioExecutor, TokioIo}; + use hyper_util::server::conn::auto::Builder; + use tower_service::Service; + + let builder = Builder::new(TokioExecutor::new()); + + loop { + let (tcp_stream, _remote_addr) = match listener.accept().await { + Ok(s) => s, + Err(_) => return, + }; + + let tls_acceptor = tls_acceptor.clone(); + let router = router.clone(); + let builder = builder.clone(); + + tokio::spawn(async move { + let tls_stream = match tls_acceptor.accept(tcp_stream).await { + Ok(s) => s, + Err(_) => return, + }; + + let peer_certs = tls_stream + .get_ref() + .1 + .peer_certificates() + .map(|certs| certs.to_vec()); + + let io = TokioIo::new(tls_stream); + + let service = hyper::service::service_fn( + move |mut req: hyper::Request| { + req.extensions_mut() + .insert(PeerCertificates(peer_certs.clone())); + let mut router = router.clone(); + async move { router.call(req).await } + }, + ); + + let _ = builder.serve_connection(io, service).await; + }); + } + }); + + addr + } + + /// Build a reqwest client with the given CA cert and optional client identity. + fn build_client( + ca_cert_path: &Path, + client_cert_path: Option<&Path>, + client_key_path: Option<&Path>, + ) -> reqwest::Client { + let ca_pem = std::fs::read(ca_cert_path).unwrap(); + let ca_cert = reqwest::tls::Certificate::from_pem(&ca_pem).unwrap(); + + let mut builder = reqwest::Client::builder() + .add_root_certificate(ca_cert) + .use_rustls_tls(); + + if let (Some(cert_path), Some(key_path)) = (client_cert_path, client_key_path) { + let cert_pem = std::fs::read(cert_path).unwrap(); + let key_pem = std::fs::read(key_path).unwrap(); + let mut identity_pem = cert_pem; + identity_pem.extend_from_slice(&key_pem); + let identity = reqwest::tls::Identity::from_pem(&identity_pem).unwrap(); + builder = builder.identity(identity); + } + + builder.build().unwrap() + } + + fn install_crypto_provider() { + let _ = rustls::crypto::ring::default_provider().install_default(); + } + + #[tokio::test] + async fn mtls_accepts_valid_client_cert() { + install_crypto_provider(); + let dir = tempfile::tempdir().unwrap(); + let pki = generate_pki(dir.path(), "TestCA", "localhost", "testuser"); + + let tls_config = TlsConfig { + cert: pki.server_cert.clone(), + key: pki.server_key.clone(), + ca: pki.ca_cert.clone(), + }; + + let auth_mode = AuthMode::Strategies(vec![AuthStrategy::Mtls]); + let addr = start_tls_server(&tls_config, false, auth_mode).await; + + let client = build_client( + &pki.ca_cert, + Some(&pki.client_cert), + Some(&pki.client_key), + ); + + let response = client + .get(format!("https://127.0.0.1:{}/runs", addr.port())) + .send() + .await + .expect("request with valid client cert should succeed"); + + assert_eq!(response.status(), 200); + } + + #[tokio::test] + async fn mtls_only_rejects_wrong_ca_client_cert() { + install_crypto_provider(); + let dir = tempfile::tempdir().unwrap(); + let pki = generate_pki(dir.path(), "TestCA", "localhost", "testuser"); + + let tls_config = TlsConfig { + cert: pki.server_cert.clone(), + key: pki.server_key.clone(), + ca: pki.ca_cert.clone(), + }; + + let auth_mode = AuthMode::Strategies(vec![AuthStrategy::Mtls]); + let addr = start_tls_server(&tls_config, false, auth_mode).await; + + // Generate a DIFFERENT CA and client cert signed by it + let wrong_dir = dir.path().join("wrong_ca"); + std::fs::create_dir_all(&wrong_dir).unwrap(); + let wrong_pki = generate_pki(&wrong_dir, "WrongCA", "localhost", "intruder"); + + // Client trusts the REAL server CA, but presents a cert from the WRONG CA + let client = build_client( + &pki.ca_cert, + Some(&wrong_pki.client_cert), + Some(&wrong_pki.client_key), + ); + + let result = client + .get(format!("https://127.0.0.1:{}/runs", addr.port())) + .send() + .await; + + // Server should reject the TLS handshake — the wrong CA client cert + // will cause a connection error (not an HTTP error) + assert!( + result.is_err(), + "request with wrong-CA client cert should fail at TLS level, but got: {:?}", + result.unwrap().status() + ); + } + + #[tokio::test] + async fn mtls_only_rejects_no_client_cert() { + install_crypto_provider(); + let dir = tempfile::tempdir().unwrap(); + let pki = generate_pki(dir.path(), "TestCA", "localhost", "testuser"); + + let tls_config = TlsConfig { + cert: pki.server_cert.clone(), + key: pki.server_key.clone(), + ca: pki.ca_cert.clone(), + }; + + // mTLS is the ONLY strategy → client cert is required at TLS level + let auth_mode = AuthMode::Strategies(vec![AuthStrategy::Mtls]); + let addr = start_tls_server(&tls_config, false, auth_mode).await; + + // Client trusts the server CA but presents NO client cert + let client = build_client(&pki.ca_cert, None, None); + + let result = client + .get(format!("https://127.0.0.1:{}/runs", addr.port())) + .send() + .await; + + // Server requires client cert → TLS handshake should fail + assert!( + result.is_err(), + "request without client cert should fail when mTLS is the only strategy, but got: {:?}", + result.unwrap().status() + ); + } + + /// Generate an Ed25519 JWT keypair. Returns (encoding_key, decoding_key). + fn generate_jwt_keypair() -> (jsonwebtoken::EncodingKey, jsonwebtoken::DecodingKey) { + let output = Command::new("openssl") + .args(["genpkey", "-algorithm", "Ed25519"]) + .output() + .expect("openssl must be available for tests"); + let private_pem = output.stdout; + + let output = Command::new("openssl") + .args(["pkey", "-pubout"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .and_then(|mut child| { + use std::io::Write; + child.stdin.take().unwrap().write_all(&private_pem).unwrap(); + child.wait_with_output() + }) + .expect("openssl pkey failed"); + let public_pem = output.stdout; + + let encoding = + jsonwebtoken::EncodingKey::from_ed_pem(&private_pem).expect("invalid private key"); + let decoding = + jsonwebtoken::DecodingKey::from_ed_pem(&public_pem).expect("invalid public key"); + (encoding, decoding) + } + + fn sign_jwt(key: &jsonwebtoken::EncodingKey, sub: &str) -> String { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let claims = serde_json::json!({ + "iss": "arc-web", + "iat": now, + "exp": now + 60, + "sub": sub, + }); + let header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::EdDSA); + jsonwebtoken::encode(&header, &claims, key).expect("failed to sign token") + } + + #[tokio::test] + async fn mtls_and_jwt_accepts_valid_jwt_without_client_cert() { + install_crypto_provider(); + let dir = tempfile::tempdir().unwrap(); + let pki = generate_pki(dir.path(), "TestCA", "localhost", "testuser"); + + let tls_config = TlsConfig { + cert: pki.server_cert.clone(), + key: pki.server_key.clone(), + ca: pki.ca_cert.clone(), + }; + + let (encoding_key, decoding_key) = generate_jwt_keypair(); + + // Both mTLS and JWT strategies; mTLS is optional since JWT is also present + let auth_mode = AuthMode::Strategies(vec![ + AuthStrategy::Mtls, + AuthStrategy::Jwt { + key: Arc::new(decoding_key), + allowed_usernames: vec!["brynary".to_string()], + }, + ]); + let addr = start_tls_server(&tls_config, true, auth_mode).await; + + // Client trusts the server CA but presents NO client cert + let client = build_client(&pki.ca_cert, None, None); + + let token = sign_jwt(&encoding_key, "https://github.com/brynary"); + + let response = client + .get(format!("https://127.0.0.1:{}/runs", addr.port())) + .bearer_auth(&token) + .send() + .await + .expect("request with valid JWT and no client cert should succeed"); + + assert_eq!( + response.status(), + 200, + "valid JWT should be accepted when strategies = [mtls, jwt]" + ); + } +} + // =========================================================================== // Full HTTP server lifecycle (TS Scenario 4) // ===========================================================================