From 5688e3d624011c26e1d5330c3e3ee56f077695e5 Mon Sep 17 00:00:00 2001 From: DevomB Date: Thu, 8 Oct 2026 21:21:12 -0700 Subject: [PATCH 1/3] A kernel copied onto the ESP takes its name only once it reads back as its source: boot-success's commit and kryptik-recover's commit and restore now compare, as the installer and kryptik-update already did boot-success copied the trial slot's kernel to BOOTX64.EFI.new, fsynced and renamed it without looking at what landed, and kryptik-recover did the same for the boot file and for the medium's kernel. A copy that read back wrong became a boot file Secure Boot refuses. Now the copy is compared with its source before the rename: a failed compare fails the commit and the trial stays, as a failed write already does, and recover stops before anything is committed. The installer compares BOOTX64.EFI and kryptik-update hashes its staged kernel. The boot-success fixture makes the copy read back wrong and expects the old boot file and record and the trial kept. --- build/service-scripts/boot-success.sh | 5 +++-- docs/design/boot-and-updates.md | 26 +++++++++++++------------- tools/tests/boot-success.sh | 10 ++++++++++ tools/update/kryptik-recover | 3 +++ 4 files changed, 29 insertions(+), 15 deletions(-) diff --git a/build/service-scripts/boot-success.sh b/build/service-scripts/boot-success.sh index 2ba424d7..3492eb37 100755 --- a/build/service-scripts/boot-success.sh +++ b/build/service-scripts/boot-success.sh @@ -79,8 +79,9 @@ commit_slot() { # commit_slot : make BOOTX64.EFI this slot's kernel if cmp -s "$src" "$dst"; then say "BOOTX64.EFI already is slot $1"; rc=0 else - # Copy, fsync, then rename: on FAT only the rename is not atomic. - cp "$src" "$dst.new" && sync -f "$dst.new" && mv -f "$dst.new" "$dst" && sync -f "$dst" && rc=0 + # Copy, fsync, compare, then rename: on FAT only the rename is not atomic, and a + # copy that reads back wrong never becomes the boot file. + cp "$src" "$dst.new" && sync -f "$dst.new" && cmp -s "$src" "$dst.new" && mv -f "$dst.new" "$dst" && sync -f "$dst" && rc=0 [ "$rc" -eq 0 ] && say "committed: BOOTX64.EFI is now slot $1" fi # The record is part of the commit, and follows the boot file: kryptik-update diff --git a/docs/design/boot-and-updates.md b/docs/design/boot-and-updates.md index fe35d3ae..e1496bc1 100644 --- a/docs/design/boot-and-updates.md +++ b/docs/design/boot-and-updates.md @@ -150,19 +150,19 @@ payloads come from [the update channel](update-channel.md) or by hand. 5. `boot-success` judges the trial slot: state persistent; eudev, seatd, the launch daemon, the net zone and the login getty up; kryptikd finding kernel support and the zones; an unambiguous ESP. Healthy: it copies the kernel - over `BOOTX64.EFI` (`.new`, fsync, rename), updates `committed-slot` alike, - clears the trial and forgets the entries, and the slot's kept manifest - raises the clock's floor if it is the newest. Unhealthy: it records that, - forgets the entries and reboots into the committed slot, `BootNext` being - spent. On a degraded state the trial record is out of reach, so - `committed-slot` says whether the boot is a trial. A trial that never comes - up also lands on the committed slot; the fallback records `trial.failed` - and forgets the entries, and the updater will not re-arm that payload - without `--retry`, which root gives (`su` from the administration login - on tty2; the refusal prints the command). Only a trial reboots; an - unhealthy committed slot is reported and left running, and so is a trial - on a degraded state whose ESP cannot be read, since nothing then says it - is one. + over `BOOTX64.EFI` (`.new`, fsync, compare, rename), updates + `committed-slot` alike, clears the trial and forgets the entries, and the + slot's kept manifest raises the clock's floor if it is the newest. + Unhealthy: it records that, forgets the entries and reboots into the + committed slot, `BootNext` being spent. On a degraded state the trial + record is out of reach, so `committed-slot` says whether the boot is a + trial. A trial that never comes up also lands on the committed slot; the + fallback records `trial.failed` and forgets the entries, and the updater + will not re-arm that payload without `--retry`, which root gives (`su` from + the administration login on tty2; the refusal prints the command). Only a + trial reboots; an unhealthy committed slot is reported and left running, + and so is a trial on a degraded state whose ESP cannot be read, since + nothing then says it is one. The net zone is in the check on purpose: a release whose net zone cannot come up could never fetch the release that fixes it. Whoever can crash diff --git a/tools/tests/boot-success.sh b/tools/tests/boot-success.sh index c283ace6..4bee30d6 100755 --- a/tools/tests/boot-success.sh +++ b/tools/tests/boot-success.sh @@ -72,6 +72,13 @@ case "$*" in *BOOTX64.EFI.new) [ ! -e "$KTEST/sync_fails_boot" ] ;; esac EOF +# cmp: the real one, but a copy of a kernel to BOOTX64.EFI.new can be made to read back wrong. +REAL_CMP="$(command -v cmp)" +cat > "$T/bin/cmp" < "$KTEST/sync_fails_boot"; go check "a boot file that cannot be replaced fails the commit, and the record goes on naming the slot BOOTX64.EFI boots" \ "$RESULT|$(cat "$KTEST/esp/EFI/BOOT/BOOTX64.EFI")|$(cat "$KTEST/esp/kryptik/committed-slot")|$([[ -e "$KTEST/boot/trial" ]] && echo kept || echo gone)" "commit-failed b|kernel-a|a|kept" +run_case copybad b "" persistent 'b\narmed=1\n' $ALL; : > "$KTEST/copy_bad"; go +check "a boot file whose copy reads back wrong is not renamed into place: the commit fails, the trial stays" \ + "$RESULT|$(cat "$KTEST/esp/EFI/BOOT/BOOTX64.EFI")|$(cat "$KTEST/esp/kryptik/committed-slot")|$([[ -e "$KTEST/boot/trial" ]] && echo kept || echo gone)" "commit-failed b|kernel-a|a|kept" run_case commit0 b "" persistent 'b\narmed=0\n' $ALL; go check "a trial that booted before its armed=1 line was written is still a trial: committed" "$RESULT" "commit b" diff --git a/tools/update/kryptik-recover b/tools/update/kryptik-recover index 205a855b..2b407e0f 100755 --- a/tools/update/kryptik-recover +++ b/tools/update/kryptik-recover @@ -91,6 +91,7 @@ commit_slot() { # commit_slot SLOT (ESP mounted rw at /run/kryptik-recover) has_fs "$(slot_dev "$s")" || die "slot $s holds no filesystem; use --restore-slot $s" cp "$k" /run/kryptik-recover/EFI/BOOT/BOOTX64.EFI.new sync -f /run/kryptik-recover/EFI/BOOT/BOOTX64.EFI.new + cmp -s "$k" /run/kryptik-recover/EFI/BOOT/BOOTX64.EFI.new || die "slot $s's kernel did not copy whole; BOOTX64.EFI is unchanged" mv -f /run/kryptik-recover/EFI/BOOT/BOOTX64.EFI.new /run/kryptik-recover/EFI/BOOT/BOOTX64.EFI put /run/kryptik-recover/kryptik/committed-slot "$s" || die "cannot record slot $s as committed" sync @@ -148,6 +149,8 @@ if [ -n "$RESTORE" ]; then mount -t vfat -o rw "$ESP" /run/kryptik-recover || die "cannot mount the target ESP" cp "$kdir/kryptik-$RESTORE.efi" "/run/kryptik-recover/EFI/kryptik/kryptik-$RESTORE.efi.new" sync -f "/run/kryptik-recover/EFI/kryptik/kryptik-$RESTORE.efi.new" + cmp -s "$kdir/kryptik-$RESTORE.efi" "/run/kryptik-recover/EFI/kryptik/kryptik-$RESTORE.efi.new" \ + || die "the medium's kernel for slot $RESTORE did not copy whole; nothing was committed" mv -f "/run/kryptik-recover/EFI/kryptik/kryptik-$RESTORE.efi.new" "/run/kryptik-recover/EFI/kryptik/kryptik-$RESTORE.efi" put "/run/kryptik-recover/kryptik/version-$RESTORE" "$ver" || die "cannot record slot ${RESTORE}'s version" commit_slot "$RESTORE" From a9758aab552da9a203e0eabb6ee2a841cdbfc6e6 Mon Sep 17 00:00:00 2001 From: DevomB Date: Thu, 8 Oct 2026 21:23:47 -0700 Subject: [PATCH 2/3] boot-success forgets the firmware entries before it removes or sets aside the trial record, so no BootNext an apply has just set is forgotten kryptik-update arms nothing while a trial record stands. boot-success removed the record (commit, interrupted arming) or moved it to trial.failed (a trial that did not boot, or came up unhealthy) and only then forgot the entries: an apply finishing in between would have its BootNext cleared and its trial recorded as failed at the next boot. Now the entries go first. The fixture records whether the trial record stood at each forget. --- build/service-scripts/boot-success.sh | 13 ++++++++----- tools/tests/boot-success.sh | 6 ++++++ 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/build/service-scripts/boot-success.sh b/build/service-scripts/boot-success.sh index 3492eb37..14575000 100755 --- a/build/service-scripts/boot-success.sh +++ b/build/service-scripts/boot-success.sh @@ -104,7 +104,9 @@ commit_slot() { # commit_slot : make BOOTX64.EFI this slot's kernel # firmware re-adds that entry at the end of BootOrder when devices change, so a # leftover entry for the other slot would win every cold boot. The committed # slot then gets its own entry back: it boots what BOOTX64.EFI boots, and is a -# second way to it should that one file be lost. +# second way to it should that one file be lost. It runs before the trial record +# is removed or set aside: while that stands kryptik-update arms nothing, so no +# BootNext an apply has just set is forgotten here. forget_entries() { # forget_entries COMMITTED-SLOT if ! kryptik-efiboot forget >/dev/null 2>&1; then say "the firmware's Kryptik entries could not be removed; its own boot order may not name the committed slot" @@ -147,9 +149,9 @@ if [ -n "$trial" ]; then failures="$(health)" if [ -z "$failures" ]; then if commit_slot "$slot"; then + forget_entries "$slot" rm -f "$B/trial" result "commit $slot" - forget_entries "$slot" say "slot $slot is healthy and committed" # Its release's signed date becomes the clock's floor if it is the newest (docs/design/time.md). say "$(kryptikd time committed "$B/release-$slot" 2>&1)" @@ -162,9 +164,10 @@ if [ -n "$trial" ]; then printf '%s\n' "$failures" | sed 's/^/boot-success: - /' printf 'trial-unhealthy %s: %s\n' "$slot" "$(printf '%s' "$failures" | tr '\n' ';')" > "$B/last-result.new" \ && mv -f "$B/last-result.new" "$B/last-result" + forgot=1; forget_entries "$(other_slot "$slot")" || forgot=0 [ ! -f "$B/trial" ] || mv -f "$B/trial" "$B/trial.failed" sync - if ! forget_entries "$(other_slot "$slot")" && [ -n "$unrecorded" ]; then + if [ "$forgot" = 0 ] && [ -n "$unrecorded" ]; then # With no record of this trial, only removing its entries # stops the next boot from repeating it. say "not rebooting: with its entries still there the firmware could boot this trial again" @@ -183,14 +186,14 @@ if [ -n "$trial" ]; then # come up. The updater will not re-arm this payload without --retry. say "trial slot $trial did NOT boot; running slot $slot again" result "trial-failed $trial" - mv -f "$B/trial" "$B/trial.failed" forget_entries "$slot" + mv -f "$B/trial" "$B/trial.failed" else # The updater stopped before setting BootNext: nothing was tried. say "the arming of slot $trial was interrupted before BootNext was set; nothing was tried" result "arming-interrupted $trial" - rm -f "$B/trial" forget_entries "$slot" + rm -f "$B/trial" fi fi elif [ -n "$stray" ]; then diff --git a/tools/tests/boot-success.sh b/tools/tests/boot-success.sh index 4bee30d6..42f304e1 100755 --- a/tools/tests/boot-success.sh +++ b/tools/tests/boot-success.sh @@ -38,6 +38,8 @@ EOF cat > "$T/bin/kryptik-efiboot" <<'EOF' #!/bin/sh echo "efiboot $*" >> "$KTEST/calls" +# Whether the trial record still stood when the entries were forgotten. +[ "$1" = forget ] && { [ -e "$KTEST/boot/trial" ] && echo kept || echo gone; } >> "$KTEST/forget-saw" [ ! -e "$KTEST/efiboot_fails" ] && [ ! -e "$KTEST/efiboot_fails_$1" ] EOF cat > "$T/bin/reboot" <<'EOF' @@ -139,6 +141,7 @@ check "BOOTX64.EFI is now the slot b kernel" "$(cat "$KTEST/esp/EFI/BOOT/BOOTX64 check "committed-slot records b" "$(cat "$KTEST/esp/kryptik/committed-slot")" "b" check "the new committed-slot record is fsynced under its temporary name" "$(grep -c -x "sync -f $KTEST/run/esp/kryptik/committed-slot.new" "$KTEST/syncs" 2>/dev/null)" "1" check "the trial record is gone" "$([[ -e "$KTEST/boot/trial" ]] && echo present || echo gone)" "gone" +check "the entries are forgotten while the trial record still stands, so no apply arms in between" "$(cat "$KTEST/forget-saw" 2>/dev/null)" "kept" check "the trial's firmware entries and BootNext are forgotten after the commit, the committed slot gets its own, and its release goes to the clock's floor" "$CALLS" "mount -t vfat -o rw,nosuid,nodev,noexec /dev/vda1 $KTEST/run/esp umount $KTEST/run/esp efiboot forget efiboot ensure b kryptikd time committed $KTEST/boot/release-b " run_case recfail b "" persistent 'b\narmed=1\n' $ALL; : > "$KTEST/sync_fails"; go check "a committed-slot record that cannot be written fails the commit: the trial stays, for the next boot to commit again" \ @@ -165,6 +168,7 @@ run_case unzones b "" persistent 'b\narmed=1\n' $ALL; rm -f "$KTEST/zones_ok"; g check "trial whose zones do not load: not committed" "${RESULT%%:*}" "trial-unhealthy b" run_case unforget b "" persistent 'b\narmed=1\n' eudev; go check "an unhealthy trial forgets its entries, gives the committed slot its own, then reboots" "$CALLS" "efiboot forget efiboot ensure a reboot " +check "an unhealthy trial's entries go before its record is set aside" "$(cat "$KTEST/forget-saw" 2>/dev/null)" "kept" run_case unforget2 b "" persistent 'b\narmed=1\n' eudev; : > "$KTEST/efiboot_fails"; go check "... and reboots if they stay: its record, now trial.failed, keeps it from coming back" "$(reboots)" "1" # On a degraded state /var is a tmpfs, so the trial record is out of reach. @@ -192,9 +196,11 @@ check "back on the old slot with BootNext consumed: trial-failed" "$RESULT" "tri check "the record moved to trial.failed" "$([[ -e "$KTEST/boot/trial.failed" ]] && cat "$KTEST/boot/trial.failed" | head -1)" "b" check "BOOTX64.EFI untouched" "$(cat "$KTEST/esp/EFI/BOOT/BOOTX64.EFI")" "kernel-a" check "the failed trial's entries are forgotten, and the committed slot gets its own" "$CALLS" "efiboot forget efiboot ensure a " +check "... before its record is set aside" "$(cat "$KTEST/forget-saw" 2>/dev/null)" "kept" run_case interrupted a "" persistent 'b\narmed=0\n' $ALL; go check "old slot with an armed=0 record: arming was interrupted, nothing failed" "$RESULT" "arming-interrupted b" check "the interrupted arming's entry is forgotten, and the committed slot gets its own" "$CALLS" "efiboot forget efiboot ensure a " +check "... before its record goes" "$(cat "$KTEST/forget-saw" 2>/dev/null)" "kept" check "no trial.failed for an interruption" "$([[ -e "$KTEST/boot/trial.failed" ]] && echo present || echo none)" "none" run_case legacy a "" persistent 'b\n' $ALL; go check "a record without an armed line (older updater) counts as armed" "$RESULT" "trial-failed b" From d0e6f32d68b1b52bc68fc0fcf6e921ad60c5f8bb Mon Sep 17 00:00:00 2001 From: DevomB Date: Thu, 8 Oct 2026 21:27:22 -0700 Subject: [PATCH 3/3] The kernel copies are compared as the device holds them: each is dropped from the page cache after its fsync, so the compare reads the ESP and not the copy still in memory From a non-author read: cmp right after sync -f read the cached pages, which says little more than cp's own status. dd iflag=nocache count=0 drops a file's clean pages, best effort; boot-success's and kryptik-recover's compares and kryptik-update's hash of its staged kernel now read what the ESP holds. --- build/service-scripts/boot-success.sh | 8 +++++--- tools/update/kryptik-recover | 4 ++++ tools/update/kryptik-update | 7 ++++++- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/build/service-scripts/boot-success.sh b/build/service-scripts/boot-success.sh index 3492eb37..5252071c 100755 --- a/build/service-scripts/boot-success.sh +++ b/build/service-scripts/boot-success.sh @@ -25,6 +25,8 @@ ident() { sed -n "s/^$1=//p" "$RUN/boot-identity" 2>/dev/null | head -1; } other_slot() { case "$1" in a) echo b ;; b) echo a ;; esac; } slot="$(ident slot)"; media="$(ident media)"; state="$(ident state)" now() { date -Iseconds 2>/dev/null || date; } +# Drop FILE's clean pages, so the next read is what the device holds; best effort. +uncache() { dd if="$1" iflag=nocache count=0 status=none 2>/dev/null || :; } # uncache FILE result() { printf '%s %s\n' "$*" "$(now)" > "$B/last-result.new" && mv -f "$B/last-result.new" "$B/last-result"; } if [ -n "$media" ]; then @@ -79,9 +81,9 @@ commit_slot() { # commit_slot : make BOOTX64.EFI this slot's kernel if cmp -s "$src" "$dst"; then say "BOOTX64.EFI already is slot $1"; rc=0 else - # Copy, fsync, compare, then rename: on FAT only the rename is not atomic, and a - # copy that reads back wrong never becomes the boot file. - cp "$src" "$dst.new" && sync -f "$dst.new" && cmp -s "$src" "$dst.new" && mv -f "$dst.new" "$dst" && sync -f "$dst" && rc=0 + # Copy, fsync, compare as the device holds it, then rename: on FAT only the + # rename is not atomic, and a copy that landed wrong never becomes the boot file. + cp "$src" "$dst.new" && sync -f "$dst.new" && uncache "$dst.new" && cmp -s "$src" "$dst.new" && mv -f "$dst.new" "$dst" && sync -f "$dst" && rc=0 [ "$rc" -eq 0 ] && say "committed: BOOTX64.EFI is now slot $1" fi # The record is part of the commit, and follows the boot file: kryptik-update diff --git a/tools/update/kryptik-recover b/tools/update/kryptik-recover index 2b407e0f..83843912 100755 --- a/tools/update/kryptik-recover +++ b/tools/update/kryptik-recover @@ -59,6 +59,8 @@ slot_dev() { case "$1" in a) echo "$SA" ;; b) echo "$SB" ;; *) die "slot must be has_fs() { [ "$(dd if="$1" bs=1 skip=1080 count=2 status=none | od -An -tx1 | tr -d ' \n')" = "53ef" ]; } # Written, fsynced, renamed, as the kernels are: a power cut leaves the old file whole, not empty. put() { printf '%s\n' "$2" > "$1.new" && sync -f "$1.new" && mv -f "$1.new" "$1"; } # put FILE LINE +# Drop FILE's clean pages, so the next read is what the device holds; best effort. +uncache() { dd if="$1" iflag=nocache count=0 status=none 2>/dev/null || :; } # uncache FILE if [ "$STATUS" = 1 ]; then mount -t vfat -o ro "$ESP" /run/kryptik-recover || die "cannot mount the ESP" @@ -91,6 +93,7 @@ commit_slot() { # commit_slot SLOT (ESP mounted rw at /run/kryptik-recover) has_fs "$(slot_dev "$s")" || die "slot $s holds no filesystem; use --restore-slot $s" cp "$k" /run/kryptik-recover/EFI/BOOT/BOOTX64.EFI.new sync -f /run/kryptik-recover/EFI/BOOT/BOOTX64.EFI.new + uncache /run/kryptik-recover/EFI/BOOT/BOOTX64.EFI.new cmp -s "$k" /run/kryptik-recover/EFI/BOOT/BOOTX64.EFI.new || die "slot $s's kernel did not copy whole; BOOTX64.EFI is unchanged" mv -f /run/kryptik-recover/EFI/BOOT/BOOTX64.EFI.new /run/kryptik-recover/EFI/BOOT/BOOTX64.EFI put /run/kryptik-recover/kryptik/committed-slot "$s" || die "cannot record slot $s as committed" @@ -149,6 +152,7 @@ if [ -n "$RESTORE" ]; then mount -t vfat -o rw "$ESP" /run/kryptik-recover || die "cannot mount the target ESP" cp "$kdir/kryptik-$RESTORE.efi" "/run/kryptik-recover/EFI/kryptik/kryptik-$RESTORE.efi.new" sync -f "/run/kryptik-recover/EFI/kryptik/kryptik-$RESTORE.efi.new" + uncache "/run/kryptik-recover/EFI/kryptik/kryptik-$RESTORE.efi.new" cmp -s "$kdir/kryptik-$RESTORE.efi" "/run/kryptik-recover/EFI/kryptik/kryptik-$RESTORE.efi.new" \ || die "the medium's kernel for slot $RESTORE did not copy whole; nothing was committed" mv -f "/run/kryptik-recover/EFI/kryptik/kryptik-$RESTORE.efi.new" "/run/kryptik-recover/EFI/kryptik/kryptik-$RESTORE.efi" diff --git a/tools/update/kryptik-update b/tools/update/kryptik-update index 6dc0ed90..2508c005 100755 --- a/tools/update/kryptik-update +++ b/tools/update/kryptik-update @@ -76,6 +76,9 @@ esp_dev() { own_part kryptik-esp; } slot_dev() { own_part "kryptik-$1"; } hdr() { awk -F': ' -v k="$2" '$1==k {print $2; exit}' "$1"; } +# Drop FILE's clean pages, so the next read is what the device holds; best effort. +uncache() { dd if="$1" iflag=nocache count=0 status=none 2>/dev/null || :; } # uncache FILE + mount_esp() { dev="$(esp_dev)" || exit 1 mkdir -p "$ESP_MNT" @@ -364,7 +367,9 @@ cmd_apply() { mkdir -p "$ESP_MNT/EFI/kryptik" "$ESP_MNT/kryptik" cp "$(payload_path "kryptik-$target.efi")" "$ESP_MNT/EFI/kryptik/kryptik-$target.efi.new" || die "staging the kernel on the ESP failed" sync -f "$ESP_MNT/EFI/kryptik/kryptik-$target.efi.new" || die "the kernel staged on the ESP could not be flushed" - # Check the staged kernel before it gets the name rollback and recovery use. + # Check the staged kernel, as the device holds it, before it gets the name + # rollback and recovery use. + uncache "$ESP_MNT/EFI/kryptik/kryptik-$target.efi.new" want_k="$H_KA"; [ "$target" = b ] && want_k="$H_KB" if [ "$(sha256sum "$ESP_MNT/EFI/kryptik/kryptik-$target.efi.new" | cut -c1-64)" != "$want_k" ]; then rm -f "$ESP_MNT/EFI/kryptik/kryptik-$target.efi.new"