From 50951a7bed3371ef1a2d52ade7972e7ca10524d7 Mon Sep 17 00:00:00 2001 From: DevomB Date: Wed, 7 Oct 2026 10:56:46 -0700 Subject: [PATCH 1/4] The ESP's committed-slot and version records are fsynced before they are renamed into place, and boot-success writes no record nothing reads On FAT only the rename is not atomic, so the kernels, BOOTX64.EFI and kryptik-recover's records are written to a new name, fsynced, then renamed. boot-success's committed-slot and kryptik-update's version-a/b skipped the fsync: a power cut after the rename could leave the record with a cluster that was never written, which the readers show as unknown and an apply then refuses as naming no committed slot. Both now fsync first. boot-success also copied version-X to version-committed on every commit. Nothing reads it, and kryptik-recover --commit-slot never updated it, so it could name the wrong release; it is no longer written. --- build/service-scripts/boot-success.sh | 3 +-- tools/tests/boot-success.sh | 3 ++- tools/update/kryptik-update | 1 + 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/build/service-scripts/boot-success.sh b/build/service-scripts/boot-success.sh index 95c30d7c..cd7c7cb9 100755 --- a/build/service-scripts/boot-success.sh +++ b/build/service-scripts/boot-success.sh @@ -83,8 +83,7 @@ commit_slot() { # commit_slot : make BOOTX64.EFI this slot's kernel cp "$src" "$dst.new" && sync -f "$dst.new" && mv -f "$dst.new" "$dst" && sync -f "$dst" && rc=0 [ "$rc" -eq 0 ] && say "committed: BOOTX64.EFI is now slot $1" fi - cp "$ESP_MNT/kryptik/version-$1" "$ESP_MNT/kryptik/version-committed" 2>/dev/null || true - printf '%s\n' "$1" > "$ESP_MNT/kryptik/committed-slot.new" && \ + printf '%s\n' "$1" > "$ESP_MNT/kryptik/committed-slot.new" && sync -f "$ESP_MNT/kryptik/committed-slot.new" && \ mv -f "$ESP_MNT/kryptik/committed-slot.new" "$ESP_MNT/kryptik/committed-slot" else say "no kernel for slot $1 on the ESP" diff --git a/tools/tests/boot-success.sh b/tools/tests/boot-success.sh index 45488d7e..898b6174 100755 --- a/tools/tests/boot-success.sh +++ b/tools/tests/boot-success.sh @@ -66,7 +66,7 @@ echo "umount $1" >> "$KTEST/calls" EOF cat > "$T/bin/sync" <<'EOF' #!/bin/sh -exit 0 +echo "sync $*" >> "$KTEST/syncs" EOF chmod +x "$T"/bin/* @@ -126,6 +126,7 @@ run_case commit b "" persistent 'b\narmed=1\n' $ALL; go check "healthy trial: committed" "$RESULT" "commit b" check "BOOTX64.EFI is now the slot b kernel" "$(cat "$KTEST/esp/EFI/BOOT/BOOTX64.EFI")" "kernel-b" 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 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 commit0 b "" persistent 'b\narmed=0\n' $ALL; go diff --git a/tools/update/kryptik-update b/tools/update/kryptik-update index f6cced5c..8c9390d7 100755 --- a/tools/update/kryptik-update +++ b/tools/update/kryptik-update @@ -372,6 +372,7 @@ cmd_apply() { fi mv -f "$ESP_MNT/EFI/kryptik/kryptik-$target.efi.new" "$ESP_MNT/EFI/kryptik/kryptik-$target.efi" printf '%s\n' "$VERSION" > "$ESP_MNT/kryptik/version-$target.new" + sync -f "$ESP_MNT/kryptik/version-$target.new" mv -f "$ESP_MNT/kryptik/version-$target.new" "$ESP_MNT/kryptik/version-$target" sync umount_esp From f5a15fc46fb9be1018217f468e5b0a9541ece90c Mon Sep 17 00:00:00 2001 From: DevomB Date: Wed, 7 Oct 2026 10:58:09 -0700 Subject: [PATCH 2/4] The design doc says every rename on the ESP follows a complete, fsynced copy: the version and committed-slot records are written as the kernels are It named two renames as the only non-atomic steps, and the version file and committed-slot were renamed without an fsync before them. --- docs/design/boot-and-updates.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/design/boot-and-updates.md b/docs/design/boot-and-updates.md index ce27d40a..fe35d3ae 100644 --- a/docs/design/boot-and-updates.md +++ b/docs/design/boot-and-updates.md @@ -143,14 +143,14 @@ payloads come from [the update channel](update-channel.md) or by hand. cut short leaves nothing that `rollback` or `kryptik-recover --commit-slot` would take. Write the slot (`dd conv=fsync`) and read it back. 3. Put its kernel on the ESP as `.efi.new`, fsync, check it, rename; write - its version file. Keep the verified manifest and signature in + its version file the same way. Keep the verified manifest and signature in `/var/lib/kryptik/boot/release-/` for the [clock's floor](time.md). 4. Record the trial (`armed=0`), run `kryptik-efiboot set-next `, record `armed=1`, reboot. 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`, + 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 @@ -181,7 +181,7 @@ payloads come from [the update channel](update-channel.md) or by hand. who rewrites the ESP itself: the committed slot's name there is not signed. -Zone data is never written. On the FAT ESP the two renames are the only +Zone data is never written. On the FAT ESP the renames are the only non-atomic steps; each follows a complete, fsynced copy and leaves a system that boots either way. From the medium, `kryptik-recover` commits or rewrites a slot and restores the state header ([user guide](../user-guide.md)). From e7ae4479258241dfe6204a9d49770c875fe262a6 Mon Sep 17 00:00:00 2001 From: DevomB Date: Thu, 8 Oct 2026 19:55:28 -0700 Subject: [PATCH 3/4] A commit whose committed-slot record cannot be written is a failed commit: the trial stays, and the next boot commits again boot-success's commit_slot returned 0 once BOOTX64.EFI was the slot's kernel, whatever became of the committed-slot record. With the record unwritten, the caller cleared the trial, recorded "commit b", forgot the entries and raised the clock's floor while the ESP still named a: the next boot found b running uncommitted and rebooted once for nothing, and kryptik-update refused every apply from b until kryptik-recover --commit-slot b from the medium. Now the record is part of the commit. Failing it keeps the trial, and since BOOTX64.EFI already boots b and kryptik-efiboot's entries come after it in BootOrder, the next boot finds BOOTX64.EFI already b and writes only the record. kryptik-recover's put already dies the same way. kryptik-update says which ESP write failed when the staged kernel's flush or the version record fails, instead of leaving set -e to exit without a word. --- build/service-scripts/boot-success.sh | 8 +++++--- tools/tests/boot-success.sh | 4 ++++ tools/update/kryptik-update | 9 +++++---- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/build/service-scripts/boot-success.sh b/build/service-scripts/boot-success.sh index cd7c7cb9..66663f95 100755 --- a/build/service-scripts/boot-success.sh +++ b/build/service-scripts/boot-success.sh @@ -83,8 +83,10 @@ commit_slot() { # commit_slot : make BOOTX64.EFI this slot's kernel cp "$src" "$dst.new" && sync -f "$dst.new" && mv -f "$dst.new" "$dst" && sync -f "$dst" && rc=0 [ "$rc" -eq 0 ] && say "committed: BOOTX64.EFI is now slot $1" fi - printf '%s\n' "$1" > "$ESP_MNT/kryptik/committed-slot.new" && sync -f "$ESP_MNT/kryptik/committed-slot.new" && \ - mv -f "$ESP_MNT/kryptik/committed-slot.new" "$ESP_MNT/kryptik/committed-slot" + # The record is part of the commit: kryptik-update applies only from the slot it names. + { printf '%s\n' "$1" > "$ESP_MNT/kryptik/committed-slot.new" && sync -f "$ESP_MNT/kryptik/committed-slot.new" && \ + mv -f "$ESP_MNT/kryptik/committed-slot.new" "$ESP_MNT/kryptik/committed-slot"; } \ + || { say "the committed-slot record could not be written; the trial stays, and the next boot commits again"; rc=1; } else say "no kernel for slot $1 on the ESP" fi @@ -149,7 +151,7 @@ if [ -n "$trial" ]; then say "$(kryptikd time committed "$B/release-$slot" 2>&1)" else result "commit-failed $slot" - say "slot $slot is healthy but the commit failed; the committed slot is unchanged" + say "slot $slot is healthy but the commit failed; the trial stays for the next boot" fi else say "trial slot $slot came up UNHEALTHY:" diff --git a/tools/tests/boot-success.sh b/tools/tests/boot-success.sh index 898b6174..76b2bced 100755 --- a/tools/tests/boot-success.sh +++ b/tools/tests/boot-success.sh @@ -67,6 +67,7 @@ EOF cat > "$T/bin/sync" <<'EOF' #!/bin/sh echo "sync $*" >> "$KTEST/syncs" +case "$*" in *committed-slot.new) [ ! -e "$KTEST/sync_fails" ] ;; esac EOF chmod +x "$T"/bin/* @@ -129,6 +130,9 @@ 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 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" \ + "$RESULT|$(cat "$KTEST/esp/kryptik/committed-slot")|$([[ -e "$KTEST/boot/trial" ]] && echo kept || echo gone)|$(grep -c 'efiboot forget' "$KTEST/calls" 2>/dev/null)" "commit-failed b|a|kept|0" 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-update b/tools/update/kryptik-update index 8c9390d7..6dc0ed90 100755 --- a/tools/update/kryptik-update +++ b/tools/update/kryptik-update @@ -363,7 +363,7 @@ cmd_apply() { mount_esp 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" + 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. 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 @@ -371,9 +371,10 @@ cmd_apply() { die "the kernel staged on the ESP does not hash to the manifest's kryptik-$target.efi" fi mv -f "$ESP_MNT/EFI/kryptik/kryptik-$target.efi.new" "$ESP_MNT/EFI/kryptik/kryptik-$target.efi" - printf '%s\n' "$VERSION" > "$ESP_MNT/kryptik/version-$target.new" - sync -f "$ESP_MNT/kryptik/version-$target.new" - mv -f "$ESP_MNT/kryptik/version-$target.new" "$ESP_MNT/kryptik/version-$target" + { printf '%s\n' "$VERSION" > "$ESP_MNT/kryptik/version-$target.new" \ + && sync -f "$ESP_MNT/kryptik/version-$target.new" \ + && mv -f "$ESP_MNT/kryptik/version-$target.new" "$ESP_MNT/kryptik/version-$target"; } \ + || die "slot $target's version could not be recorded on the ESP" sync umount_esp say "kernel for slot $target installed on the ESP (version $VERSION)" From c346847d25662d86c68986b6284417c6feda8e97 Mon Sep 17 00:00:00 2001 From: DevomB Date: Thu, 8 Oct 2026 19:57:28 -0700 Subject: [PATCH 4/4] The committed-slot record is written only once BOOTX64.EFI boots the slot: a boot file that could not be replaced left the ESP naming a slot the firmware does not boot From a non-author read: when the copy, flush or rename of BOOTX64.EFI failed, commit_slot still rewrote committed-slot to the trial's slot. The next boot took BOOTX64.EFI, the old slot, whose applies kryptik-update then refused as not committed. The fixture fails that step and expects the record unchanged. --- build/service-scripts/boot-success.sh | 11 +++++++---- tools/tests/boot-success.sh | 8 +++++++- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/build/service-scripts/boot-success.sh b/build/service-scripts/boot-success.sh index 66663f95..2ba424d7 100755 --- a/build/service-scripts/boot-success.sh +++ b/build/service-scripts/boot-success.sh @@ -83,10 +83,13 @@ commit_slot() { # commit_slot : make BOOTX64.EFI this slot's kernel cp "$src" "$dst.new" && sync -f "$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: kryptik-update applies only from the slot it names. - { printf '%s\n' "$1" > "$ESP_MNT/kryptik/committed-slot.new" && sync -f "$ESP_MNT/kryptik/committed-slot.new" && \ - mv -f "$ESP_MNT/kryptik/committed-slot.new" "$ESP_MNT/kryptik/committed-slot"; } \ - || { say "the committed-slot record could not be written; the trial stays, and the next boot commits again"; rc=1; } + # The record is part of the commit, and follows the boot file: kryptik-update + # applies only from the slot it names, so it must not name one that does not boot. + if [ "$rc" -eq 0 ]; then + { printf '%s\n' "$1" > "$ESP_MNT/kryptik/committed-slot.new" && sync -f "$ESP_MNT/kryptik/committed-slot.new" && \ + mv -f "$ESP_MNT/kryptik/committed-slot.new" "$ESP_MNT/kryptik/committed-slot"; } \ + || { say "the committed-slot record could not be written; the trial stays, and the next boot commits again"; rc=1; } + fi else say "no kernel for slot $1 on the ESP" fi diff --git a/tools/tests/boot-success.sh b/tools/tests/boot-success.sh index 76b2bced..c283ace6 100755 --- a/tools/tests/boot-success.sh +++ b/tools/tests/boot-success.sh @@ -67,7 +67,10 @@ EOF cat > "$T/bin/sync" <<'EOF' #!/bin/sh echo "sync $*" >> "$KTEST/syncs" -case "$*" in *committed-slot.new) [ ! -e "$KTEST/sync_fails" ] ;; esac +case "$*" in + *committed-slot.new) [ ! -e "$KTEST/sync_fails" ] ;; + *BOOTX64.EFI.new) [ ! -e "$KTEST/sync_fails_boot" ] ;; +esac EOF chmod +x "$T"/bin/* @@ -133,6 +136,9 @@ check "the trial's firmware entries and BootNext are forgotten after the commit, 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" \ "$RESULT|$(cat "$KTEST/esp/kryptik/committed-slot")|$([[ -e "$KTEST/boot/trial" ]] && echo kept || echo gone)|$(grep -c 'efiboot forget' "$KTEST/calls" 2>/dev/null)" "commit-failed b|a|kept|0" +run_case bootfail b "" persistent 'b\narmed=1\n' $ALL; : > "$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 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"