Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
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
4 changes: 4 additions & 0 deletions crates/bcvk-qemu/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@

mod credentials;
mod qemu;
pub mod usb_passthrough;
mod virtiofsd;

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

pub use usb_passthrough::{
detect_yubikeys, qemu_usb_args, require_yubikeys, UsbHostDevice, YUBICO_VENDOR_ID,
};
pub use virtiofsd::{spawn_virtiofsd_async, validate_virtiofsd_config, VirtiofsConfig};
21 changes: 21 additions & 0 deletions crates/bcvk-qemu/src/qemu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,11 @@ pub struct QemuConfig {

/// fw_cfg entries for passing config files to the guest
fw_cfg_entries: Vec<(String, Utf8PathBuf)>,
/// USB host devices to pass through into the VM (e.g. YubiKey).
///
/// Each device is forwarded via QEMU `-device usb-host`. A single USB 2.0
/// EHCI controller is inserted automatically when this list is non-empty.
pub usb_host_devices: Vec<crate::usb_passthrough::UsbHostDevice>,
}

impl QemuConfig {
Expand Down Expand Up @@ -464,6 +469,15 @@ impl QemuConfig {
self.fw_cfg_entries.push((name, file_path));
self
}

/// Add a USB host device to pass through into the VM.
///
/// A single QEMU USB 2.0 EHCI controller is inserted automatically
/// the first time this list becomes non-empty.
pub fn add_usb_host_device(&mut self, dev: crate::usb_passthrough::UsbHostDevice) -> &mut Self {
self.usb_host_devices.push(dev);
self
}
}

/// Allocate a unique VSOCK CID.
Expand Down Expand Up @@ -759,6 +773,13 @@ fn spawn(
cmd.args(["-fw_cfg", &format!("name={},file={}", name, file_path)]);
}

// USB host device passthrough (e.g. YubiKey)
if !config.usb_host_devices.is_empty() {
for arg in crate::usb_passthrough::qemu_usb_args(&config.usb_host_devices) {
cmd.arg(arg);
}
}

// Configure stdio based on display mode
match &config.display_mode {
DisplayMode::Console => {
Expand Down
176 changes: 176 additions & 0 deletions crates/bcvk-qemu/src/usb_passthrough.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
//! YubiKey USB passthrough detection and QEMU device argument generation.
//!
//! Detects YubiKey 5 series devices on the host via the Linux udev/sysfs
//! interface (Yubico USB vendor ID 0x1050) and generates the QEMU
//! `-device usb-host` arguments needed to pass them into an ephemeral VM.
//!
//! # Security note
//!
//! USB passthrough gives the VM direct access to the YubiKey. Only use
//! with trusted images (e.g. yubiOS) where this is intentional.

use std::path::PathBuf;

use color_eyre::eyre::{bail, Context};
use color_eyre::Result;
use tracing::debug;

/// Yubico USB vendor ID.
pub const YUBICO_VENDOR_ID: u16 = 0x1050;

/// A USB host device to pass through to QEMU.
#[derive(Debug, Clone)]
pub struct UsbHostDevice {
/// USB vendor ID (hex, e.g. 0x1050 for Yubico).
pub vendor_id: u16,
/// USB product ID. None = match any product from this vendor.
pub product_id: Option<u16>,
/// Human-readable label for logging.
pub label: String,
}

impl UsbHostDevice {
/// Match any YubiKey (any product ID under vendor 0x1050).
pub fn any_yubikey() -> Self {
Self {
vendor_id: YUBICO_VENDOR_ID,
product_id: None,
label: "YubiKey (any model)".to_string(),
}
}

/// Build QEMU `-device usb-host` argument string.
///
/// Example: `usb-host,vendorid=0x1050,productid=0x0407`
pub fn qemu_device_arg(&self) -> String {
let mut arg = format!("usb-host,vendorid=0x{:04x}", self.vendor_id);
if let Some(pid) = self.product_id {
arg.push_str(&format!(",productid=0x{:04x}", pid));
}
arg
}
}

/// Probe the host for attached YubiKey devices via sysfs.
///
/// Walks /sys/bus/usb/devices/ and matches on idVendor == 0x1050.
/// Returns one `UsbHostDevice` per unique (vendor, product) pair found.
pub fn detect_yubikeys() -> Result<Vec<UsbHostDevice>> {
let mut found = Vec::new();
let base = PathBuf::from("/sys/bus/usb/devices");

if !base.exists() {
debug!("sysfs not available — skipping YubiKey detection");
return Ok(found);
}

for entry in std::fs::read_dir(&base).context("reading /sys/bus/usb/devices")? {
let entry = entry?;
let vendor_path = entry.path().join("idVendor");
let product_path = entry.path().join("idProduct");
if !vendor_path.exists() {
continue;
}
let vendor_str = std::fs::read_to_string(&vendor_path)
.unwrap_or_default()
.trim()
.to_lowercase();
if vendor_str != format!("{:04x}", YUBICO_VENDOR_ID) {
continue;
}
let product_id = std::fs::read_to_string(&product_path)
.ok()
.and_then(|s| u16::from_str_radix(s.trim(), 16).ok());
debug!(
"Found YubiKey: vendor={} product={:?} at {}",
vendor_str,
product_id,
entry.path().display()
);
found.push(UsbHostDevice {
vendor_id: YUBICO_VENDOR_ID,
product_id,
label: format!("YubiKey (0x1050:{:04x})", product_id.unwrap_or(0)),
});
}
Ok(found)
}

/// Detect YubiKeys and fail with a clear message if none found.
pub fn require_yubikeys() -> Result<Vec<UsbHostDevice>> {
let keys = detect_yubikeys()?;
if keys.is_empty() {
bail!(
"--yubikey requested but no YubiKey detected.
Insert a YubiKey (Yubico vendor 0x1050) and retry."
);
}
Ok(keys)
}

/// Build the QEMU USB controller + host-device arguments for a list of devices.
///
/// Adds a USB 2.0 EHCI controller (one per call) then one usb-host device per
/// YubiKey. Returns a flat list of QEMU argument strings.
pub fn qemu_usb_args(devices: &[UsbHostDevice]) -> Vec<String> {
if devices.is_empty() {
return Vec::new();
}
let mut args = vec![
// USB 2.0 EHCI controller — required for usb-host on x86 and arm64
"-device".to_string(),
"usb-ehci,id=yubikey-ehci".to_string(),
];
for dev in devices {
args.push("-device".to_string());
let mut dev_arg = dev.qemu_device_arg();
dev_arg.push_str(",bus=yubikey-ehci.0");
args.push(dev_arg);
}
args
}

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

#[test]
fn test_qemu_device_arg_vendor_only() {
let dev = UsbHostDevice::any_yubikey();
assert_eq!(dev.qemu_device_arg(), "usb-host,vendorid=0x1050");
}

#[test]
fn test_qemu_device_arg_with_product() {
let dev = UsbHostDevice {
vendor_id: 0x1050,
product_id: Some(0x0407),
label: "YubiKey 5 NFC".to_string(),
};
assert_eq!(
dev.qemu_device_arg(),
"usb-host,vendorid=0x1050,productid=0x0407"
);
}

#[test]
fn test_qemu_usb_args_empty() {
assert!(qemu_usb_args(&[]).is_empty());
}

#[test]
fn test_qemu_usb_args_adds_controller() {
let devs = vec![UsbHostDevice::any_yubikey()];
let args = qemu_usb_args(&devs);
assert!(args.contains(&"usb-ehci,id=yubikey-ehci".to_string()));
assert!(args.iter().any(|a| a.contains("usb-host")));
assert!(args.iter().any(|a| a.contains("yubikey-ehci.0")));
}

#[test]
fn test_any_yubikey_vendor() {
let dev = UsbHostDevice::any_yubikey();
assert_eq!(dev.vendor_id, 0x1050);
assert!(dev.product_id.is_none());
}
}
21 changes: 21 additions & 0 deletions crates/kit/src/run_ephemeral.rs
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,18 @@ pub struct CommonVmOpts {
)]
pub ssh_keygen: bool,

/// Pass YubiKey(s) from the host into the VM via QEMU USB host passthrough.
///
/// Detects all Yubico devices (vendor 0x1050) on the host via sysfs and
/// forwards them directly into the VM. Fails immediately if `--yubikey`
/// is set but no YubiKey is detected on the host.
#[clap(
long,
help = "Forward attached YubiKey(s) from host into the VM via USB passthrough"
)]
#[serde(default)]
pub yubikey: bool,

#[clap(
long = "virtiofsd",
env = "VIRTIOFSD_BIN",
Expand Down Expand Up @@ -1670,6 +1682,15 @@ StandardOutput=file:/dev/virtio-ports/executestatus
qemu_config.add_smbios_credential(credential);
}

// YubiKey USB passthrough
#[cfg(target_os = "linux")]
if opts.common.yubikey {
let keys = bcvk_qemu::usb_passthrough::require_yubikeys()?;
for key in keys {
qemu_config.add_usb_host_device(key);
}
}

// Build kernel command line for direct boot.
//
// We deliberately omit root=, rootfstype=, and rootflags= from the
Expand Down
23 changes: 23 additions & 0 deletions docs/yubikey-passthrough.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
--- run_ephemeral.rs patch ---
Add `--yubikey` flag to CommonVmOpts and wire through to QemuConfig.

In CommonVmOpts struct, add:

/// Pass YubiKey USB devices into the VM (requires host YubiKey attached).
/// Detected via sysfs (Yubico vendor 0x1050). Adds usb-ehci controller
/// and usb-host device entries to QEMU args.
#[clap(long, help = "Pass YubiKey USB device(s) into the VM")]
pub yubikey: bool,

In run_impl() where QemuConfig is built, after existing device setup:

if opts.common.yubikey {
let keys = usb_passthrough::require_yubikeys()?;
tracing::info!("Passing {} YubiKey(s) into VM", keys.len());
for arg in usb_passthrough::qemu_usb_args(&keys) {
qemu_config.extra_args.push(arg);
}
}

Import at top of file:
use crate::usb_passthrough;