Verify the var-key attestation on the composed sysroot - #239
Draft
jetm wants to merge 3 commits into
Draft
Conversation
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.
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 <javier@peridio.com>
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 <javier@peridio.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
meta-avocado gates the
/varkey provider at three build-time tiers, and none ofthem runs on the path a device actually receives. Two read
cryptsetup-var's owndatastore, which a
.bbappendcan clear; the third hangs off a Yocto imagerecipe's
ROOTFS_POSTPROCESS_COMMAND, and this tool composes a sysroot from thefeed's RPMs without building an image.
The tree already knew this:
avocado-security-capabilities.bbexists preciselybecause the image-scope artifact never reached a jetson-orin-nano.
So a machine can declare
encrypted-var, ship a substituted unlock path, andhave every build-time check pass or never run. On first boot
cryptsetup-var.shreads the declaration, runs the provider, and either
/varnever unlocks or itopens on a key every device in the fleet shares.
Solution
What survives into the RPM is each script on the unlock path plus the
.sha256that
cryptsetup-var'sdo_installwrites beside it after its deliverabilitychecks pass. Verifying those pairs on the composed sysroot re-arms the gate for
this path.
Key changes
generate_var_key_attestation_scriptemits the check;verify_var_key_attestationruns it in the SDK container against the composed sysroot.cryptsetup-var.sh,var-key.sh, the optionalvar-hwkey.sh, andcryptsetup-var.service. The unit is included because itsExecStart=names which script performs the unlock and itsConditionPathExists=gates the unit, so editing it substitutes or skips the whole path without changing a script digest. The check also requires theinitrd-root-fs.target.wantssymlink, since deleting that disables the unit while every digest still matches.var-key.shwas absent, so on a rootfs the higher-value target was checkable only at the attacker's discretion.AVOCADO_PREFIXno longer refuses a clean sysroot.exits rather thanreturns, so a refusal cannot degrade to an unread warning ifset -eis ever disarmed at the call site.Reviewer notes
Rollout order is a hard dependency, and this should not merge first. A feed
built before meta-avocado writes the attestation has no
.sha256and isrefused, not warned. Measured,
rc=1, against a sysroot composed from a realpre-change RPM. meta-avocado ships first, feeds rebuild, then this.
Softening the missing-attestation case to a warning would reintroduce exactly
the silent pass this removes.
The component list is a hand copy across a repo boundary. It mirrors
avocado_var_key_attested_components()inavocado-security-capabilities.bbclass, and nothing binds the two. Thealternative considered and rejected was shipping a manifest of what to check
beside the digests, which puts the set of things-to-check in the same
attacker-writable directory as the digests.
Verified end to end on a real feed, not only against fixtures. A local
package feed was built from this branch's meta-avocado tree, served over HTTP,
and installed with
avocado sdk installfollowed byavocado initramfs installinto a composed sysroot carrying
encrypted-var tpm2and all four attestedcomponents with their digests. Substituting
cryptsetup-var.shin that sysrootand re-running the install returns
rc=1naming the file and both digests;restoring the original bytes returns
rc=0.That E2E is what found the last defect here. The check previously sat below the
install-stamp short-circuit, so it ran once on first install and never again,
and a tampered sysroot passed with
rc=0and no diagnostic. No unit or fixturetest could see it: they exercise the generated script directly, so they cover
what the script decides and not whether the caller asks it.
Twelve fixture cases run the generated script under
shagainst real sysroottrees in
avocadolinux/sdk:2026-edge, one per decision branch. 1762 lib testspass. Mutation-tested: replacing the whole
check_componentbody withreturn 0previously left every test green and now fails two.Draft until the meta-avocado side lands and feeds rebuild.