Private
Public Access
1
0
Files
linux_patch_api/src/enroll/provision.rs
Echo 6cfef766a7
Some checks failed
CI/CD Pipeline / Code Format (push) Successful in 4s
CI/CD Pipeline / Clippy Lints (push) Failing after 44s
CI/CD Pipeline / Enrollment Tests (push) Has been skipped
CI/CD Pipeline / Verify Enrollment CLI Flag (push) Has been skipped
CI/CD Pipeline / All Unit Tests (push) Successful in 1m15s
CI/CD Pipeline / Build Debian Package (push) Has been skipped
CI/CD Pipeline / Build Debian Package (Ubuntu 22.04) (push) Has been skipped
CI/CD Pipeline / Build RPM Package (push) Has been skipped
CI/CD Pipeline / Build Alpine Package (push) Has been skipped
CI/CD Pipeline / Build Arch Package (push) Has been skipped
CI/CD Pipeline / Security Audit (push) Successful in 4s
fix: apply cargo fmt to resolve CI formatting failures
Format all enrollment module source files and tests per rustfmt standards.
Resolves Gitea CI workflow cargo fmt check failures.
2026-05-17 05:49:26 +00:00

373 lines
13 KiB
Rust

//! 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);
}
}