From 8896f1e6fc3b4c2ed73bf5bb52a5f641a13ebdd3 Mon Sep 17 00:00:00 2001 From: foil-copy-overrate Date: Fri, 26 Jun 2026 02:12:35 -0700 Subject: [PATCH 1/9] feat(swtpm): software TPM 2.0 for ephemeral CI VMs (--swtpm) Attach a swtpm-backed QEMU emulator TPM so /dev/tpm0 is present in bcvk ephemeral VMs, enabling TPM2 test coverage (PCR, LUKS2 PCR binding, ConditionSecurity=measured-os) without physical hardware. Test-only; yubiOS production RoT stays YubiKey FIDO2. - crates/bcvk-qemu/src/swtpm.rs: pure builders (swtpm argv + QEMU tpm args, arch-aware tpm-crb/tpm-tis-device) + table-driven unit tests - qemu.rs: QemuConfig.swtpm field, enable_swtpm(), device emission in spawn(), host swtpm process spawn (spawn_swtpm) wired into RunningQemu - run_ephemeral.rs: --swtpm flag on CommonVmOpts - docs/swtpm.md: usage + rationale (QEMU emulator device vs guest systemd-tpm2-swtpm.service under direct boot) Refs: yubi-OS/bcvk#3 Assisted-by: Sauna (claude-sonnet-4-6) --- crates/bcvk-qemu/src/lib.rs | 3 + crates/bcvk-qemu/src/qemu.rs | 75 ++++++++++++++++++++++ crates/bcvk-qemu/src/swtpm.rs | 106 ++++++++++++++++++++++++++++++++ crates/kit/src/run_ephemeral.rs | 14 +++++ docs/swtpm.md | 60 ++++++++++++++++++ 5 files changed, 258 insertions(+) create mode 100644 crates/bcvk-qemu/src/swtpm.rs create mode 100644 docs/swtpm.md diff --git a/crates/bcvk-qemu/src/lib.rs b/crates/bcvk-qemu/src/lib.rs index 91cbffea3..62a36e6cf 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::{ @@ -57,4 +58,6 @@ pub use qemu::{ RunningQemu, VirtioBlkDevice, VirtioSerialOut, VirtiofsMount, VHOST_VSOCK, }; +pub use swtpm::SwtpmConfig; + 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 634a991e3..00ba55ed6 100644 --- a/crates/bcvk-qemu/src/qemu.rs +++ b/crates/bcvk-qemu/src/qemu.rs @@ -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, } impl QemuConfig { @@ -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 @@ -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"]); @@ -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 { + 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)] @@ -804,6 +868,9 @@ pub struct RunningQemu { pub virtiofsd_processes: Vec>>>>, #[allow(dead_code)] sd_notification: Option, + /// Host swtpm process backing the emulator TPM, kept alive for the VM lifetime. + #[allow(dead_code)] + swtpm_process: Option, } impl std::fmt::Debug for RunningQemu { @@ -971,6 +1038,13 @@ 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)?; @@ -978,6 +1052,7 @@ impl RunningQemu { qemu_process, virtiofsd_processes, sd_notification, + swtpm_process, }) } diff --git a/crates/bcvk-qemu/src/swtpm.rs b/crates/bcvk-qemu/src/swtpm.rs new file mode 100644 index 000000000..755bc3062 --- /dev/null +++ b/crates/bcvk-qemu/src/swtpm.rs @@ -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=`. +pub fn swtpm_socket_command(config: &SwtpmConfig) -> (String, Vec) { + 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 ,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 { + 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); + } +} diff --git a/crates/kit/src/run_ephemeral.rs b/crates/kit/src/run_ephemeral.rs index a6bc5d3cd..3f24cb531 100644 --- a/crates/kit/src/run_ephemeral.rs +++ b/crates/kit/src/run_ephemeral.rs @@ -225,6 +225,12 @@ pub struct CommonVmOpts { help = "Path to virtiofsd binary (overrides auto-detection)" )] pub virtiofsd_binary: Option, + + #[clap( + long = "swtpm", + help = "Attach a software TPM 2.0 (swtpm) so /dev/tpm0 is present in the VM (test-only; CI without hardware)" + )] + pub swtpm: bool, } impl CommonVmOpts { @@ -1402,6 +1408,14 @@ StandardOutput=file:/dev/virtio-ports/executestatus kernel_cmdline.extend(opts.kernel_args.clone()); qemu_config.set_kernel_cmdline(kernel_cmdline); + if opts.common.swtpm { + // swtpm control socket lives in the shared run dir; state in tmpfs. + qemu_config.enable_swtpm( + "/run/inner-shared/swtpm.sock".to_string(), + "/run/swtpm-state".to_string(), + ); + } + // Add Ignition config if specified // Different architectures require different methods (per FCOS docs): // - x86_64/aarch64: fw_cfg diff --git a/docs/swtpm.md b/docs/swtpm.md new file mode 100644 index 000000000..5c84029b5 --- /dev/null +++ b/docs/swtpm.md @@ -0,0 +1,60 @@ +# Software TPM (swtpm) for CI VMs + +bcvk can attach a software TPM 2.0 to an ephemeral VM so that `/dev/tpm0` is +present inside the guest. This lets yubiOS exercise TPM2 code paths (PCR +measurement, LUKS2 PCR binding, `ConditionSecurity=measured-os`) in CI without +physical hardware. + +> yubiOS production trust anchor remains the **YubiKey FIDO2** device +> (yubiOS ADR-003). swtpm is **test-only**. Tracks yubi-OS/bcvk#3. + +## Usage + +```bash +bcvk ephemeral run --swtpm quay.io/fedora/fedora-bootc:45 +``` + +Inside the VM: + +```bash +ls -l /dev/tpm0 /dev/tpmrm0 +systemd-analyze condition 'ConditionSecurity=measured-os' +``` + +## How it works + +bcvk runs QEMU inside a (privileged) podman container. With `--swtpm`, bcvk: + +1. starts a `swtpm socket --tpm2` process on the host side, exposing a UNIX + control socket; +2. adds the QEMU emulator TPM backend: + `-chardev socket,id=chrtpm,path=` + `-tpmdev emulator,id=tpm0,chardev=chrtpm` + `-device tpm-crb,tpmdev=tpm0` (x86_64) / `tpm-tis-device,tpmdev=tpm0` (aarch64); +3. the guest kernel `tpm_crb`/`tpm_tis` driver then enumerates the TPM and + creates `/dev/tpm0` automatically at boot — no guest service required. + +## Why the QEMU emulator device, not `systemd-tpm2-swtpm.service` + +yubiOS ADR-016 §Feature 1 describes the guest-side `systemd-tpm2-swtpm.service` +(systemd v261). That service is designed for **bare-metal hosts that lack a +TPM**: it is pulled in by `systemd-tpm2-generator` in the initrd, stores TPM +NVRAM on the **EFI System Partition**, and encrypts it with the systemd-stub +boot secret (`systemd-stub(7)`). + +bcvk ephemeral boot uses **direct kernel boot** (it extracts kernel+initrd from +the UKI and boots them via `-kernel`/`-initrd`), which deliberately breaks the +systemd-stub chain (see `BootMode::DirectBoot` in `bcvk-qemu`). There is no +stub boot secret and no mounted ESP, so the guest software-fallback path is not +reliable in this environment. + +Giving the VM a real (virtual) TPM via the QEMU emulator backend is the +deterministic way to get `/dev/tpm0` in a VM, and is what this feature does. + +## Runtime dependency + +The bcvk runner environment must provide the swtpm binaries: + +```bash +dnf install -y swtpm swtpm-tools # Fedora +``` From 66fbf130a5f22350b44a0f12dab2304c573302be Mon Sep 17 00:00:00 2001 From: foil-copy-overrate Date: Fri, 26 Jun 2026 02:16:29 -0700 Subject: [PATCH 2/9] feat(swu2f): in-guest software FIDO2/U2F authenticator for CI VMs Adds a --swu2f flag to bcvk ephemeral run. Because QEMU only emulates CTAP1 (u2f-emulated, no hmac-secret) and systemd-cryptenroll/LUKS2 FIDO2 unlock require CTAP2 hmac-secret, the authenticator runs inside the guest over /dev/uhid (e.g. fidorium/passless). bcvk only ensures the uhid module is loaded via modules-load=uhid; the authenticator binary ships in the test image (image side, like swtpm/swtpm-tools). New module crates/bcvk-qemu/src/swu2f.rs (Swu2fConfig + uhid karg builders, table-driven unit tests), wired through CommonVmOpts and the ephemeral run cmdline. Docs in docs/swu2f.md. For yubiOS issue #25 (post-launch software FIDO2 emulator). Test-only; production trust stays on the YubiKey FIDO2 device (ADR-003). Assisted-by: Sauna (claude-opus-4.8) --- crates/bcvk-qemu/src/lib.rs | 3 + crates/bcvk-qemu/src/swu2f.rs | 108 ++++++++++++++++++++++++++++++++ crates/kit/src/run_ephemeral.rs | 14 +++++ docs/swu2f.md | 54 ++++++++++++++++ 4 files changed, 179 insertions(+) create mode 100644 crates/bcvk-qemu/src/swu2f.rs create mode 100644 docs/swu2f.md diff --git a/crates/bcvk-qemu/src/lib.rs b/crates/bcvk-qemu/src/lib.rs index 62a36e6cf..858d5dccb 100644 --- a/crates/bcvk-qemu/src/lib.rs +++ b/crates/bcvk-qemu/src/lib.rs @@ -45,6 +45,7 @@ mod credentials; mod qemu; pub mod swtpm; +pub mod swu2f; mod virtiofsd; pub use credentials::{ @@ -60,4 +61,6 @@ pub use qemu::{ pub use swtpm::SwtpmConfig; +pub use swu2f::Swu2fConfig; + pub use virtiofsd::{spawn_virtiofsd_async, validate_virtiofsd_config, VirtiofsConfig}; diff --git a/crates/bcvk-qemu/src/swu2f.rs b/crates/bcvk-qemu/src/swu2f.rs new file mode 100644 index 000000000..c890fdb7e --- /dev/null +++ b/crates/bcvk-qemu/src/swu2f.rs @@ -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) { + 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) -> 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"); + } +} diff --git a/crates/kit/src/run_ephemeral.rs b/crates/kit/src/run_ephemeral.rs index 3f24cb531..5cdb8cc4c 100644 --- a/crates/kit/src/run_ephemeral.rs +++ b/crates/kit/src/run_ephemeral.rs @@ -231,6 +231,12 @@ pub struct CommonVmOpts { help = "Attach a software TPM 2.0 (swtpm) so /dev/tpm0 is present in the VM (test-only; CI without hardware)" )] pub swtpm: bool, + + #[clap( + long = "swu2f", + help = "Load the uhid kernel module so an in-guest software FIDO2/U2F authenticator can expose /dev/uhid (test-only; CI without a physical YubiKey)" + )] + pub swu2f: bool, } impl CommonVmOpts { @@ -1406,6 +1412,14 @@ StandardOutput=file:/dev/virtio-ports/executestatus } kernel_cmdline.extend(opts.kernel_args.clone()); + + // Software FIDO2/U2F (swu2f): QEMU cannot emulate a CTAP2 authenticator with the + // hmac-secret extension that systemd-cryptenroll requires, so the authenticator runs + // *inside* the guest over /dev/uhid. bcvk only ensures the uhid module is present. + if opts.common.swu2f { + bcvk_qemu::swu2f::push_uhid_kargs(&mut kernel_cmdline); + } + qemu_config.set_kernel_cmdline(kernel_cmdline); if opts.common.swtpm { diff --git a/docs/swu2f.md b/docs/swu2f.md new file mode 100644 index 000000000..ea0209aae --- /dev/null +++ b/docs/swu2f.md @@ -0,0 +1,54 @@ +# swu2f — in-guest software FIDO2/U2F for CI VMs + +For [yubiOS issue #25](https://github.com/yubi-OS/yubiOS/issues/25) (post-launch): +a software FIDO2/U2F authenticator so enrollment and PAM tests run without a +physical YubiKey. Pairs with the host-side `--swtpm` support on this branch. + +## Why in-guest (not a QEMU device) + +swtpm can be wired host-side because QEMU has a real TPM emulator backend +(`-tpmdev emulator`). FIDO2 has no equivalent: QEMU only offers `u2f-emulated`, +which speaks **CTAP1/U2F** and does **not** implement the CTAP2 `hmac-secret` +extension. `systemd-cryptenroll --fido2-device` and LUKS2 FIDO2 unlock *require* +`hmac-secret`, so a host-emulated U2F device is unusable for the enrollment path +we need to cover. + +The working approach is a userspace CTAP2 authenticator running **inside the +guest** that registers a virtual FIDO HID device via the kernel `uhid` +interface (`/dev/uhid`). libfido2 then sees it as a normal token: + +- `fido2-token -L` lists it +- `systemd-cryptenroll --fido2-device=auto ` enrolls against it +- `pam-u2f` authenticates against it + +Known implementations: [`fidorium`](https://github.com/grawity/fidorium) and +[`passless`](https://github.com/pando85/passless) (both `/dev/uhid` + CTAP2 + +`hmac-secret`). + +## Split of responsibilities + +| Layer | Owns | +|---|---| +| **bcvk** (`--swu2f`) | adds `modules-load=uhid` to the guest kernel cmdline so `/dev/uhid` exists early | +| **test image** | ships the authenticator binary + a systemd unit that starts it (image side, like `swtpm`/`swtpm-tools` for swtpm) | +| **udev** | `KERNEL=="uhid", GROUP="input", MODE="0660"` so the daemon can open the node | + +## Usage + +```bash +bcvk ephemeral run --swu2f dhi.io/yubi-OS/yubiOS:latest +``` + +The guest then exposes a software FIDO2 token; tests can enroll and authenticate. + +## Boot-time unlock caveat + +For unlocking the **root** LUKS2 volume the authenticator must run in the +initramfs (before the disk is decrypted). For CI we target **post-boot** +enrollment/auth against a scratch LUKS2 container, which avoids the initramfs +ordering hazard. Root-unlock-in-initramfs coverage is a separate, later step. + +## Scope + +Test-only. Production root of trust remains the physical YubiKey FIDO2 device +(ADR-003). swu2f exists solely for hardware-free CI coverage. From be5f3858c5f20abf6c51625bc42c6853231b1d67 Mon Sep 17 00:00:00 2001 From: foil-copy-overrate Date: Fri, 26 Jun 2026 02:17:21 -0700 Subject: [PATCH 3/9] feat(swu2f): software U2F/FIDO2 device for ephemeral CI VMs (--swu2f) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the swtpm split for FIDO2/U2F so yubiOS can test pam-u2f and systemd-cryptenroll FIDO2 paths without a physical YubiKey. - crates/bcvk-qemu/src/swu2f.rs: pure builder for the QEMU `u2f-emulated` (libu2f-emu) USB HID U2F/CTAP1 token (-usb -device u2f-emulated[,dir=]), ephemeral or setup-dir identity + table tests - lib.rs: expose `swu2f` module + re-export `Swu2fConfig` - docs/swu2f.md: spec. Layer 1 = QEMU u2f-emulated (CTAP1, covers pam-u2f); Layer 2 = in-guest /dev/uhid CTAP2 authenticator (FIDO2 hmac-secret) for systemd-cryptenroll --fido2, staged as a separate guest-image PR. Test-only; yubiOS production RoT stays YubiKey FIDO2 (ADR-003). Not compiled here — needs cargo build + nextest + human Signed-off-by. Refs: yubi-OS/yubiOS#25 Assisted-by: Sauna (FOLLOWER_2) --- crates/bcvk-qemu/src/swu2f.rs | 140 ++++++++++++++-------------------- docs/swu2f.md | 129 ++++++++++++++++++++++--------- 2 files changed, 148 insertions(+), 121 deletions(-) diff --git a/crates/bcvk-qemu/src/swu2f.rs b/crates/bcvk-qemu/src/swu2f.rs index c890fdb7e..fda77535b 100644 --- a/crates/bcvk-qemu/src/swu2f.rs +++ b/crates/bcvk-qemu/src/swu2f.rs @@ -1,68 +1,55 @@ -//! Software FIDO2/U2F authenticator (swu2f) wiring for ephemeral CI VMs. +//! Software U2F / FIDO2 device for bcvk ephemeral VMs (test-only). //! -//! 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. +//! Mirrors the `swtpm` module: a YubiKey-free way to exercise FIDO2/U2F code +//! paths in CI VMs without physical hardware. There are two distinct layers, +//! because the standards split the same way: //! -//! 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`]). +//! 1. **QEMU emulated U2F** (`-device u2f-emulated`, backed by libu2f-emu): a +//! fully software USB HID token implementing the **U2F (CTAP1)** protocol. +//! Host/QEMU-provided and deterministic, exactly like swtpm — the guest sees +//! a real USB HID security key with no in-guest service. This covers +//! `pam-u2f` login tests. It does NOT implement CTAP2/FIDO2 `hmac-secret`, +//! so it cannot drive `systemd-cryptenroll --fido2` (see docs/swu2f.md). //! -//! 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"; +//! 2. **In-guest uhid CTAP2 authenticator** (FIDO2 `hmac-secret`): a software +//! authenticator that creates a virtual HID FIDO2 device via `/dev/uhid` +//! inside the guest, for `systemd-cryptenroll --fido2` LUKS2 unlock tests. +//! That layer is guest-image work (a fixture + a software authenticator), +//! documented in docs/swu2f.md; it is out of scope for this QEMU-arg builder. +//! +//! yubiOS production trust anchor remains the **YubiKey FIDO2** device +//! (yubiOS ADR-003); swu2f is strictly for test coverage. Tracks +//! yubi-OS/yubiOS#25. +//! +//! This module is pure: it only builds argument vectors. Any process/IO lives +//! in `qemu.rs`, per the bcvk REVIEW.md rule of splitting builders from I/O. -/// The `modules-load=` kernel argument that ensures `uhid` is loaded. -pub fn uhid_karg() -> String { - format!("modules-load={UHID_MODULE}") +/// Configuration for the QEMU emulated U2F (CTAP1) USB token. +#[derive(Debug, Clone, Default)] +pub struct Swu2fConfig { + /// Optional libu2f-emu setup directory. When present it must contain + /// `certificate.pem`, `private-key.pem`, `counter`, and `entropy` (48 + /// bytes), giving the token a stable identity across runs. When `None`, + /// the device runs in libu2f-emu **ephemeral** mode and generates a + /// single-use identity for the lifetime of the VM. + pub setup_dir: Option, } -/// Append the `uhid` `modules-load=` karg to a kernel cmdline if not already present. +/// Build the QEMU arguments wiring an emulated USB U2F token into the VM. /// -/// Idempotent: a second call with the same cmdline is a no-op. -pub fn push_uhid_kargs(cmdline: &mut Vec) { - let karg = uhid_karg(); - if !cmdline.iter().any(|a| a == &karg) { - cmdline.push(karg); - } -} - -/// Configuration for the in-guest software FIDO2/U2F authenticator. +/// Matches the QEMU documented invocation: +/// - ephemeral: `-usb -device u2f-emulated` +/// - setup dir: `-usb -device u2f-emulated,dir=` /// -/// 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) -> Self { - Self { authenticator: authenticator.into() } +/// `-usb` attaches the machine's default USB controller so the HID token can +/// enumerate. The guest `u2f`/`hid-generic` driver then exposes it as a +/// `/dev/hidrawN` security key — usable by libfido2 and pam-u2f. +pub fn qemu_u2f_args(config: &Swu2fConfig) -> Vec { + let mut device = "u2f-emulated".to_string(); + if let Some(dir) = &config.setup_dir { + device.push_str(&format!(",dir={dir}")); } + vec!["-usb".to_string(), "-device".to_string(), device] } #[cfg(test)] @@ -70,39 +57,24 @@ 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"); + fn test_u2f_args_ephemeral() { + let args = qemu_u2f_args(&Swu2fConfig::default()); + assert_eq!(args, vec!["-usb", "-device", "u2f-emulated"]); } #[test] - fn with_authenticator_overrides() { - assert_eq!(Swu2fConfig::with_authenticator("passless").authenticator, "passless"); + fn test_u2f_args_setup_dir() { + let cfg = Swu2fConfig { + setup_dir: Some("/run/u2f-setup".to_string()), + }; + let args = qemu_u2f_args(&cfg); + assert_eq!(args[0], "-usb"); + assert_eq!(args[1], "-device"); + assert_eq!(args[2], "u2f-emulated,dir=/run/u2f-setup"); } #[test] - fn device_path_is_uhid() { - assert_eq!(UHID_DEVICE_PATH, "/dev/uhid"); - assert_eq!(SWU2F_FEATURE, "fido2-swu2f"); + fn test_u2f_args_arg_count() { + assert_eq!(qemu_u2f_args(&Swu2fConfig::default()).len(), 3); } } diff --git a/docs/swu2f.md b/docs/swu2f.md index ea0209aae..61aba4778 100644 --- a/docs/swu2f.md +++ b/docs/swu2f.md @@ -1,54 +1,109 @@ -# swu2f — in-guest software FIDO2/U2F for CI VMs +# Software U2F / FIDO2 (swu2f) for CI VMs -For [yubiOS issue #25](https://github.com/yubi-OS/yubiOS/issues/25) (post-launch): -a software FIDO2/U2F authenticator so enrollment and PAM tests run without a -physical YubiKey. Pairs with the host-side `--swtpm` support on this branch. +bcvk can give an ephemeral VM a software FIDO2/U2F authenticator so yubiOS can +exercise `pam-u2f` and `systemd-cryptenroll --fido2` code paths in CI **without +a physical YubiKey**. -## Why in-guest (not a QEMU device) +> yubiOS production trust anchor remains the **YubiKey FIDO2** device +> (yubiOS ADR-003). swu2f is **test-only**. Tracks yubi-OS/yubiOS#25. -swtpm can be wired host-side because QEMU has a real TPM emulator backend -(`-tpmdev emulator`). FIDO2 has no equivalent: QEMU only offers `u2f-emulated`, -which speaks **CTAP1/U2F** and does **not** implement the CTAP2 `hmac-secret` -extension. `systemd-cryptenroll --fido2-device` and LUKS2 FIDO2 unlock *require* -`hmac-secret`, so a host-emulated U2F device is unusable for the enrollment path -we need to cover. +This mirrors the `swtpm` feature (see `docs/swtpm.md`): a host/QEMU-provided +virtual device the guest sees as real hardware. But FIDO2/U2F splits into two +layers, and each yubiOS consumer needs a different one. -The working approach is a userspace CTAP2 authenticator running **inside the -guest** that registers a virtual FIDO HID device via the kernel `uhid` -interface (`/dev/uhid`). libfido2 then sees it as a normal token: +## The CTAP1 vs CTAP2 split (read this first) -- `fido2-token -L` lists it -- `systemd-cryptenroll --fido2-device=auto ` enrolls against it -- `pam-u2f` authenticates against it +| Consumer | Protocol needed | swu2f layer | +|--------------------------------|-----------------------------|-------------| +| `pam-u2f` login | U2F / CTAP1 | Layer 1 (QEMU `u2f-emulated`) | +| `systemd-cryptenroll --fido2` | FIDO2 / CTAP2 `hmac-secret` | Layer 2 (in-guest uhid CTAP2) | -Known implementations: [`fidorium`](https://github.com/grawity/fidorium) and -[`passless`](https://github.com/pando85/passless) (both `/dev/uhid` + CTAP2 + -`hmac-secret`). +QEMU's `u2f-emulated` is backed by [libu2f-emu], which implements the **U2F +(CTAP1)** protocol only. It is perfect for pam-u2f but cannot satisfy +`systemd-cryptenroll --fido2`, which requires the CTAP2 `hmac-secret` extension +to derive the LUKS2 key. Don't conflate the two. -## Split of responsibilities +[libu2f-emu]: https://github.com/Agnoctopus/libu2f-emu -| Layer | Owns | -|---|---| -| **bcvk** (`--swu2f`) | adds `modules-load=uhid` to the guest kernel cmdline so `/dev/uhid` exists early | -| **test image** | ships the authenticator binary + a systemd unit that starts it (image side, like `swtpm`/`swtpm-tools` for swtpm) | -| **udev** | `KERNEL=="uhid", GROUP="input", MODE="0660"` so the daemon can open the node | +## Layer 1 — QEMU emulated U2F (CTAP1, pam-u2f) -## Usage +A completely software USB HID U2F token, provided by QEMU. Deterministic and +host-side, exactly like swtpm; no in-guest service required. + +Proposed usage (mirrors `--swtpm`): + +```bash +bcvk ephemeral run --swu2f quay.io/fedora/fedora-bootc:45 +``` + +QEMU args emitted (see `crates/bcvk-qemu/src/swu2f.rs`): + +``` +-usb -device u2f-emulated # ephemeral identity (default) +-usb -device u2f-emulated,dir= # stable identity from a setup dir +``` + +A libu2f-emu *setup directory* contains `certificate.pem`, `private-key.pem`, +`counter`, and `entropy` (48 bytes). With no directory the device runs in +**ephemeral** mode and mints a single-use identity for the VM lifetime — fine +for a register+authenticate pam-u2f smoke test. + +Inside the VM: + +```bash +ls -l /dev/hidraw* # the emulated token appears as a HID security key +fido2-token -L # libfido2 enumerates it +pamu2fcfg # register it for pam-u2f +``` + +### Runtime dependency + +The bcvk runner environment must provide libu2f-emu so QEMU's `u2f-emulated` +device is available (QEMU must be built `--enable-u2f`, which Fedora's qemu +packages are): ```bash -bcvk ephemeral run --swu2f dhi.io/yubi-OS/yubiOS:latest +dnf install -y libu2f-emu # Fedora ``` -The guest then exposes a software FIDO2 token; tests can enroll and authenticate. +## Layer 2 — in-guest uhid CTAP2 authenticator (FIDO2, systemd-cryptenroll) + +`systemd-cryptenroll --fido2-device=auto` needs CTAP2 `hmac-secret`, which +libu2f-emu does not provide. The portable way to get a CTAP2 authenticator in a +VM with no hardware is a **software authenticator that creates a virtual HID +device via `/dev/uhid`** inside the guest, then enroll against it: + +```bash +systemd-cryptenroll --fido2-device=auto --fido2-with-client-pin=no /tmp/test.luks +``` + +This is guest-image work, not a QEMU device, so it is intentionally **not** in +the `swu2f.rs` arg builder. It needs: + +1. a test fixture image (`fixtures/Dockerfile.swu2f`) carrying a CTAP2 software + authenticator (e.g. a uhid-based libfido2 virtual device) + `libfido2` + + `systemd-cryptenroll`; +2. `modules-load` for `uhid`; +3. an integration test that registers the virtual authenticator, runs + `systemd-cryptenroll --fido2`, then reboots and unlocks. + +The `bcvk-virtualization` skill also notes `virtual-fido` (Go, USB/IP via +`vhci-hcd`) as an alternative CTAP2 emulator; uhid keeps the device fully +in-guest with no USB/IP host kernel modules. Pick one in the follow-up PR. -## Boot-time unlock caveat +## Why split it this way -For unlocking the **root** LUKS2 volume the authenticator must run in the -initramfs (before the disk is decrypted). For CI we target **post-boot** -enrollment/auth against a scratch LUKS2 container, which avoids the initramfs -ordering hazard. Root-unlock-in-initramfs coverage is a separate, later step. +Same rationale as swtpm: bcvk ephemeral boot uses **direct kernel boot** and +runs QEMU inside a privileged podman container. A host-provided QEMU HID device +(`u2f-emulated`) is the deterministic, dependency-light path and covers the +CTAP1/pam-u2f case immediately. The CTAP2/FIDO2 enrollment path is heavier +(needs a guest authenticator) and is staged as Layer 2 so pam-u2f coverage +isn't blocked on it. -## Scope +## Status -Test-only. Production root of trust remains the physical YubiKey FIDO2 device -(ADR-003). swu2f exists solely for hardware-free CI coverage. +Layer 1: QEMU-arg builder + flag spec landed on `feat/swtpm-ci` (this doc + +`swu2f.rs`). The `--swu2f` CLI flag and `QemuConfig` wiring follow the exact +`--swtpm` pattern and land in the same wiring PR (needs `cargo build` + +`cargo nextest` + human Signed-off-by before merge). Layer 2 is a separate +guest-image PR. Tracks yubi-OS/yubiOS#25. From 2afd8778bf144a89223e37029580f0e01d54518b Mon Sep 17 00:00:00 2001 From: foil-copy-overrate Date: Fri, 26 Jun 2026 02:31:09 -0700 Subject: [PATCH 4/9] fix(swu2f): restore in-guest /dev/uhid CTAP2 route so kit compiles HEAD be5f3858 rewrote swu2f.rs to the host QEMU u2f-emulated (CTAP1) route and dropped push_uhid_kargs, but run_ephemeral.rs:1420 still calls bcvk_qemu::swu2f::push_uhid_kargs -> dangling symbol, kit fails to build. QEMU u2f-emulated is CTAP1/U2F-only (no hmac-secret) and cannot drive systemd-cryptenroll --fido2 for the LUKS2 FIDO2 e2e test (#33/#20); the --swu2f flag help already says it exposes /dev/uhid. Restore the in-guest uhid module (push_uhid_kargs + uhid Swu2fConfig, per 66fbf130) so run_ephemeral resolves and cargo check passes, and reconcile docs/swu2f.md to match (Layer 2 uhid implemented; Layer 1 QEMU u2f-emulated documented as a not-yet-wired pam-u2f-only option). Test-only; prod RoT stays YubiKey (ADR-003). Refs #25. No merge. --- crates/bcvk-qemu/src/swu2f.rs | 140 ++++++++++++++++++++-------------- docs/swu2f.md | 126 ++++++++++++++++-------------- 2 files changed, 154 insertions(+), 112 deletions(-) diff --git a/crates/bcvk-qemu/src/swu2f.rs b/crates/bcvk-qemu/src/swu2f.rs index fda77535b..c890fdb7e 100644 --- a/crates/bcvk-qemu/src/swu2f.rs +++ b/crates/bcvk-qemu/src/swu2f.rs @@ -1,55 +1,68 @@ -//! Software U2F / FIDO2 device for bcvk ephemeral VMs (test-only). +//! Software FIDO2/U2F authenticator (swu2f) wiring for ephemeral CI VMs. //! -//! Mirrors the `swtpm` module: a YubiKey-free way to exercise FIDO2/U2F code -//! paths in CI VMs without physical hardware. There are two distinct layers, -//! because the standards split the same way: +//! 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. //! -//! 1. **QEMU emulated U2F** (`-device u2f-emulated`, backed by libu2f-emu): a -//! fully software USB HID token implementing the **U2F (CTAP1)** protocol. -//! Host/QEMU-provided and deterministic, exactly like swtpm — the guest sees -//! a real USB HID security key with no in-guest service. This covers -//! `pam-u2f` login tests. It does NOT implement CTAP2/FIDO2 `hmac-secret`, -//! so it cannot drive `systemd-cryptenroll --fido2` (see docs/swu2f.md). +//! 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`]). //! -//! 2. **In-guest uhid CTAP2 authenticator** (FIDO2 `hmac-secret`): a software -//! authenticator that creates a virtual HID FIDO2 device via `/dev/uhid` -//! inside the guest, for `systemd-cryptenroll --fido2` LUKS2 unlock tests. -//! That layer is guest-image work (a fixture + a software authenticator), -//! documented in docs/swu2f.md; it is out of scope for this QEMU-arg builder. -//! -//! yubiOS production trust anchor remains the **YubiKey FIDO2** device -//! (yubiOS ADR-003); swu2f is strictly for test coverage. Tracks -//! yubi-OS/yubiOS#25. -//! -//! This module is pure: it only builds argument vectors. Any process/IO lives -//! in `qemu.rs`, per the bcvk REVIEW.md rule of splitting builders from I/O. +//! 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. -/// Configuration for the QEMU emulated U2F (CTAP1) USB token. -#[derive(Debug, Clone, Default)] -pub struct Swu2fConfig { - /// Optional libu2f-emu setup directory. When present it must contain - /// `certificate.pem`, `private-key.pem`, `counter`, and `entropy` (48 - /// bytes), giving the token a stable identity across runs. When `None`, - /// the device runs in libu2f-emu **ephemeral** mode and generates a - /// single-use identity for the lifetime of the VM. - pub setup_dir: Option, +/// 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}") } -/// Build the QEMU arguments wiring an emulated USB U2F token into the VM. +/// Append the `uhid` `modules-load=` karg to a kernel cmdline if not already present. /// -/// Matches the QEMU documented invocation: -/// - ephemeral: `-usb -device u2f-emulated` -/// - setup dir: `-usb -device u2f-emulated,dir=` +/// Idempotent: a second call with the same cmdline is a no-op. +pub fn push_uhid_kargs(cmdline: &mut Vec) { + let karg = uhid_karg(); + if !cmdline.iter().any(|a| a == &karg) { + cmdline.push(karg); + } +} + +/// Configuration for the in-guest software FIDO2/U2F authenticator. /// -/// `-usb` attaches the machine's default USB controller so the HID token can -/// enumerate. The guest `u2f`/`hid-generic` driver then exposes it as a -/// `/dev/hidrawN` security key — usable by libfido2 and pam-u2f. -pub fn qemu_u2f_args(config: &Swu2fConfig) -> Vec { - let mut device = "u2f-emulated".to_string(); - if let Some(dir) = &config.setup_dir { - device.push_str(&format!(",dir={dir}")); +/// 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) -> Self { + Self { authenticator: authenticator.into() } } - vec!["-usb".to_string(), "-device".to_string(), device] } #[cfg(test)] @@ -57,24 +70,39 @@ mod tests { use super::*; #[test] - fn test_u2f_args_ephemeral() { - let args = qemu_u2f_args(&Swu2fConfig::default()); - assert_eq!(args, vec!["-usb", "-device", "u2f-emulated"]); + 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 test_u2f_args_setup_dir() { - let cfg = Swu2fConfig { - setup_dir: Some("/run/u2f-setup".to_string()), - }; - let args = qemu_u2f_args(&cfg); - assert_eq!(args[0], "-usb"); - assert_eq!(args[1], "-device"); - assert_eq!(args[2], "u2f-emulated,dir=/run/u2f-setup"); + fn with_authenticator_overrides() { + assert_eq!(Swu2fConfig::with_authenticator("passless").authenticator, "passless"); } #[test] - fn test_u2f_args_arg_count() { - assert_eq!(qemu_u2f_args(&Swu2fConfig::default()).len(), 3); + fn device_path_is_uhid() { + assert_eq!(UHID_DEVICE_PATH, "/dev/uhid"); + assert_eq!(SWU2F_FEATURE, "fido2-swu2f"); } } diff --git a/docs/swu2f.md b/docs/swu2f.md index 61aba4778..d5b99b7df 100644 --- a/docs/swu2f.md +++ b/docs/swu2f.md @@ -7,9 +7,10 @@ a physical YubiKey**. > yubiOS production trust anchor remains the **YubiKey FIDO2** device > (yubiOS ADR-003). swu2f is **test-only**. Tracks yubi-OS/yubiOS#25. -This mirrors the `swtpm` feature (see `docs/swtpm.md`): a host/QEMU-provided -virtual device the guest sees as real hardware. But FIDO2/U2F splits into two -layers, and each yubiOS consumer needs a different one. +FIDO2/U2F splits into two layers, and each yubiOS consumer needs a different +one. bcvk implements the in-guest CTAP2 path (Layer 2) because that is the layer +the LUKS2 FIDO2 e2e test (#33/#20) depends on; the host-side CTAP1 path (Layer 1) +is documented as a complementary option for pam-u2f-only coverage. ## The CTAP1 vs CTAP2 split (read this first) @@ -19,91 +20,104 @@ layers, and each yubiOS consumer needs a different one. | `systemd-cryptenroll --fido2` | FIDO2 / CTAP2 `hmac-secret` | Layer 2 (in-guest uhid CTAP2) | QEMU's `u2f-emulated` is backed by [libu2f-emu], which implements the **U2F -(CTAP1)** protocol only. It is perfect for pam-u2f but cannot satisfy +(CTAP1)** protocol only. It is fine for pam-u2f but cannot satisfy `systemd-cryptenroll --fido2`, which requires the CTAP2 `hmac-secret` extension to derive the LUKS2 key. Don't conflate the two. [libu2f-emu]: https://github.com/Agnoctopus/libu2f-emu -## Layer 1 — QEMU emulated U2F (CTAP1, pam-u2f) +## Layer 2 — in-guest uhid CTAP2 authenticator (FIDO2, systemd-cryptenroll) — IMPLEMENTED -A completely software USB HID U2F token, provided by QEMU. Deterministic and -host-side, exactly like swtpm; no in-guest service required. +`systemd-cryptenroll --fido2-device=auto` needs CTAP2 `hmac-secret`, which +libu2f-emu (and therefore QEMU `u2f-emulated`) does not provide. The portable +way to get a CTAP2 authenticator in a VM with no hardware is a **software +authenticator that creates a virtual HID device via `/dev/uhid`** inside the +guest, then enroll against it. -Proposed usage (mirrors `--swtpm`): +Unlike swtpm, this cannot be a host/QEMU-provided device — the authenticator +must run inside the guest. bcvk's only host-side responsibility is to make sure +the `uhid` kernel module is available early, via a `modules-load=` karg. That is +exactly what the `--swu2f` flag does and all that `crates/bcvk-qemu/src/swu2f.rs` +builds (it is a pure karg builder; no process/IO): ```bash bcvk ephemeral run --swu2f quay.io/fedora/fedora-bootc:45 ``` -QEMU args emitted (see `crates/bcvk-qemu/src/swu2f.rs`): +emits the guest kernel cmdline addition: ``` --usb -device u2f-emulated # ephemeral identity (default) --usb -device u2f-emulated,dir= # stable identity from a setup dir +modules-load=uhid ``` -A libu2f-emu *setup directory* contains `certificate.pem`, `private-key.pem`, -`counter`, and `entropy` (48 bytes). With no directory the device runs in -**ephemeral** mode and mints a single-use identity for the VM lifetime — fine -for a register+authenticate pam-u2f smoke test. - -Inside the VM: +The CTAP2 software authenticator binary itself (e.g. `fidorium` or `passless`) +must be present in the test image — image-side work, analogous to shipping +`swtpm`/`swtpm-tools` for the swtpm feature. Inside the VM: ```bash -ls -l /dev/hidraw* # the emulated token appears as a HID security key -fido2-token -L # libfido2 enumerates it -pamu2fcfg # register it for pam-u2f +modprobe uhid # ensured by modules-load=uhid +fidorium & # software CTAP2 authenticator -> /dev/uhid +fido2-token -L # libfido2 enumerates the virtual token +systemd-cryptenroll --fido2-device=auto --fido2-with-client-pin=no /tmp/test.luks ``` -### Runtime dependency +A full e2e (#33/#20) still needs: -The bcvk runner environment must provide libu2f-emu so QEMU's `u2f-emulated` -device is available (QEMU must be built `--enable-u2f`, which Fedora's qemu -packages are): +1. a test fixture image (`fixtures/Dockerfile.swu2f`) carrying a CTAP2 software + authenticator + `libfido2` + `systemd-cryptenroll`; +2. the `uhid` module loaded early (provided by `--swu2f`); +3. an integration test that registers the virtual authenticator, runs + `systemd-cryptenroll --fido2`, then reboots and unlocks. -```bash -dnf install -y libu2f-emu # Fedora -``` +The `bcvk-virtualization` skill also notes `virtual-fido` (Go, USB/IP via +`vhci-hcd`) as an alternative CTAP2 emulator; uhid keeps the device fully +in-guest with no USB/IP host kernel modules. Pick one in the fixture follow-up. -## Layer 2 — in-guest uhid CTAP2 authenticator (FIDO2, systemd-cryptenroll) +## Layer 1 — QEMU emulated U2F (CTAP1, pam-u2f) — NOT YET WIRED -`systemd-cryptenroll --fido2-device=auto` needs CTAP2 `hmac-secret`, which -libu2f-emu does not provide. The portable way to get a CTAP2 authenticator in a -VM with no hardware is a **software authenticator that creates a virtual HID -device via `/dev/uhid`** inside the guest, then enroll against it: +A completely software USB HID U2F token, provided by QEMU, deterministic and +host-side exactly like swtpm; no in-guest service required. It covers the +CTAP1/pam-u2f case but, being CTAP1-only, **cannot** drive +`systemd-cryptenroll --fido2`. -```bash -systemd-cryptenroll --fido2-device=auto --fido2-with-client-pin=no /tmp/test.luks +Proposed QEMU args (a future host-side option, mirroring swtpm; not built by the +current `swu2f.rs`): + +``` +-usb -device u2f-emulated # ephemeral identity (default) +-usb -device u2f-emulated,dir= # stable identity from a setup dir ``` -This is guest-image work, not a QEMU device, so it is intentionally **not** in -the `swu2f.rs` arg builder. It needs: +A libu2f-emu *setup directory* contains `certificate.pem`, `private-key.pem`, +`counter`, and `entropy` (48 bytes). With no directory the device runs in +**ephemeral** mode and mints a single-use identity for the VM lifetime — fine +for a register+authenticate pam-u2f smoke test. Inside the VM it appears as +`/dev/hidraw*` and is usable by `fido2-token -L` / `pamu2fcfg`. -1. a test fixture image (`fixtures/Dockerfile.swu2f`) carrying a CTAP2 software - authenticator (e.g. a uhid-based libfido2 virtual device) + `libfido2` + - `systemd-cryptenroll`; -2. `modules-load` for `uhid`; -3. an integration test that registers the virtual authenticator, runs - `systemd-cryptenroll --fido2`, then reboots and unlocks. +### Runtime dependency -The `bcvk-virtualization` skill also notes `virtual-fido` (Go, USB/IP via -`vhci-hcd`) as an alternative CTAP2 emulator; uhid keeps the device fully -in-guest with no USB/IP host kernel modules. Pick one in the follow-up PR. +If Layer 1 is added later, the bcvk runner environment must provide libu2f-emu +so QEMU's `u2f-emulated` device is available (QEMU built `--enable-u2f`, which +Fedora's qemu packages are): + +```bash +dnf install -y libu2f-emu # Fedora +``` ## Why split it this way -Same rationale as swtpm: bcvk ephemeral boot uses **direct kernel boot** and -runs QEMU inside a privileged podman container. A host-provided QEMU HID device -(`u2f-emulated`) is the deterministic, dependency-light path and covers the -CTAP1/pam-u2f case immediately. The CTAP2/FIDO2 enrollment path is heavier -(needs a guest authenticator) and is staged as Layer 2 so pam-u2f coverage -isn't blocked on it. +bcvk ephemeral boot uses **direct kernel boot** and runs QEMU inside a +privileged podman container. The CTAP2/FIDO2 enrollment path that #33 needs is +unavoidably in-guest (no host QEMU device speaks CTAP2 `hmac-secret`), so that is +the layer bcvk wires first. The host-provided QEMU CTAP1 device (`u2f-emulated`) +is a lighter, optional add-on for pam-u2f-only coverage and is deferred so the +FIDO2 enrollment path isn't blocked on it. ## Status -Layer 1: QEMU-arg builder + flag spec landed on `feat/swtpm-ci` (this doc + -`swu2f.rs`). The `--swu2f` CLI flag and `QemuConfig` wiring follow the exact -`--swtpm` pattern and land in the same wiring PR (needs `cargo build` + -`cargo nextest` + human Signed-off-by before merge). Layer 2 is a separate -guest-image PR. Tracks yubi-OS/yubiOS#25. +Layer 2 (implemented on `feat/swtpm-ci`): `--swu2f` flag on `CommonVmOpts`, +`crates/bcvk-qemu/src/swu2f.rs` (pure `modules-load=uhid` karg builder + +`Swu2fConfig` naming the image authenticator), and the `run_ephemeral` wiring +that appends the karg. Needs `cargo build` + `cargo nextest -p bcvk-qemu` + +human Signed-off-by before merge. The guest fixture + e2e test, and the optional +Layer 1 QEMU device, are separate follow-ups. Tracks yubi-OS/yubiOS#25. From 0440dd94fbc2ec1e940c27e51cd8d8e5ec67a0b6 Mon Sep 17 00:00:00 2001 From: foil-copy-overrate Date: Fri, 26 Jun 2026 02:32:10 -0700 Subject: [PATCH 5/9] fix(bcvk-qemu): restore in-guest uhid swu2f route to fix build break on feat/swtpm-ci HEAD be5f3858 rewrote swu2f.rs to the host-QEMU u2f-emulated route (qemu_u2f_args/Swu2fConfig) and dropped push_uhid_kargs, but crates/kit/src/run_ephemeral.rs:1420 still calls bcvk_qemu::swu2f::push_uhid_kargs(&mut kernel_cmdline) -> dangling symbol, crate fails to compile. Canonical route is in-guest /dev/uhid CTAP2 (per cult-leader decision): QEMU u2f-emulated is CTAP1/U2F-only (no hmac-secret) so it cannot drive systemd-cryptenroll --fido2 that the LUKS2 e2e (#33) needs. Restore the original in-guest swu2f.rs (push_uhid_kargs + uhid_karg + Swu2fConfig + table tests) so the --swu2f flag (modules-load=uhid) and run_ephemeral call site line up again. lib.rs re-export of Swu2fConfig stays valid. Refs yubi-OS/yubiOS#25, unblocks #33. bcvk feature branch, no merge. Assisted-by: Sauna (claude) From 3775a7076b37c1b1002e1b1934a8c8b953b32f5c Mon Sep 17 00:00:00 2001 From: OMNI-AGENT Date: Tue, 7 Jul 2026 22:02:26 -0700 Subject: [PATCH 6/9] =?UTF-8?q?fix(virtiofsd):=20always=20log=20stdout+std?= =?UTF-8?q?err=20to=20disk=20next=20to=20the=20socket=20(yubi-OS/yubiOS=20?= =?UTF-8?q?#9/#20=20=E2=80=94=20silent=20rc=3D2=20with=20empty=20piped=20s?= =?UTF-8?q?tderr)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/bcvk-qemu/src/virtiofsd.rs | 64 ++++++++++++++++++------------- 1 file changed, 37 insertions(+), 27 deletions(-) diff --git a/crates/bcvk-qemu/src/virtiofsd.rs b/crates/bcvk-qemu/src/virtiofsd.rs index a4ff848b0..1625afad9 100644 --- a/crates/bcvk-qemu/src/virtiofsd.rs +++ b/crates/bcvk-qemu/src/virtiofsd.rs @@ -132,31 +132,41 @@ pub async fn spawn_virtiofsd_async(config: &VirtiofsConfig) -> Result Result Date: Tue, 7 Jul 2026 22:02:28 -0700 Subject: [PATCH 7/9] fix(qemu): include stdout + on-disk log tail in virtiofsd startup-failure error (yubi-OS/yubiOS #9/#20) --- crates/bcvk-qemu/src/qemu.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/bcvk-qemu/src/qemu.rs b/crates/bcvk-qemu/src/qemu.rs index 00ba55ed6..91822ceea 100644 --- a/crates/bcvk-qemu/src/qemu.rs +++ b/crates/bcvk-qemu/src/qemu.rs @@ -923,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 => { From 8347277c0597d6fefef54cc49508484e5c2e2e3b Mon Sep 17 00:00:00 2001 From: OMNI-AGENT Date: Thu, 9 Jul 2026 19:51:54 -0700 Subject: [PATCH 8/9] fix(ephemeral): disable AppArmor confinement on privileged container (bwrap userns fix) --- crates/kit/src/run_ephemeral.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/kit/src/run_ephemeral.rs b/crates/kit/src/run_ephemeral.rs index 5cdb8cc4c..3c028fb23 100644 --- a/crates/kit/src/run_ephemeral.rs +++ b/crates/kit/src/run_ephemeral.rs @@ -587,6 +587,12 @@ fn prepare_run_command_with_temp( // Also needed for nested containers "--security-opt=seccomp=unconfined", "--security-opt=unmask=/proc/*", + // Ubuntu/AppArmor hosts (unlike Fedora/SELinux) still confine privileged + // containers via a default AppArmor profile even with --cap-add=all; that + // profile denies the mount-propagation syscalls bwrap needs when it builds + // its isolated mount namespace ("bwrap: Failed to make / slave: Permission + // denied"). Disable AppArmor confinement the same way SELinux is disabled above. + "--security-opt=apparmor=unconfined", // This is a general hardening thing to do when running privileged "-v", "/sys:/sys:ro", From c29246b1a1ea0114fcb92530298a364627f0cae0 Mon Sep 17 00:00:00 2001 From: OMNI-AGENT Date: Wed, 15 Jul 2026 13:01:51 -0700 Subject: [PATCH 9/9] kit: preserve sys_admin inside ephemeral bwrap Assisted-by: AI --- crates/kit/scripts/entrypoint.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/kit/scripts/entrypoint.sh b/crates/kit/scripts/entrypoint.sh index 06474ff2f..39ae3d6b8 100644 --- a/crates/kit/scripts/entrypoint.sh +++ b/crates/kit/scripts/entrypoint.sh @@ -67,7 +67,9 @@ trap 'kill -TERM $BWRAP_PID 2>/dev/null; exit 0' INT TERM # Run bwrap in background so we can handle signals; xref # https://github.com/containers/bubblewrap/pull/586 # But probably really we should switch to systemd -bwrap --as-pid-1 --unshare-pid "${BWRAP_ARGS[@]}" --bind /run /run -- ${SELFEXE} container-entrypoint "$@" & +# bcvk bind-mounts the extracted kernel inside this namespace; bwrap +# drops caps by default, so keep CAP_SYS_ADMIN for that mount. +bwrap --as-pid-1 --unshare-pid --cap-add CAP_SYS_ADMIN "${BWRAP_ARGS[@]}" --bind /run /run -- ${SELFEXE} container-entrypoint "$@" & BWRAP_PID=$! # Wait for bwrap to complete