// Copyright © 2026 Cyberus Technology GmbH // // SPDX-License-Identifier: Apache-2.0 // //! TLS support for migration streams over TCP. //! //! This module wraps `rustls` to provide a blocking [`TlsStream`] for migration //! traffic. [`TlsStream::new_client`] authenticates the server against //! `ca-cert.pem` and the expected hostname, and presents `client-cert.pem` and //! `client-key.pem` for mutual TLS (mTLS) authentication. [`TlsServerConfig`] loads //! `server-cert.pem` and `server-key.pem`, trusts client certificates issued by //! the CA in `ca-cert.pem`, and [`TlsStream::new_server`] uses that //! configuration to establish the server side of the connection. //! //! [`TlsStream`] implements [`Read`], [`Write`], [`ReadVolatile`], //! [`WriteVolatile`], and [`AsFd`] so it can be used by the transport layer like //! 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, PathBuf}; use std::result; use std::sync::Arc; use log::warn; use rustls::pki_types::pem::{self, PemObject}; use rustls::pki_types::{CertificateDer, InvalidDnsNameError, PrivateKeyDer, ServerName}; use rustls::server::{VerifierBuilderError, WebPkiClientVerifier}; use rustls::{ ClientConfig, ClientConnection, RootCertStore, ServerConfig, ServerConnection, StreamOwned, }; use thiserror::Error; use vm_memory::bitmap::BitmapSlice; use vm_memory::{ReadVolatile, VolatileMemoryError, VolatileSlice, WriteVolatile}; use crate::MigratableError; const CA_CERT_FILE: &str = "ca-cert.pem"; const CLIENT_CERT_FILE: &str = "client-cert.pem"; 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 { #[error("The provided hostname could not be parsed")] InvalidDnsName(#[source] InvalidDnsNameError), #[error("Rustls protocol error")] RustlsError(#[from] rustls::Error), #[error("Rustls verifier configuration error")] RustlsVerifierBuilderError(#[source] VerifierBuilderError), #[error("Rustls protocol IO error")] RustlsIoError(#[from] io::Error), #[error("TLS handshake stalled: no read/write progress while handshake is still in progress")] HandshakeError, #[error("Error handling PEM file")] RustlsPemError(#[from] pem::Error), } /// Wraps the concrete rustls stream for either side (server or client) of the /// TLS connection. /// /// [`TlsStream`] uses this enum to store a [`StreamOwned`] with either a /// [`ClientConnection`] or [`ServerConnection`] while exposing a single /// transport-agnostic API. #[derive(Debug)] enum TlsStreamParticipant { Client(StreamOwned), Server(StreamOwned), } /// Server/Client-agnostic TLS stream. pub struct TlsStream { stream: TlsStreamParticipant, // `rustls` only accepts plaintext writes as regular byte slices, so // `WriteVolatile` needs a staging buffer to copy out of guest memory. write_buf: Vec, } impl TlsStream { /// The maximum size of [`TlsStream::write_buf`]. This keeps the reusable buffer /// from growing without bound. const BUF_SIZE: usize = 64 /* KiB */ << 10; /// Creates a client [`TlsStream`]. /// /// The client verifies the server certificate against `ca-cert.pem` and the /// provided `hostname`, and presents the certificate chain in /// `client-cert.pem` together with the private key in `client-key.pem` for /// mutual TLS authentication. pub fn new_client( socket: TcpStream, cert_dir: &Path, hostname: &str, ) -> result::Result { let root_store = load_root_store(&cert_dir.join(CA_CERT_FILE))?; let client_certs = load_cert_chain(&cert_dir.join(CLIENT_CERT_FILE))?; let client_key = load_private_key(&cert_dir.join(CLIENT_KEY_FILE))?; let config = ClientConfig::builder() .with_root_certificates(root_store) .with_client_auth_cert(client_certs, client_key) .map_err(TlsError::RustlsError) .map_err(MigratableError::Tls)?; let config = Arc::new(config); let server_name = ServerName::try_from(hostname.to_string()) .map_err(TlsError::InvalidDnsName) .map_err(MigratableError::Tls)?; let conn = ClientConnection::new(config, server_name) .map_err(TlsError::RustlsError) .map_err(MigratableError::Tls)?; let mut tls = StreamOwned::new(conn, socket); while tls.conn.is_handshaking() { let (rd, wr) = tls .conn .complete_io(&mut tls.sock) .map_err(TlsError::RustlsIoError) .map_err(MigratableError::Tls)?; // No handshake progress on a connection that should be handshaking, we treat // that as a failure. if rd == 0 && wr == 0 { return Err(MigratableError::Tls(TlsError::HandshakeError)); } } Ok(Self { stream: TlsStreamParticipant::Client(tls), write_buf: Vec::new(), }) } /// Creates a server [`TlsStream`]. Encrypts and decrypts data sent through /// this stream using the certificates and key from the provided /// [`TlsServerConfig`]. pub fn new_server( socket: TcpStream, config: &TlsServerConfig, ) -> result::Result { let conn = ServerConnection::new(config.config.clone()) .map_err(TlsError::RustlsError) .map_err(MigratableError::Tls)?; let mut tls = StreamOwned::new(conn, socket); while tls.conn.is_handshaking() { let (rd, wr) = tls .conn .complete_io(&mut tls.sock) .map_err(TlsError::RustlsIoError) .map_err(MigratableError::Tls)?; // No handshake progress on a connection that should be handshaking, we treat // that as a failure. if rd == 0 && wr == 0 { return Err(MigratableError::Tls(TlsError::HandshakeError)); } } Ok(Self { stream: TlsStreamParticipant::Server(tls), write_buf: Vec::new(), }) } } impl Read for TlsStream { fn read(&mut self, buf: &mut [u8]) -> io::Result { match &mut self.stream { TlsStreamParticipant::Client(s) => Read::read(s, buf), TlsStreamParticipant::Server(s) => Read::read(s, buf), } } } impl Write for TlsStream { fn write(&mut self, buf: &[u8]) -> io::Result { match &mut self.stream { TlsStreamParticipant::Client(s) => Write::write(s, buf), TlsStreamParticipant::Server(s) => Write::write(s, buf), } } fn flush(&mut self) -> io::Result<()> { match &mut self.stream { TlsStreamParticipant::Client(s) => Write::flush(s), TlsStreamParticipant::Server(s) => Write::flush(s), } } } // Reading from or writing to these FDs would break the connection, because // those reads or writes wouldn't go through rustls. But the FD is necessary to // listen for incoming connections. impl AsFd for TlsStream { fn as_fd(&self) -> BorrowedFd<'_> { match &self.stream { TlsStreamParticipant::Client(s) => s.get_ref().as_fd(), TlsStreamParticipant::Server(s) => s.get_ref().as_fd(), } } } impl ReadVolatile for TlsStream { fn read_volatile( &mut self, vs: &mut VolatileSlice, ) -> result::Result { if vs.is_empty() { return Ok(0); } let chunk = match &mut self.stream { TlsStreamParticipant::Client(s) => BufRead::fill_buf(s), TlsStreamParticipant::Server(s) => BufRead::fill_buf(s), } .map_err(VolatileMemoryError::IOError)?; let n = chunk.len().min(vs.len()); if n == 0 { return Ok(0); } vs.copy_from(&chunk[..n]); match &mut self.stream { TlsStreamParticipant::Client(s) => BufRead::consume(s, n), TlsStreamParticipant::Server(s) => BufRead::consume(s, n), } Ok(n) } } impl WriteVolatile for TlsStream { fn write_volatile( &mut self, vs: &VolatileSlice, ) -> Result { let len = vs.len().min(Self::BUF_SIZE); if len == 0 { return Ok(0); } if self.write_buf.len() < len { self.write_buf.resize(len, 0); } let buf = &mut self.write_buf[..len]; let n = vs.copy_to(buf); if n == 0 { return Ok(0); } let n = match &mut self.stream { TlsStreamParticipant::Client(s) => Write::write(s, &buf[..n]), TlsStreamParticipant::Server(s) => Write::write(s, &buf[..n]), } .map_err(VolatileMemoryError::IOError)?; Ok(n) } } /// Carries a TLS server configuration. Intended to be turned into a [`TlsStream`] /// when paired with a [`TcpStream`]. #[derive(Debug, Clone)] pub struct TlsServerConfig { /// This config is shared between all server connections. config: Arc, } impl TlsServerConfig { /// Creates a [`TlsServerConfig`] from the certificate chain in /// `server-cert.pem`, the private key in `server-key.pem`, and the client /// trust anchors in `ca-cert.pem`. /// /// Client certificates presented during the TLS handshake must chain to a CA in /// `ca-cert.pem`. pub fn new(cert_dir: &Path) -> result::Result { let server_certs = load_cert_chain(&cert_dir.join(SERVER_CERT_FILE))?; let server_key = load_private_key(&cert_dir.join(SERVER_KEY_FILE))?; // Trust anchors used to verify client certificates for mTLS. let client_roots = Arc::new(load_root_store(&cert_dir.join(CA_CERT_FILE))?); let client_verifier = WebPkiClientVerifier::builder(client_roots) .build() .map_err(TlsError::RustlsVerifierBuilderError) .map_err(MigratableError::Tls)?; let config = ServerConfig::builder() .with_client_cert_verifier(client_verifier) .with_single_cert(server_certs, server_key) .map_err(TlsError::RustlsError) .map_err(MigratableError::Tls)?; let config = Arc::new(config); Ok(Self { config }) } } /// Loads trusted CA certificates into a root store, i.e. the set of trust anchors /// used to verify the peer's certificate chain. fn load_root_store(cert_path: &Path) -> result::Result { let mut root_store = RootCertStore::empty(); let (_, ignored) = root_store.add_parsable_certificates( CertificateDer::pem_file_iter(cert_path) .map_err(TlsError::RustlsPemError) .map_err(MigratableError::Tls)? .map(|cert| cert.map_err(TlsError::RustlsPemError)) .collect::>, TlsError>>() .map_err(MigratableError::Tls)?, ); if ignored > 0 { warn!( "Ignored {ignored} certificate(s) while loading TLS CA file {}", cert_path.display() ); } Ok(root_store) } /// Loads a certificate chain to present during the TLS handshake. fn load_cert_chain( cert_path: &Path, ) -> result::Result>, MigratableError> { CertificateDer::pem_file_iter(cert_path) .map_err(TlsError::RustlsPemError) .map_err(MigratableError::Tls)? .map(|cert| cert.map_err(TlsError::RustlsPemError)) .collect::>, TlsError>>() .map_err(MigratableError::Tls) } /// Loads the private key that proves ownership of the presented certificate chain. fn load_private_key(key_path: &Path) -> result::Result, MigratableError> { PrivateKeyDer::from_pem_file(key_path) .map_err(TlsError::RustlsPemError) .map_err(MigratableError::Tls) } #[cfg(test)] mod tests { use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; use std::{env, 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 = 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}"); } }