From 8995fafdfd040f3bdb7b7cbff6aa1d4fdd952e19 Mon Sep 17 00:00:00 2001 From: Javier Tia Date: Fri, 4 Sep 2026 12:57:05 -0600 Subject: [PATCH 1/3] rootfs/install: verify the var-key attestation on the composed sysroot meta-avocado gates the /var key provider at three tiers, and none of them runs on the path a device actually receives. Two read cryptsetup-var's own datastore, which a bbappend can clear; the third hangs off a Yocto image recipe's ROOTFS_POSTPROCESS_COMMAND, and this tool composes a sysroot from the feed's RPMs without building an image. The tree already knew: avocado-security-capabilities.bb exists precisely because the image-scope artifact never reached a jetson-orin-nano. So a machine could declare encrypted-var, ship the unusable placeholder provider, and have every build-time check pass or never run. On first boot cryptsetup-var.sh reads the declaration, runs the provider, gets exit 1, and /var never unlocks - or worse, derives a constant and every device in the fleet shares one key. What does survive into the RPM is var-key.sh and the var-key.sh.sha256 that cryptsetup-var's do_install writes after every deliverability check passes. Confirmed on the built package rather than assumed: `rpm -qpl` lists the attestation beside the provider, and the extracted digest matches the extracted script. Checking that pair here re-arms the gate for this path. The absent-provider rule mirrors the build side rather than inventing one: only the initramfs must carry a provider, a rootfs legitimately ships the udev and posture packages without one, and a rootfs that does ship one is still validated. Getting that backwards would fail every rootfs build on a declaring machine, which is what the unit test pins. Ordered before the install stamp and propagated with `?`, unlike the stamp write beside it. A stamp failure leaves a correct sysroot merely unrecorded; this failure leaves a sysroot that must not ship, and stamping it first would record it fresh and skip the check on the next run. Verified end to end, in the real SDK container against a sysroot composed from a real cryptsetup-var RPM: as shipped rc=0, attestation removed rc=1, provider swapped after validation rc=1 with both digests printed. All nine branches of the generated script were exercised separately against fixtures. sha256sum, readlink, grep, cat and cut all resolve in avocadolinux/sdk:2024-edge and sha256sum emits the field shape the script parses. 1758 lib tests pass. Rollout order is a hard constraint and is documented at the function: a feed predating the attestation is refused, not warned, so meta-avocado ships first and feeds rebuild before this goes live. --- src/commands/rootfs/install.rs | 170 +++++++++++++++++++++++++++++++++ 1 file changed, 170 insertions(+) diff --git a/src/commands/rootfs/install.rs b/src/commands/rootfs/install.rs index d1d63e3d..c79b2fd9 100644 --- a/src/commands/rootfs/install.rs +++ b/src/commands/rootfs/install.rs @@ -626,6 +626,53 @@ fn parse_probe_output(text: &str) -> Option { /// `?`-ing out of the caller: the install has already landed and its lock pins /// are recorded in memory by this point, and the caller only persists them when /// `install_sysroot` returns `Ok`. +/// Run the var-key attestation check inside the SDK container. +/// +/// Separate from the build-time tiers on purpose: those run in BitBake and +/// never see this sysroot, because avocado-cli composes from feed RPMs rather +/// than building a Yocto image. +async fn verify_var_key_attestation( + params: &SysrootInstallParams<'_>, + sysroot_dir: &str, +) -> Result<()> { + let config = RunConfig { + container_image: params.container_image.to_string(), + target: params.target.to_string(), + command: generate_var_key_attestation_script( + sysroot_dir, + matches!(params.sysroot_type, SysrootType::Initramfs), + ), + verbose: params.verbose, + source_environment: true, + interactive: false, + repo_url: params.repo_url.map(|s| s.to_string()), + repo_release: params.repo_release.map(|s| s.to_string()), + container_args: params.merged_container_args.clone(), + sdk_arch: params.sdk_arch.cloned(), + tui_context: params.tui_context.clone(), + ..Default::default() + }; + + let outcome = if let Some(context) = params.runs_on_context { + params + .container_helper + .run_in_container_with_context(&config, context) + .await + } else { + params.container_helper.run_in_container(config).await + }; + + match outcome { + Ok(true) => Ok(()), + Ok(false) => Err(anyhow::anyhow!( + "The composed {sysroot_dir} declares encrypted-var but its var-key \ + provider does not match the attestation the build wrote beside it. \ + See the diagnostic above." + )), + Err(e) => Err(e).context("could not run the var-key attestation check"), + } +} + async fn write_install_stamp( params: &SysrootInstallParams<'_>, packages: &HashMap, @@ -681,6 +728,93 @@ async fn write_install_stamp( Ok(()) } +/// Shell that verifies the composed sysroot's var-key provider against the +/// attestation the build wrote beside it. +/// +/// meta-avocado gates this at three tiers, and all three hang off a Yocto +/// *image* recipe or the cryptsetup-var recipe's own datastore. avocado-cli +/// composes a sysroot from the feed's RPMs and builds no image, so none of +/// them runs on the path a device actually receives - the tree says so itself +/// in avocado-security-capabilities.bb, which exists because the image-scope +/// artifact never reached a jetson-orin-nano. +/// +/// What survives into the RPM is `var-key.sh` plus the `var-key.sh.sha256` +/// that cryptsetup-var's do_install writes AFTER every deliverability check +/// passes. Checking the pair here is what re-arms the gate for this path: a +/// provider swapped after validation no longer matches, and a missing +/// attestation means the build-time check never completed over this provider. +/// +/// `require_provider` mirrors the build-side rule - only the initramfs must +/// carry a provider, while a rootfs legitimately ships the udev and posture +/// packages without one, and validates it when it does have one. +/// +/// ROLLOUT ORDER IS NOT OPTIONAL. A feed built before meta-avocado wrote the +/// attestation has no `.sha256`, and this refuses it rather than warning - +/// measured, rc=1, against a sysroot composed from a real pre-change RPM. So +/// meta-avocado ships first and feeds rebuild before this check goes live. +/// Softening the missing-attestation case to a warning would reintroduce +/// exactly the silent pass the check exists to remove, and would do it on the +/// louder of the two signals: the build writes the attestation LAST, so its +/// absence means the deliverability checks did not complete. +/// +/// The utilities below are present in the SDK container and verified there, +/// not assumed: sha256sum, readlink, grep, cat and cut all resolve under +/// /usr/bin in avocadolinux/sdk:2024-edge, `readlink -f` resolves, and +/// sha256sum emits the two-field output `cut -d' ' -f1` expects. +fn generate_var_key_attestation_script(sysroot_dir: &str, require_provider: bool) -> String { + let missing_provider = if require_provider { + r#" echo "avocado: this sysroot declares encrypted-var but ships no" >&2 + echo "avocado: $REL - cryptsetup-var.sh reads the declaration at boot," >&2 + echo "avocado: finds no way to derive a key, and /var never unlocks." >&2 + exit 1"# + } else { + r#" exit 0"# + }; + + format!( + r#" +set -eu +SYSROOT="$AVOCADO_PREFIX/{sysroot_dir}" +CAPS="$SYSROOT/etc/avocado-security-capabilities" +REL="usr/libexec/cryptsetup-var/var-key.sh" +PROV="$SYSROOT/$REL" + +# Undeclared, or unmigrated: nothing to check. +[ -f "$CAPS" ] || exit 0 +grep -qw 'encrypted-var' "$CAPS" || exit 0 + +if [ ! -e "$PROV" ]; then +{missing_provider} +fi + +# A symlink at any component makes this check and the device read different +# files, the same reason the build-side gates resolve rather than lstat. +if [ "$(readlink -f "$PROV")" != "$PROV" ]; then + echo "avocado: $REL resolves outside the sysroot; refusing to ship it" >&2 + exit 1 +fi + +if [ ! -f "$PROV.sha256" ]; then + echo "avocado: $REL has no attestation beside it. cryptsetup-var writes" >&2 + echo "avocado: one only after its deliverability checks pass, so its" >&2 + echo "avocado: absence means those checks did not run over this provider." >&2 + exit 1 +fi + +recorded=$(cat "$PROV.sha256") +actual=$(sha256sum "$PROV" | cut -d' ' -f1) +if [ "$recorded" != "$actual" ]; then + echo "avocado: $REL is NOT the file the build validated." >&2 + echo "avocado: attested $recorded" >&2 + echo "avocado: shipped $actual" >&2 + echo "avocado: whatever it declares about itself, it has not been shown" >&2 + echo "avocado: to derive a device-unique key." >&2 + exit 1 +fi +"# + ) +} + /// Install a sysroot (rootfs or initramfs) via DNF into the SDK container volume. /// /// This is the shared implementation used by `avocado rootfs install`, @@ -1284,6 +1418,14 @@ $DNF_SDK_HOST $DNF_SDK_TARGET_REPO_CONF \ return Err(incomplete_install_error(label, &reason)); } + // This one IS `?`, unlike the stamp write below. A sysroot whose + // var-key provider does not match the attestation the build wrote is + // not a recording problem - it is a sysroot that must not ship, and + // letting it through here is the whole gap this check exists to close. + // Placed before the stamp so a refused sysroot cannot be recorded + // fresh and skipped on the next run. + verify_var_key_attestation(params, sysroot_dir).await?; + // Deliberately not `?`. Unlike the checks above -- which report a // sysroot that is not what the config asked for -- a stamp-write // failure leaves a correct sysroot that simply is not recorded, and the @@ -1516,6 +1658,34 @@ impl RootfsInstallCommand { #[cfg(test)] mod tests { + use super::generate_var_key_attestation_script; + + /// The initramfs must carry a provider; a rootfs legitimately ships the + /// udev and posture packages without one. Getting this backwards fails + /// every rootfs build on a declaring machine, so the two scripts must + /// actually differ on that branch and agree everywhere else. + #[test] + fn attestation_script_requires_a_provider_only_for_the_initramfs() { + let initramfs = generate_var_key_attestation_script("initramfs", true); + let rootfs = generate_var_key_attestation_script("rootfs", false); + + assert!(initramfs.contains("declares encrypted-var but ships no")); + assert!(!rootfs.contains("declares encrypted-var but ships no")); + + // Everything after the absent-provider branch is shared, and is what + // validates a provider that IS present - so a rootfs that ships one is + // still checked rather than waved through. + for script in [&initramfs, &rootfs] { + assert!(script.contains("is NOT the file the build validated")); + assert!(script.contains("has no attestation beside it")); + assert!(script.contains("resolves outside the sysroot")); + assert!(script.contains("grep -qw 'encrypted-var'")); + } + + assert!(initramfs.contains("$AVOCADO_PREFIX/initramfs")); + assert!(rootfs.contains("$AVOCADO_PREFIX/rootfs")); + } + use super::{ build_overlay_script, detect_sysroot_package_removals, incomplete_install_error, incomplete_install_reason, parse_probe_output, From 45991e5c5f5f3d0a339f512e3654ddb97ae0a9b5 Mon Sep 17 00:00:00 2001 From: Javier Tia Date: Fri, 4 Sep 2026 15:04:56 -0600 Subject: [PATCH 2/3] rootfs/install: check the whole unlock path, and run the check in a test The composed-sysroot check verified var-key.sh alone, and everything it asserted about itself was asserted against the script's text rather than its behaviour. Both halves were wrong in ways only execution showed. Verify every component meta-avocado attests: the unlock script, the provider, the optional hardware backend, and the unit that decides whether any of them runs. The table mirrors avocado_var_key_attested_components() in avocado-security-capabilities.bbclass. That list is a hand copy across a repo boundary and nothing binds the two, which the doc comment says plainly; the alternative, shipping a manifest of what to check beside the digests, puts the set in the same attacker-writable directory as the digests and weakens the gate. Decide on the SET rather than on the provider. The old shape exited before the first component check whenever var-key.sh was absent, so on a rootfs the higher-value target was checkable only at the attacker's discretion: a sysroot with a substituted cryptsetup-var.sh, a stale digest and no var-key.sh was measured passing with rc=0 and no stderr, and restoring an untouched provider flipped the same tree to rc=1. Require the initrd-root-fs.target.wants symlink when the unit is present. Deleting it reaches the same outcome as repointing ExecStart for one fewer edit, with every digest still matching. Canonicalise the sysroot once and compare resolved against resolved. The parent-directory check compared readlink -f output against the raw path, so any non-canonical AVOCADO_PREFIX refused a clean sysroot with "resolves outside the sysroot": measured rc=1 on an untampered tree reached through a symlinked component, and again with a trailing slash. A shop that hits that on a legitimate build learns to route around the check, which costs more than the case it was guarding. The build-side half never had this bug because it realpaths both sides. Testing that the sysroot directory exists comes first, because readlink -f exits non-zero on a dangling path and under set -e that killed the script with rc=1 and an empty stderr, which the caller renders as a failed attestation with no reason to show for it. Refusals exit rather than return. A return leaves the refusal depending on set -e still being armed at the call site, and a later "|| true", a wrapping if, or a caller that drops errexit would turn every refusal into an unread stderr line while the script still exited 0, because the last component returns 0 on every machine shipping no hardware backend. An exit inside a function terminates the script however it was called, which is the property a gate needs; the earlier comment argued the opposite and had the semantics backwards. Add tests that run the generated script under sh against real fixture trees, one case per decision branch. Replacing the whole check_component body with "return 0" previously left both text tests green, so the gate could be gutted without failing anything. It now fails two. Every defect listed above was found by executing this script and none was visible in its text, which is the argument for the fixtures over more assertions on the emitted string. The caller's error names the unlock path rather than the provider: there are now seven distinct refusal paths across four files, and the summary line is what survives the TUI. Signed-off-by: Javier Tia --- src/commands/rootfs/install.rs | 478 ++++++++++++++++++++++++++++++--- 1 file changed, 444 insertions(+), 34 deletions(-) diff --git a/src/commands/rootfs/install.rs b/src/commands/rootfs/install.rs index c79b2fd9..e21c30a2 100644 --- a/src/commands/rootfs/install.rs +++ b/src/commands/rootfs/install.rs @@ -665,9 +665,9 @@ async fn verify_var_key_attestation( match outcome { Ok(true) => Ok(()), Ok(false) => Err(anyhow::anyhow!( - "The composed {sysroot_dir} declares encrypted-var but its var-key \ - provider does not match the attestation the build wrote beside it. \ - See the diagnostic above." + "The composed {sysroot_dir} declares encrypted-var but one or more \ + scripts on its /var unlock path failed attestation. See the \ + diagnostic above, which names the file and the reason." )), Err(e) => Err(e).context("could not run the var-key attestation check"), } @@ -738,11 +738,22 @@ async fn write_install_stamp( /// in avocado-security-capabilities.bb, which exists because the image-scope /// artifact never reached a jetson-orin-nano. /// -/// What survives into the RPM is `var-key.sh` plus the `var-key.sh.sha256` -/// that cryptsetup-var's do_install writes AFTER every deliverability check -/// passes. Checking the pair here is what re-arms the gate for this path: a -/// provider swapped after validation no longer matches, and a missing -/// attestation means the build-time check never completed over this provider. +/// What survives into the RPM is every script on the unlock path plus the +/// `.sha256` that cryptsetup-var's do_install writes beside each one AFTER +/// every deliverability check passes. Checking the pairs here is what re-arms +/// the gate for this path: a script swapped after validation no longer +/// matches, and a missing attestation means the build-time check never +/// completed over that file. +/// +/// The set is deliberately wider than the provider. `var-key.sh` only derives +/// 64 bytes; `cryptsetup-var.sh` decides whether it is called, what happens to +/// the result, and whether to refuse - so a constant key file substituted there +/// ships a fleet-wide /var key with `var-key.sh.sha256` still matching. It +/// mirrors `avocado_var_key_attested_components()` in meta-avocado's +/// avocado-security-capabilities.bbclass, which carries the reasoning and the +/// reason `avocado-posture-publish.sh` is not on the list. The two lists are +/// separate copies in separate repos; a component added there and not here +/// silently stops being checked on this path. /// /// `require_provider` mirrors the build-side rule - only the initramfs must /// carry a provider, while a rootfs legitimately ships the udev and posture @@ -764,7 +775,7 @@ async fn write_install_stamp( fn generate_var_key_attestation_script(sysroot_dir: &str, require_provider: bool) -> String { let missing_provider = if require_provider { r#" echo "avocado: this sysroot declares encrypted-var but ships no" >&2 - echo "avocado: $REL - cryptsetup-var.sh reads the declaration at boot," >&2 + echo "avocado: $DIR - cryptsetup-var.sh reads the declaration at boot," >&2 echo "avocado: finds no way to derive a key, and /var never unlocks." >&2 exit 1"# } else { @@ -774,41 +785,157 @@ fn generate_var_key_attestation_script(sysroot_dir: &str, require_provider: bool format!( r#" set -eu -SYSROOT="$AVOCADO_PREFIX/{sysroot_dir}" +# Canonicalised once, because the parent-directory check below compares a +# resolved path against this one. Comparing against the RAW prefix refused a +# perfectly good sysroot whenever $AVOCADO_PREFIX carried a trailing slash or a +# symlinked component - measured rc=1 on an untampered tree under both. The +# build-side half never had this bug: it realpath()s both sides and +# prefix-compares. +RAW="$AVOCADO_PREFIX/{sysroot_dir}" + +# Nothing composed at this path: nothing to check, and NOT a refusal. Tested +# before the canonicalisation below because `readlink -f` exits non-zero on a +# dangling path, which under `set -e` killed the script with rc=1 and an empty +# stderr - a refusal the caller reports as a failed attestation with no +# diagnostic to show for it. Observed in the SDK container against a prefix +# whose symlink resolved outside the mount. +[ -d "$RAW" ] || exit 0 +SYSROOT=$(readlink -f "$RAW") CAPS="$SYSROOT/etc/avocado-security-capabilities" -REL="usr/libexec/cryptsetup-var/var-key.sh" -PROV="$SYSROOT/$REL" +DIR="usr/libexec/cryptsetup-var" +UNITDIR="usr/lib/systemd/system" + +# One entry per attested component, mirroring +# avocado_var_key_attested_components() in meta-avocado's +# avocado-security-capabilities.bbclass. Two directories, because the unit that +# RUNS the unlock path does not live beside the scripts - and binding the +# scripts while leaving the unit unbound is the same mistake one level up as +# binding var-key.sh while leaving cryptsetup-var.sh unbound. +COMPONENTS="\ +$DIR/cryptsetup-var.sh:yes \ +$DIR/var-key.sh:yes \ +$DIR/var-hwkey.sh:no \ +$UNITDIR/cryptsetup-var.service:yes" # Undeclared, or unmigrated: nothing to check. [ -f "$CAPS" ] || exit 0 grep -qw 'encrypted-var' "$CAPS" || exit 0 -if [ ! -e "$PROV" ]; then -{missing_provider} -fi +# Decide on the SET, not on the provider. Gating the component checks on +# var-key.sh made the higher-value target checkable only when the smaller one +# was present: measured, a rootfs sysroot with a substituted cryptsetup-var.sh, +# a stale digest and no var-key.sh passed with rc=0 and no stderr. Restoring an +# untouched var-key.sh flipped the same tree to rc=1. +# +# `-L` as well as `-e` throughout, because a DANGLING symlink satisfies neither +# `-e` nor `-f` and would count as absent. A link named var-key.sh is something +# shipped, not something missing; check_component is what refuses it. +shipped= +for _entry in $COMPONENTS; do + _rel=${{_entry%:*}} + if [ -e "$SYSROOT/$_rel" ] || [ -L "$SYSROOT/$_rel" ]; then + shipped="$shipped $_rel" + fi +done -# A symlink at any component makes this check and the device read different -# files, the same reason the build-side gates resolve rather than lstat. -if [ "$(readlink -f "$PROV")" != "$PROV" ]; then - echo "avocado: $REL resolves outside the sysroot; refusing to ship it" >&2 - exit 1 +# Nothing from cryptsetup-var reached this sysroot. Only the initramfs is +# required to carry the unlock path; a rootfs legitimately ships the udev and +# posture packages without it. +if [ -z "$shipped" ]; then +{missing_provider} fi -if [ ! -f "$PROV.sha256" ]; then - echo "avocado: $REL has no attestation beside it. cryptsetup-var writes" >&2 - echo "avocado: one only after its deliverability checks pass, so its" >&2 - echo "avocado: absence means those checks did not run over this provider." >&2 - exit 1 -fi +# Every refusal calls `exit`, never `return`. A `return 1` leaves the refusal +# depending on `set -e` still being armed at the call site, and this is a +# security gate: a later `|| true`, a wrapping `if`, or a caller that drops +# errexit would turn every refusal into an unread warning on stderr while the +# script still exited 0 - because the last call, `var-hwkey.sh no`, returns 0 +# on every machine that ships no hardware backend. `exit` inside a function +# terminates the script regardless of how it was called, which is the property +# a gate needs. +check_component() {{ + rel="$1" + required="$2" + path="$SYSROOT/$rel" + + # Before the existence test, not after. A dangling symlink fails `-e`, so + # testing existence first reports it as an absent file and an optional + # component would then be skipped outright - the one shape where "not + # there" and "there and wrong" are the same syscall away. + if [ -L "$path" ]; then + echo "avocado: $rel is a symlink. This check would read the link's" >&2 + echo "avocado: target on the build host and the device would read" >&2 + echo "avocado: whatever the same path resolves to at boot, so a" >&2 + echo "avocado: match here says nothing. Ship it as a regular file." >&2 + exit 1 + fi + + if [ ! -e "$path" ]; then + if [ "$required" = no ]; then + # A machine with no key-wrapping engine ships no var-hwkey.sh and + # that is correct. One that DOES ship it still has to attest it, + # which is why absence is skipped here and not the whole component. + return 0 + fi + echo "avocado: this sysroot ships$shipped but no $rel. They install" >&2 + echo "avocado: from one package, so the directory was edited after" >&2 + echo "avocado: packaging. Nothing calls the provider without" >&2 + echo "avocado: cryptsetup-var.sh, and cryptsetup-var.sh has nothing" >&2 + echo "avocado: to call without var-key.sh, so /var never unlocks." >&2 + exit 1 + fi + + # The leaf is a regular file by here, so what this still catches is a + # symlinked PARENT DIRECTORY - the shape a leaf-only guard reads straight + # through, and the one the build-side tiers had to be widened for too. + case "$(readlink -f "$path")" in + "$SYSROOT"/*) : ;; + *) + echo "avocado: $rel resolves outside the sysroot; refusing to ship it" >&2 + exit 1 ;; + esac + + if [ ! -f "$path.sha256" ]; then + echo "avocado: $rel has no attestation beside it. cryptsetup-var" >&2 + echo "avocado: writes one for every script on the unlock path, only" >&2 + echo "avocado: after its deliverability checks pass, so its absence" >&2 + echo "avocado: means those checks did not run over this file." >&2 + exit 1 + fi + + recorded=$(cat "$path.sha256") + actual=$(sha256sum "$path" | cut -d' ' -f1) + if [ "$recorded" != "$actual" ]; then + echo "avocado: $rel is NOT the file the build validated." >&2 + echo "avocado: attested $recorded" >&2 + echo "avocado: shipped $actual" >&2 + echo "avocado: whatever it declares about itself, it has not been" >&2 + echo "avocado: shown to derive a device-unique key." >&2 + exit 1 + fi + return 0 +}} + +# Only ever one component is reported: every refusal exits, so the first one +# wrong is the last thing printed. Order therefore decides WHICH failure an +# operator sees, and cryptsetup-var.sh is the script an attacker substitutes. +for _entry in $COMPONENTS; do + check_component "${{_entry%:*}}" "${{_entry##*:}}" +done -recorded=$(cat "$PROV.sha256") -actual=$(sha256sum "$PROV" | cut -d' ' -f1) -if [ "$recorded" != "$actual" ]; then - echo "avocado: $REL is NOT the file the build validated." >&2 - echo "avocado: attested $recorded" >&2 - echo "avocado: shipped $actual" >&2 - echo "avocado: whatever it declares about itself, it has not been shown" >&2 - echo "avocado: to derive a device-unique key." >&2 +# Attesting the unit is not enough on its own: the same edit that repoints +# ExecStart can instead delete the symlink that pulls the unit into the initrd, +# which leaves every digest matching and the unit simply never started. The +# build stages that link by hand because the systemd preset does not create it +# for a WantedBy=initrd-root-fs.target unit, so its absence is never +# legitimate when the unit itself is present. +LINK="$UNITDIR/initrd-root-fs.target.wants/cryptsetup-var.service" +if [ -e "$SYSROOT/$UNITDIR/cryptsetup-var.service" ] \ + && [ ! -e "$SYSROOT/$LINK" ] && [ ! -L "$SYSROOT/$LINK" ]; then + echo "avocado: this sysroot ships cryptsetup-var.service but not the" >&2 + echo "avocado: $LINK symlink" >&2 + echo "avocado: that pulls it into the initrd. Every digest still matches" >&2 + echo "avocado: and /var is simply never unlocked." >&2 exit 1 fi "# @@ -1686,6 +1813,289 @@ mod tests { assert!(rootfs.contains("$AVOCADO_PREFIX/rootfs")); } + /// Everything above asserts on the TEXT of the generated script, which is + /// worth exactly as much as a mutation can prove. Measured: replacing the + /// whole `check_component` body with `return 0` left both text tests green, + /// so the gate could be gutted without failing anything. + /// + /// This one runs it. `sh` against real fixture trees, one case per decision + /// branch, because every defect found in this script - the dangling-symlink + /// misclassification, the non-canonical prefix false refusal, the provider + /// gating the whole check - was found by executing it and none of them was + /// visible in its text. + #[test] + fn attestation_script_refuses_each_tampered_sysroot() { + use std::os::unix::fs::symlink; + + // (case name, sysroot kind, mutate, expected refusal) + // `None` expects acceptance. + /// One executing case: what it is called, whether the sysroot is an + /// initramfs, how to damage it, and the refusal it must produce + /// (`None` meaning the sysroot must be accepted). + type Case = ( + &'static str, + bool, + fn(&std::path::Path), + Option<&'static str>, + ); + + let cases: Vec = vec![ + ("clean initramfs", true, |_| {}, None), + ( + "unlock script tampered", + true, + |d| { + std::fs::write(d.join("cryptsetup-var.sh"), "#!/bin/sh\nevil\n").unwrap(); + }, + Some("is NOT the file the build validated"), + ), + ( + "unlock script unattested", + true, + |d| std::fs::remove_file(d.join("cryptsetup-var.sh.sha256")).unwrap(), + Some("has no attestation beside it"), + ), + ( + "provider tampered", + true, + |d| { + std::fs::write(d.join("var-key.sh"), "#!/bin/sh\nevil\n").unwrap(); + }, + Some("is NOT the file the build validated"), + ), + ( + "optional backend present and unattested", + true, + |d| std::fs::write(d.join("var-hwkey.sh"), "#!/bin/sh\n").unwrap(), + Some("has no attestation beside it"), + ), + ( + "unit file tampered", + true, + |d| { + let units = d + .parent() + .unwrap() + .parent() + .unwrap() + .parent() + .unwrap() + .join("usr/lib/systemd/system"); + std::fs::write( + units.join("cryptsetup-var.service"), + "[Unit]\nConditionPathExists=/nowhere\n", + ) + .unwrap(); + }, + Some("is NOT the file the build validated"), + ), + ( + "unit enabled-symlink deleted", + true, + |d| { + let units = d + .parent() + .unwrap() + .parent() + .unwrap() + .parent() + .unwrap() + .join("usr/lib/systemd/system"); + std::fs::remove_file( + units.join("initrd-root-fs.target.wants/cryptsetup-var.service"), + ) + .unwrap(); + }, + Some("never unlocked"), + ), + ( + "component is a symlink", + true, + |d| { + std::fs::remove_file(d.join("var-key.sh")).unwrap(); + symlink("/nowhere/var-key.sh", d.join("var-key.sh")).unwrap(); + }, + Some("is a symlink"), + ), + ]; + + for (name, initramfs, mutate, want) in cases { + let root = tempfile::tempdir().unwrap(); + let dir = build_attestation_fixture(root.path(), initramfs); + mutate(&dir); + let (code, err) = run_attestation_script(root.path(), initramfs); + match want { + None => assert_eq!(code, 0, "{name}: expected accept, stderr: {err}"), + Some(needle) => { + assert_eq!(code, 1, "{name}: expected refusal, stderr: {err}"); + assert!(err.contains(needle), "{name}: wrong branch, stderr: {err}"); + } + } + } + } + + /// A rootfs shipping the unlock script but no provider must still be + /// checked. The first version gated every component on `var-key.sh`, so + /// deleting it disarmed the `cryptsetup-var.sh` check entirely - measured + /// rc=0 on a sysroot whose unlock script had been substituted. + #[test] + fn a_rootfs_unlock_script_is_checked_without_a_provider() { + let root = tempfile::tempdir().unwrap(); + let dir = build_attestation_fixture(root.path(), false); + std::fs::remove_file(dir.join("var-key.sh")).unwrap(); + std::fs::remove_file(dir.join("var-key.sh.sha256")).unwrap(); + + let (code, err) = run_attestation_script(root.path(), false); + assert_eq!(code, 1, "expected refusal, stderr: {err}"); + assert!(err.contains("but no"), "wrong branch, stderr: {err}"); + + // And a rootfs shipping none of it is the legitimate case. + let bare = tempfile::tempdir().unwrap(); + let caps = bare.path().join("rootfs/etc"); + std::fs::create_dir_all(&caps).unwrap(); + std::fs::write( + caps.join("avocado-security-capabilities"), + "encrypted-var\n", + ) + .unwrap(); + let (code, err) = run_attestation_script(bare.path(), false); + assert_eq!(code, 0, "bare rootfs should pass, stderr: {err}"); + } + + /// A non-canonical `AVOCADO_PREFIX` must not refuse a clean sysroot. The + /// parent-directory check compared `readlink -f` output against the raw + /// path, so a symlinked component or a trailing slash refused every + /// component with "resolves outside the sysroot" - measured on a tree with + /// no symlink in it. + #[test] + fn a_non_canonical_prefix_does_not_refuse_a_clean_sysroot() { + use std::os::unix::fs::symlink; + + let root = tempfile::tempdir().unwrap(); + let real = root.path().join("real"); + std::fs::create_dir_all(&real).unwrap(); + build_attestation_fixture(&real, true); + + let (code, err) = run_attestation_script(&real, true); + assert_eq!(code, 0, "canonical prefix, stderr: {err}"); + + let link = root.path().join("link"); + symlink(&real, &link).unwrap(); + let (code, err) = run_attestation_script(&link, true); + assert_eq!(code, 0, "symlinked prefix, stderr: {err}"); + + // A prefix with no sysroot under it must exit 0 silently, not die on + // `readlink -f`. Under `set -e` that produced rc=1 with an empty + // stderr, which the caller renders as a failed attestation carrying no + // reason - strictly worse than the silent pass it replaced. + let absent = tempfile::tempdir().unwrap(); + let (code, err) = run_attestation_script(absent.path(), true); + assert_eq!(code, 0, "absent sysroot, stderr: {err}"); + assert!(err.is_empty(), "absent sysroot should be silent: {err}"); + + let dangling = root.path().join("dangling"); + symlink(root.path().join("nowhere"), &dangling).unwrap(); + let (code, err) = run_attestation_script(&dangling, true); + assert_eq!(code, 0, "dangling prefix, stderr: {err}"); + + let trailing = format!("{}/", real.display()); + let (code, err) = run_attestation_script(std::path::Path::new(&trailing), true); + assert_eq!(code, 0, "trailing slash, stderr: {err}"); + } + + /// Writes a sysroot the script should accept: the capability declared, and + /// both required scripts present with matching attestations. + fn build_attestation_fixture(prefix: &std::path::Path, initramfs: bool) -> std::path::PathBuf { + let sysroot = prefix.join(if initramfs { "initramfs" } else { "rootfs" }); + let dir = sysroot.join("usr/libexec/cryptsetup-var"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::create_dir_all(sysroot.join("etc")).unwrap(); + std::fs::write( + sysroot.join("etc/avocado-security-capabilities"), + "encrypted-var\n", + ) + .unwrap(); + for name in ["cryptsetup-var.sh", "var-key.sh"] { + let body = format!("#!/bin/sh\n# {name}\n"); + std::fs::write(dir.join(name), &body).unwrap(); + std::fs::write( + dir.join(format!("{name}.sha256")), + format!("{}\n", sha256_hex(body.as_bytes())), + ) + .unwrap(); + } + + // The unit and the symlink that enables it. The build stages the link + // by hand because the preset does not create one for a + // WantedBy=initrd-root-fs.target unit. + let units = sysroot.join("usr/lib/systemd/system"); + let wants = units.join("initrd-root-fs.target.wants"); + std::fs::create_dir_all(&wants).unwrap(); + let unit = "[Unit]\nConditionPathExists=/etc/avocado/var-encrypt\n"; + std::fs::write(units.join("cryptsetup-var.service"), unit).unwrap(); + std::fs::write( + units.join("cryptsetup-var.service.sha256"), + format!("{}\n", sha256_hex(unit.as_bytes())), + ) + .unwrap(); + std::os::unix::fs::symlink( + "../cryptsetup-var.service", + wants.join("cryptsetup-var.service"), + ) + .unwrap(); + dir + } + + fn sha256_hex(bytes: &[u8]) -> String { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(bytes); + hasher + .finalize() + .iter() + .map(|b| format!("{b:02x}")) + .collect() + } + + fn run_attestation_script(prefix: &std::path::Path, initramfs: bool) -> (i32, String) { + let script = generate_var_key_attestation_script( + if initramfs { "initramfs" } else { "rootfs" }, + initramfs, + ); + let out = std::process::Command::new("sh") + .arg("-c") + .arg(&script) + .env("AVOCADO_PREFIX", prefix) + .output() + .expect("sh"); + ( + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stderr).to_string(), + ) + } + + /// The provider is the smaller target. cryptsetup-var.sh decides whether + /// the provider is called at all and what happens to the 64 bytes it + /// returns, so a check that binds only var-key.sh passes a sysroot whose + /// unlock script was replaced with one that writes a constant key. + /// + /// Asserting on the invocation lines rather than on the shared function + /// body: the body is written once, so a single `contains` there stays true + /// after a component is dropped from the call list, which is exactly the + /// regression this pins. + #[test] + fn attestation_script_checks_every_script_on_the_unlock_path() { + let script = generate_var_key_attestation_script("initramfs", true); + + assert!(script.contains("$DIR/cryptsetup-var.sh:yes")); + assert!(script.contains("$DIR/var-key.sh:yes")); + // Optional in one direction only: a machine with no key-wrapping + // engine ships no var-hwkey.sh, but one that ships it must attest it. + assert!(script.contains("$DIR/var-hwkey.sh:no")); + // The unit decides whether any of the above runs at all. + assert!(script.contains("$UNITDIR/cryptsetup-var.service:yes")); + } + use super::{ build_overlay_script, detect_sysroot_package_removals, incomplete_install_error, incomplete_install_reason, parse_probe_output, From 33631c738e9004985510bbc6beb625bd7d40d7ee Mon Sep 17 00:00:00 2001 From: Javier Tia Date: Fri, 4 Sep 2026 20:03:03 -0600 Subject: [PATCH 3/3] rootfs/install: check the attestation on the up-to-date path too The attestation check sat below the install-stamp short-circuit, so it ran on the first install of a sysroot and never again. A stamp records install INPUTS - the package set and the config that produced them - and stays current whenever nothing about the request changed. It says nothing about the sysroot's CONTENTS, which is the only thing this check reads. That gap is the threat model, not an edge of it. The check exists to catch a script substituted after the sysroot was composed, and an overlay does not change any stamp input, so the first install verified the sysroot and every run after it returned early above the check. Measured against a real composed initramfs sysroot on a local feed, with cryptsetup-var.sh substituted in place: rc=0 and no diagnostic before this change, rc=1 naming the file and both digests after it, and rc=0 again once the original bytes were restored. None of the existing tests could see this. They exercise generate_var_key_attestation_script directly, so they cover what the script decides and not whether the caller asks it. This costs one container exec on an otherwise no-op install, which is a real regression against the short-circuit's "pays nothing" promise and is taken deliberately: a gate that runs only on the first install is not a gate. The cheaper alternatives were both rejected. Hashing the sysroot in -process would duplicate the digest logic outside the container the rest of the check runs in, and gating on a content stamp would need that same hash to decide whether to hash. Signed-off-by: Javier Tia --- src/commands/rootfs/install.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/commands/rootfs/install.rs b/src/commands/rootfs/install.rs index e21c30a2..74e097d3 100644 --- a/src/commands/rootfs/install.rs +++ b/src/commands/rootfs/install.rs @@ -982,6 +982,26 @@ pub async fn install_sysroot(params: &mut SysrootInstallParams<'_>) -> Result<() if let Some(stamp) = params.prefetched_stamp.as_ref() { if let Some(inputs) = compute_install_stamp_inputs(params, &packages)? { if stamp.is_current(&inputs) { + // The attestation check runs even here, and the stamp is + // exactly why it has to. A stamp records install INPUTS - + // the package set and the config that produced them - so it + // is current whenever nothing about the request changed. It + // says nothing about the sysroot's CONTENTS, which is the + // only thing this check looks at. + // + // Skipping it on this path meant a sysroot mutated after + // installation passed forever: the first install verified + // it, and every run after that short-circuited above the + // check. Measured against a real composed sysroot with + // cryptsetup-var.sh substituted in place - rc=0, no + // diagnostic, on the exact overlay-then-install shape the + // check exists to refuse. + // + // This costs one container exec on an otherwise no-op + // install, which is a real regression against the "pays + // nothing" note above and is accepted deliberately: a gate + // that runs only on the first install is not a gate. + verify_var_key_attestation(params, sysroot_dir).await?; print_success( &format!("{label} sysroot is up to date."), OutputLevel::Normal,