Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package sdbootutil for openSUSE:Factory checked in at 2026-09-09 16:19:29 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/sdbootutil (Old) and /work/SRC/openSUSE:Factory/.sdbootutil.new.1265 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "sdbootutil" Wed Sep 9 16:19:29 2026 rev:110 rq:1376603 version:1+git20260909.7cfa1f0 Changes: -------- --- /work/SRC/openSUSE:Factory/sdbootutil/sdbootutil.changes 2026-09-04 12:37:53.943292417 +0200 +++ /work/SRC/openSUSE:Factory/.sdbootutil.new.1265/sdbootutil.changes 2026-09-09 16:21:39.164279909 +0200 @@ -1,0 +2,19 @@ +Wed Sep 09 11:42:02 UTC 2026 - Alberto Planas Dominguez <[email protected]> + +- Update to version 1+git20260909.7cfa1f0: + * Drop PCR warning when is not part of the policy + +------------------------------------------------------------------- +Tue Sep 08 13:51:04 UTC 2026 - Alberto Planas Dominguez <[email protected]> + +- Update to version 1+git20260908.c641fc2: + * Update the shim when required + * Warn when recovery PIN is different from recovery key + * Improves extra boot entries support + * Better report when the default snapshot diverges + * Explain how to do re-enrolling if recovery PIN fails + * Explain why update-prediction fails and how to solve it + * Hide comparison output + * Add bootctl default entry in the prediction + +------------------------------------------------------------------- Old: ---- sdbootutil-1+git20260903.f91f636.obscpio New: ---- sdbootutil-1+git20260909.7cfa1f0.obscpio ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ sdbootutil.spec ++++++ --- /var/tmp/diff_new_pack.2hWZOR/_old 2026-09-09 16:21:40.513336124 +0200 +++ /var/tmp/diff_new_pack.2hWZOR/_new 2026-09-09 16:21:40.516336248 +0200 @@ -17,8 +17,45 @@ %global rustflags '-Clink-arg=-Wl,-z,relro,-z,now' + +# Body shared by the file triggers below. `sdbootutil update` is the +# one command that reconciles the ESP with the system: the bootloader, +# the shim and the extra entries that other packages drop in +# `entries.d`. It is asked here for all of them, because the answer +# to "did any of them change" is the same command. +# +# The `--disable-predictions` marker is set by the snapper plugin, +# which already scheduled a deferred update-predictions for this +# transaction. The plugin sets it in the pre snapshot, so it is here +# before this trigger runs, and the deferred service runs after the +# post snapshot, once the entries that this trigger does not touch are +# in place. Updating the predictions here too builds the pcrlock +# policy and rewrites the TPM2 NVIndex a second time, inside the rpm +# transaction, and that first policy is discarded by the deferred run +# seconds later. +# +# Without the snapper plugin there is no marker and no deferral, so +# the trigger stays the only chance to update the predictions +%global sdbootutil_update_trigger() \ +cat > /dev/null || : \ +[ "$YAST_IS_RUNNING" != 'instsys' ] || exit 0 \ +[ -e /sys/firmware/efi/efivars ] || exit 0 \ +[ -z "$TRANSACTIONAL_UPDATE" ] || exit 0 \ +[ -z "$VERBOSE_FILETRIGGERS" ] || echo "%{name}-%{version}-%{release}: updating bootloader" \ +predictions= \ +[ ! -e /run/sdbootutil/update-predictions ] || predictions=--disable-predictions \ +if [ -e /etc/sysconfig/bootloader ]; then \ + . /etc/sysconfig/bootloader &> /dev/null \ + if [ "$LOADER_TYPE" = "grub2-bls" ] || [ "$LOADER_TYPE" = "systemd-boot" ]; then \ + sdbootutil update $predictions \ + fi \ +else \ + sdbootutil update $predictions \ +fi \ +%{nil} + Name: sdbootutil -Version: 1+git20260903.f91f636 +Version: 1+git20260909.7cfa1f0 Release: 0 Summary: Bootctl wrapper for BLS boot loaders License: MIT @@ -200,33 +237,24 @@ # present, as sdbootutil is called for enrollment install -d -m 700 %{buildroot}%{_sharedstatedir}/%{name} -%transfiletriggerin -- %{_prefix}/lib/systemd/boot/efi %{_datadir}/grub2/%{_build_arch}-efi %{_datadir}/efi/%{_build_arch} -cat > /dev/null || : -[ "$YAST_IS_RUNNING" != 'instsys' ] || exit 0 -[ -e /sys/firmware/efi/efivars ] || exit 0 -[ -z "$TRANSACTIONAL_UPDATE" ] || exit 0 -[ -z "$VERBOSE_FILETRIGGERS" ] || echo "%{name}-%{version}-%{release}: updating bootloader" -# The marker is set by the snapper plugin, that already scheduled a -# deferred update-predictions for this transaction. The plugin sets -# it in the pre snapshot, so it is here before this trigger runs, and -# the deferred service runs after the post snapshot, once the entries -# that this trigger does not touch are in place. Updating the -# predictions here too builds the pcrlock policy and rewrites the TPM2 -# NVIndex a second time, inside the rpm transaction, and that first -# policy is discarded by the deferred run seconds later. -# -# Without the snapper plugin there is no marker and no deferral, so -# this trigger stays the only chance to update the predictions -predictions= -[ ! -e /run/sdbootutil/update-predictions ] || predictions=--disable-predictions -if [ -e /etc/sysconfig/bootloader ]; then - . /etc/sysconfig/bootloader &> /dev/null - if [ "$LOADER_TYPE" = "grub2-bls" ] || [ "$LOADER_TYPE" = "systemd-boot" ]; then - sdbootutil update $predictions - fi -else - sdbootutil update $predictions -fi +# Drop-in point for the extra boot entries of other packages. It is +# owned here, and not only by whoever drops a file in it, because it +# is the interface and because the file trigger below watches it +install -d -m 755 %{buildroot}%{_prefix}/lib/%{name}/entries.d + +%transfiletriggerin -- %{_prefix}/lib/systemd/boot/efi %{_datadir}/grub2/%{_build_arch}-efi %{_datadir}/efi/%{_build_arch} %{_prefix}/lib/%{name}/entries.d +%sdbootutil_update_trigger + +# An extra entry is removed by the same `sdbootutil update` that adds +# it: `install_extra_entries` deletes the ESP binary and the loader +# entry of every `entries.d` file that is no longer there. Nothing +# else would notice the removal, so without this trigger the menu +# keeps offering an entry whose package is gone. The bootloader +# directories are deliberately not watched here: a bootloader is +# removed only to install another one, and that installation fires the +# trigger above +%transfiletriggerpostun -- %{_prefix}/lib/%{name}/entries.d +%sdbootutil_update_trigger %preun %service_del_preun %{name}-update-predictions.service @@ -273,6 +301,8 @@ %{_tmpfilesdir}/%{name}.conf %dir %{_libexecdir}/%{name} %{_libexecdir}/%{name}/uhmac +%dir %{_prefix}/lib/%{name} +%dir %{_prefix}/lib/%{name}/entries.d %files snapper %dir %{_prefix}/lib/snapper ++++++ _servicedata ++++++ --- /var/tmp/diff_new_pack.2hWZOR/_old 2026-09-09 16:21:40.576338749 +0200 +++ /var/tmp/diff_new_pack.2hWZOR/_new 2026-09-09 16:21:40.584339082 +0200 @@ -1,6 +1,6 @@ <servicedata> <service name="tar_scm"> <param name="url">https://github.com/openSUSE/sdbootutil.git</param> - <param name="changesrevision">f91f636d0fe261f4fecf667716deaf8ad35b4f5f</param></service></servicedata> + <param name="changesrevision">7cfa1f0ab1bba2c808ef5ff63aff22b26aa42d08</param></service></servicedata> (No newline at EOF) ++++++ sdbootutil-1+git20260903.f91f636.obscpio -> sdbootutil-1+git20260909.7cfa1f0.obscpio ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/sdbootutil-1+git20260903.f91f636/10-sdbootutil.snapper new/sdbootutil-1+git20260909.7cfa1f0/10-sdbootutil.snapper --- old/sdbootutil-1+git20260903.f91f636/10-sdbootutil.snapper 2026-09-03 15:44:04.000000000 +0200 +++ new/sdbootutil-1+git20260909.7cfa1f0/10-sdbootutil.snapper 2026-09-09 13:40:14.000000000 +0200 @@ -168,13 +168,24 @@ [ "$path" = "/" ] || return 0 [ "$fs" = btrfs ] || return 1 - /usr/bin/sdbootutil update --disable-predictions "$num" - # If we are in a transactional system we abort here. The # kernel entries are added later via set_default_snapshot, # when tukit set via snapper the new snapshot as complete is_transactional && return 0 + # `update` is deliberately not given "$num". Unlike the kernel + # entries below, everything it writes is shared by every snapshot + # (the bootloader, the shim, and the extra entries that packages + # drop in /usr/lib/sdbootutil/entries.d), so it has to reflect the + # snapshot that boots, never the one that is merely being created. + # A "pre" snapshot does not have the packages that the transaction + # is about to install, so reconciling the ESP against it deletes + # their files -- restored only by the "post" snapshot, and not at + # all if the transaction fails in between. With no argument the + # running root is used, which is what the rpm file trigger does + # too, and it is already the correct answer at both events + /usr/bin/sdbootutil update --disable-predictions + # The entries are added here only for Tumbleweed # (non-transactional systems) /usr/bin/sdbootutil add-all-kernels --disable-predictions "$num" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/sdbootutil-1+git20260903.f91f636/sdbootutil new/sdbootutil-1+git20260909.7cfa1f0/sdbootutil --- old/sdbootutil-1+git20260903.f91f636/sdbootutil 2026-09-03 15:44:04.000000000 +0200 +++ new/sdbootutil-1+git20260909.7cfa1f0/sdbootutil 2026-09-09 13:40:14.000000000 +0200 @@ -352,13 +352,14 @@ Install the bootloader and shim into ESP needs-update - Check whether the bootloader in ESP needs updating + Check whether the bootloader or the shim in ESP needs + updating update - Update the bootloader in the ESP if a newer version - is available. Passing the --sync option will also - allow downgrades, ensuring that the version in the ESP - matches the one installed in the system. + Update the bootloader and the shim in the ESP if a + newer version is available. Passing the --sync option + will also allow downgrades, ensuring that the version + in the ESP matches the one installed in the system. force-update Update the bootloader in any case @@ -392,20 +393,30 @@ Secrets: Each one is taken from the --ask-X parameter, the X variable - or the %u:sdbootutil-x keyring entry, in that order: + or the %u:sdbootutil-x keyring entry, in that order. + + Presented, to authorize a change: parameter variable keyring (%u) Current password --ask-current-pw CURRENT_PW cryptenroll - New recovery key --ask-key KEY sdbootutil-key - New password --ask-pw PW sdbootutil-pw - TPM2 PIN --ask-pin PIN sdbootutil-tpm2-pin Recovery PIN --ask-recovery-pin RECOVERY_PIN sdbootutil-recovery-pin + Enrolled, as the new secret: + + Recovery key --ask-key KEY sdbootutil-key + Password --ask-pw PW sdbootutil-pw + TPM2 PIN --ask-pin PIN sdbootutil-tpm2-pin + The TPM2 PIN is typed at every boot; the recovery PIN re-authorizes the pcrlock NVIndex, and PIN also names it when there is no TPM2 PIN (--method=tpm2, update-predictions). - --ask-pin asks both. With no source, the recovery key and - the recovery PIN are generated and printed. + --ask-pin asks both. + + The recovery PIN cannot be changed: the same value has to + authorize the old NVIndex and seal the new one. It is sealed + by the first enrollment, taken from the recovery key so that + there is a single secret to keep, and only presented after + that. With no source both are generated and printed. Prefer the keyring, it expires and the environment does not. %u:sdbootutil (any secret, read last) and %u:sdbootutil-pin @@ -2568,6 +2579,35 @@ return 0 } +shim_version() +{ + local fn="$1" + if [ -z "$1" ]; then + is_shim_installed || return 1 + if [ -n "$arg_portable" ]; then + fn="${esp_root}/EFI/BOOT/BOOT${firmware_arch^^}.EFI" + else + fn="${esp_root}${esp_dst}/shim.efi" + fi + fi + [ -e "$fn" ] || return 1 + + # Useless as it reports mayor.minor, so append the last update + # time until the minutes, as the FAT store dates differently + # than other filesystems + local v + # The leading `$` is a literal, and grep only reads it as one + # when it is escaped + # shellcheck disable=SC2016 + read -r _ v _ < <(grep -ao '\$Version: [^$]\+ \$' "$fn") + [ -n "$v" ] || return 1 + v="$v-$(date -r "$fn" +'%Y%m%d%H%M')" + + dbg "Shim version $v" + + echo "$v" +} + bootloader_version() { local fn="$1" @@ -2605,6 +2645,13 @@ bootloader_version > /dev/null && [ -e "${esp_root}/${esp_dst}/installed_by_sdbootutil" ] } +find_shim() +{ + local prefix="" + [ -z "$have_snapshots" ] || prefix="/.snapshots/${1-$root_snapshot}/snapshot" + echo "$prefix$shimdir/shim.efi" +} + find_sdboot() { local prefix="" @@ -2654,6 +2701,38 @@ fi } +shim_needs_update() +{ + local snapshot="" + [ -z "$have_snapshots" ] || snapshot="${1-$root_snapshot}" + + info "Checking if shim needs update" + + local v nv + # The status of the next commands is checked below, so it must + # be collected in the same line: `set -e` would end the script + # before the check, and without any message + v="$(shim_version)" || : + [ -n "$v" ] || return 1 + info "Deployed shim version $v" + nv="$(shim_version "$(find_shim "$snapshot")")" || : + [ -n "$nv" ] || return 1 + info "System shim version $nv" + local status=0 + # Only the exit status is wanted here, for the same reason as in + # `bootloader_needs_update` + systemd-analyze compare-versions "$v" "$nv" > /dev/null 2>&1 || status="$?" + if [ "$status" = "11" ]; then + info "shim is newer than system shim" + return 2 + elif [ "$status" = "12" ]; then + info "shim needs to be updated" + return 0 + fi + info "shim is already up-to-date" + return 1 +} + bootloader_needs_update() { local prefix="" @@ -2677,17 +2756,31 @@ [ -n "$nv" ] || return 1 info "System version $nv" local status=0 - systemd-analyze compare-versions "$v" "$nv" 2> /dev/null || status="$?" + # The comparison goes to stdout ("261.2 == 261.2"), and only the + # exit status is wanted here. Our stdout is read through a pipe by + # transactional-update and weak-modules2, and it also lands in the + # rpm scriptlet output, where the bare line explains nothing + systemd-analyze compare-versions "$v" "$nv" > /dev/null 2>&1 || status="$?" bldr_name=$(bootloader_name "$snapshot") + if [ "$status" = "12" ]; then + info "$bldr_name needs to be updated" + return 0 + fi + + # `install_bootloader` deploys shim and the bootloader together, so + # a new shim alone is already a reason to run it. It is asked here + # and not by the caller because both are the one question, "is what + # the ESP boots the one the system has" + local shim_status=0 + shim_needs_update "$snapshot" || shim_status=$? + [ "$shim_status" != "0" ] || return 0 + if [ "$status" = "11" ]; then info "$bldr_name is newer than system bootloader" return 2 - elif [ "$status" = "12" ]; then - info "$bldr_name needs to be updated" - return 0 fi info "$bldr_name is already up-to-date" - return 1 + return "$shim_status" } esp_root_drive_partno_dseq() @@ -2723,20 +2816,21 @@ extra_entry_needs_update() { - local entry="$1" - local nv v - - # If we do not have a version in the entry file (or the entry - # file is missing), then we assume that an update is required - [ -n "$entry" ] || return 0 - nv="$(entry_field "$entry" version)" - [ -n "$nv" ] || return 0 - - entry="${boot_root}/loader/entries/$(basename "$entry")" - v="$(entry_field "$entry" version)" - [ -n "$v" ] || return 0 + local efi="$1" + local entry="$2" - systemd-analyze compare-versions "$v" "<" "$nv" 2> /dev/null + # The `version` field of the entry says which release the + # provider calls this build, and nothing more: a package can be + # rebuilt with a new binary and the same version (a re-signed + # EFI is exactly that), and it can be downgraded, which a + # "newer than" comparison reads as "nothing to do" while the + # ESP keeps a binary that the system no longer has. What has + # to be in the ESP is what is installed now, so the files are + # what is asked + cmp -s "$efi" "${esp_root}/EFI/extra/$(basename "$efi")" || return 0 + [ -n "$entry" ] || return 1 + cmp -s "$entry" "${boot_root}/loader/entries/$(basename "$entry")" || return 0 + return 1 } install_extra_entry() @@ -2762,7 +2856,8 @@ # updated) echo "/EFI/extra/$(basename "${e[EFI]}")" - extra_entry_needs_update "${subvol:1}${e[ENTRY]}" || return 0 + extra_entry_needs_update "${subvol:1}${e[EFI]}" \ + "${e[ENTRY]:+${subvol:1}${e[ENTRY]}}" || return 0 mkdir -p "${esp_root}/EFI/extra" install -p -D "${subvol:1}${e[EFI]}" "${esp_root}/EFI/extra" @@ -3179,6 +3274,77 @@ fi } +# The snapshot that the boot loader is going to boot, which is not +# always the one that snapper calls default. snapper switches the +# btrfs default subvolume before running the plugin, and the entry +# that the boot loader reads is written from inside it, so an +# interruption in that window, a manual `btrfs subvolume set-default` +# or an aborted rollback leaves the two pointing at different +# snapshots, for good. Nothing repairs it later, as every actor keeps +# consulting the one it wrote (bsc#1252227) +# +# Empty when no default is set, when the default entry is gone, or +# when it describes no snapshot (a system without snapshots, or an +# entry that this script does not manage) +default_entry_snapshot() +{ + local id + id="$(get_default_entry 2> /dev/null)" || return 0 + [ -n "$id" ] || return 0 + + update_entries_for_this_system + entry_snapshot "$id" < "$entryfile" +} + +# The snapshot that an entry describes, read from the entry list in +# stdin. Empty when the entry is not in the list or describes no +# snapshot +# +# The ID is matched case insensitively: when it comes from +# "LoaderEntryDefault" it has been through `bli_efi_var_get`, which +# lower-cases what it reads, and an entry token is not always lower +# case (same workaround as in "list_entries") +entry_snapshot() +{ + local id="${1:?}" + + jq -r --arg id "$id" '.[] + | select((.id | ascii_downcase) == ($id | ascii_downcase)) + | ((.options // "") | capture("rootflags=subvol=[^ ]*/\\.snapshots/(?<n>[0-9]+)/snapshot") | .n) + // ((.version // "") | capture("^(?<n>[0-9]+)@") | .n) + // empty' +} + +# The snapshot that snapper reports as default, which is the btrfs +# default subvolume +default_snapper_snapshot() +{ + [ -n "$have_snapshots" ] || return 0 + [ -s "$snapperfile" ] || update_snapper + jq -r '.root[]|select(.default==true)|.number' "$snapperfile" +} + +# Say it when the two defaults disagree. Nothing repairs this on its +# own: each actor keeps reading the store it wrote, and the machine +# goes on booting an entry that no tool calls the default until the +# next successful `set-default-snapshot` writes both again +# +# The repair is that same command, with the snapshot that the boot +# loader already boots: aligning the other way would point the boot +# loader at a snapshot whose entries may be the ones that failed to +# install +warn_default_snapshot_diverged() +{ + local esp snapper + esp="$(default_entry_snapshot)" + snapper="$(default_snapper_snapshot)" + [ -n "$esp" ] && [ -n "$snapper" ] || return 0 + [ "$esp" != "$snapper" ] || return 0 + + warn "The default boot entry boots snapshot $esp, but snapper and btrfs default to $snapper" + warn "Run 'sdbootutil set-default-snapshot $esp' to align them" +} + set_timeout_sdboot() { local timeout="${1:?}" @@ -3426,6 +3592,18 @@ [ -n "${snapshots[$root_snapshot]}" ] || snapshot_ids+=("$root_snapshot") snapshots["$root_snapshot"]=1 fi + # What the boot loader is going to boot comes before what + # snapper calls default. They are the same snapshot in the + # ordinary case, and then this costs no slot, but when they + # disagree it is the boot loader that decides which entry is + # measured, and sealing the other one is sealing a boot that + # never happens: the machine comes up asking for the recovery + # key and no later update can notice (bsc#1252227) + id="$(default_entry_snapshot)" + if [ -n "$id" ]; then + [ -n "${snapshots[$id]}" ] || snapshot_ids+=("$id") + snapshots[$id]=1 + fi while read -r id; do [ -n "${snapshots[$id]}" ] || snapshot_ids+=("$id") snapshots[$id]=1 @@ -3515,6 +3693,9 @@ # higher one first # local filter + # $id, $ids and $kernel below are jq variables, bound by --arg + # and by "as", so the filter has to reach jq unexpanded + # shellcheck disable=SC2016 if [ -n "$have_snapshots" ]; then filter='def priority(id): id as $id | $ids | split(" ") | index($id); map(. + {"priority": priority(.version | scan("(\\d+)@") | .[]), "kernel": .version | scan(".*@(?:(\\d+).(\\d+).(\\d+)-(\\d+))") | map(. | tonumber)})' else @@ -4406,7 +4587,7 @@ # problem that may not be there [ -r "/sys/class/tpm/tpm0/pcr-$bank/$i" ] || continue # sysfs writes them in upper case, the policy in lower - current="$(tr 'A-Z' 'a-z' < "/sys/class/tpm/tpm0/pcr-$bank/$i")" + current="$(tr '[:upper:]' '[:lower:]' < "/sys/class/tpm/tpm0/pcr-$bank/$i")" jq -e --arg c "$current" --argjson p "$i" \ '.pcrValues[] | select(.pcr == $p) | .values | index($c)' \ < /var/lib/systemd/pcrlock.json > /dev/null || \ @@ -4416,6 +4597,113 @@ echo "$stale" } +# What a PCR covers, and what legitimately moves it. The second half of +# each line is the one that matters: a number alone cannot tell the +# reader whether the change was theirs +pcr_meaning() +{ + case "${1:?}" in + 0) echo "firmware code. A firmware update moves it" ;; + 1) echo "firmware settings. A BIOS setting or boot order change moves it" ;; + 2) echo "option ROM code. Adding or removing a plug-in card moves it" ;; + 3) echo "option ROM settings. Adding or removing a plug-in card moves it" ;; + 4) echo "the boot loader binaries. A shim, systemd-boot or GRUB2 update moves it, and so does booting another entry" ;; + 5) echo "the partition table. Adding, removing or resizing a partition moves it" ;; + 7) echo "secure boot state and certificates. Toggling secure boot, or a keys, db or dbx update, moves it" ;; + 8) echo "the GRUB2 commands. Editing the command line or the GRUB2 configuration moves it" ;; + 9) echo "the kernel and initrd. A kernel or initrd update moves it" ;; + 11) echo "the UKI sections. A kernel update moves it" ;; + 12) echo "the kernel command line and credentials. Editing either moves it" ;; + 14) echo "the shim MOK certificates. Using mokutil moves it" ;; + *) echo "something this tool cannot describe" ;; + esac +} + +# The two places a recovery PIN can be read from, as "k:e" flags so that +# a caller can still report them one by one. Both empty means the only +# copy left is the one sealed in the TPM2, and that one can only be +# unsealed while the PCRs still match the policy +recovery_pin_sources() +{ + local keyring="" environment="" + + ! recovery_pin_keyid > /dev/null 2>&1 || keyring=1 + [ -z "$env_recovery_pin$env_pin" ] || environment=1 + + echo "$keyring:$environment" +} + +# The enrolled TPM2 method, for the commands the warnings below tell +# the user to run. `unenroll` cannot tell "tpm2" from "tpm2+pin" (both +# wipe the same slot), but `enroll` can: only the second one passes +# `--tpm2-with-pin=1`, so advising "tpm2" on a machine that asks for a +# PIN at boot would quietly enroll it without one. The header is the +# only place that knows, and "tpm2" is the safe answer when it cannot +# be read: it is also what the machine has +enrolled_tpm2_method() +{ + local dev + + if detect_tracked_devices; then + for dev in "${tracked_devices[@]}"; do + luks_header "$dev" 2> /dev/null | jq -e ' + [ (.tokens // {})[] + | select(.type == "systemd-tpm2") + | ."tpm2-pin" // false ] | any' \ + > /dev/null 2>&1 || continue + echo "tpm2+pin" + return 0 + done + fi + + echo "tpm2" +} + +# A PIN that cannot be read here is the normal state, not a problem: +# `systemd-pcrlock` always seals one in the policy metadata, the keyring +# copy lives $keyctl_timeout seconds and never survives a reboot, and +# from then on the sealed one is unsealed without help on every update, +# because the PCRs still satisfy the policy that sealed it. +# +# What is a dead end is that unseal failing: a bound PCR that moved out +# of the alternatives the policy accepts locks the NVIndex, and without +# a reachable PIN every `update-predictions` from then on fails the same +# way (github issue 250). So both halves have to hold before saying +# anything, and the pair is checked here, and not only in `status`, +# because this runs on every kernel and boot loader update while nobody +# runs `status` before hitting it. Right after a policy was written the +# PCRs match by construction, so in practice this is the "Policy is up +# to date" return, the one path where nothing authorizes the NVIndex and +# a stale PCR would otherwise stay unreported until the next change +warn_no_recovery_pin() +{ + [ "$(recovery_pin_sources)" = ":" ] || return 0 + + local stale + stale="$(policy_stale_pcrs)" + [ -n "$stale" ] || return 0 + + warn "PCR $stale no longer match the policy, and no recovery PIN is reachable, so every 'update-predictions' from now on fails the same way" + explain_pcrlock_recovery "$stale" +} + +# What the PCRs that moved measure, and the two ways out. Both ways +# trust the current boot path, so tampering has to be ruled out before +# either. The list can be empty when sysfs does not export the PCRs +explain_pcrlock_recovery() +{ + local i + for i in ${1//,/ }; do + warn "PCR $i measures $(pcr_meaning "$i")" + done + + local m + m="$(enrolled_tpm2_method)" + warn "If you made no such change, the boot path was altered. Both fixes below trust the current state, so explain it first" + warn "With the recovery PIN: re-run with --ask-recovery-pin (or RECOVERY_PIN=...)" + warn "Without it: check that a password opens the devices, then 'sdbootutil unenroll --method=$m' and 'sdbootutil enroll --method=$m --ask-recovery-pin'" +} + # `systemd-pcrlock make-policy` has to authorize the NVIndex against # the policy that is already stored in it. With a recovery PIN that is # a PolicySigned and no PCR takes part, but without one the PIN is first @@ -4436,20 +4724,52 @@ local errorfile="$1" local pin="$2" - grep -qE "None of the alternative values|PCR values kept changing|Failed to submit super PCR policy|Failed to unseal PIN" "$errorfile" || return 0 - # A recovery PIN was used, so the PCRs were not part of the - # authorization and this is a different problem. Saying anything - # about PCRs here would only send the reader the wrong way - [ -z "$pin" ] || return 0 + # authorization and this is a different problem: the PIN is what has + # to satisfy the PolicySigned of the NVIndex, and the TPM2 refusing + # the write is what a wrong one looks like. Saying anything about + # PCRs here would only send the reader the wrong way + if [ -n "$pin" ]; then + grep -q "Failed to write to NV index" "$errorfile" || return 0 + + warn "The TPM2 rejected the recovery PIN, so the NVIndex keeps the policy it had" + + # The recovery key and the recovery PIN are meant to be the + # same secret, and a key enrolled while the PIN was out of + # reach used to be published under the name of the PIN. A + # "PIN" that opens the devices is that confusion, and it + # cannot become the PIN: `systemd-pcrlock` authorizes the + # NVIndex with the PIN that sealed it, and only with it + local dev + for dev in "${tracked_devices[@]}"; do + valid_password "$dev" "$pin" || continue + warn "That value opens $dev, so it is the recovery key and not the recovery PIN of the NVIndex" + break + done + + local m + m="$(enrolled_tpm2_method)" + warn "Use the recovery PIN that sealed the NVIndex, or start again with one you keep: 'sdbootutil unenroll --method=$m' then 'sdbootutil enroll --method=$m --ask-recovery-pin'" + return 0 + fi local stale stale="$(policy_stale_pcrs)" - warn "The policy stored in the TPM2 cannot be re-authorized${stale:+: PCR $stale no longer match it}" - warn "No recovery PIN was available, so the one sealed in the TPM2 had to be used, and reading it back needs the current PCRs to match the previous prediction. This does not recover by itself, every new run fails the same way" - warn "With the recovery PIN, re-run with --ask-recovery-pin (or RECOVERY_PIN=...)" - warn "Without it, the policy has to be created again: 'systemd-pcrlock remove-policy' and then 'sdbootutil enroll --method=tpm2'. Until that is done the TPM2 does not open the device, so first make sure that a password does" + # Where the authorization fails decides what is printed. + # `tpm2_policy_super_pcr()` names the PCR it could not satisfy, but + # the same stale PCR can instead break the later + # `Esys_PolicyAuthorizeNV`, and that one says nothing about PCRs. + # Both are the one condition, so the second form is only taken when + # a PCR really did move: any other TPM2 error reaching + # PolicyAuthorizeNV must not collect this explanation + if ! grep -qE "None of the alternative values|PCR values kept changing|Failed to submit super PCR policy|Failed to unseal PIN" "$errorfile"; then + [ -n "$stale" ] || return 0 + grep -qE "Failed to submit AuthorizeNV policy" "$errorfile" || return 0 + fi + + warn "The TPM2 policy cannot be re-authorized${stale:+: PCR $stale moved}. No recovery PIN was available, so every new run fails the same way" + explain_pcrlock_recovery "$stale" } shim_changed() @@ -4868,6 +5188,20 @@ # keyring, so that it is available to `sdbootutil # enroll --method=recovery-key` keyctl_add_with_timeout "sdbootutil-recovery-pin" "$pin" + + # A recovery key that is already in the LUKS2 header was + # made equal to the recovery PIN of that moment, and + # `enroll_recovery_key` says so when it enrolls it. This + # PIN is a new one, so from here on they are two different + # secrets, and the value printed above is the only copy of + # this one + local rdev + for rdev in "${tracked_devices[@]}"; do + have_slot "$rdev" "recovery" || continue + warn "There is already a recovery key for $rdev" + warn "The recovery key and the recovery PIN are now different" + break + done fi [ -e /var/lib/systemd/pcrlock.json ] || { @@ -4932,16 +5266,18 @@ # *current* password, so it is tried first [ -z "$env_current_pw" ] || candidates+=("$env_current_pw") - # PW, KEY and the %user:sdbootutil* entries hold the *new* - # secret for the password, recovery-key and tpm2+pin methods, - # and during the first enrollment they can contain the recovery - # PIN, so every candidate is validated before it is used. + # PW, KEY, RECOVERY_PIN and the %user:sdbootutil* entries hold + # the *new* secret for the password, recovery-key and tpm2+pin + # methods, and during the first enrollment they can contain the + # recovery PIN, which is then also the recovery key, so every + # candidate is validated before it is used. # Adding a wrong one is not harmless: keyctl replaces the # payload, so it would drop a valid password. Because of the # validation, %user:sdbootutil is not ambiguous here and it is # not deprecated as a candidate [ -z "$env_pw" ] || candidates+=("$env_pw") [ -z "$env_key" ] || candidates+=("$env_key") + [ -z "$env_recovery_pin" ] || candidates+=("$env_recovery_pin") local name for name in sdbootutil-pw sdbootutil-key sdbootutil-recovery-pin sdbootutil-pin sdbootutil; do if keyid="$(keyctl id %user:"$name" 2> /dev/null)"; then @@ -5225,6 +5561,19 @@ generate_tpm2_predictions_pcrlock + # Only once the policy is good: `generate_tpm2_predictions_pcrlock` + # leaves the script on failure, and there the missing PIN is + # already the subject of `explain_pcrlock_policy_error`. Here it + # also covers the "Policy is up to date" exit, which is the common + # one and the one that would otherwise never say anything + warn_no_recovery_pin + + # The policy that was just written covers the entry that the boot + # loader selects, so the machine still unlocks, but a divergence + # spends one of the few slots (two under grub2-bls) on a snapshot + # that nothing is going to boot + warn_default_snapshot_diverged + # Generate a PCR 15 prediction only in certain cases, as for # now this will ask the password (can be resolved by an # external tool that extract the password from the TPM2 if the @@ -5775,9 +6124,13 @@ key="$env_key" elif [ -n "$keyid_key" ]; then key="$(keyctl pipe "$keyid_key")" - elif [ -n "$keyid_int" ]; then + elif [ -n "$env_recovery_pin" ]; then # There is no recovery key, but there is a recovery PIN: - # make both the same one (see below) + # make both the same one (see below). RECOVERY_PIN wins + # over the keyring entry, as it does in + # generate_tpm2_predictions_pcrlock + key="$env_recovery_pin" + elif [ -n "$keyid_int" ]; then key="$(keyctl pipe "$keyid_int")" elif [ -n "$keyid_dep" ]; then deprecated_keyring_secret "recovery key" "sdbootutil-key" @@ -5821,21 +6174,6 @@ check_enrolled "$dev" "recovery" "$header" || return 1 - # If we enroll a recovery key first, we can use the generated - # key as a recovery PIN later when we enroll a TPM2[+PIN] - # (note that the recovery PIN is not the same PIN for the - # tpm2+pin method). - # - # If we enroll the recovery key after the TPM2 enrollment and - # we send the recovery PIN via the keyring, then we can make - # the recovery key the same as the recovery PIN. But if the - # PIN is missing from the keyring, then we missed the - # synchronization and the key and the PIN are different. - if [ -z "$keyid_int" ] && have_slot "$dev" "tpm2"; then - warn "There is already a recovery PIN for the TPM2" - warn "The recovery key and the recovery PIN are now different" - fi - if [ -n "$generated_key" ]; then echo "Recovery key: $key" if [ -x /usr/bin/qrencode ]; then @@ -5850,14 +6188,37 @@ if [ -z "$keyid" ] && [ -z "$unlock_method" ]; then keyctl_add_with_timeout "cryptenroll" "$key" fi - # ... and to --method=tpm2[+pin] via a private keyring. An - # entry that is already there holds the recovery PIN that - # protects the pcrlock NV index, and it is not overwritten: - # that PIN cannot be recovered from anywhere else - if [ -z "$keyid_int" ]; then - keyctl_add_with_timeout "sdbootutil-recovery-pin" "$key" - elif [ "$key" != "$(keyctl pipe "$keyid_int")" ]; then + # ... and to --method=tpm2[+pin] via a private keyring, so that + # the key enrolled here is the recovery PIN that seals the + # pcrlock NVIndex and the user has a single secret to keep. + # That is only possible while the NVIndex has no PIN yet, or + # while the one it has is reachable: the PIN cannot be read back + # from the TPM2, and `systemd-pcrlock` takes a single + # --recovery-pin, which has to authorize the old NVIndex and + # seal the new one, so it cannot be replaced by this key either + local reachable_pin= + if [ -n "$env_recovery_pin" ]; then + reachable_pin="$env_recovery_pin" + elif [ -n "$keyid_int" ]; then + reachable_pin="$(keyctl pipe "$keyid_int")" + fi + if [ -n "$reachable_pin" ]; then + # The value presented is the recovery PIN, and it is not + # replaced: that PIN cannot be recovered from anywhere else + [ "$key" = "$reachable_pin" ] || \ + warn "The recovery key and the recovery PIN are now different" + elif have_slot "$dev" "tpm2"; then + # A PIN sealed the NVIndex of this enrollment and it is + # not reachable here, so this key is not it. Publishing + # it would name as the recovery PIN a value that no policy + # update can use, and `status` and `warn_no_recovery_pin` + # would report a PIN that is not there, which is the one + # warning that must reach a machine heading into the dead + # end of github issue 250 + warn "There is already a recovery PIN for the TPM2, and it is not reachable here" warn "The recovery key and the recovery PIN are now different" + else + keyctl_add_with_timeout "sdbootutil-recovery-pin" "$key" fi } @@ -6091,6 +6452,16 @@ ;; "recovery-key") + # The recovery key and the recovery PIN of the + # pcrlock NVIndex are the same secret when sdbootutil + # enrolled both. Only the first one lives in this + # slot: the PIN stays in the TPM2, and it is what + # re-authorizes the policy on the next update, so the + # value cannot be discarded with the slot + if have_slot "$dev" "tpm2"; then + warn "The recovery PIN of the TPM2 policy is not removed with the recovery key of $dev" + warn "Keep the value: it is still needed to update the predictions" + fi systemd-cryptenroll \ --wipe-slot=recovery \ "$dev" @@ -6510,7 +6881,7 @@ else # sysfs writes them in upper case, the policy in # lower - current="$(tr 'A-Z' 'a-z' < "/sys/class/tpm/tpm0/pcr-$bank/$i")" + current="$(tr '[:upper:]' '[:lower:]' < "/sys/class/tpm/tpm0/pcr-$bank/$i")" if jq -e --arg c "$current" --argjson p "$i" \ '.pcrValues[] | select(.pcr == $p) | .values | index($c)' \ < "$policy" > /dev/null; then @@ -6585,7 +6956,11 @@ # TPM_Extend is a hash over the concatenation of the two # binary digests. `printf %b` writes the NUL bytes # straight to the pipe, which a command substitution - # around the binary would have eaten + # around the binary would have eaten. ${var//;/} cannot + # replace this sed: the pattern is every *pair* of + # characters and "&" is the pair that matched, and bash + # has globs there, without repetition or back-reference + # shellcheck disable=SC2001 value="$(printf '%b' "$(sed 's/../\\x&/g' <<<"$value$digest")" | \ openssl dgst -sha256 -r | cut -d' ' -f1)" measured=1 @@ -6686,29 +7061,22 @@ # Without it a policy that no longer matches the PCRs cannot be # rewritten, and that is a dead end rather than an inconvenience # (github issue 250) - local keyring="" environment="" issue="" f + local keyring environment + IFS=: read -r keyring environment <<<"$(recovery_pin_sources)" - ! recovery_pin_keyid > /dev/null 2>&1 || keyring=1 - [ -z "$env_recovery_pin$env_pin" ] || environment=1 - for f in /run/issue.d/91-recovery-pin.issue /run/issue.d/91-recovery-pin.conf; do - [ ! -e "$f" ] || issue=1 - done - - [ -z "$keyring$environment$issue" ] || status_pin_reachable=1 + [ -z "$keyring$environment" ] || status_pin_reachable=1 echo "Recovery PIN (only whether it can be reached, never its value)" if [ -n "$arg_full" ]; then status_row "Kernel keyring" "$(status_yn "$keyring")" status_row "Environment" "$(status_yn "$environment")" - status_row "Issue file" "$(status_yn "$issue")" return 0 fi local where=() [ -z "$keyring" ] || where+=("kernel keyring") [ -z "$environment" ] || where+=("environment") - [ -z "$issue" ] || where+=("issue file") if [ "${#where[@]}" -eq 0 ]; then status_row "Reachable" "no" @@ -6939,10 +7307,24 @@ local next="${oneshot:-$default}" local broken="" ! status_is_dangling "$next" || broken=" -- DOES NOT BOOT, it names a file that is not in the ESP" + # What snapper and btrfs call the default is not what the boot + # loader reads, it only has to agree with it. When it does not, + # each tool keeps answering for its own store and none of them + # looks wrong on its own, so this is reported where the default + # entry is named and nowhere else + local diverged="" + if [ -n "$have_snapshots" ] && [ -n "$default" ]; then + local esp_snapshot="" btrfs_snapshot="" + esp_snapshot="$(entry_snapshot "$default" <<<"$entries")" + btrfs_snapshot="$(default_snapper_snapshot)" + [ -z "$esp_snapshot" ] || [ -z "$btrfs_snapshot" ] || [ "$esp_snapshot" = "$btrfs_snapshot" ] || \ + diverged=" -- boots snapshot $esp_snapshot, while snapper and btrfs default to $btrfs_snapshot; run 'sdbootutil set-default-snapshot $esp_snapshot' to align them" + fi + if [ -n "$oneshot" ]; then - status_row "Next boot" "$oneshot (one shot, and then ${default:-the default})$broken" + status_row "Next boot" "$oneshot (one shot, and then ${default:-the default})$broken$diverged" else - status_row "Next boot" "${default:-unknown, no default is set}$broken" + status_row "Next boot" "${default:-unknown, no default is set}$broken$diverged" fi [ -z "$arg_full" ] || { @@ -7050,12 +7432,15 @@ # current PCRs to satisfy the *old* policy (github issue 250) [ -z "$status_policy_stale" ] || { echo + local i if [ -n "$status_pin_reachable" ]; then - warn "PCR $status_policy_stale no longer match the policy. The next 'sdbootutil update-predictions' rewrites it, and a recovery PIN is reachable to authorize the new NVIndex" + warn "PCR $status_policy_stale no longer match the policy. The next 'sdbootutil update-predictions' rewrites it with the reachable recovery PIN, trusting the current boot path" + for i in ${status_policy_stale//,/ }; do + warn "PCR $i measures $(pcr_meaning "$i")" + done else - warn "PCR $status_policy_stale no longer match the policy, and no recovery PIN is reachable" - warn "The PIN sealed in the TPM2 is the only one left, and reading it back needs the current PCRs to match the previous prediction, which is what just failed. This does not recover by itself: every 'update-predictions' from now on fails the same way" - warn "First make sure that a password opens the devices, then 'systemd-pcrlock remove-policy' and 'sdbootutil enroll --method=tpm2'" + warn "PCR $status_policy_stale no longer match the policy, and no recovery PIN is reachable, so every 'update-predictions' from now on fails the same way" + explain_pcrlock_recovery "$status_policy_stale" fi } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/sdbootutil-1+git20260903.f91f636/sdbootutil.spec new/sdbootutil-1+git20260909.7cfa1f0/sdbootutil.spec --- old/sdbootutil-1+git20260903.f91f636/sdbootutil.spec 2026-09-03 15:44:04.000000000 +0200 +++ new/sdbootutil-1+git20260909.7cfa1f0/sdbootutil.spec 2026-09-09 13:40:14.000000000 +0200 @@ -17,6 +17,43 @@ %global rustflags '-Clink-arg=-Wl,-z,relro,-z,now' + +# Body shared by the file triggers below. `sdbootutil update` is the +# one command that reconciles the ESP with the system: the bootloader, +# the shim and the extra entries that other packages drop in +# `entries.d`. It is asked here for all of them, because the answer +# to "did any of them change" is the same command. +# +# The `--disable-predictions` marker is set by the snapper plugin, +# which already scheduled a deferred update-predictions for this +# transaction. The plugin sets it in the pre snapshot, so it is here +# before this trigger runs, and the deferred service runs after the +# post snapshot, once the entries that this trigger does not touch are +# in place. Updating the predictions here too builds the pcrlock +# policy and rewrites the TPM2 NVIndex a second time, inside the rpm +# transaction, and that first policy is discarded by the deferred run +# seconds later. +# +# Without the snapper plugin there is no marker and no deferral, so +# the trigger stays the only chance to update the predictions +%global sdbootutil_update_trigger() \ +cat > /dev/null || : \ +[ "$YAST_IS_RUNNING" != 'instsys' ] || exit 0 \ +[ -e /sys/firmware/efi/efivars ] || exit 0 \ +[ -z "$TRANSACTIONAL_UPDATE" ] || exit 0 \ +[ -z "$VERBOSE_FILETRIGGERS" ] || echo "%{name}-%{version}-%{release}: updating bootloader" \ +predictions= \ +[ ! -e /run/sdbootutil/update-predictions ] || predictions=--disable-predictions \ +if [ -e /etc/sysconfig/bootloader ]; then \ + . /etc/sysconfig/bootloader &> /dev/null \ + if [ "$LOADER_TYPE" = "grub2-bls" ] || [ "$LOADER_TYPE" = "systemd-boot" ]; then \ + sdbootutil update $predictions \ + fi \ +else \ + sdbootutil update $predictions \ +fi \ +%{nil} + Name: sdbootutil Version: 0 Release: 0 @@ -200,33 +237,24 @@ # present, as sdbootutil is called for enrollment install -d -m 700 %{buildroot}%{_sharedstatedir}/%{name} -%transfiletriggerin -- %{_prefix}/lib/systemd/boot/efi %{_datadir}/grub2/%{_build_arch}-efi %{_datadir}/efi/%{_build_arch} -cat > /dev/null || : -[ "$YAST_IS_RUNNING" != 'instsys' ] || exit 0 -[ -e /sys/firmware/efi/efivars ] || exit 0 -[ -z "$TRANSACTIONAL_UPDATE" ] || exit 0 -[ -z "$VERBOSE_FILETRIGGERS" ] || echo "%{name}-%{version}-%{release}: updating bootloader" -# The marker is set by the snapper plugin, that already scheduled a -# deferred update-predictions for this transaction. The plugin sets -# it in the pre snapshot, so it is here before this trigger runs, and -# the deferred service runs after the post snapshot, once the entries -# that this trigger does not touch are in place. Updating the -# predictions here too builds the pcrlock policy and rewrites the TPM2 -# NVIndex a second time, inside the rpm transaction, and that first -# policy is discarded by the deferred run seconds later. -# -# Without the snapper plugin there is no marker and no deferral, so -# this trigger stays the only chance to update the predictions -predictions= -[ ! -e /run/sdbootutil/update-predictions ] || predictions=--disable-predictions -if [ -e /etc/sysconfig/bootloader ]; then - . /etc/sysconfig/bootloader &> /dev/null - if [ "$LOADER_TYPE" = "grub2-bls" ] || [ "$LOADER_TYPE" = "systemd-boot" ]; then - sdbootutil update $predictions - fi -else - sdbootutil update $predictions -fi +# Drop-in point for the extra boot entries of other packages. It is +# owned here, and not only by whoever drops a file in it, because it +# is the interface and because the file trigger below watches it +install -d -m 755 %{buildroot}%{_prefix}/lib/%{name}/entries.d + +%transfiletriggerin -- %{_prefix}/lib/systemd/boot/efi %{_datadir}/grub2/%{_build_arch}-efi %{_datadir}/efi/%{_build_arch} %{_prefix}/lib/%{name}/entries.d +%sdbootutil_update_trigger + +# An extra entry is removed by the same `sdbootutil update` that adds +# it: `install_extra_entries` deletes the ESP binary and the loader +# entry of every `entries.d` file that is no longer there. Nothing +# else would notice the removal, so without this trigger the menu +# keeps offering an entry whose package is gone. The bootloader +# directories are deliberately not watched here: a bootloader is +# removed only to install another one, and that installation fires the +# trigger above +%transfiletriggerpostun -- %{_prefix}/lib/%{name}/entries.d +%sdbootutil_update_trigger %preun %service_del_preun %{name}-update-predictions.service @@ -273,6 +301,8 @@ %{_tmpfilesdir}/%{name}.conf %dir %{_libexecdir}/%{name} %{_libexecdir}/%{name}/uhmac +%dir %{_prefix}/lib/%{name} +%dir %{_prefix}/lib/%{name}/entries.d %files snapper %dir %{_prefix}/lib/snapper diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/sdbootutil-1+git20260903.f91f636/tests/README.md new/sdbootutil-1+git20260909.7cfa1f0/tests/README.md --- old/sdbootutil-1+git20260903.f91f636/tests/README.md 2026-09-03 15:44:04.000000000 +0200 +++ new/sdbootutil-1+git20260909.7cfa1f0/tests/README.md 2026-09-09 13:40:14.000000000 +0200 @@ -81,6 +81,47 @@ # fde-state diff before after ``` +## Secret routing (`tests/unit`) + +`run` never writes outside a copy of the ESP, which is what makes it +safe to point at a machine one wants to keep — and what puts enrollment +out of its reach. `enroll` writes LUKS2 headers and the pcrlock +NVIndex, and it cannot even be aimed: `detect_tracked_devices` builds +its list from `lsblk` ∩ `/etc/crypttab`, so a scratch container added to +crypttab is enrolled *alongside* the real root, not instead of it. + +`tests/unit` covers the part that does not need any of that. Which +source of a secret wins, what is published to the kernel keyring, and +which warnings are printed are all decided before anything is written, +and a scratch LUKS2 container on a loop device answers `cryptsetup` and +`systemd-cryptenroll` exactly like a root device would. It sources the +library half of `sdbootutil` — everything above the `####### main` +marker, which the completion data already relies on — and calls the +functions one at a time. + +```console +# tests/unit +ok get_device_password takes CURRENT_PW +... +13 cases, all passed +``` + +It needs root, for `losetup` and the keyring, and it uses the real +keyring names because the names are in the code: it refuses to start +when one of them is already there rather than overwrite a secret left +behind by an installer or an interrupted enrollment, and it purges them +between cases. Every case runs in a subshell with its own environment, +against a freshly formatted header, and can replace a function — +`t_erk_unreachable_pin` fakes a TPM2 slot, because the answer it checks +must not depend on the machine having a TPM2. + +What stays out of reach here is the NVIndex itself: authorizing with the +old recovery PIN, the "not needed, so not validated" skip, the dead end +of github issue 250. Those need a real TPM2 and destroy the enrollment +of the machine they run on, so they belong to a destructive guest suite +that does not exist yet, and whose first requirement is a way to put a +guest back. + ## Adding a scenario A scenario is a bash script in `scenarios/`, run on the guest with diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/sdbootutil-1+git20260903.f91f636/tests/fde-state new/sdbootutil-1+git20260909.7cfa1f0/tests/fde-state --- old/sdbootutil-1+git20260903.f91f636/tests/fde-state 2026-09-03 15:44:04.000000000 +0200 +++ new/sdbootutil-1+git20260909.7cfa1f0/tests/fde-state 2026-09-09 13:40:14.000000000 +0200 @@ -689,7 +689,7 @@ [ -r "/sys/class/tpm/tpm0/pcr-$bank/$i" ] || continue checked=$((checked + 1)) # sysfs prints upper case, the policy stores lower case - current="$(tr 'A-Z' 'a-z' < "/sys/class/tpm/tpm0/pcr-$bank/$i")" + current="$(tr '[:upper:]' '[:lower:]' < "/sys/class/tpm/tpm0/pcr-$bank/$i")" jq -e --arg c "$current" --argjson p "$i" \ '.pcrValues[] | select(.pcr == $p) | .values | index($c)' \ < /var/lib/systemd/pcrlock.json > /dev/null || \ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/sdbootutil-1+git20260903.f91f636/tests/run new/sdbootutil-1+git20260909.7cfa1f0/tests/run --- old/sdbootutil-1+git20260903.f91f636/tests/run 2026-09-03 15:44:04.000000000 +0200 +++ new/sdbootutil-1+git20260909.7cfa1f0/tests/run 2026-09-09 13:40:14.000000000 +0200 @@ -99,6 +99,9 @@ exit 1 } +# The callers build the remote command with local values ($scratch, the +# scenario name), so the expansion on this side is the point of it +# shellcheck disable=SC2029 on() { local ip="$1"; shift @@ -107,6 +110,7 @@ # Like on(), but the exit status is the command's. on() pipes through # grep to drop the host-key notice, which would swallow it +# shellcheck disable=SC2029 on_q() { local ip="$1"; shift diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/sdbootutil-1+git20260903.f91f636/tests/unit new/sdbootutil-1+git20260909.7cfa1f0/tests/unit --- old/sdbootutil-1+git20260903.f91f636/tests/unit 1970-01-01 01:00:00.000000000 +0100 +++ new/sdbootutil-1+git20260909.7cfa1f0/tests/unit 2026-09-09 13:40:14.000000000 +0200 @@ -0,0 +1,391 @@ +#!/bin/bash +# SPDX-License-Identifier: MIT +# SPDX-FileCopyrightText: Copyright 2026 SUSE LLC +# +# Unit tests for the routing of the secrets: which source wins, what is +# published to the kernel keyring, and which warnings are printed. +# +# These are decisions the tool takes *before* it writes anything to the +# TPM2, so they do not need one, and they do not need a guest either: a +# scratch LUKS2 container on a loop device answers `cryptsetup` and +# `systemd-cryptenroll` exactly like a real root device does. What +# cannot be reached from here is everything about the pcrlock NVIndex +# (authorizing with the old PIN, the "not needed, so not validated" +# skip); that needs a real TPM2 and destroys the enrollment of the +# machine it runs on, so it belongs to a guest and not to this file. +# +# `tests/run` drives the boot and FDE state of a whole guest and never +# writes outside a copy of the ESP. This one is the opposite: it writes +# LUKS2 headers, but only the ones it made itself. +# +# Usage: +# tests/unit [-h] +# +# Needs root, for losetup and for the keyring. The entries it uses are +# the real ones -- the names are in the code -- so it refuses to start +# when one of them is already there, rather than overwrite a secret that +# an installer or an interrupted enrollment left behind. + +set -e +shopt -s nullglob + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +top="$(dirname "$here")" + +# The password of the scratch container, and the credential that +# authorizes every enrollment below +BOOTSTRAP="bootstrap-password" + +# The keyring entries sdbootutil reads or writes. Purged between cases +# so that one cannot inherit what the one before it left +KEYS=(cryptenroll sdbootutil sdbootutil-key sdbootutil-pw sdbootutil-pin + sdbootutil-recovery-pin sdbootutil-tpm2-pin) + +[ "${1:-}" != "-h" ] && [ "${1:-}" != "--help" ] || { + sed -n '4,26p' "${BASH_SOURCE[0]}" | sed 's/^# \?//' + exit 0 +} + +skip() +{ + echo "SKIP: $*" + exit 77 +} + +die() +{ + echo "ERROR: $*" >&2 + exit 1 +} + +[ "$(id -u)" = 0 ] || skip "needs root for losetup and the keyring" +for cmd in cryptsetup systemd-cryptenroll keyctl losetup jq; do + command -v "$cmd" > /dev/null || skip "$cmd is not installed" +done +for key in "${KEYS[@]}"; do + keyctl id %user:"$key" &> /dev/null && \ + die "%user:$key is in the keyring. Purge it first: keyctl purge user $key" +done + +# The library half of sdbootutil. Everything below the marker is the +# command dispatcher, which would run on source; everything above it is +# definitions, and the marker is already there for the completion data +lib= +dev= +workdir="$(mktemp -d -t sdbootutil-unit.XXXXXX)" + +cleanup_unit() +{ + [ -z "$dev" ] || losetup -d "$dev" 2> /dev/null || true + rm -rf "$workdir" + purge_keys +} +trap cleanup_unit EXIT + +purge_keys() +{ + local key + for key in "${KEYS[@]}"; do + keyctl purge user "$key" &> /dev/null || true + done +} + +extract_lib() +{ + grep -q '^####### main #######$' "$top/sdbootutil" || \ + die "the 'main' marker is gone from sdbootutil, nothing to source" + lib="$workdir/lib.sh" + sed '/^####### main #######$/,$d' "$top/sdbootutil" > "$lib" + # shellcheck source=/dev/null + ( . "$lib" ) || die "the library half of sdbootutil does not source cleanly" +} + +# A fresh LUKS2 header for every case, so that the slots of one do not +# reach the next. pbkdf2 with the lowest iteration count keeps a format +# under a tenth of a second; nothing here measures key derivation +format_device() +{ + cryptsetup luksFormat --type luks2 --batch-mode \ + --pbkdf pbkdf2 --pbkdf-force-iterations 1000 \ + "$dev" <<<"$BOOTSTRAP" +} + +setup_device() +{ + truncate -s 32M "$workdir/luks.img" + dev="$(losetup --find --show "$workdir/luks.img")" +} + +####### the assertions ####### +# +# They report and continue, so that one case tells the whole story +# instead of only its first mistake. `case_failed` is a plain variable +# because every case runs in its own subshell + +fail() +{ + echo " $*" + case_failed=1 +} + +is() +{ + [ "$2" = "$3" ] || fail "$1: expected '$2', got '$3'" +} + +says() +{ + case "$2" in + *"$3"*) ;; + *) fail "$1: '$3' was not said" ;; + esac +} + +says_not() +{ + case "$2" in + *"$3"*) fail "$1: '$3' was said, and should not" ;; + esac +} + +key_is() +{ + local id value= + if id="$(keyctl id %user:"$1" 2> /dev/null)"; then + value="$(keyctl pipe "$id")" + fi + is "keyring %user:$1" "$2" "$value" +} + +no_key() +{ + ! keyctl id %user:"$1" &> /dev/null || \ + fail "keyring %user:$1 exists, and should not" +} + +opens() +{ + cryptsetup luksOpen --test-passphrase --disable-external-tokens \ + "$dev" <<<"$1" &> /dev/null || \ + fail "$2: the value does not open the device" +} + +# Source the library with whatever the case put in the environment, and +# take back the `set -e` that sdbootutil turns on: an assertion has to +# be able to fail without ending the case +load() +{ + # shellcheck source=/dev/null + . "$lib" + set +e +} + +####### the cases ####### +# +# get_device_password: every source of a *current* password, and the +# rule that a candidate is validated before it is used + +t_gdp_current_pw() +{ + CURRENT_PW="$BOOTSTRAP" load + + is "returned" "$BOOTSTRAP" "$(get_device_password "$dev")" + key_is cryptenroll "$BOOTSTRAP" +} + +t_gdp_key() +{ + KEY="$BOOTSTRAP" load + + is "returned" "$BOOTSTRAP" "$(get_device_password "$dev")" +} + +t_gdp_pw() +{ + PW="$BOOTSTRAP" load + + is "returned" "$BOOTSTRAP" "$(get_device_password "$dev")" +} + +# The recovery PIN is the recovery key while the two are linked, so the +# variable has to unlock exactly like its keyring twin below +t_gdp_recovery_pin() +{ + RECOVERY_PIN="$BOOTSTRAP" load + + is "returned" "$BOOTSTRAP" "$(get_device_password "$dev")" +} + +t_gdp_keyring_recovery_pin() +{ + load + keyctl padd user sdbootutil-recovery-pin @u <<<"$BOOTSTRAP" > /dev/null + + is "returned" "$BOOTSTRAP" "$(get_device_password "$dev")" +} + +# %user:sdbootutil holds "any secret" and is deprecated as a name, but +# not as a candidate: it is validated like the rest +t_gdp_keyring_deprecated() +{ + load + keyctl padd user sdbootutil @u <<<"$BOOTSTRAP" > /dev/null + + is "returned" "$BOOTSTRAP" "$(get_device_password "$dev")" +} + +t_gdp_all_wrong() +{ + CURRENT_PW="wrong-1" KEY="wrong-2" PW="wrong-3" RECOVERY_PIN="wrong-4" load + + is "returned" "" "$(get_device_password "$dev")" + # A wrong candidate must not displace a good entry, so nothing + # is written when nothing validated + no_key cryptenroll +} + +t_gdp_wrong_then_valid() +{ + CURRENT_PW="wrong-1" KEY="$BOOTSTRAP" load + + is "returned" "$BOOTSTRAP" "$(get_device_password "$dev")" + key_is cryptenroll "$BOOTSTRAP" +} + +####### enroll_recovery_key: what is enrolled, and what is published +####### as the recovery PIN + +t_erk_key_env() +{ + CURRENT_PW="$BOOTSTRAP" KEY="the-recovery-key" load + + out="$(enroll_recovery_key "$dev" 2>&1)" + + opens "the-recovery-key" "enrolled key" + # No PIN anywhere, so this key becomes one: a single secret + key_is sdbootutil-recovery-pin "the-recovery-key" + says_not "output" "$out" "are now different" +} + +# Nothing was given: systemd-cryptenroll generates the key, it is +# printed, and it is published as the recovery PIN too +t_erk_generated() +{ + CURRENT_PW="$BOOTSTRAP" load + + out="$(enroll_recovery_key "$dev" 2>&1)" + + local key + key="$(sed -n 's/^Recovery key: //p' <<<"$out")" + [ -n "$key" ] || fail "output: no recovery key was printed" + opens "$key" "generated key" + key_is sdbootutil-recovery-pin "$key" +} + +# A recovery PIN is presented and there is no recovery key yet, so the +# key enrolled is the PIN: still one secret, and the entry that holds +# the PIN is not rewritten +t_erk_recovery_pin_env() +{ + CURRENT_PW="$BOOTSTRAP" RECOVERY_PIN="the-recovery-pin" load + + out="$(enroll_recovery_key "$dev" 2>&1)" + + opens "the-recovery-pin" "enrolled key" + says_not "output" "$out" "are now different" +} + +# Both given and different: the key is enrolled, the PIN is left alone +# (it cannot be replaced) and the divergence is reported +t_erk_diverging() +{ + CURRENT_PW="$BOOTSTRAP" KEY="the-recovery-key" RECOVERY_PIN="the-recovery-pin" load + + out="$(enroll_recovery_key "$dev" 2>&1)" + + opens "the-recovery-key" "enrolled key" + says "output" "$out" "The recovery key and the recovery PIN are now different" + no_key sdbootutil-recovery-pin +} + +# A PIN already seals an NVIndex and it is not reachable here, so this +# key is not it. Publishing it would name as the recovery PIN a value +# that no policy update can use, and `status` would then report a PIN +# that is not there (github issue 250). The TPM2 slot is faked because +# the answer must not depend on having a TPM2 +t_erk_unreachable_pin() +{ + CURRENT_PW="$BOOTSTRAP" KEY="the-recovery-key" load + + # Called by enroll_recovery_key, from the sourced library + # shellcheck disable=SC2329 + have_slot() + { + [ "$2" = "tpm2" ] && return 0 + grep -q "$2" < <(systemd-cryptenroll "$1") + } + + out="$(enroll_recovery_key "$dev" 2>&1)" + + opens "the-recovery-key" "enrolled key" + says "output" "$out" "There is already a recovery PIN for the TPM2" + says "output" "$out" "The recovery key and the recovery PIN are now different" + no_key sdbootutil-recovery-pin +} + +####### the runner ####### + +run_case() +{ + local fn="$1"; shift + local desc="$*" + local rc=0 + + purge_keys + format_device + + # The case runs apart: it sources the library, it defines + # secrets in its environment and it can replace a function + ( set +e; case_failed=; "$fn"; [ -z "$case_failed" ] ) || rc=$? + + total=$((total + 1)) + if [ "$rc" = 0 ]; then + echo "ok $desc" + else + failed=$((failed + 1)) + echo "FAIL $desc" + fi +} + +extract_lib +setup_device + +total=0 +failed=0 + +while read -r fn desc; do + [ -n "$fn" ] || continue + run_case "$fn" "$desc" +done <<-'EOF' + t_gdp_current_pw get_device_password takes CURRENT_PW + t_gdp_key get_device_password takes KEY + t_gdp_pw get_device_password takes PW + t_gdp_recovery_pin get_device_password takes RECOVERY_PIN + t_gdp_keyring_recovery_pin get_device_password takes %u:sdbootutil-recovery-pin + t_gdp_keyring_deprecated get_device_password takes the deprecated %u:sdbootutil + t_gdp_all_wrong get_device_password writes nothing when no candidate is valid + t_gdp_wrong_then_valid get_device_password skips a wrong candidate and keeps looking + t_erk_key_env enroll_recovery_key enrolls KEY and publishes it as the recovery PIN + t_erk_generated enroll_recovery_key prints the generated key and publishes it + t_erk_recovery_pin_env enroll_recovery_key enrolls the presented recovery PIN as the key + t_erk_diverging enroll_recovery_key reports a key that differs from the PIN + t_erk_unreachable_pin enroll_recovery_key publishes nothing when a PIN it cannot reach exists +EOF + +echo +if [ "$failed" = 0 ]; then + echo "$total cases, all passed" +else + echo "$total cases, $failed failed" + exit 1 +fi ++++++ sdbootutil.obsinfo ++++++ --- /var/tmp/diff_new_pack.2hWZOR/_old 2026-09-09 16:21:40.820348917 +0200 +++ /var/tmp/diff_new_pack.2hWZOR/_new 2026-09-09 16:21:40.825349125 +0200 @@ -1,5 +1,5 @@ name: sdbootutil -version: 1+git20260903.f91f636 -mtime: 1788443044 -commit: f91f636d0fe261f4fecf667716deaf8ad35b4f5f +version: 1+git20260909.7cfa1f0 +mtime: 1788954014 +commit: 7cfa1f0ab1bba2c808ef5ff63aff22b26aa42d08
