//! PKI provisioning module for self-enrollment. //! Handles certificate extraction, validation, and secure file writing. use crate::auth::WhitelistManager; use anyhow::{bail, Context, Result}; use std::fs::{self, OpenOptions}; use std::io::Write; use std::os::unix::fs::OpenOptionsExt; /// Default certificate directory when TLS config is not provided. #[allow(dead_code)] const DEFAULT_CERT_DIR: &str = "/etc/linux_patch_api/certs"; /// Default CA certificate path. const DEFAULT_CA_CERT: &str = "/etc/linux_patch_api/certs/ca.pem"; /// Default server certificate path. const DEFAULT_SERVER_CERT: &str = "/etc/linux_patch_api/certs/server.pem"; /// Default server key path. const DEFAULT_SERVER_KEY: &str = "/etc/linux_patch_api/certs/server.key.pem"; /// Validate that a PEM string has proper format (BEGIN/END markers present). /// /// Checks for `-----BEGIN {expected_type}-----` and `-----END {expected_type}-----` markers. /// Returns an error if either marker is missing or the data is empty. pub fn validate_pem(pem_data: &str, expected_type: &str) -> Result<()> { let trimmed = pem_data.trim(); if trimmed.is_empty() { bail!("PEM data is empty for type '{}'", expected_type); } let begin_marker = format!("-----BEGIN {}-----", expected_type); let end_marker = format!("-----END {}-----", expected_type); if !trimmed.contains(&begin_marker) { bail!( "Invalid PEM format: missing '{}' marker for type '{}'", begin_marker, expected_type ); } if !trimmed.contains(&end_marker) { bail!( "Invalid PEM format: missing '{}' marker for type '{}'", end_marker, expected_type ); } Ok(()) } /// Write PEM data to disk with secure permissions using atomic write pattern. /// /// 1. Create target directory if it doesn't exist (with 0o755 permissions) /// 2. Backup existing file if present (.bak extension) /// 3. Write to temp file in same directory /// 4. Set correct permissions (key=0o600, certs=0o644) /// 5. Rename atomically to target path pub fn write_pem_file(path: &str, pem_data: &str, is_key: bool) -> Result<()> { let path = std::path::Path::new(path); // Ensure target directory exists if let Some(parent) = path.parent() { if !parent.exists() { fs::create_dir_all(parent) .with_context(|| format!("Failed to create directory: {}", parent.display()))?; // Set directory permissions (0o755 for readability by service, restricted write) #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let mut perms = fs::metadata(parent)?.permissions(); perms.set_mode(0o755); fs::set_permissions(parent, perms).with_context(|| { format!("Failed to set permissions on: {}", parent.display()) })?; } } } // Backup existing file if present if path.exists() { let backup_path = format!("{}.bak", path.display()); fs::rename(path, &backup_path) .with_context(|| format!("Failed to backup existing file: {}", path.display()))?; tracing::info!( original = %path.display(), backup = %backup_path, "Backed up existing certificate file" ); } // Create temp file in same directory for atomic rename let temp_path = path.with_extension("tmp"); // Write PEM data to temp file let mut file = OpenOptions::new() .write(true) .create_new(true) .truncate(true) .mode(if is_key { 0o600 } else { 0o644 }) .open(&temp_path) .with_context(|| format!("Failed to create temp file: {}", temp_path.display()))?; file.write_all(pem_data.as_bytes()) .with_context(|| format!("Failed to write PEM data to: {}", temp_path.display()))?; file.flush() .with_context(|| format!("Failed to flush PEM data to: {}", temp_path.display()))?; // Atomic rename to target path fs::rename(&temp_path, path).with_context(|| { format!( "Failed to atomically rename {} to {}", temp_path.display(), path.display() ) })?; tracing::info!( path = %path.display(), is_key = is_key, permissions = if is_key { "0600" } else { "0644" }, "Successfully wrote PEM file" ); Ok(()) } /// Provision the full PKI bundle from an approved enrollment response. /// /// Writes CA cert, server cert, and server key to configured paths. /// Paths are read from TLS config if available, otherwise defaults are used. pub async fn provision_pki_bundle( ca_crt: &str, server_crt: &str, server_key: &str, tls_config: Option<&super::super::config::loader::TlsConfig>, ) -> Result<()> { // Determine target paths from config or defaults let (ca_path, cert_path, key_path) = if let Some(tls) = tls_config { ( tls.ca_cert.clone(), tls.server_cert.clone(), tls.server_key.clone(), ) } else { ( DEFAULT_CA_CERT.to_string(), DEFAULT_SERVER_CERT.to_string(), DEFAULT_SERVER_KEY.to_string(), ) }; // 1. Validate all three PEM strings before any writes validate_pem(ca_crt, "CERTIFICATE").context("CA certificate validation failed")?; validate_pem(server_crt, "CERTIFICATE").context("Server certificate validation failed")?; // Server key can be PRIVATE KEY (PKCS#8), RSA PRIVATE KEY (PKCS#1), or EC PRIVATE KEY let key_valid = validate_pem(server_key, "PRIVATE KEY").is_ok() || validate_pem(server_key, "RSA PRIVATE KEY").is_ok() || validate_pem(server_key, "EC PRIVATE KEY").is_ok(); if !key_valid { bail!( "Server key validation failed: PEM must be PRIVATE KEY, RSA PRIVATE KEY, or EC PRIVATE KEY" ); } // 2. Write to configured paths (atomic writes) write_pem_file(&ca_path, ca_crt, false).context("Failed to write CA certificate")?; write_pem_file(&cert_path, server_crt, false).context("Failed to write server certificate")?; write_pem_file(&key_path, server_key, true).context("Failed to write server key")?; // 3. Log successful provisioning with structured fields tracing::info!( ca_cert = %ca_path, server_cert = %cert_path, server_key = %key_path, "PKI bundle provisioned successfully - all certificates written and validated" ); Ok(()) } /// Append the manager IP to the whitelist after successful enrollment. /// /// Creates or loads a `WhitelistManager` and calls `append_entry()` with the /// provided IP/CIDR string. Returns an error if the file cannot be locked, /// written, or reloaded. pub async fn append_manager_to_whitelist(manager_ip: &str, whitelist_path: &str) -> Result<()> { // Validate input before touching any files let ip_or_cidr = manager_ip.trim(); if ip_or_cidr.is_empty() { bail!("Manager IP address cannot be empty"); } // Create or load WhitelistManager and call append_entry let mut manager = WhitelistManager::new(whitelist_path).with_context(|| { format!( "Failed to initialize whitelist manager for path: {}", whitelist_path ) })?; manager.append_entry(ip_or_cidr).with_context(|| { format!( "Failed to append manager IP '{}' to whitelist at: {}", ip_or_cidr, whitelist_path ) })?; Ok(()) } #[cfg(test)] mod tests { use super::*; use tempfile::tempdir; fn sample_certificate() -> String { "-----BEGIN CERTIFICATE-----\nMIIBxTCCAWugAwIBAgIRA ...\nBASE64ENCODED DATA HERE ...\n-----END CERTIFICATE-----".to_string() } fn sample_rsa_key() -> String { "-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA0Z3VS5JJcds3...\nBASE64ENCODED DATA HERE ...\n-----END RSA PRIVATE KEY-----".to_string() } fn sample_pkcs8_key() -> String { "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQE...\nBASE64ENCODED DATA HERE ...\n-----END PRIVATE KEY-----".to_string() } fn sample_ec_key() -> String { "-----BEGIN EC PRIVATE KEY-----\nMHQCAQEEIBkg5Lb/...\nBASE64ENCODED DATA HERE ...\n-----END EC PRIVATE KEY-----".to_string() } #[test] fn test_validate_pem_valid_certificate() { let cert = sample_certificate(); assert!(validate_pem(&cert, "CERTIFICATE").is_ok()); } #[test] fn test_validate_pem_valid_rsa_key() { let key = sample_rsa_key(); assert!(validate_pem(&key, "RSA PRIVATE KEY").is_ok()); } #[test] fn test_validate_pem_valid_pkcs8_key() { let key = sample_pkcs8_key(); assert!(validate_pem(&key, "PRIVATE KEY").is_ok()); } #[test] fn test_validate_pem_valid_ec_key() { let key = sample_ec_key(); assert!(validate_pem(&key, "EC PRIVATE KEY").is_ok()); } #[test] fn test_validate_pem_empty_data_fails() { assert!(validate_pem("", "CERTIFICATE").is_err()); } #[test] fn test_validate_pem_missing_begin_marker_fails() { let malformed = "BASE64DATA\n-----END CERTIFICATE-----".to_string(); let err = validate_pem(&malformed, "CERTIFICATE").unwrap_err(); assert!(err.to_string().contains("BEGIN")); } #[test] fn test_validate_pem_missing_end_marker_fails() { let malformed = "-----BEGIN CERTIFICATE-----\nBASE64DATA".to_string(); let err = validate_pem(&malformed, "CERTIFICATE").unwrap_err(); assert!(err.to_string().contains("END")); } #[test] fn test_validate_pem_wrong_type_fails() { let cert = sample_certificate(); // Certificate data checked against wrong type should fail let err = validate_pem(&cert, "RSA PRIVATE KEY").unwrap_err(); assert!(err.to_string().contains("BEGIN")); } #[test] fn test_validate_pem_whitespace_tolerance() { let cert = format!("\n \n {} \n ", sample_certificate()); assert!(validate_pem(&cert, "CERTIFICATE").is_ok()); } #[test] fn test_write_pem_file_creates_directory() { let dir = tempdir().expect("failed to create temp dir"); let target_path = dir.path().join("subdir").join("cert.pem"); let cert = sample_certificate(); write_pem_file(target_path.to_str().unwrap(), &cert, false).expect("write failed"); assert!(target_path.exists()); } #[test] fn test_write_pem_file_atomic_rename() { let dir = tempdir().expect("failed to create temp dir"); let target_path = dir.path().join("cert.pem"); let cert = sample_certificate(); write_pem_file(target_path.to_str().unwrap(), &cert, false).expect("write failed"); // Verify content matches let written = fs::read_to_string(&target_path).expect("failed to read back"); assert_eq!(written, cert); } #[test] fn test_write_pem_file_key_permissions() { use std::os::unix::fs::PermissionsExt; let dir = tempdir().expect("failed to create temp dir"); let target_path = dir.path().join("key.pem"); let key = sample_rsa_key(); write_pem_file(target_path.to_str().unwrap(), &key, true).expect("write failed"); let metadata = fs::metadata(&target_path).expect("failed to get metadata"); let mode = metadata.permissions().mode() & 0o777; assert_eq!(mode, 0o600, "Key file should have 0600 permissions"); } #[test] fn test_write_pem_file_cert_permissions() { use std::os::unix::fs::PermissionsExt; let dir = tempdir().expect("failed to create temp dir"); let target_path = dir.path().join("cert.pem"); let cert = sample_certificate(); write_pem_file(target_path.to_str().unwrap(), &cert, false).expect("write failed"); let metadata = fs::metadata(&target_path).expect("failed to get metadata"); let mode = metadata.permissions().mode() & 0o777; assert_eq!(mode, 0o644, "Cert file should have 0644 permissions"); } #[test] fn test_write_pem_file_backup_existing() { let dir = tempdir().expect("failed to create temp dir"); let target_path = dir.path().join("cert.pem"); let cert1 = sample_certificate(); let cert2 = "-----BEGIN CERTIFICATE-----\nNEWCERTDATA\n-----END CERTIFICATE-----".to_string(); // Write initial file write_pem_file(target_path.to_str().unwrap(), &cert1, false).expect("initial write failed"); // Write again - should create backup write_pem_file(target_path.to_str().unwrap(), &cert2, false).expect("second write failed"); let backup_path = format!("{}.bak", target_path.display()); assert!( std::path::Path::new(&backup_path).exists(), "Backup file should exist" ); // Original content in backup let backup_content = fs::read_to_string(&backup_path).expect("failed to read backup"); assert_eq!(backup_content, cert1); } }