Skip to content

Verify the var-key attestation on the composed sysroot - #239

Draft
jetm wants to merge 3 commits into
mainfrom
varkey-attest/compose-verify
Draft

Verify the var-key attestation on the composed sysroot#239
jetm wants to merge 3 commits into
mainfrom
varkey-attest/compose-verify

Conversation

@jetm

@jetm jetm commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Problem

meta-avocado gates the /var key provider at three build-time 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 this: avocado-security-capabilities.bb exists precisely
because the image-scope artifact never reached a jetson-orin-nano.

So a machine can declare encrypted-var, ship a substituted unlock path, and
have every build-time check pass or never run. On first boot cryptsetup-var.sh
reads the declaration, runs the provider, and either /var never unlocks or it
opens on a key every device in the fleet shares.

Solution

What survives into the RPM is each script on the unlock path plus the .sha256
that cryptsetup-var's do_install writes beside it after its deliverability
checks pass. Verifying those pairs on the composed sysroot re-arms the gate for
this path.

Key changes

  • generate_var_key_attestation_script emits the check; verify_var_key_attestation runs it in the SDK container against the composed sysroot.
  • Covers every component meta-avocado attests: cryptsetup-var.sh, var-key.sh, the optional var-hwkey.sh, and cryptsetup-var.service. The unit is included because its ExecStart= names which script performs the unlock and its ConditionPathExists= gates the unit, so editing it substitutes or skips the whole path without changing a script digest. The check also requires the initrd-root-fs.target.wants symlink, since deleting that disables the unit while every digest still matches.
  • Decides on the SET rather than on the provider. The first 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.
  • Runs on the install-stamp short-circuit as well as on a fresh install. A stamp records install INPUTS and stays current when nothing about the request changed; it says nothing about the sysroot's CONTENTS, which is all this check reads.
  • Refuses a swapped script, a missing attestation, a symlink at any path component, a partial component set, and an absent unlock path on the initramfs.
  • Canonicalises the sysroot once and prefix-compares, so a non-canonical AVOCADO_PREFIX no longer refuses a clean sysroot.
  • Every refusal exits rather than returns, so a refusal cannot degrade to an unread warning if set -e is 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 .sha256 and is
refused, not warned. Measured, rc=1, against a sysroot composed from a real
pre-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() in
avocado-security-capabilities.bbclass, and nothing binds the two. The
alternative 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 install followed by avocado initramfs install
into a composed sysroot carrying encrypted-var tpm2 and all four attested
components with their digests. Substituting cryptsetup-var.sh in that sysroot
and re-running the install returns rc=1 naming 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=0 and no diagnostic. No unit or fixture
test 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 sh against real sysroot
trees in avocadolinux/sdk:2026-edge, one per decision branch. 1762 lib tests
pass. Mutation-tested: replacing the whole check_component body with
return 0 previously left every test green and now fails two.

Draft until the meta-avocado side lands and feeds rebuild.

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.
jetm added 2 commits September 4, 2026 15:04
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant