From 320403a11e7ade3ceecd6d4322c6e143bfad5a3c Mon Sep 17 00:00:00 2001 From: Sebastian Eydam Date: Thu, 30 Apr 2026 14:20:06 +0200 Subject: [PATCH] vmm: validate TLS related files Validate that all files that are necessary for TLS encryption are in the given folder. The knowledge which files are necessary is part of the TLS module. On-behalf-of: SAP sebastian.eydam@sap.com Signed-off-by: Sebastian Eydam --- vm-migration/src/tls.rs | 216 +++++++++++++++++++++++++++++++++++++++- vmm/src/api/mod.rs | 85 ++++++++++++++-- 2 files changed, 294 insertions(+), 7 deletions(-) diff --git a/vm-migration/src/tls.rs b/vm-migration/src/tls.rs index c0ecb7628..3cdd31282 100644 --- a/vm-migration/src/tls.rs +++ b/vm-migration/src/tls.rs @@ -18,10 +18,11 @@ //! other migration streams. All data must pass through rustls; direct I/O on the //! underlying socket would bypass TLS processing and break the connection. +use std::fs::{self, File}; use std::io::{self, BufRead, Read, Write}; use std::net::TcpStream; use std::os::fd::{AsFd, BorrowedFd}; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::result; use std::sync::Arc; @@ -44,6 +45,130 @@ const CLIENT_KEY_FILE: &str = "client-key.pem"; const SERVER_CERT_FILE: &str = "server-cert.pem"; const SERVER_KEY_FILE: &str = "server-key.pem"; +/// Identifies which side of live migration uses a TLS certificate directory. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TlsEndpoint { + Client, + Server, +} + +impl TlsEndpoint { + fn required_files(self) -> [&'static str; 3] { + match self { + Self::Client => [CA_CERT_FILE, CLIENT_CERT_FILE, CLIENT_KEY_FILE], + Self::Server => [CA_CERT_FILE, SERVER_CERT_FILE, SERVER_KEY_FILE], + } + } + + fn as_str(self) -> &'static str { + match self { + Self::Client => "migration client", + Self::Server => "migration server", + } + } +} + +/// Validation errors for a migration TLS certificate directory. +#[derive(Error, Debug)] +pub enum TlsConfigError { + #[error("TLS directory does not exist or is inaccessible: {path}: {source}")] + DirectoryMetadata { + path: PathBuf, + #[source] + source: io::Error, + }, + + #[error("TLS directory must point to a directory: {path}")] + NotADirectory { path: PathBuf }, + + #[error("Missing required TLS file for {endpoint}: {path}")] + MissingFile { + endpoint: &'static str, + path: PathBuf, + }, + + #[error("Required TLS path for {endpoint} must be a regular file: {path}")] + NotAFile { + endpoint: &'static str, + path: PathBuf, + }, + + #[error("Required TLS file for {endpoint} is not readable: {path}: {source}")] + FileRead { + endpoint: &'static str, + path: PathBuf, + #[source] + source: io::Error, + }, + + #[error("Failed to inspect required TLS file for {endpoint}: {path}: {source}")] + FileMetadata { + endpoint: &'static str, + path: PathBuf, + #[source] + source: io::Error, + }, +} + +/// Validates that a TLS directory contains all files required by the endpoint. +/// +/// Each required file must exist, be a regular file, and be readable by the +/// current Cloud Hypervisor process. +pub fn validate_tls_dir( + cert_dir: &Path, + endpoint: TlsEndpoint, +) -> result::Result<(), TlsConfigError> { + let metadata = fs::metadata(cert_dir).map_err(|source| TlsConfigError::DirectoryMetadata { + path: cert_dir.to_path_buf(), + source, + })?; + + if !metadata.is_dir() { + return Err(TlsConfigError::NotADirectory { + path: cert_dir.to_path_buf(), + }); + } + + for file_name in endpoint.required_files() { + let path = cert_dir.join(file_name); + let endpoint_name = endpoint.as_str(); + + let metadata = match fs::metadata(&path) { + Ok(metadata) => metadata, + Err(source) if source.kind() == io::ErrorKind::NotFound => { + return Err(TlsConfigError::MissingFile { + endpoint: endpoint_name, + path, + }); + } + Err(source) => { + return Err(TlsConfigError::FileMetadata { + endpoint: endpoint_name, + path, + source, + }); + } + }; + + if !metadata.is_file() { + return Err(TlsConfigError::NotAFile { + endpoint: endpoint_name, + path, + }); + } + + if let Err(source) = File::open(&path) { + return Err(TlsConfigError::FileRead { + endpoint: endpoint_name, + path, + source, + }); + } + } + + Ok(()) +} + /// Errors that can occur when establishing a TLS-encrypted migration channel. #[derive(Error, Debug)] pub enum TlsError { @@ -348,3 +473,92 @@ fn load_private_key(key_path: &Path) -> result::Result, M .map_err(TlsError::RustlsPemError) .map_err(MigratableError::Tls) } + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + use std::time::{SystemTime, UNIX_EPOCH}; + use std::{fs, process}; + + use super::*; + + struct TestDir { + path: PathBuf, + } + + impl TestDir { + fn new(name: &str) -> Self { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "cloud-hypervisor-{name}-{}-{unique}", + process::id() + )); + fs::create_dir(&path).unwrap(); + Self { path } + } + + fn add_file(&self, file_name: &str) { + fs::write(self.path.join(file_name), b"test").unwrap(); + } + + fn add_client_files(&self) { + self.add_file(CA_CERT_FILE); + self.add_file(CLIENT_CERT_FILE); + self.add_file(CLIENT_KEY_FILE); + } + + fn add_server_files(&self) { + self.add_file(CA_CERT_FILE); + self.add_file(SERVER_CERT_FILE); + self.add_file(SERVER_KEY_FILE); + } + } + + impl Drop for TestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } + } + + #[test] + fn validate_tls_dir_accepts_complete_client_directory() { + let dir = TestDir::new("tls-client"); + dir.add_client_files(); + + validate_tls_dir(&dir.path, TlsEndpoint::Client).unwrap(); + } + + #[test] + fn validate_tls_dir_accepts_complete_server_directory() { + let dir = TestDir::new("tls-server"); + dir.add_server_files(); + + validate_tls_dir(&dir.path, TlsEndpoint::Server).unwrap(); + } + + #[test] + fn validate_tls_dir_rejects_missing_role_specific_file() { + let dir = TestDir::new("tls-missing-client-key"); + dir.add_file(CA_CERT_FILE); + dir.add_file(CLIENT_CERT_FILE); + + let err = validate_tls_dir(&dir.path, TlsEndpoint::Client).unwrap_err(); + let err = err.to_string(); + assert!(err.contains(CLIENT_KEY_FILE), "unexpected error: {err}"); + } + + #[test] + fn validate_tls_dir_rejects_non_file_entry() { + let dir = TestDir::new("tls-non-file"); + dir.add_file(CA_CERT_FILE); + dir.add_file(CLIENT_KEY_FILE); + fs::create_dir(dir.path.join(CLIENT_CERT_FILE)).unwrap(); + + let err = validate_tls_dir(&dir.path, TlsEndpoint::Client).unwrap_err(); + let err = err.to_string(); + assert!(err.contains(CLIENT_CERT_FILE), "unexpected error: {err}"); + } +} diff --git a/vmm/src/api/mod.rs b/vmm/src/api/mod.rs index 2c933820e..e7dddcd0e 100644 --- a/vmm/src/api/mod.rs +++ b/vmm/src/api/mod.rs @@ -46,6 +46,7 @@ use option_parser::{OptionParser, OptionParserError, Toggle}; use serde::{Deserialize, Serialize}; use thiserror::Error; use vm_migration::MigratableError; +use vm_migration::tls::{TlsEndpoint, validate_tls_dir}; use vmm_sys_util::eventfd::EventFd; #[cfg(feature = "dbus_api")] @@ -342,6 +343,14 @@ impl VmReceiveMigrationData { )); } + if let Some(tls_dir) = &self.tls_dir { + validate_tls_dir(tls_dir, TlsEndpoint::Server).map_err(|e| { + VmReceiveMigrationConfigError::ValidationError(format!( + "invalid TLS configuration for receive-migration: {e}" + )) + })?; + } + Ok(()) } } @@ -576,6 +585,14 @@ impl VmSendMigrationData { } } + if let Some(tls_dir) = &self.tls_dir { + validate_tls_dir(tls_dir, TlsEndpoint::Client).map_err(|e| { + VmSendMigrationConfigError::ValidationError(format!( + "invalid TLS configuration for send-migration: {e}" + )) + })?; + } + Ok(()) } } @@ -1849,8 +1866,53 @@ impl ApiAction for VmNmi { #[cfg(test)] mod unit_tests { + use std::path::PathBuf; + use std::time::{SystemTime, UNIX_EPOCH}; + use std::{fs, process}; + use super::*; + struct TestDir { + path: PathBuf, + } + + impl TestDir { + fn new(name: &str) -> Self { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "cloud-hypervisor-api-{name}-{}-{unique}", + process::id() + )); + fs::create_dir(&path).unwrap(); + Self { path } + } + + fn add_file(&self, file_name: &str) { + fs::write(self.path.join(file_name), b"test").unwrap(); + } + + fn add_receive_tls_files(&self) { + self.add_file("ca-cert.pem"); + self.add_file("server-cert.pem"); + self.add_file("server-key.pem"); + } + + fn add_send_tls_files(&self) { + self.add_file("ca-cert.pem"); + self.add_file("client-cert.pem"); + self.add_file("client-key.pem"); + } + } + + impl Drop for TestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } + } + #[test] fn test_vm_receive_migration_data_parse() { let data = VmReceiveMigrationData::parse("receiver_url=tcp:192.168.1.1:8080").unwrap(); @@ -1872,20 +1934,29 @@ mod unit_tests { let data = VmReceiveMigrationData::parse("receiver_url=unix:/tmp/ch=migrate.sock").unwrap(); assert_eq!(data.receiver_url, "unix:/tmp/ch=migrate.sock"); - let tls_dir = std::env::temp_dir(); + let tls_dir = TestDir::new("receive-tls"); + tls_dir.add_receive_tls_files(); + let tls_dir_path = tls_dir.path.clone(); let data = VmReceiveMigrationData::parse(&format!( "receiver_url=tcp:192.168.1.1:8080,tls_dir={}", - tls_dir.display() + tls_dir_path.display() )) .unwrap(); assert_eq!( data, VmReceiveMigrationData { receiver_url: "tcp:192.168.1.1:8080".to_string(), - tls_dir: Some(tls_dir), + tls_dir: Some(tls_dir_path), } ); + let tls_dir = TestDir::new("receive-empty-tls"); + VmReceiveMigrationData::parse(&format!( + "receiver_url=tcp:192.168.1.1:8080,tls_dir={}", + tls_dir.path.display() + )) + .unwrap_err(); + VmReceiveMigrationData::parse("receiver_url=file:///tmp/migration").unwrap_err(); VmReceiveMigrationData::parse("receiver_url=tcp:192.168.1.1").unwrap_err(); VmReceiveMigrationData::parse("receiver_url=tcp:[2001:db8::1]").unwrap_err(); @@ -1995,9 +2066,11 @@ mod unit_tests { ); // Happy path, fully specified - let tls_dir = std::env::temp_dir(); + let tls_dir = TestDir::new("send-tls"); + tls_dir.add_send_tls_files(); + let tls_dir_path = tls_dir.path.clone(); let data = - VmSendMigrationData::parse(&format!("destination_url=tcp:192.168.1.1:8080,downtime_ms=150,timeout_s=900,timeout_strategy=ignore,connections=4,tls_dir={}", tls_dir.display())) + VmSendMigrationData::parse(&format!("destination_url=tcp:192.168.1.1:8080,downtime_ms=150,timeout_s=900,timeout_strategy=ignore,connections=4,tls_dir={}", tls_dir_path.display())) .unwrap(); assert_eq!( data, @@ -2008,7 +2081,7 @@ mod unit_tests { timeout_s: NonZeroU64::new(900).unwrap(), timeout_strategy: TimeoutStrategy::Ignore, connections: NonZeroU32::new(4).unwrap(), - tls_dir: Some(tls_dir), + tls_dir: Some(tls_dir_path), } ); }