diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 39eb04d2b..f321ff63b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,16 +65,31 @@ jobs: # For a not-ancient podman runs-on: ubuntu-24.04 steps: + - name: Get a newer podman for heredoc support (from debian testing) + run: | + set -eux + echo 'deb [trusted=yes] https://ftp.debian.org/debian/ testing main' | sudo tee /etc/apt/sources.list.d/testing.list + sudo apt update + sudo apt install -y crun/testing podman/testing skopeo/testing - name: Checkout repository uses: actions/checkout@v4 - name: Free up disk space on runner run: sudo ./ci/clean-gha-runner.sh + - name: Enable fsverity for / + run: sudo tune2fs -O verity $(findmnt -vno SOURCE /) + - name: Install utils + run: sudo apt -y install fsverity - name: Integration tests run: | set -xeu + # Build images to test; TODO investigate doing single container builds + # via GHA and pushing to a temporary registry to share among workflows? sudo podman build -t localhost/bootc -f hack/Containerfile . + sudo podman build -t localhost/bootc-fsverity -f ci/Containerfile.install-fsverity + export CARGO_INCREMENTAL=0 # because we aren't caching the test runner bits cargo build --release -p tests-integration + df -h / sudo install -m 0755 target/release/tests-integration /usr/bin/bootc-integration-tests rm target -rf @@ -84,8 +99,16 @@ jobs: -v /run/dbus:/run/dbus -v /run/systemd:/run/systemd localhost/bootc /src/ostree-ext/ci/priv-integration.sh # Nondestructive but privileged tests sudo bootc-integration-tests host-privileged localhost/bootc - # Finally the install-alongside suite + # Install tests sudo bootc-integration-tests install-alongside localhost/bootc + + # And the fsverity case + sudo podman run --privileged --pid=host localhost/bootc-fsverity bootc install to-existing-root --stateroot=other \ + --acknowledge-destructive --skip-fetch-check + # Crude cross check + sudo find /ostree/repo/objects -name '*.file' -type f | while read f; do + sudo fsverity measure $f >/dev/null + done docs: if: ${{ contains(github.event.pull_request.labels.*.name, 'documentation') }} runs-on: ubuntu-latest diff --git a/Cargo.lock b/Cargo.lock index 90a3982ad..a7223d5d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -104,9 +104,9 @@ checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f" [[package]] name = "async-compression" -version = "0.4.18" +version = "0.4.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df895a515f70646414f4b45c0b79082783b80552b373a68283012928df56f522" +checksum = "c0cf008e5e1a9e9e22a7d3c9a4992e21a350290069e36d8fb72304ed17e8f2d2" dependencies = [ "flate2", "futures-core", @@ -213,7 +213,6 @@ dependencies = [ "clap", "clap_mangen", "comfy-table", - "composefs", "fn-error-context", "hex", "indicatif", @@ -517,7 +516,7 @@ dependencies = [ "tempfile", "thiserror 2.0.12", "tokio", - "zerocopy 0.8.14", + "zerocopy 0.8.23", "zstd", ] @@ -1581,6 +1580,7 @@ dependencies = [ "clap", "clap_mangen", "comfy-table", + "composefs", "containers-image-proxy", "flate2", "fn-error-context", @@ -2871,11 +2871,11 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.14" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a367f292d93d4eab890745e75a778da40909cab4d6ff8173693812f79c4a2468" +checksum = "fd97444d05a4328b90e75e503a34bad781f14e28a823ad3557f0750df1ebcbc6" dependencies = [ - "zerocopy-derive 0.8.14", + "zerocopy-derive 0.8.23", ] [[package]] @@ -2891,9 +2891,9 @@ dependencies = [ [[package]] name = "zerocopy-derive" -version = "0.8.14" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3931cb58c62c13adec22e38686b559c86a30565e16ad6e8510a337cedc611e1" +checksum = "6352c01d0edd5db859a63e2605f4ea3183ddbd15e2c4a9e7d32184df75e4f154" dependencies = [ "proc-macro2", "quote", diff --git a/ci/Containerfile.install-fsverity b/ci/Containerfile.install-fsverity new file mode 100644 index 000000000..a47c2964f --- /dev/null +++ b/ci/Containerfile.install-fsverity @@ -0,0 +1,14 @@ +# Enable fsverity at install time +FROM localhost/bootc +RUN < /usr/lib/ostree/prepare-root.conf < /usr/lib/bootc/install/90-ext4.toml < FsckResult { fsck_ok() } +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum VerityState { + Enabled, + Disabled, + Inconsistent((u64, u64)), +} + +#[derive(Debug, Default)] +struct ObjectsVerityState { + /// Count of objects with fsverity + enabled: u64, + /// Count of objects without fsverity + disabled: u64, + /// Objects which should have fsverity but do not + missing: Vec, +} + +/// Check the fsverity state of all regular files in this object directory. +#[context("Computing verity state")] +fn verity_state_of_objects( + d: &Dir, + prefix: &str, + expected: bool, +) -> anyhow::Result { + let mut enabled = 0; + let mut disabled = 0; + let mut missing = Vec::new(); + for ent in d.entries()? { + let ent = ent?; + if !ent.file_type()?.is_file() { + continue; + } + let name = ent.file_name(); + let name = name + .into_string() + .map(Utf8PathBuf::from) + .map_err(|_| anyhow::anyhow!("Invalid UTF-8"))?; + let Some("file") = name.extension() else { + continue; + }; + let f = d.open(&name)?; + let r: Option = + composefs::fsverity::ioctl::fs_ioc_measure_verity(f.as_fd())?; + drop(f); + if r.is_some() { + enabled += 1; + } else { + disabled += 1; + if expected { + missing.push(format!("{prefix}{name}")); + } + } + } + let r = ObjectsVerityState { + enabled, + disabled, + missing, + }; + Ok(r) +} + +async fn verity_state_of_all_objects( + repo: &ostree::Repo, + expected: bool, +) -> anyhow::Result { + // Limit concurrency here + const MAX_CONCURRENT: usize = 3; + + let repodir = Dir::reopen_dir(&repo.dfd_borrow())?; + + // It's convenient here to reuse tokio's spawn_blocking as a threadpool basically. + let mut joinset = tokio::task::JoinSet::new(); + let mut results = Vec::new(); + + for ent in repodir.read_dir("objects")? { + // Block here if the queue is full + while joinset.len() >= MAX_CONCURRENT { + results.push(joinset.join_next().await.unwrap()??); + } + let ent = ent?; + if !ent.file_type()?.is_dir() { + continue; + } + let name = ent.file_name(); + let name = name + .into_string() + .map(Utf8PathBuf::from) + .map_err(|_| anyhow::anyhow!("Invalid UTF-8"))?; + + let objdir = ent.open_dir()?; + let expected = expected.clone(); + joinset.spawn_blocking(move || verity_state_of_objects(&objdir, name.as_str(), expected)); + } + + // Drain the remaining tasks. + while let Some(output) = joinset.join_next().await { + results.push(output??); + } + // Fold the results. + let r = results + .into_iter() + .fold(ObjectsVerityState::default(), |mut acc, v| { + acc.enabled += v.enabled; + acc.disabled += v.disabled; + acc.missing.extend(v.missing); + acc + }); + Ok(r) +} + +#[distributed_slice(FSCK_CHECKS)] +static CHECK_FSVERITY: FsckCheck = + FsckCheck::new("fsverity", 10, FsckFnImpl::Async(check_fsverity)); +fn check_fsverity(storage: &Storage) -> Pin + '_>> { + Box::pin(check_fsverity_inner(storage)) +} + +async fn check_fsverity_inner(storage: &Storage) -> FsckResult { + let repo = &storage.repo(); + let verity_state = ostree_ext::fsverity::is_verity_enabled(repo)?; + tracing::debug!( + "verity: expected={:?} found={:?}", + verity_state.desired, + verity_state.enabled + ); + + let verity_found_state = + verity_state_of_all_objects(&storage.repo(), verity_state.desired == Tristate::Enabled) + .await?; + let Some((missing, rest)) = + iterator_split_nonempty_rest_count(verity_found_state.missing.iter(), 5) + else { + return fsck_ok(); + }; + let mut err = String::from("fsverity enabled, but objects without fsverity:\n"); + for obj in missing { + // SAFETY: Writing into a String + writeln!(err, " {obj}").unwrap(); + } + if rest > 0 { + // SAFETY: Writing into a String + writeln!(err, " ...and {rest} more").unwrap(); + } + fsck_err(err) +} + pub(crate) async fn fsck(storage: &Storage, mut output: impl std::io::Write) -> anyhow::Result<()> { let mut checks = FSCK_CHECKS.static_slice().iter().collect::>(); checks.sort_by(|a, b| a.ordering.cmp(&b.ordering)); diff --git a/lib/src/install.rs b/lib/src/install.rs index 7254ed1ef..e7f1b5269 100644 --- a/lib/src/install.rs +++ b/lib/src/install.rs @@ -40,6 +40,7 @@ use fn_error_context::context; use ostree::gio; use ostree_ext::oci_spec; use ostree_ext::ostree; +use ostree_ext::ostree_prepareroot::{ComposefsState, Tristate}; use ostree_ext::prelude::Cast; use ostree_ext::sysroot::SysrootLock; use ostree_ext::{container as ostree_container, ostree_prepareroot}; @@ -77,6 +78,15 @@ const SELINUXFS: &str = "/sys/fs/selinux"; const EFIVARFS: &str = "/sys/firmware/efi/efivars"; pub(crate) const ARCH_USES_EFI: bool = cfg!(any(target_arch = "x86_64", target_arch = "aarch64")); +const DEFAULT_REPO_CONFIG: &[(&str, &str)] = &[ + // Default to avoiding grub2-mkconfig etc. + ("sysroot.bootloader", "none"), + // Always flip this one on because we need to support alongside installs + // to systems without a separate boot partition. + ("sysroot.bootprefix", "true"), + ("sysroot.readonly", "true"), +]; + /// Kernel argument used to specify we want the rootfs mounted read-write by default const RW_KARG: &str = "rw"; @@ -638,14 +648,7 @@ async fn initialize_ostree_root(state: &State, root_setup: &RootSetup) -> Result crate::lsm::ensure_dir_labeled(rootfs_dir, "boot", None, 0o755.into(), sepolicy)?; } - for (k, v) in [ - // Default to avoiding grub2-mkconfig etc. - ("sysroot.bootloader", "none"), - // Always flip this one on because we need to support alongside installs - // to systems without a separate boot partition. - ("sysroot.bootprefix", "true"), - ("sysroot.readonly", "true"), - ] { + for (k, v) in DEFAULT_REPO_CONFIG.iter() { Command::new("ostree") .args(["config", "--repo", "ostree/repo", "set", k, v]) .cwd_dir(rootfs_dir.try_clone()?) @@ -657,6 +660,19 @@ async fn initialize_ostree_root(state: &State, root_setup: &RootSetup) -> Result ostree::Sysroot::new(Some(&gio::File::for_path(path))) }; sysroot.load(cancellable)?; + let repo = &sysroot.repo(); + + let repo_verity_state = ostree_ext::fsverity::is_verity_enabled(&repo)?; + let prepare_root_composefs = state + .prepareroot_config + .get("composefs.enabled") + .map(|v| ComposefsState::from_str(&v)) + .transpose()? + .unwrap_or(ComposefsState::default()); + if prepare_root_composefs.requires_fsverity() || repo_verity_state.desired == Tristate::Enabled + { + ostree_ext::fsverity::ensure_verity(repo).await?; + } let stateroot_exists = rootfs_dir.try_exists(format!("ostree/deploy/{stateroot}"))?; ensure!( diff --git a/lib/src/install/baseline.rs b/lib/src/install/baseline.rs index e8f02b0b2..ec14e3bf0 100644 --- a/lib/src/install/baseline.rs +++ b/lib/src/install/baseline.rs @@ -162,13 +162,12 @@ pub(crate) fn install_create_rootfs( state: &State, opts: InstallBlockDeviceOpts, ) -> Result { + let install_config = state.install_config.as_ref(); let luks_name = "root"; // Ensure we have a root filesystem upfront let root_filesystem = opts .filesystem - .or(state - .install_config - .as_ref() + .or(install_config .and_then(|c| c.filesystem_root()) .and_then(|r| r.fstype)) .ok_or_else(|| anyhow::anyhow!("No root filesystem specified"))?; @@ -207,7 +206,7 @@ pub(crate) fn install_create_rootfs( } // Use the install configuration to find the block setup, if we have one - let block_setup = if let Some(config) = state.install_config.as_ref() { + let block_setup = if let Some(config) = install_config { config.get_block_setup(opts.block_setup.as_ref().copied())? } else if opts.filesystem.is_some() { // Otherwise, if a filesystem is specified then we default to whatever was @@ -386,8 +385,20 @@ pub(crate) fn install_create_rootfs( None }; + // Unconditionally enable fsverity for ext4 + let mkfs_options = match root_filesystem { + Filesystem::Ext4 => ["-O", "verity"].as_slice(), + _ => [].as_slice(), + }; + // Initialize rootfs - let root_uuid = mkfs(&rootdev, root_filesystem, "root", opts.wipe, [])?; + let root_uuid = mkfs( + &rootdev, + root_filesystem, + "root", + opts.wipe, + mkfs_options.iter().copied(), + )?; let rootarg = format!("root=UUID={root_uuid}"); let bootsrc = boot_uuid.as_ref().map(|uuid| format!("UUID={uuid}")); let bootarg = bootsrc.as_deref().map(|bootsrc| format!("boot={bootsrc}")); diff --git a/ostree-ext/Cargo.toml b/ostree-ext/Cargo.toml index d2e3be5df..b54b5b5ad 100644 --- a/ostree-ext/Cargo.toml +++ b/ostree-ext/Cargo.toml @@ -20,6 +20,7 @@ ostree = { features = ["v2025_1"], version = "0.20.0" } anyhow = { workspace = true } bootc-utils = { path = "../utils" } camino = { workspace = true, features = ["serde1"] } +composefs = { git = "https://github.com/containers/composefs-rs", rev = "55ae2e9ba72f6afda4887d746e6b98f0a1875ac4" } chrono = { workspace = true } olpc-cjson = "0.1.1" clap = { workspace = true, features = ["derive","cargo"] } diff --git a/ostree-ext/src/container/store.rs b/ostree-ext/src/container/store.rs index 2b3f5dfe3..74500f36e 100644 --- a/ostree-ext/src/container/store.rs +++ b/ostree-ext/src/container/store.rs @@ -1058,7 +1058,7 @@ impl ImageImporter { let root_dir = td.open_dir(rootpath)?; let modifier = - ostree::RepoCommitModifier::new(ostree::RepoCommitModifierFlags::CONSUME, None); + ostree::RepoCommitModifier::new(ostree::RepoCommitModifierFlags::empty(), None); modifier.set_devino_cache(&devino); // If we have derived layers, then we need to handle the case where // the derived layers include custom policy. Just relabel everything diff --git a/ostree-ext/src/fsverity.rs b/ostree-ext/src/fsverity.rs new file mode 100644 index 000000000..2929cb072 --- /dev/null +++ b/ostree-ext/src/fsverity.rs @@ -0,0 +1,138 @@ +//! Integration with fsverity + +use std::os::fd::AsFd; +use std::os::unix::ffi::OsStrExt; +use std::path::Path; +use std::str::FromStr; + +use anyhow::{Context, Result}; +use cap_std::fs::Dir; +use cap_std_ext::cap_std; +use composefs::fsverity as composefs_fsverity; +use composefs_fsverity::Sha256HashValue; +use ostree::gio; + +use crate::keyfileext::KeyFileExt; +use crate::ostree_prepareroot::Tristate; + +/// The relative path to the repository config file. +const CONFIG_PATH: &str = "config"; + +/// The ostree integrity config section +pub const INTEGRITY_SECTION: &str = "ex-integrity"; +/// The ostree repo config option to enable fsverity +pub const INTEGRITY_FSVERITY: &str = "fsverity"; + +/// State of fsverity in a repo +#[derive(Debug, Clone)] +pub struct RepoVerityState { + /// True if fsverity is desired to be enabled + pub desired: Tristate, + /// True if fsverity is known to be enabled on all objects + pub enabled: bool, +} + +/// Check if fsverity is fully enabled for the target repository. +pub fn is_verity_enabled(repo: &ostree::Repo) -> Result { + let desired = repo + .config() + .optional_string(INTEGRITY_SECTION, INTEGRITY_FSVERITY)? + .map(|s| Tristate::from_str(s.as_str())) + .transpose()? + .unwrap_or_default(); + let repo_dir = &Dir::reopen_dir(&repo.dfd_borrow())?; + let config = repo_dir + .open(CONFIG_PATH) + .with_context(|| format!("Opening repository {CONFIG_PATH}"))?; + // We use the flag of having fsverity set on the repository config as a flag to say that + // fsverity is fully enabled; all objects have it. + let enabled = + composefs_fsverity::measure_verity_digest::<_, composefs_fsverity::Sha256HashValue>( + config.as_fd(), + ) + .is_ok(); + Ok(RepoVerityState { desired, enabled }) +} + +/// Enable fsverity on regular file objects in this directory. +fn enable_fsverity_in_objdir(d: &Dir) -> anyhow::Result<()> { + for ent in d.entries()? { + let ent = ent?; + if !ent.file_type()?.is_file() { + continue; + } + let name = ent.file_name(); + let Some(b"file") = Path::new(&name).extension().map(|e| e.as_bytes()) else { + continue; + }; + let f = d.open(&name)?; + let enabled = + composefs::fsverity::ioctl::fs_ioc_measure_verity::<_, Sha256HashValue>(f.as_fd())? + .is_some(); + if !enabled { + composefs_fsverity::ioctl::fs_ioc_enable_verity::<_, Sha256HashValue>(&f)?; + } + } + Ok(()) +} + +/// Ensure that fsverity is enabled on this repository. +/// +/// - Walk over all regular file objects and ensure that fsverity is enabled on them +/// - Update the repo config if necessary to ensure that future objects have it by default +/// - Update the repo config to enable fsverity on the file itself as a completion flag +pub async fn ensure_verity(repo: &ostree::Repo) -> Result<()> { + let state = is_verity_enabled(repo)?; + // If we're already enabled, then we're done. + if state.enabled { + return Ok(()); + } + + // Limit concurrency here + const MAX_CONCURRENT: usize = 3; + + let repodir = Dir::reopen_dir(&repo.dfd_borrow())?; + + // It's convenient here to reuse tokio's spawn_blocking as a threadpool basically. + let mut joinset = tokio::task::JoinSet::new(); + + // Walk over all objects + for ent in repodir.read_dir("objects")? { + // Block here if the queue is full + while joinset.len() >= MAX_CONCURRENT { + // SAFETY: We just checked the length so we know there's something pending + let _: () = joinset.join_next().await.unwrap()??; + } + let ent = ent?; + if !ent.file_type()?.is_dir() { + continue; + } + let objdir = ent.open_dir()?; + // Spawn a thread for each object directory just on general principle + // of doing multi-threading. + joinset.spawn_blocking(move || enable_fsverity_in_objdir(&objdir)); + } + + // Drain the remaining tasks. + while let Some(output) = joinset.join_next().await { + let _: () = output??; + } + + // Ensure the flag is set in the config file, which is what libostree parses. + if state.desired != Tristate::Enabled { + let config = repo.copy_config(); + config.set_boolean(INTEGRITY_SECTION, INTEGRITY_FSVERITY, true); + repo.write_config(&config)?; + repo.reload_config(gio::Cancellable::NONE)?; + } + // And finally, enable fsverity as a flag that we have successfully + // enabled fsverity on all objects. + let f = repodir.open(CONFIG_PATH)?; + match composefs_fsverity::ioctl::fs_ioc_enable_verity::<_, composefs_fsverity::Sha256HashValue>( + f.as_fd(), + ) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(()), + Err(e) => Err(e.into()), + } +} diff --git a/ostree-ext/src/lib.rs b/ostree-ext/src/lib.rs index 97ec80de9..53f8267cc 100644 --- a/ostree-ext/src/lib.rs +++ b/ostree-ext/src/lib.rs @@ -16,6 +16,7 @@ // Re-export our dependencies. See https://gtk-rs.org/blog/2021/06/22/new-release.html // "Dependencies are re-exported". Users will need e.g. `gio::File`, so this avoids // them needing to update matching versions. +pub use composefs; pub use containers_image_proxy; pub use containers_image_proxy::oci_spec; pub use ostree; @@ -64,6 +65,7 @@ mod utils; #[cfg(feature = "docgen")] mod docgen; +pub mod fsverity; /// Prelude, intended for glob import. pub mod prelude {