diff --git a/crates/bcvk-qemu/src/lib.rs b/crates/bcvk-qemu/src/lib.rs index 91cbffea3..858d5dccb 100644 --- a/crates/bcvk-qemu/src/lib.rs +++ b/crates/bcvk-qemu/src/lib.rs @@ -44,6 +44,8 @@ mod credentials; mod qemu; +pub mod swtpm; +pub mod swu2f; mod virtiofsd; pub use credentials::{ @@ -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}; diff --git a/crates/bcvk-qemu/src/qemu.rs b/crates/bcvk-qemu/src/qemu.rs index 634a991e3..91822ceea 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 { @@ -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 => { @@ -971,6 +1041,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 +1055,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/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/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/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 diff --git a/crates/kit/src/run_ephemeral.rs b/crates/kit/src/run_ephemeral.rs index dbcd25fc8..de03837bc 100644 --- a/crates/kit/src/run_ephemeral.rs +++ b/crates/kit/src/run_ephemeral.rs @@ -444,6 +444,18 @@ pub struct CommonVmOpts { )] 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, + + #[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, + /// Select how VM output is presented. /// /// `console` (default): the VM's hvc0 console is forwarded to stdio. @@ -822,6 +834,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", @@ -1707,8 +1725,24 @@ 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 { + // 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 +``` diff --git a/docs/swu2f.md b/docs/swu2f.md new file mode 100644 index 000000000..d5b99b7df --- /dev/null +++ b/docs/swu2f.md @@ -0,0 +1,123 @@ +# Software U2F / FIDO2 (swu2f) for CI VMs + +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**. + +> yubiOS production trust anchor remains the **YubiKey FIDO2** device +> (yubiOS ADR-003). swu2f is **test-only**. Tracks yubi-OS/yubiOS#25. + +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) + +| 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) | + +QEMU's `u2f-emulated` is backed by [libu2f-emu], which implements the **U2F +(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 2 — in-guest uhid CTAP2 authenticator (FIDO2, systemd-cryptenroll) — IMPLEMENTED + +`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. + +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 +``` + +emits the guest kernel cmdline addition: + +``` +modules-load=uhid +``` + +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 +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 +``` + +A full e2e (#33/#20) still needs: + +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. + +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 1 — QEMU emulated U2F (CTAP1, pam-u2f) — NOT YET WIRED + +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`. + +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 +``` + +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`. + +### Runtime dependency + +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 + +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 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.