Skip to content
Open
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
6 changes: 6 additions & 0 deletions crates/bcvk-qemu/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@

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

pub use credentials::{
Expand All @@ -57,4 +59,8 @@ pub use qemu::{
RunningQemu, VirtioBlkDevice, VirtioSerialOut, VirtiofsMount, VHOST_VSOCK,
};

pub use swtpm::SwtpmConfig;

pub use swu2f::Swu2fConfig;

pub use virtiofsd::{spawn_virtiofsd_async, validate_virtiofsd_config, VirtiofsConfig};
80 changes: 79 additions & 1 deletion crates/bcvk-qemu/src/qemu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,10 @@ pub struct QemuConfig {

/// fw_cfg entries for passing config files to the guest
fw_cfg_entries: Vec<(String, Utf8PathBuf)>,

/// Optional software TPM (swtpm) backing the QEMU emulator TPM device.
/// Test-only vTPM so `/dev/tpm0` is present in CI VMs without hardware.
pub swtpm: Option<crate::swtpm::SwtpmConfig>,
}

impl QemuConfig {
Expand Down Expand Up @@ -291,6 +295,15 @@ impl QemuConfig {
self
}

/// Enable a software TPM (swtpm) backed emulator TPM device.
///
/// `socket_path` is the swtpm control socket QEMU connects to; `state_dir`
/// holds the (ephemeral) TPM NVRAM. Test-only; see yubi-OS/bcvk#3.
pub fn enable_swtpm(&mut self, socket_path: String, state_dir: String) -> &mut Self {
self.swtpm = Some(crate::swtpm::SwtpmConfig { socket_path, state_dir });
self
}

/// Validate configuration before VM creation.
pub fn validate(&self) -> Result<()> {
// Memory validation
Expand Down Expand Up @@ -650,6 +663,14 @@ fn spawn(
]);
}

// Software TPM (swtpm) emulator backend: chardev -> tpmdev -> tpm device.
// The guest kernel tpm_tis/tpm_crb driver creates /dev/tpm0 from this device.
if let Some(swtpm) = config.swtpm.as_ref() {
for arg in crate::swtpm::qemu_tpm_args(&swtpm.socket_path, std::env::consts::ARCH) {
cmd.arg(arg);
}
}

// Add virtio-serial controller - always needed for console
cmd.args(["-device", "virtio-serial"]);

Expand Down Expand Up @@ -781,6 +802,49 @@ fn spawn(
cmd.spawn().context("Failed to spawn QEMU")
}

/// Spawn a `swtpm socket` process providing a software TPM 2.0 over a UNIX
/// control socket, then wait for the socket to appear so QEMU can connect.
async fn spawn_swtpm(config: &crate::swtpm::SwtpmConfig) -> Result<Child> {
std::fs::create_dir_all(&config.state_dir)
.with_context(|| format!("creating swtpm state dir {}", config.state_dir))?;

let (program, args) = crate::swtpm::swtpm_socket_command(config);
let mut cmd = Command::new(&program);
cmd.args(&args);
// SAFETY: This API is safe to call in a forked child. Ties swtpm lifetime to ours.
#[allow(unsafe_code)]
unsafe {
cmd.pre_exec(|| {
rustix::process::set_parent_process_death_signal(Some(rustix::process::Signal::TERM))
.map_err(Into::into)
});
}
cmd.stdout(Stdio::null()).stderr(Stdio::inherit());
tracing::debug!("Spawning swtpm: {program} {args:?}");
let child = cmd.spawn().context("Failed to spawn swtpm")?;

let socket_path = Utf8Path::new(&config.socket_path).to_owned();
let wait = async {
loop {
if socket_path.exists() {
break;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
};
tokio::pin!(wait);
let timeout = tokio::time::sleep(Duration::from_secs(10));
tokio::pin!(timeout);
tokio::select! {
_ = &mut wait => {}
_ = &mut timeout => {
return Err(eyre!("timed out waiting for swtpm socket {}", config.socket_path));
}
}
tracing::debug!("swtpm socket ready: {}", config.socket_path);
Ok(child)
}

struct VsockCopier {
port: VsockAddr,
#[allow(dead_code)]
Expand All @@ -804,6 +868,9 @@ pub struct RunningQemu {
pub virtiofsd_processes: Vec<Pin<Box<dyn Future<Output = std::io::Result<Output>>>>>,
#[allow(dead_code)]
sd_notification: Option<VsockCopier>,
/// Host swtpm process backing the emulator TPM, kept alive for the VM lifetime.
#[allow(dead_code)]
swtpm_process: Option<Child>,
}

impl std::fmt::Debug for RunningQemu {
Expand Down Expand Up @@ -856,9 +923,12 @@ impl RunningQemu {
tracing::trace!("virtiofsd exited");
let output = output?;
let status = output.status;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let log_path = format!("{socket_path}.log");
let log_tail = std::fs::read_to_string(&log_path).unwrap_or_default();
return Err(eyre!(
"virtiofsd failed to start for socket {socket_path}\nExit status: {status:?}\nOutput: {stderr}"
"virtiofsd failed to start for socket {socket_path}\nExit status: {status:?}\nStdout: {stdout}\nStderr: {stderr}\nLog file ({log_path}):\n{log_tail}"
));
}
_ = timeout => {
Expand Down Expand Up @@ -971,13 +1041,21 @@ impl RunningQemu {
})
.unwrap_or_default();

// Start the swtpm socket process (if configured) before QEMU connects.
let swtpm_process = if let Some(swtpm) = config.swtpm.clone() {
Some(spawn_swtpm(&swtpm).await?)
} 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,
})
}

Expand Down
106 changes: 106 additions & 0 deletions crates/bcvk-qemu/src/swtpm.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
//! Software TPM (swtpm) configuration for the QEMU emulator TPM backend.
//!
//! Provides a test-only virtual TPM 2.0 for bcvk ephemeral VMs so that
//! `/dev/tpm0` is present and TPM2 code paths (PCR measurement, LUKS2 PCR
//! binding) can be exercised in CI without physical hardware.
//!
//! yubiOS production trust anchor remains the YubiKey FIDO2 device; swtpm is
//! strictly for test coverage (see yubi-OS/bcvk#3, yubiOS ADR-016).
//!
//! This module is pure: it only builds argument vectors. Process spawning and
//! socket I/O live in `qemu.rs` (`spawn_swtpm`), per the bcvk REVIEW.md rule of
//! splitting parsers/builders from I/O.

/// Configuration for a software TPM backed by `swtpm socket`.
#[derive(Debug, Clone)]
pub struct SwtpmConfig {
/// Path to the swtpm UNIX control socket QEMU connects to.
pub socket_path: String,
/// Directory holding swtpm TPM state (NVRAM).
pub state_dir: String,
}

/// Build the `swtpm socket` command (program + args) for a TPM 2.0 emulator.
///
/// QEMU connects to the control socket via `-chardev socket,path=<socket>`.
pub fn swtpm_socket_command(config: &SwtpmConfig) -> (String, Vec<String>) {
let args = vec![
"socket".to_string(),
"--tpm2".to_string(),
"--tpmstate".to_string(),
format!("dir={}", config.state_dir),
"--ctrl".to_string(),
format!("type=unixio,path={}", config.socket_path),
"--flags".to_string(),
"startup-clear".to_string(),
];
("swtpm".to_string(), args)
}

/// Build the QEMU arguments wiring the swtpm emulator backend into the VM.
///
/// Returns a flat list of alternating flag/value arguments:
/// `-chardev socket,... -tpmdev emulator,... -device <model>,tpmdev=tpm0`.
///
/// The TPM device model is architecture-dependent:
/// - x86_64 (and default): `tpm-crb` (TPM2-only CRB interface on q35)
/// - aarch64: `tpm-tis-device` (MMIO TIS on the `virt` machine; CRB is x86-only)
pub fn qemu_tpm_args(socket_path: &str, arch: &str) -> Vec<String> {
let device_model = match arch {
"aarch64" => "tpm-tis-device",
_ => "tpm-crb",
};
vec![
"-chardev".to_string(),
format!("socket,id=chrtpm,path={socket_path}"),
"-tpmdev".to_string(),
"emulator,id=tpm0,chardev=chrtpm".to_string(),
"-device".to_string(),
format!("{device_model},tpmdev=tpm0"),
]
}

#[cfg(test)]
mod tests {
use super::*;

fn cfg() -> SwtpmConfig {
SwtpmConfig {
socket_path: "/run/inner-shared/swtpm.sock".to_string(),
state_dir: "/run/swtpm-state".to_string(),
}
}

#[test]
fn test_swtpm_socket_command() {
let (program, args) = swtpm_socket_command(&cfg());
assert_eq!(program, "swtpm");
assert_eq!(args[0], "socket");
assert!(args.contains(&"--tpm2".to_string()));
assert!(args.contains(&"type=unixio,path=/run/inner-shared/swtpm.sock".to_string()));
assert!(args.contains(&"dir=/run/swtpm-state".to_string()));
}

#[test]
fn test_qemu_tpm_args_device_model_per_arch() {
let cases = [
("x86_64", "tpm-crb,tpmdev=tpm0"),
("aarch64", "tpm-tis-device,tpmdev=tpm0"),
("powerpc64", "tpm-crb,tpmdev=tpm0"),
];
for (arch, expected_device) in cases {
let args = qemu_tpm_args("/tmp/swtpm.sock", arch);
assert_eq!(args[0], "-chardev");
assert_eq!(args[1], "socket,id=chrtpm,path=/tmp/swtpm.sock");
assert_eq!(args[2], "-tpmdev");
assert_eq!(args[3], "emulator,id=tpm0,chardev=chrtpm");
assert_eq!(args[4], "-device");
assert_eq!(args[5], expected_device);
}
}

#[test]
fn test_qemu_tpm_args_arg_count() {
assert_eq!(qemu_tpm_args("/x.sock", "x86_64").len(), 6);
}
}
108 changes: 108 additions & 0 deletions crates/bcvk-qemu/src/swu2f.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
//! Software FIDO2/U2F authenticator (swu2f) wiring for ephemeral CI VMs.
//!
//! Unlike [`crate::swtpm`], a software FIDO2 token cannot be provided by QEMU:
//! QEMU only ships `u2f-emulated`, which speaks CTAP1/U2F and lacks the CTAP2
//! `hmac-secret` extension that `systemd-cryptenroll --fido2-device` and LUKS2
//! FIDO2 unlock require. The authenticator therefore runs *inside* the guest as
//! a userspace daemon (e.g. `fidorium` or `passless`) that registers a virtual
//! FIDO HID device through `/dev/uhid`. libfido2 (`fido2-token -L`),
//! `systemd-cryptenroll`, and `pam-u2f` then see it as a real token.
//!
//! bcvk's only responsibility on the host side is to make sure the guest kernel
//! has the `uhid` module available early via a `modules-load=` karg. The
//! authenticator binary itself must be present in the test image (image side,
//! analogous to shipping `swtpm`/`swtpm-tools` for [`crate::swtpm`]).
//!
//! Test-only: yubiOS production trust stays on the physical YubiKey FIDO2 device
//! (ADR-003). swu2f exists purely to cover enrollment / PAM code paths in CI
//! without hardware. See `docs/swu2f.md` and yubiOS issue #25.

/// Feature name used in user-facing flags / docs.
pub const SWU2F_FEATURE: &str = "fido2-swu2f";

/// Kernel module that backs userspace HID devices (`/dev/uhid`).
pub const UHID_MODULE: &str = "uhid";

/// Device node a uhid-based authenticator opens to register itself.
pub const UHID_DEVICE_PATH: &str = "/dev/uhid";

/// The `modules-load=` kernel argument that ensures `uhid` is loaded.
pub fn uhid_karg() -> String {
format!("modules-load={UHID_MODULE}")
}

/// Append the `uhid` `modules-load=` karg to a kernel cmdline if not already present.
///
/// Idempotent: a second call with the same cmdline is a no-op.
pub fn push_uhid_kargs(cmdline: &mut Vec<String>) {
let karg = uhid_karg();
if !cmdline.iter().any(|a| a == &karg) {
cmdline.push(karg);
}
}

/// Configuration for the in-guest software FIDO2/U2F authenticator.
///
/// Currently this only records which authenticator binary the test image is
/// expected to provide; the daemon lifecycle is owned by the guest (a systemd
/// unit in the image), not by bcvk on the host.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Swu2fConfig {
/// Name of the userspace authenticator binary shipped in the test image.
pub authenticator: String,
}

impl Default for Swu2fConfig {
fn default() -> Self {
Self { authenticator: "fidorium".to_string() }
}
}

impl Swu2fConfig {
/// Build a config naming a specific authenticator binary.
pub fn with_authenticator(authenticator: impl Into<String>) -> Self {
Self { authenticator: authenticator.into() }
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn uhid_karg_is_modules_load() {
assert_eq!(uhid_karg(), "modules-load=uhid");
}

#[test]
fn push_uhid_kargs_appends_once() {
let mut c = vec!["selinux=0".to_string()];
push_uhid_kargs(&mut c);
push_uhid_kargs(&mut c);
assert_eq!(c.iter().filter(|a| a.as_str() == "modules-load=uhid").count(), 1);
assert!(c.contains(&"selinux=0".to_string()));
}

#[test]
fn push_uhid_kargs_preserves_order_tail() {
let mut c = vec!["a".to_string(), "b".to_string()];
push_uhid_kargs(&mut c);
assert_eq!(c.last().unwrap(), "modules-load=uhid");
}

#[test]
fn default_authenticator() {
assert_eq!(Swu2fConfig::default().authenticator, "fidorium");
}

#[test]
fn with_authenticator_overrides() {
assert_eq!(Swu2fConfig::with_authenticator("passless").authenticator, "passless");
}

#[test]
fn device_path_is_uhid() {
assert_eq!(UHID_DEVICE_PATH, "/dev/uhid");
assert_eq!(SWU2F_FEATURE, "fido2-swu2f");
}
}
Loading