diff --git a/Cargo.lock b/Cargo.lock index 981a20f..cf79156 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -390,6 +390,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arc-swap" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +dependencies = [ + "rustversion", +] + [[package]] name = "arraydeque" version = "0.5.1" @@ -1925,7 +1934,9 @@ dependencies = [ "actix-web-actors", "addr", "anyhow", + "arc-swap", "async-channel", + "base64 0.22.1", "chrono", "clap", "config", @@ -4271,6 +4282,7 @@ dependencies = [ "lazy_static", "nom", "oid-registry", + "ring", "rusticata-macros", "thiserror 1.0.69", "time", diff --git a/Cargo.toml b/Cargo.toml index 426896b..67d5f78 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,7 @@ tokio = { version = "1", features = ["full"] } rustls = { version = "0.23", features = ["aws_lc_rs"] } rustls-pemfile = "2" tokio-rustls = "0.26" -x509-parser = "0.16" +x509-parser = { version = "0.16", features = ["verify"] } # WebSocket support (actix-web-actors provides WebSocket for Actix-web) tokio-tungstenite = "0.21" @@ -83,6 +83,12 @@ socket2 = { version = "0.5", features = ["all"] } # File locking for concurrent-safe whitelist modifications fs2 = "0.4" +# Atomic swapping for CRL state updates without rebuilding ServerConfig +arc-swap = "1" + +# Base64 decoding for PEM CRL parsing +base64 = "0.22" + [dev-dependencies] actix-rt = "2" tokio-test = "0.4" diff --git a/src/api/handlers/system.rs b/src/api/handlers/system.rs index fd6dd81..7734cb9 100644 --- a/src/api/handlers/system.rs +++ b/src/api/handlers/system.rs @@ -12,6 +12,7 @@ use tracing::{error, info, warn}; use uuid::Uuid; use super::packages::ApiResponse; +use crate::auth::crl::{CrlStatus, SharedCrlState}; use crate::jobs::manager::{JobManager, JobOperation, JobStatus}; use crate::packages::PackageManagerBackend; @@ -47,6 +48,8 @@ pub struct HealthData { pub version: String, pub last_cache_update: Option, // RFC3339 timestamp pub cache_status: String, // "fresh", "stale", "unknown", "failed" + pub crl_status: Option, // "valid", "expired", "missing", "invalid", "degraded" + pub crl_age_seconds: Option, // age of on-disk CRL file } /// Service status response data @@ -113,6 +116,7 @@ pub async fn get_system_info( pub async fn health_check( backend: web::Data>, cache_state: web::Data, + crl_state: web::Data, _req: HttpRequest, ) -> impl Responder { let _request_id = Uuid::new_v4().to_string(); @@ -134,7 +138,7 @@ pub async fn health_check( // Check cache status and refresh if stale let cache_status_val = cache_state.status(); - let (status, cache_status_str, last_cache_update) = if cache_state.is_stale() { + let (mut status, cache_status_str, last_cache_update) = if cache_state.is_stale() { match backend.refresh_package_cache(&cache_state) { Ok(_) => { let updated = cache_state.status(); @@ -161,12 +165,31 @@ pub async fn health_check( ) }; + // CRL status from shared state + let crl = crl_state.load(); + let crl_status_str = match crl.status { + CrlStatus::Valid + | CrlStatus::Expired + | CrlStatus::Missing + | CrlStatus::Invalid + | CrlStatus::Degraded => { + // Downgrade overall health if CRL is invalid + if crl.status == CrlStatus::Invalid { + status = "degraded".to_string(); + } + crl.status.to_string() + } + }; + let crl_age = crl.crl_age_seconds(); + let response = ApiResponse::success(HealthData { status, uptime_seconds, version, last_cache_update, cache_status: cache_status_str, + crl_status: Some(crl_status_str), + crl_age_seconds: crl_age, }); HttpResponse::Ok().json(response) @@ -386,6 +409,8 @@ mod tests { version: "0.1.0".to_string(), last_cache_update: Some("2026-05-27T14:00:00+00:00".to_string()), cache_status: "fresh".to_string(), + crl_status: Some("valid".to_string()), + crl_age_seconds: Some(3600), }; let json = serde_json::to_string(&health).unwrap(); assert!(json.contains("healthy")); diff --git a/src/auth/crl.rs b/src/auth/crl.rs new file mode 100644 index 0000000..09ee680 --- /dev/null +++ b/src/auth/crl.rs @@ -0,0 +1,419 @@ +//! CRL (Certificate Revocation List) Loading, Parsing, and Refresh +//! +//! Provides CRL consumption for agent-side mTLS revocation enforcement. +//! Parses CRL from disk, verifies signature against pinned CA, +//! builds an in-memory revoked-serial index, and refreshes from the manager. + +use arc_swap::ArcSwap; +use std::collections::HashSet; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{Duration, SystemTime}; +use tracing::{debug, error, info, warn}; +use x509_parser::prelude::FromDer; +use x509_parser::revocation_list::CertificateRevocationList; + +/// CRL status reported via the health endpoint. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum CrlStatus { + /// CRL loaded, signature valid, not expired. + Valid, + /// CRL loaded and signature valid, but nextUpdate has passed. + Expired, + /// No CRL file found on disk. + Missing, + /// CRL exists but failed signature verification -- fail-closed. + Invalid, + /// CRL fetch or load failed; operating in degraded (WebPKI-only) mode. + Degraded, +} + +impl std::fmt::Display for CrlStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + CrlStatus::Valid => write!(f, "valid"), + CrlStatus::Expired => write!(f, "expired"), + CrlStatus::Missing => write!(f, "missing"), + CrlStatus::Invalid => write!(f, "invalid"), + CrlStatus::Degraded => write!(f, "degraded"), + } + } +} + +/// In-memory CRL state, atomically swapped on refresh via ArcSwap. +#[derive(Debug, Clone)] +pub struct CrlState { + /// Hex-encoded serial numbers of revoked certificates (lowercase, no prefix). + pub revoked_serials: HashSet, + /// CRL status for health reporting. + pub status: CrlStatus, + /// Time the CRL file was last modified (used to compute age). + pub crl_mtime: Option, + /// When this CrlState was loaded into memory. + pub loaded_at: SystemTime, +} + +impl Default for CrlState { + fn default() -> Self { + Self { + revoked_serials: HashSet::new(), + status: CrlStatus::Missing, + crl_mtime: None, + loaded_at: SystemTime::now(), + } + } +} + +impl CrlState { + /// Check whether a certificate serial is revoked. + pub fn is_revoked(&self, serial_hex: &str) -> bool { + self.revoked_serials.contains(serial_hex) + } + + /// Age of the on-disk CRL file in seconds. + pub fn crl_age_seconds(&self) -> Option { + self.crl_mtime.and_then(|mtime| { + SystemTime::now() + .duration_since(mtime) + .ok() + .map(|d| d.as_secs()) + }) + } +} + +/// Shared, atomically-swappable CRL handle. +pub type SharedCrlState = Arc>; + +/// Create a new shared CRL state (initially missing). +pub fn new_shared_state() -> SharedCrlState { + Arc::new(ArcSwap::from_pointee(CrlState::default())) +} + +/// Extract the hex-encoded serial from a DER-encoded X.509 certificate. +/// Returns lowercase hex with no separators or prefix. +pub fn cert_serial_hex(cert_der: &[u8]) -> Option { + x509_parser::parse_x509_certificate(cert_der) + .ok() + .map(|(_, cert)| format_serial_hex(&cert.serial)) +} + +/// Format a BigUint serial as lowercase hex string (no 0x prefix, no colons). +fn format_serial_hex(serial: &x509_parser::num_bigint::BigUint) -> String { + let bytes = serial.to_bytes_be(); + bytes.iter().map(|b| format!("{:02x}", b)).collect() +} + +/// Load and validate a CRL from disk. +/// +/// Steps: +/// 1. Read PEM file +/// 2. Parse CRL with x509-parser +/// 3. Verify CRL signature against the CA certificate +/// 4. Build in-memory revoked-serial index +/// 5. Check nextUpdate for staleness +/// +/// Returns the new CrlState. On signature failure, returns CrlStatus::Invalid (fail-closed). +/// On missing file, returns CrlStatus::Missing. On parse error, returns CrlStatus::Degraded. +pub fn load_crl(crl_path: &Path, ca_cert_der: &[u8]) -> CrlState { + let crl_bytes = match fs::read(crl_path) { + Ok(b) => b, + Err(e) => { + if e.kind() == std::io::ErrorKind::NotFound { + info!(path = %crl_path.display(), "No CRL file found -- operating in WebPKI-only mode"); + return CrlState { + status: CrlStatus::Missing, + crl_mtime: None, + loaded_at: SystemTime::now(), + revoked_serials: HashSet::new(), + }; + } + warn!(path = %crl_path.display(), error = %e, "Failed to read CRL file"); + return CrlState { + status: CrlStatus::Degraded, + crl_mtime: None, + loaded_at: SystemTime::now(), + revoked_serials: HashSet::new(), + }; + } + }; + + let crl_mtime = fs::metadata(crl_path).ok().and_then(|m| m.modified().ok()); + + // Parse PEM: extract the DER block between BEGIN/END X509 CRL markers + let crl_der = match extract_pem_crl_der(&crl_bytes) { + Some(der) => der, + None => { + // Try parsing as raw DER + crl_bytes.clone() + } + }; + + // Parse CRL + let (_, crl) = match CertificateRevocationList::from_der(&crl_der) { + Ok(r) => r, + Err(e) => { + error!(error = %e, "Failed to parse CRL -- marking as invalid"); + return CrlState { + status: CrlStatus::Invalid, + crl_mtime, + loaded_at: SystemTime::now(), + revoked_serials: HashSet::new(), + }; + } + }; + + // Verify CRL signature against CA + let (_, ca_cert) = match x509_parser::parse_x509_certificate(ca_cert_der) { + Ok(r) => r, + Err(e) => { + error!(error = %e, "Failed to parse CA cert for CRL signature verification"); + return CrlState { + status: CrlStatus::Invalid, + crl_mtime, + loaded_at: SystemTime::now(), + revoked_serials: HashSet::new(), + }; + } + }; + + let verify_result = crl.verify_signature(ca_cert.public_key()); + + if let Err(e) = verify_result { + error!(error = %e, "CRL signature verification FAILED -- refusing to use this CRL (fail-closed)"); + return CrlState { + status: CrlStatus::Invalid, + crl_mtime, + loaded_at: SystemTime::now(), + revoked_serials: HashSet::new(), + }; + } + + // Build revoked serial index + let revoked_serials: HashSet = crl + .iter_revoked_certificates() + .map(|revoked| format_serial_hex(revoked.serial())) + .collect(); + + info!( + revoked_count = revoked_serials.len(), + "CRL loaded and signature verified" + ); + + // Check nextUpdate for staleness + let now = x509_parser::time::ASN1Time::now(); + let is_expired = crl.next_update().map(|next| next < now).unwrap_or(false); + + let status = if is_expired { + warn!("CRL nextUpdate has passed -- CRL is stale, continuing with degraded status"); + CrlStatus::Expired + } else { + CrlStatus::Valid + }; + + CrlState { + revoked_serials, + status, + crl_mtime, + loaded_at: SystemTime::now(), + } +} + +/// Extract DER bytes from a PEM-encoded CRL. +/// Looks for `-----BEGIN X509 CRL-----` / `-----END X509 CRL-----` blocks. +fn extract_pem_crl_der(pem_bytes: &[u8]) -> Option> { + let pem_str = String::from_utf8_lossy(pem_bytes); + let begin_marker = "-----BEGIN X509 CRL-----"; + let end_marker = "-----END X509 CRL-----"; + + let begin_idx = pem_str.find(begin_marker)?; + let after_begin = begin_idx + begin_marker.len(); + let end_idx = pem_str[after_begin..].find(end_marker)?; + let b64_block = pem_str[after_begin..after_begin + end_idx].trim(); + + use base64::Engine; + base64::engine::general_purpose::STANDARD + .decode(b64_block) + .ok() +} + +/// Fetch the CRL from the manager, verify, persist, and update in-memory state. +/// +/// The CRL endpoint is public (no auth): GET {manager_url}/api/v1/pki/crl.pem +pub async fn refresh_crl( + manager_url: &str, + crl_path: &Path, + ca_cert_der: &[u8], + shared_state: &SharedCrlState, +) -> Result<(), String> { + let crl_url = format!("{}/api/v1/pki/crl.pem", manager_url.trim_end_matches('/')); + + info!(url = %crl_url, "Fetching CRL from manager"); + + let response = reqwest::get(&crl_url) + .await + .map_err(|e| format!("CRL fetch request failed: {}", e))?; + + if !response.status().is_success() { + let status = response.status(); + return Err(format!("CRL fetch returned HTTP {}", status)); + } + + let crl_pem = response + .text() + .await + .map_err(|e| format!("Failed to read CRL response body: {}", e))?; + + // Persist to disk (atomic write via temp file) + let parent = crl_path.parent().unwrap_or(Path::new("/tmp")); + if !parent.exists() { + fs::create_dir_all(parent).map_err(|e| format!("Failed to create CRL directory: {}", e))?; + } + + let tmp_path = crl_path.with_extension("pem.tmp"); + fs::write(&tmp_path, &crl_pem).map_err(|e| format!("Failed to write temp CRL file: {}", e))?; + + fs::rename(&tmp_path, crl_path) + .map_err(|e| format!("Failed to rename temp CRL file: {}", e))?; + + debug!(path = %crl_path.display(), "CRL persisted to disk"); + + // Load the freshly written CRL to get a validated CrlState + let new_state = load_crl(crl_path, ca_cert_der); + + if new_state.status == CrlStatus::Invalid { + return Err("CRL signature verification failed after fetch".to_string()); + } + + info!( + status = %new_state.status, + revoked = new_state.revoked_serials.len(), + "CRL refreshed successfully" + ); + + // Atomically swap the in-memory state + shared_state.store(Arc::new(new_state)); + + Ok(()) +} + +/// Spawn the CRL refresh background task. +/// +/// Runs on a 24-hour interval. On failure, logs a warning and continues +/// serving with the existing (possibly stale) CRL. +pub fn spawn_crl_refresh_task( + manager_url: String, + crl_path: PathBuf, + ca_cert_der: Vec, + shared_state: SharedCrlState, +) { + let interval = Duration::from_secs(24 * 60 * 60); // 24 hours + + tokio::spawn(async move { + // Initial small delay to let the server finish binding + tokio::time::sleep(Duration::from_secs(30)).await; + + loop { + let result = refresh_crl(&manager_url, &crl_path, &ca_cert_der, &shared_state).await; + + match result { + Ok(()) => { + info!("CRL background refresh completed successfully"); + } + Err(e) => { + warn!( + error = %e, + "CRL background refresh failed -- continuing with current CRL" + ); + } + } + + tokio::time::sleep(interval).await; + } + }); + + info!( + interval_secs = interval.as_secs(), + "CRL refresh background task spawned" + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_serial_hex() { + use x509_parser::num_bigint::BigUint; + let serial = BigUint::from(0x0123_abcdu64); + let hex = format_serial_hex(&serial); + assert_eq!(hex, "0123abcd"); + } + + #[test] + fn test_format_serial_hex_single_byte() { + use x509_parser::num_bigint::BigUint; + let serial = BigUint::from(0x42u64); + let hex = format_serial_hex(&serial); + assert_eq!(hex, "42"); + } + + #[test] + fn test_crl_state_default_is_missing() { + let state = CrlState::default(); + assert_eq!(state.status, CrlStatus::Missing); + assert!(state.revoked_serials.is_empty()); + assert!(state.crl_mtime.is_none()); + } + + #[test] + fn test_crl_state_is_revoked() { + let mut state = CrlState::default(); + state.revoked_serials.insert("deadbeef".to_string()); + assert!(state.is_revoked("deadbeef")); + assert!(!state.is_revoked("cafef00d")); + } + + #[test] + fn test_crl_status_display() { + assert_eq!(CrlStatus::Valid.to_string(), "valid"); + assert_eq!(CrlStatus::Expired.to_string(), "expired"); + assert_eq!(CrlStatus::Missing.to_string(), "missing"); + assert_eq!(CrlStatus::Invalid.to_string(), "invalid"); + assert_eq!(CrlStatus::Degraded.to_string(), "degraded"); + } + + #[test] + fn test_extract_pem_crl_der_invalid() { + // Not PEM + assert!(extract_pem_crl_der(b"not pem").is_none()); + // PEM but wrong type + assert!(extract_pem_crl_der( + b"-----BEGIN CERTIFICATE-----\nAA==\n-----END CERTIFICATE-----" + ) + .is_none()); + } + + #[test] + fn test_shared_crl_state_swap() { + let shared = new_shared_state(); + let initial = shared.load(); + assert_eq!(initial.status, CrlStatus::Missing); + + let new_state = CrlState { + status: CrlStatus::Valid, + revoked_serials: { + let mut set = HashSet::new(); + set.insert("abc".to_string()); + set + }, + ..Default::default() + }; + shared.store(Arc::new(new_state)); + + let updated = shared.load(); + assert_eq!(updated.status, CrlStatus::Valid); + assert!(updated.is_revoked("abc")); + } +} diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 961ab81..32ee577 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -6,9 +6,11 @@ //! - Silent drop for non-compliant connections //! - Comprehensive audit logging +pub mod crl; pub mod mtls; pub mod whitelist; +pub use crl::{new_shared_state, CrlState, CrlStatus, SharedCrlState}; pub use mtls::{ClientCertInfo, MtlsConfig, MtlsError, MtlsMiddleware}; pub use whitelist::{WhitelistConfig, WhitelistEntry, WhitelistManager, WhitelistMiddleware}; diff --git a/src/auth/mtls.rs b/src/auth/mtls.rs index 9e9db04..45d7332 100644 --- a/src/auth/mtls.rs +++ b/src/auth/mtls.rs @@ -2,6 +2,7 @@ //! //! Provides mutual TLS authentication middleware for Actix-web. //! Non-mTLS connections are silently dropped (no response). +//! Supports CRL-aware client certificate verification when CRL is available. use actix_web::{ dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform}, @@ -11,14 +12,21 @@ use actix_web::{ use chrono::{DateTime, Duration, Utc}; use futures_util::future::LocalBoxFuture; use rustls::{ + client::danger::HandshakeSignatureValid, crypto::aws_lc_rs, - server::{ServerConfig, WebPkiClientVerifier}, + pki_types::{CertificateDer, UnixTime}, + server::{ + danger::{ClientCertVerified, ClientCertVerifier}, + ServerConfig, WebPkiClientVerifier, + }, version::TLS13, - RootCertStore, + DigitallySignedStruct, DistinguishedName, Error as RustlsError, RootCertStore, SignatureScheme, }; use rustls_pemfile::{certs, private_key}; use std::{fs::File, io::BufReader, sync::Arc}; -use tracing::{debug, info, warn}; +use tracing::{debug, error, info, warn}; + +use super::crl::{cert_serial_hex, SharedCrlState}; /// Check for duplicate critical headers (VULN-006) /// Returns true if duplicate headers are detected @@ -45,6 +53,107 @@ fn has_duplicate_critical_headers(req: &ServiceRequest) -> bool { false } +/// CRL-aware client certificate verifier. +/// +/// Wraps WebPkiClientVerifier for chain validation, then checks the +/// end-entity certificate serial against the in-memory CRL index. +/// If CRL is unavailable (Missing/Degraded), falls back to WebPKI-only. +#[derive(Debug)] +struct CrlAwareVerifier { + inner: Arc, + crl_state: SharedCrlState, +} + +impl CrlAwareVerifier { + fn new(inner: Arc, crl_state: SharedCrlState) -> Self { + Self { inner, crl_state } + } +} + +impl ClientCertVerifier for CrlAwareVerifier { + fn offer_client_auth(&self) -> bool { + self.inner.offer_client_auth() + } + + fn client_auth_mandatory(&self) -> bool { + self.inner.client_auth_mandatory() + } + + fn root_hint_subjects(&self) -> &[DistinguishedName] { + self.inner.root_hint_subjects() + } + + fn verify_client_cert( + &self, + end_entity: &CertificateDer<'_>, + intermediates: &[CertificateDer<'_>], + now: UnixTime, + ) -> Result { + // 1. Delegate chain validation to WebPKI + self.inner + .verify_client_cert(end_entity, intermediates, now)?; + + // 2. Check CRL revocation status + let crl = self.crl_state.load(); + match crl.status { + super::crl::CrlStatus::Valid | super::crl::CrlStatus::Expired => { + // CRL is available -- check serial + if let Some(serial_hex) = cert_serial_hex(end_entity.as_ref()) { + if crl.is_revoked(&serial_hex) { + warn!( + serial = %serial_hex, + "Client certificate is revoked per CRL -- rejecting connection" + ); + return Err(RustlsError::InvalidCertificate( + rustls::CertificateError::Revoked, + )); + } + } + Ok(ClientCertVerified::assertion()) + } + super::crl::CrlStatus::Missing | super::crl::CrlStatus::Degraded => { + // No CRL available -- fall back to WebPKI-only (already passed above) + warn!( + status = %crl.status, + "CRL not available -- allowing connection with WebPKI-only verification" + ); + Ok(ClientCertVerified::assertion()) + } + super::crl::CrlStatus::Invalid => { + // Invalid CRL signature -- fail-closed + error!( + "CRL signature is invalid -- refusing all client certificates (fail-closed)" + ); + Err(RustlsError::InvalidCertificate( + rustls::CertificateError::Revoked, + )) + } + } + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> Result { + self.inner.verify_tls12_signature(message, cert, dss) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> Result { + self.inner.verify_tls13_signature(message, cert, dss) + } + + fn supported_verify_schemes(&self) -> Vec { + self.inner.supported_verify_schemes() + } +} + /// mTLS Configuration #[derive(Debug, Clone)] pub struct MtlsConfig { @@ -71,12 +180,30 @@ impl MtlsMiddleware { }) } - /// Build rustls server configuration with client certificate verification - pub fn build_rustls_config(&self) -> Result, MtlsError> { - let client_verifier = WebPkiClientVerifier::builder(self.cert_store.clone()) + /// Build rustls server configuration with client certificate verification. + /// + /// When `crl_state` is provided and the CRL is available, wraps the + /// WebPkiClientVerifier with CrlAwareVerifier for revocation checking. + /// When CRL is missing/degraded, falls back to WebPKI-only verification. + pub fn build_rustls_config( + &self, + crl_state: Option, + ) -> Result, MtlsError> { + let webpki_verifier = WebPkiClientVerifier::builder(self.cert_store.clone()) .build() .map_err(|e| MtlsError::ClientVerifierError(e.to_string()))?; + let client_verifier: Arc = match crl_state { + Some(state) => { + info!("CRL-aware client verification enabled"); + Arc::new(CrlAwareVerifier::new(webpki_verifier, state)) + } + None => { + info!("No CRL state provided -- using WebPKI-only client verification"); + webpki_verifier + } + }; + let server_cert = load_certs(&self.config.server_cert_path)?; let server_key = load_private_key(&self.config.server_key_path)?; diff --git a/src/config/loader.rs b/src/config/loader.rs index 0ef4734..0d3fef1 100644 --- a/src/config/loader.rs +++ b/src/config/loader.rs @@ -35,6 +35,14 @@ pub struct TlsConfig { pub server_key: String, #[serde(default = "default_tls_version")] pub min_tls_version: String, + /// Path to persist the CRL fetched from the manager. + /// Defaults to /etc/linux_patch_api/certs/crl.pem + #[serde(default = "default_crl_path")] + pub crl_path: String, +} + +fn default_crl_path() -> String { + "/etc/linux_patch_api/certs/crl.pem".to_string() } fn default_true() -> bool { diff --git a/src/main.rs b/src/main.rs index 6db3b7f..8333602 100644 --- a/src/main.rs +++ b/src/main.rs @@ -27,6 +27,7 @@ use std::sync::Arc; use tracing::{error, info, warn}; use linux_patch_api::api::{configure_api_routes, configure_health_route}; +use linux_patch_api::auth::crl::{self, CrlStatus}; use linux_patch_api::auth::{mtls, MtlsMiddleware, WhitelistManager}; use linux_patch_api::config::loader::{validate_certs, CertStatus}; use linux_patch_api::enroll; @@ -297,6 +298,10 @@ async fn main() -> Result<()> { let cache_state = web::Data::new(PackageCacheState::new()); info!("Package cache state initialized"); + // Initialize shared CRL state (available even when TLS is off for health reporting) + let shared_crl_state = crl::new_shared_state(); + let crl_state_data = web::Data::new(shared_crl_state.clone()); + // Configure bind address let bind_address = format!("{}:{}", config.server.bind, config.server.port); @@ -306,7 +311,8 @@ async fn main() -> Result<()> { .wrap(Logger::default()) .app_data(job_manager_data.clone()) .app_data(backend_data.clone()) - .app_data(cache_state.clone()); + .app_data(cache_state.clone()) + .app_data(crl_state_data.clone()); // Configure API routes app = app.configure(|cfg| { @@ -345,6 +351,7 @@ async fn main() -> Result<()> { server_cert = %tls_config.server_cert, server_key = %tls_config.server_key, min_tls_version = %tls_config.min_tls_version, + crl_path = %tls_config.crl_path, "Initializing mTLS authentication with TLS binding" ); @@ -355,11 +362,50 @@ async fn main() -> Result<()> { min_tls_version: tls_config.min_tls_version.clone(), }; + // Load CRL from disk into the shared CRL state + let crl_path = std::path::PathBuf::from(&tls_config.crl_path); + let ca_cert_der = std::fs::read(&tls_config.ca_cert).unwrap_or_default(); + + // Load initial CRL from disk (missing is OK -- degraded mode) + let initial_crl = crl::load_crl(&crl_path, &ca_cert_der); + match initial_crl.status { + CrlStatus::Invalid => { + error!("CRL signature is invalid -- refusing to start (fail-closed)"); + std::process::exit(ExitCode::Error as i32); + } + CrlStatus::Valid | CrlStatus::Expired => { + info!( + status = %initial_crl.status, + revoked = initial_crl.revoked_serials.len(), + "CRL loaded from disk" + ); + shared_crl_state.store(std::sync::Arc::new(initial_crl)); + } + CrlStatus::Missing => { + info!("No CRL on disk -- starting in WebPKI-only mode"); + } + CrlStatus::Degraded => { + warn!("CRL load failed -- starting in degraded (WebPKI-only) mode"); + } + } + + // Spawn CRL refresh background task if manager URL is configured + if let Some(manager_url) = config.enrollment_manager_url() { + crl::spawn_crl_refresh_task( + manager_url.to_string(), + crl_path, + ca_cert_der, + shared_crl_state.clone(), + ); + } else { + info!("No manager URL configured -- CRL auto-refresh disabled"); + } + match MtlsMiddleware::new(mtls_config.clone()) { Ok(middleware) => { - // Build rustls server configuration + // Build rustls server configuration with CRL-aware verifier let rustls_config = middleware - .build_rustls_config() + .build_rustls_config(Some(shared_crl_state.clone())) .map_err(|e| anyhow::anyhow!("Failed to build rustls config: {}", e))?; info!("mTLS middleware and rustls config initialized successfully"); diff --git a/tests/e2e/test_enrollment_e2e.rs b/tests/e2e/test_enrollment_e2e.rs index f9d8603..ee8c825 100644 --- a/tests/e2e/test_enrollment_e2e.rs +++ b/tests/e2e/test_enrollment_e2e.rs @@ -78,6 +78,7 @@ fn build_tls_config(cert_dir: &std::path::Path) -> TlsConfig { .to_string_lossy() .to_string(), min_tls_version: "1.3".to_string(), + crl_path: String::new(), // No CRL in E2E tests } }