From dce409bc350400540267d9fe6aef25d9561fb2fd Mon Sep 17 00:00:00 2001 From: Alex Lyn Date: Tue, 26 May 2026 19:45:19 +0800 Subject: [PATCH] kata-agent: Add dm-verity device creation for GPT-partitioned layers GPT-partitioned EROFS layers can carry dm-verity hashes appended after the filesystem data within the same partition. The host runtime passes the root hash and parameters as X-kata.dmverity.* storage options; the agent must set up the kernel dm-verity target before mounting so that every read is integrity-checked against the Merkle tree. Implement dm-verity device creation: option parsing from storage options, device name generation, and create helper via devicemapper ioctls with hash_start_block calculation (accounting for v1 superblock presence). Signed-off-by: Alex Lyn --- src/agent/src/storage/multi_layer_erofs.rs | 306 ++++++++++++++++++++- 1 file changed, 297 insertions(+), 9 deletions(-) diff --git a/src/agent/src/storage/multi_layer_erofs.rs b/src/agent/src/storage/multi_layer_erofs.rs index cd40675638..fd17180ab8 100644 --- a/src/agent/src/storage/multi_layer_erofs.rs +++ b/src/agent/src/storage/multi_layer_erofs.rs @@ -13,7 +13,6 @@ //! - Supports X-kata.mkdir.path options to create directories in upper layer before overlay mount //! - Supports GPT-partitioned disks with dm-verity integrity verification for each partition -#[allow(unused_imports)] use nix::sys::stat::{self, Mode, SFlag}; use std::collections::HashMap; use std::fs; @@ -38,9 +37,9 @@ use safe_path::scoped_join; use slog::Logger; use tokio::sync::Mutex; -// no-udev device-mapper helpers -#[allow(unused_imports)] -use devicemapper::{DmFlags, DmOptions, DmUdevFlags}; +// dm-verity support imports +use devicemapper::{DevId, DmFlags, DmName, DmOptions, DmUdevFlags, DM}; +use kata_types::mount::DmVerityInfo; /// EROFS Type const EROFS_TYPE: &str = "erofs"; @@ -60,8 +59,19 @@ const OPT_GPT_PARTITIONED: &str = "X-kata.gpt-partitioned=true"; const OPT_MKDIR_PATH: &str = "X-kata.mkdir.path="; const OPT_PARTITION_NUMBER: &str = "X-kata.partition-number="; -/// Build DmOptions that fully disable udev synchronization. +/// dm-verity related storage options #[allow(dead_code)] +const OPT_DMVERITY_ENABLED: &str = "X-kata.dmverity-enabled=true"; +const OPT_DMVERITY_ROOT_HASH: &str = "X-kata.dmverity.roothash="; +const OPT_DMVERITY_HASH_OFFSET: &str = "X-kata.dmverity.hashoffset="; +const OPT_DMVERITY_BLOCK_SIZE: &str = "X-kata.dmverity.blocksize="; +const OPT_DMVERITY_HASH_SIZE: &str = "X-kata.dmverity.hashsize="; +const OPT_DMVERITY_HASH_ALGORITHM: &str = "X-kata.dmverity.algorithm="; +const OPT_DMVERITY_SALT: &str = "X-kata.dmverity.salt="; +const OPT_DMVERITY_HASH_TYPE: &str = "X-kata.dmverity.hashtype="; +const OPT_DMVERITY_NO_SUPERBLOCK: &str = "X-kata.dmverity.no-superblock="; + +/// Build DmOptions that fully disable udev synchronization. fn no_udev_dm_options() -> DmOptions { DmOptions::default().set_udev_flags( DmUdevFlags::DM_UDEV_DISABLE_LIBRARY_FALLBACK @@ -73,19 +83,16 @@ fn no_udev_dm_options() -> DmOptions { } /// Build DmOptions for read-only device removal in a no-udev environment. -#[allow(dead_code)] fn dm_opts_readonly() -> DmOptions { no_udev_dm_options().set_flags(DmFlags::DM_READONLY) } /// Build DmOptions for deferred device removal in a no-udev environment. -#[allow(dead_code)] fn dm_opts_deferred_remove() -> DmOptions { no_udev_dm_options().set_flags(DmFlags::DM_DEFERRED_REMOVE) } /// Create a block device node for a dm-verity device using mknod(2). -#[allow(dead_code)] fn create_dm_dev_node(name: &str, dev: devicemapper::Device) -> Result { // Ensure /dev/mapper exists. let mapper_dir = Path::new("/dev/mapper"); @@ -115,7 +122,6 @@ fn create_dm_dev_node(name: &str, dev: devicemapper::Device) -> Result { } /// Remove a device node that was created by create_dm_dev_node. -#[allow(dead_code)] fn remove_dm_dev_node(dev_path: &str) { if dev_path.starts_with("/dev/mapper/") && Path::new(dev_path).exists() { if let Err(e) = std::fs::remove_file(dev_path) { @@ -129,6 +135,21 @@ fn remove_dm_dev_node(dev_path: &str) { } } +/// Generate a unique dm-verity device name based on the source device path and verity hash. +fn build_dmverity_device_name(source_device_path: &Path, verity_info: &DmVerityInfo) -> String { + let source_short = source_device_path + .file_name() + .map(|f| f.to_string_lossy()) + .unwrap_or_default(); + let hash_prefix = &verity_info.hash[..verity_info.hash.len().min(32)]; + let mut name = format!( + "kata-verity-{}-off{}-{}", + source_short, verity_info.offset, hash_prefix + ); + name.truncate(128); + name +} + #[derive(Debug)] pub struct MultiLayerErofsHandler {} @@ -511,6 +532,215 @@ fn is_lower_storage(storage: &Storage) -> bool { || (storage.fstype == EROFS_TYPE && storage.options.iter().any(|o| o == OPT_MULTI_LAYER)) } +/// Parse dm-verity configuration from storage options +fn parse_dmverity_options(storage: &Storage) -> Result { + let mut hashtype = String::from("sha256"); + let mut hash = String::new(); + let mut blocknum: u64 = 0; + let mut blocksize: u64 = 4096; + let mut hashsize: u64 = 4096; + let mut offset: u64 = 0; + let mut salt: Option = None; + let mut hash_type: u32 = 1; + let mut no_superblock: bool = false; + + for opt in &storage.options { + if let Some(value) = opt.strip_prefix(OPT_DMVERITY_ROOT_HASH) { + hash = value.to_string(); + } else if let Some(value) = opt.strip_prefix(OPT_DMVERITY_HASH_OFFSET) { + offset = value.parse::().context("Invalid hashoffset value")?; + } else if let Some(value) = opt.strip_prefix(OPT_DMVERITY_BLOCK_SIZE) { + blocksize = value.parse::().context("Invalid blocksize value")?; + } else if let Some(value) = opt.strip_prefix(OPT_DMVERITY_HASH_SIZE) { + hashsize = value.parse::().context("Invalid hashsize value")?; + } else if let Some(value) = opt.strip_prefix(OPT_DMVERITY_HASH_ALGORITHM) { + hashtype = value.to_string(); + } else if let Some(value) = opt.strip_prefix(OPT_DMVERITY_SALT) { + salt = if value.is_empty() || value == "-" { + None + } else { + Some(value.to_string()) + }; + } else if let Some(value) = opt.strip_prefix(OPT_DMVERITY_HASH_TYPE) { + hash_type = value.parse::().context("Invalid hash type value")?; + } else if let Some(value) = opt.strip_prefix(OPT_DMVERITY_NO_SUPERBLOCK) { + no_superblock = value + .parse::() + .context("Invalid no-superblock value")?; + } + } + + // Calculate blocknum from hashoffset and blocksize + if offset > 0 && blocksize > 0 { + blocknum = offset / blocksize; + } + + if hash.is_empty() { + return Err(anyhow!("dm-verity roothash is required but not provided")); + } + if offset == 0 { + return Err(anyhow!("dm-verity hashoffset is required but not provided")); + } + if blocksize == 0 || hashsize == 0 { + return Err(anyhow!("dm-verity blocksize/hashsize must be non-zero")); + } + if !offset.is_multiple_of(blocksize) { + return Err(anyhow!( + "dm-verity hashoffset {} is not aligned to blocksize {}", + offset, + blocksize + )); + } + if blocknum == 0 { + return Err(anyhow!( + "dm-verity blocknum resolved to zero from hashoffset {} and blocksize {}", + offset, + blocksize + )); + } + + Ok(DmVerityInfo { + hashtype, + hash, + blocknum, + blocksize, + hashsize, + offset, + salt, + hash_type, + no_superblock, + }) +} + +/// Create dm-verity device for a partition and return the verity device path +#[allow(dead_code)] +fn create_partition_dmverity_device( + partition_path: &str, + storage: &Storage, + logger: &Logger, +) -> Result { + info!( + logger, + "Creating dm-verity device for partition"; + "partition" => partition_path, + "source" => &storage.source, + ); + + // Parse dm-verity options from storage + let verity_info = + parse_dmverity_options(storage).context("Failed to parse dm-verity options")?; + + // Create dm-verity device + let verity_device_path = create_dmverity_device(&verity_info, Path::new(partition_path)) + .context("failed to create dm-verity device")?; + + info!( + logger, + "Successfully created dm-verity device"; + "partition" => partition_path, + "verity-device" => &verity_device_path, + ); + + Ok(verity_device_path) +} + +/// Create a dm-verity device using devicemapper +fn create_dmverity_device(verity_info: &DmVerityInfo, source_device_path: &Path) -> Result { + let dm = DM::new()?; + let verity_name_string = build_dmverity_device_name(source_device_path, verity_info); + let verity_name = DmName::new(&verity_name_string)?; + let id = DevId::Name(verity_name); + + let opts = no_udev_dm_options(); + let ro_opts = dm_opts_readonly(); + + // Step 0: Remove stale device if it already exists + if dm.device_remove(&id, dm_opts_deferred_remove()).is_ok() { + // Stale device removed; continue with creation. + } + + // Step 1: Create device as read-only with no-udev flags + dm.device_create(verity_name, None, ro_opts)?; + + // Calculate hash start block. + // + // The `offset` field (from X-kata.dmverity.hashoffset) is the byte offset + // of the dm-verity superblock from the start of the device, as stored in + // the containerd .erofs.dmverity JSON. It equals data_blocks * data_block_size. + // + // In the dm-verity table, `hash_start_block` is the block number (in + // hash_block_size units) where the hash TREE DATA begins — NOT where the + // superblock begins. When version=1 (with superblock), the superblock + // occupies one hash-block-aligned region at `offset`, and the actual hash + // tree starts after it. The kernel never reads the superblock; it relies + // entirely on the table parameters. + // + // Therefore, when no_superblock=false: + // hash_start_block = (offset / hashsize) + superblock_blocks + // where superblock_blocks = ceil(512 / hashsize) = 1 (for hashsize >= 512) + // + // When no_superblock=true (version=0, no superblock): + // hash_start_block = offset / hashsize + let hash_start_block: u64 = if verity_info.no_superblock { + verity_info.offset / verity_info.hashsize + } else { + // dm-verity v1 superblock is 512 bytes, aligned up to hash block size + let superblock_blocks = 512_u64.div_ceil(verity_info.hashsize); + (verity_info.offset / verity_info.hashsize) + superblock_blocks + }; + + // Use provided salt or default to "-" (no salt) + let salt = verity_info.salt.as_deref().unwrap_or("-"); + let verity_params = format!( + "{} {} {} {} {} {} {} {} {} {}", // 10 parameters + verity_info.hash_type, // version: "1" for verity v1.0 + source_device_path.display(), // data device + source_device_path.display(), // hash device (usually same as data) + verity_info.blocksize, // data block size + verity_info.hashsize, // hash block size + verity_info.blocknum, // number of data blocks + hash_start_block, // hash start block + verity_info.hashtype, // hash algorithm ("sha256", "sha1", etc.) + verity_info.hash, // root hash (hex encoded) + salt // salt (hex encoded or "-" for none) + ); + + let verity_table = vec![( + 0, + verity_info.blocknum * verity_info.blocksize / 512, + "verity".into(), + verity_params.clone(), + )]; + + info!( + slog_scope::logger(), + "dm-verity table parameters"; + "device" => source_device_path.display(), + "data_blocks" => verity_info.blocknum, + "data_block_size" => verity_info.blocksize, + "hash_block_size" => verity_info.hashsize, + "hash_start_block" => hash_start_block, + "hash_algorithm" => &verity_info.hashtype, + "hash_type" => verity_info.hash_type, + "no_superblock" => verity_info.no_superblock, + "salt" => salt, + "table_params" => &verity_params, + ); + + // Step 2: Load table and resume (activate) with read-only + no-udev flags + dm.table_load(&id, verity_table.as_slice(), ro_opts)?; + dm.device_suspend(&id, opts)?; + + // Step 3: Get device info and create the device node via mknod. + // In a udev-less guest VM, /dev/block/M:N and /dev/mapper/ are + // never created by udev. We must create the node ourselves using the + // major:minor numbers returned by the device-mapper ioctl. + let device_info = dm.device_info(&id)?; + let dev_path = create_dm_dev_node(&verity_name_string, device_info.device())?; + + Ok(dev_path) +} + /// Validate that a container ID does not contain path traversal sequences. /// /// Container IDs are used to construct filesystem paths. A malicious ID containing @@ -1016,4 +1246,62 @@ mod tests { part ); } + + // --- parse_dmverity_options --- + + #[test] + fn test_parse_dmverity_options_required_fields_and_blocknum() { + // Test required fields and blocknum calculation. + // + // dm-verity roothash and hashoffset are mandatory — without them the + // verity table cannot be constructed. blocknum is computed as + // offset/blocksize and must be non-zero for a valid verity device. + // Uses hashoffset=8192, blocksize=4096 so blocknum = 8192/4096 = 2. + let make_valid_dmverity_storage = || -> Storage { + Storage { + options: vec![ + OPT_DMVERITY_ENABLED.to_string(), + format!("{}{}", OPT_DMVERITY_ROOT_HASH, "aabbccdd"), + format!("{}{}", OPT_DMVERITY_HASH_OFFSET, "8192"), + format!("{}{}", OPT_DMVERITY_BLOCK_SIZE, "4096"), + format!("{}{}", OPT_DMVERITY_HASH_SIZE, "4096"), + format!("{}{}", OPT_DMVERITY_SALT, "0000000000000000"), + format!("{}{}", OPT_DMVERITY_NO_SUPERBLOCK, "false"), + ], + ..Default::default() + } + }; + + // Missing roothash + let mut s = make_valid_dmverity_storage(); + s.options.retain(|o| !o.starts_with(OPT_DMVERITY_ROOT_HASH)); + let err = parse_dmverity_options(&s).unwrap_err(); + assert!( + err.to_string().contains("roothash is required"), + "expected roothash error, got: {}", + err + ); + + // hashoffset=0 + let mut s = make_valid_dmverity_storage(); + s.options + .retain(|o| !o.starts_with(OPT_DMVERITY_HASH_OFFSET)); + s.options + .push(format!("{}{}", OPT_DMVERITY_HASH_OFFSET, "0")); + let err = parse_dmverity_options(&s).unwrap_err(); + assert!( + err.to_string().contains("hashoffset is required"), + "expected hashoffset error, got: {}", + err + ); + + // Valid case: verify blocknum = offset / blocksize + let s = make_valid_dmverity_storage(); + let info = parse_dmverity_options(&s).expect("valid options should succeed"); + assert_eq!(info.blocknum, 2); // 8192 / 4096 + assert_eq!(info.offset, 8192); + assert_eq!(info.blocksize, 4096); + assert_eq!(info.salt.as_deref(), Some("0000000000000000")); + assert!(!info.no_superblock); + } }