Skip to content

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

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

jetm wants to merge 5 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

The gate this verifies is no longer in meta-avocado. It now lives in
meta-avocado-nonpublic PR 3, along with the rest of the security-capability
work, because those features are becoming enterprise. The rollout order is
unchanged in shape and changed in target:

meta-avocado-nonpublic PR 3 merges -> feeds rebuild -> this merges

Nothing about that ordering softened. A feed built before the attestation
exists has no .sha256, and this refuses it rather than warning.

The limitation that arrangement creates, stated plainly. This tool is
public and the contract it enforces is not. Three consequences, and none of
them is hypothetical:

  • A feed built from PUBLIC meta-avocado carries no attestations at all. Its
    cryptsetup-var.bb is 117 lines with no sha256 anywhere, against 1141 in
    the private version, and its avocado-security-capabilities.bbclass is 189
    lines with none of the gate. So this check fails closed against every
    public-built sysroot that declares encrypted-var. That is the correct
    behaviour and it is also a hard coupling: avocado sdk install succeeds or
    fails depending on which repository the feed came from.
  • The component list here is a hand copy of
    avocado_var_key_attested_components(), and that function is now in a
    private repository. Before the move the drift was at least checkable by
    anyone reading both trees. Now a contributor to this repo cannot see the
    source of truth, cannot tell whether the list is current, and will get no
    signal if it goes stale. The alternative previously considered and rejected -
    shipping a manifest of what to check beside the digests - remains rejected
    for the same reason: it puts the set of things-to-check in the same
    attacker-writable directory as the digests.
  • There is no flag to turn this off, deliberately. Adding one would reintroduce
    the silent pass the check exists to remove, and a default-off flag is a
    silent pass with extra steps. The consequence is that the behaviour cannot be
    varied per build, only per feed.

Follow-up, not resolved here: how enterprise features should be applied at
all.
This PR is the first place the public/private boundary produces a
user-visible behaviour difference, and it does so by accident of where a file
happens to live rather than by any deliberate mechanism. There is no notion in
this tool of a feature being enterprise, no way for a public build to say "this
capability is not available to you" rather than failing an attestation check,
and no way for the private side to declare what it provides. Worth designing
before more capabilities move, because the number of places that need the
answer grows with each one.

Related and still undecided: ten machine configurations in public
meta-avocado still declare encrypted-var. Until that changes, a public
build can request a capability whose implementation is private, which is the
shape this check turns into a hard failure.

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 meta-avocado-nonpublic PR 3 lands and feeds rebuild. Undrafting this before then would merge a check with nothing to check against.

jetm added 5 commits September 4, 2026 13:16
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>
The Windows compile check failed: three tests use std::os::unix::fs::symlink
directly, and their two shared helpers create a real symlink and shell out
to `sh`, which does not exist on that target. Neither is incidental - the
tests exist to run the generated attestation script against real fixture
trees rather than assert on its text, and a POSIX shell running against
real filesystem symlinks is the whole point of that exercise.

Gate the three executing tests and their two helpers behind #[cfg(unix)],
matching the crate's existing cfg(unix) convention elsewhere. The two tests
that only assert on the generated script's text stay ungated - they were
never the problem. All 1762 lib tests still pass on Linux, including the
five gated ones, which the gate does not skip on a unix host by
construction.

Signed-off-by: Javier Tia <javier@peridio.com>
This branch pinned rustls 0.23.39, and a new advisory published against
that exact version blocks every push through the cargo-audit pre-push
hook. main already carries 0.23.45; this branch just never picked it up
since it branched.

cargo update -p rustls --precise 0.23.45, which also carries aws-lc-rs
and rustls-webpki forward to satisfy it. 1762 lib tests still pass.

Signed-off-by: Javier Tia <javier@peridio.com>
@jetm

jetm commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

While fixing the Windows compile check, two other build legs went red on this branch (build (qemuarm64, 2024, next), build (qemux86-64, 2024, next), and on rerun build (qemux86-64, 2026, next) too). Confirmed reproducible, not flaky - failed identically on rerun. Real failure, after some normal rpm scriptlet noise:

Error: Failed to walk
    0: IO error for operation on : No such file or directory (os error 2)
    1: No such file or directory (os error 2)

Not caused by either commit on this branch. The two things I added - #[cfg(unix)] on the attestation exec tests, and a rustls/aws-lc-rs lockfile bump to clear RUSTSEC-2026-0285 - don't touch this code path. Confirmed by reverting the lockfile locally and rebuilding: identical result, and the crate whose error format matches (tough, "Failed to walk directory tree '{}': {}") sits at the same version, 0.22.0, before and after the bump.

Not caused by anything else on this branch either, as far as I can tell. Every other branch that ran this same CI workflow today passed, including rel/1.0.0-rc.4 and three separate runs of jschneck/kernel-version-stamp. Only this branch and one unrelated one (peridio-b5/package-ext-compile-stamp) fail.

Where I looked and came up empty. The error text is tough's own WalkDir variant, from its local-datastore directory walk. I checked avocado-cli (this branch, 1.0.0-rc.3, and today's 1.0.0-rc.4), avocadoctl, and stone: avocado-cli and avocadoctl both carry tough only for its schema types (deserializing signed root/timestamp JSON) and never construct a RepositoryLoader or call .datastore() - the walk code is dead from their own call graphs, at every version checked. stone doesn't depend on tough at all. The reusable workflow driving this (avocado-linux/actions/.github/workflows/extension-test.yml@v1) only runs checkout, setup-avocado-cli, sdk install, ext install, ext build - nothing else at the job-definition level.

I don't have a fix. The remaining candidate is the SDK container's own internal tooling (avocadolinux/sdk:...), which isn't something I can inspect by reading a git repo - that's as far as source-level elimination gets. Leaving this here rather than guessing further.

@jetm

jetm commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

Found and fixed the root cause: #274. Nothing to do with tough after all - it was Path::parent() on a bare avocado.yaml returning Some("") rather than None, in avocado-cli's own project_root(). Once #274 lands these two build legs should go green without any change needed here.

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