Repository navigation
Self-update, stage 1: signed A/B slots, ssh … update < image, swap over exec - #530
Conversation
The crate every later piece of the self-update track reads one format through: the loader, `/system/bin/update` and the Mac's signing step. - image: a header naming a monotonic version and the SHA-256 of each section (kernel, cmdline, root), then an Ed25519 signature over the header, then the sections. The signature covers the hashes and never the bytes, so a later chunked form is the same signature over the same hashes. - sig: Ed25519 (ed25519-dalek, verify_strict) over PROTOCOL.sshsig's signed data in the `toyos-image` namespace, so an image signed by `ssh-keygen -Y sign` verifies here and ours verifies under `ssh-keygen -Y verify`. - slots: the slot table, two copies with a sequence, a writer writing the copy that is not current, so moving the mark survives a torn block. - record: what the loader keeps between passes: the image it booted last and the images that died. - policy: the anti-rollback floor, the updater's newer-than-running rule, and every refusal by the word the kernel is handed. Oracles: RFC 8032 §7.1 vectors 1-3 through the verifier this crate calls, and a header signed by OpenSSH 10.3's ssh-keygen (fixture: its signature and public key, the throwaway private key deleted) verifying, with a bent byte and another key refused. The reverse direction was run once by hand: a signature this crate made verified under `ssh-keygen -Y verify` (exit 0) and failed over a bent header (exit 255). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014iqcj4jDKpaiDX8B7CMvmK
…ver exec The first stage of the self-update track, proven in QEMU. Image and loader - Every image is slotted: the ESP carries the loader and `log.guid`; a slot table partition names each slot's FAT volume (kernel.elf, cmdline, image.sig) and ROOT. Images a mode builds carry an empty slot B with room for a ROOT twice their own; test images carry slot A alone. - The loader reads the slot table off its own disk, boots the marked slot, and holds every byte it hands the kernel to the slot's signed header: signature under the embedded key, kernel/cmdline/ROOT hashes, and the anti-rollback floor. A refused marked slot falls back to the other; the kernel is told `boot-slot=` and `slot-refused=` and logs `boot: slot A, because the marked slot B was refused: <word>`. - The attempts file becomes the slots' record: the image booted last, and images that died (a black-box death, or a hang of an image above the floor). A death is the one refusal a slot comes back from where nothing else verifies, so a one-slot machine behaves as before. - The floor is `ToyOSImageFloor`, NV + boot-services-only, raised on a boot that hands the machine back on purpose; what it cannot defend is filed. - The old pick of ROOT by `root=` among TOYOS-ROOT candidates is gone, with toyos-rootimage's `pick`. Userland - `update` (manifest `slots = true`, init claims the slot table and the idle slot's two partitions against the ROOT the kernel holds): reads a signed image on stdin, verifies before writing, streams ROOT, writes the FAT volume through toyos-fat32, fsyncs, then moves the mark. - `swap <service> [<sha256> <length>]` over plain exec replaces sshd's `toyos-swap` subsystem, which is deleted; init's side is unchanged. Build and keys - A throwaway key per build process signs everything that stays on the Mac; `--owner-key` and `--update-image <path>` sign with the owner's key (TOYOS_SIGNING_KEY or ~/.config/toyos/image-signing-key, minted by `--signing-key-new`, refused by name where missing). The public key reaches the loader and `update` as TOYOS_IMAGE_KEY at compile time. Tests - update_boots_the_new_kernel, update_refusals_boot_the_other_slot, update_falls_back_from_a_dying_kernel (tests/updatecase), with a per-test writable OVMF variable store. - The ROOT-pick tests are reworked for signed slots; the two whose claim was the superblock the loader no longer reads are deleted. - toyos-metal names an NVMe disk: the owner lifted "the T14's NVMe is never written". Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014iqcj4jDKpaiDX8B7CMvmK
- `VALUED_PARAMS` names `boot-slot=` and `slot-refused=`, which `params::claims` matches, as it names the black box's word: the loader appends all three. - The kernel takes the last of each slot word, so the loader's own, appended after the slot's signed parameter, is the one it logs. - `ALL_CONFIGS` covers tests/updatecase. - The swap rehearsal's headers say `swap` carries the ask, not sshd. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014iqcj4jDKpaiDX8B7CMvmK
- root_chunk_refused waits on the read's own line: QEMU's failing stick answers the loader's next `loader.log` write with nothing, so the refusal after it never reaches the console. - root_candidate_overlaps overlaps the partition before ROOT, and the host reads an extent as its table states it (`toyos_gpt::list`), not through `locate`'s range and overlap checks, which are the loader's to refuse. The loader locates a slot's ROOT before it reads a byte of the slot, so the refusal names the overlap. - log_partition_layout asserts the five-partition layout, the three ToyOS types read with the kernel's parser. - A dead slot booted because nothing else verifies still tells the kernel which marked slot was refused, where it is not the marked one. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014iqcj4jDKpaiDX8B7CMvmK
It waits for slot A's fallback record or for slot B booting a second time, and names the second as the finding, so a loader that boots the dead slot again is red on an assertion rather than a stalled wait. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014iqcj4jDKpaiDX8B7CMvmK
The upper bound was the count of loader lines between the two markers, and the root bridges' descriptor dump alone is 410 characters: at the metal profile's 1920-pixel mode, 240 eight-pixel columns, it is two rows. The fast tier's red "a growth of 16, where the loader printed 15 lines" is that wrap arriving inside the window once the loader's slot verification moved how far it had got when the first dump landed. The bound is now the rows each line takes at the mode's columns (`EFI_GLYPH_WIDTH`, eight), read off the loader's own GOP line. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014iqcj4jDKpaiDX8B7CMvmK
…h a key `toyos_abi::boot` gained `SLOT_PARAM` and `SLOT_REFUSED_PARAM`, so the published crate's minor moves and every in-tree pin and lockfile with it (the abi-split judge's rule). `cargo run -- --clippy` hands clippy a throwaway `TOYOS_IMAGE_KEY`: the loader does not compile without the key it embeds. Six invocations clean. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014iqcj4jDKpaiDX8B7CMvmK
Each carries a pin that moved, so each is a changed published crate. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014iqcj4jDKpaiDX8B7CMvmK
Where a lockfile holds the registry's older toyos-abi beside the path one, its dependency lists spell the version, and cargo rewrote them at the first build after the bump. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014iqcj4jDKpaiDX8B7CMvmK
main brought #529 (every test's scratch goes with it). Conflicts, each hunk accounted for: - userland/sshd/Cargo.toml: this branch deleted the swap subsystem's `toyos` and `toyos-swap` dependencies; main added the `toyos-tmpdir` dev-dependency for sshd's tests. Both kept. - Cargo.lock: the root package's list takes main's `toyos-tmpdir` and this branch's `toyos-update`. - userland/Cargo.lock: sshd's list is main's without the two deleted dependencies; main's `toyos-tmpdir` package and this branch's `toyos-update` both kept. The image test this branch added takes its scratch through `toyos_tmpdir::TempDir`, as main now requires of every unit test. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014iqcj4jDKpaiDX8B7CMvmK
After a reset the loader's passes speak on the 16550 alone, one of them hashing ROOT, so the harness's console-only liveness read a machine working through a panic's hold and two passes as one gone quiet: the dying-kernel test went red on a loaded host with the fallback still on its way. The update tests now wait on both channels, with the harness's own quiet and wedged bounds. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014iqcj4jDKpaiDX8B7CMvmK
|
Review of #530 at 01a9171. CI: Net lines ( BLOCKER
NOTE
REMOVE
SEND BACK |
The floor rises only to the version of the proven slot's signed header, verified again in the pass that raises it and held to the digest the record names; the record on the log partition is the running system's to write, so its version is never read for the floor (B1). The first record a pass writes names no booted image, so a failed second write credits nothing (B2). The floor is one firmware variable per signing key (toyos_update::floor): an owner-key loader keeps the machine's, a throwaway-key loader one per image, keyed by the log partition's GUID, so no stick or bisect is locked out and no throwaway image reads or deletes the owner's floor (B3). A stored floor the loader did not write is refused and boots nothing, never read as none (B8). init claims only an idle slot's partitions on the running disk, of their kind's type, and neither of the running slot's (B4). swap requires its length and digest; the short form, which could not tell a cut input from its end, is deleted with its issue (B5). abi-split runs in the merge queue too, judging a group against the base the queue built it on (B9's gate hole). The throwaway key lives per checkout under target/, so an unchanged tree rebuilds nothing (B10); an owner key wider than 0600 is refused (N2). Guest tests: update_refusals sends a flipped kernel, a flipped ROOT and an appended byte through `update` (B6, B7, N5) and forges the record at the reboot; update_boots forges the digest; update_hang_kills_an_unproven_image, update_grant_refuses_a_stray_partition and update_floor_is_the_images_own are new; every update test is nightly (N1). Parts::mismatched is deleted (N6). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014iqcj4jDKpaiDX8B7CMvmK
…rol reds at its own assertion Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014iqcj4jDKpaiDX8B7CMvmK
#525 landed with the same version bumps this branch carries (toyos-abi 0.13.0, toyos 0.14.0, toyos-window 0.15.0); the next commit moves past them. The two lockfile conflicts were each side's own packages: this branch's side taken, and cargo added main's (blockd, toyos-blockhold, toyos-blockring and what toyos-rust-tests now links); sshd's toyos-swap edge stays gone with the subsystem this branch deleted. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014iqcj4jDKpaiDX8B7CMvmK
#525 landed toyos-abi 0.13.0, toyos 0.14.0 and toyos-window 0.15.0, the versions this branch had taken for its own change (toyos_abi::boot's two slot words). The second to land bumps again: every manifest pin and every tracked lockfile moves with it, re-resolved by cargo. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014iqcj4jDKpaiDX8B7CMvmK
Clippy's type_complexity refused the tuple `vars::walk` returned; it is a struct now, and nothing else moves. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014iqcj4jDKpaiDX8B7CMvmK
…10 on Found by this branch's fast tier: lan_mdns_answer red in lane 10 on a 104-byte socket path, green alone; nothing here touches how the path is built, so it is filed for its owner rather than fixed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014iqcj4jDKpaiDX8B7CMvmK
|
Hand-back for the review of Each control is a checked patch: the substitution matched exactly once, the mutated tree built (exit 0), the test ran, the file was restored, and the tree was clean. Red means the test's own exit code. Guest controls ran at
Gates: Fast tier: 1, with 395 passed. The one red is T14: a |
|
T14 run 148 (orchestrator) at the staged image: metal_device_probe PASS (judge exit 0). Loader: slot A marked, its signed header verifies under the loader's key, ROOT hashed in 662,100,857 TSC cycles, the image-scoped floor variable ToyOSImageFloor-I48b74aaec69b6a65 holds 0 before and after — the per-image floor on real NVRAM behaves as designed and leaves no machine-wide floor. Loader 2937 ms (ROOT read 1155 ms) against 2270 ms on main's run 145; the ~0.67 s is the ROOT hash and signature check. Kernel to Boot: complete 1214 ms. |
|
Review of #530, round 2, at eaa49e3. CI: Net lines ( Round-1 BLOCKERs
BLOCKER
NOTE
REMOVE
SEND BACK |
|
B11, the loader.log floor lines from T14 run 148 (orchestrator), verbatim: So creating and raising the image floor happened on the T14's real NVRAM. Deleting a stale image floor is read on the next metal run of a different image (its pass must remove I48b74aaec69b6a65); the orchestrator records that reading on this PR. |
…pure crate - update_refused_pass_credits_no_image (B12): a pass that refuses every slot panics after its first record write, and the record it leaves names no booted image. The loader's first write carrying the last pass's `booted` is the mutation it reds on. - update_boots_the_new_kernel (B13): one more plain reboot from slot B raises the floor from 100 to 200, and slot A at 100, marked again, is refused under it while B boots. `slot::proven` reading slot A whatever the record names is the mutation it reds on. - toyos_update::floor::Stored::answered (N2): the map from GetVariable's answer to `Stored` is pure, so an unreadable floor read as absent reds on the host. - toyos_update::floor::deleted (N4): a runtime-made floor the firmware will not delete is refused, where it was read as floor 0 and every raise then failed against its attributes. OVMF deletes one planted with time-based authenticated write, so this arm is pure only. - Stray::Duplicate (N5): a GUID two listed partitions carry is named so, not called another disk. - issues: the running slot's volume is claimable by `update` too, through the table it writes (N1); the sentence saying it cannot is deleted. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014iqcj4jDKpaiDX8B7CMvmK
#531 (17eb66a) gave daemons a `service` row and moved their state under /state; this branch gave `update` a `slots` row and `swap` its own row. Every conflict is the two additions side by side: - src/build.rs, toyos-manifest/src/lib.rs: `ProgramConfig` and `Program` carry both `slots` and `service`; `render` and `parse` write and read both records, and the doc table lists both. - system.toml and tests/{e1000talk,flrswap,lantalk,swap}case/system.toml: sshd is `service = true` (main) and receives `netd` and `launcher` only, since `swap` is its own row here, which receives `swap`. - userland/Cargo.lock: init keeps `toyos-gpt` (this branch); sshd depends on neither `toyos` nor `toyos-swap` (its subsystem is deleted here, and main's sshd change uses neither). - tests/updatecase/system.toml, new on this branch: logd, netd and sshd marked `service = true` as main marks them in every other config. - issues/build/a-lane-socket-path-overruns-sun-len.md deleted: main filed the same defect as a-lane-s-tap-socket-path-is-past-sun-len-on-the-dev-host.md. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014iqcj4jDKpaiDX8B7CMvmK
#531 landed toyos-abi 0.14.0, toyos 0.15.0 and toyos-window 0.16.0, the versions this branch had taken for its change to toyos_abi::boot. The second to land bumps again: every manifest pin moves, and every tracked lockfile carrying them is re-resolved with `cargo update -w --offline` in its own workspace, which moved those three crates' versions and nothing else. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014iqcj4jDKpaiDX8B7CMvmK
…very boot The loader now refuses a runtime-made floor it cannot delete, where it read one as floor 0 and then failed every raise. That trades a silent loss of anti-rollback for a denial a kernel booted before the first raise can cause; recorded where the floor's other limits are. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014iqcj4jDKpaiDX8B7CMvmK
|
Hand-back for the review of Each control is a checked patch: the substitution matched exactly once, the mutated tree built with
N3 (the Actions cache) was not in my brief and is not answered. The body says so. Gates at
Fast tier: exit 1 on every run.
|
|
T14 run 149 (orchestrator) of the image staged from 513be66: booted clean, all five jobs ran, no panic. The stale-floor deletion B11 asked for, verbatim from loader.log: So on the T14's real NVRAM a new image deletes the previous image's floor and creates its own: the store holds one image floor at a time. (The automated judge could not compile in the worktree mid-merge; the verdict is read from the logs.) |
#528 (e4e540d) published toyos 0.16.0 and toyos-window 0.17.0 for the iced/ winit work, the same versions this branch had already set for its own ABI change, so the two collide on crates.io. toyos-abi 0.15.0 is still free (main never moved it past 0.14.0), so it stays; toyos moves to 0.17.0 and toyos-window to 0.18.0, past main's landed versions, with every in-tree pin and lockfile re-locked behind them (`cargo update -w` in toyos/, userland/, userland/libc/, tests/toyos-rust-tests/ and tests/iced-counter/ — the five workspaces a tracked lockfile of theirs names toyos or toyos-window). Conflicts, each the two branches' additions side by side: - src/build.rs: `ALL_CONFIGS` gained `tests/toolkitcase/system.toml` (main) and `tests/updatecase/system.toml` (this branch); both kept, alphabetical. - tests/toyos-rust-tests/Cargo.lock, userland/Cargo.lock: re-locked rather than resolved by hand, so `swap`/`update` (this branch's new programs) and main's iced/winit additions both land, and the stale `toyos-abi` 0.1.0/ 0.2.0 registry entries main's fork-pin fix retired stay gone. `rust`'s gitlink is unchanged (`git ls-tree HEAD rust` still names 80ea645f, main's own) — this branch never moved it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014iqcj4jDKpaiDX8B7CMvmK
|
Merged |
|
Round-2 BLOCKERs closed and judged: B12 and B13 each pinned by a guest test red under the review's mutation, B11 read on the T14 (runs 148 and 149: the floor raised on real NVRAM, and a new image deletes the previous image's floor), B9 settled by rebumping after #528 (toyos-abi 0.15.0, toyos 0.17.0, toyos-window 0.18.0; both abi-split arms exit 0, the merge-queue arm rehearsed on a main-first merge). --ci host, update_ 7/7 and swap 12/12 exit 0 at a3bcbfa. The one unsettled fast-tier red (quiesce_leaves_the_volume_whole 1/4 against 0/4, with related quiesce reds on main) runs through nothing this branch changes. Landing. |
…528) Conflicts, each resolved to carry both sides: - bootloader/src/main.rs: the boot map's ROOT_* names with #530's update imports. - src/image.rs: main's slotted layout, with `arch` threaded into create_boot_image and create_esp_volume for the removable loader's path. - src/build.rs: main's Parts, shipped_parts and signing, keyed per architecture: loader_key takes the arch, the embedded key and the floor scope; root_image_key the plan (config and arch) and the key; build() writes image_for(plan.arch). - tests/common/qemu.rs: a test's own firmware variables file where it names one, and otherwise the architecture's pflash. - toyos-window is 0.19.0, past main's 0.18.0, since its scanout drain moved into arch modules here; both lockfiles take main's and that version. - tests/common/update.rs names the x86-64 plan and image. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014iqcj4jDKpaiDX8B7CMvmK
main landed #528 (iced, `toyos::wake`) and #530 (self-update), which took toyos-abi 0.15.0, toyos 0.17.0 and toyos-window 0.18.0. - SDK versions: past main's, to toyos-abi 0.16.0, toyos 0.18.0 and toyos-window 0.19.0; every tracked lockfile re-locked in its own workspace with `cargo update -w`, tests/iced-counter's among them. - toyos/src/lib.rs: both `power` (this branch) and `wake` (main). - userland/logd/src/main.rs: main's hunks move logd's `Own`/`Theirs` bell onto `toyos::wake`; this branch deleted that machinery (logd's own lines are records in its ring), so they have nothing to land on and this side is kept. `toyos::wake` stays for its other users. - userland/soundd/src/say.rs: deleted here; main's hunk moves its wake pipe onto `toyos::wake`, and soundd's mix thread writes a lane here instead. - rust: main's pin is still 80ea645f83b, which this branch's pin already merges. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014iqcj4jDKpaiDX8B7CMvmK
…, #531, #534) into the Netstack3 netd Two conflicts. tests/common/origin.rs: main added `volumes` to the imports this branch had split to name `segment`'s NEIGHBOUR and NEIGHBOUR_MAC; both kept. userland/Cargo.lock: main's lock taken whole and re-locked against this branch's manifests (`cargo metadata`), which adds the mirrored crates and their dependencies and drops smoltcp and its defmt. The rust gitlink fast-forwarded to main's fbf6ad143d8; this branch's pin (7a809b7591f) is its ancestor, and the branch made no fork commit of its own. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014iqcj4jDKpaiDX8B7CMvmK
The first stage of
issues/boot-media/the-machine-updates-itself-without-ubuntu.md, proven in QEMU: a kernel change reaches a running machine asssh … update < image, is written to the idle slot and boots after a reboot; a slot with a flipped byte, no signature, another key's signature or a version under the floor is refused by name and the other slot boots; a kernel that dies, or hangs before any boot proved it, falls back on its own and the next boot's/lognames it;swapruns over a plainexecand sshd'stoyos-swapsubsystem is gone. Every metal stick boots this loader, so the T14's NVRAM carries its floor: what that does there is under "The floor on the T14".What changed, per decision
The signed image (
toyos-update, new, pure,no_std). A header — magic, format1, a monotonicu64version, and for each ofkernel,cmdline,rootits length and SHA-256 — then a 64-byte Ed25519 signature over that header, then the sections. The sections are fixed in name and order and refused by name otherwise; ROOT is whole 4 KiB blocks. The signature covers the hashes and never the bytes, so a chunked, content-addressed form later is the same signature over the same hashes. The signed message is OpenSSH's SSHSIG signed-data in thetoyos-imagenamespace (PROTOCOL.sshsig), sossh-keygen -Y sign -n toyos-imagemakes a signature this verifies and ours verifies underssh-keygen -Y verify; the namespace stops a signature the owner's key made for anything else passing as an image.Why
ed25519-dalek2.2: the Ed25519 the Rust ecosystem ships (RustCrypto'sssh-keyandrussh, which sshd already runs, verify with it),no_stdwithoutalloc, and already in the userland lockfile at this version. No default features (nostd, no precomputed tables, nozeroize— the machine holds no secret).verify_strict. The loader builds it withcurve25519_dalek_backend="serial"andsha2'sforce-soft, becausex86_64-unknown-uefiis soft-float and the default backends reach for AVX2/SSE/SHA-NI registers. New crates in the root and loader lockfiles:ed25519-dalek,ed25519,curve25519-dalek,curve25519-dalek-derive,signature,subtle,fiat-crypto,rustc_version(build) — all MIT/Apache/BSD-3, which the licence gate allows;getrandom0.3 was already resolved.Slots. Every image is slotted: the ESP carries the loader and
log.guid; a slot table partition (type94464329-…) names each slot's FAT volume (type037719D7-…:toyos/kernel.elf,toyos/cmdline,toyos/image.sig) and ROOT by unique GUID, in two copies with a sequence — the writer writes the copy that is not current, so moving the mark survives a torn block. Entry order is ESP, slot table, log, slot A's volume, slot A's ROOT[, slot B's], so the log stays partition 3 wheretoyos-metalreads it. Images a mode builds (cargo run,--build-only) carry an empty slot B with room for a ROOT twice their own; test images carry slot A alone.The loader reads the slot table off the disk it was loaded from, tries the marked slot then the other, and boots a slot only once its ROOT partition locates cleanly, its signed header verifies under the embedded key, its image is not recorded dead, its version is at or above the floor, and its kernel, cmdline and ROOT hash to what the header names — each refusal said by name. The kernel is handed
boot-slot=<A|B>and, where the marked slot was refused,slot-refused=<slot>:<word>, and logsboot: slot A, because the marked slot B was refused: died, whichlogdcarries to/log. ROOT is found by the slot's partition GUID and read for exactly the header's length; the old pick of ROOT byroot=among TOYOS-ROOT candidates is deleted withtoyos_rootimage::pick.Death and fallback, through the existing attempt counter. The attempts file becomes the slots' record (
toyos_update::record): the image a pass booted and the images that died. A black-box death (panic, fault, wedge, armed-and-silent) marks the booted image dead; a hang the count catches marks it dead only when its version is above the floor — the count cannot tell a hang from a power cut, and a power cut is no reason to leave a proven image. A death is the one refusal a slot comes back from: where nothing else verifies, the dead slot boots again, so a one-slot machine behaves as it did before slots. The first record a pass writes names no booted image (record::account); the slot it boots is written down only once chosen, so a pass whose second write fails, or which chooses nothing, leaves nothing an earlier pass booted for the next pass to credit.Anti-rollback: a signed version, in a variable per key. The floor rises only on a boot that hands the machine back on purpose (the black box's DONE), and only to a version the loader verifies in the pass that raises it: the record on the log partition — which the running system can write — names the slot and the digest of the header it booted; the loader reads that slot's signed header again, verifies it under its key, requires its SHA-256 to be the record's digest, and raises to the header's version, or says
Anti-rollback floor: not raised, because the proven image is not verified: …. So the running system can raise the floor to no version the owner did not sign, which is no more thanupdateinstalling that image does. The floor is a UEFI variable the loader creates non-volatile and boot-services-only (nothing afterExitBootServicescan reach it), one per signing key (toyos_update::floor): namedToyOSImageFloor-K<fingerprint of the key>for a loader built with the owner's key, which keeps the machine's floor, andToyOSImageFloor-I<fingerprint of the key and the log partition's GUID>for one built with a throwaway key, which keeps a floor per image — every image mints its log GUID, so each starts at none. A loader deletes the floors it can tell are stale: the owner's every other one, a throwaway's every other image's and never an owner's. A stored floor with runtime access was made after a handoff (UEFI 2.10 §8.2 refuses a runtimeSetVariableof a name that exists without it), so it is deleted and the floor is none, and refused where the firmware will not delete it, since every raise would then fail against it; any other shape — the loader's attributes and not 8 bytes, attributes it never writes, a variable firmware will not read — is refused and boots nothing, never read as no floor, naming the variable and how to delete it. Which firmware answer is which is pure (floor::Stored::answered: onlyEFI_NOT_FOUNDis no variable), and so is the delete's (floor::deleted). What it cannot defend, filed asissues/boot-media/the-anti-rollback-floor-is-a-firmware-variable.md: anything firmware boots instead of the loader, a firmware reset of its variables, a machine only ever powered off, and a throwaway-key image whose running system rewrites its log GUID.update's newer-than-running check reads the slot table's advisory versions; the floor is the enforcement.update(userland/update, manifestslots = true, gated to that one program). init resolves the grant against the kernel's own word: the running ROOT is the TOYOS-ROOT partition the kernel holds, the slot table is the one on that disk, the idle slot is the other (toyos_update::slots::grant): each of the idle slot's partitions must be listed on the running ROOT's disk, of its kind's type (TOYOS_BOOT, TOYOS_ROOT), and neither of the running slot's; otherwise init saysno slot to grant: the idle slot's volume is …andupdateholds nothing; a GUID two listed partitions carry is refused as such. Which volume is the running slot's, init knows only from the table, whichupdatewrites, so a table naming the running volume as the idle slot's is granted it: a denial only, since the loader holds that volume to the signature, and filed.updatereads the signed header off stdin and writes nothing until the signature is this machine's key's and the version is newer than the running image and no older than the idle slot's; it streams ROOT onto its partition hashing as it goes, writes the volume's three files throughtoyos-fat32(a block-caching adapter so FAT's cluster writes are not a read-modify-write per 512 bytes), refuses a kernel or ROOT that is not the bytes the header names and any byte past the last section, fsyncs both claims, and only then writes the table copy that marks the idle slot, and fsyncs it. Slot writes use the partition-claim path; #525 (blockd and partition sessions) landed while this was in review, and movingupdateonto a session is not this PR's.swapover exec, and its length is not optional./system/bin/swap <service> <sha256> <length>replaces sshd's subsystem, which is deleted (userland/sshd/src/swap.rs, the header framing,SUBSYSTEM,sshd_said). The input is exactly<length>bytes and an input that ends sooner is refused by name before init hears of it: a connection that drops mid-upload ends a program's input exactly as the end of a file does, so the short formswap <service>, which read to the end of its input, is deleted, and with itissues/boot-media/swap-without-a-length-cannot-tell-a-cut-input-from-its-end.md. init's side is untouched — only init swaps; the build lets onlyswapreceive the port. After answering, the program holds its input open until the caller closes it — the go, since the service being swapped may carry the caller's connection — orANSWER_MS. Its own refusals answerunasked <why>, so the host knows init will say nothing.toyos-metal --swapand the swap rehearsals drive it through the harness client, which sends a program's input beside reading its channel.Keys. Images for QEMU guests, CI and metal-loop sticks are signed with this checkout's throwaway key, minted once
0600intotarget/image-signing-throwawayand kept there (never committed; two processes minting at once link one file into place and agree), so an unchanged tree rebuilds nothing that embeds it; its public half reaches the loader andupdateasTOYOS_IMAGE_KEYat compile time, withTOYOS_IMAGE_FLOOR=image.--owner-keyand--update-image <path>sign with the owner's key (TOYOS_IMAGE_FLOOR=machine), read fromTOYOS_SIGNING_KEYor~/.config/toyos/image-signing-key(never a checkout), an unencrypted OpenSSH Ed25519 key refused if its mode lets anyone but its owner read it; a missing one is refused by name before anything builds, namingcargo run -- --signing-key-new, which mints one0600and refuses to replace one. Only the fingerprint is ever printed.abi-splitruns in the merge queue too. It ran on the pull request alone, so two branches that took the same next version each passed againstmain, git merged their identical bumps without a conflict, and the second would ship its change under the first's version — this branch and #525 were that pair. In a merge group it now judges the branch against the base the queue built it on (sdkversion::judge_queued: the group's tip is one merge onto its first parent), which holds every branch queued or landed ahead of it. This branch mergedmainafter #525 and again after #531, which each took the versions this branch had, and bumped past both: toyos-abi 0.15.0, toyos 0.16.0, toyos-window 0.17.0, every pin and tracked lockfile with them.toyos-metalmay name the NVMe (owner ruling):Nodeis a whole SCSI or NVMe disk (/dev/nvme<n>n<m>, partitions spelledp<k>), and the doc and issue text stating "the NVMe is never written" are changed. The stick identity check stays.Tests (
tests/common/update.rs,tests/updatecase), each on a machine with its own writable OVMF variable store (BootOptions::firmware_vars), all seven nightly (each 9–83 s of boots at the head):update_boots_the_new_kernel— the exit; then the slots' record is forged at the reboot (slot B, a digest not its header's, version 2^63) and the floor stays where the verified boot put it; then a plain reboot from B raises the floor from 100 to 200, and slot A, marked again, is refused asits version 100 is below 200while B boots.update_refused_pass_credits_no_image— slot A boots once and the record names it; then a byte of A's kernel is flipped and B holds no image, so the pass panics after its first write, and the record it leaves names no booted image.update_refusals_boot_the_other_slot—updaterefuses another key, an older version, a flipped kernel byte, a flipped ROOT byte and one appended byte, each by name with status 1; the record is forged at the reboot (slot A's own digest, version 2^63) and the floor rises to 100, the version the loader verified; then four slots the loader refuses boot the other.update_falls_back_from_a_dying_kernel— unchanged.update_hang_kills_an_unproven_image— slot B cut at the loader's handoff, the retry marks it dead, slot A boots.update_grant_refuses_a_stray_partition— a table naming the ESP, the log partition, the running slot's volume or its ROOT as slot B's is refused by init by name, andupdateholds nothing.update_floor_is_the_images_own— the owner's floor and another image's, bothu64::MAX, planted in the variable store: this image boots, the other image's floor is deleted and the owner's stays, and the reboot raises this image's own to 100; then this image's own floor planted as nine bytes is refused and nothing boots.The forged record is written while QEMU holds the machine at its kernel's reset (
qemu::QmpHold:set-action reboot=shutdown,shutdown=pause, thensystem_resetandcont), after that kernel's last write and before the loader's first read: a forge written under a running kernel is overwritten by its cache writing back the 4 KiB block the record's cluster shares (measured: the first run of the forged-digest arm raised the floor to 200, the true digest). The variable store is read and planted by EDK2'sVariableFormat.hlayout (tests/common/update.rs'svars), not by anything the loader shares.swap_refusalsalso sends half of netd under its whole length and digest, and with no length, each refused asunaskedwith netd untouched.Gates
Every row ran at
eefac4fb:mainat17eb66a4(#531) merged in, and the bump past it. The head,513be66d, adds one line to an issue file.cargo test -p toyos-update(at811afc01, the last commit to touch the crate)cargo run -- --ci abi-splitbumped: toyos-abi 0.14.0 -> 0.15.0, toyos 0.15.0 -> 0.16.0, toyos-window 0.16.0 -> 0.17.0GITHUB_EVENT_NAME=merge_group cargo run -- --ci abi-spliton a group of17eb66a4+eefac4fb(git commit-tree, first parentmain)17eb66a4+de9369d8(the merge before the bump, still 0.14.0)toyos-abi changed and its version did not: toyos-abi/Cargo.toml still says 0.14.0, and the next one is 0.15.0cargo test --test toyos-build -- --nightly update_(7 tests)cargo test --test toyos-build -- swap(12 tests)cargo run -- --ci host(43 steps)cargo testHigh-risk: the two checks
Negative controls. Each a checked patch (the substitution matched exactly once), the mutated tree shown to build (exit 0), the test run, the file restored and the tree clean. Guest controls ran at
6d196f98(B3a and B8a again at56aa8182, after the floor test was reordered so each reds at its own stage); the code they mutate is unchanged since. Round 3's (B12, B13, and the second N2, N4, N5) ran at811afc01, before the merge; of the files they mutate or run, the merge changed onlytests/updatecase/system.toml(threeservice = truerows), and both tests are green at the head.update_refusals_boot_the_other_slotits version 100 is below …) and saidno slot verifies— the review's brick, reproducedslot::provenupdate_boots_the_new_kernelnot raised …never said; the floor rose to 200 on a forged digestaccountkeeps the last pass'sbootedcargo test -p toyos-updatethe_first_write_credits_no_imageupdate_floor_is_the_images_ownu64::MAXfloor refused slot A, nothing bootedupdate_floor_is_the_images_ownthe variable store holds ToyOSImageFloor-K03f7… as []cargo test -p toyos-updatea_loader_deletes_the_stale_floors_of_its_scope_and_never_the_ownersslots::grantback toslots::idleupdate_grant_refuses_a_stray_partitioncargo test -p toyos-updatea_grant_claims_only_an_idle_slot_on_the_running_diskswap_refusalsswap netdwith half of netd was taken and the connection died (nothing answered in 120s)readforread_exact)swap_refusalsrefused the binary hashes to …stream_rootdeletedupdate_refusals_boot_the_other_slotroot-flipped.updateendedSome(0)and installedtake's hash comparison deletedkernel-flipped.updateendedSome(0)appended.updateendedSome(0)update_floor_is_the_images_owncargo test -p toyos-updateonly_the_loaders_own_variable_is_a_floor_and_nothing_else_is_nonecargo test -p toyos-update(None, true) => Ended::Unknownupdate_hang_kills_an_unproven_imagedied on its last boot…never said; slot B booted againcargo test --lib signing::an_owner_key_wider_than_0600_is_refusedbooted: previous.as_ref().ok().and_then(|p| p.booted)update_refused_pass_credits_no_imagea pass that booted nothing left a record naming slot A's image, red again aloneslot::provenreadstable.slot(Which::A)update_boots_the_new_kernelnot raised … slot B's signed header is d7a8…, and the record names ffd5…, never200, raised from 100; red again aloneStored::answeredmaps every failure toAbsentcargo test -p toyos-updateonly_not_found_is_no_variablefloor::deletedreads an undeletable variable as floor 0cargo test -p toyos-updatea_runtime_variable_that_stays_is_refusedOtherDiskagaincargo test -p toyos-updatea_grant_claims_only_an_idle_slot_on_the_running_diskgit commit-tree), checked outtoyos-abi changed and its version did not: … still says 0.13.0, and the next one is 0.14.0); the pull-request arm, all the queue had before, 0 on the same commitsdkversion::tests::two_branches_that_take_one_version_are_refused_in_the_queuede9369d8, 0.14.0), as a queue group on17eb66a4GITHUB_EVENT_NAME=merge_group cargo run -- --ci abi-splitstill says 0.14.0, and the next one is 0.15.0; the bumped head's group: 0Every guest control was red again on the harness's alone re-run, the same way for all but B5's two, which the harness calls different failures: the short form's differs only in the forwarded port its message names, and the short read's re-run went red at the wrong-digest case, which the partial
readalso breaks.B10, rebuild cost (
/usr/bin/time -p cargo run -- --build-only, two consecutive runs on an unchanged tree after a priming build, this host under other agents' load):main(7345e3fe, no key at all) 2.45 s, 2.09 s, 0 crates compiled; this head 2.86 s, 6.41 s, then 2.62 s, 2.39 s, 2.33 s, 0 crates compiled; the control — the per-process key restored as a checked patch at this head — 5.31 s, 3.33 s, the loader andupdaterecompiled on every run. ROOT's in-process memo is the same on both arms.Independent oracles — RFC 8032 §7.1 vectors 1–3 and
toyos-update/tests/sshsig_oracle.rs, a header signed by OpenSSH 10.3'sssh-keygen -Y sign(fixture committed; the throwaway private key deleted); by hand once, a signature this tree made verified underssh-keygen -Y verifyand failed over a bent header, and--signing-key-new's key reads underssh-keygen -ywith the same-lfingerprint. The variable store is read and written by EDK2's published layout against OVMF's own firmware, which accepts the planted variables and writes the floor the test then reads back. The T14 run below is the hardware oracle for the floor's life on real NVRAM.The floor on the T14
Every metal stick is built with the orchestrator checkout's throwaway key, so its loader keeps an image-scoped floor. On the T14's NVRAM, each stick's first pass lists the variables under vendor
33BE3D4A-30E6-49F5-8050-F169D93A20FB, deletes everyToyOSImageFloor*but its own and anyToyOSImageFloor-K…— every earlier stick's floor — and reads its ownToyOSImageFloor-I<key, log GUID>, which a fresh flash has not written: floor 0, said asAnti-rollback floor: ToyOSImageFloor-I… (image scope) holds 0. The next flash has a new log GUID, so its loader deletes that variable as stale and starts at 0. So the T14 holds at most one ToyOS floor variable at a time, no stick is held to a floor another stick raised — a stick built earlier, or a bisect, boots — and no owner floor exists there until an owner-signed image is installed, which no stick reads or deletes. Ubuntu cannot see or change the variable (boot-services-only). The one way a stick boots nothing is its own variable in a shape its loader never writes, which only a broken loader or something booted in its place could leave; it is refused by name on the stick'sloader.log, and clearing it needs a UEFI shell (dmpstore -d) or the firmware's variable reset.T14 run 148 (the orchestrator's, at
a97708ae's staged image; its reading is on this PR):metal_device_probePASS, slot A's signed header verified under the loader's key, ROOT hashed in 662,100,857 TSC cycles, and the floor lines of itsloader.log, verbatim:So the chain-ending pass created and raised the image floor on the T14's NVRAM. That the next stick deletes it (
… is no floor this image loader keeps; deleted) is owed by the next metal run of a different image: one is staged from the head513be66d:cargo test --test toyos-build -- --metal --metal-readback <scratch>/update530c/metal metal_devicestagedmetaldevicecase(5 jobs), its image andtoyos-metalinvocation in that directory'srequest.txt. No T14 reading of it is claimed here.Measured
At
01a91713, not re-run since (the paths measured are unchanged):ssh … update < image(32.8 MB: 5.6 MB kernel, 27 MB ROOT) to slot B's ready marker: 18,541–24,352 ms over five runs (22,190, 18,541, 22,583, 23,940, 24,352); of that,updateitself took 1,834–4,043 ms (1,834, 4,043, 2,306, 3,673, 3,921), and the rest is the old kernel's shutdown, the loader pass that proves it, the pass that boots B, and B's boot.Unsure
Stored::answered): OVMF will not fail a read on request.it is deleted, and the floor is 0), so no guest here reaches the refusal. The refusal means a kernel that plants such a variable before the first raise, on firmware that keeps it, stops every boot until the firmware's variables are reset; that is a denial a kernel could already cause, and the loader names the variable.targetcache still carries the throwaway seed; it signs only CI guests with image-scoped floors.boot-slot=; the kernel takes the loader's, which is appended last.partition_claim_departurewent red once beside other guests earlier in this branch's life and green alone —issues/boot-media/partition-claim-departure-told-no-flush-beside-other-guests.md, not this diff.What
toyos-metalstill runs Ubuntu for (the next PR's list)wipefs --allanddd of=/dev/sda conv=fsyncunder the sudoers rule.efibootmgr --create-only,--delete-bootnum,--bootnext.dd if=/dev/sda3andmount -o ro/umountof the log partition.reboot,true,date -u +%s, the/sys/class/blockidentity reads of the stick, and waiting for Ubuntu's sshd after every ToyOS boot.ssh t14reach Ubuntu's sshd.Filed
issues/boot-media/the-anti-rollback-floor-is-a-firmware-variable.mdissues/boot-media/the-running-slots-volume-is-claimable.md— the kernel holds the running ROOT but not the same slot's FAT volume, andupdatecan be granted it through the table it writes; a denial only.🤖 Generated with Claude Code
https://claude.ai/code/session_014iqcj4jDKpaiDX8B7CMvmK