Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions crates/bcvk-qemu/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@

mod credentials;
mod qemu;
pub mod swtpm;
mod virtiofsd;

pub use credentials::{
Expand All @@ -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};
44 changes: 44 additions & 0 deletions crates/bcvk-qemu/src/qemu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<crate::swtpm::SwtpmConfig>,
}

impl QemuConfig {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -804,6 +827,8 @@ pub struct RunningQemu {
pub virtiofsd_processes: Vec<Pin<Box<dyn Future<Output = std::io::Result<Output>>>>>,
#[allow(dead_code)]
sd_notification: Option<VsockCopier>,
/// swtpm process backing the emulated TPM; killed when the VM stops.
swtpm_process: Option<Child>,
}

impl std::fmt::Debug for RunningQemu {
Expand Down Expand Up @@ -971,19 +996,38 @@ 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)?;

Ok(Self {
qemu_process,
virtiofsd_processes,
sd_notification,
swtpm_process,
})
}

/// Wait for QEMU process to exit.
pub async fn wait(&mut self) -> Result<std::process::ExitStatus> {
let r = self.qemu_process.wait()?;
if let Some(mut swtpm) = self.swtpm_process.take() {
let _ = swtpm.kill();
let _ = swtpm.wait();
}
Ok(r)
}
}
Expand Down
181 changes: 181 additions & 0 deletions crates/bcvk-qemu/src/swtpm.rs
Original file line number Diff line number Diff line change
@@ -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<Self> {
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<Self> {
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<String> {
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=")));
}
}
20 changes: 20 additions & 0 deletions crates/kit/src/run_ephemeral.rs
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,16 @@ pub struct CommonVmOpts {
help = "Path to virtiofsd binary (overrides auto-detection)"
)]
pub virtiofsd_binary: Option<String>,

/// 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<String>,
}

impl CommonVmOpts {
Expand Down Expand Up @@ -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");
Expand Down
30 changes: 30 additions & 0 deletions docs/swtpm-tpm2.md
Original file line number Diff line number Diff line change
@@ -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.