diff --git a/crates/bcvk-qemu/src/lib.rs b/crates/bcvk-qemu/src/lib.rs index 0d04fb290..3344dbccc 100644 --- a/crates/bcvk-qemu/src/lib.rs +++ b/crates/bcvk-qemu/src/lib.rs @@ -54,7 +54,8 @@ pub use credentials::{ pub use qemu::{ qemu_version, BootMode, DiskFormat, DisplayMode, MachineType, NetworkMode, QemuConfig, - ResourceLimits, RunningQemu, VirtioBlkDevice, VirtioSerialOut, VirtiofsMount, VHOST_VSOCK, + ResourceLimits, RunningQemu, TpmConfig, VirtioBlkDevice, VirtioSerialOut, VirtiofsMount, + VHOST_VSOCK, }; pub use virtiofsd::{spawn_virtiofsd_async, validate_virtiofsd_config, VirtiofsConfig}; diff --git a/crates/bcvk-qemu/src/qemu.rs b/crates/bcvk-qemu/src/qemu.rs index 1c7140ba8..526d2286a 100644 --- a/crates/bcvk-qemu/src/qemu.rs +++ b/crates/bcvk-qemu/src/qemu.rs @@ -186,6 +186,15 @@ pub enum BootMode { }, } +/// A TPM 2.0 emulated by swtpm. +#[derive(Debug, Clone)] +pub struct TpmConfig { + /// Directory holding the TPM's persistent state. + pub state_dir: Utf8PathBuf, + /// Socket QEMU connects to. + pub socket_path: Utf8PathBuf, +} + /// Complete QEMU VM configuration with builder pattern. #[derive(Default, Debug)] pub struct QemuConfig { @@ -231,6 +240,9 @@ pub struct QemuConfig { /// fw_cfg entries for passing config files to the guest fw_cfg_entries: Vec<(String, Utf8PathBuf)>, + + /// TPM 2.0 to attach. + tpm: Option, } impl QemuConfig { @@ -306,6 +318,10 @@ impl QemuConfig { return Err(eyre!("Memory too high: {}MB (maximum 1TB)", self.memory_mb)); } + if self.tpm.is_some() { + tpm_device()?; + } + // CPU validation if self.vcpus == 0 { return Err(eyre!("vCPU count must be at least 1")); @@ -393,6 +409,12 @@ impl QemuConfig { self } + /// Attach a TPM 2.0 emulated by swtpm. + pub fn set_tpm(&mut self, tpm: TpmConfig) -> &mut Self { + self.tpm = Some(tpm); + self + } + /// Set the main virtiofs configuration for the root filesystem. pub fn set_main_virtiofs(&mut self, config: VirtiofsConfig) -> &mut Self { self.main_virtiofs_config = Some(config); @@ -815,6 +837,17 @@ fn spawn( cmd.args(["-fw_cfg", &format!("name={},file={}", name, file_path)]); } + if let Some(tpm) = &config.tpm { + cmd.args([ + "-chardev", + &format!("socket,id=chrtpm,path={}", tpm.socket_path), + "-tpmdev", + "emulator,id=tpm0,chardev=chrtpm", + "-device", + &format!("{},tpmdev=tpm0", tpm_device()?), + ]); + } + // Configure stdio based on display mode match &config.display_mode { DisplayMode::Console => { @@ -837,6 +870,79 @@ fn spawn( cmd.spawn().context("Failed to spawn QEMU") } +/// Wait until the process `name` creates `socket_path`. Fails if +/// `process` exits first or `timeout` passes. +async fn wait_for_socket( + name: &str, + socket_path: &Utf8Path, + process: &mut Pin>>>, + timeout: Duration, +) -> Result<()> { + let query_exists = async { + while !socket_path.exists() { + tokio::time::sleep(Duration::from_millis(100)).await; + } + }; + debug!("Waiting for socket at {socket_path}"); + tokio::select! { + output = process => { + tracing::trace!("{name} exited"); + let output = output?; + let status = output.status; + let stderr = String::from_utf8_lossy(&output.stderr); + Err(eyre!( + "{name} failed to start for socket {socket_path}\nExit status: {status:?}\nOutput: {stderr}" + )) + } + _ = tokio::time::sleep(timeout) => { + Err(eyre!("timed out waiting for {name} socket {socket_path} to be created (waited {timeout:?})")) + } + _ = query_exists => { + debug!("{name} socket created: {socket_path}"); + Ok(()) + } + } +} + +/// The QEMU TPM device model for the host architecture. +fn tpm_device() -> Result<&'static str> { + match std::env::consts::ARCH { + "x86_64" => Ok("tpm-tis"), + "aarch64" => Ok("tpm-tis-device"), + arch => Err(eyre!("TPM emulation is not supported on {arch}")), + } +} + +/// Start swtpm for `tpm` and wait until it listens on its socket. +async fn spawn_swtpm( + tpm: &TpmConfig, +) -> Result>>>> { + std::fs::create_dir_all(&tpm.state_dir) + .with_context(|| format!("Creating TPM state directory {}", tpm.state_dir))?; + let mut cmd = Command::new("swtpm"); + cmd.args(["socket", "--tpm2", "--tpmstate"]) + .arg(format!("dir={}", tpm.state_dir)) + .arg("--ctrl") + .arg(format!("type=unixio,path={}", tpm.socket_path)) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .lifecycle_bind_to_parent_thread(); + let mut cmd = tokio::process::Command::from(cmd); + cmd.kill_on_drop(true); + debug!("{cmd:?}"); + let child = cmd.spawn().context("Failed to spawn swtpm")?; + let mut process: Pin>>> = + Box::pin(child.wait_with_output()); + wait_for_socket( + "swtpm", + &tpm.socket_path, + &mut process, + Duration::from_secs(10), + ) + .await?; + Ok(process) +} + struct VsockCopier { port: VsockAddr, #[allow(dead_code)] @@ -860,6 +966,9 @@ pub struct RunningQemu { pub virtiofsd_processes: Vec>>>>, #[allow(dead_code)] sd_notification: Option, + /// The swtpm process backing the VM's TPM. + #[allow(dead_code)] + swtpm_process: Option>>>>, } impl std::fmt::Debug for RunningQemu { @@ -877,6 +986,12 @@ impl std::fmt::Debug for RunningQemu { impl RunningQemu { /// Spawn QEMU with the given configuration. pub async fn spawn(mut config: QemuConfig) -> Result { + config.validate()?; + let swtpm_process = match &config.tpm { + Some(tpm) => Some(spawn_swtpm(tpm).await?), + None => None, + }; + // Spawn all virtiofsd processes first let mut awaiting_virtiofsd = Vec::new(); let virtiofsd_configs = config @@ -891,40 +1006,16 @@ impl RunningQemu { // Wait for all virtiofsd to be ready let mut virtiofsd_processes = Vec::new(); while let Some((proc, socket_path)) = awaiting_virtiofsd.pop() { - let socket_path = &socket_path; - let query_exists = async move { - loop { - if socket_path.exists() { - break; - } - tokio::time::sleep(Duration::from_millis(100)).await; - } - }; - tokio::pin!(query_exists); - let timeout_val = Duration::from_secs(60); - let timeout = tokio::time::sleep(timeout_val); - tokio::pin!(timeout); - debug!("Waiting for socket at {socket_path}"); let mut output: Pin>>> = Box::pin(proc.wait_with_output()); - tokio::select! { - output = &mut output => { - tracing::trace!("virtiofsd exited"); - let output = output?; - let status = output.status; - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(eyre!( - "virtiofsd failed to start for socket {socket_path}\nExit status: {status:?}\nOutput: {stderr}" - )); - } - _ = timeout => { - return Err(eyre!("timed out waiting for virtiofsd socket {} to be created (waited {timeout_val:?})", socket_path)); - } - _ = query_exists => { - } - } + wait_for_socket( + "virtiofsd", + &socket_path, + &mut output, + Duration::from_secs(60), + ) + .await?; virtiofsd_processes.push(output); - tracing::debug!("virtiofsd socket created: {socket_path}"); } let vsockdata = if let Some(vhost_fd) = config.vhost_fd.take() { @@ -1034,6 +1125,7 @@ impl RunningQemu { qemu_process, virtiofsd_processes, sd_notification, + swtpm_process, }) } diff --git a/crates/integration-tests/src/tests/run_ephemeral_ssh.rs b/crates/integration-tests/src/tests/run_ephemeral_ssh.rs index e20329c16..fbfe3b4c9 100644 --- a/crates/integration-tests/src/tests/run_ephemeral_ssh.rs +++ b/crates/integration-tests/src/tests/run_ephemeral_ssh.rs @@ -286,6 +286,22 @@ echo "All checks passed!" } integration_test!(test_run_tmpfs); +/// Test that --tpm attaches a TPM 2.0 to the VM +fn test_run_ephemeral_tpm() -> TestResult { + let sh = shell()?; + let bck = get_bck_command()?; + let image = get_test_image(); + let label = INTEGRATION_TEST_LABEL; + + cmd!( + sh, + "{bck} ephemeral run-ssh --label {label} --tpm {image} -- test -c /dev/tpmrm0" + ) + .run()?; + Ok(()) +} +integration_test!(test_run_ephemeral_tpm); + /// Test that containers are properly cleaned up even when the image is broken /// /// This test verifies that the drop handler for ContainerCleanup works correctly diff --git a/crates/kit/src/run_ephemeral.rs b/crates/kit/src/run_ephemeral.rs index 4e408c963..f43724a12 100644 --- a/crates/kit/src/run_ephemeral.rs +++ b/crates/kit/src/run_ephemeral.rs @@ -133,6 +133,12 @@ const IGNITION_SERIAL_NAME: &str = "ignition"; /// Mount path for Ignition config inside the container const IGNITION_CONFIG_MOUNT_PATH: &str = "/run/ignition-config.json"; +/// The TPM state directory inside the container. +const SWTPM_STATE_DIR: &str = "/run/swtpm"; + +/// The swtpm control socket QEMU connects to. +const SWTPM_SOCKET: &str = "/run/inner-shared/swtpm.sock"; + /// Kernel initramfs magic marker for bootconfig trailer. const BOOTCONFIG_MAGIC: &[u8; 12] = b"#BOOTCONFIG\n"; @@ -546,6 +552,12 @@ pub struct RunEphemeralOpts { )] pub mount_disk_files: Vec, + #[clap( + long = "tpm", + help = "Enable TPM 2.0 support using swtpm, with state discarded on exit" + )] + pub tpm: bool, + #[clap(long = "karg", help = "Additional kernel command line arguments")] pub kernel_args: Vec, @@ -2132,6 +2144,13 @@ Options= None }; + if opts.tpm { + qemu_config.set_tpm(qemu::TpmConfig { + state_dir: SWTPM_STATE_DIR.into(), + socket_path: SWTPM_SOCKET.into(), + }); + } + // Add virtio-blk devices for blk_device in virtio_blk_devices { qemu_config.add_virtio_blk_device_with_format( diff --git a/crates/kit/src/to_disk.rs b/crates/kit/src/to_disk.rs index c160b88ab..3292ecfc8 100644 --- a/crates/kit/src/to_disk.rs +++ b/crates/kit/src/to_disk.rs @@ -573,6 +573,7 @@ pub fn run(mut opts: ToDiskOpts) -> Result { opts.target_disk, opts.additional.format.as_str() )], // Attach target disk + tpm: false, kernel_args: vec!["systemd.unit=bcvk-to-disk.target".to_string()], ignition_config: None, debug_entrypoint: None, diff --git a/crates/kit/src/varlink_ipc.rs b/crates/kit/src/varlink_ipc.rs index c1194fd4b..417632ddb 100644 --- a/crates/kit/src/varlink_ipc.rs +++ b/crates/kit/src/varlink_ipc.rs @@ -274,6 +274,7 @@ impl BcvkService { bind_storage_ro: false, add_swap: opts.add_swap, mount_disk_files: opts.mount_disk_files.unwrap_or_default(), + tpm: false, kernel_args: opts.kargs.unwrap_or_default(), ignition_config: None, host_dns_servers: None, diff --git a/docs/src/man/bcvk-ephemeral-run-ssh.md b/docs/src/man/bcvk-ephemeral-run-ssh.md index 51680ae6d..7470744da 100644 --- a/docs/src/man/bcvk-ephemeral-run-ssh.md +++ b/docs/src/man/bcvk-ephemeral-run-ssh.md @@ -160,6 +160,10 @@ For longer-running VMs where you need to reconnect multiple times, use Mount disk file as virtio-blk device at /dev/disk/by-id/virtio- +**--tpm** + + Enable TPM 2.0 support using swtpm, with state discarded on exit + **--karg**=*KERNEL_ARGS* Additional kernel command line arguments diff --git a/docs/src/man/bcvk-ephemeral-run.md b/docs/src/man/bcvk-ephemeral-run.md index 5b97e7877..a59f723db 100644 --- a/docs/src/man/bcvk-ephemeral-run.md +++ b/docs/src/man/bcvk-ephemeral-run.md @@ -163,6 +163,10 @@ This design allows bcvk to provide VM-like isolation and boot behavior while lev Mount disk file as virtio-blk device at /dev/disk/by-id/virtio- +**--tpm** + + Enable TPM 2.0 support using swtpm, with state discarded on exit + **--karg**=*KERNEL_ARGS* Additional kernel command line arguments diff --git a/docs/src/man/bcvk-ephemeral-test-basic.md b/docs/src/man/bcvk-ephemeral-test-basic.md index 235e28b40..7284391c2 100644 --- a/docs/src/man/bcvk-ephemeral-test-basic.md +++ b/docs/src/man/bcvk-ephemeral-test-basic.md @@ -161,6 +161,10 @@ and print an error on stderr. Mount disk file as virtio-blk device at /dev/disk/by-id/virtio- +**--tpm** + + Enable TPM 2.0 support using swtpm, with state discarded on exit + **--karg**=*KERNEL_ARGS* Additional kernel command line arguments