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
Format all enrollment module source files and tests per rustfmt standards. Resolves Gitea CI workflow cargo fmt check failures.
373 lines
13 KiB
Rust
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);
|
|
}
|
|
}
|