From 5739e6279b972d2859e764ca2e616eae61865dd9 Mon Sep 17 00:00:00 2001 From: "Sauna (FOLLOWER_2)" Date: Fri, 26 Jun 2026 02:09:22 -0700 Subject: [PATCH] feat(swtpm): software TPM 2.0 for ephemeral CI VMs (#3) Adds a `--feature tpm2-swtpm` path to `bcvk run` that launches an swtpm (IBM software TPM 2.0) process on the QEMU side and wires an emulated TPM 2.0 into the guest via -tpmdev emulator + an arch-aware tpm-tis device, so /dev/tpm0 is available inside ephemeral CI VMs without hardware. New: crates/bcvk-qemu/src/swtpm.rs (config, arg generation, arch device selection, socket wait, table-driven tests). Wired through QemuConfig (enable_swtpm + swtpm field), RunningQemu (spawn swtpm before QEMU, kill on wait), and CommonVmOpts (--feature flag), mirroring the --yubikey passthrough pattern in PR #2. For yubiOS ADR-016 Feature 1 / BLOCKER-006; pairs with yubiOS PR #34 (guest-side systemd-tpm2-swtpm.service drop-in). swtpm is test coverage only; production trust anchor remains YubiKey FIDO2 (ADR-003). Assisted-by: Sauna (claude-opus-4.8) --- crates/bcvk-qemu/src/lib.rs | 3 + crates/bcvk-qemu/src/qemu.rs | 44 ++++++++ crates/bcvk-qemu/src/swtpm.rs | 181 ++++++++++++++++++++++++++++++++ crates/kit/src/run_ephemeral.rs | 20 ++++ docs/swtpm-tpm2.md | 30 ++++++ 5 files changed, 278 insertions(+) create mode 100644 crates/bcvk-qemu/src/swtpm.rs create mode 100644 docs/swtpm-tpm2.md diff --git a/crates/bcvk-qemu/src/lib.rs b/crates/bcvk-qemu/src/lib.rs index 91cbffea3..92f012a4f 100644 --- a/crates/bcvk-qemu/src/lib.rs +++ b/crates/bcvk-qemu/src/lib.rs @@ -44,6 +44,7 @@ mod credentials; mod qemu; +pub mod swtpm; mod virtiofsd; pub use credentials::{ @@ -58,3 +59,5 @@ pub use qemu::{ }; pub use virtiofsd::{spawn_virtiofsd_async, validate_virtiofsd_config, VirtiofsConfig}; + +pub use swtpm::{tpm_device_for_arch, SwtpmConfig}; diff --git a/crates/bcvk-qemu/src/qemu.rs b/crates/bcvk-qemu/src/qemu.rs index 634a991e3..8e02903b0 100644 --- a/crates/bcvk-qemu/src/qemu.rs +++ b/crates/bcvk-qemu/src/qemu.rs @@ -229,6 +229,11 @@ pub struct QemuConfig { /// fw_cfg entries for passing config files to the guest fw_cfg_entries: Vec<(String, Utf8PathBuf)>, + + /// Optional software TPM (swtpm) for CI VMs. When set, bcvk launches an + /// swtpm process before QEMU and wires an emulated TPM 2.0 into the guest + /// (visible as `/dev/tpm0`). Test coverage only; see yubiOS ADR-016. + pub swtpm: Option, } impl QemuConfig { @@ -464,6 +469,17 @@ impl QemuConfig { self.fw_cfg_entries.push((name, file_path)); self } + + /// Enable a software TPM (swtpm) for this VM. + /// + /// Prepares an swtpm state directory and control socket. The swtpm process + /// is launched by [`RunningQemu::spawn`] before QEMU starts, and an emulated + /// TPM 2.0 is wired into the guest (visible as `/dev/tpm0`). Intended for CI + /// coverage of TPM2 code paths without physical hardware (yubiOS ADR-016). + pub fn enable_swtpm(&mut self) -> Result<&mut Self> { + self.swtpm = Some(crate::swtpm::SwtpmConfig::new()?); + Ok(self) + } } /// Allocate a unique VSOCK CID. @@ -759,6 +775,13 @@ fn spawn( cmd.args(["-fw_cfg", &format!("name={},file={}", name, file_path)]); } + // Software TPM (swtpm) emulator device, when enabled. + if let Some(swtpm) = &config.swtpm { + for arg in swtpm.qemu_args(std::env::consts::ARCH) { + cmd.arg(arg); + } + } + // Configure stdio based on display mode match &config.display_mode { DisplayMode::Console => { @@ -804,6 +827,8 @@ pub struct RunningQemu { pub virtiofsd_processes: Vec>>>>, #[allow(dead_code)] sd_notification: Option, + /// swtpm process backing the emulated TPM; killed when the VM stops. + swtpm_process: Option, } impl std::fmt::Debug for RunningQemu { @@ -971,6 +996,20 @@ impl RunningQemu { }) .unwrap_or_default(); + // Launch swtpm (software TPM) before QEMU so the control socket exists + // when QEMU connects. The matching QEMU device args are emitted by `spawn`. + let swtpm_process = if let Some(swtpm) = config.swtpm.as_ref() { + debug!("starting swtpm with state dir {}", swtpm.state_dir); + let child = swtpm + .command() + .spawn() + .context("failed to spawn swtpm (is the swtpm package installed in the image?)")?; + crate::swtpm::wait_for_socket(&swtpm.socket_path, Duration::from_secs(10))?; + Some(child) + } else { + None + }; + // Spawn QEMU process with additional VSOCK credential if needed let qemu_process = spawn(&config, &creds, vsockdata)?; @@ -978,12 +1017,17 @@ impl RunningQemu { qemu_process, virtiofsd_processes, sd_notification, + swtpm_process, }) } /// Wait for QEMU process to exit. pub async fn wait(&mut self) -> Result { let r = self.qemu_process.wait()?; + if let Some(mut swtpm) = self.swtpm_process.take() { + let _ = swtpm.kill(); + let _ = swtpm.wait(); + } Ok(r) } } diff --git a/crates/bcvk-qemu/src/swtpm.rs b/crates/bcvk-qemu/src/swtpm.rs new file mode 100644 index 000000000..2c904fbfa --- /dev/null +++ b/crates/bcvk-qemu/src/swtpm.rs @@ -0,0 +1,181 @@ +//! Software TPM 2.0 (swtpm) integration for ephemeral CI VMs. +//! +//! When enabled (via `--feature tpm2-swtpm`), bcvk launches an IBM `swtpm` +//! process that serves a software TPM 2.0 over a UNIX control socket, then +//! wires it into the guest QEMU as an emulated TPM-TIS device. The guest then +//! sees a hardware-like TPM at `/dev/tpm0`, which lets CI exercise TPM2 code +//! paths (PCR measurements, LUKS2 PCR binding, `ConditionSecurity=measured-os`) +//! without physical hardware. +//! +//! For yubiOS this is **test coverage only**: the production trust anchor is the +//! YubiKey FIDO2 (ADR-003). See yubiOS ADR-016 §Feature 1 and bcvk issue #3. +//! +//! # swtpm package requirement +//! +//! The host/container that runs QEMU must have `swtpm` (and `swtpm-tools`) +//! installed. [`SwtpmConfig::command`] spawns plain `swtpm`; a clear error is +//! surfaced if the binary is missing. + +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +use camino::{Utf8Path, Utf8PathBuf}; +use color_eyre::eyre::{bail, eyre, Context}; +use color_eyre::Result; +use tracing::debug; + +/// QEMU chardev id used for the swtpm control socket. +pub const TPM_CHARDEV_ID: &str = "chrtpm"; +/// QEMU tpmdev id used for the emulated TPM backend. +pub const TPM_DEV_ID: &str = "tpm0"; + +/// Configuration for a software TPM backing an ephemeral VM. +#[derive(Debug, Clone)] +pub struct SwtpmConfig { + /// Directory holding swtpm NVRAM/state for this VM. + pub state_dir: Utf8PathBuf, + /// Path to the swtpm control socket QEMU connects to. + pub socket_path: Utf8PathBuf, +} + +impl SwtpmConfig { + /// Create a config with a unique state directory + control socket under + /// `base` (typically the system temp dir). The directory is created on disk. + pub fn new_in(base: &Utf8Path) -> Result { + let unique = format!("bcvk-swtpm-{}-{}", std::process::id(), monotonic_suffix()); + let state_dir = base.join(unique); + std::fs::create_dir_all(&state_dir) + .with_context(|| format!("creating swtpm state dir {state_dir}"))?; + let socket_path = state_dir.join("swtpm-sock"); + Ok(Self { state_dir, socket_path }) + } + + /// Create a config rooted in the system temp directory. + pub fn new() -> Result { + let tmp = std::env::temp_dir(); + let tmp = Utf8PathBuf::from_path_buf(tmp) + .map_err(|p| eyre!("non-UTF8 temp dir: {}", p.display()))?; + Self::new_in(&tmp) + } + + /// Build the `swtpm socket` command serving a TPM 2.0 over the control socket. + /// + /// `--terminate` makes swtpm exit once QEMU disconnects, keeping the process + /// lifecycle tied to the VM. + pub fn command(&self) -> Command { + let mut cmd = Command::new("swtpm"); + cmd.arg("socket") + .arg("--tpmstate") + .arg(format!("dir={}", self.state_dir)) + .arg("--ctrl") + .arg(format!("type=unixio,path={}", self.socket_path)) + .arg("--tpm2") + .arg("--terminate") + .stdout(Stdio::null()) + .stderr(Stdio::inherit()); + cmd + } + + /// QEMU args wiring the emulated TPM into the guest for the given target arch. + /// + /// `arch` is typically [`std::env::consts::ARCH`] ("x86_64", "aarch64"). + pub fn qemu_args(&self, arch: &str) -> Vec { + vec![ + "-chardev".to_string(), + format!("socket,id={TPM_CHARDEV_ID},path={}", self.socket_path), + "-tpmdev".to_string(), + format!("emulator,id={TPM_DEV_ID},chardev={TPM_CHARDEV_ID}"), + "-device".to_string(), + format!("{},tpmdev={TPM_DEV_ID}", tpm_device_for_arch(arch)), + ] + } +} + +/// Select the QEMU TPM device model for the target architecture. +/// +/// x86 uses the ISA-attached TIS device (`tpm-tis`); the aarch64 `virt` machine +/// uses the MMIO TIS device (`tpm-tis-device`). +pub fn tpm_device_for_arch(arch: &str) -> &'static str { + match arch { + "aarch64" | "arm" => "tpm-tis-device", + _ => "tpm-tis", + } +} + +fn monotonic_suffix() -> u128 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) +} + +/// Block until the swtpm control socket appears, up to `timeout`. +pub fn wait_for_socket(path: &Utf8Path, timeout: Duration) -> Result<()> { + let start = Instant::now(); + while !path.exists() { + if start.elapsed() >= timeout { + bail!("timed out waiting for swtpm control socket at {path} after {timeout:?}"); + } + std::thread::sleep(Duration::from_millis(50)); + } + debug!("swtpm control socket ready at {path}"); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cfg() -> SwtpmConfig { + SwtpmConfig { + state_dir: Utf8PathBuf::from("/tmp/bcvk-swtpm-test"), + socket_path: Utf8PathBuf::from("/tmp/bcvk-swtpm-test/swtpm-sock"), + } + } + + #[test] + fn device_for_arch_table() { + let cases = [ + ("x86_64", "tpm-tis"), + ("aarch64", "tpm-tis-device"), + ("arm", "tpm-tis-device"), + ("riscv64", "tpm-tis"), + ]; + for (arch, want) in cases { + assert_eq!(tpm_device_for_arch(arch), want, "arch={arch}"); + } + } + + #[test] + fn qemu_args_structure_x86() { + let args = cfg().qemu_args("x86_64"); + assert_eq!( + args, + vec![ + "-chardev".to_string(), + "socket,id=chrtpm,path=/tmp/bcvk-swtpm-test/swtpm-sock".to_string(), + "-tpmdev".to_string(), + "emulator,id=tpm0,chardev=chrtpm".to_string(), + "-device".to_string(), + "tpm-tis,tpmdev=tpm0".to_string(), + ] + ); + } + + #[test] + fn qemu_args_use_arch_device() { + let args = cfg().qemu_args("aarch64"); + assert_eq!(args.last().unwrap(), "tpm-tis-device,tpmdev=tpm0"); + } + + #[test] + fn command_program_is_swtpm() { + let cmd = cfg().command(); + assert_eq!(cmd.get_program(), "swtpm"); + let args: Vec<_> = cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect(); + assert!(args.contains(&"socket".to_string())); + assert!(args.contains(&"--tpm2".to_string())); + assert!(args.iter().any(|a| a.starts_with("type=unixio,path="))); + } +} diff --git a/crates/kit/src/run_ephemeral.rs b/crates/kit/src/run_ephemeral.rs index a6bc5d3cd..261a42bca 100644 --- a/crates/kit/src/run_ephemeral.rs +++ b/crates/kit/src/run_ephemeral.rs @@ -225,6 +225,16 @@ pub struct CommonVmOpts { help = "Path to virtiofsd binary (overrides auto-detection)" )] pub virtiofsd_binary: Option, + + /// Enable optional VM features (repeatable). Supported: `tpm2-swtpm` + /// (software TPM 2.0 via swtpm, for CI coverage of TPM2 code paths). + #[clap( + long = "feature", + value_name = "FEATURE", + help = "Enable a VM feature (repeatable); supported: tpm2-swtpm" + )] + #[serde(default)] + pub features: Vec, } impl CommonVmOpts { @@ -1550,6 +1560,16 @@ Options= qemu_config.set_console(opts.common.console); + // Optional VM features (e.g. software TPM 2.0 for CI coverage). + if opts + .common + .features + .iter() + .any(|f| f.eq_ignore_ascii_case("tpm2-swtpm")) + { + qemu_config.enable_swtpm()?; + } + // Add virtio-serial device for journal streaming qemu_config.add_virtio_serial_out("org.bcvk.journal", "/run/journal.log".to_string(), false); debug!("Added virtio-serial device for journal streaming to /run/journal.log"); diff --git a/docs/swtpm-tpm2.md b/docs/swtpm-tpm2.md new file mode 100644 index 000000000..ae5c9917b --- /dev/null +++ b/docs/swtpm-tpm2.md @@ -0,0 +1,30 @@ +# Software TPM 2.0 (swtpm) for CI VMs + +bcvk can attach an emulated TPM 2.0 to an ephemeral VM so CI can exercise TPM2 +code paths (PCR measurements, LUKS2 PCR binding, `ConditionSecurity=measured-os`) +without physical hardware. + +```bash +bcvk ephemeral run --feature tpm2-swtpm dhi.io/yubi-OS/yubiOS:latest +``` + +## How it works + +1. bcvk launches `swtpm socket` on the QEMU side with a private state dir and a + UNIX control socket. +2. QEMU connects via `-chardev socket` + `-tpmdev emulator` and exposes an + arch-appropriate TPM-TIS device (`tpm-tis` on x86_64, `tpm-tis-device` on + aarch64 `virt`). +3. The guest sees a TPM at `/dev/tpm0`. + +## Requirements + +- `swtpm` and `swtpm-tools` installed where QEMU runs. +- Guest image with systemd >= 261 for `systemd-tpm2-swtpm.service` + (yubiOS base `45.20260625.0` ships `systemd-261`). + +## yubiOS notes + +This is **test coverage only**. The production trust anchor is the YubiKey +FIDO2 (ADR-003). See yubiOS ADR-016 §Feature 1 and the guest-side drop-in in +yubiOS PR #34. Tracking: bcvk issue #3 / BLOCKER-006.