diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index de2645c67ea..cc31a89102d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,12 +4,11 @@ name: ci # is `cargo run -- --ci ` (src/ci.rs), which runs the same on a dev host; # nightly.yml boots the guests. # -# The two required checks read different events, and neither is absent on the -# other — it is skipped, which a required check counts as passing: `abi-split` -# runs on the pull request alone, where the branch's own commits are; `host` -# runs only in the merge queue, where every branch has already been judged as -# a pull request. That is how each half enters the queue it does not itself -# run in. +# `abi-split` runs on both events: against main on the pull request, and in +# the merge queue against the group's base, where two branches that took one +# version meet. `host` runs only in the merge queue, where every branch has +# already been judged as a pull request; on the pull request it is skipped, +# which a required check counts as passing. on: pull_request: @@ -21,15 +20,14 @@ concurrency: cancel-in-progress: true jobs: - # The published crates' versions, read from the branch against its merge - # base — a merge group's branches were each already judged this way as a - # pull request, so this does not run there. The name is the required check's. + # The published crates' versions; `src/ci.rs`'s `abi_split` reads which + # event it judges. The name is the required check's. abi-split: - if: github.event_name == 'pull_request' runs-on: ubuntu-latest timeout-minutes: 5 steps: - # Full history: `sdkversion::judge` needs the merge base with origin/main. + # Full history: the judge needs the merge base with origin/main, or the + # group's base. - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: fetch-depth: 0 diff --git a/Cargo.lock b/Cargo.lock index e07e5c65ebe..1c6b07c793b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -328,6 +328,32 @@ dependencies = [ "typenum", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "digest" version = "0.10.7" @@ -338,6 +364,27 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "sha2", + "subtle", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -372,6 +419,12 @@ dependencies = [ "log", ] +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "filetime" version = "0.2.29" @@ -864,6 +917,15 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa9c45b374136f52f2d6311062c7146bff20fec063c3f5d46a410bd937746955" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.4" @@ -973,6 +1035,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" + [[package]] name = "simd-adler32" version = "0.3.10" @@ -991,6 +1059,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.117" @@ -1090,7 +1164,7 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] name = "toyos-abi" -version = "0.14.0" +version = "0.15.0" dependencies = [ "rustc-std-workspace-core", ] @@ -1130,6 +1204,7 @@ dependencies = [ "fatfs", "flate2", "fontdue", + "getrandom 0.3.4", "gpt", "image", "libc", @@ -1156,6 +1231,7 @@ dependencies = [ "toyos-symbols", "toyos-tco", "toyos-tmpdir", + "toyos-update", "toyos-userbound", "toyos-wallclock", "toyos-xhci", @@ -1346,6 +1422,14 @@ version = "0.1.0" name = "toyos-untrusted" version = "0.1.0" +[[package]] +name = "toyos-update" +version = "0.1.0" +dependencies = [ + "ed25519-dalek", + "sha2", +] + [[package]] name = "toyos-userbound" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index f76ef66ae86..16388297212 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,6 +55,7 @@ members = [ "toyos-tco", "toyos-tmpdir", "toyos-untrusted", + "toyos-update", "toyos-userbound", "toyos-userpin", "toyos-wallclock", @@ -142,6 +143,11 @@ toyos-tco = { path = "toyos-tco" } # every test here; `--ci host` holds the host tests to leaving nothing behind. toyos-tmpdir = { path = "toyos-tmpdir" } toyos-blackbox = { path = "toyos-blackbox" } +# The signed image, the slot table and the signer: `src/signing.rs` signs with +# the same crate the loader and `/system/bin/update` verify with. +toyos-update = { path = "toyos-update", features = ["sign"] } +# A throwaway key's seed, from the operating system. Already resolved here. +getrandom = "0.3" # `classify`, so the host reading a log volume and `logd` writing it cannot # disagree about which files on it are `logd`'s. toyos-wallclock = { path = "toyos-wallclock" } diff --git a/bootloader/.cargo/config.toml b/bootloader/.cargo/config.toml index 9311d3dc2ea..6abe5e339da 100644 --- a/bootloader/.cargo/config.toml +++ b/bootloader/.cargo/config.toml @@ -1,6 +1,13 @@ [build] target = "x86_64-unknown-uefi" -rustflags = ["-Dwarnings", "-Cforce-frame-pointers=yes"] +rustflags = [ + "-Dwarnings", + "-Cforce-frame-pointers=yes", + # `ed25519-dalek`'s field arithmetic in plain 64-bit integers: its default + # on x86-64 is the AVX2 backend, which this soft-float target has no + # registers for. + "--cfg=curve25519_dalek_backend=\"serial\"", +] [target.x86_64-unknown-uefi] linker = "toyos-ld" \ No newline at end of file diff --git a/bootloader/Cargo.lock b/bootloader/Cargo.lock index 928fd536ebf..94c38a3f9f7 100644 --- a/bootloader/Cargo.lock +++ b/bootloader/Cargo.lock @@ -18,11 +18,21 @@ version = "2.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed570934406eb16438a4e976b1b4500774099c13b8cb96eec99f620f05090ddf" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bootloader" version = "0.1.0" dependencies = [ "bcachefs", + "sha2", "toyos-abi", "toyos-acpi", "toyos-blackbox", @@ -31,6 +41,7 @@ dependencies = [ "toyos-gpt", "toyos-rootimage", "toyos-tco", + "toyos-update", "toyos-wallclock", "uefi", "uefi-services", @@ -42,6 +53,104 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.48", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "sha2", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + [[package]] name = "log" version = "0.4.20" @@ -86,6 +195,44 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "1.0.109" @@ -110,7 +257,7 @@ dependencies = [ [[package]] name = "toyos-abi" -version = "0.14.0" +version = "0.15.0" [[package]] name = "toyos-acpi" @@ -143,10 +290,24 @@ version = "0.1.0" name = "toyos-tco" version = "0.1.0" +[[package]] +name = "toyos-update" +version = "0.1.0" +dependencies = [ + "ed25519-dalek", + "sha2", +] + [[package]] name = "toyos-wallclock" version = "0.1.0" +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "ucs2" version = "0.3.2" @@ -215,3 +376,9 @@ name = "unicode-ident" version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" diff --git a/bootloader/Cargo.toml b/bootloader/Cargo.toml index 72ea1ec1af3..9f03ccfdc9a 100644 --- a/bootloader/Cargo.toml +++ b/bootloader/Cargo.toml @@ -26,6 +26,13 @@ toyos-gpt = { path = "../toyos-gpt" } toyos-rootimage = { path = "../toyos-rootimage" } bcachefs = { path = "../bcachefs", default-features = false } toyos-tco = { path = "../toyos-tco" } +# The signed image, the slot table and the anti-rollback floor: every decision +# this loader makes about which slot to boot, host-tested there. +toyos-update = { path = "../toyos-update" } +# `force-soft`, unified into the SHA-256 and SHA-512 `toyos-update` hashes +# with: this target is soft-float, and the x86 backend reaches for SSE and +# SHA-NI registers a UEFI application may not assume are its own. +sha2 = { version = "0.10", default-features = false, features = ["force-soft"] } # `alloc`: `variable_keys` and `get_variable_boxed`, which are how the boot # entry pointing at this image is found among the firmware's own variables. uefi = { version = "0.26.0", default-features = false, features = ["alloc"] } diff --git a/bootloader/src/attempt.rs b/bootloader/src/attempt.rs index 696d9295e29..d70560a2b42 100644 --- a/bootloader/src/attempt.rs +++ b/bootloader/src/attempt.rs @@ -1,5 +1,6 @@ //! How many times this image has been handed the machine without reporting -//! back, kept on the stick it boots from. +//! back, and which slot's image it handed it to, kept on the stick it boots +//! from. //! //! **The bound on a hang, and the machine has no other way out of one.** //! `bootnext::point_at_us` aims `BootNext` at this loader before every kernel @@ -17,10 +18,19 @@ //! it counts for is one a reader can be handed on its own. //! //! One hand per hang, never two: the second attempt of an image whose first -//! never reported boots no kernel at all. +//! never reported boots no kernel at all. **The same file is the slots' +//! record** (`toyos_update::record`): the image a pass handed the machine to, +//! and every image that died on a boot of its own, so the pass after a hang or +//! a death boots the other slot rather than the one that died. +//! +//! Written twice a pass: before the log opens, with the count and whatever the +//! last boot's end taught, so a pass that dies before its handoff has still +//! counted; and after the slot is chosen, through the log's own open volume, +//! with the image it chose. use alloc::string::String; -use uefi::proto::media::file::{File, FileAttribute, FileMode}; +use toyos_update::record::{self, Record}; +use uefi::proto::media::file::{Directory, File, FileAttribute, FileMode}; use uefi::prelude::*; use uefi::{cstr16, CStr16}; @@ -33,77 +43,69 @@ use crate::loaderlog; /// (`toyos_build::bootlog::split_listing`). const NAME: &CStr16 = cstr16!("attempts"); -/// What the file holds: the partition's signature, then the count. -const BYTES: usize = 16 + 1; - /// What the stick says about this image's attempts, or why it could not say. /// /// **`Err` is not zero.** A volume this cannot read is one the bound is off on, /// and the caller says so rather than treating an unreadable stick as a first /// attempt — which would be a bound that silently never fires. -pub fn read(system_table: &SystemTable, guid: &[u8; 16]) -> Result { +pub fn read(system_table: &SystemTable, guid: &[u8; 16]) -> Result { loaderlog::with_volume(system_table, guid, |root| { let file = match root.open(NAME, FileMode::Read, FileAttribute::empty()) { Ok(file) => file, // No file is a first attempt, which is every freshly flashed image. - Err(e) if e.status() == Status::NOT_FOUND => return Ok(0), + Err(e) if e.status() == Status::NOT_FOUND => return Ok(Record::default()), Err(e) => return Err(alloc::format!("{NAME} would not open ({e})")), }; let Some(mut file) = file.into_regular_file() else { return Err(alloc::format!("{NAME} on the log partition is a directory")); }; - let mut buffer = [0u8; BYTES]; - match file.read(&mut buffer) { - Ok(BYTES) => {} - // Short, or longer than it should be: not this file, and a count - // guessed out of it would be a bound firing on nothing. - Ok(n) => return Err(alloc::format!("{NAME} holds {n} bytes, wanted {BYTES}")), - Err(e) => return Err(alloc::format!("{NAME} would not read ({e})")), - } - if buffer.get(..16) != Some(&guid[..]) { - return Err(alloc::format!( - "{NAME} counts for {:02x?} and this partition is {guid:02x?}", - &buffer[..16] - )); - } - Ok(buffer[16]) + // One byte more than a record, so a longer file is told from a whole one. + let mut buffer = [0u8; record::BYTES + 1]; + let n = file.read(&mut buffer).map_err(|e| alloc::format!("{NAME} would not read ({e})"))?; + // Short, longer, or another partition's: not this file, and a count + // guessed out of it would be a bound firing on nothing. + Record::decode(&buffer[..n], guid).map_err(|why| alloc::format!("{NAME} {why}")) }) .and_then(|inner| inner) } -/// Write `count` down, replacing whatever was there. +/// Write `record` down before the log opens, replacing whatever was there. /// /// Written **before the kernel is handed the machine**, because a count written /// after it is a count a hang never gets to. -pub fn write(system_table: &SystemTable, guid: &[u8; 16], count: u8) -> Result<(), String> { - loaderlog::with_volume(system_table, guid, |root| { - // Deleted and recreated rather than rewound: a fixed-width record is - // still a record a shorter write would leave the tail of. - match root.open(NAME, FileMode::ReadWrite, FileAttribute::empty()) { - Ok(stale) => { - if let Err(e) = stale.delete() { - return Err(alloc::format!("{NAME} would not delete ({e})")); - } +pub fn write(system_table: &SystemTable, guid: &[u8; 16], record: &Record) -> Result<(), String> { + loaderlog::with_volume(system_table, guid, |root| put(root, guid, record)).and_then(|inner| inner) +} + +/// Write `record` down through the log's own open volume, once the slot this +/// pass boots is chosen. +pub fn write_chosen(guid: &[u8; 16], record: &Record) -> Result<(), String> { + loaderlog::with_open_volume(|root| put(root, guid, record)).and_then(|inner| inner) +} + +fn put(root: &mut Directory, guid: &[u8; 16], record: &Record) -> Result<(), String> { + // Deleted and recreated rather than rewound: a fixed-width record is + // still a record a shorter write would leave the tail of. + match root.open(NAME, FileMode::ReadWrite, FileAttribute::empty()) { + Ok(stale) => { + if let Err(e) = stale.delete() { + return Err(alloc::format!("{NAME} would not delete ({e})")); } - Err(e) if e.status() == Status::NOT_FOUND => {} - Err(e) => return Err(alloc::format!("{NAME} would not open ({e})")), } - let file = root - .open(NAME, FileMode::CreateReadWrite, FileAttribute::empty()) - .map_err(|e| alloc::format!("{NAME} would not be created ({e})"))?; - let Some(mut file) = file.into_regular_file() else { - return Err(alloc::format!("{NAME} on the log partition is a directory")); - }; - let mut buffer = [0u8; BYTES]; - buffer[..16].copy_from_slice(guid); - buffer[16] = count; - file.write(&buffer).map_err(|e| alloc::format!("{NAME} would not write ({e})"))?; - // **Flushed here and not at the handoff.** What this file exists to - // survive is a power cut, and a byte in a cache survives nothing. - file.flush().map_err(|e| alloc::format!("{NAME} would not flush ({e})"))?; - Ok(()) - }) - .and_then(|inner| inner) + Err(e) if e.status() == Status::NOT_FOUND => {} + Err(e) => return Err(alloc::format!("{NAME} would not open ({e})")), + } + let file = root + .open(NAME, FileMode::CreateReadWrite, FileAttribute::empty()) + .map_err(|e| alloc::format!("{NAME} would not be created ({e})"))?; + let Some(mut file) = file.into_regular_file() else { + return Err(alloc::format!("{NAME} on the log partition is a directory")); + }; + file.write(&record.encode(guid)).map_err(|e| alloc::format!("{NAME} would not write ({e})"))?; + // **Flushed here and not at the handoff.** What this file exists to + // survive is a power cut, and a byte in a cache survives nothing. + file.flush().map_err(|e| alloc::format!("{NAME} would not flush ({e})"))?; + Ok(()) } /// The next count to write down, given what the stick said. @@ -125,4 +127,3 @@ pub const fn next(previous: u8) -> u8 { pub const fn is_the_retry(has_a_page: bool, harvested: bool, previous: u8) -> bool { has_a_page && !harvested && previous >= 1 } - diff --git a/bootloader/src/blackbox.rs b/bootloader/src/blackbox.rs index 589c8e0dcfb..6dfc7851c92 100644 --- a/bootloader/src/blackbox.rs +++ b/bootloader/src/blackbox.rs @@ -86,6 +86,9 @@ pub struct Finding { /// True where the chain ends here: the last boot has been accounted for, so /// booting the kernel again would start the same loop over. pub ends_the_chain: bool, + /// How the boot that wrote it ended, as the slots' record takes it: a + /// handover on purpose proves its image, and every other state is a death. + pub ended: toyos_update::record::Ended, } /// Split a record's text into what a person at the machine reads and what only @@ -229,7 +232,11 @@ pub fn harvest( the boot after this one will report the crash above a second time" )); } - (Some(Finding { lines, filed, ends_the_chain: true }), None) + let ended = match state { + State::Done => toyos_update::record::Ended::Proven, + State::Panic | State::Wedged | State::Armed | State::Fault => toyos_update::record::Ended::Died, + }; + (Some(Finding { lines, filed, ends_the_chain: true, ended }), None) } /// When the boot this record came from was armed, as the loader stamped it. diff --git a/bootloader/src/floor.rs b/bootloader/src/floor.rs new file mode 100644 index 00000000000..b0210e5615a --- /dev/null +++ b/bootloader/src/floor.rs @@ -0,0 +1,111 @@ +//! The anti-rollback floor: the highest image version a boot has proven, in a +//! firmware variable this loader alone can write — one per signing key, named +//! and judged by `toyos_update::floor`, whose header says whose floor a loader +//! keeps and which others it deletes. +//! +//! **Why a variable and not the disk.** The variable is created +//! non-volatile and boot-services-only (UEFI 2.10 §8.2: without +//! `EFI_VARIABLE_RUNTIME_ACCESS` it is neither readable nor writable once +//! `ExitBootServices` has run), so no kernel this loader hands the machine to +//! — and nothing it lets write the disk — can lower it. +//! +//! What it cannot defend is `toyos_update::policy`'s to say: anything booted +//! before this loader, and the firmware's own reset of its variables. + +use alloc::string::String; +use alloc::vec::Vec; +use toyos_update::floor::{self, Read, Scope, Stored}; +use uefi::prelude::*; +use uefi::table::runtime::{VariableAttributes, VariableVendor}; +use uefi::{guid, CString16}; + +/// Whose images this loader's floor holds, as its build decided. +const SCOPE: Scope = Scope::from_word(env!("TOYOS_IMAGE_FLOOR")); + +/// The vendor every floor is under: `33BE3D4A-30E6-49F5-8050-F169D93A20FB`, +/// minted for this and used for nothing else. +const VENDOR: VariableVendor = VariableVendor(guid!("33be3d4a-30e6-49f5-8050-f169d93a20fb")); + +/// The only attributes the floor is written with. +const ATTRIBUTES: VariableAttributes = + VariableAttributes::NON_VOLATILE.union(VariableAttributes::BOOTSERVICE_ACCESS); + +/// The head of every line this module writes. +const HEAD: &str = "Anti-rollback floor:"; + +/// This loader's floor: its variable's name, and the version it holds. +pub struct Floor { + name: CString16, + pub value: u64, +} + +/// The floor, as this loader left it — `0` where no boot has proven one — +/// and the lines to say once the log is open; or why the stored floor is +/// refused, which boots nothing. Read before the log opens, because the +/// slots' record written then needs it. +pub fn read(system_table: &SystemTable, log_guid: &[u8; 16]) -> Result<(Floor, Vec), String> { + let rt = system_table.runtime_services(); + let own = floor::name(SCOPE, &crate::slot::KEY, log_guid); + let name = CString16::try_from(own.as_str()).expect("a floor's name is ASCII"); + let mut notes = Vec::new(); + match rt.variable_keys() { + Ok(keys) => { + for key in keys.iter().filter(|key| key.vendor == VENDOR) { + let Ok(other) = key.name() else { continue }; + let text = alloc::format!("{other}"); + if floor::stale(SCOPE, &own, &text) { + let deleted = match rt.delete_variable(other, &VENDOR) { + Ok(()) => String::from("deleted"), + Err(e) => alloc::format!("not deleted: firmware refused ({e})"), + }; + notes.push(alloc::format!("{HEAD} {text} is no floor this {} loader keeps; {deleted}", SCOPE.word())); + } + } + } + Err(e) => notes.push(alloc::format!("{HEAD} firmware would not list its variables ({e}), so no stale floor is deleted")), + } + let got = rt.get_variable_boxed(&name, &VENDOR); + let stored = Stored::answered( + got.as_ref().map(|(value, attributes)| (&value[..], attributes.bits())).map_err(|e| e.status().0), + ); + let refused = |why: floor::Refused| { + alloc::format!( + "{HEAD} {name}: {why}, so nothing this loader wrote is there, and it is refused rather than read as no \ + floor. Nothing boots until the variable {name} under vendor {} is deleted, from the firmware's setup or \ + a UEFI shell", + VENDOR.0 + ) + }; + match floor::judge(stored) { + Ok(Read::Floor(value)) => { + notes.push(alloc::format!("{HEAD} {name} ({} scope) holds {value}", SCOPE.word())); + Ok((Floor { name, value }, notes)) + } + Ok(Read::RuntimeMade { attributes, len }) => { + let value = floor::deleted(attributes, rt.delete_variable(&name, &VENDOR).map_err(|e| e.status().0)) + .map_err(refused)?; + notes.push(alloc::format!( + "{HEAD} {name} carries runtime access ({attributes:#x}, {len} bytes), so it was made after a \ + handoff and no floor this loader wrote stands behind it; it is deleted, and the floor is {value}" + )); + Ok((Floor { name, value }, notes)) + } + Err(why) => Err(refused(why)), + } +} + +/// Raise `floor` to `to`, and say so. +pub fn raise(system_table: &SystemTable, floor: &mut Floor, to: u64) { + if to <= floor.value { + println!("{HEAD} stays {}, which is at or above {to}", floor.value); + return; + } + let rt = system_table.runtime_services(); + match rt.set_variable(&floor.name, &VENDOR, ATTRIBUTES, &to.to_le_bytes()) { + Ok(()) => { + println!("{HEAD} {to}, raised from {} by the boot that proved it", floor.value); + floor.value = to; + } + Err(e) => println!("{HEAD} firmware refused {to} ({e}), so it stays {}", floor.value), + } +} diff --git a/bootloader/src/loaderlog.rs b/bootloader/src/loaderlog.rs index 6a51d92b512..feec501f273 100644 --- a/bootloader/src/loaderlog.rs +++ b/bootloader/src/loaderlog.rs @@ -14,7 +14,7 @@ use core::cell::UnsafeCell; use core::fmt; -use uefi::proto::media::file::{File, FileAttribute, FileMode, RegularFile}; +use uefi::proto::media::file::{Directory, File, FileAttribute, FileMode, RegularFile}; use uefi::proto::media::fs::SimpleFileSystem; use uefi::proto::media::partition::PartitionInfo; use uefi::table::boot::{BootServices, OpenProtocolAttributes, OpenProtocolParams, SearchType}; @@ -46,6 +46,8 @@ pub const ENDS_AT_CHAIN: &str = pub fn close_without_a_kernel() { // SAFETY: [`Sink`]'s contract. Dropping the handle closes it. unsafe { *SINK.0.get() = None }; + // SAFETY: [`Volume`]'s contract. + unsafe { *VOLUME.0.get() = None }; } const NAME: &CStr16 = cstr16!("loader.log"); @@ -62,6 +64,16 @@ unsafe impl Sync for Sink {} static SINK: Sink = Sink(UnsafeCell::new(None)); +/// The log partition's root, kept open beside [`SINK`] for the same span and +/// under the same contract: the one way to write another file on the volume +/// once [`open`] holds it exclusively ([`with_open_volume`]). +struct Volume(UnsafeCell>); + +// SAFETY: [`Sink`]'s contract, which this shares. +unsafe impl Sync for Volume {} + +static VOLUME: Volume = Volume(UnsafeCell::new(None)); + /// What a pass that appends writes before its own first line, so the boot being /// reported on and the pass reporting on it are never read as one. pub const SEPARATOR: &str = "--- the pass after the reset, reading what the boot above left"; @@ -119,6 +131,18 @@ pub fn with_volume( Ok(visit(&mut root)) } +/// Hand the log partition's root to `visit`, once [`open`] holds the volume; +/// `Err` where it does not, which is a pass whose log never opened. +pub fn with_open_volume(visit: impl FnOnce(&mut Directory) -> T) -> Result { + // SAFETY: [`Volume`]'s contract: one caller at a time, and no borrow + // outlives this call. + let volume = unsafe { &mut *VOLUME.0.get() }; + match volume.as_mut() { + Some(root) => Ok(visit(root)), + None => Err("the log partition is not open".into()), + } +} + pub fn open(system_table: &SystemTable, guid: &[u8; 16], truncate: bool) { let bs = system_table.boot_services(); let handle = match volume_handle(bs, guid) { @@ -168,6 +192,8 @@ pub fn open(system_table: &SystemTable, guid: &[u8; 16], truncate: bool) { } // SAFETY: [`Sink`]'s contract. unsafe { *SINK.0.get() = Some(file) }; + // SAFETY: [`Volume`]'s contract. + unsafe { *VOLUME.0.get() = Some(root) }; if !truncate { println!("{SEPARATOR}"); } @@ -196,6 +222,8 @@ pub fn close() { println!("{ENDS_AT}"); // SAFETY: [`Sink`]'s contract. Dropping the handle closes it. unsafe { *SINK.0.get() = None }; + // SAFETY: [`Volume`]'s contract. + unsafe { *VOLUME.0.get() = None }; } /// The unique GUID of the GPT partition `handle` sits on. `None` is a handle diff --git a/bootloader/src/main.rs b/bootloader/src/main.rs index b8a154ab904..9031a8cc2e3 100644 --- a/bootloader/src/main.rs +++ b/bootloader/src/main.rs @@ -21,6 +21,8 @@ use uefi::{ }; use toyos_abi::boot::{KernelArgs, MemoryMapEntry, RootBridgeWindow, MAX_ROOT_BRIDGE_WINDOWS}; use toyos_bootmap::{Plan, BOOT_MAP_BYTES, MAX_PAGES, PML4_HIGH_HALF, PML4_IDENTITY}; +use toyos_update::policy; +use toyos_update::record::{self, Booted, Ended, Record}; /// Every line this loader prints: the firmware's console, and the file on the /// stick once [`loaderlog::open`] has one. The arguments are evaluated once, so @@ -39,10 +41,12 @@ macro_rules! println { mod attempt; mod blackbox; mod bootnext; +mod floor; mod gcd; mod loaderlog; mod rootbridge; mod rootimage; +mod slot; mod watchdog; /// The largest file the bootloader will read off the ESP. @@ -216,28 +220,19 @@ fn boot_partition(handle: Handle, system_table: &SystemTable) -> Option) -> vec::Vec { - load_file_bytes(handle, system_table, cstr16!("\\toyos\\cmdline")) -} - /// Name the partition the kernel's log goes on, without reading it. /// -/// Written beside `kernel.elf` by `src/image.rs`, which draws the GUID and +/// Written beside this loader by `src/image.rs`, which draws the GUID and /// stamps the same sixteen bytes into the GPT entry. Read here because this is /// the volume firmware designated and because the kernel has no filesystem yet: /// the identity is *given* all the way down, and nothing at any level scans for /// a partition of the right type or format. /// /// A missing or short file panics, like every other check in this file. The -/// same function writes all four, so a volume with three of them was assembled -/// by something that is not this project — and booting it anyway would mean a -/// kernel that quietly has nowhere to write its log, on the machine that has no -/// other channel. +/// same function writes the loader and this file, so a volume with one of them +/// was assembled by something that is not this project — and booting it anyway +/// would mean a kernel that quietly has nowhere to write its log, on the +/// machine that has no other channel. fn log_partition_guid(handle: Handle, system_table: &SystemTable) -> [u8; 16] { let bytes = load_file_bytes(handle, system_table, cstr16!("\\toyos\\log.guid")); <[u8; 16]>::try_from(bytes.as_slice()).unwrap_or_else(|_| { @@ -866,18 +861,42 @@ fn main(handle: Handle, mut system_table: SystemTable) -> Status { // so this is the one window there is to read and write it. let previous = attempt::read(&system_table, &log_guid); let retry = match &previous { - Ok(previous) => attempt::is_the_retry(page.is_some(), finding.is_some(), *previous), + Ok(previous) => attempt::is_the_retry(page.is_some(), finding.is_some(), previous.count), Err(_) => false, }; + // **How the last boot ended is a fact about the image it ran**: a hang the + // count caught and every death the page recorded mark that image dead in + // its slot, and a handover on purpose proves it. + let ended = match (&finding, retry) { + (Some(finding), _) => finding.ended, + (None, true) => Ended::Hung, + (None, false) => Ended::Unknown, + }; + // Read here and not where it is used: a hang is a death only for an image + // above it, and this is the pass that has to know which. A floor refused + // boots nothing, and is said on the stick before it is said anywhere else. + let (mut image_floor, floor_notes) = match floor::read(&system_table, &log_guid) { + Ok(read) => read, + Err(why) => { + loaderlog::open(&system_table, &log_guid, false); + println!("{}", loaderlog::BEGINS_AT); + println!("{why}"); + panic!("{why}"); + } + }; + let accounted = record::account(previous.clone().unwrap_or_default(), ended, image_floor.value); // Cleared where the last boot is accounted for, and where this pass is // about to hand the machine back: both leave the next boot of this image a // first attempt, which is what one hand per hang means. let next = if finding.is_some() || retry { 0 } else { - attempt::next(previous.clone().unwrap_or(0)) + attempt::next(previous.as_ref().map_or(0, |previous| previous.count)) }; - let wrote = attempt::write(&system_table, &log_guid, next); + // Names no booted image: the slot this pass boots is written down once it + // is chosen, and only then. + let mut record = Record { count: next, ..accounted.record }; + let wrote = attempt::write(&system_table, &log_guid, &record); loaderlog::open(&system_table, &log_guid, finding.is_none() && !retry); println!("{}", loaderlog::BEGINS_AT); if let Some(line) = claim_refused { @@ -890,7 +909,31 @@ fn main(handle: Handle, mut system_table: SystemTable) -> Status { // see of it: a stick this could not count on is a machine with no bound. match (&previous, &wrote) { (Err(why), _) | (_, Err(why)) => println!("{ATTEMPTS} {why}, so this boot is not counted and a hang here needs a hand"), - (Ok(previous), Ok(())) => println!("{ATTEMPTS} this image has had the machine {previous} time(s) without reporting; now {next}"), + (Ok(previous), Ok(())) => println!("{ATTEMPTS} this image has had the machine {} time(s) without reporting; now {next}", previous.count), + } + if let (Some(died), Some(booted)) = (accounted.died, previous.as_ref().ok().and_then(|p| p.booted)) { + let mut hex = [0u8; 64]; + println!( + "Slot {}: its image {} died on its last boot, so no pass boots it again until an update replaces it", + died.letter(), + toyos_update::hex(&booted.digest, &mut hex) + ); + } + for note in floor_notes { + println!("{note}"); + } + // Raised before either end of the chain below: the pass that reads a + // handover on purpose is the one pass that knows the image proved itself — + // and raised to the version its slot's signed header carries, verified + // here, never to the one the record on the disk names. + if let Some(booted) = accounted.proven { + match slot::proven(handle, &system_table, &booted) { + Ok(version) => { + let to = policy::raised(image_floor.value, version); + floor::raise(&system_table, &mut image_floor, to); + } + Err(why) => println!("Anti-rollback floor: not raised, because the proven image is not verified: {why}"), + } } if retry { // **The hang, and the only bound there is on one.** The last boot of @@ -945,30 +988,53 @@ fn main(handle: Handle, mut system_table: SystemTable) -> Status { None => println!("Boot partition: this machine has none"), } - println!("Loading kernel..."); - let kernel_bytes = load_file_bytes(handle, &system_table, cstr16!("\\toyos\\kernel.elf")); - println!("Kernel: {} bytes", kernel_bytes.len()); - println!("Log partition: signature {:02x?}", log_guid); - // The word naming the page is appended to what the ESP carried, because - // whether there is a page is a fact only this loader has: the kernel is + // Every byte the kernel is handed below — its ELF, its parameter and ROOT — + // is one the chosen slot's signed header names. ROOT is read before the + // kernel is loaded and not after: it is the allocation the image pages come + // from, and a slot whose ROOT is refused has no use for the kernel's. + let chosen = slot::choose(handle, &system_table, image_floor.value, &record) + .unwrap_or_else(|why| panic!("Slots: {why}")); + record.booted = Some(Booted { slot: chosen.which, version: chosen.version, digest: chosen.digest }); + match attempt::write_chosen(&log_guid, &record) { + Ok(()) => println!("{ATTEMPTS} slot {}'s image is written down as the one this pass boots", chosen.which.letter()), + Err(why) => println!( + "{ATTEMPTS} {why}, so a death of slot {}'s image is not seen by the next pass", + chosen.which.letter() + ), + } + let kernel_bytes = chosen.kernel; + println!("Kernel: {} bytes", kernel_bytes.len()); + + // The words naming the slot and the page are appended to what the slot + // carried, because each is a fact only this loader has: the kernel is // handed one line and reads its own parameters out of it. - let mut cmdline = cmdline(handle, &system_table); - if let Some(word) = blackbox::param(page) { + let mut cmdline = chosen.cmdline; + let mut append = |word: &str| { if !cmdline.is_empty() { cmdline.push(b','); } cmdline.extend_from_slice(word.as_bytes()); + }; + append(&alloc::format!("{}{}", toyos_abi::boot::SLOT_PARAM, chosen.which.letter())); + if let Some((refused, why)) = chosen.refused { + append(&alloc::format!("{}{}:{}", toyos_abi::boot::SLOT_REFUSED_PARAM, refused.letter(), why.word())); + } + if let Some(word) = blackbox::param(page) { + append(&word); } let params = core::str::from_utf8(&cmdline) - .unwrap_or_else(|e| panic!("\\toyos\\cmdline is not UTF-8: {e}")); + .unwrap_or_else(|e| panic!("slot {}'s cmdline is not UTF-8: {e}", chosen.which.letter())); println!("Boot parameter: {params:?}"); - // Before the kernel is loaded and not after: this is the allocation the - // image pages come from, and a machine whose ROOT is refused has no use for - // the kernel's. - let root_image = rootimage::read(handle, &system_table, params); + let root_image = if toyos_abi::boot::actuators(params).any(|token| token == toyos_abi::boot::WITHHOLD_ROOT_PARAM) { + println!("ROOT: withheld on {}; the kernel is handed no image", toyos_abi::boot::WITHHOLD_ROOT_PARAM); + chosen.root.free(system_table.boot_services()); + None + } else { + Some(chosen.root) + }; println!("Loading kernel elf..."); let loaded_kernel = load_kernel_elf(&kernel_bytes); diff --git a/bootloader/src/rootimage.rs b/bootloader/src/rootimage.rs index 27b9f043465..d5d0be69ff5 100644 --- a/bootloader/src/rootimage.rs +++ b/bootloader/src/rootimage.rs @@ -1,31 +1,29 @@ -//! ROOT, read whole into memory the kernel keeps: the kernel mounts `/system` +//! The boot disk as the loader reads it: its slot table, and a slot's ROOT +//! read whole into memory the kernel keeps, so the kernel mounts `/system` //! from these bytes and needs no storage driver to boot. //! -//! **Which ROOT** is the boot parameter's `root=` against each -//! TOYOS-ROOT-typed partition's filesystem, on the disk firmware loaded this -//! image from and on no other: a slot is a pair of partitions on one disk, and -//! a ROOT on another disk is another machine's business. Each candidate is -//! `toyos_gpt::locate`d, and its superblock is the one `Superblock::read` -//! decides, primary or backup, which is the decision the kernel's mount makes -//! over the same bytes. `toyos_rootimage::pick` makes the choice. +//! **Which disk** is the one firmware loaded this image from and no other: a +//! slot is a pair of partitions on one disk, and a slot table on another disk +//! is another machine's business. **Which ROOT** is the one the slot table +//! names by its unique GUID, `toyos_gpt::locate`d on that disk, and how many +//! bytes of it is what the slot's signed header says — never the partition's +//! length, and never a superblock's word: the header's SHA-256 is what those +//! bytes are held to (`crate::slot`). //! -//! **The contract with the kernel**: [`RootImage`] is made only by [`read`], -//! its pages are `LoaderData`, which the kernel keeps out of its allocator by -//! the address it is handed, as it keeps the black box's page, and nothing -//! writes them between the read and the jump. A check over the image between -//! [`read`] and [`RootImage::handoff`] is therefore a check over exactly what -//! the kernel mounts. +//! **The contract with the kernel**: [`RootImage`] is made only by +//! [`Disk::read_root`], its pages are `LoaderData`, which the kernel keeps out +//! of its allocator by the address it is handed, as it keeps the black box's +//! page, and nothing writes them between the read and the jump. A check over +//! the image between the read and [`RootImage::handoff`] is therefore a check +//! over exactly what the kernel mounts. use alloc::alloc::Layout; -use alloc::vec::Vec; -use core::cell::Cell; +use alloc::string::String; use core::num::NonZeroU64; -use bcachefs::{BlockBuf, BlockNum, DeviceError, FsError, FsUuid, Superblock, TransferError}; -use toyos_abi::boot::WITHHOLD_ROOT_PARAM; use toyos_gpt::{Guid, Partition, Sectors}; use toyos_rootimage::chunk; -use toyos_rootimage::pick::{pick, Refused}; +use toyos_update::slots::{self, Table}; use uefi::prelude::*; use uefi::proto::device_path::{DevicePath, DevicePathNode, DeviceSubType, DeviceType}; use uefi::proto::loaded_image::LoadedImage; @@ -36,10 +34,6 @@ use uefi::table::boot::{AllocateType, MemoryType, OpenProtocolAttributes, OpenPr /// here is allocated at. const BLOCK: usize = 4096; -/// How many TOYOS-ROOT partitions the boot disk may offer: the two slots of a -/// self-updating machine, with room to name a third in the refusal. -const MAX_CANDIDATES: usize = 4; - /// The most one firmware read of ROOT asks for. /// /// Block I/O reports no largest request it serves, so this is the loader's @@ -74,98 +68,48 @@ impl RootImage { pub fn handoff(&self) -> (u64, u64, [u8; 16], u64) { (self.at, self.len, self.partition, self.cycles) } -} -/// Say `why` on the console and in `loader.log`, then refuse the boot. -fn refuse(why: core::fmt::Arguments) -> ! { - println!("ROOT: REFUSED, {why}"); - panic!("ROOT: {why}"); -} - -/// Read the ROOT `params` names into memory, or `None` when the parameter -/// tells this loader to withhold it. -pub fn read(handle: Handle, system_table: &SystemTable, params: &str) -> Option { - if toyos_abi::boot::actuators(params).any(|token| token == WITHHOLD_ROOT_PARAM) { - println!("ROOT: withheld on {WITHHOLD_ROOT_PARAM}; the kernel is handed no image"); - return None; + /// The image's bytes, for the hash its slot's header names. + pub fn bytes(&self) -> &[u8] { + // SAFETY: `at` and `len` are the pages `Disk::read_root` allocated and + // filled, identity-mapped while boot services live, written by nothing + // after that read, and released only by `free`, which takes `self`. + unsafe { core::slice::from_raw_parts(self.at as *const u8, self.len as usize) } } - let Some(named) = toyos_abi::boot::root_uuid(params).and_then(FsUuid::parse) else { - refuse(format_args!("the boot parameter names no root filesystem this loader can parse")); - }; - let bs = system_table.boot_services(); - let disk = boot_disk(handle, bs); - let mut sectors = Disk::open(bs, disk); - let blank = Partition { - index: 0, - type_guid: Guid::ZERO, - unique_guid: Guid::ZERO, - first_lba: 0, - last_lba: 0, - }; - let mut found = [blank; MAX_CANDIDATES]; - let scan = toyos_gpt::locate_type(&mut sectors, Guid::TOYOS_ROOT, &mut found) - .unwrap_or_else(|e| refuse(format_args!("the boot disk's partition table: {e:?}"))); - - let mut candidates: Vec<((Partition, u64), Option)> = Vec::new(); - for candidate in &found[..scan.listed] { - let part = toyos_gpt::locate(&mut sectors, candidate.unique_guid) - .unwrap_or_else(|e| { - refuse(format_args!( - "the boot disk's table names the TOYOS-ROOT candidate {} and then refuses it: {e:?}", - candidate.unique_guid - )) - }) - .partition; - let superblock = sectors.superblock(&part); - println!( - "ROOT: candidate {} at LBA {}..={} holds {}", - part.unique_guid, - part.first_lba, - part.last_lba, - match &superblock { - Ok(sb) => alloc::format!("{}", sb.uuid), - Err(e) => alloc::format!("no superblock this loader can read ({e:?})"), - } - ); - let superblock = superblock.ok(); - let blocks = superblock.as_ref().map_or(0, |sb| sb.block_count); - candidates.push(((part, blocks), superblock.map(|sb| sb.uuid))); + /// Give the pages back: a ROOT whose slot was refused is not the kernel's. + pub fn free(self, bs: &BootServices) { + // SAFETY: the pages are this image's own allocation, and `self` is + // consumed, so nothing reads them after this. + let freed = unsafe { bs.free_pages(self.at, (self.len as usize).div_ceil(BLOCK)) }; + if let Err(e) = freed { + println!("ROOT: firmware would not take back the {} bytes at {:#x} ({e:?})", self.len, self.at); + } } - let (root, blocks) = pick(&named, &candidates, scan.matched).unwrap_or_else(|refused| match refused { - Refused::Unlisted { matched, listed } => refuse(format_args!( - "the boot disk carries {matched} TOYOS-ROOT partitions and this loader looks at {listed}" - )), - Refused::Matches { matches, candidates } => refuse(format_args!( - "root={named} matches {matches} of the {candidates} TOYOS-ROOT partition(s) on the boot disk" - )), - }); - Some(sectors.read_filesystem(bs, &root, blocks)) } /// The whole-disk block device carrying the partition firmware loaded this /// image from: the one handle whose device path is the partition's without /// its last node, the HARDDRIVE one. -fn boot_disk(handle: Handle, bs: &BootServices) -> Handle { +pub fn boot_disk(handle: Handle, bs: &BootServices) -> Result { let image = bs .open_protocol_exclusive::(handle) - .unwrap_or_else(|e| refuse(format_args!("this image's LoadedImage: {e:?}"))); - let Some(device) = image.device() else { - refuse(format_args!("firmware names no device this image was loaded from")); - }; - let path = get_protocol::(bs, device); - let nodes: Vec<&DevicePathNode> = path.node_iter().collect(); + .map_err(|e| alloc::format!("this image's LoadedImage: {e:?}"))?; + let device = image.device().ok_or("firmware names no device this image was loaded from")?; + let path = try_get_protocol::(bs, device) + .map_err(|e| alloc::format!("the boot device's path: {e:?}"))?; + let nodes: alloc::vec::Vec<&DevicePathNode> = path.node_iter().collect(); let Some((last, disk_nodes)) = nodes.split_last() else { - refuse(format_args!("the boot device's path is empty")); + return Err("the boot device's path is empty".into()); }; if last.full_type() != (DeviceType::MEDIA, DeviceSubType::MEDIA_HARD_DRIVE) { - refuse(format_args!("the boot device is not a partition, so there is no disk to find ROOT on")); + return Err("the boot device is not a partition, so there is no disk to find the slots on".into()); } let handles = bs .find_handles::() - .unwrap_or_else(|e| refuse(format_args!("firmware lists no block devices: {e:?}"))); - let disks: Vec = handles + .map_err(|e| alloc::format!("firmware lists no block devices: {e:?}"))?; + let disks: alloc::vec::Vec = handles .into_iter() // Not the partition itself, whose path is held open above: a second // open by this agent is closed along with the first, and the second @@ -177,26 +121,11 @@ fn boot_disk(handle: Handle, bs: &BootServices) -> Handle { }) .collect(); match disks[..] { - [disk] => disk, - _ => refuse(format_args!( - "{} block devices answer to the boot partition's disk path, wanted one", - disks.len() - )), + [disk] => Ok(disk), + _ => Err(alloc::format!("{} block devices answer to the boot partition's disk path, wanted one", disks.len())), } } -/// Open `P` on `handle` without taking it from whoever holds it. -/// -/// Never exclusive: EXCLUSIVE stops every driver holding the protocol, and on -/// a disk that is the partition driver the ESP's filesystem sits on. -fn get_protocol( - bs: &BootServices, - handle: Handle, -) -> ScopedProtocol<'_, P> { - try_get_protocol(bs, handle) - .unwrap_or_else(|e| refuse(format_args!("the boot disk's protocol: {e:?}"))) -} - fn try_get_protocol( bs: &BootServices, handle: Handle, @@ -205,6 +134,9 @@ fn try_get_protocol( // installed until the `ScopedProtocol` drops. This loader is the one image // running, registers no callback that could uninstall either, and calls no // boot service that connects or disconnects a controller. + // + // Never exclusive: EXCLUSIVE stops every driver holding the protocol, and + // on a disk that is the partition driver the ESP's filesystem sits on. unsafe { bs.open_protocol::

( OpenProtocolParams { handle, agent: bs.image_handle(), controller: None }, @@ -214,7 +146,7 @@ fn try_get_protocol( } /// The boot disk, read through the firmware's block I/O. -struct Disk<'a> { +pub struct Disk<'a> { io: ScopedProtocol<'a, BlockIO>, media_id: u32, lba_bytes: u32, @@ -225,77 +157,81 @@ struct Disk<'a> { } impl<'a> Disk<'a> { - fn open(bs: &'a BootServices, handle: Handle) -> Self { - let io = get_protocol::(bs, handle); + pub fn open(bs: &'a BootServices, handle: Handle) -> Result { + let io = try_get_protocol::(bs, handle).map_err(|e| alloc::format!("the boot disk's block I/O: {e:?}"))?; let media = io.media(); if !media.is_media_present() { - refuse(format_args!("the boot disk reports no media")); + return Err("the boot disk reports no media".into()); } let lba_bytes = media.block_size(); // UEFI 2.11 §13.9: `IoAlign` is 0 or 1 for none, else a power of two. let align = media.io_align().max(1) as usize; if !align.is_power_of_two() || align > BLOCK { - refuse(format_args!("the boot disk wants buffers aligned to {align} bytes")); + return Err(alloc::format!("the boot disk wants buffers aligned to {align} bytes")); } if lba_bytes == 0 || !BLOCK.is_multiple_of(lba_bytes as usize) { - refuse(format_args!("the boot disk's {lba_bytes}-byte block does not divide {BLOCK}")); - } - let scratch = aligned(BLOCK); - Disk { - media_id: media.media_id(), - lba_bytes, - lba_count: media.last_block() + 1, - io, - scratch, + return Err(alloc::format!("the boot disk's {lba_bytes}-byte block does not divide {BLOCK}")); } + let (media_id, lba_count) = (media.media_id(), media.last_block() + 1); + Ok(Disk { media_id, lba_bytes, lba_count, io, scratch: aligned(BLOCK) }) + } + + /// The partition `guid` names on this disk, checked against the table as + /// `toyos_gpt::locate` checks every one. + pub fn locate(&mut self, guid: [u8; 16]) -> Result { + toyos_gpt::locate(self, Guid(guid)) + .map(|located| located.partition) + .map_err(|e| alloc::format!("partition {} on the boot disk: {e:?}", Guid(guid))) } - /// The superblock `Superblock::read` decides for `part` — block 0, or the - /// backup at the last whole block when block 0 is not one — which is the - /// decision the kernel's mount makes over the image; `Err` for a candidate - /// that carries none. A read the disk would not do refuses the boot, - /// naming the firmware's status. - fn superblock(&self, part: &Partition) -> Result { + /// The slot table on this disk's one TOYOS-SLOTS partition. + pub fn slot_table(&mut self) -> Result { + let blank = Partition { index: 0, type_guid: Guid::ZERO, unique_guid: Guid::ZERO, first_lba: 0, last_lba: 0 }; + let mut found = [blank; 2]; + let scan = toyos_gpt::locate_type(self, Guid::TOYOS_SLOTS, &mut found) + .map_err(|e| alloc::format!("the boot disk's partition table: {e:?}"))?; + if scan.matched != 1 { + return Err(alloc::format!("the boot disk carries {} slot tables, and a machine has one", scan.matched)); + } + let part = self.locate(found[0].unique_guid.0)?; let lbas = BLOCK as u64 / u64::from(self.lba_bytes); - let view = View { - io: &self.io, - media_id: self.media_id, - first_lba: part.first_lba, - lbas, - blocks: part.lba_count() / lbas, - failed: Cell::new(None), - }; - match Superblock::read(&view) { - Err(FsError::DeviceRead(block, _)) => refuse(format_args!( - "the read of block {} of the TOYOS-ROOT candidate {} failed: {:?}", - block.raw(), - part.unique_guid, - view.failed.get().expect("a refused read recorded the firmware's status") - )), - decided => decided, + if part.lba_count() < slots::COPIES * lbas { + return Err(alloc::format!("the slot table's partition is {} blocks, short of its two copies", part.lba_count())); + } + let mut copies = [[0u8; BLOCK]; 2]; + for (i, copy) in copies.iter_mut().enumerate() { + let at = part.first_lba + i as u64 * lbas; + self.io + .read_blocks(self.media_id, at, self.scratch) + .map_err(|e| alloc::format!("the slot table's copy {i} would not read: {:?}", e.status()))?; + copy.copy_from_slice(self.scratch); } + slots::current([&copies[0], &copies[1]]) + .map(|(table, _)| table) + .map_err(|why| alloc::format!("the slot table's partition holds {why}")) } - /// The filesystem on `part`, its `blocks` from the partition's start, in - /// chunks of at most [`CHUNK_BOUND`], into pages the kernel keeps. + /// The first `len` bytes of `part`, in chunks of at most [`CHUNK_BOUND`], + /// into pages the kernel keeps. /// /// **Nothing bounds a chunk that never returns.** `ReadBlocks` takes no /// timeout and says nothing while it runs, so a firmware driver that stalls /// holds this loader until the firmware watchdog `main` armed at entry /// resets the machine, if the firmware honours it. What shows where it - /// stopped is the `ROOT: candidate` line before the read and the attempt - /// count the next pass reads. - fn read_filesystem(&mut self, bs: &BootServices, part: &Partition, blocks: u64) -> RootImage { - let Some(len) = blocks.checked_mul(BLOCK as u64) else { - refuse(format_args!("ROOT's filesystem states {blocks} blocks, which is no byte length")); - }; - let lbas = len / u64::from(self.lba_bytes); + /// stopped is the slot's line before the read and the attempt count the + /// next pass reads. + pub fn read_root(&mut self, bs: &BootServices, part: &Partition, len: u64) -> Result { + let capacity = part.lba_count().saturating_mul(u64::from(self.lba_bytes)); + if len > capacity || !len.is_multiple_of(BLOCK as u64) { + return Err(alloc::format!("{len} bytes of ROOT do not fit whole in its {capacity}-byte partition")); + } + let pages = (len / BLOCK as u64) as usize; // `LoaderData`, as the black box's page is, for the reason its module // header gives. let at = bs - .allocate_pages(AllocateType::AnyPages, MemoryType::LOADER_DATA, blocks as usize) - .unwrap_or_else(|e| refuse(format_args!("firmware would not give {len} bytes for ROOT: {e:?}"))); - // SAFETY: the `blocks` pages at `at` were just allocated to this loader, + .allocate_pages(AllocateType::AnyPages, MemoryType::LOADER_DATA, pages) + .map_err(|e| alloc::format!("firmware would not give {len} bytes for ROOT: {e:?}"))?; + // SAFETY: the `pages` pages at `at` were just allocated to this loader, // are identity-mapped while boot services live, and nothing else holds them. let into = unsafe { core::slice::from_raw_parts_mut(at as *mut u8, len as usize) }; @@ -306,25 +242,29 @@ impl<'a> Disk<'a> { let began = crate::tsc(); let mut device = Firmware { io: &self.io, media_id: self.media_id }; let read = chunk::read(&mut device, part.first_lba, self.lba_bytes, chunk, into); + let image = RootImage { at, len, partition: part.unique_guid.0, cycles: crate::tsc().wrapping_sub(began) }; if let Err(failed) = read { - refuse(format_args!( + let why = alloc::format!( "the read of {} blocks at LBA {} failed: {:?}, after {} of {len} bytes read", failed.blocks, failed.lba, failed.error.status(), failed.read - )); + ); + image.free(bs); + return Err(why); } - let took = crate::tsc().wrapping_sub(began); println!( - "{READ_AT} {at:#x}+{len:#x} from LBA {}+{lbas}, {chunk} bytes a request (optimal granularity: {}), in {took} TSC cycles", + "{READ_AT} {at:#x}+{len:#x} from LBA {}+{}, {chunk} bytes a request (optimal granularity: {}), in {} TSC cycles", part.first_lba, + len / u64::from(self.lba_bytes), match granularity { Some(lbas) => alloc::format!("{lbas} block(s)"), - None => alloc::string::String::from("not reported"), - } + None => String::from("not reported"), + }, + image.cycles ); - RootImage { at, len, partition: part.unique_guid.0, cycles: took } + Ok(image) } /// `OptimalTransferLengthGranularity`, where the media is revision 3 or @@ -355,45 +295,6 @@ impl chunk::Blocks for Firmware<'_> { } } -/// One candidate partition as `bcachefs` reads a device: `blocks` whole -/// `BLOCK`s of `lbas` logical blocks each from `first_lba`, keeping the status -/// of a read the firmware refused. -struct View<'a> { - io: &'a BlockIO, - media_id: u32, - first_lba: u64, - lbas: u64, - blocks: u64, - failed: Cell>, -} - -/// A read the firmware attempted and failed. -struct Attempted; - -impl TransferError for Attempted { - fn refused_before_attempt(&self) -> bool { - false - } -} - -impl bcachefs::BlockIO for View<'_> { - fn read_block(&self, block: BlockNum, buf: &mut BlockBuf) -> Result<(), DeviceError> { - assert!(block.raw() < self.blocks, "the loader read {block} of a {}-block candidate", self.blocks); - self.io.read_blocks(self.media_id, self.first_lba + block.raw() * self.lbas, &mut buf.0).map_err(|e| { - self.failed.set(Some(e.status())); - DeviceError::classify(&Attempted) - }) - } - - fn write_block(&self, block: BlockNum, _buf: &BlockBuf) -> Result<(), DeviceError> { - panic!("the loader wrote {block} of a ROOT candidate, and it writes no disk"); - } - - fn block_count(&self) -> u64 { - self.blocks - } -} - impl Sectors for Disk<'_> { fn lba_bytes(&self) -> u32 { self.lba_bytes diff --git a/bootloader/src/slot.rs b/bootloader/src/slot.rs new file mode 100644 index 00000000000..9d04623bf9f --- /dev/null +++ b/bootloader/src/slot.rs @@ -0,0 +1,276 @@ +//! Which slot this boot hands the machine to, and every byte of it held to the +//! owner's signature before it is. +//! +//! The slot table marks one slot; that one is tried first and the other only +//! when the marked one is refused (`toyos_update::policy::order`). A slot is +//! booted only once, in this order: its signed header is on its FAT +//! partition, the signature is [`KEY`]'s, its image did not die on its last +//! boot, its version is at or above the floor a boot has proven, and its +//! kernel, boot parameter and ROOT each hash to what the header names. Each +//! refusal is said by name, and the boot that falls back tells the kernel +//! which slot it refused and why, so the next boot's log carries it. +//! +//! **A death is the one refusal a slot comes back from**: where no slot +//! verifies but one whose image died, that one boots, and says so — a machine +//! with one slot, or with two that both died, boots what it has rather than +//! nothing, which is what it did before it had slots. +//! +//! **The loader is the part signing does not cover**: it reads the key it +//! checks against out of its own binary, and only UEFI Secure Boot over the +//! loader closes that. Until then a writable ESP is the gap. + +use alloc::string::String; +use alloc::vec::Vec; + +use toyos_update::image::{Header, SIGNATURE_BYTES, SIGNED_BYTES}; +use toyos_update::policy::{self, Refusal}; +use toyos_update::record::{self, Booted, Record}; +use toyos_update::slots::{self, Slot, Which}; +use toyos_update::{sig, Digest}; +use uefi::prelude::*; +use uefi::proto::media::file::{File, FileAttribute, FileInfo, FileMode}; +use uefi::proto::media::fs::SimpleFileSystem; +use uefi::CString16; + +use crate::rootimage::{Disk, RootImage}; + +/// The owner's public key, embedded at build time: the only key this loader +/// boots an image under. +pub const KEY: [u8; 32] = sig::key_from_hex(env!("TOYOS_IMAGE_KEY")); + +/// The head of every line this module writes. +const HEAD: &str = "Slot"; + +/// A slot every byte of which its signature vouches for. +pub struct Chosen { + pub which: Which, + pub version: u64, + /// The SHA-256 of its signed header, which names the image exactly. + pub digest: Digest, + pub kernel: Vec, + pub cmdline: Vec, + pub root: RootImage, + /// The slot tried before this one, and why it was refused. + pub refused: Option<(Which, Refusal)>, +} + +/// The slot to boot, or every refusal and why there is nothing to boot. +pub fn choose( + handle: Handle, + system_table: &SystemTable, + floor: u64, + record: &Record, +) -> Result { + let bs = system_table.boot_services(); + let disk = crate::rootimage::boot_disk(handle, bs)?; + let mut disk = Disk::open(bs, disk)?; + let table = disk.slot_table()?; + println!( + "{HEAD}s: the table marks {} (sequence {}); slot A {}, slot B {}; the floor is {floor}", + table.marked.letter(), + table.sequence, + if table.slots[0].is_some() { "present" } else { "absent" }, + if table.slots[1].is_some() { "present" } else { "absent" }, + ); + let mut refused: Option<(Which, Refusal)> = None; + let mut dead: alloc::vec::Vec = alloc::vec::Vec::new(); + for which in policy::order(&table).into_iter().flatten() { + let slot = table.slot(which).expect("`order` names only slots the table carries"); + match verify(bs, &mut disk, which, slot, floor, Some(record)) { + Ok(mut chosen) => { + chosen.refused = refused; + return Ok(chosen); + } + Err(why) => { + println!("{HEAD} {}: REFUSED, {why}", which.letter()); + if why == Refusal::Died { + dead.push(which); + } + // The marked slot's refusal is the one the kernel is told: it + // is why this boot is not the one the table asked for. + refused.get_or_insert((which, why)); + } + } + } + for which in dead { + let slot = table.slot(which).expect("a slot `order` named"); + println!("{HEAD} {}: no slot verifies but this one, whose image died on its last boot; it boots again", which.letter()); + match verify(bs, &mut disk, which, slot, floor, None) { + Ok(mut chosen) => { + if which != table.marked { + chosen.refused = refused; + } + return Ok(chosen); + } + Err(why) => println!("{HEAD} {}: REFUSED, {why}", which.letter()), + } + } + Err(alloc::format!( + "no slot verifies: the marked slot {} was refused ({})", + table.marked.letter(), + refused.map_or(String::new(), |(_, why)| alloc::format!("{why}")) + )) +} + +/// `slot`, held to [`KEY`] and the floor, and to the record's deaths where +/// there is a record to hold it to. +fn verify( + bs: &BootServices, + disk: &mut Disk<'_>, + which: Which, + slot: Slot, + floor: u64, + record: Option<&Record>, +) -> Result { + let letter = which.letter(); + // First, because it costs one read of the table and refuses by name a + // ROOT its own table does not vouch for — one overlapping a neighbour — + // before a byte of the slot is read. + let part = disk.locate(slot.root).map_err(|why| { + println!("{HEAD} {letter}: {why}"); + Refusal::Unreadable("root") + })?; + let (header, digest) = signed_header(bs, which, &slot)?; + if record.is_some_and(|record| record::died(record, which, &digest)) { + return Err(Refusal::Died); + } + policy::admits(floor, header.version)?; + + let kernel = read_section(bs, &slot.boot, slots::KERNEL_FILE, header.kernel().len, "kernel")?; + held(&kernel, header.kernel().sha256, "kernel")?; + let cmdline = read_section(bs, &slot.boot, slots::CMDLINE_FILE, header.cmdline().len, "cmdline")?; + held(&cmdline, header.cmdline().sha256, "cmdline")?; + + let root = disk.read_root(bs, &part, header.root().len).map_err(|why| { + println!("{HEAD} {letter}: ROOT: {why}"); + Refusal::Unreadable("root") + })?; + let began = crate::tsc(); + let root_hash = toyos_update::sha256(root.bytes()); + println!("{HEAD} {letter}: ROOT hashed in {} TSC cycles", crate::tsc().wrapping_sub(began)); + if root_hash != header.root().sha256 { + root.free(bs); + return Err(Refusal::Hash("root")); + } + println!("{HEAD} {letter}: kernel, cmdline and ROOT are the bytes the signed header names"); + Ok(Chosen { which, version: header.version, digest, kernel, cmdline, root, refused: None }) +} + +/// `slot`'s signed header, held to [`KEY`], and its SHA-256. +fn signed_header(bs: &BootServices, which: Which, slot: &Slot) -> Result<(Header, Digest), Refusal> { + let letter = which.letter(); + let signed = match read_file(bs, &slot.boot, slots::SIGNED_FILE, SIGNED_BYTES as u64) { + Ok(bytes) => bytes, + Err(FileRefused::Missing) => return Err(Refusal::Unsigned), + Err(FileRefused::Other(why)) => { + println!("{HEAD} {letter}: {} {why}", slots::SIGNED_FILE); + return Err(Refusal::Unreadable("signed header")); + } + }; + let signed: [u8; SIGNED_BYTES] = signed.as_slice().try_into().map_err(|_| Refusal::Malformed)?; + let header = Header::parse(&signed).map_err(|why| { + println!("{HEAD} {letter}: {why}"); + Refusal::Malformed + })?; + let header_bytes: &[u8; toyos_update::image::HEADER_BYTES] = + signed[..toyos_update::image::HEADER_BYTES].try_into().expect("a signed header begins with its header"); + let signature: [u8; SIGNATURE_BYTES] = toyos_update::image::signature_of(&signed); + sig::verify(&KEY, header_bytes, &signature).map_err(|why| { + println!("{HEAD} {letter}: {why}"); + Refusal::Signature + })?; + let digest = toyos_update::sha256(&signed); + let mut hex = [0u8; 64]; + println!( + "{HEAD} {letter}: signed header {} verifies under this loader's key, version {}", + toyos_update::hex(&digest, &mut hex), + header.version + ); + Ok((header, digest)) +} + +/// The version the image the record says the last boot proved carries, read +/// out of its slot's signed header, verified in this pass; or why no version +/// is: **the record is on a partition the running system writes**, so its +/// word is only which slot to read and the digest that slot's header must +/// hash to. +pub fn proven(handle: Handle, system_table: &SystemTable, booted: &Booted) -> Result { + let bs = system_table.boot_services(); + let letter = booted.slot.letter(); + let mut disk = Disk::open(bs, crate::rootimage::boot_disk(handle, bs)?)?; + let table = disk.slot_table()?; + let slot = table.slot(booted.slot).ok_or_else(|| alloc::format!("the slot table carries no slot {letter}"))?; + let (header, digest) = signed_header(bs, booted.slot, &slot).map_err(|why| alloc::format!("slot {letter}: {why}"))?; + if digest != booted.digest { + let (mut have, mut want) = ([0u8; 64], [0u8; 64]); + return Err(alloc::format!( + "slot {letter}'s signed header is {}, and the record names {}", + toyos_update::hex(&digest, &mut have), + toyos_update::hex(&booted.digest, &mut want) + )); + } + Ok(header.version) +} + +/// `bytes` is the section whose header entry names `want`. +fn held(bytes: &[u8], want: Digest, section: &'static str) -> Result<(), Refusal> { + if toyos_update::sha256(bytes) != want { + return Err(Refusal::Hash(section)); + } + Ok(()) +} + +/// A section's file, exactly `len` bytes long. +fn read_section(bs: &BootServices, guid: &[u8; 16], path: &str, len: u64, section: &'static str) -> Result, Refusal> { + match read_file(bs, guid, path, len) { + Ok(bytes) if bytes.len() as u64 == len => Ok(bytes), + Ok(bytes) => { + println!("{HEAD}: {path} is {} bytes and its signed header names {len}", bytes.len()); + Err(Refusal::Hash(section)) + } + Err(FileRefused::Missing) => { + println!("{HEAD}: {path} is missing"); + Err(Refusal::Unreadable(section)) + } + Err(FileRefused::Other(why)) => { + println!("{HEAD}: {path} {why}"); + Err(Refusal::Unreadable(section)) + } + } +} + +enum FileRefused { + Missing, + Other(String), +} + +/// The file at `path` on the FAT partition `guid` names, if it is at most +/// `max` bytes. +fn read_file(bs: &BootServices, guid: &[u8; 16], path: &str, max: u64) -> Result, FileRefused> { + let handle = crate::loaderlog::volume_handle(bs, guid).map_err(FileRefused::Other)?; + let mut fs = bs + .open_protocol_exclusive::(handle) + .map_err(|e| FileRefused::Other(alloc::format!("would not open its volume ({e})")))?; + let mut root = fs.open_volume().map_err(|e| FileRefused::Other(alloc::format!("has no volume ({e})")))?; + let name = CString16::try_from(path.replace('/', "\\").as_str()) + .map_err(|_| FileRefused::Other(String::from("is no UCS-2 path")))?; + let file = match root.open(&name, FileMode::Read, FileAttribute::empty()) { + Ok(file) => file, + Err(e) if e.status() == Status::NOT_FOUND => return Err(FileRefused::Missing), + Err(e) => return Err(FileRefused::Other(alloc::format!("would not open ({e})"))), + }; + let mut file = file.into_regular_file().ok_or(FileRefused::Other(String::from("is a directory")))?; + let info = file + .get_boxed_info::() + .map_err(|e| FileRefused::Other(alloc::format!("would not say its size ({e})")))?; + let size = info.file_size(); + if size > max { + return Err(FileRefused::Other(alloc::format!("is {size} bytes, past the {max} expected"))); + } + let mut bytes = crate::alloc_uninit(size as usize); + let read = file.read(&mut bytes).map_err(|e| FileRefused::Other(alloc::format!("would not read ({e})")))?; + if read != bytes.len() { + return Err(FileRefused::Other(alloc::format!("read short: {read} of {size} bytes"))); + } + Ok(bytes) +} diff --git a/issues/boot-media/the-anti-rollback-floor-is-a-firmware-variable.md b/issues/boot-media/the-anti-rollback-floor-is-a-firmware-variable.md new file mode 100644 index 00000000000..87ffd4ff50f --- /dev/null +++ b/issues/boot-media/the-anti-rollback-floor-is-a-firmware-variable.md @@ -0,0 +1,45 @@ +--- +status: open +kind: defect +opened: 2026-09-26 +--- + +# The anti-rollback floor is a firmware variable, which the machine in hand can reset + +The loader refuses an image whose version is below the highest version a boot +has proven, and keeps that floor in a UEFI variable per signing key +(`toyos_update::floor`) that it creates non-volatile and boot-services-only +(`bootloader/src/floor.rs`). That +holds against everything after the handoff: no kernel, no program and no write +to the disk can lower it, because the variable is unreachable once +`ExitBootServices` has run. It does not hold against: + +- **anything firmware boots instead of the loader** — an EFI shell or another + OS's loader off a stick deletes the variable with one `SetVariable`, and + nothing stops firmware booting one until Secure Boot is on with the owner's + key (the track's verified-boot stage); +- **the firmware's own reset of its variables** — a setup-menu "restore + defaults", a CMOS clear, a flash of the firmware; +- **a floor that never rises** — it rises only on a boot that hands the machine + back on purpose, so a machine that is only ever powered off keeps the floor of + its last clean reboot, and the old slot beside it stays bootable. +- **an image signed by a throwaway key** — its floor is per image, keyed by + the log partition's GUID, so a running system that rewrites that GUID + resets it; that is the price of an old stick or a bisect still booting, and + a throwaway key signs nothing the owner's machine runs. +- **a kernel booted before the first raise** — it can make a variable of the + floor's name with runtime access, which a later `SetVariable` from the + loader cannot replace. The loader deletes it; where the firmware keeps it + (an authenticated-write variable on firmware that enforces that), the loader + refuses rather than boot with no floor, so nothing boots until the + firmware's variables are reset. OVMF deletes one made with time-based + authenticated write, so no guest reaches that refusal. + +`/system/bin/update`'s own check — newer than the running image — reads the +versions in the slot table, which is on the disk and is advisory; the loader's +floor is the enforcement. + +**Exit**: the floor is a monotonic counter the platform refuses to lower — a +TPM 2.0 NV counter, extended by the loader and read by it — or the machine is +documented as having none and the loader says so on every boot; and the floor +rises on a boot that confirms itself healthy, not only on a reboot. diff --git a/issues/boot-media/the-machine-updates-itself-without-ubuntu.md b/issues/boot-media/the-machine-updates-itself-without-ubuntu.md index 00207e51533..404ebff7983 100644 --- a/issues/boot-media/the-machine-updates-itself-without-ubuntu.md +++ b/issues/boot-media/the-machine-updates-itself-without-ubuntu.md @@ -6,30 +6,75 @@ opened: 2026-09-24 # The machine updates itself, without Ubuntu, and boots only what the owner signed -Every kernel change on the T14 still needs Ubuntu to write the stick, and a -change to the machine's policy needs nothing more than a writable disk. The -owner's goal: Ubuntu is recovery only, updates are as fast as possible, and the -loader boots only images signed with the owner's key. - -## The shape - -- **Two slots, each a pair of partitions**: a FAT holding the kernel and its - command line, and a bcachefs ROOT selected by `root=`. The updater - claims the idle slot's partitions by their unique GUIDs, as it would any - device (`issues/boot-media/` partition claims, PRs #487 and #489). -- **The update rides the swap pipeline**: an authenticated ssh upload, the - hash checked, and only the blocks that changed are written. A marker block is - written last, after an fsync that answers for the updater's own writes. -- **The loader chooses.** It boots the marked slot and falls back to the other - through the attempt counter it already has. A dead boot is reported on the - next boot through logd. The boot order is set once. The loader itself is not - slotted. -- **Signed images.** The Mac holds the owner's Ed25519 key and signs the - kernel and ROOT of every image; the key never reaches the T14. The loader - refuses a slot whose signature does not verify, by name, and falls back. The - loader is the part signing does not cover: only UEFI Secure Boot over the - loader closes that, and until then a writable ESP is the gap. - -**Exit**: a kernel change reaches the T14 and boots with Ubuntu never started. -A slot with a flipped byte or no signature is refused and the other slot boots. -A boot that dies falls back on its own. +The owner's end state (ruling 2026-09-26): **no Ubuntu at all**. The T14's +stick carries ToyOS and ToyOS is installed on its NVMe; the T14 is a +playground, so a broken driver may wipe that NVMe and the next install writes it +again. Updates are `ssh update < image` — a normal `exec`, the image on +stdin — and the machine installs nothing the owner did not sign. + +## Stage 1 — slots, signatures and `update`, proven in QEMU (done) + +- **A signed image** (`toyos-update`): a header naming a monotonic version and + the SHA-256 of the kernel, its boot parameter and ROOT, signed with Ed25519 + over OpenSSH's SSHSIG message in the `toyos-image` namespace — so + `ssh-keygen -Y sign` signs one and `ssh-keygen -Y verify` checks ours. The + signature covers hashes, never bytes. +- **Two slots**, each a FAT volume (kernel, parameter, signed header) and a + ROOT, named by a slot table (two copies, a sequence, the writer writes the + copy that is not current). Every image carries slot A; an image built for a + machine carries an empty slot B. +- **The loader** boots the marked slot and falls back to the other where the + marked one is refused — unsigned, another key's, a flipped byte, a version + under the floor — or died on its last boot; the kernel logs which and why, and + `logd` carries it to `/log`. +- **`update`** holds the slot table and the idle slot's two partitions, which + init claims against the ROOT the kernel holds, writes the image and moves the + mark last, after an fsync of its own writes. **`swap`** replaced sshd's + subsystem the same way. +- **Keys**: a throwaway per checkout, kept in its `target/`, for QEMU, CI and + the metal loop, whose loaders keep a floor per image; the owner's + (`--owner-key`, `--update-image`, minted by `--signing-key-new`) for an image + installed on the owner's machine, whose loader keeps the machine's floor. + +## Stage 2 — the T14 installs ToyOS on its NVMe and updates without Ubuntu + +What `toyos-metal` still runs Ubuntu for, each of which this stage replaces: + +1. **Writing the image** — `wipefs` and `dd of=/dev/sda` under a sudoers rule. + Replaced by the machine booting the stick and installing onto its own NVMe, + then `ssh t14 update < image` for every change after. +2. **Choosing the next boot** — `efibootmgr --create-only`, `--delete-bootnum`, + `--bootnext`. The loader already points `BootNext` at itself; an install + writes its own entry once. +3. **Reading a boot's verdict** — `dd if=/dev/sda3` and `mount -o ro` of the log + partition. Replaced by `logd`'s record stream and `ssh … cat`. +4. **Reboots and liveness** — `reboot`, `true`, `date -u +%s`, the `/sys` + identity reads of the stick, and the loop's wait for Ubuntu's sshd to come + back after every ToyOS boot. +5. **The runner key and `ssh t14`** themselves reach Ubuntu's sshd, not ToyOS's. + +**Exit**: a kernel change reaches the T14 and boots with Ubuntu never started; +a slot with a flipped byte, no signature or a lower version is refused and the +other boots; a boot that dies falls back on its own — each on the T14. + +## Later stages — the end state, which no earlier stage may block + +- **An image-based, read-only system**: `/system` is the signed ROOT and + nothing else, and nothing the machine runs is outside an image or a package. +- **A/B slots with automatic rollback**: stage 1's fallback, plus a boot that + confirms itself healthy before the floor rises (today the floor rises only on + a boot that hands the machine back on purpose). +- **A verified boot chain**: UEFI Secure Boot with the owner's key over the + loader. Until then the loader is the one binary no signature covers, and a + writable ESP is the gap. +- **Anti-rollback that holds against the machine in hand**: + `issues/boot-media/the-anti-rollback-floor-is-a-firmware-variable.md`. +- **Pull-based updates** from a release server over HTTPS, with The Update + Framework's roles and metadata over the same signed header — `update` takes + the bytes on stdin and does not care how they came. +- **Content-addressed, chunked delta updates**: the header already names each + section by hash, so a chunked form is the same signature over the same + hashes, and only chunks the idle slot lacks cross the wire. +- **User data never touched by an update**: `/home` and `/apps` are on DATA, + which no slot includes. +- **Apps updated separately** through `pkg`. diff --git a/issues/boot-media/the-running-slots-volume-is-claimable.md b/issues/boot-media/the-running-slots-volume-is-claimable.md new file mode 100644 index 00000000000..8360ffe805c --- /dev/null +++ b/issues/boot-media/the-running-slots-volume-is-claimable.md @@ -0,0 +1,32 @@ +--- +status: open +kind: defect +opened: 2026-09-26 +--- + +# The running slot's FAT volume is claimable, where its ROOT is not + +The kernel holds the partition its ROOT image came from for the machine's life +(`kernel/src/rootfs.rs`, `hold_source`), so no claim can write the ROOT a boot +runs. It holds nothing of the same slot's FAT volume — the kernel, its boot +parameter and the signed header the loader read — so any process whose +`SysCap` carries `device` can claim that partition by its unique GUID and write +it. The test estate's `test-runner` holds `device`, and so would any row +a config gives it to. + +`/system/bin/update` reaches it too. `slots::grant` (`toyos-update/src/slots.rs`) +knows which volume is the running slot's only from the slot table, and +`update` writes that table: one naming a decoy volume beside the running ROOT +as slot A, and the running volume beside the idle ROOT as slot B, is granted +the running volume on the next boot. Nothing the kernel holds names the booted +volume, so init has nothing to check the table's word against. + +A write there is not a silent compromise: the loader holds every byte to the +signature at the next boot and refuses the slot by name. It is a denial — +the running slot is refused next boot and the machine falls back to an older +image, or to nothing on a one-slot image. + +**Exit**: the loader hands the kernel the booted slot's volume by its unique +GUID beside ROOT's, and the kernel holds it as it holds ROOT's, so a claim on +it is refused as `PermissionDenied` like every partition the kernel holds; a +guest test asserts the refusal. diff --git a/issues/isolation/a-swapped-binary-lives-where-any-process-can-rewrite-it.md b/issues/isolation/a-swapped-binary-lives-where-any-process-can-rewrite-it.md index be8e22d49d1..079e73036bb 100644 --- a/issues/isolation/a-swapped-binary-lives-where-any-process-can-rewrite-it.md +++ b/issues/isolation/a-swapped-binary-lives-where-any-process-can-rewrite-it.md @@ -21,8 +21,9 @@ guarantee — only by nobody having rewritten it in between. Two windows: - **For the service's life.** The kernel pages a binary in on demand, and a restore after a failed swap spawns the previous installed path again. -Nothing in the tree does either; the authority to ask for a swap is sshd's -alone, and this is the one step of it the capability model does not cover. +Nothing in the tree does either; the authority to ask for a swap is +`/system/bin/swap`'s alone, and this is the one step of it the capability +model does not cover. **Exit condition.** The spawn takes what init verified rather than a path: a spawn from a file handle init holds, or from bytes init hands over — an ABI diff --git a/issues/kernel/the-kernel-is-small-interrupts-post-and-threads-wait.md b/issues/kernel/the-kernel-is-small-interrupts-post-and-threads-wait.md index 830346ab591..e7ba3863a2c 100644 --- a/issues/kernel/the-kernel-is-small-interrupts-post-and-threads-wait.md +++ b/issues/kernel/the-kernel-is-small-interrupts-post-and-threads-wait.md @@ -42,8 +42,9 @@ times: **Done** (#506; T14 run 143 green): on the stick the loader reads ROOT in 1160 ms, the kernel reaches `Boot: complete` 1229 ms after it starts, and the kernel issues 0 storage commands before init. The NVMe cost is not - measured and cannot be on the T14: nothing is ever written to its NVMe, so - there is no ROOT there to read. + measured yet: ToyOS is not installed on the T14's NVMe, so there is no ROOT + there to read until the self-update track installs it + (`issues/boot-media/the-machine-updates-itself-without-ubuntu.md`). 2. **One way to wait.** Every waitable object has one `Watch`, and a waiter is either a thread (woken) or a user poll ring (posted). The multi-waiter queue, the per-thread record ring, the `Source` double dispatch and the per-module diff --git a/kernel/Cargo.lock b/kernel/Cargo.lock index 27590e95506..edf3a3f0abd 100644 --- a/kernel/Cargo.lock +++ b/kernel/Cargo.lock @@ -75,7 +75,7 @@ checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" [[package]] name = "toyos-abi" -version = "0.14.0" +version = "0.15.0" [[package]] name = "toyos-acpi" diff --git a/kernel/src/main.rs b/kernel/src/main.rs index 22873881bed..97ffa155518 100644 --- a/kernel/src/main.rs +++ b/kernel/src/main.rs @@ -380,6 +380,16 @@ unsafe fn kernel_main(kernel_args: &KernelArgs) -> ! { kernel_args.rtc_utc_offset_minutes, kernel_args.rtc_utc_offset_known, kernel_args.cmdline_addr, kernel_args.cmdline_len ); + // Before `mm::init`, which may hand the parameter's memory out. This record + // is how a slot that died or was refused reaches the next boot's `/log`. + match params::slot(cmdline) { + (Some(slot), None) => log!("{} {slot}, the one the slot table marks", params::SLOT_RECORD), + (Some(slot), Some(refused)) => match refused.split_once(':') { + Some((marked, why)) => log!("{} {slot}, because the marked slot {marked} was refused: {why}", params::SLOT_RECORD), + None => log!("{} {slot}, because the marked slot was refused: {refused}", params::SLOT_RECORD), + }, + (None, _) => log!("{} none: the loader named no slot", params::SLOT_RECORD), + } let kernel_elf = core::slice::from_raw_parts( DirectMap::from_phys(kernel_args.kernel_elf_addr).as_ptr::(), diff --git a/kernel/src/params.rs b/kernel/src/params.rs index 4121ebcf90c..8c98cd8feef 100644 --- a/kernel/src/params.rs +++ b/kernel/src/params.rs @@ -38,6 +38,19 @@ pub fn claims(token: &str) -> bool { PARAMS.iter().any(|(name, _)| *name == token) || token.starts_with(toyos_blackbox::PARAM) || token.starts_with(toyos_tco::DEADLINE_PARAM) + || token.starts_with(toyos_abi::boot::SLOT_PARAM) + || token.starts_with(toyos_abi::boot::SLOT_REFUSED_PARAM) +} + +/// The head of the record naming the slot this boot came from. +pub const SLOT_RECORD: &str = "boot: slot"; + +/// The slot the loader booted, and the marked slot it refused and why, as the +/// loader wrote them: the last of each, because the loader appends its words +/// after the slot's own parameter and so has the last one. +pub fn slot(cmdline: &str) -> (Option<&str>, Option<&str>) { + let word = |prefix: &str| toyos_abi::boot::actuators(cmdline).filter_map(|t| t.strip_prefix(prefix)).last(); + (word(toyos_abi::boot::SLOT_PARAM), word(toyos_abi::boot::SLOT_REFUSED_PARAM)) } pub fn watchdog() -> bool { diff --git a/src/build.rs b/src/build.rs index c915e457a3e..9050f94db48 100644 --- a/src/build.rs +++ b/src/build.rs @@ -132,6 +132,8 @@ struct ProgramConfig { /// `toyos_manifest::syscap_rights` takes. A handful of rows in the whole /// tree declare one. syscap: Vec, + /// The idle slot, granted as partition claims (`toyos_manifest::Program::slots`). + slots: bool, /// A system service: init starts it with `HOME` at its own `/state/` /// and makes that directory, where every other row gets the session's. service: bool, @@ -425,11 +427,22 @@ pub const PROFILE: &str = "toyos"; struct GuestEnv { toolchain: PathBuf, path: String, + /// The public key the loader and `/system/bin/update` embed + /// (`signing::KEY_ENV`): every guest build carries it, so no crate that + /// names it can be built without it. + image_key: String, + /// Whose floor the loader keeps (`signing::FLOOR_ENV`), which follows the key. + floor_scope: &'static str, } impl GuestEnv { fn new(root: &Path, sysroot: &crate::sysroot::Sysroot) -> Self { - Self { toolchain: sysroot.dir.clone(), path: toolchain::path_with_toyos_ld(root) } + Self { + toolchain: sysroot.dir.clone(), + path: toolchain::path_with_toyos_ld(root), + image_key: crate::signing::key().public_hex(), + floor_scope: crate::signing::key().floor_scope().word(), + } } } @@ -452,6 +465,8 @@ fn cargo_build( .env("RUSTUP_TOOLCHAIN", &env.toolchain) .env_remove("RUSTFLAGS") .env("PATH", &env.path) + .env(crate::signing::KEY_ENV, &env.image_key) + .env(crate::signing::FLOOR_ENV, env.floor_scope) .env_remove("RUSTC"); for (k, v) in extra_env { cmd.env(k, v); @@ -637,12 +652,15 @@ const INIT_PROGRAM: &str = "init"; /// rather than a constant here and a string in init. const INIT_SERVED: &[&str] = &["launcher", toyos_swap::PORT]; -/// Who may hold the swap port in `config`: [`toyos_swap::HOLDER`] and nothing -/// else — no other `[programs]` row and never `[apps]`. +/// Who may hold the two authorities that change what the machine runs: +/// the swap port, [`toyos_swap::HOLDER`] and nothing else — no other +/// `[programs]` row and never `[apps]` — and the idle slot, +/// [`toyos_update::slots::HOLDER`] alone. /// /// **Checked on every manifest rendered, not only on the committed configs**, -/// because the holder of this connector can replace any service's binary. -fn swap_is_sshds_alone(config: &SystemConfig) -> Result<(), String> { +/// because the holder of the one can replace any service's binary and the +/// holder of the other writes the image the machine boots next. +fn held_by_their_holders_alone(config: &SystemConfig) -> Result<(), String> { for (name, program) in &config.programs { if name != toyos_swap::HOLDER && program.receives.iter().any(|r| r == toyos_swap::PORT) { return Err(format!( @@ -651,6 +669,9 @@ fn swap_is_sshds_alone(config: &SystemConfig) -> Result<(), String> { toyos_swap::HOLDER )); } + if name != toyos_update::slots::HOLDER && program.slots { + return Err(format!("`{name}` asks for `slots`, which only `{}` may hold", toyos_update::slots::HOLDER)); + } } if config.apps.receives.iter().any(|r| r == toyos_swap::PORT) { return Err(format!("`[apps] receives` names `{}`, which only `{}` may hold", toyos_swap::PORT, toyos_swap::HOLDER)); @@ -664,7 +685,7 @@ fn swap_is_sshds_alone(config: &SystemConfig) -> Result<(), String> { /// round-trip test is what makes "what the build writes is what init reads" a /// fact rather than two hand-matched implementations. fn render_manifest(config: &SystemConfig) -> Vec { - if let Err(why) = swap_is_sshds_alone(config) { + if let Err(why) = held_by_their_holders_alone(config) { panic!("system.toml cannot be rendered as a manifest: {why}"); } let mut names: Vec<&String> = config.programs.keys().collect(); @@ -683,6 +704,7 @@ fn render_manifest(config: &SystemConfig) -> Vec { receives: cfg.receives.clone(), devices: cfg.devices.clone(), syscap: cfg.syscap.clone(), + slots: cfg.slots, service: cfg.service, } }) @@ -857,6 +879,10 @@ pub struct Plan { pub config: PathBuf, pub features: Vec, pub params: Vec, + /// The version the image's signed header names. + pub version: u64, + /// Room for a second slot, or none: a machine that cannot update itself. + pub second: Option, } impl Plan { @@ -865,6 +891,8 @@ impl Plan { config: config.to_path_buf(), features: features.iter().map(|f| (*f).to_string()).collect(), params: params.iter().map(|p| (*p).to_string()).collect(), + version: image::version_now(), + second: None, } } } @@ -890,6 +918,8 @@ pub fn plan_for(root: &Path, boot: &Boot, debug: bool, args: &[String]) -> Plan config: boot.config.clone(), features: features.split(',').filter(|f| !f.is_empty()).map(Into::into).collect(), params: param, + version: image::version_now(), + second: None, } } @@ -1132,6 +1162,10 @@ fn check_params(root: &Path, params: &[String]) { const VALUED_PARAMS: &[(&str, &str)] = &[ ("toyos_blackbox::PARAM", toyos_blackbox::PARAM), ("toyos_tco::DEADLINE_PARAM", toyos_tco::DEADLINE_PARAM), + // The loader's words about the slot it booted, appended as it appends the + // black box's. + ("toyos_abi::boot::SLOT_PARAM", toyos_abi::boot::SLOT_PARAM), + ("toyos_abi::boot::SLOT_REFUSED_PARAM", toyos_abi::boot::SLOT_REFUSED_PARAM), ]; /// The names in [`VALUED_PARAMS`], for a refusal that says what it would have @@ -1619,9 +1653,6 @@ pub fn build( rebuild_toolchain: bool, plan: &Plan, ) -> PathBuf { - let kernel_features = plan.features.join(","); - let params = plan.params.join(","); - // Every lock below is `build_test_image`'s own, and the flags it cannot // combine with were refused before any of them. if boot.case { @@ -1632,6 +1663,66 @@ pub fn build( return image_path; } + let (kernel_bytes, bl_bytes, root_bytes) = shipped_parts(root, &boot, rebuild_toolchain, plan); + let key = said_key(plan); + // A machine this image is flashed onto updates itself, so it carries + // the second slot an update is written to, with room for a ROOT twice + // this one's size. + let second = image::SecondSlot { root_bytes: 2 * root_bytes.len() as u64 }; + let disk_bytes = image::create_boot_image( + &kernel_bytes, + &bl_bytes, + &root_bytes, + &plan.params.join(","), + image::Signing { key, version: plan.version }, + Some(second), + ); + let image_path = root.join(&boot.image); + fs::write(&image_path, disk_bytes).expect("Failed to write image"); + + let nvme_path = root.join("target/nvme.img"); + if !nvme_path.exists() { + create_sparse(&nvme_path, 1024 * 1024 * 1024); + } + + image_path +} + +/// The image `ssh update` takes, of the boot `boot` names, written +/// to `out`: the same kernel, parameter and ROOT [`build`] would put in a +/// slot, signed with this run's key at the plan's version. +pub fn build_update(root: &Path, boot: &Boot, rebuild_toolchain: bool, plan: &Plan, out: &Path) { + assert!(!boot.case, "an update image is built from a mode's config, and a case's image is a test's"); + let (kernel_bytes, _, root_bytes) = shipped_parts(root, boot, rebuild_toolchain, plan); + let key = said_key(plan); + let bytes = image::update_image( + &kernel_bytes, + &root_bytes, + &plan.params.join(","), + image::Signing { key, version: plan.version }, + ); + fs::write(out, bytes).unwrap_or_else(|e| panic!("write {}: {e}", out.display())); +} + +/// This run's key, said with whose it is and the version it signs. +fn said_key(plan: &Plan) -> &'static crate::signing::Key { + let key = crate::signing::key(); + println!( + "Signed with {} {} at version {}", + match key.whose() { + crate::signing::Whose::Owner(_) => "the owner's key", + crate::signing::Whose::Throwaway => "this checkout's throwaway key", + }, + key.fingerprint(), + plan.version + ); + key +} + +/// The kernel, the loader and ROOT a mode's image is made of. +fn shipped_parts(root: &Path, boot: &Boot, rebuild_toolchain: bool, plan: &Plan) -> (Vec, Vec, Vec) { + let kernel_features = plan.features.join(","); + // Before every build lock, which is the order `buildlock`'s header fixes. // What it bounds is the host: ten agents' builds spend the same fourteen // cores, and nothing was counting them. @@ -1692,7 +1783,7 @@ pub fn build( "bootloader/target/x86_64-unknown-uefi/{PROFILE}/bootloader.efi" )), "bootloader.efi", - key_hash(&[PROFILE]), + key_hash(&[PROFILE, &env.image_key, env.floor_scope]), ), ) }; @@ -1701,16 +1792,7 @@ pub fn build( build_and_assemble(root, &config, &env, &[], false); let bl_bytes = fs::read(&bl_art).expect("Failed to read staged bootloader"); - let disk_bytes = image::create_boot_image(&kernel_bytes, &bl_bytes, &root_bytes, ¶ms); - let image_path = root.join(&boot.image); - fs::write(&image_path, disk_bytes).expect("Failed to write image"); - - let nvme_path = root.join("target/nvme.img"); - if !nvme_path.exists() { - create_sparse(&nvme_path, 1024 * 1024 * 1024); - } - - image_path + (kernel_bytes, bl_bytes, root_bytes) } /// Create an empty disk image the guest sees at full size and the host pays @@ -1803,10 +1885,11 @@ static ROOT_IMAGE: Memo = Memo::new(); /// files the caller adds to it. Hashed whole: a key over the test binaries' /// names and lengths would call two different builds of one binary the same /// image. -fn root_image_key(config_path: &Path, extra_files: &[(String, Vec)]) -> u64 { +fn root_image_key(config_path: &Path, image_key: &str, extra_files: &[(String, Vec)]) -> u64 { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); config_path.hash(&mut h); + image_key.hash(&mut h); for (name, data) in extra_files { name.hash(&mut h); data.hash(&mut h); @@ -1827,6 +1910,43 @@ pub fn build_test_image( quiet: bool, extra_files: &[(String, Vec)], ) -> Vec { + let parts = build_test_parts(root, plan, quiet, extra_files); + image::create_boot_image( + &parts.kernel, + &parts.bootloader, + &parts.root, + &plan.params.join(","), + image::Signing { key: crate::signing::key(), version: plan.version }, + plan.second, + ) +} + +/// The image `ssh … update` takes, built from a plan as a test image is and +/// signed with this process's key at the plan's version. +pub fn build_update_image(root: &Path, plan: &Plan, quiet: bool, extra_files: &[(String, Vec)]) -> Vec { + let parts = build_test_parts(root, plan, quiet, extra_files); + image::update_image( + &parts.kernel, + &parts.root, + &plan.params.join(","), + image::Signing { key: crate::signing::key(), version: plan.version }, + ) +} + +/// The three parts one image is made of, each memoized for this process. +pub struct Parts { + pub kernel: Arc>, + pub bootloader: Arc>, + pub root: Arc>, +} + +/// [`Parts`] for a plan: built, or this process's memo of them. +pub fn build_test_parts( + root: &Path, + plan: &Plan, + quiet: bool, + extra_files: &[(String, Vec)], +) -> Parts { let config_path = plan.config.as_path(); let (kernel_features, kernel_params) = (&plan.features, &plan.params); let config = parse_config(config_path); @@ -1841,17 +1961,19 @@ pub fn build_test_image( || kernel_features.iter().eq(TEST_KERNEL.iter().copied()), "a boot asking for {kernel_params:?} must boot the test kernel, not {kernel_features:?}" ); - let params = kernel_params.join(","); let kernel_key = kernel_key(&features); - let bl_key = key_hash(&[PROFILE]); - let root_image_key = root_image_key(config_path, extra_files); + // The loader and ROOT (whose `/system/bin/update` embeds it) are each a + // function of the key this process signs with. + let image_key = crate::signing::key().public_hex(); + let bl_key = key_hash(&[PROFILE, &image_key, crate::signing::key().floor_scope().word()]); + let root_image_key = root_image_key(config_path, &image_key, extra_files); // Nothing left to build, so nothing for the lock, the toolchain check or the // staleness sweep to protect. - if let (Some(kernel), Some(bl), Some(root)) = + if let (Some(kernel), Some(bootloader), Some(root)) = (KERNEL.get(kernel_key), BOOTLOADER.get(bl_key), ROOT_IMAGE.get(root_image_key)) { - return image::create_boot_image(&kernel, &bl, &root, ¶ms); + return Parts { kernel, bootloader, root }; } // A cache miss is shared setup, not a property of whichever test happened @@ -1927,7 +2049,7 @@ pub fn build_test_image( drop(build_timer); - image::create_boot_image(&kernel_bytes, &bl_bytes, &root_bytes, ¶ms) + Parts { kernel: kernel_bytes, bootloader: bl_bytes, root: root_bytes } } /// The host binaries the network judges drive, built here rather than inside a @@ -2876,6 +2998,7 @@ mod tests { "tests/swapcase/system.toml", "tests/testcases/system.toml", "tests/toolkitcase/system.toml", + "tests/updatecase/system.toml", ]; fn load(cfg: &str) -> SystemConfig { @@ -2914,21 +3037,27 @@ mod tests { assert!(receives_have_providers(&bad).is_err()); } - /// The swap port reaches sshd and nothing else, in every committed config - /// and in a config that tries either other door. + /// The swap port reaches `swap` and nothing else, and the idle slot + /// `update` and nothing else, in every committed config and in a config + /// that tries any other door. #[test] - fn only_sshd_may_receive_the_swap_port() { + fn only_their_holders_may_hold_the_swap_port_and_the_slots() { for cfg in ALL_CONFIGS { - swap_is_sshds_alone(&load(cfg)).unwrap_or_else(|e| panic!("{cfg}: {e}")); + held_by_their_holders_alone(&load(cfg)).unwrap_or_else(|e| panic!("{cfg}: {e}")); } - let sshd: SystemConfig = - toml::from_str("[programs.sshd]\nreceives = [\"netd\", \"swap\"]\n").unwrap(); - assert!(swap_is_sshds_alone(&sshd).is_ok()); + let ok: SystemConfig = + toml::from_str("[programs.swap]\nreceives = [\"swap\"]\n[programs.update]\nslots = true\n").unwrap(); + assert!(held_by_their_holders_alone(&ok).is_ok()); let shell: SystemConfig = toml::from_str("[programs.shell]\nreceives = [\"swap\"]\n").unwrap(); - assert!(swap_is_sshds_alone(&shell).is_err()); + assert!(held_by_their_holders_alone(&shell).is_err()); + let sshd: SystemConfig = + toml::from_str("[programs.sshd]\nreceives = [\"netd\", \"swap\"]\n").unwrap(); + assert!(held_by_their_holders_alone(&sshd).is_err()); let apps: SystemConfig = toml::from_str("[apps]\nreceives = [\"swap\"]\n").unwrap(); - assert!(swap_is_sshds_alone(&apps).is_err()); + assert!(held_by_their_holders_alone(&apps).is_err()); + let slots: SystemConfig = toml::from_str("[programs.shell]\nslots = true\n").unwrap(); + assert!(held_by_their_holders_alone(&slots).is_err()); } /// `[apps] receives` is narrower than a program's: a `provides` name is one diff --git a/src/ci.rs b/src/ci.rs index 727ee2bc967..52ccf75082a 100644 --- a/src/ci.rs +++ b/src/ci.rs @@ -3,13 +3,13 @@ //! verdict. //! //! `.github/workflows/` is three files. `ci.yml` runs on a pull request and in -//! the merge queue and boots no guest, split so each half reads only the event -//! it needs: [`Job::AbiSplit`] runs as its own job on the pull request, where -//! the branch's own commits are; [`Job::Host`] and then [`Job::GateStage`] run -//! as `host`, only in the merge queue, where every branch has already been -//! judged as a pull request. A required check a workflow skips on the other -//! event still reports, and a skip counts as passing — that is how each half -//! enters the queue it does not itself run in. Every test that boots no guest +//! the merge queue and boots no guest: [`Job::AbiSplit`] runs on both, against +//! `main` on the pull request and against the group's base in the queue, where +//! two branches that took one version meet; [`Job::Host`] and then +//! [`Job::GateStage`] run as `host`, only in the merge queue, where every +//! branch has already been judged as a pull request. A required check a +//! workflow skips on the other event still reports, and a skip counts as +//! passing — that is how `host` enters the queue. Every test that boots no guest //! is in [`Job::Host`], so a merge is gated on all of them. `nightly.yml` runs //! everything that boots a guest, `host` again to write the cache the merge //! queue restores, and portability. `publish.yml` puts a landing's crates on @@ -519,12 +519,14 @@ fn left_behind(tmp: &Path) -> Result { )) } -/// A pull request against `main`, run as its own `ci.yml` job because it reads -/// the branch's own history against its merge base — a merge group's is -/// several branches', each already judged this way as a pull request, which is -/// why `abi-split` is not a job there at all. It keeps the name branch -/// protection requires. +/// The published crates' versions, on both events, keeping the name branch +/// protection requires: a pull request is judged against `main`, and a merge +/// group against the base the queue built it on (`sdkversion::judge_queued`) +/// — the one place two branches that took the same version meet. fn abi_split(root: &Path) -> Result { + if std::env::var("GITHUB_EVENT_NAME").as_deref() == Ok("merge_group") { + return sdkversion::judge_queued(root); + } pr::git(root, &["fetch", "--quiet", "origin", "+refs/heads/main:refs/remotes/origin/main"])?; sdkversion::judge(root, "origin/main") } diff --git a/src/clippy.rs b/src/clippy.rs index ce35ab4beb0..5ea33719eda 100644 --- a/src/clippy.rs +++ b/src/clippy.rs @@ -110,6 +110,10 @@ pub fn run(root: &Path) -> Vec { let status = Command::new("cargo") .arg("clippy") .args(shape.args()) + // The loader will not compile without the key it embeds; a + // throwaway one, since nothing linted here is signed. + .env(crate::signing::KEY_ENV, crate::signing::key().public_hex()) + .env(crate::signing::FLOOR_ENV, crate::signing::key().floor_scope().word()) .current_dir(root.join(shape.dir)) .status() .unwrap_or_else(|e| panic!("running cargo clippy in {scope}: {e}")); diff --git a/src/flags.rs b/src/flags.rs index 20eda8613bb..182f8eb7ee2 100644 --- a/src/flags.rs +++ b/src/flags.rs @@ -80,6 +80,14 @@ declare_flags!(pub CARGO_RUN = { pub REGEN_SOUNDFONT = "--regen-soundfont", Next; pub WORKTREE = "--worktree", Rest; pub CHECK_FORKS = "--check-forks", None; + /// Mint the owner's image-signing key where `signing::owner_key_path` + /// says, refusing to replace one. + pub SIGNING_KEY_NEW = "--signing-key-new", None; + /// Sign what this run builds with the owner's key, not a throwaway. + pub OWNER_KEY = "--owner-key", None; + /// Write the image `ssh update` takes to this path, signed with + /// the owner's key. + pub UPDATE_IMAGE = "--update-image", Next; }); /// What became of a command line, checked before anything else in `main` runs. diff --git a/src/image.rs b/src/image.rs index dee85f01e7f..efc09239153 100644 --- a/src/image.rs +++ b/src/image.rs @@ -115,31 +115,117 @@ fn superblock_of(bytes: &[u8]) -> Superblock { Superblock::parse(&BlockBuf(block)).expect("the ROOT image this build wrote carries a superblock") } +/// What an image is signed with: the key, and the monotonic version the +/// signed header names. +#[derive(Clone, Copy)] +pub struct Signing<'a> { + pub key: &'a crate::signing::Key, + pub version: u64, +} + +/// The version a build that names none signs its image with: its Unix time in +/// seconds, which the owner's one Mac only moves forward. +pub fn version_now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("this host's clock is past 1970") + .as_secs() +} + +/// An image's second slot: empty, with room for an update's ROOT of up to +/// `root_bytes`. An image without one is a machine that cannot update itself, +/// which is every QEMU guest but the ones whose subject is the update. +#[derive(Clone, Copy, Debug)] +pub struct SecondSlot { + pub root_bytes: u64, +} + +/// The sections of one image, as a slot carries them and an update sends them. +struct Sections { + kernel: Vec, + cmdline: Vec, + signed: [u8; toyos_update::image::SIGNED_BYTES], +} + +fn sections(kernel: &[u8], root: &[u8], params: &str, signing: Signing<'_>) -> Sections { + let cmdline = cmdline_with_root(root_uuid_of(root), params).into_bytes(); + let header = toyos_update::image::Header::of(signing.version, kernel, &cmdline, root); + Sections { kernel: kernel.to_vec(), signed: signing.key.sign(&header), cmdline } +} + +/// The image `ssh update` takes on its standard input: the signed +/// header, then the kernel, the boot parameter and ROOT, in the order the +/// header names them (`toyos_update::image`). +pub fn update_image(kernel: &[u8], root: &[u8], params: &str, signing: Signing<'_>) -> Vec { + let s = sections(kernel, root, params, signing); + let mut out = s.signed.to_vec(); + out.extend_from_slice(&s.kernel); + out.extend_from_slice(&s.cmdline); + out.extend_from_slice(root); + out +} + /// Takes the artifacts as bytes rather than reading them: the caller stages them /// under a build-key-derived name first, because cargo's own path is shared by /// every config and is overwritten by any concurrent build (see `build.rs`). +/// +/// The disk: the ESP (the loader and the log partition's name), the slot +/// table, the log partition — third, where the metal loop finds it — and then +/// slot A's FAT and ROOT, marked, and slot B's where `second` asks for one. pub fn create_boot_image( kernel_bytes: &[u8], bl_bytes: &[u8], root_bytes: &[u8], params: &str, + signing: Signing<'_>, + second: Option, ) -> Vec { // Drawn here and written twice: into the GPT entry that *is* the log // partition, and into a file on the ESP that the bootloader hands the // kernel. The kernel is given the partition by name; nothing anywhere goes - // looking for one by type or by format. - let log_guid = uuid::Uuid::new_v4(); - let esp_guid = uuid::Uuid::new_v4(); - // ROOT is the one exception: its *type* selects candidates and its - // superblock's UUID picks one, because a release puts several ROOTs on one - // disk and the bootloader chooses by writing this argument. - let cmdline = cmdline_with_root(root_uuid_of(root_bytes), params); - let esp_volume = create_esp_volume(kernel_bytes, bl_bytes, log_guid, &cmdline); - let log_volume = create_log_volume(); - create_gpt_disk(esp_volume, root_bytes, log_volume, esp_guid, log_guid) + // looking for one by type or by format. Every slot partition is named the + // same way, by the slot table. + let guid = uuid::Uuid::new_v4; + let (log_guid, esp_guid, table_guid) = (guid(), guid(), guid()); + let a = (guid(), guid()); + let b = second.map(|room| (guid(), guid(), room)); + let raw = |g: uuid::Uuid| g.to_bytes_le(); + + let s = sections(kernel_bytes, root_bytes, params, signing); + // Room for a kernel twice this one's size beside it, so an update can + // write a new kernel into a slot's volume before the old one is gone. + let slot_bytes = round_up_sectors(((kernel_bytes.len() * 2 + ESP_FREE_BYTES) * 64 / 63).max(FAT32_MIN_BYTES)); + let a_boot = create_slot_volume(Some(&s), slot_bytes); + let slot = |(boot, root): (uuid::Uuid, uuid::Uuid), version| toyos_update::slots::Slot { boot: raw(boot), root: raw(root), version }; + let table = toyos_update::slots::Table { + sequence: 1, + marked: toyos_update::slots::Which::A, + slots: [Some(slot(a, signing.version)), b.map(|(boot, root, _)| slot((boot, root), 0))], + }; + let mut table_volume = vec![0u8; PARTITION_ALIGN]; + table_volume[..toyos_update::slots::BLOCK].copy_from_slice(&table.encode()); + + let mut parts = vec![ + Part::full("ESP", "EFI System", gpt::partition_types::EFI, esp_guid, create_esp_volume(bl_bytes, log_guid), Some(Volume::Fat32)), + Part::full("slot table", "ToyOS slots", TOYOS_SLOTS, table_guid, table_volume, None), + // Microsoft Basic Data, and that type is the whole reason this is a + // partition of its own: macOS never auto-mounts an EFI-typed partition + // and this host refuses even a manual non-root mount of one, so a log on + // the ESP is unreachable without the admin account. This type mounts in + // Finder, in Windows and in Linux on plug-in, with nothing configured. + Part::full("log partition", "ToyOS log", gpt::partition_types::BASIC, log_guid, create_log_volume(), Some(Volume::Fat32)), + Part::full("slot A's volume", "ToyOS slot A", TOYOS_BOOT, a.0, a_boot, Some(Volume::Fat32)), + Part::full("slot A's ROOT", "ToyOS root A", TOYOS_ROOT, a.1, root_bytes.to_vec(), Some(Volume::Root)), + ]; + if let Some((boot, root, room)) = b { + parts.push(Part::full("slot B's volume", "ToyOS slot B", TOYOS_BOOT, boot, create_slot_volume(None, slot_bytes), Some(Volume::Fat32))); + let len = align_up(room.root_bytes as usize, PARTITION_ALIGN) as u64; + parts.push(Part { what: "slot B's ROOT", name: "ToyOS root B", kind: TOYOS_ROOT, guid: root, bytes: Vec::new(), len, judge: None }); + } + create_gpt_disk(&parts) } -/// The boot parameter as [`CMDLINE`] carries it: ROOT's name, then `params`. +/// The boot parameter as a slot carries it: ROOT's name, then `params`. fn cmdline_with_root(root: FsUuid, params: &str) -> String { if params.is_empty() { format!("root={root}") @@ -148,21 +234,15 @@ fn cmdline_with_root(root: FsUuid, params: &str) -> String { } } -/// The file on the ESP that says which actuators an image is armed with. -/// -/// Written by [`create_esp_volume`] and read back by [`params_of`], from this -/// one name: the writer and its inverse cannot drift apart while they share it. -/// The bootloader spells it `\toyos\cmdline`. -const CMDLINE: &str = "toyos/cmdline"; - /// Why a boot may not arm `asked` on the image at `path`, or `None` because /// that image is armed with exactly that list. /// -/// **An image carries the actuators it was built with**, in [`CMDLINE`] on its -/// own ESP — the file the bootloader reads and hands the kernel in -/// `KernelArgs`. So what a guest will be armed with is a fact about the image, -/// answerable before anything starts and without asking the guest; a caller -/// that has an image and a list can be told it is holding two different boots. +/// **An image carries the actuators it was built with**, in the boot parameter +/// on its marked slot's volume — the file the bootloader reads, holds to the +/// slot's signature and hands the kernel in `KernelArgs`. So what a guest will +/// be armed with is a fact about the image, answerable before anything starts +/// and without asking the guest; a caller that has an image and a list can be +/// told it is holding two different boots. /// /// Pure, and every input a parameter, so both directions can be staged without /// a guest — which is what `an_image_says_what_it_is_armed_with` does. @@ -190,57 +270,220 @@ pub fn params_of(path: &Path) -> Result, String> { Ok(toyos_abi::boot::actuators(&text).map(str::to_string).collect()) } -/// The whole boot parameter an image carries, read back off the image. -/// -/// The ESP is located through the partition table rather than at the offset -/// [`create_gpt_disk`] happens to place it at: the writer asks `add_partition` -/// where the partition went, and so does this. +/// The whole boot parameter the image's marked slot carries, read back off +/// the image: the slot table through the partition table, then the slot's +/// volume, rather than at the offsets [`create_gpt_disk`] happens to place +/// them at. fn cmdline_of(path: &Path) -> Result { - let (start, len) = { - let disk = gpt::GptConfig::new() - .writable(false) - .logical_block_size(gpt::disk::LogicalBlockSize::Lb512) - .open(path) - .map_err(|e| format!("{} has no readable GPT: {e}", path.display()))?; - let esps: Vec<_> = disk - .partitions() - .values() - .filter(|p| p.part_type_guid == gpt::partition_types::EFI) - .collect(); - let [esp] = esps.as_slice() else { - return Err(format!( - "{} has {} ESPs, and a boot image has one", - path.display(), - esps.len() - )); - }; - (esp.first_lba * 512, (esp.last_lba - esp.first_lba + 1) * 512) - }; + let mut file = std::fs::File::open(path).map_err(|e| format!("opening {}: {e}", path.display()))?; + let table = slot_table_of(&mut file).map_err(|why| format!("{}: {why}", path.display()))?; + let slot = table.slot(table.marked).expect("a table marks a slot it carries"); + let bytes = read_file_on(&mut file, slot.boot, toyos_update::slots::CMDLINE_FILE) + .map_err(|why| format!("{}: {why}", path.display()))?; + String::from_utf8(bytes).map_err(|e| format!("the boot parameter on {} is not text: {e}", path.display())) +} - // The ESP alone, and never the whole image: a test image is a quarter of a - // gigabyte and this runs once per boot that stages one. - let len = usize::try_from(len).map_err(|_| format!("{} has a {len}-byte ESP", path.display()))?; +/// The slot table on the disk image `file`, as the loader reads it. +pub fn slot_table_of(file: &mut std::fs::File) -> Result { + table_on(file).map(|(table, _, _)| table) +} + +/// Where the partition `guid` names is on the disk image `file`, in bytes, as +/// its table states it: checked to the entry array's CRC and not for range or +/// overlap, which are the loader's to refuse — a test that bends them still +/// has to read what the image says. +pub fn partition_extent(file: &mut std::fs::File, guid: [u8; 16]) -> Result<(u64, u64), String> { + let mut out = [BLANK_PARTITION; 16]; + let scan = toyos_gpt::list(&mut FileSectors(file), &mut out) + .map_err(|e| format!("no readable partition table: {e:?}"))?; + let found: Vec<&toyos_gpt::Partition> = + out[..scan.listed].iter().filter(|p| p.unique_guid == toyos_gpt::Guid(guid)).collect(); + match found[..] { + [p] if scan.listed == scan.matched as usize => { + Ok((p.first_lba * u64::from(LBA), p.lba_count() * u64::from(LBA))) + } + _ => Err(format!( + "partition {}: the table states it {} time(s) among {} entries", + toyos_gpt::Guid(guid), + found.len(), + scan.matched + )), + } +} + +/// The file `name` on the FAT partition `guid` names, read with the driver the +/// kernel mounts such a volume with rather than the crate that formatted it — +/// [`populate`]'s argument, in the other direction. The volume alone, and never +/// the whole image: a test image is a quarter of a gigabyte. +pub fn read_file_on(file: &mut std::fs::File, guid: [u8; 16], name: &str) -> Result, String> { + let (start, len) = partition_extent(file, guid)?; + let len = usize::try_from(len).map_err(|_| format!("a {len}-byte volume"))?; let mut volume = vec![0u8; len]; - let mut file = std::fs::File::open(path) - .map_err(|e| format!("opening {}: {e}", path.display()))?; file.seek(SeekFrom::Start(start)) - .map_err(|e| format!("seeking to the ESP of {}: {e}", path.display()))?; - file.read_exact(&mut volume) - .map_err(|e| format!("reading the ESP of {}: {e}", path.display()))?; - - // Read with the driver the kernel mounts this very volume with, rather than - // with the crate that formatted it — [`populate`]'s argument, in the other - // direction. - let mut fs = Fat32::mount(VolumeIo(&mut volume)) - .map_err(|e| format!("the ESP of {} does not mount: {e}", path.display()))?; - let mut file = fs - .open(CMDLINE) - .map_err(|e| format!("{} has no {CMDLINE} on its ESP: {e}", path.display()))?; - let mut text = vec![0u8; usize::try_from(file.len()).unwrap_or(usize::MAX)]; - fs.read(&mut file, 0, &mut text) - .map_err(|e| format!("reading {CMDLINE} from {}: {e}", path.display()))?; - String::from_utf8(text) - .map_err(|e| format!("{CMDLINE} on {} is not text: {e}", path.display())) + .and_then(|_| file.read_exact(&mut volume)) + .map_err(|e| format!("reading the volume at byte {start}: {e}"))?; + let mut fs = Fat32::mount(VolumeIo(&mut volume)).map_err(|e| format!("the volume does not mount: {e}"))?; + let mut found = fs.open(name).map_err(|e| format!("the volume has no {name}: {e}"))?; + let mut bytes = vec![0u8; usize::try_from(found.len()).unwrap_or(usize::MAX)]; + fs.read(&mut found, 0, &mut bytes).map_err(|e| format!("reading {name}: {e}"))?; + Ok(bytes) +} + +/// Put `update`'s sections into slot `which` of the disk image at `path` and +/// mark it — what `/system/bin/update` does on a machine, **with nothing +/// checked**: a test's way to put a slot in front of the loader that the +/// updater would refuse to write. `signed: false` leaves the slot without its +/// signed header. +pub fn stage_slot(path: &Path, which: toyos_update::slots::Which, update: &[u8], signed: bool) -> Result<(), String> { + use std::io::Write; + let parts = toyos_update::image::Parts::split(update).map_err(|why| format!("the update image: {why}"))?; + let mut file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(path) + .map_err(|e| format!("open {}: {e}", path.display()))?; + let (table, copy, table_at) = table_on(&mut file)?; + let slot = table.slot(which).ok_or_else(|| format!("{} carries no slot {}", path.display(), which.letter()))?; + + let (root_at, root_len) = partition_extent(&mut file, slot.root)?; + if parts.root.len() as u64 > root_len { + return Err(format!("ROOT is {} bytes and slot {}'s partition {root_len}", parts.root.len(), which.letter())); + } + file.seek(SeekFrom::Start(root_at)) + .and_then(|_| file.write_all(parts.root)) + .map_err(|e| format!("writing slot {}'s ROOT: {e}", which.letter()))?; + + let (boot_at, boot_len) = partition_extent(&mut file, slot.boot)?; + let mut volume = vec![0u8; boot_len as usize]; + file.seek(SeekFrom::Start(boot_at)) + .and_then(|_| file.read_exact(&mut volume)) + .map_err(|e| format!("reading slot {}'s volume: {e}", which.letter()))?; + { + let time = build_time(); + let mut fs = Fat32::mount(VolumeIo(&mut volume)).map_err(|e| format!("slot {}'s volume: {e}", which.letter()))?; + fs.create_dir_all("toyos", time).map_err(|e| format!("toyos/: {e}"))?; + let mut files: Vec<(&str, &[u8])> = vec![ + (toyos_update::slots::KERNEL_FILE, parts.kernel), + (toyos_update::slots::CMDLINE_FILE, parts.cmdline), + ]; + if signed { + files.push((toyos_update::slots::SIGNED_FILE, &parts.signed[..])); + } + for name in [toyos_update::slots::KERNEL_FILE, toyos_update::slots::CMDLINE_FILE, toyos_update::slots::SIGNED_FILE] { + if fs.exists(name).map_err(|e| format!("{name}: {e}"))? { + fs.remove(name).map_err(|e| format!("removing {name}: {e}"))?; + } + } + for (name, bytes) in files { + let mut f = fs.create(name, time).map_err(|e| format!("creating {name}: {e}"))?; + fs.write(&mut f, 0, bytes).map_err(|e| format!("writing {name}: {e}"))?; + fs.flush_meta(&mut f, time).map_err(|e| format!("recording {name}: {e}"))?; + } + fs.sync().map_err(|e| format!("syncing slot {}'s volume: {e}", which.letter()))?; + } + file.seek(SeekFrom::Start(boot_at)) + .and_then(|_| file.write_all(&volume)) + .map_err(|e| format!("writing slot {}'s volume: {e}", which.letter()))?; + + let mut next = table; + next.marked = which; + let mut marked = slot; + marked.version = parts.header.version; + next.slots[which.index()] = Some(marked); + let (to, block) = toyos_update::slots::next_write((table, copy), next); + file.seek(SeekFrom::Start(table_at + (to * toyos_update::slots::BLOCK) as u64)) + .and_then(|_| file.write_all(&block)) + .and_then(|_| file.sync_all()) + .map_err(|e| format!("writing the slot table: {e}")) +} + +/// Make `edit` of the slot table the disk image at `path` carries, as a writer +/// does — the copy that is not current, one sequence past it — **with nothing +/// checked**: a test's way to put a table in front of init that the updater +/// holding the grant could write. +pub fn restage_table(path: &Path, edit: impl FnOnce(&mut toyos_update::slots::Table)) -> Result<(), String> { + use std::io::Write; + let mut file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(path) + .map_err(|e| format!("open {}: {e}", path.display()))?; + let (table, copy, at) = table_on(&mut file)?; + let mut next = table; + edit(&mut next); + let (to, block) = toyos_update::slots::next_write((table, copy), next); + file.seek(SeekFrom::Start(at + (to * toyos_update::slots::BLOCK) as u64)) + .and_then(|_| file.write_all(&block)) + .and_then(|_| file.sync_all()) + .map_err(|e| format!("writing the slot table: {e}")) +} + +/// The unique GUID of the one partition of type `kind` on the disk image +/// `file`, as a GPT entry stores it. +pub fn unique_guid_of(file: &mut std::fs::File, kind: toyos_gpt::Guid) -> Result<[u8; 16], String> { + let mut out = [BLANK_PARTITION; 2]; + let scan = toyos_gpt::locate_type(&mut FileSectors(file), kind, &mut out) + .map_err(|e| format!("no readable partition table: {e:?}"))?; + match scan.matched { + 1 => Ok(out[0].unique_guid.0), + n => Err(format!("{n} partitions of type {kind}, and this asks for one")), + } +} + +/// Overwrite the file `name` on the FAT partition `guid` of the disk image at +/// `path` with `bytes`, exactly its length, **writing its data clusters and +/// nothing else** — so a guest running on the image that does not write that +/// file sees nothing else of its volume move. +pub fn overwrite_file_on(path: &Path, guid: [u8; 16], name: &str, bytes: &[u8]) -> Result<(), String> { + use std::io::Write; + let mut file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(path) + .map_err(|e| format!("open {}: {e}", path.display()))?; + let (start, len) = partition_extent(&mut file, guid)?; + let mut volume = vec![0u8; usize::try_from(len).map_err(|_| format!("a {len}-byte volume"))?]; + file.seek(SeekFrom::Start(start)) + .and_then(|_| file.read_exact(&mut volume)) + .map_err(|e| format!("reading the volume at byte {start}: {e}"))?; + let mut fs = Fat32::mount(VolumeIo(&mut volume)).map_err(|e| format!("the volume does not mount: {e}"))?; + let found = fs.open(name).map_err(|e| format!("the volume has no {name}: {e}"))?; + if found.len() != bytes.len() as u64 { + return Err(format!("{name} is {} bytes, and this writes {} in place", found.len(), bytes.len())); + } + let mut rest = bytes; + for extent in fs.extents(name, usize::MAX).map_err(|e| format!("{name}'s clusters: {e}"))? { + let n = rest.len().min(extent.len as usize); + file.seek(SeekFrom::Start(start + extent.offset)) + .and_then(|_| file.write_all(&rest[..n])) + .map_err(|e| format!("writing {name} at byte {}: {e}", start + extent.offset))?; + rest = &rest[n..]; + } + if !rest.is_empty() { + return Err(format!("{name}'s clusters hold {} bytes fewer than its length", rest.len())); + } + file.sync_all().map_err(|e| format!("syncing {}: {e}", path.display())) +} + +/// The slot table on the disk image `file`, which copy is current, and where +/// its partition starts. +fn table_on(file: &mut std::fs::File) -> Result<(toyos_update::slots::Table, usize, u64), String> { + let mut out = [BLANK_PARTITION; 2]; + let scan = toyos_gpt::locate_type(&mut FileSectors(file), toyos_gpt::Guid::TOYOS_SLOTS, &mut out) + .map_err(|e| format!("no readable partition table: {e:?}"))?; + if scan.matched != 1 { + return Err(format!("{} slot tables, and a boot image has one", scan.matched)); + } + let mut copies = [[0u8; toyos_update::slots::BLOCK]; 2]; + let at = out[0].first_lba * u64::from(LBA); + for (i, copy) in copies.iter_mut().enumerate() { + file.seek(SeekFrom::Start(at + (i * toyos_update::slots::BLOCK) as u64)) + .and_then(|_| file.read_exact(copy)) + .map_err(|e| format!("reading the slot table's copy {i}: {e}"))?; + } + let (table, copy) = toyos_update::slots::current([&copies[0], &copies[1]]) + .map_err(|why| format!("the slot table's partition holds {why}"))?; + Ok((table, copy, at)) } /// A raw block device rejects a write that is not a whole number of sectors, so @@ -410,45 +653,50 @@ fn populate(volume: &mut [u8], label: &str, files: &[(&str, &[u8])]) { fs.sync().unwrap_or_else(|e| panic!("syncing the {label} volume: {e}")); } -/// The partition firmware boots from: the bootloader, the kernel, the kernel's -/// arguments, and the name of the partition the kernel's log goes on. -fn create_esp_volume( - kernel: &[u8], - bootloader: &[u8], - log_guid: uuid::Uuid, - cmdline: &str, -) -> Vec { - let content_size = kernel.len() + bootloader.len(); - let total_size = round_up_sectors( - ((content_size + ESP_FREE_BYTES) * 64 / 63).max(FAT32_MIN_BYTES), - ); - +/// The partition firmware boots from: the bootloader, and the name of the +/// partition the kernel's log goes on. The kernel and its parameter are a +/// slot's (`create_slot_volume`), because the loader is the one part of the +/// machine that is not slotted. +fn create_esp_volume(bootloader: &[u8], log_guid: uuid::Uuid) -> Vec { + let total_size = round_up_sectors(((bootloader.len() + ESP_FREE_BYTES) * 64 / 63).max(FAT32_MIN_BYTES)); let mut volume = format_fat32(total_size, "TOYOS-BOOT"); - populate( &mut volume, "TOYOS-BOOT", &[ ("EFI/BOOT/BOOTx64.EFI", bootloader), - ("toyos/kernel.elf", kernel), // Mirrored in `bootloader/src/main.rs` as `\toyos\log.guid`, which - // reads it beside the two files above and refuses the volume if it - // is not there. The sixteen bytes are the GPT entry's own, in the - // entry's own order: nothing converts them on the way to the kernel - // and nothing converts the table's, so the comparison that decides - // which partition holds the log cannot be got backwards. + // reads it beside itself and refuses the volume if it is not there. + // The sixteen bytes are the GPT entry's own, in the entry's own + // order: nothing converts them on the way to the kernel and nothing + // converts the table's, so the comparison that decides which + // partition holds the log cannot be got backwards. ("toyos/log.guid", &log_guid.to_bytes_le()), - // ROOT's name and then the actuators this boot arms, comma- - // separated. Read by the bootloader beside the two above and handed - // to the kernel in `KernelArgs`, because the earliest actuator - // fires before `mm::init` and there is nowhere later to fetch it - // from. `kernel/src/actuator.rs` is the actuator list, and - // [`params_of`] is how the host asks a finished image which of them - // a guest booting it would arm. - (CMDLINE, cmdline.as_bytes()), ], ); + volume +} +/// A slot's FAT volume of `bytes`: the kernel, its boot parameter and the +/// signed header naming both and ROOT (`toyos_update::slots`), or nothing for +/// a slot no image has been installed in yet. +/// +/// The parameter is ROOT's name and then the actuators this boot arms, +/// comma-separated: handed to the kernel in `KernelArgs`, because the earliest +/// actuator fires before `mm::init` and there is nowhere later to fetch it +/// from. [`params_of`] is how the host asks a finished image which of them a +/// guest booting it would arm. +fn create_slot_volume(image: Option<&Sections>, bytes: usize) -> Vec { + let mut volume = format_fat32(bytes, "TOYOS-SLOT"); + let files: Vec<(&str, &[u8])> = match image { + Some(s) => vec![ + (toyos_update::slots::KERNEL_FILE, &s.kernel), + (toyos_update::slots::CMDLINE_FILE, &s.cmdline), + (toyos_update::slots::SIGNED_FILE, &s.signed), + ], + None => Vec::new(), + }; + populate(&mut volume, "TOYOS-SLOT", &files); volume } @@ -468,7 +716,7 @@ fn create_log_volume() -> Vec { /// /// `toyos_gpt::Guid::TOYOS_ROOT` is the same sixteen bytes as the kernel /// matches them, and `toyos_root_text_is_the_type_guid` is what keeps the two -/// spellings one constant. +/// spellings one constant — and the same for each type below. const TOYOS_ROOT: gpt::partition_types::Type = gpt::partition_types::Type { guid: toyos_gpt::Guid::TOYOS_ROOT_TEXT, os: gpt::partition_types::OperatingSystem::None, @@ -480,6 +728,18 @@ const TOYOS_DATA: gpt::partition_types::Type = gpt::partition_types::Type { os: gpt::partition_types::OperatingSystem::None, }; +/// `94464329-…`, the slot table's partition type. +const TOYOS_SLOTS: gpt::partition_types::Type = gpt::partition_types::Type { + guid: toyos_gpt::Guid::TOYOS_SLOTS_TEXT, + os: gpt::partition_types::OperatingSystem::None, +}; + +/// `037719D7-…`, a slot's FAT volume. +const TOYOS_BOOT: gpt::partition_types::Type = gpt::partition_types::Type { + guid: toyos_gpt::Guid::TOYOS_BOOT_TEXT, + os: gpt::partition_types::OperatingSystem::None, +}; + /// Lay a table on the disk at `path`, already `len` bytes long, carrying one /// TOYOS-DATA partition, stamp the designation at its first block, and answer /// where it landed. The table and the stamp are all this writes, so a sparse @@ -631,20 +891,45 @@ impl toyos_gpt::Sectors for FileSectors<'_> { } } -fn create_gpt_disk( - esp_volume: Vec, - root_volume: &[u8], - log_volume: Vec, - esp_guid: uuid::Uuid, - log_guid: uuid::Uuid, -) -> Vec { +/// One partition of an image: what it is called in a refusal and in the table, +/// its type and unique GUID, its bytes, its length — at least its bytes', the +/// rest zero — and the reader that judges it before the image is published. +struct Part { + what: &'static str, + name: &'static str, + kind: gpt::partition_types::Type, + guid: uuid::Uuid, + bytes: Vec, + len: u64, + judge: Option, +} + +impl Part { + /// A partition exactly as long as its bytes. + fn full( + what: &'static str, + name: &'static str, + kind: gpt::partition_types::Type, + guid: uuid::Uuid, + bytes: Vec, + judge: Option, + ) -> Self { + let len = bytes.len() as u64; + Self { what, name, kind, guid, bytes, len, judge } + } +} + +fn create_gpt_disk(parts: &[Part]) -> Vec { // `add_partition` places each partition itself; this is the size the disk // has to be for it to have somewhere to put them — an aligned gap before - // the ESP, an aligned gap between each pair, and one after the last for the - // backup table. - let root_at = align_up(PARTITION_ALIGN + esp_volume.len(), PARTITION_ALIGN); - let log_at = align_up(root_at + root_volume.len(), PARTITION_ALIGN); - let total_size = round_up_sectors(log_at + log_volume.len() + PARTITION_ALIGN); + // the first, each one aligned after the last, and a gap after the last for + // the backup table. + let mut end = PARTITION_ALIGN; + for part in parts { + assert!(part.bytes.len() as u64 <= part.len, "the {} is {} bytes in a {}-byte partition", part.what, part.bytes.len(), part.len); + end = align_up(end, PARTITION_ALIGN) + part.len as usize; + } + let total_size = round_up_sectors(align_up(end, PARTITION_ALIGN) + PARTITION_ALIGN); assert_eq!(total_size % 512, 0, "image must be a whole number of 512-byte sectors to be flashable"); let mut disk = vec![0u8; total_size]; @@ -667,93 +952,50 @@ fn create_gpt_disk( .expect("failed to initialize partition table"); let align = Some((PARTITION_ALIGN / 512) as u64); - let esp_id = gdisk - .add_partition("EFI System", esp_volume.len() as u64, gpt::partition_types::EFI, 0, align) - .expect("failed to add ESP partition"); - // The one partition selected by *type*: which ROOT a boot mounts is decided - // by the UUID in its own superblock, against the `root=` on the ESP. - let root_id = gdisk - .add_partition("ToyOS root", root_volume.len() as u64, TOYOS_ROOT, 0, align) - .expect("failed to add the root partition"); - // Microsoft Basic Data, and that type is the whole reason this is a second - // partition at all: macOS never auto-mounts an EFI-typed partition and this - // host refuses even a manual non-root mount of one, so a log on the ESP is - // unreachable without the admin account. This type mounts in Finder, in - // Windows and in Linux on plug-in, with nothing configured. - let log_id = gdisk - .add_partition("ToyOS log", log_volume.len() as u64, gpt::partition_types::BASIC, 0, align) - .expect("failed to add the log partition"); - - // The GUID `add_partition` drew for each of these is discarded: the log's - // for the one already written to the ESP, and the ESP's own because a - // firmware boot entry names a partition by GUID and every name is drawn once. - let mut table = gdisk.partitions().clone(); - table.get_mut(&esp_id).expect("the ESP was just added").part_guid = esp_guid; - table - .get_mut(&log_id) - .expect("the log partition was just added") - .part_guid = log_guid; - gdisk - .update_partitions(table) - .expect("failed to stamp the ESP's and the log partition's unique GUIDs"); - - let start_of = |id: u32| { - gdisk - .partitions() - .get(&id) - .expect("a partition that was just added") - .bytes_start(gpt::disk::LogicalBlockSize::Lb512) - .expect("failed to get a partition's start") as usize - }; - let esp_start = start_of(esp_id); - let root_start = start_of(root_id); - let log_start = start_of(log_id); - - // `Superblock::check` refuses a superblock whose block count is not its - // device's, so a partition wider than the image it carries is one the - // kernel cannot mount. Checked here rather than left to a boot: this is the - // writer, and the failure there is a machine with no userland. - let root_bytes = gdisk - .partitions() - .get(&root_id) - .expect("the root partition was just added") - .bytes_len(gpt::disk::LogicalBlockSize::Lb512) - .expect("failed to get the root partition's length"); - assert_eq!( - root_bytes, - root_volume.len() as u64, - "the table gives ROOT {root_bytes} bytes for a {}-byte image", - root_volume.len() - ); - - let named = |id: u32| { - toyos_gpt::Guid( + let ids: Vec = parts + .iter() + .map(|part| { gdisk - .partitions() - .get(&id) - .expect("a partition that was just added") - .part_guid - .to_bytes_le(), - ) - }; - let esp_guid = named(esp_id); - let root_partition_guid = named(root_id); - let log_partition_guid = named(log_id); + .add_partition(part.name, part.len, part.kind.clone(), 0, align) + .unwrap_or_else(|e| panic!("failed to add the {}: {e}", part.what)) + }) + .collect(); + + // The GUID `add_partition` drew for each is discarded for the one this + // image already wrote down: the log's on the ESP, every slot partition's in + // the slot table, and the ESP's own because a firmware boot entry names a + // partition by GUID and every name is drawn once. + let mut table = gdisk.partitions().clone(); + for (part, id) in parts.iter().zip(&ids) { + table.get_mut(id).expect("a partition that was just added").part_guid = part.guid; + } + gdisk.update_partitions(table).expect("failed to stamp the partitions' unique GUIDs"); + + let placed: Vec<(&str, usize, usize)> = parts + .iter() + .zip(&ids) + .map(|(part, id)| { + let entry = gdisk.partitions().get(id).expect("a partition that was just added"); + let start = entry.bytes_start(gpt::disk::LogicalBlockSize::Lb512).expect("a partition's start") as usize; + let len = entry.bytes_len(gpt::disk::LogicalBlockSize::Lb512).expect("a partition's length"); + // A slot's ROOT is read for exactly its signed length, and a + // partition the table made any other length than asked is one + // whose contents nothing here placed. + assert_eq!(len, part.len, "the table gives the {} {len} bytes for a {}-byte partition", part.what, part.len); + (part.what, start, part.len as usize) + }) + .collect(); // The invariant [`PARTITION_ALIGN`] exists for, checked rather than - // assumed: the kernel mounts all three at once over one 4 KiB block - // device, and a device block belonging to two volumes would be cached - // twice. - let placed = [ - ("ESP", esp_start, esp_volume.len()), - ("root partition", root_start, root_volume.len()), - ("log partition", log_start, log_volume.len()), - ]; - for (what, start, len) in placed { + // assumed: the kernel mounts several at once over one 4 KiB block device, + // and a device block belonging to two volumes would be cached twice. + for (what, start, len) in &placed { assert_eq!(start % SECTOR, 0, "the {what} starts at byte {start}, off a {SECTOR}-byte block"); assert_eq!(len % SECTOR, 0, "the {what} is {len} bytes, not whole {SECTOR}-byte blocks"); } - for (before, after) in placed.iter().zip(&placed[1..]) { + let mut by_start = placed.clone(); + by_start.sort_by_key(|(_, start, _)| *start); + for (before, after) in by_start.iter().zip(&by_start[1..]) { assert!( before.1 + before.2 <= after.1, "the {} runs to {} and the {} starts at {}", @@ -770,19 +1012,15 @@ fn create_gpt_disk( let mut final_bytes = vec![0u8; total_size]; disk_device.read_exact(&mut final_bytes).expect("failed to read disk"); - final_bytes[esp_start..esp_start + esp_volume.len()].copy_from_slice(&esp_volume); - final_bytes[root_start..root_start + root_volume.len()].copy_from_slice(root_volume); - final_bytes[log_start..log_start + log_volume.len()].copy_from_slice(&log_volume); + for (part, (_, start, _)) in parts.iter().zip(&placed) { + final_bytes[*start..*start + part.bytes.len()].copy_from_slice(&part.bytes); + } - certify( - &final_bytes, - &[ - ("ESP", esp_guid, Volume::Fat32), - ("root partition", root_partition_guid, Volume::Root), - ("log partition", log_partition_guid, Volume::Fat32), - ], - ) - .unwrap_or_else(|refusal| panic!("{refusal}")); + let judged: Vec<(&str, toyos_gpt::Guid, Volume)> = parts + .iter() + .filter_map(|part| part.judge.map(|kind| (part.what, toyos_gpt::Guid(part.guid.to_bytes_le()), kind))) + .collect(); + certify(&final_bytes, &judged).unwrap_or_else(|refusal| panic!("{refusal}")); final_bytes } @@ -870,6 +1108,15 @@ mod tests { create_root_image(&[("bin/init".to_string(), b"init".to_vec())], &[], true) } + /// A fixed key: what these tests judge is the image, not whose it is. + fn key() -> crate::signing::Key { + crate::signing::Key::throwaway_from([0x42; 32]) + } + + fn signing(key: &crate::signing::Key) -> Signing<'_> { + Signing { key, version: 7 } + } + /// ROOT carries its contents and less than one alignment unit of free /// blocks: the loader reads every block of it and the kernel keeps them. #[test] @@ -882,15 +1129,19 @@ mod tests { assert!(sb.free_blocks < (PARTITION_ALIGN / 4096) as u64, "{} free blocks", sb.free_blocks); } - /// The two volumes this build writes break no rule of the format, and the + /// The volumes this build writes break no rule of the format, and the /// gate is silence rather than sameness: a suite that could only ask the /// guest to add no *new* complaint hides every complaint the writer left. /// Here rather than in the boot suite because it needs no guest, no QEMU and /// no kernel — a claim about the writer, failing in seconds. #[test] fn the_volumes_this_build_writes_break_no_format_rule() { + let key = key(); + let s = sections(b"kernel", &tiny_root(), "", signing(&key)); for (what, volume) in [ - ("ESP", create_esp_volume(b"kernel", b"bootloader", uuid::Uuid::new_v4(), "")), + ("ESP", create_esp_volume(b"bootloader", uuid::Uuid::new_v4())), + ("slot volume", create_slot_volume(Some(&s), FAT32_MIN_BYTES)), + ("empty slot volume", create_slot_volume(None, FAT32_MIN_BYTES)), ("log volume", create_log_volume()), ] { let complaints = toyos_fat32_check::check(&volume); @@ -902,17 +1153,24 @@ mod tests { } } + /// The one partition of `kind` on `disk`. + fn only(disk: &[u8], kind: toyos_gpt::Guid) -> toyos_gpt::Guid { + let mut out = [BLANK_PARTITION; 2]; + let scan = toyos_gpt::locate_type(&mut ImageSectors(disk), kind, &mut out) + .expect("the image has a partition table"); + assert_eq!(scan.matched, 1, "the image carries {} partitions of type {kind}", scan.matched); + out[0].unique_guid + } + /// Publishing a flash target runs every reader over the assembled image, and /// a damaged one is refused by name: each mutation is staged on an image /// [`create_gpt_disk`] just certified, so each refusal is its own. #[test] fn a_damaged_image_is_refused_by_the_reader_that_caught_it() { - let log_uuid = uuid::Uuid::new_v4(); - let esp = create_esp_volume(b"kernel", b"bootloader", log_uuid, ""); let root_image = tiny_root(); - let disk = - create_gpt_disk(esp, &root_image, create_log_volume(), uuid::Uuid::new_v4(), log_uuid); - let log = toyos_gpt::Guid(log_uuid.to_bytes_le()); + let key = key(); + let disk = create_boot_image(b"kernel", b"bootloader", &root_image, "", signing(&key), None); + let log = only(&disk, toyos_gpt::Guid::MICROSOFT_BASIC); let root = root_partition_guid_of(&disk); let parts = [("log partition", log, Volume::Fat32), ("root partition", root, Volume::Root)]; @@ -948,45 +1206,85 @@ mod tests { /// The unique GUID the table drew for the one TOYOS-ROOT-typed partition. fn root_partition_guid_of(disk: &[u8]) -> toyos_gpt::Guid { - let mut out = [toyos_gpt::Partition { - index: 0, - type_guid: toyos_gpt::Guid::ZERO, - unique_guid: toyos_gpt::Guid::ZERO, - first_lba: 0, - last_lba: 0, - }; 2]; - let scan = - toyos_gpt::locate_type(&mut ImageSectors(disk), toyos_gpt::Guid::TOYOS_ROOT, &mut out) - .expect("the image has a partition table"); - assert_eq!(scan.matched, 1, "a boot image carries one ROOT"); - out[0].unique_guid + only(disk, toyos_gpt::Guid::TOYOS_ROOT) } - /// And it is clean because it is right, not because it is empty: a - /// `populate` that wrote nothing at all would satisfy the gate above. - /// Exactly these and nothing else, because an unnamed file on the ESP is - /// one the volume pays for and nothing loads. - #[test] - fn the_esp_carries_what_the_bootloader_looks_for() { - let mut esp = create_esp_volume(b"kernel", b"bootloader", uuid::Uuid::new_v4(), ""); - let mut fs = Fat32::mount(VolumeIo(&mut esp)).expect("mount the ESP we just built"); + /// Every file on `volume`, sorted. + fn files_of(mut volume: Vec) -> Vec { + let mut fs = Fat32::mount(VolumeIo(&mut volume)).expect("mount a volume we just built"); let mut found: Vec = fs .walk("", 64) - .expect("walk the ESP") + .expect("walk the volume") .into_iter() .map(|(path, _)| path.trim_start_matches('/').to_string()) .filter(|path| !path.ends_with('/')) .collect(); found.sort(); + found + } + + /// And it is clean because it is right, not because it is empty: a + /// `populate` that wrote nothing at all would satisfy the gate above. + /// Exactly these and nothing else, because an unnamed file on a volume is + /// one the volume pays for and nothing loads. + #[test] + fn the_esp_and_a_slot_carry_what_the_bootloader_looks_for() { + assert_eq!( + files_of(create_esp_volume(b"bootloader", uuid::Uuid::new_v4())), + ["EFI/BOOT/BOOTx64.EFI", "toyos/log.guid"] + ); + let key = key(); + let s = sections(b"kernel", &tiny_root(), "", signing(&key)); assert_eq!( - found, - [ - "EFI/BOOT/BOOTx64.EFI", - "toyos/cmdline", - "toyos/kernel.elf", - "toyos/log.guid", - ] + files_of(create_slot_volume(Some(&s), FAT32_MIN_BYTES)), + [toyos_update::slots::CMDLINE_FILE, toyos_update::slots::SIGNED_FILE, toyos_update::slots::KERNEL_FILE] ); + assert!(files_of(create_slot_volume(None, FAT32_MIN_BYTES)).is_empty()); + } + + /// **The host's own reading of what the loader checks**: the marked slot's + /// signed header verifies under the key the image was signed with and names + /// the kernel, the boot parameter and ROOT the image carries, byte for + /// byte; the table names the slot's partitions; and an update image for the + /// same parts splits into the same header and sections. A second slot is + /// present, empty, and not marked. + #[test] + fn an_image_s_marked_slot_is_signed_over_exactly_what_it_carries() { + let key = key(); + let root = tiny_root(); + let dir = toyos_tmpdir::TempDir::new("image-slot"); + let path = dir.join("slotted.img"); + let disk = create_boot_image(b"\x7fELF kernel", b"bootloader", &root, "sched-fast-health", signing(&key), Some(SecondSlot { root_bytes: 8 << 20 })); + std::fs::write(&path, &disk).expect("write the image"); + let mut file = std::fs::File::open(&path).expect("open the image"); + let table = slot_table_of(&mut file).expect("the slot table"); + assert_eq!(table.marked, toyos_update::slots::Which::A); + let a = table.slots[0].expect("slot A"); + let b = table.slots[1].expect("slot B"); + assert_eq!(a.version, 7); + assert!(read_file_on(&mut file, b.boot, toyos_update::slots::SIGNED_FILE).is_err(), "an empty slot is unsigned"); + assert_eq!(partition_extent(&mut file, b.root).expect("slot B's ROOT").1, 8 << 20); + + let signed = read_file_on(&mut file, a.boot, toyos_update::slots::SIGNED_FILE).expect("the signed header"); + let signed: [u8; toyos_update::image::SIGNED_BYTES] = signed.try_into().expect("a signed header's length"); + let header = toyos_update::image::Header::parse(&signed).expect("a header"); + let bytes: [u8; toyos_update::image::HEADER_BYTES] = signed[..toyos_update::image::HEADER_BYTES].try_into().unwrap(); + toyos_update::sig::verify(&key.public(), &bytes, &toyos_update::image::signature_of(&signed)).expect("the signature"); + let kernel = read_file_on(&mut file, a.boot, toyos_update::slots::KERNEL_FILE).expect("the kernel"); + let cmdline = read_file_on(&mut file, a.boot, toyos_update::slots::CMDLINE_FILE).expect("the cmdline"); + let (at, _) = partition_extent(&mut file, a.root).expect("slot A's ROOT"); + let on_disk = &disk[at as usize..at as usize + header.root().len as usize]; + assert_eq!(kernel, b"\x7fELF kernel"); + assert_eq!(on_disk, &root[..]); + assert_eq!(header, toyos_update::image::Header::of(7, &kernel, &cmdline, on_disk)); + drop(file); + drop(dir); + + let update = update_image(b"\x7fELF kernel", &root, "sched-fast-health", signing(&key)); + let parts = toyos_update::image::Parts::split(&update).expect("an update image splits"); + assert_eq!(parts.header, header); + assert_eq!(toyos_update::image::Header::of(7, parts.kernel, parts.cmdline, parts.root), header); + assert_eq!(parts.signed, &signed); } /// An image says which actuators a guest booting it would arm, and the @@ -1003,7 +1301,7 @@ mod tests { let root_image = tiny_root(); let write = |name: &str, params: &str| { let path = dir.join(name); - std::fs::write(&path, create_boot_image(b"kernel", b"bootloader", &root_image, params)) + std::fs::write(&path, create_boot_image(b"kernel", b"bootloader", &root_image, params, signing(&key()), None)) .expect("write an image"); path }; @@ -1115,7 +1413,8 @@ mod tests { let symlinks = vec![("bin/ls".to_string(), "/system/bin/toybox".to_string())]; let root_image = create_root_image(&files, &symlinks, true); - let disk = create_boot_image(b"kernel", b"bootloader", &root_image, ""); + let key = key(); + let disk = create_boot_image(b"kernel", b"bootloader", &root_image, "", signing(&key), None); // Located by *type*, through the parser the kernel uses, at the offset // the table gives — never at the one the writer computed. @@ -1218,6 +1517,8 @@ mod tests { b"bootloader", &create_root_image(&files, &symlinks, true), "", + signing(&key()), + None, ); let mut out = [BLANK_PARTITION; 2]; diff --git a/src/lib.rs b/src/lib.rs index b75d4ed6639..aad25394387 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,6 +19,7 @@ pub mod hostws; pub mod icmp; pub mod identity; pub mod image; +pub mod signing; /// Which kernel containers may be hashed, and by whose keys; read by nothing /// but its own tests. #[cfg(test)] diff --git a/src/main.rs b/src/main.rs index ba74d901838..555a53c4dc4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -134,6 +134,29 @@ fn main() { toyos_build::forkcheck::dispatch_callers(&root, &args); return; } + // Writes one file outside the checkout and builds nothing. + if asked(&flags::SIGNING_KEY_NEW) { + match toyos_build::signing::mint_owner_key() { + Ok((path, fingerprint)) => println!("The owner's image-signing key is at {} ({fingerprint}).", path.display()), + Err(why) => { + eprintln!("Error: {why}"); + std::process::exit(1); + } + } + return; + } + // Before anything is built, so a missing key is refused before any lock + // and no image this run writes is signed by two keys. + let update_image = CARGO_RUN.value(&args, &flags::UPDATE_IMAGE).map(PathBuf::from); + if asked(&flags::OWNER_KEY) || update_image.is_some() { + match toyos_build::signing::use_owner() { + Ok(key) => println!("Signing with the owner's key {}.", key.fingerprint()), + Err(why) => { + eprintln!("Error: {why}"); + std::process::exit(1); + } + } + } check_prerequisites(&root); env::set_current_dir(&root).expect("Failed to cd to project root"); @@ -231,6 +254,11 @@ fn main() { // Toolchain included: `build` holds the build lock across both, so no other // agent's clean or bootstrap can land between the two. let plan = toyos_build::build::plan_for(&root, &boot, debug, &args); + if let Some(out) = update_image { + toyos_build::build::build_update(&root, &boot, rebuild_toolchain, &plan, &out); + println!("Update image: {} (ssh update < it)", out.display()); + return; + } let image = toyos_build::build::build(&root, boot, rebuild_toolchain, &plan); println!("Build finished."); println!("Boot image: {}", image.display()); diff --git a/src/metal.rs b/src/metal.rs index fe94ffcaa61..c145f4cced5 100644 --- a/src/metal.rs +++ b/src/metal.rs @@ -10,8 +10,10 @@ //! //! Two admission checks stand before any write: the image is a whole number of //! [`LBA`]-byte sectors carrying `EFI PART` in its final one, and the disk -//! answers with the identity this loop was given. A node that is not -//! `/dev/sd` cannot be written down, so the machine's NVMe is unnameable. +//! answers with the identity this loop was given. A node is a whole SCSI or +//! NVMe disk, `/dev/sd` or `/dev/nvmen`, and nothing else: the +//! T14 is a playground whose internal NVMe ToyOS may be installed on and a +//! broken driver may wipe, so it is nameable like the stick. use std::fmt; use std::io::{Read, Seek, SeekFrom, Write}; @@ -211,7 +213,8 @@ impl fmt::Display for Refusal { match self { Self::Node(got) => write!( f, - "{got:?} is not /dev/sd; this loop writes a USB disk and can name no other" + "{got:?} is not /dev/sd or /dev/nvmen; this loop writes a whole disk \ + and names no partition and no other device" ), Self::Word(got) => { write!(f, "{got:?} cannot be written into a sudoers command line unambiguously") @@ -346,37 +349,62 @@ impl fmt::Display for Refusal { } } -/// A SCSI disk node, `/dev/sd` and one lower-case letter. -/// -/// **An NVMe node cannot be written down.** The T14's Ubuntu install is on -/// `nvme0n1`, and this type is what keeps that true whatever a caller passes. +/// A whole disk's node: a SCSI disk, `/dev/sd` and one lower-case letter, or +/// an NVMe namespace, `/dev/nvmen` with one digit each. +/// A partition is spelled from one and never passed as one, so no job can be +/// aimed at a partition of a disk it was not given. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct Node(u8); +enum Node { + Scsi(u8), + Nvme { controller: u8, namespace: u8 }, +} impl Node { fn parse(path: &str) -> Result { - let letter = path.strip_prefix("/dev/sd").and_then(|rest| { + let scsi = path.strip_prefix("/dev/sd").and_then(|rest| { let [byte] = rest.as_bytes() else { return None }; - byte.is_ascii_lowercase().then_some(*byte) + byte.is_ascii_lowercase().then_some(Node::Scsi(*byte)) + }); + let nvme = path.strip_prefix("/dev/nvme").and_then(|rest| { + let [controller, b'n', namespace] = rest.as_bytes() else { return None }; + (controller.is_ascii_digit() && (b'1'..=b'9').contains(namespace)) + .then_some(Node::Nvme { controller: *controller, namespace: *namespace }) }); - letter.map(Node).ok_or_else(|| Refusal::Node(path.to_string())) + scsi.or(nvme).ok_or_else(|| Refusal::Node(path.to_string())) + } + + /// The disk's own name, as `/dev` and `/sys/class/block` spell it. + fn name(&self) -> String { + match *self { + Node::Scsi(letter) => format!("sd{}", letter as char), + Node::Nvme { controller, namespace } => format!("nvme{}n{}", controller as char, namespace as char), + } + } + + /// A partition's name: `sda3`, and `nvme0n1p3` where the disk's own name + /// ends in a digit. + fn part_name(&self, index: u32) -> String { + match self { + Node::Scsi(_) => format!("{}{index}", self.name()), + Node::Nvme { .. } => format!("{}p{index}", self.name()), + } } fn whole(&self) -> String { - format!("/dev/sd{}", self.0 as char) + format!("/dev/{}", self.name()) } fn partition(&self, index: u32) -> String { - format!("{}{index}", self.whole()) + format!("/dev/{}", self.part_name(index)) } /// Where `/sys` answers for the disk, and for one of its partitions. fn sysfs(&self) -> String { - format!("/sys/class/block/sd{}", self.0 as char) + format!("/sys/class/block/{}", self.name()) } fn part_sysfs(&self, index: u32) -> String { - format!("{}/sd{}{index}", self.sysfs(), self.0 as char) + format!("{}/{}", self.sysfs(), self.part_name(index)) } } @@ -545,7 +573,7 @@ impl Target { user: "t14".to_string(), host: "t14".to_string(), key: PathBuf::from(home).join(".ssh/id_ed25519_toyos_runner"), - node: Node(b'a'), + node: Node::Scsi(b'a'), esp_part: 1, log_part: 3, mount: "/home/t14/toyos-log".to_string(), @@ -574,9 +602,7 @@ impl Target { // The log partition read whole, as bytes rather than as files: what // `toyos-fat32-check` judges is the volume `logd` wrote, and a // `mount` has already had a driver's opinion about it. Read-only, and - // the partition is the only node named — `/dev/sd` plus an - // index is all `Node` can spell, so the internal NVMe is unnameable - // here as everywhere. + // the partition is the only node named, spelled from the disk's. Job::ReadLog => literal(&[DD, &read_log, "bs=4M"]), // `--create-only`, never `--create`: the latter "add[s] to // bootorder" (efibootmgr(8)) at the top, so the boot after the one @@ -694,9 +720,11 @@ struct Flashable { /// /// **The metal profile flashes test images**, so an actuator is admissible here /// where `build::flashable_params` refuses it for the owner's own flash path. -/// What is not admissible is an arm that would leave the machine changed: the -/// internal NVMe is never written, and firmware state a reboot does not undo is -/// a machine somebody has to open a lid to repair. +/// What is not admissible is an arm that would leave the machine changed where +/// a flash cannot put it back: firmware state a reboot does not undo is a +/// machine somebody has to open a lid to repair. The internal NVMe is not such +/// state — the T14 is a playground, and a disk a broken driver wipes is a disk +/// the next install writes again. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Flash { /// The T14 survives it: the boot ends and the machine is what it was. @@ -772,7 +800,7 @@ pub const FLASHABLE: &[(&str, Flash)] = &[ (LOAD_ARM, Flash::Ok), // It withholds transfers to the boot stick so the transport breaks on // purpose. Admissible because it writes nothing the stick did not already - // hold, reaches neither the internal NVMe nor firmware state, and the worst + // hold, reaches no firmware state, and the worst // it leaves is a stick a replug clears — the defect the arm exists to stage. ("usb-transport-break", Flash::Ok), ( @@ -2811,14 +2839,26 @@ mod tests { } #[test] - fn an_nvme_node_cannot_be_written_down() { - for name in ["/dev/nvme0n1", "/dev/nvme0n1p3", "/dev/sda1", "/dev/sdaa", "/dev/SDA", "sda"] - { + fn a_node_is_a_whole_scsi_or_nvme_disk_and_nothing_else() { + for name in [ + "/dev/nvme0n1p3", + "/dev/nvme0n0", + "/dev/nvme10n1", + "/dev/nvme0", + "/dev/sda1", + "/dev/sdaa", + "/dev/SDA", + "sda", + ] { assert_eq!(Node::parse(name), Err(Refusal::Node(name.to_string())), "{name}"); } assert_eq!(Node::parse("/dev/sda").unwrap().whole(), "/dev/sda"); assert_eq!(Node::parse("/dev/sdb").unwrap().partition(3), "/dev/sdb3"); assert_eq!(Node::parse("/dev/sda").unwrap().part_sysfs(1), "/sys/class/block/sda/sda1"); + let nvme = Node::parse("/dev/nvme0n1").unwrap(); + assert_eq!(nvme.whole(), "/dev/nvme0n1"); + assert_eq!(nvme.partition(3), "/dev/nvme0n1p3"); + assert_eq!(nvme.part_sysfs(1), "/sys/class/block/nvme0n1/nvme0n1p1"); } #[test] @@ -2884,8 +2924,7 @@ mod tests { assert!(rule.contains("/usr/sbin/wipefs --all /dev/sdb"), "{rule}"); assert_eq!(moved.argv(Job::Wipe, None).unwrap(), ["/usr/sbin/wipefs", "--all", "/dev/sdb"]); // The outside judge's read moves with the same field the mount does, - // and it is a *partition* — the one thing on this machine that can - // never be spelled `/dev/nvme0n1`. + // and it is a *partition*, spelled from the disk's own name. assert!(rule.contains("/usr/bin/dd if\\=/dev/sdb2 bs\\=4M"), "{rule}"); assert_eq!( moved.argv(Job::ReadLog, None).unwrap(), @@ -3098,7 +3137,7 @@ mod tests { assert!(Refusal::Log(bootlog::Unfit::NoBootRecord).about_the_boot()); assert!(Refusal::Log(bootlog::Unfit::Unfinished("x".to_string())).about_the_boot()); assert!(Refusal::Silent { what: "come back", secs: return_secs() }.about_the_boot()); - assert!(!Refusal::Node("/dev/nvme0n1".to_string()).about_the_boot()); + assert!(!Refusal::Node("/dev/nvme0n1p1".to_string()).about_the_boot()); assert!(!Refusal::Sudo("a password is required".to_string()).about_the_boot()); assert!(!Refusal::Landed { what: "dd".to_string(), want: 1, got: 2 }.about_the_boot()); assert!(!Refusal::NoHome.about_the_boot()); @@ -3164,7 +3203,9 @@ mod tests { assert_eq!(args.target.host, "box"); let nvme = ["--device".to_string(), "/dev/nvme0n1".to_string()]; - assert_eq!(Args::parse(&nvme), Err(Refusal::Node("/dev/nvme0n1".to_string()))); + assert_eq!(Args::parse(&nvme).unwrap().target.node.whole(), "/dev/nvme0n1"); + let part = ["--device".to_string(), "/dev/nvme0n1p2".to_string()]; + assert_eq!(Args::parse(&part), Err(Refusal::Node("/dev/nvme0n1p2".to_string()))); assert!(matches!(Args::parse(&["--image".to_string()]), Err(Refusal::Usage(_)))); assert!(matches!(Args::parse(&["--flash".to_string()]), Err(Refusal::Usage(_)))); assert!(matches!( diff --git a/src/metalswap.rs b/src/metalswap.rs index 425174d6958..9005806af6e 100644 --- a/src/metalswap.rs +++ b/src/metalswap.rs @@ -2,8 +2,8 @@ //! what the host heard of it: the ask over ssh, init's words over the record //! stream, and the machine answering ssh again afterwards. //! -//! One exchange for a QEMU guest and for the T14: the ask is -//! [`toyos_swap::SUBSYSTEM`] through the harness's russh client, and init's +//! One exchange for a QEMU guest and for the T14: the ask is an `exec` of +//! `/system/bin/swap` through the harness's russh client, and init's //! verdict is read off the stream in [`toyos_swap::heard`]'s form. **The stream //! is the one channel that outlives a swap of netd**: the ssh connection that //! asked goes with the netd that carried it, and this side's connection to @@ -33,13 +33,14 @@ const RETRY: Duration = Duration::from_secs(1); const REFUSED_WORD: Duration = Duration::from_secs(10); /// How long `logd`'s [`toyos_logstream::CARRIER_LEAVING`] has to reach this -/// side before the swap goes: sshd hangs up on init this long after it answered -/// whether or not its client has closed, and the old netd is stopped then. +/// side before the swap goes: `swap` hangs up on init this long after it +/// answered whether or not this side has closed its input, and the old netd is +/// stopped then. /// /// **Not widened to cover a slow `logd` round, because nothing on this side /// can.** The line reaches a reader in the round after the one `logd` was in /// when it was said, and that round's flush may outlast `logd`'s own -/// `LOG_WRITE_BUDGET` on a slow volume; the swap goes on sshd's clock whatever +/// `LOG_WRITE_BUDGET` on a slow volume; the swap goes on `swap`'s clock whatever /// this side waits for. A reader that dialled again without the line could be /// admitted by the netd being stopped and lose its connection with no word, so /// a missing line is a red verdict rather than a longer wait. @@ -68,10 +69,6 @@ pub struct Swapped { pub words: Vec<(Word, String)>, /// The service's own lines on the stream after the ask. pub said: Vec, - /// sshd's own refusal of the ask, read off the stream before init ever - /// heard of it — `Malformed`, `NoAuthority`, a connection that died - /// mid-upload, or any other refusal init is never asked about. - pub sshd_refused: Option, /// `echo` asked after init's verdict, through whatever carries the /// network then. pub again: Result, @@ -102,18 +99,8 @@ pub struct Ask<'a> { pub named: Option, } -/// What ended the wait for one ask's outcome. -#[derive(Debug, Clone, PartialEq, Eq)] -enum Settled { - /// init said its final word. - Outcome, - /// sshd refused the ask itself, before init ever heard of it. - SshdRefused(String), -} - -/// The manifest's names for the two programs whose lines judge a swap. +/// The manifest's name for the program whose lines judge a swap. const INIT: &str = "init"; -const SSHD: &str = "sshd"; /// `line`'s text where it is `tag`'s own line: judged by the head `logd` /// gives it (`toyos_logstream::program_line`), never by its words, so no other @@ -128,24 +115,13 @@ fn init_heard<'a>(line: &'a str, service: &str) -> Option<(Word, &'a str)> { } /// Whether the wait for `service`'s outcome is over, reading `lines[mark..]`: -/// init's final word about it, or sshd's own refused line — named for -/// `service`, or (before sshd has read the request's header) `?` — whichever -/// is first on the stream. `sshd`'s line settles the wait even where init is -/// never asked and so never speaks. -fn settled(lines: &[String], mark: usize, service: &str) -> Option { - for line in &lines[mark.min(lines.len())..] { - if init_heard(line, service).is_some_and(|(word, _)| word.is_final()) { - return Some(Settled::Outcome); - } - let Some(text) = said_by(line, SSHD) else { continue }; - let named = text.contains(&format!(": swap {service}: refused ")) || text.contains(": swap ?: refused "); - if named { - if let Some(why) = toyos_swap::sshd_refused(text) { - return Some(Settled::SshdRefused(why.to_string())); - } - } - } - None +/// init's final word about it. A refusal `swap` made before init heard of the +/// ask is its answer on the channel (`unasked …`), and is never waited for here. +fn settled(lines: &[String], mark: usize, service: &str) -> Option<()> { + lines[mark.min(lines.len())..] + .iter() + .any(|line| init_heard(line, service).is_some_and(|(word, _)| word.is_final())) + .then_some(()) } /// Ask the machine that opens `stream` to replace a service's binary, and @@ -156,8 +132,8 @@ fn settled(lines: &[String], mark: usize, service: &str) -> Option { /// /// `Err` is a boot that never opened the stream within `window`, a binary this /// host cannot read, or a swap of the stream's own carrier whose `logd` never -/// said it would turn readers away before sshd let the swap go without this -/// side. +/// said it would turn readers away before `swap` let the swap go without +/// this side. pub fn swap( stream: &Stream, ssh: &Ssh, @@ -209,8 +185,8 @@ pub fn swap( // stream at once; an accepted swap is followed to init's final word. let accepted = matches!(&answer, Ok(said) if said.starts_with("accepted ")); let until = match &answer { - Ok(said) if said.starts_with("refused") => Some(REFUSED_WORD), - Ok(said) if said != "no-subsystem" => Some(window), + Ok(said) if said.starts_with("refused ") => Some(REFUSED_WORD), + Ok(said) if said.starts_with("accepted ") || said.starts_with("unanswered ") => Some(window), _ => None, }; if accepted && service == toyos_logstream::CARRIER { @@ -223,7 +199,7 @@ pub fn swap( if leaving.is_none() { return Err(format!( "`logd` did not say it turns readers away within {} ms of the swap of {service} \ - being accepted, so sshd let the swap go without this side, and the connection \ + being accepted, so `swap` let the swap go without this side, and the connection \ it carries may end with no word", CARRIER_WORD.as_millis() )); @@ -238,12 +214,9 @@ pub fn swap( stream.redial(window, TURNED_AWAY_CEILING); } let mut outcome_ms = None; - let mut sshd_refused = None; if let Some(until) = until { - match stream.wait_until(until.saturating_sub(began.elapsed()), |lines| settled(lines, mark, service)) { - Some(Settled::Outcome) => outcome_ms = Some(began.elapsed().as_millis() as u64), - Some(Settled::SshdRefused(why)) => sshd_refused = Some(why), - None => {} + if stream.wait_until(until.saturating_sub(began.elapsed()), |lines| settled(lines, mark, service)).is_some() { + outcome_ms = Some(began.elapsed().as_millis() as u64); } } let again_at = match (ssh_at, stream.peer()) { @@ -276,7 +249,6 @@ pub fn swap( .filter(|line| said_by(line, service).is_some()) .cloned() .collect(), - sshd_refused, again, connections: (before, stream.connections()), turned_away: stream.turned_away() - away, @@ -311,7 +283,9 @@ pub fn judge(heard: &Swapped, expect: Expect) -> Result, Vec Expect::InService | Expect::Restored => "accepted ", }; match &heard.answer { - Ok(answer) if answer.starts_with(owed_prefix) => { + // A refusal `swap` made itself, before init heard of the ask, is a + // refusal too, and the one init never speaks about. + Ok(answer) if answer.starts_with(owed_prefix) || (expect == Expect::Refused && answer.starts_with("unasked ")) => { said.push(format!("the machine answered the swap of {service} {answer:?} in {} ms", heard.answer_ms)) } // The answer and the service carrying it can go together: what @@ -333,10 +307,6 @@ pub fn judge(heard: &Swapped, expect: Expect) -> Result, Vec (Expect::Restored, Some((Word::Restored, detail))) if word(Word::Failed) => { said.push(format!("init: the new {service} failed and {detail} is back")) } - (_, None) if heard.sshd_refused.is_some() => bad.push(format!( - "sshd refused the ask before init heard it: {}", - heard.sshd_refused.as_deref().unwrap_or("") - )), (Expect::Refused, _) if !word(Word::Stopping) => said.push(format!( "init stopped nothing: {:?}", heard.words.iter().map(|(w, d)| format!("{}: {d}", w.as_str())).collect::>() @@ -385,7 +355,6 @@ const ANSWER: &str = "swap_answer"; const ANSWER_FAILED: &str = "swap_answer_failed"; const WORD: &str = "swap_word"; const SAID: &str = "swap_said"; -const SSHD_REFUSED: &str = "swap_sshd_refused"; const AGAIN_STATUS: &str = "swap_again_status"; const AGAIN_STDOUT: &str = "swap_again_stdout"; const AGAIN_FAILED: &str = "swap_again_failed"; @@ -443,9 +412,6 @@ impl Swapped { for line in &self.said { out.push_str(&format!("{SAID} {}\n", quote(line))); } - if let Some(why) = &self.sshd_refused { - out.push_str(&format!("{SSHD_REFUSED} {}\n", quote(why))); - } match &self.again { Ok(exec) => { match exec.status { @@ -487,7 +453,6 @@ impl Swapped { words.push((word, detail.to_string())); } let said = all(SAID).into_iter().map(unquote).collect::>()?; - let sshd_refused = one(SSHD_REFUSED).map(unquote).transpose()?; let again = match (one(AGAIN_STATUS), one(AGAIN_STDOUT), one(AGAIN_FAILED)) { (Some(status), Some(stdout), None) => Ok(Exec { status: match status { @@ -524,7 +489,6 @@ impl Swapped { answer, words, said, - sshd_refused, again, connections: (before as usize, after as usize), turned_away, @@ -546,14 +510,14 @@ mod tests { format!("{}\n", toyos_logstream::ProgramLine { stamp, at_ns: 2_940_000_000, tag, text: text.as_bytes() }) } - /// init's words, sshd's refusal and `logd`'s carrier line count only under - /// their own programs' names: another program printing them settles nothing. + /// init's words and `logd`'s carrier line count only under their own + /// programs' names: another program printing them settles nothing. #[test] fn a_judge_reads_the_speaker_from_the_head_and_never_the_words() { let words = toyos_swap::said("netd", Word::InService, "/tmp/netd as pid 12"); - let refusal = toyos_swap::sshd_said("10.0.2.2:1", "netd", "refused x"); - assert_eq!(settled(&[line("init", &words)], 0, "netd"), Some(Settled::Outcome)); - assert_eq!(settled(&[line("test-runner", &words), line("netd", &refusal)], 0, "netd"), None); + assert_eq!(settled(&[line("init", &words)], 0, "netd"), Some(())); + assert_eq!(settled(&[line("test-runner", &words), line("netd", &words)], 0, "netd"), None); + assert_eq!(settled(&[line("init", &words)], 1, "netd"), None, "before mark"); assert_eq!(init_heard(&line("test-runner", &words), "netd"), None); let leaving = line("logd", toyos_logstream::CARRIER_LEAVING); assert_eq!(said_by(&leaving, toyos_logstream::LOGD), Some(toyos_logstream::CARRIER_LEAVING)); @@ -593,7 +557,6 @@ mod tests { answer: Ok(answer), words, said: vec![line("netd", "netd: DHCP: lease 10.0.2.15/24 from 10.0.2.2, \"x\"")], - sshd_refused: None, again: Ok(Exec { stdout: metaltalk::owed(), status: Some(0) }), connections: (1, 2), turned_away: 3, @@ -638,53 +601,6 @@ mod tests { assert!(judge(&spun, Expect::InService).is_err(), "a redial that reached its ceiling"); } - /// The recorded stream from a swap sshd refused before init ever heard of - /// it: an upload's connection died mid-transfer, and sshd's own line — - /// naming `?`, since it had not read the header — is the only word the - /// machine ever says about it. - #[test] - fn settled_ends_on_sshds_own_refusal_before_init_ever_heard_it() { - let lines: Vec = [ - "[2026-09-24 22:01:36 2.433 cpu0] pcidev: PCI 00:03.0 BAR 0 (0x20000 bytes) placed at 0xc0200000", - "[2026-09-24 22:01:36 2.939 cpu0] pcidev: slot 0 took its first message on vector 0x28", - ] - .into_iter() - .map(String::from) - .chain([ - line("sshd", "sshd: 10.0.2.2:60872: swap ?: refused the channel ended after 2080768 bytes, before the request was whole"), - line("sshd", "sshd: session error: DecryptionError"), - ]) - .collect(); - assert_eq!( - settled(&lines, 0, "netd"), - Some(Settled::SshdRefused( - "the channel ended after 2080768 bytes, before the request was whole".into() - )) - ); - let mut without = lines; - without.remove(2); - assert_eq!(settled(&without, 0, "netd"), None); - } - - /// sshd's refusal is only past `mark`, and only for this service or `?`. - #[test] - fn settled_ignores_a_refusal_before_mark_or_for_another_service() { - let refusal = line("sshd", &toyos_swap::sshd_said("10.0.2.2:1", "?", "refused x")); - assert_eq!(settled(&[refusal], 1, "netd"), None, "before mark"); - let other = line("sshd", &toyos_swap::sshd_said("10.0.2.2:1", "soundd", "refused x")); - assert_eq!(settled(&[other], 0, "netd"), None, "another service"); - } - - /// `Swapped` carries sshd's own refusal, and `render`/`parse` round-trip it. - #[test] - fn sshd_refused_reads_back_as_it_was_written() { - let mut swapped = heard(Expect::InService); - swapped.sshd_refused = - Some("the channel ended after 2080768 bytes, before the request was whole".into()); - let back = Swapped::parse(&swapped.render()).expect("it parses").expect("it is one"); - assert_eq!(back, swapped); - } - #[test] fn unquote_reads_what_debug_writes() { for text in ["plain", "a \"quote\"", "tab\tand\nnewline", "back\\slash", "é and \u{1b}"] { diff --git a/src/metaltalk.rs b/src/metaltalk.rs index d8b59afde47..7e903cfec69 100644 --- a/src/metaltalk.rs +++ b/src/metaltalk.rs @@ -769,6 +769,33 @@ impl Ssh { Ok(Exec { stdout, status }) } + /// Run `command` with `stdin`'s bytes on its input, and collect its stdout + /// and status: `ssh update < image`, for one. + pub fn pipe(&self, at: SocketAddr, command: &str, stdin: &Path, scratch: &Path) -> Result { + let (out, err) = (scratch.join("pipe.out"), scratch.join("pipe.err")); + let (host, port) = (at.ip().to_string(), at.port().to_string()); + let said = self.run(&[ + "pipe", + &host, + &port, + path_str(&self.key)?, + path_str(&out)?, + path_str(&err)?, + path_str(stdin)?, + command, + ])?; + let status = match said.lines().last().unwrap_or("") { + "no-exit-status" => None, + line => Some( + line.strip_prefix("exit ") + .and_then(|code| code.parse().ok()) + .ok_or_else(|| format!("the client answered {said:?}"))?, + ), + }; + let stdout = std::fs::read(&out).map_err(|e| format!("{}: {e}", out.display()))?; + Ok(Exec { stdout, status }) + } + /// Ask for `command` and answer the machine's reply to the request, without /// waiting for the program. pub fn fire(&self, at: SocketAddr, command: &str) -> Result { @@ -777,15 +804,16 @@ impl Ssh { Ok(said.lines().last().unwrap_or("").to_string()) } - /// Send `binary` as `service`'s replacement, naming `digest` for it, and - /// answer the machine's word: `accepted `, `refused `, - /// `unanswered ` or `no-subsystem`. + /// Send `binary` as `service`'s replacement, naming `digest` for it — an + /// `exec` of `/system/bin/swap` with the binary on its input — and answer + /// the machine's word: `accepted `, `refused ` (init's), + /// `unasked ` (`swap`'s own), or `unanswered `. /// - /// **An `accepted` is answered with the channel still open**: closing it is - /// the go — sshd hangs up on init once its client has closed it, and init - /// stops the old service then ([`toyos_swap::ANSWER_MS`] bounds the wait) — - /// and it is [`Answered::go`]'s, so a caller whose own connection that - /// service carries can see to it first. + /// **An `accepted` is answered with the program's input still open**: + /// closing it is the go — `swap` hangs up on init once its input closes, + /// and init stops the old service then ([`toyos_swap::ANSWER_MS`] bounds + /// the wait) — and it is [`Answered::go`]'s, so a caller whose own + /// connection that service carries can see to it first. pub fn swap( &self, at: SocketAddr, diff --git a/src/sdkversion.rs b/src/sdkversion.rs index 300403d7ed0..6ca0b6cf473 100644 --- a/src/sdkversion.rs +++ b/src/sdkversion.rs @@ -142,6 +142,26 @@ fn next_minor(version: &str) -> Option { Some(format!("{major}.{}.0", minor + 1)) } +/// The rule over a merge queue's group at `root`: its tip is one branch merged +/// onto the group's base — its first parent, which holds `main` and every +/// branch queued ahead of it — and the branch is judged against that base. +/// +/// **Not against `main`**: two branches that each take the same next version +/// both pass as pull requests, git merges their identical bumps without a +/// conflict, and the second ships a changed crate under the first's version. +/// Only the base the queue built it on holds the first's bump. +pub fn judge_queued(root: &Path) -> Result { + let line = git(root, &["rev-list", "--parents", "-n", "1", "HEAD"])?; + let parents = line.split_whitespace().count().saturating_sub(1); + if parents != 2 { + return Err(format!( + "[sdk] the group's tip has {parents} parent(s), and a group is one merge onto its base, \ + so there is no base to judge it against" + )); + } + judge(root, "HEAD^1") +} + /// The rule over the branch at `root` against `base`: the one-line verdict, or /// the refusal. pub fn judge(root: &Path, base: &str) -> Result { @@ -458,6 +478,52 @@ mod tests { assert!(verdict.contains("does not publish yet"), "{verdict}"); } + /// **Two branches that take one version**: each passes alone as a pull + /// request, git merges their identical bumps cleanly, and only the merge + /// queue's judge — the second branch against the base the queue built it + /// on — refuses the second. The same branch bumped once more passes there. + #[test] + fn two_branches_that_take_one_version_are_refused_in_the_queue() { + let (_dir, _origin, wt) = repo("sdk-queue-collision"); + commit(&wt, "toyos-abi/Cargo.toml", &manifest("toyos-abi", "0.1.0", &[]), "abi manifest"); + commit(&wt, "toyos-abi/src/lib.rs", "pub struct A;\n", "abi source"); + commit(&wt, "toyos-abi/src/b.rs", "pub struct B;\n", "abi source"); + publishing(&wt); + main_is_here(&wt); + + sh(&wt, &["switch", "-q", "-c", "first", "main"]); + commit(&wt, "toyos-abi/src/lib.rs", "pub struct A(pub u64);\n", "abi: widen A"); + commit(&wt, "toyos-abi/Cargo.toml", &manifest("toyos-abi", "0.2.0", &[]), "abi: 0.2.0"); + assert!(judge(&wt, "main").expect("the first alone").contains("toyos-abi 0.1.0 -> 0.2.0")); + + sh(&wt, &["switch", "-q", "-c", "second", "main"]); + commit(&wt, "toyos-abi/src/b.rs", "pub struct B(pub u64);\n", "abi: widen B"); + commit(&wt, "toyos-abi/Cargo.toml", &manifest("toyos-abi", "0.2.0", &[]), "abi: 0.2.0"); + assert!(judge(&wt, "main").expect("the second alone").contains("toyos-abi 0.1.0 -> 0.2.0")); + + // The first lands; the queue's group for the second is its merge onto that. + sh(&wt, &["switch", "-q", "main"]); + sh(&wt, &["merge", "-q", "--no-ff", "first", "-m", "the first lands"]); + sh(&wt, &["switch", "-q", "--detach", "main"]); + sh(&wt, &["merge", "-q", "--no-ff", "second", "-m", "the queue's group for the second"]); + let refusal = judge_queued(&wt).expect_err("the second ships its change under the first's version"); + assert!(refusal.contains("toyos-abi changed and its version did not"), "{refusal}"); + assert!(refusal.contains("still says 0.2.0, and the next one is 0.3.0"), "{refusal}"); + + // What the second owes: main merged in, and the next version. + sh(&wt, &["switch", "-q", "second"]); + sh(&wt, &["merge", "-q", "--no-edit", "main"]); + commit(&wt, "toyos-abi/Cargo.toml", &manifest("toyos-abi", "0.3.0", &[]), "abi: 0.3.0"); + sh(&wt, &["switch", "-q", "--detach", "main"]); + sh(&wt, &["merge", "-q", "--no-ff", "second", "-m", "the queue's group, again"]); + let verdict = judge_queued(&wt).expect("a bump past the first's"); + assert!(verdict.contains("toyos-abi 0.2.0 -> 0.3.0"), "{verdict}"); + + // A tip that is no merge has no base to be judged against. + sh(&wt, &["switch", "-q", "--detach", "first"]); + assert!(judge_queued(&wt).unwrap_err().contains("has 1 parent(s)")); + } + /// The other half, which a bump alone passes: the dependent's pin. #[test] fn a_bump_that_leaves_a_dependents_pin_behind_is_refused_by_name() { diff --git a/src/signing.rs b/src/signing.rs new file mode 100644 index 00000000000..4faf307b757 --- /dev/null +++ b/src/signing.rs @@ -0,0 +1,473 @@ +//! The key an image is signed with, and the one place a private key is held. +//! +//! **Two keys, chosen by what the image is for.** An image for a QEMU guest +//! of `cargo run` or `cargo test`, a CI run or a metal-loop stick is signed +//! with this checkout's throwaway key: minted once into [`THROWAWAY_FILE`] +//! under the checkout's `target/` and kept there, so an unchanged tree +//! rebuilds nothing that embeds it, and never committed, since `target/` is +//! not. Its loader keeps a floor per image +//! (`toyos_update::floor::Scope::Image`), so no image this key signs holds +//! another to a floor. An image to be installed on the owner's machine +//! (`--owner-key`, `--update-image`) is signed with the owner's key, read +//! from [`owner_key_path`], and a build that asks for it where there is none +//! is refused by name before anything is built; its loader keeps the +//! machine's floor (`Scope::Machine`). +//! +//! **The owner's key never reaches the machine and is never printed**: the +//! loader and `/system/bin/update` carry the public half ([`KEY_ENV`] at their +//! compile), the private half is read here and handed to nothing but the +//! signer, and only the public key's fingerprint is ever said. The file is an +//! OpenSSH Ed25519 private key without a passphrase, so `ssh-keygen -Y sign -n +//! toyos-image` over an image's header makes the same signature this does. + +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +use toyos_update::image::{Header, HEADER_BYTES, SIGNATURE_BYTES, SIGNED_BYTES}; + +/// The variable the loader and `/system/bin/update` take the public key from +/// at compile time: 64 lowercase hex digits. +pub const KEY_ENV: &str = "TOYOS_IMAGE_KEY"; + +/// The variable the loader takes its floor's scope from at compile time: +/// `toyos_update::floor::Scope`'s word for whose key it embeds. +pub const FLOOR_ENV: &str = "TOYOS_IMAGE_FLOOR"; + +/// This checkout's throwaway key, under its `target/`: the seed, in 64 hex +/// digits. +pub const THROWAWAY_FILE: &str = "target/image-signing-throwaway"; + +/// The variable naming the owner's private key, where it is not at +/// [`DEFAULT_OWNER_KEY`] under `$HOME`. +pub const OWNER_KEY_ENV: &str = "TOYOS_SIGNING_KEY"; + +/// Where the owner's key is by default: outside every checkout. +pub const DEFAULT_OWNER_KEY: &str = ".config/toyos/image-signing-key"; + +/// A key this process signs with. +pub struct Key { + seed: [u8; 32], + public: [u8; 32], + whose: Whose, +} + +/// Whose key it is, which is what every line about it says. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Whose { + /// This checkout's, from [`THROWAWAY_FILE`], or one a test minted. + Throwaway, + /// The owner's, read from this file. + Owner(PathBuf), +} + +impl Key { + fn from_seed(seed: [u8; 32], whose: Whose) -> Self { + Self { public: toyos_update::sig::public_of(&seed), seed, whose } + } + + /// The public key as [`KEY_ENV`] carries it. + pub fn public_hex(&self) -> String { + self.public.iter().map(|b| format!("{b:02x}")).collect() + } + + pub fn public(&self) -> [u8; 32] { + self.public + } + + pub fn whose(&self) -> &Whose { + &self.whose + } + + /// Whose floor a loader embedding this key keeps. + pub fn floor_scope(&self) -> toyos_update::floor::Scope { + match self.whose { + Whose::Owner(_) => toyos_update::floor::Scope::Machine, + Whose::Throwaway => toyos_update::floor::Scope::Image, + } + } + + /// `SHA256:` of the OpenSSH public key blob, as `ssh-keygen -l` + /// prints it: the one name for this key a line may carry. + pub fn fingerprint(&self) -> String { + let digest = toyos_update::sha256(&public_blob(&self.public)); + format!("SHA256:{}", base64_encode(&digest).trim_end_matches('=')) + } + + /// The header naming these sections, and its signature: a slot's signed + /// header. + pub fn sign(&self, header: &Header) -> [u8; SIGNED_BYTES] { + let bytes = header.encode(); + let signature: [u8; SIGNATURE_BYTES] = toyos_update::sig::sign(&self.seed, &bytes); + let mut out = [0u8; SIGNED_BYTES]; + out[..HEADER_BYTES].copy_from_slice(&bytes); + out[HEADER_BYTES..].copy_from_slice(&signature); + out + } + + /// A key from a seed the caller chose, for a test that needs a second, + /// wrong key or a fixed one. + pub fn throwaway_from(seed: [u8; 32]) -> Self { + Self::from_seed(seed, Whose::Throwaway) + } + + /// A fresh throwaway key. + pub fn mint() -> Self { + let mut seed = [0u8; 32]; + getrandom::fill(&mut seed).expect("the operating system's randomness"); + Self::throwaway_from(seed) + } +} + +static KEY: OnceLock = OnceLock::new(); + +/// This process's key: the owner's where [`use_owner`] was asked first, this +/// checkout's throwaway otherwise. +pub fn key() -> &'static Key { + KEY.get_or_init(|| { + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + checkout_throwaway(root).unwrap_or_else(|why| panic!("this checkout's throwaway image key: {why}")) + }) +} + +/// The throwaway key at [`THROWAWAY_FILE`] under `root`, minted there first +/// where there is none. Two processes minting at once agree: each writes its +/// own file and links it into place, and the one that loses reads the +/// winner's. +fn checkout_throwaway(root: &Path) -> Result { + use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; + let path = root.join(THROWAWAY_FILE); + if !path.exists() { + let dir = path.parent().expect("a file under target/"); + std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?; + let mine = dir.join(format!("image-signing-throwaway.{}", std::process::id())); + let key = Key::mint(); + let written = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&mine) + .and_then(|mut file| { + file.write_all(format!("{}\n", hex_of(&key.seed)).as_bytes())?; + file.sync_all() + }); + let linked = written.and_then(|()| match std::fs::hard_link(&mine, &path) { + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(()), + other => other, + }); + let _ = std::fs::remove_file(&mine); + linked.map_err(|e| format!("{}: {e}", path.display()))?; + } + let text = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?; + let seed = seed_of_hex(text.trim()) + .ok_or_else(|| format!("{} is not 64 hex digits; delete it and the next build mints another", path.display()))?; + Ok(Key::throwaway_from(seed)) +} + +fn hex_of(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +fn seed_of_hex(text: &str) -> Option<[u8; 32]> { + if text.len() != 64 || !text.is_ascii() { + return None; + } + let mut seed = [0u8; 32]; + for (i, byte) in seed.iter_mut().enumerate() { + *byte = u8::from_str_radix(&text[2 * i..2 * i + 2], 16).ok()?; + } + Some(seed) +} + +/// Sign everything this process builds with the owner's key, or say why not. +/// Asked before anything is built, so no image is signed by two keys. +pub fn use_owner() -> Result<&'static Key, String> { + let path = owner_key_path()?; + let owner = read_owner_key(&path)?; + if KEY.set(owner).is_err() { + return Err("this process already signs with another key".into()); + } + Ok(key()) +} + +/// Where the owner's key is: [`OWNER_KEY_ENV`], else [`DEFAULT_OWNER_KEY`] +/// under `$HOME`. +pub fn owner_key_path() -> Result { + if let Some(path) = std::env::var_os(OWNER_KEY_ENV) { + return Ok(PathBuf::from(path)); + } + let home = std::env::var_os("HOME").ok_or("no $HOME, and no TOYOS_SIGNING_KEY names the owner's key")?; + Ok(PathBuf::from(home).join(DEFAULT_OWNER_KEY)) +} + +/// The owner's key at `path`, refused by name where it is not one — or where +/// anyone but its owner may read it, as `ssh` refuses such a key. +fn read_owner_key(path: &Path) -> Result { + use std::os::unix::fs::PermissionsExt; + if let Ok(meta) = std::fs::metadata(path) { + let mode = meta.permissions().mode() & 0o777; + if mode & 0o077 != 0 { + return Err(format!( + "{} is mode {mode:04o}, which lets more than its owner read it, and a signing key is \ + refused unless it is 0600 or narrower: `chmod 600 {}`", + path.display(), + path.display() + )); + } + } + let text = std::fs::read_to_string(path).map_err(|e| { + format!( + "the owner's image-signing key is not at {} ({e}). Mint one with \ + `cargo run -- --signing-key-new` (it is written there and never into a checkout), \ + or point {OWNER_KEY_ENV} at an OpenSSH Ed25519 key without a passphrase", + path.display() + ) + })?; + let seed = openssh_seed(&text).map_err(|why| format!("{}: {why}", path.display()))?; + Ok(Key::from_seed(seed, Whose::Owner(path.to_path_buf()))) +} + +/// Mint the owner's key at [`owner_key_path`], refusing to replace one: a key +/// replaced is every installed machine refusing the next update. +pub fn mint_owner_key() -> Result<(PathBuf, String), String> { + let path = owner_key_path()?; + if path.exists() { + return Err(format!("{} already holds a key, and it is not replaced", path.display())); + } + let key = Key::mint(); + let dir = path.parent().ok_or_else(|| format!("{} has no directory", path.display()))?; + std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?; + let text = openssh_private(&key.seed, &key.public); + use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&path) + .map_err(|e| format!("{}: {e}", path.display()))?; + file.write_all(text.as_bytes()).map_err(|e| format!("{}: {e}", path.display()))?; + file.sync_all().map_err(|e| format!("{}: {e}", path.display()))?; + Ok((path, key.fingerprint())) +} + +/// `string ssh-ed25519 | string key`, the OpenSSH public key blob. +fn public_blob(public: &[u8; 32]) -> Vec { + let mut out = Vec::new(); + put_string(&mut out, b"ssh-ed25519"); + put_string(&mut out, public); + out +} + +fn put_string(out: &mut Vec, bytes: &[u8]) { + out.extend_from_slice(&(bytes.len() as u32).to_be_bytes()); + out.extend_from_slice(bytes); +} + +fn take_string<'a>(bytes: &mut &'a [u8]) -> Result<&'a [u8], String> { + let len = bytes.get(..4).ok_or("the key ends inside a length")?; + let len = u32::from_be_bytes(len.try_into().expect("four bytes")) as usize; + let s = bytes.get(4..4 + len).ok_or("the key ends inside a field")?; + *bytes = &bytes[4 + len..]; + Ok(s) +} + +const BEGIN: &str = "-----BEGIN OPENSSH PRIVATE KEY-----"; +const END: &str = "-----END OPENSSH PRIVATE KEY-----"; +const MAGIC: &[u8] = b"openssh-key-v1\0"; + +/// The Ed25519 seed an unencrypted `openssh-key-v1` file holds +/// (OpenSSH's `PROTOCOL.key`). +fn openssh_seed(text: &str) -> Result<[u8; 32], String> { + let body = text + .trim() + .strip_prefix(BEGIN) + .and_then(|t| t.strip_suffix(END)) + .ok_or("not an OpenSSH private key")?; + let blob = base64_decode(body)?; + let mut rest = blob.strip_prefix(MAGIC).ok_or("not an openssh-key-v1 key")?; + if take_string(&mut rest)? != b"none" || take_string(&mut rest)? != b"none" { + return Err("the key is encrypted, and a build cannot ask for its passphrase".into()); + } + take_string(&mut rest)?; + let count = rest.get(..4).ok_or("no key count")?; + if u32::from_be_bytes(count.try_into().expect("four bytes")) != 1 { + return Err("the file holds more than one key".into()); + } + rest = &rest[4..]; + let mut public = take_string(&mut rest)?; + let mut private = take_string(&mut rest)?; + if take_string(&mut public)? != b"ssh-ed25519" { + return Err("the key is not Ed25519".into()); + } + let public: [u8; 32] = take_string(&mut public)?.try_into().map_err(|_| "a public key that is not 32 bytes")?; + let checks = private.get(..8).ok_or("no check words")?; + if checks[..4] != checks[4..] { + return Err("the check words disagree, which is a key that was not decrypted".into()); + } + private = &private[8..]; + if take_string(&mut private)? != b"ssh-ed25519" { + return Err("the private half is not Ed25519".into()); + } + if take_string(&mut private)? != public { + return Err("the private half names another public key".into()); + } + let pair = take_string(&mut private)?; + let seed: [u8; 32] = pair.get(..32).and_then(|s| s.try_into().ok()).ok_or("a private key that is not 64 bytes")?; + if toyos_update::sig::public_of(&seed) != public { + return Err("the seed does not make the public key the file names".into()); + } + Ok(seed) +} + +/// An unencrypted `openssh-key-v1` file for `seed`, which OpenSSH reads. +fn openssh_private(seed: &[u8; 32], public: &[u8; 32]) -> String { + let mut blob = MAGIC.to_vec(); + put_string(&mut blob, b"none"); + put_string(&mut blob, b"none"); + put_string(&mut blob, b""); + blob.extend_from_slice(&1u32.to_be_bytes()); + put_string(&mut blob, &public_blob(public)); + let mut private = Vec::new(); + let mut check = [0u8; 4]; + getrandom::fill(&mut check).expect("the operating system's randomness"); + private.extend_from_slice(&check); + private.extend_from_slice(&check); + put_string(&mut private, b"ssh-ed25519"); + put_string(&mut private, public); + let mut pair = seed.to_vec(); + pair.extend_from_slice(public); + put_string(&mut private, &pair); + put_string(&mut private, b"toyos-image"); + let mut pad = 1u8; + while !private.len().is_multiple_of(8) { + private.push(pad); + pad += 1; + } + put_string(&mut blob, &private); + let encoded = base64_encode(&blob); + let mut text = format!("{BEGIN}\n"); + for line in encoded.as_bytes().chunks(70) { + text.push_str(std::str::from_utf8(line).expect("base64 is ASCII")); + text.push('\n'); + } + text.push_str(END); + text.push('\n'); + text +} + +const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +fn base64_encode(bytes: &[u8]) -> String { + let mut out = String::new(); + for chunk in bytes.chunks(3) { + let n = chunk.iter().enumerate().fold(0u32, |acc, (i, &b)| acc | u32::from(b) << (16 - 8 * i)); + for i in 0..4 { + if i <= chunk.len() { + out.push(ALPHABET[(n >> (18 - 6 * i) & 63) as usize] as char); + } else { + out.push('='); + } + } + } + out +} + +fn base64_decode(text: &str) -> Result, String> { + let digits: Vec = text.bytes().filter(|c| !c.is_ascii_whitespace() && *c != b'=').collect(); + let mut out = Vec::new(); + for chunk in digits.chunks(4) { + if chunk.len() == 1 { + return Err("base64 that ends one digit into a group".into()); + } + let mut n = 0u32; + for (i, c) in chunk.iter().enumerate() { + let v = ALPHABET.iter().position(|a| a == c).ok_or_else(|| format!("{c:#x} is not base64"))?; + n |= (v as u32) << (18 - 6 * i); + } + out.extend_from_slice(&n.to_be_bytes()[1..chunk.len()]); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A key this module writes is one it reads back, and one it signs with + /// verifies under the verifier the loader calls. + #[test] + fn an_owner_key_reads_back_and_signs_what_the_loader_verifies() { + let key = Key::mint(); + let text = openssh_private(&key.seed, &key.public); + assert_eq!(openssh_seed(&text), Ok(key.seed)); + let header = Header::of(3, b"k", b"c", &[0; 4096]); + let signed = key.sign(&header); + let sig: [u8; 64] = signed[HEADER_BYTES..].try_into().unwrap(); + let bytes: [u8; HEADER_BYTES] = signed[..HEADER_BYTES].try_into().unwrap(); + assert_eq!(toyos_update::sig::verify(&key.public, &bytes, &sig), Ok(())); + assert!(Key::mint().public != key.public, "two mints made one key"); + } + + /// **A checkout's throwaway key is one key for the checkout**: minted + /// `0600` once, read back the same by every later process, so nothing that + /// embeds it rebuilds; and a bent file is refused, never replaced. + #[test] + fn a_checkouts_throwaway_key_is_minted_once_and_read_back() { + use std::os::unix::fs::PermissionsExt; + let dir = toyos_tmpdir::TempDir::new("throwaway-key"); + let first = checkout_throwaway(dir.path()).expect("minted"); + let again = checkout_throwaway(dir.path()).expect("read back"); + assert_eq!(first.public, again.public); + assert_eq!(first.floor_scope(), toyos_update::floor::Scope::Image); + let file = dir.path().join(THROWAWAY_FILE); + assert_eq!(std::fs::metadata(&file).unwrap().permissions().mode() & 0o777, 0o600); + std::fs::write(&file, "not a seed\n").unwrap(); + assert!(checkout_throwaway(dir.path()).err().unwrap().contains("is not 64 hex digits")); + } + + /// An owner's key anyone else may read is refused by its mode, before its + /// contents are read; the same key `0600` reads. + #[test] + fn an_owner_key_wider_than_0600_is_refused() { + use std::os::unix::fs::PermissionsExt; + let dir = toyos_tmpdir::TempDir::new("owner-key-mode"); + let path = dir.path().join("key"); + let key = Key::mint(); + std::fs::write(&path, openssh_private(&key.seed, &key.public)).unwrap(); + for mode in [0o644, 0o640, 0o604] { + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)).unwrap(); + let refusal = read_owner_key(&path).err().expect("a key others may read"); + assert!(refusal.contains(&format!("is mode {mode:04o}")), "{refusal}"); + } + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + let owner = read_owner_key(&path).expect("a 0600 key reads"); + assert_eq!((owner.public, owner.floor_scope()), (key.public, toyos_update::floor::Scope::Machine)); + } + + /// Everything that is not an unencrypted Ed25519 `openssh-key-v1` key is + /// refused, each by the word for it; the fingerprint is the form + /// `ssh-keygen -l` prints. + #[test] + fn a_key_that_is_not_one_is_refused_by_name() { + let key = Key::throwaway_from([7; 32]); + assert!(openssh_seed("not a key").unwrap_err().contains("not an OpenSSH private key")); + let armour = |blob: &[u8]| format!("{BEGIN}\n{}\n{END}\n", base64_encode(blob)); + let mut encrypted = MAGIC.to_vec(); + put_string(&mut encrypted, b"aes256-ctr"); + put_string(&mut encrypted, b"bcrypt"); + assert!(openssh_seed(&armour(&encrypted)).unwrap_err().contains("encrypted")); + let text = openssh_private(&key.seed, &key.public); + let body: String = text.lines().filter(|l| !l.starts_with("-----")).collect(); + let mut blob = base64_decode(&body).unwrap(); + // The seed's last byte, inside the private half: a seed that does not + // make the public key the file names. + let at = blob.windows(32).position(|w| w == key.seed).expect("the seed is in the file") + 31; + blob[at] ^= 1; + assert!(openssh_seed(&armour(&blob)).unwrap_err().contains("does not make the public key")); + assert!(key.fingerprint().starts_with("SHA256:")); + assert_eq!(base64_decode(&base64_encode(b"any carnal pleas")).unwrap(), b"any carnal pleas"); + assert_eq!(base64_encode(b"Man"), "TWFu"); + assert_eq!(base64_encode(b"Ma"), "TWE="); + } +} diff --git a/system.toml b/system.toml index 592a145eec5..96d6cbe28cc 100644 --- a/system.toml +++ b/system.toml @@ -79,11 +79,24 @@ service = true serves = ["netd"] devices = ["pci:1af4:1041"] -# `swap` is the authority to replace a running service's binary; the build -# refuses it to any other program (`src/build.rs`). +# `launcher` is how a login's program is started holding its own row: `update` +# and `swap` below are two such programs. [programs.sshd] service = true -receives = ["netd", "launcher", "swap"] +receives = ["netd", "launcher"] + +# `swap` is the authority to replace a running service's binary; the build +# refuses it to any other program (`src/build.rs`). `ssh swap netd +# < netd` runs it for a login. +[programs.swap] +receives = ["swap"] + +# The idle slot: the slot table and the idle slot's two partitions, which init +# claims against the ROOT the kernel holds. The build refuses it to any other +# program; the owner's signature on the image is the authority to install it. +# `ssh update < image` runs it for a login. +[programs.update] +slots = true # A name's addresses, asked of netd's resolver: `netd` is the whole of what it # holds. diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 2169cbf2713..0c64deee567 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -67,6 +67,7 @@ pub mod storage; pub mod swap; #[allow(dead_code)] pub mod toybox; +pub mod update; #[allow(dead_code)] pub mod usb; #[allow(dead_code)] diff --git a/tests/common/qemu.rs b/tests/common/qemu.rs index 759d00c02db..cfa4e181a02 100644 --- a/tests/common/qemu.rs +++ b/tests/common/qemu.rs @@ -2294,6 +2294,12 @@ pub struct BootOptions { /// that the boot *after* a reset is this loader again, reading what the boot /// before it left — and a guest with it set runs until the harness kills it. pub takes_the_reset: bool, + /// Keep the firmware's variables in this file, writable, instead of the + /// shared read-only template: a copy the test made, so what one boot's + /// loader writes — the anti-rollback floor, `BootNext` — is what the next + /// boot of the same machine reads. `None` is every other boot, whose + /// variables live in firmware memory and die with the guest. + pub firmware_vars: Option, /// The console line that means the boot reached the state under test. /// Anything other than [`DEFAULT_READY`] also declares that a panic is the /// expected outcome rather than a boot failure -- the early-panic screen @@ -2450,6 +2456,7 @@ impl Default for BootOptions { i8042: true, mute: false, takes_the_reset: false, + firmware_vars: None, ready_marker: DEFAULT_READY, nvme_image: None, boot_image: None, @@ -3958,6 +3965,52 @@ impl QmpResets { } } +/// A machine that takes its own resets, held at the next one: the guest's +/// reset pauses it with its memory — the black box — as the reset left it, so +/// a test can change what the next pass reads off the disk after the kernel's +/// last write and before the loader's first read, and then let it go. +pub struct QmpHold(Qmp); + +impl QmpHold { + /// The guest's next reset pauses the machine instead. + pub fn arm(socket: &Path) -> Self { + let mut qmp = Qmp::connect(socket); + qmp.execute("{\"execute\":\"set-action\",\"arguments\":{\"reboot\":\"shutdown\",\"shutdown\":\"pause\"}}"); + Self(qmp) + } + + /// Wait up to `budget` for the machine to stop at its reset. + pub fn held(&mut self, budget: Duration) -> Result<(), String> { + use std::io::Read; + let qmp = &mut self.0; + qmp.stream.set_read_timeout(Some(budget)).map_err(|e| format!("qmp: the hold's budget: {e}"))?; + let began = Instant::now(); + loop { + if qmp.pending.windows(6).any(|w| w == b"\"STOP\"") { + return Ok(()); + } + let mut buf = [0u8; 4096]; + match qmp.stream.read(&mut buf) { + Ok(n) if n > 0 && began.elapsed() < budget => qmp.pending.extend_from_slice(&buf[..n]), + _ => { + return Err(format!( + "the machine did not stop at a reset within {} s: {}", + budget.as_secs(), + String::from_utf8_lossy(&qmp.pending) + )) + } + } + } + } + + /// Take the held reset and run on, taking every later reset as before. + pub fn release(mut self) { + self.0.execute("{\"execute\":\"set-action\",\"arguments\":{\"reboot\":\"reset\",\"shutdown\":\"poweroff\"}}"); + self.0.execute("{\"execute\":\"system_reset\"}"); + self.0.execute("{\"execute\":\"cont\"}"); + } +} + /// `RESET` events the *guest* caused, scanned rather than parsed like /// [`shutdown_reason`]. QEMU raises one for its own power-on reset too, which /// carries `"guest": false` and is not a claim about anything the guest did. @@ -4307,10 +4360,13 @@ fn qemu_command( ovmf_dir.join("OVMF_CODE-pure-efi.fd").display() )) .arg("-drive") - .arg(format!( - "if=pflash,format=raw,unit=1,file={},readonly=on", - ovmf_dir.join("OVMF_VARS-pure-efi.fd").display() - )) + .arg(match &options.firmware_vars { + Some(vars) => format!("if=pflash,format=raw,unit=1,file={},readonly=off", vars.display()), + None => format!( + "if=pflash,format=raw,unit=1,file={},readonly=on", + ovmf_dir.join("OVMF_VARS-pure-efi.fd").display() + ), + }) .arg("-drive") .arg(format!( "if=none,id=stick,{}{}", diff --git a/tests/common/ssh.rs b/tests/common/ssh.rs index c500359e4fe..83e88fc9601 100644 --- a/tests/common/ssh.rs +++ b/tests/common/ssh.rs @@ -109,6 +109,21 @@ pub fn ssh_exec( collected(&said, &out, &err) } +/// Run `command` with the file `stdin` on its input and nothing asked +/// before it: `ssh update < image`, as a test asks it. +pub fn ssh_pipe(host: &str, port: u16, identity: &Identity, command: &str, stdin: &Path) -> Result { + let (out, err, port) = capture(identity, port)?; + let said = client(&["pipe", host, &port, str(&identity.private), str(&out), str(&err), str(stdin), command])?; + collected(&said, &out, &err) +} + +/// Ask for `command` and answer the guest's reply to the request, without +/// waiting for the program: `reboot`, whose status no client can collect. +pub fn ssh_fire(host: &str, port: u16, identity: &Identity, command: &str) -> Result { + let said = client(&["fire", host, &port.to_string(), str(&identity.private), command])?; + Ok(said.lines().last().unwrap_or("").to_string()) +} + /// Run `command` with `stdin` on its input, after asking the guest to set an /// environment variable. `Ok`'s second half is what it answered that request. pub fn ssh_feed( diff --git a/tests/common/swap.rs b/tests/common/swap.rs index 3bb552d1583..f57defc2d0b 100644 --- a/tests/common/swap.rs +++ b/tests/common/swap.rs @@ -1,7 +1,7 @@ //! A running service's binary replaced with no reboot, rehearsed in QEMU: netd -//! swapped for its own rebuild while `logd` streams to this host and sshd -//! carries the ask — and the three ways a swap must leave the old service -//! running. +//! swapped for its own rebuild while `logd` streams to this host and +//! `/system/bin/swap`, run over ssh, carries the ask — and the three ways a swap +//! must leave the old service running. //! //! **The machine's own `/log` is the oracle**, read off the image behind the //! guest's back once `reboot` over ssh has ended the boot: one `Boot: @@ -235,10 +235,11 @@ pub fn lan_swap( netd_in_service("lan-swap", super::lan::TALK_BENCH) } -/// **Two asks that must change nothing**: the right binary under the wrong -/// digest, and the right binary under the right digest from a key the image -/// does not authorize. Each leaves netd as it was — no `stopping` word, the -/// machine answering over the same netd — and `/log` shows netd spawned once. +/// **Asks that must change nothing**: the right binary under the wrong digest, +/// the right binary under the right digest from a key the image does not +/// authorize, and half the binary — under its whole length, and with none. +/// Each leaves netd as it was — no `stopping` word, the machine answering over +/// the same netd — and `/log` shows netd spawned once. pub fn swap_refusals( _test_config: &Path, _c_bins: &[(String, Vec)], @@ -267,6 +268,27 @@ pub fn swap_refusals( } other => return Err(format!("a stranger's swap was answered {other:?}")), } + + // **An input that ends early is no binary**: half of netd under its whole + // length and digest, and half of netd with no length at all — the form a + // cut connection could not be told apart in, which is gone. Each is + // refused before init hears of it. + let whole = std::fs::read(&binary).map_err(|e| e.to_string())?; + let cut = rig.staged.scratch.join("netd.cut"); + std::fs::write(&cut, &whole[..whole.len() / 2]).map_err(|e| e.to_string())?; + let hex: String = toyos_swap::digest(&whole).iter().map(|b| format!("{b:02x}")).collect(); + let port = rig.forward.port(); + for (command, owed) in [ + (format!("swap netd {hex} {}", whole.len()), format!("the input ended before the {} bytes it promised", whole.len())), + ("swap netd".to_string(), "usage: swap ".to_string()), + ] { + let exec = super::ssh::ssh_pipe(super::ssh::HOST, port, &rig.staged.identity, &command, &cut)?; + if exec.status != Some(1) || !exec.stdout_text().starts_with("unasked ") || !exec.stdout_text().contains(&owed) { + let said = exec.stdout_text(); + return Err(rig.fail(format!("`{command}` with half of netd ended {:?} saying {said:?}, where {owed:?} is owed", exec.status))); + } + eprintln!(" [swap] `{command}` with half of netd: {owed}"); + } let (file, streamed, staged) = rig.finish(None)?; let stopped: Vec<&String> = streamed.iter().chain(&file).filter(|l| toyos_swap::heard(l, "netd").is_some_and(|(w, _)| w == Word::Stopping)).collect(); diff --git a/tests/common/update.rs b/tests/common/update.rs new file mode 100644 index 00000000000..15b8bc03f56 --- /dev/null +++ b/tests/common/update.rs @@ -0,0 +1,752 @@ +//! The machine updates itself, rehearsed in QEMU: an image goes over ssh into +//! `update`'s standard input, is written to the slot the machine is not +//! running, and boots after a reboot; a slot the loader must refuse, and one +//! whose kernel dies, each leave the machine on the other slot. +//! +//! **The oracles are the loader's and the kernel's own lines**, read off the +//! 16550 the loader speaks on and the console the kernel does: which slot the table marked, each refusal by its name, the slot +//! the kernel says it came from, and the kernel's length the loader loaded — +//! against the sections this host built and signed, which it holds. A dead +//! boot's report is read back out of `/log` too, which is where the owner +//! finds it on a machine with no console. +//! +//! One machine per test, and it keeps its firmware variables in a copy of +//! its own (`BootOptions::firmware_vars`), because the anti-rollback floor a +//! proven boot raises is what the next boot of the same machine is held to. +//! What the running system can write and the loader must not trust — the +//! slots' record, the slot table — the host writes into the image between or +//! beneath boots, and the variable store it reads and plants in by EDK2's own +//! layout ([`vars`]). + +use std::path::{Path, PathBuf}; +use std::time::Instant; + +use toyos_build::bootlog; +use toyos_build::build::{self, Plan}; +use toyos_build::image::{self, SecondSlot, Signing}; +use toyos_build::signing::{self, Key}; +use toyos_update::floor::{self as floors, Scope}; +use toyos_update::record::{Booted, Record}; +use toyos_update::slots::Which; + +use super::qemu::{self, BootOptions, QemuInstance, Staged, DEFAULT_READY}; +use super::ssh::{self, Identity, HOST}; + +/// The boot config every image here is built from. +const CONFIG: &str = "tests/updatecase"; + +/// The versions the three images carry: the machine's own, the update, and an +/// image older than the floor the first proves. +const BASE: u64 = 100; +const NEXT: u64 = 200; +const OLDER: u64 = 50; + +/// The kernel record naming the slot a boot came from (`kernel/src/params.rs`). +const SLOT_RECORD: &str = "boot: slot"; + +/// What the loader says of a slot whose every byte its signature vouched for. +const VERIFIED: &str = "kernel, cmdline and ROOT are the bytes the signed header names"; + +/// A version no image here carries, which a forged record names. +const FORGED: u64 = 1 << 63; + +/// The loader's refusal of a stored floor it did not write +/// (`bootloader/src/floor.rs`), which boots nothing. +const FLOOR_REFUSED: &str = "it is refused rather than read as no floor"; + +/// The loader's slots' record, on the log partition beside `loader.log`. +const RECORD_FILE: &str = "attempts"; + +/// One machine: its disk, its firmware variables, the key the host logs in +/// with, and where the host reaches its sshd. +struct Rig { + scratch: PathBuf, + image: PathBuf, + vars: PathBuf, + identity: Identity, + port: u16, + /// The base image's parts, for what the loader is held to. + base_kernel: usize, +} + +/// The plan for this config's image: `features` is the kernel build, `params` +/// the actuators its slot arms, `version` its signed header's. +fn plan(features: &[&str], params: &[&str], version: u64, second: Option) -> Plan { + let mut plan = Plan::new(&super::compile::repo_root().join(CONFIG).join("system.toml"), features, params); + plan.version = version; + plan.second = second; + plan +} + +/// What every image here carries on ROOT beside the config's own: the key the +/// host logs in with. +fn staged(identity: &Identity) -> Vec<(String, Vec)> { + vec![(ssh::KEYS_ON_ROOT.to_string(), identity.authorized_line().into_bytes())] +} + +impl Rig { + /// The machine's disk: slot A holding this config's image at [`BASE`] on + /// the shipping kernel, marked, and an empty slot B with room for the same + /// ROOT again. + fn stage(name: &str) -> Result { + let scratch = super::lane::dir().join(name); + std::fs::create_dir_all(&scratch).map_err(|e| format!("{}: {e}", scratch.display()))?; + let identity = Identity::mint(&format!("{name}-key"))?; + let root = super::compile::repo_root(); + let files = staged(&identity); + let base = plan(&[], &[], BASE, None); + let parts = build::build_test_parts(&root, &base, true, &files); + let room = SecondSlot { root_bytes: 2 * parts.root.len() as u64 }; + let disk = image::create_boot_image( + &parts.kernel, + &parts.bootloader, + &parts.root, + "", + Signing { key: signing::key(), version: BASE }, + Some(room), + ); + let image = scratch.join("machine.img"); + std::fs::write(&image, disk).map_err(|e| format!("write {}: {e}", image.display()))?; + let vars = scratch.join("OVMF_VARS.fd"); + std::fs::copy(root.join("ovmf/OVMF_VARS-pure-efi.fd"), &vars) + .map_err(|e| format!("copy the firmware's variable store: {e}"))?; + Ok(Self { scratch, image, vars, identity, port: qemu::free_host_port(), base_kernel: parts.kernel.len() }) + } + + /// An update for this machine, written to a file: `features` and `params` + /// as [`plan`] takes them, signed by `key` at `version`. + fn update(&self, name: &str, features: &[&str], params: &[&str], version: u64, key: &Key) -> Result<(PathBuf, usize), String> { + let root = super::compile::repo_root(); + let parts = build::build_test_parts(&root, &plan(features, params, version, None), true, &staged(&self.identity)); + let bytes = image::update_image(&parts.kernel, &parts.root, ¶ms.join(","), Signing { key, version }); + let path = self.scratch.join(format!("{name}.update")); + std::fs::write(&path, bytes).map_err(|e| format!("write {}: {e}", path.display()))?; + Ok((path, parts.kernel.len())) + } + + /// Boot the machine once, taking every reset it makes, to its ready + /// marker: the guest and its console so far. + fn boot(&self) -> Result<(QemuInstance, String), String> { + let options = BootOptions { + profile: qemu::Profile::Headless, + boot_image: Some(Staged::Written(self.image.clone())), + ssh_port: Some(self.port), + takes_the_reset: true, + firmware_vars: Some(self.vars.clone()), + qmp: true, + ..Default::default() + }; + let config = super::compile::repo_root().join(CONFIG); + let mut guest = QemuInstance::boot_with_options(&config, &[], &[], options); + let mut console = guest.boot_log().to_string(); + qemu::await_marker(&mut guest, &mut console, "sshd: listening on port 22", "sshd to open its port")?; + Ok((guest, console)) + } + + /// Boot the machine on the T14's shape, whose 16550 is its console, to + /// the loader's line `marker`: for a boot that never reaches a kernel, or + /// is cut at the loader's handoff. + fn launch(&self, marker: &'static str) -> QemuInstance { + let options = BootOptions { + profile: qemu::Profile::Metal, + boot_image: Some(Staged::Written(self.image.clone())), + takes_the_reset: true, + firmware_vars: Some(self.vars.clone()), + ready_marker: marker, + ..Default::default() + }; + QemuInstance::boot_with_options(&super::compile::repo_root().join(CONFIG), &[], &[], options) + } + + /// The log partition's unique GUID, which the slots' record carries. + fn log_guid(&self) -> Result<[u8; 16], String> { + let mut file = std::fs::File::open(&self.image).map_err(|e| format!("{}: {e}", self.image.display()))?; + image::unique_guid_of(&mut file, toyos_gpt::Guid::MICROSOFT_BASIC) + } + + /// The slots' record the loader last wrote. + fn record(&self) -> Result { + let guid = self.log_guid()?; + let mut file = std::fs::File::open(&self.image).map_err(|e| format!("{}: {e}", self.image.display()))?; + let bytes = image::read_file_on(&mut file, guid, RECORD_FILE)?; + Record::decode(&bytes, &guid).map_err(|why| format!("the slots' record {why}")) + } + + /// `reboot` over ssh, with `edit` made to the slots' record while the + /// machine is held at the reset its kernel makes — **the record as the + /// running system could have left it**, written after that kernel's last + /// write (its cache writes back whole blocks, the record's among them) and + /// before the loader's first read — then the console until `marker`: where + /// on the console and on the 16550 the boots after the reboot begin. + fn reboot_forging( + &self, + guest: &mut QemuInstance, + console: &mut String, + edit: impl FnOnce(&mut Record) -> Result<(), String>, + marker: &str, + ) -> Result<(usize, usize), String> { + let (from, uart) = (console.len(), guest.uart_log().len()); + let mut hold = qemu::QmpHold::arm(guest.qmp_socket()); + let asked = ssh::ssh_fire(HOST, self.port, &self.identity, "reboot")?; + eprintln!(" [update] `reboot` answered {asked:?}"); + hold.held(qemu::GUEST_WEDGED)?; + let guid = self.log_guid()?; + let mut record = self.record()?; + edit(&mut record)?; + image::overwrite_file_on(&self.image, guid, RECORD_FILE, &record.encode(&guid))?; + if self.record()? != record { + return Err("the forged record did not read back".into()); + } + hold.release(); + await_machine(guest, console, &format!("{marker:?} after the forged reboot"), |c| c[from.min(c.len())..].contains(marker))?; + Ok((from, uart)) + } + + /// The name of the floor this machine's loader keeps. + fn floor_name(&self) -> Result { + let key = signing::key(); + Ok(floors::name(key.floor_scope(), &key.public(), &self.log_guid()?).as_str().to_string()) + } + + /// `update < file` over ssh: its status and what it said. + fn install(&self, file: &Path) -> Result<(Option, String), String> { + let exec = ssh::ssh_pipe(HOST, self.port, &self.identity, "update", file)?; + let said = format!("{}{}", exec.stdout_text(), exec.stderr_text()); + eprintln!(" [update] `update < {}` ended {:?}: {}", file.display(), exec.status, said.trim()); + Ok((exec.status, said)) + } + + /// `reboot` over ssh, and the console until `marker`: where on the console + /// and on the loader's 16550 the boots after the reboot begin. + fn reboot_until(&self, guest: &mut QemuInstance, console: &mut String, marker: &str) -> Result<(usize, usize), String> { + let (from, uart) = (console.len(), guest.uart_log().len()); + let asked = ssh::ssh_fire(HOST, self.port, &self.identity, "reboot")?; + eprintln!(" [update] `reboot` answered {asked:?}"); + await_machine(guest, console, &format!("{marker:?} after the reboot"), |c| c[from.min(c.len())..].contains(marker)) + .map_err(|why| { + let all = guest.uart_log(); + format!("{why}\nthe 16550 since the reboot:\n{}", &all[uart.min(all.len())..]) + })?; + Ok((from, uart)) + } +} + +/// Wait until `done` holds of the console, while the machine is talking on +/// either of its channels. +/// +/// **Not the harness's own wait**: that one hears the console alone, and +/// between a kernel's reset and the next kernel's first line the machine +/// talks only on the 16550 — the loader's passes, one of which hashes ROOT — +/// so a machine working through two of them reads as one gone quiet. Its +/// bounds are the harness's, [`qemu::GUEST_QUIET`] of silence on both and +/// [`qemu::GUEST_WEDGED`] in all. +fn await_machine(guest: &mut QemuInstance, console: &mut String, doing: &str, done: impl Fn(&str) -> bool) -> Result<(), String> { + let began = Instant::now(); + let (mut heard, mut grew) = (0usize, Instant::now()); + loop { + if done(console) { + return Ok(()); + } + let more = guest.drain_serial(std::time::Duration::from_millis(200)); + console.push_str(&more); + let now = console.len() + guest.uart_log().len(); + if now != heard { + (heard, grew) = (now, Instant::now()); + } + if grew.elapsed() >= qemu::GUEST_QUIET { + return Err(format!( + "{} waiting for {doing}: the console and the 16550 both went quiet for {} s", + qemu::STALLED, + qemu::GUEST_QUIET.as_secs() + )); + } + if began.elapsed() >= qemu::GUEST_WEDGED { + return Err(format!("{} waiting for {doing}: it never stopped talking and never got there", qemu::STALLED)); + } + } +} + +/// `what` is in `console` from `from` on, or the finding that it is not. +fn owed(console: &str, from: usize, what: &str) -> Result<(), String> { + if console[from.min(console.len())..].contains(what) { + return Ok(()); + } + Err(format!("{what:?} is not on the console after byte {from}:\n{}", &console[from.min(console.len())..])) +} + +/// `what` is among the loader's lines from byte `from` of its 16550 on. +fn loader_said(guest: &QemuInstance, from: usize, what: &str) -> Result<(), String> { + let uart = guest.uart_log(); + let since = &uart[from.min(uart.len())..]; + if since.contains(what) { + return Ok(()); + } + let loader: Vec<&str> = since.lines().filter(|l| !l.starts_with("[kernel ")).collect(); + Err(format!("the loader never said {what:?}; it said:\n{}", loader.join("\n"))) +} + +/// **The exit**: a kernel change reaches the running machine as `ssh … update +/// < image`, is written to the idle slot, and is the kernel the next boot +/// runs; the boot that proved the old image raised the floor, and the one +/// that proves the new image raises it past the old one. +pub fn update_boots_the_new_kernel(_: &Path, _: &[(String, Vec)], _: &[(String, Vec)]) -> Result<(), String> { + let rig = Rig::stage("update-boots")?; + // The actuator kernel, and no actuator armed: a different kernel binary + // that boots the same machine. + let (next, next_kernel) = rig.update("next", build::TEST_KERNEL, &[], NEXT, signing::key())?; + if next_kernel == rig.base_kernel { + return Err(format!("the update's kernel is {next_kernel} bytes, the base's too: no change to carry")); + } + let (mut guest, mut console) = rig.boot()?; + owed(&console, 0, &format!("{SLOT_RECORD} A, the one the slot table marks"))?; + + let asked = Instant::now(); + let (status, said) = rig.install(&next)?; + let installed = asked.elapsed(); + if status != Some(0) || !said.contains(&format!("update: installed version {NEXT} in slot B")) { + return Err(format!("`update` ended {status:?} saying {said:?}")); + } + let (from, uart) = rig.reboot_until(&mut guest, &mut console, &format!("{SLOT_RECORD} B, the one the slot table marks"))?; + await_machine(&mut guest, &mut console, "the new slot's ready marker", |c| c[from..].contains(DEFAULT_READY))?; + let booted = asked.elapsed(); + loader_said(&guest, uart, &format!("Anti-rollback floor: {BASE}, raised from 0 by the boot that proved it"))?; + loader_said(&guest, uart, &format!("Slot B: {VERIFIED}"))?; + loader_said(&guest, uart, &format!("Kernel: {next_kernel} bytes"))?; + for line in guest.uart_log()[uart..].lines().filter(|l| l.contains("TSC cycles") || l.contains("Loader TSC")) { + eprintln!(" [update] loader: {line}"); + } + eprintln!( + " [update] {} bytes installed in {} ms; slot B's kernel ({next_kernel} bytes, the base's {}) \ + at its ready marker {} ms after `update` was asked", + std::fs::metadata(&next).map(|m| m.len()).unwrap_or(0), + installed.as_millis(), + rig.base_kernel, + booted.as_millis() + ); + + // **A record the running system forged proves nothing**: slot B's own + // entry, a digest that is not its header's and a version no image + // carries. The pass that reads the clean reboot verifies B's header, + // finds another digest, and leaves the floor at the base's version. + let forge = |record: &mut Record| { + let booted = record.booted.filter(|b| b.slot == Which::B).ok_or("the loader wrote down no boot of slot B")?; + let mut digest = booted.digest; + digest[0] ^= 1; + record.booted = Some(Booted { version: FORGED, digest, ..booted }); + Ok(()) + }; + let (from, uart) = + rig.reboot_forging(&mut guest, &mut console, forge, &format!("{SLOT_RECORD} B, the one the slot table marks"))?; + await_machine(&mut guest, &mut console, "slot B's ready marker again", |c| c[from..].contains(DEFAULT_READY))?; + loader_said(&guest, uart, "Anti-rollback floor: not raised, because the proven image is not verified: slot B's signed header is")?; + loader_said(&guest, uart, &format!("{} (image scope) holds {BASE}", rig.floor_name()?))?; + let since = guest.uart_log()[uart..].to_string(); + for raised in [format!("Anti-rollback floor: {NEXT}"), format!("Anti-rollback floor: {FORGED}")] { + if since.contains(&raised) { + return Err(format!("a forged record raised the floor: the loader said {raised:?}")); + } + } + eprintln!(" [update] a record naming slot B under another digest and version {FORGED} raised nothing"); + + // **What anti-rollback is for**: the plain reboot proves slot B's own + // image, the floor rises to the update's version, and the slot the machine + // updated from is below it. + let (from, uart) = rig.reboot_until(&mut guest, &mut console, &format!("{SLOT_RECORD} B, the one the slot table marks"))?; + await_machine(&mut guest, &mut console, "slot B's ready marker a third time", |c| c[from..].contains(DEFAULT_READY))?; + loader_said(&guest, uart, &format!("Anti-rollback floor: {NEXT}, raised from {BASE} by the boot that proved it"))?; + drop(guest); + image::restage_table(&rig.image, |t| t.marked = Which::A)?; + let (guest, console) = rig.boot()?; + loader_said(&guest, 0, &format!("Slot A: REFUSED, its version {BASE} is below {NEXT}, the highest a boot has proven"))?; + owed(&console, 0, &format!("{SLOT_RECORD} B, because the marked slot A was refused: version"))?; + eprintln!(" [update] the boot of slot B raised the floor to {NEXT}, and slot A at {BASE} is refused under it"); + drop(guest); + let _ = std::fs::remove_dir_all(&rig.scratch); + Ok(()) +} + +/// **Every slot the loader must refuse is refused by name, and the other boots** +/// — a flipped byte, no signed header, another key's signature, a version +/// under the floor — and `update` itself refuses what it can see: another +/// key and an image older than what runs, before it writes anything, and a +/// kernel or ROOT that is not the bytes the header names, or bytes past the +/// last section, before it moves the mark. And the floor the reboot raises is +/// the version the loader verified, whatever the record on the disk says. +pub fn update_refusals_boot_the_other_slot(_: &Path, _: &[(String, Vec)], _: &[(String, Vec)]) -> Result<(), String> { + let rig = Rig::stage("update-refusals")?; + let stranger = Key::mint(); + let (next, _) = rig.update("next", &[], &[], NEXT, signing::key())?; + let (foreign, _) = rig.update("foreign", &[], &[], NEXT, &stranger)?; + let (older, _) = rig.update("older", &[], &[], OLDER, signing::key())?; + let bytes = |path: &Path| std::fs::read(path).map_err(|e| format!("{}: {e}", path.display())); + let whole = bytes(&next)?; + let header = toyos_update::image::Header::parse(&whole).map_err(|why| why.to_string())?; + let root_at = toyos_update::image::SIGNED_BYTES + (header.kernel().len + header.cmdline().len) as usize; + let bent = |name: &str, bend: &dyn Fn(&mut Vec)| -> Result { + let mut bytes = whole.clone(); + bend(&mut bytes); + let path = rig.scratch.join(format!("{name}.update")); + std::fs::write(&path, bytes).map_err(|e| format!("write {}: {e}", path.display()))?; + Ok(path) + }; + let kernel_flipped = bent("kernel-flipped", &|b| b[toyos_update::image::SIGNED_BYTES + 100] ^= 0x01)?; + let root_flipped = bent("root-flipped", &|b| b[root_at + 100] ^= 0x01)?; + let appended = bent("appended", &|b| b.push(0))?; + + // The machine's first boot: `update` refuses each, and a reboot proves the + // base image, which raises the floor to its version. + let (mut guest, mut console) = rig.boot()?; + let older_word = format!("its version {OLDER} is older than {BASE}"); + for (file, word) in [ + (&foreign, "the signature is not this machine's key's"), + (&older, older_word.as_str()), + (&kernel_flipped, "the kernel is not the bytes its signed header names"), + (&root_flipped, "ROOT is not the bytes its signed header names"), + (&appended, "the input carries more bytes than its signed header names"), + ] { + let (status, said) = rig.install(file)?; + if status != Some(1) || !said.contains(word) { + return Err(format!("`update < {}` ended {status:?} saying {said:?}, where {word:?} is owed", file.display())); + } + } + // **What the running system writes, the loader does not believe**: the + // record names slot A and its own digest, and a version no image carries. + let forge = |record: &mut Record| { + let booted = record.booted.filter(|b| b.slot == Which::A).ok_or("the loader wrote down no boot of slot A")?; + record.booted = Some(Booted { version: FORGED, ..booted }); + Ok(()) + }; + let (from, uart) = rig.reboot_forging(&mut guest, &mut console, forge, DEFAULT_READY)?; + loader_said(&guest, uart, &format!("Anti-rollback floor: {BASE}, raised from 0 by the boot that proved it"))?; + if guest.uart_log()[uart..].contains(&FORGED.to_string()) { + return Err(format!("the loader said the forged version {FORGED}")); + } + owed(&console, from, &format!("{SLOT_RECORD} A, the one the slot table marks"))?; + drop(guest); + + let mut flipped = bytes(&next)?; + // A byte of the kernel, past the signed header: the signature still + // verifies and the hash does not. + flipped[toyos_update::image::SIGNED_BYTES + 100] ^= 0x01; + let cases: [(&str, Vec, bool, &str); 4] = [ + ("a flipped byte", flipped, true, "hash"), + ("no signed header", bytes(&next)?, false, "unsigned"), + ("another key's signature", bytes(&foreign)?, true, "signature"), + ("a version under the floor", bytes(&older)?, true, "version"), + ]; + for (what, update, signed, word) in cases { + image::stage_slot(&rig.image, Which::B, &update, signed)?; + let (guest, console) = rig.boot()?; + loader_said(&guest, 0, "Slot B: REFUSED")?; + owed(&console, 0, &format!("{SLOT_RECORD} A, because the marked slot B was refused: {word}"))?; + loader_said(&guest, 0, &format!("Slot A: {VERIFIED}"))?; + eprintln!(" [update] slot B with {what}: refused as {word:?}, and slot A booted"); + drop(guest); + } + let _ = std::fs::remove_dir_all(&rig.scratch); + Ok(()) +} + +/// **A boot that dies falls back on its own**: an update whose kernel panics +/// is installed and marked, the reboot boots it, it panics, the loader reads +/// the panic and marks that image dead, and the pass after boots slot A — +/// whose kernel says why, and whose `/log` carries the saying. +pub fn update_falls_back_from_a_dying_kernel(_: &Path, _: &[(String, Vec)], _: &[(String, Vec)]) -> Result<(), String> { + let rig = Rig::stage("update-dies")?; + // A panic once the boot is complete, and the panicked kernel's own reset + // bound shortened so it hands the machine back inside the test: the + // reset is what brings the loader round to read the panic. + let (dying, _) = rig.update("dying", build::TEST_KERNEL, &["test-late-panic", "panic-reboot-fast"], NEXT, signing::key())?; + let (mut guest, mut console) = rig.boot()?; + let (status, said) = rig.install(&dying)?; + if status != Some(0) || !said.contains(&format!("update: installed version {NEXT} in slot B")) { + return Err(format!("`update` ended {status:?} saying {said:?}")); + } + let fell_back = format!("{SLOT_RECORD} A, because the marked slot B was refused: died"); + // Until slot A says it fell back, or slot B has booted a second time: a + // loader that does not fall back boots the dead slot again, and that is the + // answer, not a wait for one that never comes. + let again = format!("{SLOT_RECORD} B, "); + let (from, uart) = (console.len(), guest.uart_log().len()); + ssh::ssh_fire(HOST, rig.port, &rig.identity, "reboot")?; + await_machine(&mut guest, &mut console, "slot A to fall back, or slot B to boot again", |c| { + let since = &c[from.min(c.len())..]; + since.contains(&fell_back) || since.matches(&again).count() >= 2 + })?; + let booted_b = console[from..].matches(&again).count(); + if !console[from..].contains(&fell_back) { + return Err(format!("slot B booted {booted_b} times after the update and slot A never did")); + } + await_machine(&mut guest, &mut console, "slot A's ready marker", |c| c[from..].contains(DEFAULT_READY))?; + loader_said(&guest, uart, "Previous boot's panic:")?; + loader_said(&guest, uart, "died on its last boot, so no pass boots it again until an update replaces it")?; + + // The owner's channel on a machine with no console: the fallback boot's + // own `/log`, whole once that boot has ended itself. + let at = console.len(); + ssh::ssh_fire(HOST, rig.port, &rig.identity, "reboot")?; + qemu::await_marker_new(&mut guest, &mut console, bootlog::REBOOTING, at, "the fallback boot to end")?; + drop(guest); + let bytes = std::fs::read(&rig.image).map_err(|e| format!("{}: {e}", rig.image.display()))?; + let (start, len) = super::volumes::log_extent(&bytes, &rig.image)?; + let log = super::volumes::whole_log(&rig.image, start, len)?; + if !log.iter().any(|line| line.contains(&fell_back)) { + return Err(format!("/log never says {fell_back:?}; it holds {} lines", log.len())); + } + eprintln!(" [update] slot B's kernel panicked, slot A booted on its own, and /log says {fell_back:?}"); + let _ = std::fs::remove_dir_all(&rig.scratch); + Ok(()) +} + +/// Each of `whats` is among `log`'s lines, or the finding that one is not. +fn said(log: &str, whats: &[&str]) -> Result<(), String> { + for what in whats { + if !log.contains(what) { + return Err(format!("{what:?} is not in what the machine said:\n{log}")); + } + } + Ok(()) +} + +/// **A hang of an image no boot has proven is a death**: slot B is handed the +/// machine and cut at the loader's handoff, which is a hang or a power cut +/// as far as any pass can tell; the retry boots nothing and marks B's image +/// dead, since no floor stands at or above its version; and the pass after +/// boots slot A. +pub fn update_hang_kills_an_unproven_image(_: &Path, _: &[(String, Vec)], _: &[(String, Vec)]) -> Result<(), String> { + let rig = Rig::stage("update-hang")?; + let (next, _) = rig.update("next", &[], &[], NEXT, signing::key())?; + let update = std::fs::read(&next).map_err(|e| format!("{}: {e}", next.display()))?; + image::stage_slot(&rig.image, Which::B, &update, true)?; + + let handed = rig.launch(bootlog::LOADER_LAST_LINE); + said(handed.boot_log(), &[&format!("Slot B: {VERIFIED}")])?; + drop(handed); + let retry = rig.launch(bootlog::CHAIN_ENDS_LINE); + said(retry.boot_log(), &[bootlog::HUNG_WITHOUT_A_RECORD, "died on its last boot, so no pass boots it again"])?; + drop(retry); + let after = rig.launch(bootlog::LOADER_LAST_LINE); + said(after.boot_log(), &["Slot B: REFUSED, its image died on its last boot", &format!("Slot A: {VERIFIED}")])?; + drop(after); + eprintln!(" [update] slot B cut at its handoff was a death: the retry marked it, and slot A booted"); + let _ = std::fs::remove_dir_all(&rig.scratch); + Ok(()) +} + +/// **The record a pass writes before it has chosen names no booted image**: a +/// pass whose every slot is refused panics after that first write, and the +/// record it leaves must not still credit the image the last pass booted — +/// whose clean end the next pass would take as that image's proof. +pub fn update_refused_pass_credits_no_image(_: &Path, _: &[(String, Vec)], _: &[(String, Vec)]) -> Result<(), String> { + let rig = Rig::stage("update-uncredited")?; + let handed = rig.launch(bootlog::LOADER_LAST_LINE); + said(handed.boot_log(), &[&format!("Slot A: {VERIFIED}")])?; + drop(handed); + if rig.record()?.booted.map(|b| b.slot) != Some(Which::A) { + return Err(format!("the pass that booted slot A wrote down {:?}", rig.record()?.booted)); + } + // A byte of slot A's kernel, and slot B holds no image: every slot is + // refused, so the pass panics after its first write and before its second. + let mut file = std::fs::File::open(&rig.image).map_err(|e| format!("{}: {e}", rig.image.display()))?; + let a = image::slot_table_of(&mut file)?.slot(Which::A).ok_or("no slot A")?; + let mut kernel = image::read_file_on(&mut file, a.boot, toyos_update::slots::KERNEL_FILE)?; + drop(file); + kernel[100] ^= 0x01; + image::overwrite_file_on(&rig.image, a.boot, toyos_update::slots::KERNEL_FILE, &kernel)?; + let refused = rig.launch("Slots: no slot verifies"); + said(refused.boot_log(), &["Slot A: REFUSED, its kernel is not the bytes its signed header names", "Slot B: REFUSED"])?; + drop(refused); + if let Some(booted) = rig.record()?.booted { + return Err(format!("a pass that booted nothing left a record naming slot {}'s image", booted.slot.letter())); + } + eprintln!(" [update] a pass that refused every slot left a record naming no booted image"); + let _ = std::fs::remove_dir_all(&rig.scratch); + Ok(()) +} + +/// **init claims nothing the slot table names but an idle slot's partition on +/// the running disk**: a table naming the ESP, the log partition, or either +/// of the running slot's partitions as the idle slot's is refused by name, +/// and `update` holds nothing. +pub fn update_grant_refuses_a_stray_partition(_: &Path, _: &[(String, Vec)], _: &[(String, Vec)]) -> Result<(), String> { + use toyos_update::slots::Slot; + let rig = Rig::stage("update-grant")?; + let (next, _) = rig.update("next", &[], &[], NEXT, signing::key())?; + let mut file = std::fs::File::open(&rig.image).map_err(|e| format!("{}: {e}", rig.image.display()))?; + let esp = image::unique_guid_of(&mut file, toyos_gpt::Guid::EFI_SYSTEM)?; + let table = image::slot_table_of(&mut file)?; + drop(file); + let (a, b) = (table.slot(Which::A).ok_or("no slot A")?, table.slot(Which::B).ok_or("no slot B")?); + let log = rig.log_guid()?; + let not_a_slot = "the idle slot's volume is not of the type a slot's partition of that kind carries"; + let cases = [ + ("the ESP", esp, b.root, not_a_slot), + ("the log partition", log, b.root, not_a_slot), + ("the running slot's volume", a.boot, b.root, "the idle slot's volume is one of the running slot's"), + ("the running slot's ROOT", b.boot, a.root, "the idle slot's ROOT is one of the running slot's"), + ]; + for (what, boot, root, why) in cases { + image::restage_table(&rig.image, |t| t.slots[Which::B.index()] = Some(Slot { boot, root, version: 0 }))?; + let (mut guest, mut console) = rig.boot()?; + let (status, said) = rig.install(&next)?; + if status != Some(1) || !said.contains("this process holds no `slots:table`") { + return Err(format!("with slot B naming {what}, `update` ended {status:?} saying {said:?}")); + } + let refused = format!("init: update: no slot to grant: {why}"); + await_machine(&mut guest, &mut console, &format!("init to refuse {what}"), |c| c.contains(&refused))?; + eprintln!(" [update] slot B naming {what}: init granted nothing, and `update` held nothing"); + drop(guest); + } + let _ = std::fs::remove_dir_all(&rig.scratch); + Ok(()) +} + +/// **A floor is its key's and its image's**: the owner's floor and another +/// image's, both at the highest version there is, hold this image to nothing +/// — the other image's is deleted, the owner's never — and the clean reboot +/// raises this image's own. And this image's own floor, stored in a shape its +/// loader never writes, is refused and boots nothing. +pub fn update_floor_is_the_images_own(_: &Path, _: &[(String, Vec)], _: &[(String, Vec)]) -> Result<(), String> { + let rig = Rig::stage("update-floor")?; + let key = signing::key(); + let own = rig.floor_name()?; + let owner = floors::name(Scope::Machine, &key.public(), &[0; 16]).as_str().to_string(); + let other = floors::name(Scope::Image, &key.public(), &[0x55; 16]).as_str().to_string(); + let template = super::compile::repo_root().join("ovmf/OVMF_VARS-pure-efi.fd"); + let fresh = || std::fs::copy(&template, &rig.vars).map(|_| ()).map_err(|e| format!("{}: {e}", rig.vars.display())); + + fresh()?; + vars::plant(&rig.vars, &owner, floors::ATTRIBUTES, &u64::MAX.to_le_bytes())?; + vars::plant(&rig.vars, &other, floors::ATTRIBUTES, &u64::MAX.to_le_bytes())?; + let (mut guest, mut console) = rig.boot()?; + owed(&console, 0, &format!("{SLOT_RECORD} A, the one the slot table marks"))?; + loader_said(&guest, 0, &format!("Anti-rollback floor: {other} is no floor this image loader keeps; deleted"))?; + loader_said(&guest, 0, &format!("Anti-rollback floor: {own} (image scope) holds 0"))?; + let (_, uart) = rig.reboot_until(&mut guest, &mut console, DEFAULT_READY)?; + loader_said(&guest, uart, &format!("Anti-rollback floor: {BASE}, raised from 0 by the boot that proved it"))?; + drop(guest); + + let stored = vars::live(&rig.vars)?; + let value = |name: &str| stored.iter().filter(|v| v.name == name).map(|v| v.data.clone()).collect::>(); + let want = [(&owner, vec![u64::MAX.to_le_bytes().to_vec()]), (&own, vec![BASE.to_le_bytes().to_vec()]), (&other, vec![])]; + for (name, holds) in want { + if value(name) != holds { + return Err(format!("the variable store holds {name} as {:?}, where {holds:?} is owed", value(name))); + } + } + eprintln!(" [update] the owner's floor and another image's held this one to nothing; the other's went, the owner's stayed"); + + fresh()?; + vars::plant(&rig.vars, &own, floors::ATTRIBUTES, &[1; 9])?; + let refused = rig.launch(FLOOR_REFUSED); + said(refused.boot_log(), &[&format!("Anti-rollback floor: {own}: it holds 9 bytes where this loader writes 8")])?; + drop(refused); + eprintln!(" [update] this image's own floor in nine bytes was refused, and nothing booted"); let _ = std::fs::remove_dir_all(&rig.scratch); + Ok(()) +} + +/// The firmware's variable store, as OVMF keeps it in its `VARS` file: a +/// firmware volume holding an authenticated variable store, read and written +/// by the layout EDK2 declares for it (`MdeModulePkg/Include/Guid/ +/// VariableFormat.h`) and not by anything the loader shares — only the +/// floor's vendor GUID, which the store is asked for. +mod vars { + use std::path::Path; + + /// The floor's vendor, `33BE3D4A-30E6-49F5-8050-F169D93A20FB`, in the + /// byte order `EFI_GUID` stores. + const VENDOR: [u8; 16] = [0x4a, 0x3d, 0xbe, 0x33, 0xe6, 0x30, 0xf5, 0x49, 0x80, 0x50, 0xf1, 0x69, 0xd9, 0x3a, 0x20, 0xfb]; + /// `EFI_FIRMWARE_VOLUME_HEADER`: its signature and its header's length. + const FV_SIGNATURE: (usize, &[u8]) = (0x28, b"_FVH"); + const FV_HEADER_LEN_AT: usize = 0x30; + /// `VARIABLE_STORE_HEADER`: signature GUID, size, format, state, reserved. + const STORE_HEADER: usize = 16 + 4 + 1 + 1 + 2 + 4; + /// `AUTHENTICATED_VARIABLE_HEADER`: start id, state, reserved, attributes, + /// monotonic count, time stamp, public key index, name size, data size, + /// vendor GUID. + const HEADER: usize = 2 + 1 + 1 + 4 + 8 + 16 + 4 + 4 + 4 + 16; + const START_ID: u16 = 0x55AA; + const VAR_ADDED: u8 = 0x3F; + /// `VAR_ADDED & VAR_IN_DELETED_TRANSITION`: still the variable until the + /// copy replacing it is added. + const IN_TRANSITION: u8 = 0x3E; + + pub struct Var { + pub name: String, + pub data: Vec, + } + + /// Where the variables begin and where the store ends. + fn store(bytes: &[u8]) -> Result<(usize, usize), String> { + let (at, sig) = FV_SIGNATURE; + if bytes.get(at..at + sig.len()) != Some(sig) { + return Err("the variable file is no firmware volume".into()); + } + let header = u16::from_le_bytes([bytes[FV_HEADER_LEN_AT], bytes[FV_HEADER_LEN_AT + 1]]) as usize; + let size = u32::from_le_bytes(bytes[header + 16..header + 20].try_into().expect("four bytes")) as usize; + Ok((header + STORE_HEADER, header + size)) + } + + /// One variable header in the store. + struct Found { + state: u8, + vendor: [u8; 16], + var: Var, + } + + /// Every variable header in the store, and where the erased space after + /// them begins. + fn walk(bytes: &[u8]) -> Result<(Vec, usize), String> { + let (mut at, end) = store(bytes)?; + let mut out = Vec::new(); + while at + HEADER <= end && u16::from_le_bytes([bytes[at], bytes[at + 1]]) == START_ID { + let word = |off: usize| u32::from_le_bytes(bytes[at + off..at + off + 4].try_into().expect("four bytes")) as usize; + let (name_len, data_len) = (word(36), word(40)); + let vendor: [u8; 16] = bytes[at + 44..at + 60].try_into().expect("sixteen bytes"); + let name_at = at + HEADER; + let units: Vec = bytes[name_at..name_at + name_len] + .chunks(2) + .map(|c| u16::from_le_bytes([c[0], c[1]])) + .take_while(|&u| u != 0) + .collect(); + let data = bytes[name_at + name_len..name_at + name_len + data_len].to_vec(); + out.push(Found { state: bytes[at + 2], vendor, var: Var { name: String::from_utf16_lossy(&units), data } }); + at = (name_at + name_len + data_len).next_multiple_of(4); + } + Ok((out, at)) + } + + /// The live variables under the floor's vendor. + pub fn live(path: &Path) -> Result, String> { + let bytes = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?; + Ok(walk(&bytes)? + .0 + .into_iter() + .filter(|found| found.vendor == VENDOR && (found.state == VAR_ADDED || found.state == IN_TRANSITION)) + .map(|found| found.var) + .collect()) + } + + /// Add `name` under the floor's vendor with `attributes` and `data`, as + /// the firmware would have added it. + pub fn plant(path: &Path, name: &str, attributes: u32, data: &[u8]) -> Result<(), String> { + let mut bytes = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?; + let (_, end) = store(&bytes)?; + let (_, at) = walk(&bytes)?; + let mut units: Vec = name.encode_utf16().chain([0]).flat_map(u16::to_le_bytes).collect(); + let mut var = vec![0u8; HEADER]; + var[..2].copy_from_slice(&START_ID.to_le_bytes()); + var[2] = VAR_ADDED; + var[4..8].copy_from_slice(&attributes.to_le_bytes()); + var[36..40].copy_from_slice(&(units.len() as u32).to_le_bytes()); + var[40..44].copy_from_slice(&(data.len() as u32).to_le_bytes()); + var[44..60].copy_from_slice(&VENDOR); + var.append(&mut units); + var.extend_from_slice(data); + if at + var.len() > end || bytes[at..at + var.len()].iter().any(|&b| b != 0xFF) { + return Err(format!("no erased room for {name} at byte {at} of the variable store")); + } + bytes[at..at + var.len()].copy_from_slice(&var); + std::fs::write(path, bytes).map_err(|e| format!("{}: {e}", path.display())) + } +} diff --git a/tests/common/volumes.rs b/tests/common/volumes.rs index 1105c1b3dab..f81fa7f43e7 100644 --- a/tests/common/volumes.rs +++ b/tests/common/volumes.rs @@ -58,10 +58,10 @@ fn blob() -> Vec { } /// The files the build put on the ESP, which the guest must not have touched. -/// `BOOTx64.EFI` is the one firmware reads and `kernel.elf` the one the +/// `BOOTx64.EFI` is the one firmware reads and `log.guid` the one the /// bootloader does. Damaging either makes the stick unbootable, so /// "still byte-identical" is the assertion that matters most here. -const UNTOUCHED: [&str; 2] = ["EFI/BOOT/BOOTx64.EFI", "toyos/kernel.elf"]; +const UNTOUCHED: [&str; 2] = ["EFI/BOOT/BOOTx64.EFI", "toyos/log.guid"]; fn test_dir() -> PathBuf { super::lane::dir() @@ -391,7 +391,7 @@ pub fn esp_filesystem( } // The assertion this test exists for. A guest test once wrote five bytes - // over `kernel.elf` through the VFS; `esp_files` tries exactly that, and + // over the kernel through the VFS; `esp_files` tries that on the loader, and // this is where the answer comes from — the image the device received, // not the guest's opinion of what it did. for (name, want) in UNTOUCHED.iter().zip(&before) { @@ -2421,8 +2421,14 @@ pub fn log_partition_layout( .open(&image_path) .map_err(|e| format!("the built image has no readable GPT: {e}"))?; let table: Vec<_> = disk.partitions().values().collect(); - let [esp, root, log] = table.as_slice() else { - return Err(format!("the built image has {} partitions, wanted three", table.len())); + // In entry order: the log is third, where the metal loop reads it, and a + // test image carries one slot. + let [esp, slots, log, volume, root] = table.as_slice() else { + return Err(format!( + "the built image has {} partitions, wanted five: the ESP, the slot table, the log, and \ + slot A's volume and ROOT", + table.len() + )); }; let types = [ @@ -2434,32 +2440,32 @@ pub fn log_partition_layout( return Err(format!("the {what} is typed {got}, wanted {want}")); } } - // ROOT's type is read with the kernel's parser: the `gpt` crate answers the - // all-zero GUID for a type its own table does not name. - let mut found = [toyos_gpt::Partition { - index: 0, - type_guid: toyos_gpt::Guid::ZERO, - unique_guid: toyos_gpt::Guid::ZERO, - first_lba: 0, - last_lba: 0, - }; 4]; - let scan = toyos_gpt::locate_type( - &mut ImageSectors { bytes: &image }, - toyos_gpt::Guid::TOYOS_ROOT, - &mut found, - ) - .map_err(|e| format!("the kernel's own GPT parser cannot read this table: {e:?}"))?; - if (scan.matched, scan.listed) != (1, 1) { - return Err(format!( - "the built image carries {} partitions typed {ROOT_TYPE}, wanted one", - scan.matched - )); - } - if found[0].first_lba != root.first_lba || found[0].last_lba != root.last_lba { - return Err(format!( - "the kernel's parser puts ROOT at LBA {}..{} and the table says {}..{}", - found[0].first_lba, found[0].last_lba, root.first_lba, root.last_lba - )); + // ROOT's, the slot table's and the slot volume's types are read with the + // kernel's parser: the `gpt` crate answers the all-zero GUID for a type its + // own table does not name. + for (kind, text, what, entry) in [ + (toyos_gpt::Guid::TOYOS_ROOT, ROOT_TYPE, "ROOT", root), + (toyos_gpt::Guid::TOYOS_SLOTS, toyos_gpt::Guid::TOYOS_SLOTS_TEXT, "the slot table", slots), + (toyos_gpt::Guid::TOYOS_BOOT, toyos_gpt::Guid::TOYOS_BOOT_TEXT, "slot A's volume", volume), + ] { + let mut found = [toyos_gpt::Partition { + index: 0, + type_guid: toyos_gpt::Guid::ZERO, + unique_guid: toyos_gpt::Guid::ZERO, + first_lba: 0, + last_lba: 0, + }; 4]; + let scan = toyos_gpt::locate_type(&mut ImageSectors { bytes: &image }, kind, &mut found) + .map_err(|e| format!("the kernel's own GPT parser cannot read this table: {e:?}"))?; + if (scan.matched, scan.listed) != (1, 1) { + return Err(format!("the built image carries {} partitions typed {text}, wanted one", scan.matched)); + } + if found[0].first_lba != entry.first_lba || found[0].last_lba != entry.last_lba { + return Err(format!( + "the kernel's parser puts {what} at LBA {}..{} and the table says {}..{}", + found[0].first_lba, found[0].last_lba, entry.first_lba, entry.last_lba + )); + } } // The attribute field, spelled out. Bit 0 marks a partition the firmware @@ -2477,7 +2483,7 @@ pub fn log_partition_layout( } let log_guid = log.part_guid; - let guids = [esp.part_guid, root.part_guid, log_guid]; + let guids = [esp.part_guid, slots.part_guid, log_guid, volume.part_guid, root.part_guid]; if guids.iter().any(uuid::Uuid::is_nil) { return Err("a partition was given the all-zero GUID, which GPT reads as unused".to_string()); } @@ -2488,14 +2494,16 @@ pub fn log_partition_layout( } // The alignment `create_gpt_disk` asserts, checked again from the table: - // the kernel mounts all three over one 4 KiB block device and caches - // device blocks per volume, so a block belonging to two would be held - // twice and go stale on the other's write. + // the kernel mounts several over one 4 KiB block device and caches device + // blocks per volume, so a block belonging to two would be held twice and go + // stale on the other's write. let extent = |p: &gpt::partition::Partition| (p.first_lba * 512, (p.last_lba + 1) * 512); let placed = [ ("ESP", extent(esp)), - ("root partition", extent(root)), + ("slot table", extent(slots)), ("log partition", extent(log)), + ("slot A's volume", extent(volume)), + ("root partition", extent(root)), ]; for (what, (start, end)) in placed { if start % 4096 != 0 || end % 4096 != 0 { @@ -3111,12 +3119,11 @@ fn root_twin( Ok(()) } -/// **A ROOT candidate the loader cannot read a superblock from is refused by -/// name.** Disk contents crossed a trust boundary, so a partition wearing the -/// ROOT type over bytes that are not a filesystem is named with what it holds -/// and never handed to the kernel. The boot disk's own ROOT has both -/// superblocks — block 0, which the loader reads, and the backup at the -/// volume's last block — inverted, so it is the one candidate and it is bad. +/// **A ROOT whose bytes are not the ones its slot's signed header names is +/// refused by name, and never handed to the kernel.** Disk contents crossed a +/// trust boundary: the boot disk's own ROOT has both superblocks — block 0 and +/// the backup at the volume's last block — inverted, so the image's one slot +/// is bad and the machine has nothing else to boot. pub fn root_candidate_malformed( test_config: &Path, c_bins: &[(String, Vec)], @@ -3134,26 +3141,19 @@ pub fn root_candidate_malformed( let log = boot_expecting_root_refusal(test_config, c_bins, rust_bins, &path)?; let _ = std::fs::remove_file(&path); - let seen = log - .lines() - .find(|l| l.contains("ROOT: candidate ") && l.contains("no superblock this loader can read")) - .ok_or_else(|| format!("the malformed candidate was not named:\n{}", volume_lines(&log)))? - .trim() - .to_string(); let verdict = root_refusal(&log)?; - if !verdict.contains("matches 0 of the 1") { - return Err(format!("the loader did not refuse a boot disk with no good ROOT: {verdict}")); + if !verdict.contains("its root is not the bytes its signed header names") { + return Err(format!("the loader did not refuse a ROOT its signature does not cover: {verdict}")); } - eprintln!(" [root] {seen}"); eprintln!(" [root] {verdict}"); Ok(()) } -/// **A boot disk that carries no filesystem the boot parameter names is a -/// boot the loader refuses, saying which one and what it saw.** One hex digit -/// of `root=` on the ESP is flipped, which is a byte-for-byte edit inside a -/// file of the same length — so the FAT volume is untouched and only the name -/// changes. +/// **A boot parameter naming a ROOT its slot does not carry is a boot the +/// loader refuses before the kernel reads it**: the parameter is one of the +/// slot's signed sections. One hex digit of `root=` on the slot's volume is +/// flipped, which is a byte-for-byte edit inside a file of the same length — +/// so the FAT volume is untouched and only the name changes. pub fn root_named_but_absent( test_config: &Path, c_bins: &[(String, Vec)], @@ -3162,24 +3162,30 @@ pub fn root_named_but_absent( let mut image = qemu::build_boot_image(test_config, c_bins, rust_bins, &[]); let path = test_dir().join("root-absent.img"); std::fs::write(&path, &image).map_err(|e| format!("write the boot image: {e}"))?; - // `root=` alone appears five times on the ESP, because `kernel.elf` carries - // the token as a literal. The search is for the whole argument, whose - // sixteen bytes are read out of ROOT's own superblock. + // `root=` alone appears several times on the slot's volume, because + // `kernel.elf` carries the token as a literal. The search is for the whole + // argument, whose sixteen bytes are read out of ROOT's own superblock. let (root_at, _) = root_extent(&image)?; let named: String = image[root_at + 106..root_at + 122].iter().map(|b| format!("{b:02x}")).collect(); let want = format!("root={named}"); - let (esp_at, esp_len) = esp_extent(&image, &path)?; - let esp = &image[esp_at..esp_at + esp_len]; - let hits: Vec = (0..esp.len().saturating_sub(want.len())) - .filter(|&i| &esp[i..i + want.len()] == want.as_bytes()) + let (slot_at, slot_len) = { + let mut file = std::fs::File::open(&path).map_err(|e| format!("{}: {e}", path.display()))?; + let table = toyos_build::image::slot_table_of(&mut file)?; + let slot = table.slot(table.marked).ok_or("the table marks no slot it carries")?; + toyos_build::image::partition_extent(&mut file, slot.boot)? + }; + let (slot_at, slot_len) = (slot_at as usize, slot_len as usize); + let volume = &image[slot_at..slot_at + slot_len]; + let hits: Vec = (0..volume.len().saturating_sub(want.len())) + .filter(|&i| &volume[i..i + want.len()] == want.as_bytes()) .collect(); let [at] = hits[..] else { - return Err(format!("the ESP carries {} {want:?} tokens, wanted one", hits.len())); + return Err(format!("the slot's volume carries {} {want:?} tokens, wanted one", hits.len())); }; // The last digit, so the flip cannot collide with the first: two names that // differ in one place are still two names. - let digit = esp_at + at + want.len() - 1; + let digit = slot_at + at + want.len() - 1; let was = image[digit]; image[digit] = if was == b'0' { b'1' } else { b'0' }; @@ -3188,25 +3194,18 @@ pub fn root_named_but_absent( let _ = std::fs::remove_file(&path); let verdict = root_refusal(&log)?; - if !verdict.contains("matches 0 of the 1") { - return Err(format!("the loader did not report a disk with no such ROOT: {verdict}")); + if !verdict.contains("its cmdline is not the bytes its signed header names") { + return Err(format!("the loader did not refuse a parameter its signature does not cover: {verdict}")); } - let seen = log - .lines() - .find(|l| l.contains("ROOT: candidate ") && l.contains(" at LBA ")) - .ok_or_else(|| format!("the refusal named no candidate:\n{}", volume_lines(&log)))? - .trim() - .to_string(); eprintln!(" [root] {verdict}"); - eprintln!(" [root] {seen}"); Ok(()) } /// **A second disk answering to the same name is not the boot's business.** A /// second stick carries an untouched copy of the boot image, so the machine -/// has two TOYOS-ROOT partitions whose superblocks carry one UUID. The loader -/// looks on the disk it was loaded from and on no other: it names one -/// candidate, and the kernel mounts that one from memory. +/// has two slot tables and two ROOTs whose superblocks carry one UUID. The +/// loader reads the slot table on the disk it was loaded from and on no other: +/// it reads one ROOT, and the kernel mounts that one from memory. pub fn root_named_twice( test_config: &Path, c_bins: &[(String, Vec)], @@ -3233,12 +3232,12 @@ pub fn root_named_twice( let named: Vec<&str> = log .lines() - .filter(|l| l.contains("ROOT: candidate ") && l.contains(" at LBA ")) + .filter(|l| l.contains(READ_AT)) .map(str::trim) .collect(); if named.len() != 1 { return Err(format!( - "the loader named {} candidates and the boot disk carries one:\n{}", + "the loader read {} ROOTs and the boot disk's slot names one:\n{}", named.len(), volume_lines(&log) )); @@ -3277,7 +3276,11 @@ pub fn root_chunk_refused( BootOptions { boot_image: Some(qemu::Staged::Written(path.clone())), stick_read_error: Some(bad), - ready_marker: ROOT_REFUSED, + // The read's own line and not the refusal after it: the stick + // QEMU fails a read on answers the loader's next write to + // `loader.log` with nothing, and the console line before that + // write is the last this boot says. + ready_marker: CHUNK_REFUSED, ..Default::default() }, ); @@ -3287,7 +3290,7 @@ pub fn root_chunk_refused( let verdict = log .lines() - .find(|l| l.contains(ROOT_REFUSED) && l.contains("the read of ")) + .find(|l| l.contains(CHUNK_REFUSED)) .map(str::trim) .ok_or_else(|| format!("the loader did not refuse the unreadable chunk:\n{}", volume_lines(&log)))?; let number = |after: &str| -> Result { @@ -3316,13 +3319,13 @@ pub fn root_chunk_refused( Ok(()) } -/// **A ROOT candidate its own table refuses is a boot the loader refuses, -/// naming why.** The partition after ROOT on the boot disk has its first LBA -/// moved eight blocks inside ROOT's end, both copies of the table rewritten -/// and their checksums recomputed, so the table is well-formed and ROOT -/// overlaps its neighbour: `toyos_gpt::locate` refuses it, and a loader that -/// read the candidate without asking would hand the kernel blocks another -/// partition also claims. +/// **A ROOT its own table refuses is a boot the loader refuses, naming why.** +/// The partition before ROOT on the boot disk has its last LBA moved eight +/// blocks inside ROOT's start, both copies of the table rewritten and their +/// checksums recomputed, so the table is well-formed and ROOT overlaps its +/// neighbour: `toyos_gpt::locate` refuses it before a byte of the slot is +/// read, and a loader that read it without asking would hand the kernel +/// blocks another partition also claims. pub fn root_candidate_overlaps( test_config: &Path, c_bins: &[(String, Vec)], @@ -3330,17 +3333,18 @@ pub fn root_candidate_overlaps( ) -> Result<(), String> { let mut image = qemu::build_boot_image(test_config, c_bins, rust_bins, &[]); let (at, len) = root_extent(&image)?; - let root_last = ((at + len) / GPT_LBA - 1) as u64; + let root_first = (at / GPT_LBA) as u64; + let _ = len; let size = image.len(); rewrite_gpt(&mut image, size, |entries, entry_bytes| { - let next = entries + let before = entries .chunks(entry_bytes) .enumerate() - .filter(|(_, entry)| entry[..16] != [0; 16] && entry_lba(entry, 32) > root_last) - .min_by_key(|(_, entry)| entry_lba(entry, 32)) + .filter(|(_, entry)| entry[..16] != [0; 16] && entry_lba(entry, 40) < root_first) + .max_by_key(|(_, entry)| entry_lba(entry, 40)) .map(|(index, _)| index) - .ok_or("no partition follows ROOT on the boot disk")?; - entries[next * entry_bytes + 32..][..8].copy_from_slice(&(root_last - 7).to_le_bytes()); + .ok_or("no partition comes before ROOT on the boot disk")?; + entries[before * entry_bytes + 40..][..8].copy_from_slice(&(root_first + 7).to_le_bytes()); Ok(()) })?; let path = test_dir().join("root-overlaps.img"); @@ -3350,7 +3354,7 @@ pub fn root_candidate_overlaps( let verdict = log .lines() - .find(|l| l.contains(ROOT_REFUSED)) + .find(|l| l.contains("Slot A: partition ")) .map(str::trim) .ok_or_else(|| format!("the loader did not refuse an overlapping ROOT:\n{}", volume_lines(&log)))?; if !verdict.contains("PartitionOverlap") { @@ -3360,11 +3364,12 @@ pub fn root_candidate_overlaps( Ok(()) } -/// **Two filesystems answering to one name on the boot disk is a boot the -/// loader refuses rather than one it guesses at.** The boot disk grows by a -/// second TOYOS-ROOT partition holding a byte-for-byte copy of ROOT under its -/// own unique GUID, so both candidates carry the name `root=` gives and both -/// are equally good. +/// **A second filesystem answering to ROOT's name on the boot disk is not a +/// candidate at all**: the slot table names ROOT by its partition's unique +/// GUID, and the loader reads that partition and no other. The boot disk grows +/// by a second TOYOS-ROOT partition holding a byte-for-byte copy of ROOT under +/// its own unique GUID, so both carry the name `root=` gives, and the boot +/// reads the one the table names. pub fn root_named_twice_on_the_boot_disk( test_config: &Path, c_bins: &[(String, Vec)], @@ -3394,33 +3399,6 @@ pub fn root_named_twice_on_the_boot_disk( image[twin_at..twin_at + len].copy_from_slice(&root); let path = test_dir().join("root-twice-one-disk.img"); std::fs::write(&path, &image).map_err(|e| format!("write the boot image: {e}"))?; - let log = boot_expecting_root_refusal(test_config, c_bins, rust_bins, &path)?; - let _ = std::fs::remove_file(&path); - - let verdict = root_refusal(&log)?; - if !verdict.contains("matches 2 of the 2") { - return Err(format!("the loader did not refuse two filesystems under one name: {verdict}")); - } - eprintln!(" [root] {verdict}"); - Ok(()) -} - -/// **A ROOT whose primary superblock is bad and whose backup is good boots, -/// because the loader decides the superblock as the kernel's mount does.** -/// Block 0 of the boot disk's ROOT is inverted and the backup at its last -/// block is left alone. -pub fn root_backup_superblock( - test_config: &Path, - c_bins: &[(String, Vec)], - rust_bins: &[(String, Vec)], -) -> Result<(), String> { - let mut image = qemu::build_boot_image(test_config, c_bins, rust_bins, &[]); - let (at, _) = root_extent(&image)?; - for byte in &mut image[at..at + 4096] { - *byte = !*byte; - } - let path = test_dir().join("root-backup-superblock.img"); - std::fs::write(&path, &image).map_err(|e| format!("write the boot image: {e}"))?; let qemu = qemu::QemuInstance::boot_with_options( test_config, c_bins, @@ -3431,69 +3409,12 @@ pub fn root_backup_superblock( drop(qemu); let _ = std::fs::remove_file(&path); - let seen = log - .lines() - .find(|l| l.contains("ROOT: candidate ") && l.contains(" at LBA ")) - .map(str::trim) - .ok_or_else(|| format!("the loader named no candidate:\n{}", volume_lines(&log)))?; - if seen.contains("no superblock") { - return Err(format!("the loader did not read the backup superblock: {seen}")); - } - let mounted = log - .lines() - .find(|l| l.contains("root: mounted read-only from memory at")) - .map(str::trim) - .ok_or_else(|| format!("ROOT did not mount from memory:\n{}", volume_lines(&log)))?; - eprintln!(" [root] {seen}"); - eprintln!(" [root] {mounted}"); - Ok(()) -} - -/// **A ROOT candidate whose superblock the disk will not read is a boot the -/// loader refuses, naming the firmware's status.** Block 0 of the boot disk's -/// ROOT is inverted, so the loader reads on to the backup at its last block, -/// and the boot stick fails with EIO every read covering that block's last -/// sector. Block 0 itself is left readable: firmware reads it when it connects -/// the partition, and an unreadable one stalls it before the loader runs. -pub fn root_superblock_unreadable( - test_config: &Path, - c_bins: &[(String, Vec)], - rust_bins: &[(String, Vec)], -) -> Result<(), String> { - let mut image = qemu::build_boot_image(test_config, c_bins, rust_bins, &[]); - let (at, len) = root_extent(&image)?; - for byte in &mut image[at..at + 4096] { - *byte = !*byte; - } - let backup = len / 4096 - 1; - let path = test_dir().join("root-superblock-unreadable.img"); - std::fs::write(&path, &image).map_err(|e| format!("write the boot image: {e}"))?; - let qemu = qemu::QemuInstance::boot_with_options( - test_config, - c_bins, - rust_bins, - BootOptions { - boot_image: Some(qemu::Staged::Written(path.clone())), - stick_read_error: Some(((at + len) / GPT_LBA - 1) as u64), - ready_marker: ROOT_REFUSED, - ..Default::default() - }, - ); - let log = format!("{}{}", qemu.boot_log(), qemu.uart_log()); - drop(qemu); - let _ = std::fs::remove_file(&path); - - let verdict = log - .lines() - .find(|l| l.contains(ROOT_REFUSED)) - .map(str::trim) - .ok_or_else(|| format!("the loader did not refuse an unreadable superblock:\n{}", volume_lines(&log)))?; - if !verdict.contains(&format!("the read of block {backup} of the TOYOS-ROOT candidate")) - || !verdict.contains("DEVICE_ERROR") - { - return Err(format!("the refusal does not name the superblock's read and the firmware's status: {verdict}")); + let read: Vec<&str> = log.lines().filter(|l| l.contains(READ_AT)).map(str::trim).collect(); + let from_the_table = format!("from LBA {}+", at / GPT_LBA); + match read[..] { + [one] if one.contains(&from_the_table) => eprintln!(" [root] {one}"), + _ => return Err(format!("the loader read {read:?}, where one read {from_the_table} is owed")), } - eprintln!(" [root] {verdict}"); Ok(()) } @@ -3558,9 +3479,15 @@ fn rewrite_gpt( Ok(()) } -/// The loader's refusal line: the first word a boot whose ROOT is refused -/// says, and so the marker the boot is waited on. -const ROOT_REFUSED: &str = "ROOT: REFUSED, "; +/// The loader's refusal of the image's one slot: the line a boot whose ROOT +/// is refused says, and so the marker the boot is waited on. +const ROOT_REFUSED: &str = "Slot A: REFUSED, "; + +/// The loader's line for the one ROOT it read into memory. +const READ_AT: &str = "ROOT: read into memory at"; + +/// The loader's line for a chunk of the slot's ROOT the disk would not read. +const CHUNK_REFUSED: &str = "Slot A: ROOT: the read of "; /// Boot an image whose ROOT the loader is expected to refuse, and hand back the /// log, which ends at the refusal. @@ -3588,7 +3515,7 @@ fn boot_expecting_root_refusal( /// The loader's refusal line, or what the boot said instead. fn root_refusal(log: &str) -> Result { log.lines() - .find(|l| l.contains(ROOT_REFUSED) && l.contains("TOYOS-ROOT partition")) + .find(|l| l.contains(ROOT_REFUSED)) .map(|l| l.trim().to_string()) .ok_or_else(|| format!("the loader did not refuse this ROOT set:\n{}", volume_lines(log))) } diff --git a/tests/e1000talkcase/system.toml b/tests/e1000talkcase/system.toml index 1bedfc16982..86d586bce48 100644 --- a/tests/e1000talkcase/system.toml +++ b/tests/e1000talkcase/system.toml @@ -18,10 +18,13 @@ service = true serves = ["netd"] devices = ["pci:8086:10d3"] -# `swap` too: the host replaces netd on this boot without rebooting it. [programs.sshd] service = true -receives = ["netd", "launcher", "swap"] +receives = ["netd", "launcher"] + +# The host replaces netd on this boot without rebooting it. +[programs.swap] +receives = ["swap"] [programs.test-runner] syscap = ["logread"] diff --git a/tests/flrswapcase/system.toml b/tests/flrswapcase/system.toml index f7112fbef3e..58d1b2700f1 100644 --- a/tests/flrswapcase/system.toml +++ b/tests/flrswapcase/system.toml @@ -20,7 +20,10 @@ devices = ["pci:8086:10d3", "pci:8086:10c9"] [programs.sshd] service = true -receives = ["netd", "launcher", "swap"] +receives = ["netd", "launcher"] + +[programs.swap] +receives = ["swap"] [programs.test-runner] syscap = ["logread"] diff --git a/tests/iced-counter/Cargo.lock b/tests/iced-counter/Cargo.lock index 9e1c46583fc..0c505a36315 100644 --- a/tests/iced-counter/Cargo.lock +++ b/tests/iced-counter/Cargo.lock @@ -2957,14 +2957,14 @@ dependencies = [ [[package]] name = "toyos" -version = "0.16.0" +version = "0.17.0" dependencies = [ "toyos-abi", ] [[package]] name = "toyos-abi" -version = "0.14.0" +version = "0.15.0" [[package]] name = "toyos-font" @@ -2976,7 +2976,7 @@ version = "0.1.0" [[package]] name = "toyos-window" -version = "0.17.0" +version = "0.18.0" dependencies = [ "toyos", "toyos-abi", diff --git a/tests/lantalkcase/system.toml b/tests/lantalkcase/system.toml index cff48ed353f..8893c117538 100644 --- a/tests/lantalkcase/system.toml +++ b/tests/lantalkcase/system.toml @@ -23,10 +23,13 @@ service = true serves = ["netd"] devices = ["pci:8086:15fc"] -# `swap` too: the host replaces netd on this boot without rebooting it. [programs.sshd] service = true -receives = ["netd", "launcher", "swap"] +receives = ["netd", "launcher"] + +# The host replaces netd on this boot without rebooting it. +[programs.swap] +receives = ["swap"] [programs.test-runner] syscap = ["logread"] diff --git a/tests/ssh-client-host/Cargo.lock b/tests/ssh-client-host/Cargo.lock index a93acc436ec..2e4831eb7f9 100644 --- a/tests/ssh-client-host/Cargo.lock +++ b/tests/ssh-client-host/Cargo.lock @@ -1848,7 +1848,7 @@ dependencies = [ [[package]] name = "toyos-abi" -version = "0.14.0" +version = "0.15.0" [[package]] name = "toyos-manifest" diff --git a/tests/ssh-client-host/src/main.rs b/tests/ssh-client-host/src/main.rs index 1cbd6f410f0..fc76e06031d 100644 --- a/tests/ssh-client-host/src/main.rs +++ b/tests/ssh-client-host/src/main.rs @@ -16,6 +16,8 @@ //! → exit | no-exit-status //! toyos_ssh feed //! → env , exit +//! toyos_ssh pipe +//! → exit | no-exit-status //! toyos_ssh abandon → ok //! toyos_ssh fire //! → accepted | refused | closed | silent @@ -24,9 +26,11 @@ //! toyos_ssh list → entry …, ok //! toyos_ssh swap //! → accepted | refused -//! | unanswered | no-subsystem -//! (after `accepted`, the channel is -//! held until stdin closes) +//! | unasked | unanswered +//! (`swap ` +//! over `exec`; after `accepted`, the +//! program's input is held until this +//! one's stdin closes) //! ``` //! //! A program's stdout and stderr go to files rather than to this process's own, @@ -89,7 +93,10 @@ async fn run(args: &[String]) -> Result<(), String> { exec(host, port, key, out, err, &command.join(" "), None).await } ["feed", host, port, key, out, err, stdin, command @ ..] => { - exec(host, port, key, out, err, &command.join(" "), Some(stdin)).await + exec(host, port, key, out, err, &command.join(" "), Some((stdin, true))).await + } + ["pipe", host, port, key, out, err, stdin, command @ ..] => { + exec(host, port, key, out, err, &command.join(" "), Some((stdin, false))).await } ["abandon", host, port, key, command @ ..] => { abandon(host, port, key, &command.join(" ")).await @@ -194,8 +201,8 @@ async fn auth(host: &str, port: &str, key: &str) -> Result<(), String> { /// Run a command and collect what came back on each of the channel's two /// streams. With `stdin`, the file's bytes are sent to the program first, and -/// an environment request is made before the exec so the caller learns what -/// the guest answers one with. +/// where it asks, an environment request is made before the exec so the caller +/// learns what the guest answers one with. async fn exec( host: &str, port: &str, @@ -203,7 +210,7 @@ async fn exec( out: &str, err: &str, command: &str, - stdin: Option<&str>, + stdin: Option<(&str, bool)>, ) -> Result<(), String> { let session = connect(host, port, key).await?; let mut channel = session @@ -211,10 +218,10 @@ async fn exec( .await .map_err(|e| format!("opening a session channel: {e}"))?; let feed = match stdin { - Some(path) => Some(std::fs::read(path).map_err(|e| format!("reading {path}: {e}"))?), + Some((path, _)) => Some(std::fs::read(path).map_err(|e| format!("reading {path}: {e}"))?), None => None, }; - if feed.is_some() { + if stdin.is_some_and(|(_, probe)| probe) { channel .set_env(true, "TOYOS_SSH_PROBE", "1") .await @@ -226,15 +233,19 @@ async fn exec( } } channel.exec(true, command).await.map_err(|e| format!("asking for {command:?}: {e}"))?; - if let Some(bytes) = feed { - channel - .data(&bytes[..]) - .await - .map_err(|e| format!("sending the program its input: {e}"))?; - } - // A program reading stdin sees the end of it here: either right away, or - // after the bytes above. - channel.eof().await.map_err(|e| format!("closing the program's input: {e}"))?; + // **The input is sent beside the reading, never before it**: a program + // that refuses its input and exits closes the channel with most of it + // unsent, and a send waiting on a window that closed channel will never + // open again is a client that never reads the status it was answered. + // A program reading stdin sees its end when the writer shuts down: right + // away, or after the bytes. + let mut writer = channel.make_writer(); + let feeding = tokio::spawn(async move { + if let Some(bytes) = feed { + writer.write_all(&bytes).await?; + } + writer.shutdown().await + }); let (mut stdout, mut stderr, mut status) = (Vec::new(), Vec::new(), None); while let Some(message) = channel.wait().await { @@ -245,6 +256,8 @@ async fn exec( _ => {} } } + // Whatever of the input the program never took is nobody's now. + feeding.abort(); write(out, &stdout)?; write(err, &stderr)?; match status { @@ -337,10 +350,14 @@ const SWAP_ANSWER: Duration = Duration::from_secs(60); /// Send `binary` as `service`'s replacement, naming `digest` for it, and /// report the guest's answer. /// -/// `digest` is the caller's and is sent as given — never computed here — so a -/// caller can name the wrong one and see it refused. **`unanswered` is an -/// answer too**: the service being swapped may be the one carrying this -/// connection, and whether it went is the machine's log's to say. +/// An ordinary `exec` of `swap ` with the binary on +/// its input. `digest` is the caller's and is sent as given — never computed +/// here — so a caller can name the wrong one and see it refused. **The input +/// is not closed after the binary**: the program answers, then waits for its +/// input to close — this program's stdin closing is the caller's go — because +/// the service being swapped may be the one carrying this connection. +/// `unanswered` is an answer too: whether it went is the machine's log's to +/// say. async fn swap( host: &str, port: &str, @@ -350,66 +367,61 @@ async fn swap( digest: &str, ) -> Result<(), String> { let body = std::fs::read(binary).map_err(|e| format!("reading {binary}: {e}"))?; - let digest = toyos_swap::parse_hex(digest) - .ok_or_else(|| format!("{digest:?} is not a SHA-256 in lowercase hex"))?; - let header = - toyos_swap::Header { service: service.to_string(), digest, len: body.len() as u64 }; + if toyos_swap::parse_hex(digest).is_none() { + return Err(format!("{digest:?} is not a SHA-256 in lowercase hex")); + } let session = connect(host, port, key).await?; let mut channel = session .channel_open_session() .await .map_err(|e| format!("opening a session channel: {e}"))?; - channel - .request_subsystem(true, toyos_swap::SUBSYSTEM) - .await - .map_err(|e| format!("asking for the {} subsystem: {e}", toyos_swap::SUBSYSTEM))?; - match channel.wait().await { - Some(ChannelMsg::Success) => {} - Some(ChannelMsg::Failure) => { - println!("no-subsystem"); - drop(session); - return Ok(()); - } - other => return Err(format!("the subsystem request was answered {other:?}")), - } - let mut sent = header.render().into_bytes(); - sent.extend_from_slice(&body); - channel.data(&sent[..]).await.map_err(|e| format!("sending the binary: {e}"))?; - channel.eof().await.map_err(|e| format!("ending the binary: {e}"))?; - let (mut stdout, mut status) = (Vec::new(), None); + let command = format!("swap {service} {digest} {}", body.len()); + channel.exec(true, command.as_str()).await.map_err(|e| format!("asking for {command:?}: {e}"))?; + channel.data(&body[..]).await.map_err(|e| format!("sending the binary: {e}"))?; + let (mut stdout, mut status, mut line) = (Vec::new(), None, None); let answered = tokio::time::timeout(SWAP_ANSWER, async { - // Until the guest's EOF, which follows its answer: the guest leaves - // the channel open for this program to close, and that close is the go. + // Until the answer's newline, or the channel's end for a program that + // exited without one. while let Some(message) = channel.wait().await { match message { - ChannelMsg::Data { data } => stdout.extend_from_slice(&data), + ChannelMsg::Data { data } => { + stdout.extend_from_slice(&data); + if let Some(end) = stdout.iter().position(|&b| b == b'\n') { + line = Some(String::from_utf8_lossy(&stdout[..end]).to_string()); + return; + } + } ChannelMsg::ExitStatus { exit_status } => status = Some(exit_status), - ChannelMsg::Eof => break, + ChannelMsg::Eof | ChannelMsg::Close => return, _ => {} } } }) .await; - let said = String::from_utf8_lossy(&stdout).trim_end().to_string(); - match (answered, status, said.split_once(' ')) { - (Ok(()), Some(0), Some(("accepted", _))) => { + match (answered, &line) { + (Ok(()), Some(said)) if said.starts_with("accepted ") => { println!("{said}"); - // **The close below is the go**: the guest stops the service that - // may be carrying this connection once it has it, so the caller — - // whose own connections that service may carry too — says when, by - // closing this program's stdin. + // **The program's input closing is the go**: it stops waiting and + // exits, and init stops the old service when it hangs up. The + // caller — whose own connections that service may carry too — says + // when, by closing this program's stdin. tokio::task::spawn_blocking(|| std::io::Read::read_to_end(&mut std::io::stdin(), &mut Vec::new())) .await .map_err(|e| format!("waiting for the go: {e}"))? .map_err(|e| format!("reading the go: {e}"))?; + let _ = channel.eof().await; } - (Ok(()), Some(1), Some(("refused", _))) => println!("{said}"), - (Ok(()), _, _) => println!("unanswered the channel closed after {said:?}, status {status:?}"), - (Err(_), _, _) => println!("unanswered nothing in {}s after {said:?}", SWAP_ANSWER.as_secs()), + (Ok(()), Some(said)) if said.starts_with("refused ") || said.starts_with("unasked ") => println!("{said}"), + (Ok(()), _) => println!( + "unanswered the channel ended after {:?}, status {status:?}", + String::from_utf8_lossy(&stdout) + ), + (Err(_), _) => println!( + "unanswered nothing in {}s after {:?}", + SWAP_ANSWER.as_secs(), + String::from_utf8_lossy(&stdout) + ), } - // The guest waits for this close before it stops the service that may be - // carrying this connection: it is this client saying it has the answer. - let _ = channel.close().await; // Dropped rather than disconnected: the connection may be gone with the // service that carried it. drop(session); diff --git a/tests/swapcase/system.toml b/tests/swapcase/system.toml index 1c85d1576c5..b13829995da 100644 --- a/tests/swapcase/system.toml +++ b/tests/swapcase/system.toml @@ -1,6 +1,6 @@ # A running service's binary replaced with no reboot, rehearsed on QEMU's -# virtio-net: `logd` streams every record to the host's listener, sshd takes the -# swap on an authenticated channel and hands it to init, and netd is the +# virtio-net: `logd` streams every record to the host's listener, sshd runs +# `swap` for an authenticated login and it hands the binary to init, and netd is the # service swapped — the one whose restart the stream and the ssh connection # asking for it both have to outlive. # @@ -22,11 +22,14 @@ service = true serves = ["netd"] devices = ["pci:1af4:1041"] -# `swap` is the authority to replace a service's binary, and no other program -# may hold it. [programs.sshd] service = true -receives = ["netd", "launcher", "swap"] +receives = ["netd", "launcher"] + +# `swap` is the authority to replace a service's binary, and no other program +# may hold it: the host runs it over ssh. +[programs.swap] +receives = ["swap"] [programs.test-runner] syscap = ["logread"] diff --git a/tests/toyos-rust-tests/Cargo.lock b/tests/toyos-rust-tests/Cargo.lock index e8f3b17fbe9..facdf122562 100644 --- a/tests/toyos-rust-tests/Cargo.lock +++ b/tests/toyos-rust-tests/Cargo.lock @@ -2084,14 +2084,14 @@ dependencies = [ [[package]] name = "toyos" -version = "0.16.0" +version = "0.17.0" dependencies = [ "toyos-abi", ] [[package]] name = "toyos-abi" -version = "0.14.0" +version = "0.15.0" [[package]] name = "toyos-blockhold" @@ -2180,7 +2180,7 @@ version = "0.1.0" [[package]] name = "toyos-window" -version = "0.17.0" +version = "0.18.0" dependencies = [ "toyos", "toyos-abi", diff --git a/tests/toyos-rust-tests/src/bin/boot_volume_metadata_error.rs b/tests/toyos-rust-tests/src/bin/boot_volume_metadata_error.rs index c842e999935..b20894d5a63 100644 --- a/tests/toyos-rust-tests/src/bin/boot_volume_metadata_error.rs +++ b/tests/toyos-rust-tests/src/bin/boot_volume_metadata_error.rs @@ -22,13 +22,13 @@ use std::fs; /// Written by the image builder, so it is on every boot volume this project -/// produces and it is the file `esp_files` reads back byte for byte. -const KERNEL: &str = "/boot/toyos/kernel.elf"; +/// produces. +const NAMED: &str = "/boot/toyos/log.guid"; fn main() { // The defect in one line: this used to be `Option`, and `None` was both // "no such file" and "the volume would not say". - match fs::File::open(KERNEL) { + match fs::File::open(NAMED) { Ok(_) => println!("boot-io: open succeeded"), Err(e) => println!("boot-io: open failed: kind={:?}: {e}", e.kind()), } diff --git a/tests/toyos-rust-tests/src/bin/esp_files.rs b/tests/toyos-rust-tests/src/bin/esp_files.rs index 19567d303ce..1811f95f287 100644 --- a/tests/toyos-rust-tests/src/bin/esp_files.rs +++ b/tests/toyos-rust-tests/src/bin/esp_files.rs @@ -10,11 +10,12 @@ //! `/boot` is read-only toward userland and the write direction is exercised //! on `/log`, which is the same adapter over the same driver. That split is //! the point rather than an accident of where the files went: `/boot` is what -//! firmware and the bootloader read the machine out of, and it had no -//! permission model at all — `fs::write("/boot/toyos/kernel.elf", "TEETH")` -//! from an ordinary process truncated the kernel image to five bytes. The -//! host's byte-for-byte check of the build artifacts is the other half of -//! that; this half is the attack. +//! firmware reads the loader off, and it had no permission model at all — +//! `fs::write("/boot/toyos/kernel.elf", "TEETH")` from an ordinary process +//! truncated the kernel image to five bytes, when the kernel lived here. The +//! loader is the one binary left on it and the one no signature covers, so it +//! is the file attacked. The host's byte-for-byte check of the build artifacts +//! is the other half of that; this half is the attack. use std::fs; use std::io::Write; @@ -34,8 +35,9 @@ const GUEST_BLOB: &str = "/log/guest-blob.bin"; /// off-by-one in the size bookkeeping gets wrong. const BLOB_LEN: usize = 10 * 4096 + 137; -/// The file whose truncation is the reason `/boot` has a permission model. -const KERNEL: &str = "/boot/toyos/kernel.elf"; +/// The file whose truncation is the reason `/boot` has a permission model: +/// the loader, which firmware runs and nothing verifies. +const LOADER: &str = "/boot/EFI/BOOT/BOOTx64.EFI"; fn blob() -> Vec { (0..BLOB_LEN).map(|i| (i.wrapping_mul(97) ^ 0x5A) as u8).collect() @@ -50,13 +52,13 @@ fn names(dir: &str) -> Vec { out } -/// The first 64 bytes of `kernel.elf`; a plain WRITE at offset 0 shows in content, not length. -fn kernel_prefix() -> [u8; 64] { - let h = syscall::open(KERNEL.as_bytes(), OpenFlags::READ).expect("open kernel.elf for read"); +/// The first 64 bytes of the loader; a plain WRITE at offset 0 shows in content, not length. +fn loader_prefix() -> [u8; 64] { + let h = syscall::open(LOADER.as_bytes(), OpenFlags::READ).expect("open the loader for read"); let mut buf = [0u8; 64]; - let n = syscall::read(h, &mut buf).expect("read kernel.elf prefix"); + let n = syscall::read(h, &mut buf).expect("read the loader's prefix"); syscall::close(h); - assert_eq!(n, buf.len(), "short read of kernel.elf prefix"); + assert_eq!(n, buf.len(), "short read of the loader's prefix"); buf } @@ -70,7 +72,7 @@ fn main() { // The bootloader's own directory, which firmware and the build both put // there — so a listing that misses it is a listing, not a namespace. let toyos = names("/boot/toyos"); - for want in ["kernel.elf", "log.guid", "host-note.txt"] { + for want in ["log.guid", "host-note.txt"] { assert!(toyos.iter().any(|n| n == want), "/boot/toyos has {toyos:?}, wanted {want}"); } let root = names("/boot"); @@ -94,23 +96,23 @@ fn main() { /// /// One per syscall rather than one representative, because they are separate /// entry points and a gate on `open` alone would have said nothing about -/// `unlink` or `rename`. The truncation of `kernel.elf` is first because it is -/// the one that was actually done, by a guest test, to a real image. +/// `unlink` or `rename`. The truncation is first because it is the one that was +/// actually done, by a guest test, to a real image. fn boot_refuses_every_way_of_changing_it() { - let before = fs::metadata(KERNEL).expect("stat the kernel image").len(); - assert!(before > 4096, "the kernel image is {before} bytes before we start"); + let before = fs::metadata(LOADER).expect("stat the loader").len(); + assert!(before > 4096, "the loader is {before} bytes before we start"); - let err = fs::write(KERNEL, "TEETH").expect_err("truncating the kernel image was permitted"); - println!(" PASS writing {KERNEL} is refused: {err}"); - let after = fs::metadata(KERNEL).expect("stat the kernel image again").len(); - assert_eq!(after, before, "the refused write changed the kernel image's length"); + let err = fs::write(LOADER, "TEETH").expect_err("truncating the loader was permitted"); + println!(" PASS writing {LOADER} is refused: {err}"); + let after = fs::metadata(LOADER).expect("stat the loader again").len(); + assert_eq!(after, before, "the refused write changed the loader's length"); fs::write("/boot/toyos/new-file.txt", "x").expect_err("creating a file on /boot was permitted"); fs::remove_file(HOST_NOTE).expect_err("deleting a file on /boot was permitted"); fs::create_dir("/boot/toyos/newdir").expect_err("mkdir on /boot was permitted"); fs::rename(HOST_NOTE, "/boot/toyos/moved.txt").expect_err("rename on /boot was permitted"); assert!( - toyos_abi::syscall::symlink(b"/boot/toyos/kernel.elf", b"/boot/toyos/link").is_err(), + toyos_abi::syscall::symlink(b"/boot/EFI/BOOT/BOOTx64.EFI", b"/boot/toyos/link").is_err(), "symlink on /boot was permitted" ); @@ -126,22 +128,22 @@ fn boot_refuses_every_way_of_changing_it() { println!(" PASS create, delete, mkdir, rename and symlink are all refused on /boot"); // The path checked must be the path opened, and plain WRITE is the hole: CREATE/TRUNCATE unlink the link. - let before = kernel_prefix(); - assert_eq!(&before[..4], b"\x7fELF", "kernel.elf is not an ELF before the symlink attack"); - syscall::symlink(b"../boot/toyos/kernel.elf", b"/tmp/evil").expect("a /tmp symlink is allowed"); + let before = loader_prefix(); + assert_eq!(&before[..2], b"MZ", "the loader is not a PE image before the symlink attack"); + syscall::symlink(b"../boot/EFI/BOOT/BOOTx64.EFI", b"/tmp/evil").expect("a /tmp symlink is allowed"); assert!( syscall::open(b"/tmp/evil", OpenFlags::WRITE).is_err(), - "a /tmp symlink opened {KERNEL} for writing", + "a /tmp symlink opened {LOADER} for writing", ); - let reader = syscall::open(KERNEL.as_bytes(), OpenFlags::READ).expect("read /boot is allowed"); + let reader = syscall::open(LOADER.as_bytes(), OpenFlags::READ).expect("read /boot is allowed"); assert!( syscall::open(b"/tmp/evil", OpenFlags::WRITE).is_err(), - "a /tmp symlink opened {KERNEL} for writing while a /boot read handle was held", + "a /tmp symlink opened {LOADER} for writing while a /boot read handle was held", ); syscall::close(reader); - assert_eq!(kernel_prefix(), before, "a refused symlink write still changed kernel.elf"); - println!(" PASS a /tmp symlink to {KERNEL} is refused for writing"); + assert_eq!(loader_prefix(), before, "a refused symlink write still changed the loader"); + println!(" PASS a /tmp symlink to {LOADER} is refused for writing"); } /// The write direction, on the volume userland is allowed to have. diff --git a/tests/toyos.rs b/tests/toyos.rs index d8bc14a348f..2da449c7b7d 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -821,6 +821,21 @@ const MACHINE_TESTS: &[(&str, Sched, Tier)] = &[ // guest's own `/log`; every clock is a liveness guard on a guest that // stopped talking, and probation is init's. ("swap_netd", Sched::Parallel, Tier::Fast), + // The machine updates itself: an image over `ssh … update` is written to + // the idle slot and is the kernel the next boot runs; every slot the loader + // must refuse is refused by name and the other boots; and a slot whose + // kernel dies falls back on its own, its death in the next boot's `/log`. + // The same machine's floor, grant and hang: a floor is its key's and its + // image's, init grants nothing the slot table names but an idle slot's + // partition, and a hang of an unproven image is a death. Each is 25 s or + // more of boots, past the fast tier's line, so they are nightly. + ("update_boots_the_new_kernel", Sched::Parallel, Tier::Nightly), + ("update_refusals_boot_the_other_slot", Sched::Parallel, Tier::Nightly), + ("update_falls_back_from_a_dying_kernel", Sched::Parallel, Tier::Nightly), + ("update_hang_kills_an_unproven_image", Sched::Parallel, Tier::Nightly), + ("update_grant_refuses_a_stray_partition", Sched::Parallel, Tier::Nightly), + ("update_floor_is_the_images_own", Sched::Parallel, Tier::Nightly), + ("update_refused_pass_credits_no_image", Sched::Parallel, Tier::Nightly), ("lan_swap", Sched::Parallel, Tier::Fast), ("swap_refusals", Sched::Parallel, Tier::Fast), ("swap_crash_rolls_back", Sched::Parallel, Tier::Fast), @@ -1459,19 +1474,16 @@ const MACHINE_TESTS: &[(&str, Sched, Tier)] = &[ ("log_backing_read_error", Sched::Parallel, Tier::Fast), ("boot_volume_metadata_error", Sched::Parallel, Tier::Fast), ("log_partition_layout", Sched::Parallel, Tier::Fast), - // What the loader does with a ROOT set: a bad candidate, an absent name, an - // overlapping candidate, a twin on the boot disk, an unreadable superblock - // and an unreadable chunk refused by name, a bad primary superblock read - // past to its backup, and a twin on another disk never read. Serial, not by - // association: each stages a whole boot image, one a second 32 GiB stick - // beside it. + // What the loader does with a slot's ROOT: bytes its signature does not + // cover, a parameter naming another, an overlapping partition and an + // unreadable chunk refused by name, and a twin on the boot disk or on + // another disk never read. Serial, not by association: each stages a whole + // boot image, one a second 32 GiB stick beside it. ("root_candidate_malformed", Sched::Serial, Tier::Fast), ("root_named_but_absent", Sched::Serial, Tier::Fast), ("root_chunk_refused", Sched::Serial, Tier::Fast), ("root_candidate_overlaps", Sched::Serial, Tier::Fast), ("root_named_twice_on_the_boot_disk", Sched::Serial, Tier::Fast), - ("root_backup_superblock", Sched::Serial, Tier::Fast), - ("root_superblock_unreadable", Sched::Serial, Tier::Fast), ("root_named_twice", Sched::Serial, Tier::Nightly), ("log_partition_identity", Sched::Parallel, Tier::Nightly), ("cache_eviction", Sched::Parallel, Tier::Nightly), @@ -1592,6 +1604,15 @@ const MACHINE_TESTS: &[(&str, Sched, Tier)] = &[ /// naming this table, and a row naming what the suite did not build panics /// before the boot. const CARRIES: &[(&str, &[&str])] = &[ + // Each stages its own machine and carries no test binary: what is under + // test is the image itself. + ("update_boots_the_new_kernel", &[]), + ("update_refusals_boot_the_other_slot", &[]), + ("update_falls_back_from_a_dying_kernel", &[]), + ("update_hang_kills_an_unproven_image", &[]), + ("update_grant_refuses_a_stray_partition", &[]), + ("update_floor_is_the_images_own", &[]), + ("update_refused_pass_credits_no_image", &[]), ("blocking_read_window", &["test_rs_blocking_read_stress"]), ("writeback_reopen", &["test_rs_writeback_reopen"]), ("writeback_spawn", &["test_rs_writeback_spawn"]), @@ -4647,16 +4668,29 @@ fn run_screen_test( )); } let printed = last - query; + // The rows those lines take on the firmware's console, whose glyph is + // eight pixels wide (UEFI 2.11 §12.9, `EFI_GLYPH_WIDTH`): a line + // wider than the mode's columns — the root bridges' descriptor dump + // is one — wraps onto a row per width it fills. + let columns = lines[query] + .split("GOP: mode ") + .nth(1) + .and_then(|mode| mode.split('x').next()) + .and_then(|width| width.parse::().ok()) + .map(|width| width / 8) + .filter(|&columns| columns > 0) + .ok_or_else(|| format!("the GOP line names no mode width: {:?}", lines[query]))?; + let rows: usize = lines[query + 1..=last].iter().map(|line| line.len().div_ceil(columns).max(1)).sum(); // A range and not an equality: each panel is dumped after its marker // reached the console, so a line drawn in between is on the panel // and not in the count. let grew = after as i64 - before as i64; - if !(1..=printed as i64).contains(&grew) { + if !(1..=rows as i64).contains(&grew) { return Err(format!( "the panel carried {before} rows at the GOP query and {after} at the loader's \ last line, a growth of {grew}, where the loader printed {printed} lines \ - between them\n{console}" + between them, {rows} rows at {columns} columns\n{console}" )); } eprintln!( @@ -11841,10 +11875,6 @@ fn run_machine_test( "root_named_twice_on_the_boot_disk" => { common::volumes::root_named_twice_on_the_boot_disk(test_config, c_bins, rust_bins) } - "root_backup_superblock" => common::volumes::root_backup_superblock(test_config, c_bins, rust_bins), - "root_superblock_unreadable" => { - common::volumes::root_superblock_unreadable(test_config, c_bins, rust_bins) - } "root_named_twice" => { common::volumes::root_named_twice(test_config, c_bins, rust_bins) } @@ -15379,6 +15409,25 @@ fn run_machine_test( "lan_talk" => lan::lan_talk(test_config, c_bins, rust_bins), "lan_mdns_answer" => common::origin::mdns(c_bins, rust_bins), "swap_netd" => common::swap::swap_netd(test_config, c_bins, rust_bins), + "update_boots_the_new_kernel" => common::update::update_boots_the_new_kernel(test_config, c_bins, rust_bins), + "update_refusals_boot_the_other_slot" => { + common::update::update_refusals_boot_the_other_slot(test_config, c_bins, rust_bins) + } + "update_falls_back_from_a_dying_kernel" => { + common::update::update_falls_back_from_a_dying_kernel(test_config, c_bins, rust_bins) + } + "update_hang_kills_an_unproven_image" => { + common::update::update_hang_kills_an_unproven_image(test_config, c_bins, rust_bins) + } + "update_grant_refuses_a_stray_partition" => { + common::update::update_grant_refuses_a_stray_partition(test_config, c_bins, rust_bins) + } + "update_floor_is_the_images_own" => { + common::update::update_floor_is_the_images_own(test_config, c_bins, rust_bins) + } + "update_refused_pass_credits_no_image" => { + common::update::update_refused_pass_credits_no_image(test_config, c_bins, rust_bins) + } "lan_swap" => common::swap::lan_swap(test_config, c_bins, rust_bins), "swap_refusals" => common::swap::swap_refusals(test_config, c_bins, rust_bins), "swap_crash_rolls_back" => common::swap::swap_crash_rolls_back(test_config, c_bins, rust_bins), diff --git a/tests/updatecase/system.toml b/tests/updatecase/system.toml new file mode 100644 index 00000000000..ca95cc23d4f --- /dev/null +++ b/tests/updatecase/system.toml @@ -0,0 +1,36 @@ +# The machine updates itself, rehearsed in QEMU: sshd runs `update` for the +# host's login with the image on its input, `update` writes the slot this boot +# is not running, and the reboot after it boots that slot — or, where the loader +# refuses it or it dies, the other one. `tests/common/update.rs` stages every +# image and judges every boot. + +[boot] +start = ["logd", "netd", "sshd", "test-runner"] + +[programs.logd] +service = true +syscap = ["logread"] + +[programs.netd] +service = true +serves = ["netd"] +devices = ["pci:1af4:1041"] + +[programs.sshd] +service = true +receives = ["netd", "launcher"] + +# The idle slot, which init claims against the ROOT the kernel holds; the +# owner's signature on the image is the authority to install it. +[programs.update] +slots = true + +[programs.test-runner] +syscap = ["logread"] + +# `reboot` is how the host hands the machine to the slot `update` marked. +[programs.toybox] +syscap = ["power"] + +[symlinks] +"bin/reboot" = "/system/bin/toybox" diff --git a/toyos-abi/Cargo.toml b/toyos-abi/Cargo.toml index 724ce7f88c7..6358951b77d 100644 --- a/toyos-abi/Cargo.toml +++ b/toyos-abi/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toyos-abi" -version = "0.14.0" +version = "0.15.0" edition = "2021" license = "MIT OR Apache-2.0" description = "The ToyOS kernel ABI: syscall numbers, struct layouts and typed syscall wrappers." diff --git a/toyos-abi/src/boot.rs b/toyos-abi/src/boot.rs index 11a46d72b72..1c1c40a5050 100644 --- a/toyos-abi/src/boot.rs +++ b/toyos-abi/src/boot.rs @@ -137,6 +137,17 @@ pub struct KernelArgs { /// negative control on the kernel's refusal, and read by both of them. pub const WITHHOLD_ROOT_PARAM: &str = "loader-withholds-root"; +/// The word the loader appends naming the slot it booted, `A` or `B`: the +/// signed boot parameter is the slot's own, and which slot carried it is a +/// fact only the loader has. +pub const SLOT_PARAM: &str = "boot-slot="; + +/// The word the loader appends when it booted the other slot than the one the +/// slot table marks: the marked slot and why it was refused, `B:died`. The +/// kernel logs it, which is how a dead or refused boot reaches the next boot's +/// log. +pub const SLOT_REFUSED_PARAM: &str = "slot-refused="; + /// The most windows the loader will carry. pub const MAX_ROOT_BRIDGE_WINDOWS: usize = 64; diff --git a/toyos-gpt/src/guid.rs b/toyos-gpt/src/guid.rs index 5731e946605..7c1ec1bd362 100644 --- a/toyos-gpt/src/guid.rs +++ b/toyos-gpt/src/guid.rs @@ -65,6 +65,30 @@ impl Guid { pub const TOYOS_DATA_TEXT: &'static str = "064E3777-5076-4C71-8E07-90AD24CFE8D6"; + /// `94464329-E06E-4288-A9DA-7FC7154F5E92` — the slot table's partition + /// type: which partitions make each slot, and which slot is marked + /// (`toyos_update::slots`). A disk carries one. + pub const TOYOS_SLOTS: Self = Self::from_fields( + 0x9446_4329, + 0xE06E, + 0x4288, + [0xA9, 0xDA, 0x7F, 0xC7, 0x15, 0x4F, 0x5E, 0x92], + ); + + pub const TOYOS_SLOTS_TEXT: &'static str = "94464329-E06E-4288-A9DA-7FC7154F5E92"; + + /// `037719D7-DEA5-481A-AA07-6AF8BE6D51E2` — a slot's FAT partition: its + /// kernel, its boot parameter and its signed header. A *type*, so it + /// selects nothing; the slot table names each one by its unique GUID. + pub const TOYOS_BOOT: Self = Self::from_fields( + 0x0377_19D7, + 0xDEA5, + 0x481A, + [0xAA, 0x07, 0x6A, 0xF8, 0xBE, 0x6D, 0x51, 0xE2], + ); + + pub const TOYOS_BOOT_TEXT: &'static str = "037719D7-DEA5-481A-AA07-6AF8BE6D51E2"; + pub const fn from_fields(a: u32, b: u16, c: u16, d: [u8; 8]) -> Self { let a = a.to_le_bytes(); let b = b.to_le_bytes(); @@ -133,6 +157,10 @@ mod tests { assert_eq!(&text[..], Guid::TOYOS_ROOT_TEXT.as_bytes()); let text = heapless_format(Guid::TOYOS_DATA); assert_eq!(&text[..], Guid::TOYOS_DATA_TEXT.as_bytes()); + let text = heapless_format(Guid::TOYOS_SLOTS); + assert_eq!(&text[..], Guid::TOYOS_SLOTS_TEXT.as_bytes()); + let text = heapless_format(Guid::TOYOS_BOOT); + assert_eq!(&text[..], Guid::TOYOS_BOOT_TEXT.as_bytes()); assert_ne!(Guid::TOYOS_ROOT, Guid::TOYOS_DATA); } diff --git a/toyos-manifest/src/lib.rs b/toyos-manifest/src/lib.rs index b65a479f850..3ac58ee5cbb 100644 --- a/toyos-manifest/src/lib.rs +++ b/toyos-manifest/src/lib.rs @@ -20,6 +20,7 @@ //! receive a connector in this program's namespace //! device a claim init mints and endows //! syscap a right on the SysCap dup init endows +//! slots the idle slot's partitions and the slot table, claimed by init //! service a system service: its `HOME` is `/state/`, not the session's //! init-serve a name init serves itself //! start init starts this program at boot @@ -135,6 +136,13 @@ pub struct Program { /// the system may enter the RT band, mint a device claim, read the machine /// log, list every process in the machine, or power the machine off. pub syscap: Vec, + /// The machine's idle slot, granted as claims: the slot table's partition + /// and the idle slot's FAT volume and ROOT, which init resolves against + /// the ROOT the kernel holds and mints (`toyos_update::slots`). The + /// authority to write the next image and nothing else: the slot a boot + /// runs is never among them. One program holds it — `src/build.rs` gates + /// which. + pub slots: bool, /// A system service: its `HOME` is its own [`STATE`] directory rather /// than the session user's home, so what it keeps is machine state and no /// user's. @@ -255,6 +263,9 @@ pub fn render(manifest: &Manifest) -> Result, RenderError> { out.push_str(&format!("{word} {value}\n")); } } + if program.slots { + out.push_str("slots\n"); + } if program.service { out.push_str("service\n"); } @@ -340,6 +351,7 @@ pub fn parse(text: &str) -> Manifest { "receive" => program.receives.push(rest.to_string()), "device" => program.devices.push(rest.to_string()), "syscap" => program.syscap.push(rest.to_string()), + "slots" if rest.is_empty() => program.slots = true, "service" => program.service = true, other => panic!("manifest: unknown record `{other}`"), } @@ -374,6 +386,12 @@ mod tests { service: true, ..Program::default() }, + Program { + name: "update".into(), + path: "/system/bin/update".into(), + slots: true, + ..Program::default() + }, Program { name: "terminal".into(), path: "/system/bin/terminal".into(), diff --git a/toyos-rootimage/src/lib.rs b/toyos-rootimage/src/lib.rs index 56362e2df72..d5daa0f5616 100644 --- a/toyos-rootimage/src/lib.rs +++ b/toyos-rootimage/src/lib.rs @@ -1,7 +1,7 @@ //! ROOT in memory: every decision the loader and the kernel make about the -//! image, pure. The loader [`pick`]s the one partition the boot parameter -//! names and reads it in bounded [`chunk`]s; the kernel keeps the extent it is -//! handed only once [`handoff`] finds it inside memory the loader allocated. +//! image, pure. The loader reads the partition its slot names in bounded +//! [`chunk`]s; the kernel keeps the extent it is handed only once [`handoff`] +//! finds it inside memory the loader allocated. //! //! No `alloc`, no firmware, no `unsafe`: the loader and the kernel supply the //! device, the candidates and the map. @@ -11,4 +11,3 @@ pub mod chunk; pub mod handoff; -pub mod pick; diff --git a/toyos-rootimage/src/pick.rs b/toyos-rootimage/src/pick.rs deleted file mode 100644 index f7576f44b11..00000000000 --- a/toyos-rootimage/src/pick.rs +++ /dev/null @@ -1,30 +0,0 @@ -//! Which partition is ROOT: the one candidate whose filesystem is the one the -//! boot parameter names. None, or more than one, is a boot refused by name, -//! and so is a table carrying more candidates than the loader looked at, -//! because a match among the ones it did not look at would go unseen. - -/// Why the boot disk names no one ROOT. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Refused { - /// The table carries `matched` candidates and the loader looked at `listed`. - Unlisted { matched: u32, listed: usize }, - /// `matches` of the `candidates` carry the filesystem named, and only one is an answer. - Matches { matches: usize, candidates: usize }, -} - -/// The one candidate whose filesystem is `named`. -/// -/// `candidates` are every candidate the loader looked at, each with the name -/// its filesystem carries, `None` for one that carries none it can read; -/// `matched` is how many the table carries. -pub fn pick(named: &U, candidates: &[(P, Option)], matched: u32) -> Result { - if usize::try_from(matched).map_or(true, |matched| matched > candidates.len()) { - return Err(Refused::Unlisted { matched, listed: candidates.len() }); - } - let named_by = |(_, uuid): &&(P, Option)| uuid.as_ref() == Some(named); - let matches = candidates.iter().filter(named_by).count(); - match candidates.iter().find(named_by) { - Some(&(one, _)) if matches == 1 => Ok(one), - _ => Err(Refused::Matches { matches, candidates: candidates.len() }), - } -} diff --git a/toyos-rootimage/tests/pick.rs b/toyos-rootimage/tests/pick.rs deleted file mode 100644 index 97f41e6eaab..00000000000 --- a/toyos-rootimage/tests/pick.rs +++ /dev/null @@ -1,37 +0,0 @@ -//! The pick: one candidate carrying the name is the answer, and every other -//! count, or a table the loader did not look at whole, is a refusal saying which. - -use toyos_rootimage::pick::{pick, Refused}; - -const ROOT: u32 = 0xA; -const OTHER: u32 = 0xB; - -#[test] -fn no_candidate_names_it() { - let candidates = [(1, Some(OTHER)), (2, None)]; - assert_eq!(pick(&ROOT, &candidates, 2), Err(Refused::Matches { matches: 0, candidates: 2 })); -} - -#[test] -fn an_empty_table_names_nothing() { - let candidates: [(u32, Option); 0] = []; - assert_eq!(pick(&ROOT, &candidates, 0), Err(Refused::Matches { matches: 0, candidates: 0 })); -} - -#[test] -fn one_candidate_names_it_among_others() { - let candidates = [(1, Some(OTHER)), (2, None), (3, Some(ROOT))]; - assert_eq!(pick(&ROOT, &candidates, 3), Ok(3)); -} - -#[test] -fn two_candidates_name_it_and_neither_is_picked() { - let candidates = [(1, Some(ROOT)), (2, Some(OTHER)), (3, Some(ROOT))]; - assert_eq!(pick(&ROOT, &candidates, 3), Err(Refused::Matches { matches: 2, candidates: 3 })); -} - -#[test] -fn a_table_with_more_candidates_than_were_looked_at_is_refused() { - let candidates = [(1, Some(ROOT)), (2, Some(OTHER))]; - assert_eq!(pick(&ROOT, &candidates, 3), Err(Refused::Unlisted { matched: 3, listed: 2 })); -} diff --git a/toyos-swap/src/lib.rs b/toyos-swap/src/lib.rs index 8355a0ec85a..7da962816ea 100644 --- a/toyos-swap/src/lib.rs +++ b/toyos-swap/src/lib.rs @@ -1,23 +1,26 @@ -//! Replacing a running service's binary: every word sshd, `/system/bin/init` -//! and the host say about it, and every decision init makes about one. Pure. +//! Replacing a running service's binary: every word `/system/bin/swap`, +//! `/system/bin/init` and the host say about it, and every decision init makes +//! about one. Pure. //! -//! **A swap is asked over ssh and done by init, and nothing else can do -//! either.** sshd serves the [`SUBSYSTEM`] on an authenticated channel only; it -//! stages the bytes it was sent under [`STAGING`] and asks init over the -//! [`PORT`] init serves, which the build gate lets no program but sshd -//! receive. init is the only process holding the system capability, so it is -//! the only one that can stop a service, give its device claims back and start -//! it again holding exactly its manifest row. +//! **A swap is asked by `/system/bin/swap` and done by init, and nothing else +//! can do either.** `swap` is an ordinary program — `ssh swap netd +//! < netd` runs it over a plain `exec` channel, for a login sshd has already +//! authenticated — and it stages the bytes it was given under [`STAGING`] and +//! asks init over the [`PORT`] init serves, which the build gate lets no +//! program but [`HOLDER`] receive. init is the only process holding the system +//! capability, so it is the only one that can stop a service, give its device +//! claims back and start it again holding exactly its manifest row. //! //! **The order is fixed and every refusal leaves the old service running.** //! init reads the staged file, holds its bytes against the requester's //! [`Digest`] ([`verify`]), writes them to the [`installed_path`] — a //! temporary name and one rename, so the place a service is started from is //! never half-written — answers [`MSG_ACCEPTED`], and waits for the requester -//! to hang up, at most [`HANGUP_MS`]. sshd hangs up once its client has closed -//! the channel the answer came on, which is that client's proof it has the -//! answer: the service being swapped may be the one carrying it, and is not -//! stopped before it has arrived. Then init stops the old +//! to hang up, at most [`HANGUP_MS`]. `swap` hangs up once its caller has closed +//! its input, or [`ANSWER_MS`] after its answer — the close is the caller's +//! proof it has the answer: the service being swapped may be +//! the one carrying it, and is not stopped before it has arrived. Then init +//! stops the old //! process, starts the new binary, and holds it on probation for //! [`PROBATION_MS`]. A binary that does not spawn, is refused a device the //! process it replaces held, or ends inside probation, is [`Word::Failed`], and @@ -42,18 +45,15 @@ pub const PORT: &str = "swap"; /// name in it is a name whatever the holder runs undeclared would hold too. pub const LABEL: &str = "swap"; -/// The one program a build may let receive [`PORT`]: it serves the request -/// only on a channel whose key it has already accepted. -pub const HOLDER: &str = "sshd"; - -/// The SSH subsystem a client opens to ask for a swap. -pub const SUBSYSTEM: &str = "toyos-swap"; +/// The one program a build may let receive [`PORT`]: `/system/bin/swap`, +/// which a login runs and nothing starts at boot. +pub const HOLDER: &str = "swap"; /// Where a swap's bytes are staged and where a swapped binary is started from. pub const STAGING: &str = "/tmp/swap"; -/// The largest binary a swap carries. A bound on the memory sshd and init each -/// spend on one request; a service binary in this tree is a few megabytes. +/// The largest binary a swap carries. A bound on the memory `swap` and init +/// each spend on one request; a service binary in this tree is a few megabytes. pub const MAX_BINARY_BYTES: u64 = 64 * 1024 * 1024; /// How long a new binary must keep running before init calls it in service. @@ -63,9 +63,9 @@ pub const MAX_BINARY_BYTES: u64 = 64 * 1024 * 1024; /// did not start, and one that ends after it is a service that died. pub const PROBATION_MS: u64 = 5_000; -/// How long sshd waits for its client to close the channel it answered on — -/// the client's proof that it has the answer, and its word that the swap may -/// go — before it hangs up on init anyway. +/// How long `swap` waits for its caller to close its input once it has +/// answered — the caller's proof that it has the answer, and its word that the +/// swap may go — before it hangs up on init anyway. pub const ANSWER_MS: u64 = 2_000; /// How long init waits for the requester to hang up once it has answered @@ -76,7 +76,7 @@ pub const HANGUP_MS: u64 = 2 * ANSWER_MS; const _: () = assert!(HANGUP_MS > ANSWER_MS); -/// The request, sshd to init: [`Request::encode`]'s bytes. +/// The request, `swap` to init: [`Request::encode`]'s bytes. pub const MSG_SWAP: u32 = 1; /// init's answer: verified and installed; the payload is the installed path. pub const MSG_ACCEPTED: u32 = 2; @@ -86,7 +86,7 @@ pub const MSG_REFUSED: u32 = 3; /// A SHA-256 digest. pub type Digest = [u8; 32]; -/// The digest of `bytes`: the one definition the host, sshd and init share. +/// The digest of `bytes`: the one definition the host, `swap` and init share. pub fn digest(bytes: &[u8]) -> Digest { Sha256::digest(bytes).into() } @@ -138,7 +138,7 @@ pub enum Refusal { AlreadyRuns(String), /// init could not put the verified bytes where a service is started from. Install { path: String, why: String }, - /// This sshd was given no [`PORT`]: the image does not let it swap. + /// This process was given no [`PORT`]: the image does not let it swap. NoAuthority, } @@ -159,7 +159,7 @@ impl core::fmt::Display for Refusal { } Self::AlreadyRuns(path) => write!(f, "the service already runs {path}"), Self::Install { path, why } => write!(f, "{path} could not be written: {why}"), - Self::NoAuthority => write!(f, "this sshd holds no `{PORT}` connector"), + Self::NoAuthority => write!(f, "this process holds no `{PORT}` connector"), } } } @@ -174,54 +174,7 @@ pub fn verify(bytes: &[u8], want: &Digest) -> Result<(), Refusal> { Ok(()) } -/// What a client sends first on the [`SUBSYSTEM`] channel, one line: -/// ` \n`, then exactly `` bytes. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Header { - pub service: String, - pub digest: Digest, - pub len: u64, -} - -/// The longest header line, newline included; a longer one is refused rather -/// than buffered. -pub const MAX_HEADER: usize = toyos_manifest::MAX_PROGRAM_NAME + 1 + 64 + 1 + 20 + 1; - -impl Header { - pub fn render(&self) -> String { - format!("{} {} {}\n", self.service, hex(&self.digest), self.len) - } - - /// The header at the front of `buf`, and how many bytes it took; `Ok(None)` - /// while no newline has arrived and the line could still be one. - pub fn take(buf: &[u8]) -> Result, Refusal> { - let Some(end) = buf.iter().position(|&b| b == b'\n') else { - if buf.len() >= MAX_HEADER { - return Err(Refusal::Malformed(format!("no header in the first {MAX_HEADER} bytes"))); - } - return Ok(None); - }; - let line = core::str::from_utf8(&buf[..end]) - .map_err(|_| Refusal::Malformed("the header is not UTF-8".into()))?; - let words: Vec<&str> = line.split(' ').collect(); - let [service, digest, len] = words[..] else { - return Err(Refusal::Malformed(format!("{line:?} is not ` `"))); - }; - if !is_service_name(service) { - return Err(Refusal::Malformed(format!("{service:?} is not a service name"))); - } - let digest = parse_hex(digest) - .ok_or_else(|| Refusal::Malformed(format!("{digest:?} is not a SHA-256 in hex")))?; - let len: u64 = - len.parse().map_err(|_| Refusal::Malformed(format!("{len:?} is not a length")))?; - if len > MAX_BINARY_BYTES { - return Err(Refusal::TooLarge(len)); - } - Ok(Some((Self { service: service.to_string(), digest, len }, end + 1))) - } -} - -/// Where sshd stages one request's bytes. `nonce` keeps two sessions' files +/// Where `swap` stages one request's bytes. `nonce` keeps two requests' files /// apart; the name is never one a service is started from. pub fn staged_path(service: &str, nonce: u64) -> String { format!("{STAGING}/incoming-{service}-{nonce}") @@ -265,7 +218,7 @@ pub fn installed_digest(path: &str) -> Option { parse_hex(dir) } -/// The request sshd sends init: the service, the staged path and the digest the +/// The request `swap` sends init: the service, the staged path and the digest the /// client named. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Request { @@ -357,22 +310,6 @@ pub fn said(service: &str, word: Word, detail: &str) -> String { format!("init: swap {service}: {}: {detail}", word.as_str()) } -/// sshd's own line about one ask, before or instead of asking init: -/// `sshd: : swap : ` — `` is `?` while sshd has -/// not yet read the request's header. What sshd prints (`userland/sshd`) and -/// what [`sshd_refused`] reads back are the same spelling. -pub fn sshd_said(peer: &str, service: &str, answer: &str) -> String { - format!("sshd: {peer}: swap {service}: {answer}") -} - -/// sshd's own refusal inside `line` — [`sshd_said`]'s form, for whichever peer -/// and service it named, in any form the log renders it — or `None`. -pub fn sshd_refused(line: &str) -> Option<&str> { - let (_, rest) = line.split_once(": swap ")?; - let (_, answer) = rest.split_once(": ")?; - answer.trim_end_matches(['\n', '\r']).strip_prefix("refused ") -} - /// init's line about `service` inside `line` — a console line or a record in /// any of the forms the log renders one in — as `(word, detail)`. pub fn heard<'a>(line: &'a str, service: &str) -> Option<(Word, &'a str)> { @@ -420,33 +357,6 @@ mod tests { assert!(matches!(refused, Refusal::Mismatch { .. })); } - #[test] - fn a_header_round_trips_and_waits_for_its_newline() { - let header = Header { service: "netd".into(), digest: digest(b"abc"), len: 2_317_912 }; - let line = header.render(); - let mut buf = line.clone().into_bytes(); - buf.extend_from_slice(b"\x7fELF"); - assert_eq!(Header::take(&buf), Ok(Some((header, line.len())))); - assert_eq!(Header::take(&line.as_bytes()[..10]), Ok(None)); - assert!(line.len() <= MAX_HEADER); - } - - #[test] - fn a_header_that_is_not_one_is_refused() { - let ok_digest = ABC; - for line in [ - format!("netd {ok_digest}\n"), - format!("netd {ok_digest} 12 extra\n"), - format!("../netd {ok_digest} 12\n"), - format!("netd {} 12\n", &ok_digest[1..]), - format!("netd {ok_digest} twelve\n"), - format!("netd {ok_digest} {}\n", MAX_BINARY_BYTES + 1), - ] { - assert!(Header::take(line.as_bytes()).is_err(), "{line:?}"); - } - assert!(Header::take(&[b'x'; MAX_HEADER]).is_err()); - } - #[test] fn a_request_round_trips_and_a_path_it_did_not_stage_is_refused() { let request = @@ -523,20 +433,6 @@ mod tests { } } - #[test] - fn sshd_refused_reads_back_what_sshd_said() { - let line = sshd_said("10.0.2.2:60872", "?", "refused x"); - assert_eq!(line, "sshd: 10.0.2.2:60872: swap ?: refused x"); - assert_eq!(sshd_refused(&line), Some("x")); - let accepted = sshd_said("10.0.2.2:60872", "netd", "accepted /tmp/swap/abc/netd"); - assert_eq!(sshd_refused(&accepted), None); - for rendered in - [format!("{line}\n"), format!("[2026-09-24 22:01:36 2.612 cpu0] @{line}\n")] - { - assert_eq!(sshd_refused(&rendered), Some("x"), "{rendered:?}"); - } - } - #[test] fn every_word_is_spelled_once() { for (i, (_, a)) in WORDS.iter().enumerate() { diff --git a/toyos-update/Cargo.toml b/toyos-update/Cargo.toml new file mode 100644 index 00000000000..3d24ef3ef42 --- /dev/null +++ b/toyos-update/Cargo.toml @@ -0,0 +1,30 @@ +# A member of the host workspace (root `Cargo.toml`): the bootloader and +# `/system/bin/update` depend on it by path and its tests run on the host. It is +# every decision a signed image, a slot and its record make, so the loader and +# the updater cannot read one format two ways. + +[package] +name = "toyos-update" +version = "0.1.0" +edition = "2021" +license = "MIT OR Apache-2.0" +publish = false + +[features] +# Signing, for the host that holds the key. Off in the loader and the updater, +# so no binary on the machine carries a path that makes a signature. +sign = [] + +[dependencies] +# **The one verifier, and why this one.** `ed25519-dalek` is the Ed25519 the +# Rust ecosystem ships (RustCrypto's `ssh-key` and `russh`, which this tree's +# sshd already runs, verify with it), it is `no_std` without `alloc`, and it +# is already resolved in the userland lockfile at this version. No default +# features: no `std`, no precomputed tables (a verify is one per boot), no +# `zeroize` (the machine holds no secret key). Verified with `verify_strict`, +# which refuses the small-order and non-canonical encodings RFC 8032 §5.1.7 +# leaves to the implementation. +ed25519-dalek = { version = "2.2", default-features = false } +# SHA-256 over the sections and SHA-512 over the signed header, as SSHSIG +# names it. Already resolved in every lockfile that builds this crate. +sha2 = { version = "0.10", default-features = false } diff --git a/toyos-update/src/floor.rs b/toyos-update/src/floor.rs new file mode 100644 index 00000000000..379b3fdc362 --- /dev/null +++ b/toyos-update/src/floor.rs @@ -0,0 +1,325 @@ +//! The anti-rollback floor as a firmware variable: whose it is, what it is +//! called, what a stored one is worth, and which others a loader deletes. +//! +//! **One floor per signing key, never one per machine.** A floor is a promise +//! about the images one key signs, so a loader holds images to the floor its +//! own key's images raised and no other: an image signed by a throwaway key +//! never reads or raises the owner's. The name carries the key's fingerprint +//! and the [`Scope`] the loader was built with: +//! +//! - [`Scope::Machine`], the owner's key: one floor for the machine, which +//! every image the key signs is held to, whatever disk it arrives on. +//! - [`Scope::Image`], a throwaway key — every image built for QEMU, for CI +//! and for the metal loop: the fingerprint is over the key *and the log +//! partition's GUID*, which every image mints afresh, so each image starts +//! at no floor and raises only its own. An image flashed after a newer one, +//! or a bisect, boots; and a checkout's key living across its builds +//! locks nothing out. What it gives up is exactly what a throwaway key has +//! no use for: a running system that rewrites that GUID resets the floor. +//! +//! A loader deletes the floors it can tell are stale, so a machine's NVRAM +//! holds at most one of each scope: a [`Scope::Machine`] loader every other +//! floor, and a [`Scope::Image`] loader every other image's — never a +//! machine's, so no stick booted on the owner's machine lowers the owner's +//! floor. +//! +//! **A stored variable is the loader's only if it carries exactly the +//! attributes the loader writes** — non-volatile, boot-services-only — and +//! eight bytes. One carrying runtime access was made after a handoff, and +//! UEFI 2.10 §8.2 refuses a runtime `SetVariable` of a name that exists +//! without runtime access, so no floor this loader wrote stands behind it: it +//! is deleted and the floor is none — or refused, where the firmware will not +//! delete it, since every raise would fail against it. Anything else under +//! the name, and a +//! variable the firmware will not read, could only have been written before +//! a handoff — by this loader broken, or by whatever booted instead of it — and +//! is refused, never read as no floor. + +use crate::Digest; + +/// UEFI variable attributes (UEFI 2.10 §8.2, `EFI_VARIABLE_*`). +pub const NON_VOLATILE: u32 = 0x1; +pub const BOOTSERVICE_ACCESS: u32 = 0x2; +pub const RUNTIME_ACCESS: u32 = 0x4; + +/// The attributes the loader writes the floor with, and the only ones it reads. +pub const ATTRIBUTES: u32 = NON_VOLATILE | BOOTSERVICE_ACCESS; + +/// Whose images a loader's floor holds, fixed when the loader is built. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Scope { + /// The owner's key: one floor for the machine. + Machine, + /// A throwaway key: one floor per image, reset by every image. + Image, +} + +impl Scope { + /// The word the build hands the loader (`TOYOS_IMAGE_FLOOR`). + pub const fn word(self) -> &'static str { + match self { + Self::Machine => "machine", + Self::Image => "image", + } + } + + /// The scope `word` names; any other word is refused where the loader is + /// compiled. + pub const fn from_word(word: &str) -> Self { + let w = word.as_bytes(); + if eq(w, b"machine") { + Self::Machine + } else if eq(w, b"image") { + Self::Image + } else { + panic!("TOYOS_IMAGE_FLOOR is `machine` or `image`") + } + } + + const fn tag(self) -> u8 { + match self { + Self::Machine => b'K', + Self::Image => b'I', + } + } +} + +const fn eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + let mut i = 0; + while i < a.len() { + if a[i] != b[i] { + return false; + } + i += 1; + } + true +} + +/// What every floor variable's name begins with. +pub const PREFIX: &str = "ToyOSImageFloor"; + +/// The hex digits of the fingerprint a name carries. +const FINGERPRINT_HEX: usize = 16; + +/// `ToyOSImageFloor-`. +pub const NAME_BYTES: usize = PREFIX.len() + 2 + FINGERPRINT_HEX; + +/// A floor variable's name, in ASCII. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Name([u8; NAME_BYTES]); + +impl Name { + pub fn as_str(&self) -> &str { + core::str::from_utf8(&self.0).expect("a floor's name is ASCII") + } +} + +/// The floor a loader of `scope` embedding `key` keeps, on the image whose log +/// partition is `log_guid`. +pub fn name(scope: Scope, key: &[u8; 32], log_guid: &[u8; 16]) -> Name { + let fingerprint: Digest = match scope { + Scope::Machine => crate::sha256(key), + Scope::Image => { + let mut both = [0u8; 48]; + both[..32].copy_from_slice(key); + both[32..].copy_from_slice(log_guid); + crate::sha256(&both) + } + }; + let mut out = [0u8; NAME_BYTES]; + out[..PREFIX.len()].copy_from_slice(PREFIX.as_bytes()); + out[PREFIX.len()] = b'-'; + out[PREFIX.len() + 1] = scope.tag(); + crate::hex(&fingerprint[..FINGERPRINT_HEX / 2], &mut out[PREFIX.len() + 2..]); + Name(out) +} + +/// Whether a loader keeping `own` deletes the variable `other` found under +/// the floor's vendor GUID. +pub fn stale(scope: Scope, own: &Name, other: &str) -> bool { + if other == own.as_str() || !other.starts_with(PREFIX) { + return false; + } + let machine = other.as_bytes().get(PREFIX.len()..PREFIX.len() + 2) == Some(&[b'-', Scope::Machine.tag()][..]); + match scope { + Scope::Machine => true, + Scope::Image => !machine, + } +} + +/// `EFI_NOT_FOUND` (UEFI 2.10 Appendix D): the error bit and 14. +pub const NOT_FOUND: usize = (1 << (usize::BITS - 1)) | 14; + +/// What the firmware answered for the floor's name. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Stored<'a> { + Absent, + Held { attributes: u32, value: &'a [u8] }, + /// The firmware would not read it, with this status code. + Unreadable(usize), +} + +impl<'a> Stored<'a> { + /// What `GetVariable` answered: the value and its attributes, or its + /// status. Only `EFI_NOT_FOUND` is no variable; every other failure is + /// one the firmware would not read. + pub fn answered(got: Result<(&'a [u8], u32), usize>) -> Self { + match got { + Ok((value, attributes)) => Self::Held { attributes, value }, + Err(NOT_FOUND) => Self::Absent, + Err(status) => Self::Unreadable(status), + } + } +} + +/// What a stored floor is worth. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Read { + /// The loader's floor: `0` where it has written none. + Floor(u64), + /// Made after a handoff: deleted, and the floor is none. + RuntimeMade { attributes: u32, len: usize }, +} + +/// Why a stored floor is refused. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Refused { + /// The loader's attributes and not eight bytes. + Size(usize), + /// Attributes this loader never writes, without runtime access. + Attributes(u32), + Unreadable(usize), + /// Made after a handoff, and the firmware would not delete it: no floor + /// this loader writes can take its name. + Undeletable { attributes: u32, status: usize }, +} + +impl core::fmt::Display for Refused { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match *self { + Self::Size(n) => write!(f, "it holds {n} bytes where this loader writes 8"), + Self::Attributes(a) => { + write!(f, "it carries attributes {a:#x} where this loader writes {ATTRIBUTES:#x}") + } + Self::Unreadable(status) => write!(f, "the firmware would not read it (status {status:#x})"), + Self::Undeletable { attributes, status } => write!( + f, + "it carries runtime access ({attributes:#x}), so it was made after a handoff, and the firmware \ + would not delete it (status {status:#x}), so no floor this loader writes can replace it" + ), + } + } +} + +/// The floor once a variable made after a handoff ([`Read::RuntimeMade`]) has +/// been asked deleted, given the firmware's answer: none if it went, and +/// refused if it stayed, because every raise would fail against it. +pub fn deleted(attributes: u32, answer: Result<(), usize>) -> Result { + match answer { + Ok(()) => Ok(0), + Err(status) => Err(Refused::Undeletable { attributes, status }), + } +} + +/// The floor a stored variable holds, or why it is refused. +pub fn judge(stored: Stored<'_>) -> Result { + match stored { + Stored::Absent => Ok(Read::Floor(0)), + Stored::Held { attributes, value } if attributes & RUNTIME_ACCESS != 0 => { + Ok(Read::RuntimeMade { attributes, len: value.len() }) + } + Stored::Held { attributes, value } if attributes == ATTRIBUTES => match <[u8; 8]>::try_from(value) { + Ok(bytes) => Ok(Read::Floor(u64::from_le_bytes(bytes))), + Err(_) => Err(Refused::Size(value.len())), + }, + Stored::Held { attributes, .. } => Err(Refused::Attributes(attributes)), + Stored::Unreadable(status) => Err(Refused::Unreadable(status)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const KEY: [u8; 32] = [7; 32]; + const LOG: [u8; 16] = [3; 16]; + + /// A key's floor is its own: another key, or for an image scope another + /// image, is another name, and the scope is in the name. + #[test] + fn a_floor_is_named_for_its_key_and_scope() { + let machine = name(Scope::Machine, &KEY, &LOG); + assert!(machine.as_str().starts_with("ToyOSImageFloor-K"), "{}", machine.as_str()); + assert_eq!(machine.as_str().len(), NAME_BYTES); + assert_eq!(machine, name(Scope::Machine, &KEY, &[9; 16]), "the owner's floor is the machine's, whatever disk"); + assert_ne!(machine, name(Scope::Machine, &[8; 32], &LOG)); + let image = name(Scope::Image, &KEY, &LOG); + assert!(image.as_str().starts_with("ToyOSImageFloor-I"), "{}", image.as_str()); + assert_ne!(image, name(Scope::Image, &KEY, &[9; 16]), "every image resets its own"); + assert_eq!(Scope::from_word(Scope::Image.word()), Scope::Image); + assert_eq!(Scope::from_word(Scope::Machine.word()), Scope::Machine); + } + + /// **A throwaway key's loader never deletes the owner's floor**; the + /// owner's deletes every other; nobody deletes its own or a stranger's + /// variable. + #[test] + fn a_loader_deletes_the_stale_floors_of_its_scope_and_never_the_owners() { + let image = name(Scope::Image, &KEY, &LOG); + let machine = name(Scope::Machine, &KEY, &LOG); + let other_image = name(Scope::Image, &KEY, &[9; 16]); + assert!(!stale(Scope::Image, &image, image.as_str())); + assert!(stale(Scope::Image, &image, other_image.as_str())); + assert!(!stale(Scope::Image, &image, machine.as_str()), "the owner's floor"); + assert!(stale(Scope::Image, &image, "ToyOSImageFloor"), "the unscoped name no loader writes"); + assert!(!stale(Scope::Image, &image, "BootOrder")); + assert!(!stale(Scope::Machine, &machine, machine.as_str())); + assert!(stale(Scope::Machine, &machine, name(Scope::Machine, &[8; 32], &LOG).as_str())); + assert!(stale(Scope::Machine, &machine, image.as_str())); + } + + /// Eight bytes under the loader's attributes are the floor; runtime access + /// is a variable made after a handoff; **anything else is refused, never + /// read as no floor** — a wrong size, attributes the loader never writes, + /// and a variable the firmware will not read. + #[test] + fn only_the_loaders_own_variable_is_a_floor_and_nothing_else_is_none() { + assert_eq!(judge(Stored::Absent), Ok(Read::Floor(0))); + let eight = 200u64.to_le_bytes(); + assert_eq!(judge(Stored::Held { attributes: ATTRIBUTES, value: &eight }), Ok(Read::Floor(200))); + assert_eq!(judge(Stored::Held { attributes: ATTRIBUTES, value: &[0; 9] }), Err(Refused::Size(9))); + assert_eq!(judge(Stored::Held { attributes: ATTRIBUTES, value: &[] }), Err(Refused::Size(0))); + assert_eq!(judge(Stored::Held { attributes: BOOTSERVICE_ACCESS, value: &eight }), Err(Refused::Attributes(2))); + let runtime = ATTRIBUTES | RUNTIME_ACCESS; + assert_eq!(judge(Stored::Held { attributes: runtime, value: &[0; 9] }), Ok(Read::RuntimeMade { attributes: runtime, len: 9 })); + assert_eq!(judge(Stored::Unreadable(7)), Err(Refused::Unreadable(7))); + } + + /// **Only an absent variable is no floor**: every other failure the + /// firmware answers a read with is `Unreadable`, which `judge` refuses. + #[test] + fn only_not_found_is_no_variable() { + assert_eq!(Stored::answered(Err(NOT_FOUND)), Stored::Absent); + let device_error = (1 << (usize::BITS - 1)) | 7; + assert_eq!(Stored::answered(Err(device_error)), Stored::Unreadable(device_error)); + assert_eq!(judge(Stored::answered(Err(device_error))), Err(Refused::Unreadable(device_error))); + let eight = 5u64.to_le_bytes(); + assert_eq!(Stored::answered(Ok((&eight, ATTRIBUTES))), Stored::Held { attributes: ATTRIBUTES, value: &eight }); + } + + /// **A variable made after a handoff that the firmware keeps is refused**, + /// never read as no floor. + #[test] + fn a_runtime_variable_that_stays_is_refused() { + let runtime = ATTRIBUTES | RUNTIME_ACCESS | 0x20; + let security_violation = (1 << (usize::BITS - 1)) | 26; + assert_eq!(deleted(runtime, Ok(())), Ok(0)); + assert_eq!( + deleted(runtime, Err(security_violation)), + Err(Refused::Undeletable { attributes: runtime, status: security_violation }) + ); + } +} diff --git a/toyos-update/src/image.rs b/toyos-update/src/image.rs new file mode 100644 index 00000000000..14b04330199 --- /dev/null +++ b/toyos-update/src/image.rs @@ -0,0 +1,294 @@ +//! The signed image: a header, its signature, and the sections it names. +//! +//! ```text +//! header magic "TOYOSIMG" | format u32 | sections u32 | version u64 +//! then per section: name [u8; 16] | length u64 | sha256 [u8; 32] +//! signed header | signature [u8; 64] (SIGNED_BYTES) +//! image signed | the sections' bytes, in header order +//! ``` +//! +//! Little-endian throughout. **The sections are exactly [`SECTIONS`], in that +//! order**, and a header naming anything else is refused rather than read +//! around: a loader that skipped a section it did not know would boot bytes +//! nothing vouched for. The header is the whole of what is signed, so a +//! section's bytes are judged by its hash and never by being signed +//! themselves — which is what lets a later format fetch them as +//! content-addressed chunks under this same header. + +use crate::Digest; + +/// The first eight bytes of every header. +pub const MAGIC: [u8; 8] = *b"TOYOSIMG"; + +/// This layout. A header of any other format is refused by name. +pub const FORMAT: u32 = 1; + +/// The kernel ELF. +pub const KERNEL: &str = "kernel"; +/// The boot parameter the loader hands the kernel. +pub const CMDLINE: &str = "cmdline"; +/// ROOT's filesystem, whole. +pub const ROOT: &str = "root"; + +/// Every section an image carries, in the order it carries them. +pub const SECTIONS: [&str; 3] = [KERNEL, CMDLINE, ROOT]; + +/// Bytes a section's name takes, NUL-padded. +const NAME_BYTES: usize = 16; +/// One section's entry. +const ENTRY_BYTES: usize = NAME_BYTES + 8 + 32; +/// The fixed part ahead of the entries. +const FIXED_BYTES: usize = 8 + 4 + 4 + 8; + +/// The header's length, which is fixed by [`SECTIONS`]. +pub const HEADER_BYTES: usize = FIXED_BYTES + SECTIONS.len() * ENTRY_BYTES; +/// An Ed25519 signature. +pub const SIGNATURE_BYTES: usize = 64; +/// The header and its signature: what a slot keeps as its signed header. +pub const SIGNED_BYTES: usize = HEADER_BYTES + SIGNATURE_BYTES; + +/// The largest section of each kind an image may carry, so a header is a +/// bound on what a reader allocates before a single hash is checked. The +/// kernel's is the loader's own bound on an ESP file; ROOT's is well above +/// any ROOT this tree builds and below what a slot is. +pub const MAX_BYTES: [u64; 3] = [1 << 30, 4096, 4 << 30]; + +/// ROOT is whole 4 KiB blocks: it is written to a partition and read back in them. +pub const ROOT_BLOCK: u64 = 4096; + +/// One section as the header names it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Section { + pub len: u64, + pub sha256: Digest, +} + +/// A header, decoded. Every field was checked by [`Header::parse`]; nothing in +/// it has been vouched for until [`crate::sig::verify`] says so. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Header { + pub version: u64, + /// In [`SECTIONS`] order. + pub sections: [Section; 3], +} + +/// Why bytes are not an image. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Malformed { + /// Fewer bytes than a signed header. + Short(usize), + Magic, + Format(u32), + SectionCount(u32), + /// The section at this index is not the one [`SECTIONS`] puts there. + SectionName(usize), + /// The section at this index is longer than [`MAX_BYTES`] allows. + TooLong(usize, u64), + /// ROOT is not whole [`ROOT_BLOCK`]s, or is empty. + RootNotBlocks(u64), + /// The image's bytes do not add up to the header's sections. + Length { have: u64, want: u64 }, +} + +impl core::fmt::Display for Malformed { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match *self { + Self::Short(n) => write!(f, "{n} bytes is shorter than a signed header ({SIGNED_BYTES})"), + Self::Magic => write!(f, "the header does not begin TOYOSIMG"), + Self::Format(n) => write!(f, "the header is format {n}, and this reads format {FORMAT}"), + Self::SectionCount(n) => write!(f, "the header names {n} sections, and an image has {}", SECTIONS.len()), + Self::SectionName(i) => write!(f, "section {i} is not `{}`", SECTIONS[i]), + Self::TooLong(i, len) => write!(f, "the `{}` section is {len} bytes, past its bound of {}", SECTIONS[i], MAX_BYTES[i]), + Self::RootNotBlocks(len) => write!(f, "ROOT is {len} bytes, which is not whole {ROOT_BLOCK}-byte blocks"), + Self::Length { have, want } => write!(f, "the image is {have} bytes and its header adds up to {want}"), + } + } +} + +fn u32_at(bytes: &[u8], at: usize) -> u32 { + u32::from_le_bytes(bytes[at..at + 4].try_into().expect("four bytes")) +} + +fn u64_at(bytes: &[u8], at: usize) -> u64 { + u64::from_le_bytes(bytes[at..at + 8].try_into().expect("eight bytes")) +} + +/// `name` NUL-padded to [`NAME_BYTES`]. +fn padded(name: &str) -> [u8; NAME_BYTES] { + let mut out = [0u8; NAME_BYTES]; + out[..name.len()].copy_from_slice(name.as_bytes()); + out +} + +impl Header { + /// The header at the front of `bytes`, which must be at least a signed + /// header long: the signature is not checked here, only the layout. + pub fn parse(bytes: &[u8]) -> Result { + if bytes.len() < SIGNED_BYTES { + return Err(Malformed::Short(bytes.len())); + } + if bytes[..8] != MAGIC { + return Err(Malformed::Magic); + } + let format = u32_at(bytes, 8); + if format != FORMAT { + return Err(Malformed::Format(format)); + } + let count = u32_at(bytes, 12); + if count as usize != SECTIONS.len() { + return Err(Malformed::SectionCount(count)); + } + let version = u64_at(bytes, 16); + let mut sections = [Section { len: 0, sha256: [0; 32] }; 3]; + for (i, section) in sections.iter_mut().enumerate() { + let at = FIXED_BYTES + i * ENTRY_BYTES; + if bytes[at..at + NAME_BYTES] != padded(SECTIONS[i]) { + return Err(Malformed::SectionName(i)); + } + let len = u64_at(bytes, at + NAME_BYTES); + if len > MAX_BYTES[i] { + return Err(Malformed::TooLong(i, len)); + } + section.len = len; + section.sha256.copy_from_slice(&bytes[at + NAME_BYTES + 8..at + ENTRY_BYTES]); + } + let root = sections[2].len; + if root == 0 || !root.is_multiple_of(ROOT_BLOCK) { + return Err(Malformed::RootNotBlocks(root)); + } + Ok(Self { version, sections }) + } + + /// The header's bytes, which are what is signed. + pub fn encode(&self) -> [u8; HEADER_BYTES] { + let mut out = [0u8; HEADER_BYTES]; + out[..8].copy_from_slice(&MAGIC); + out[8..12].copy_from_slice(&FORMAT.to_le_bytes()); + out[12..16].copy_from_slice(&(SECTIONS.len() as u32).to_le_bytes()); + out[16..24].copy_from_slice(&self.version.to_le_bytes()); + for (i, section) in self.sections.iter().enumerate() { + let at = FIXED_BYTES + i * ENTRY_BYTES; + out[at..at + NAME_BYTES].copy_from_slice(&padded(SECTIONS[i])); + out[at + NAME_BYTES..at + NAME_BYTES + 8].copy_from_slice(§ion.len.to_le_bytes()); + out[at + NAME_BYTES + 8..at + ENTRY_BYTES].copy_from_slice(§ion.sha256); + } + out + } + + /// The header naming exactly these sections' bytes. + pub fn of(version: u64, kernel: &[u8], cmdline: &[u8], root: &[u8]) -> Self { + let section = |bytes: &[u8]| Section { len: bytes.len() as u64, sha256: crate::sha256(bytes) }; + Self { version, sections: [section(kernel), section(cmdline), section(root)] } + } + + pub fn kernel(&self) -> Section { + self.sections[0] + } + + pub fn cmdline(&self) -> Section { + self.sections[1] + } + + pub fn root(&self) -> Section { + self.sections[2] + } + + /// The bytes the sections take after the signed header. + pub fn payload_len(&self) -> u64 { + self.sections.iter().map(|s| s.len).sum() + } +} + +/// The signature at the end of a signed header. +pub fn signature_of(signed: &[u8; SIGNED_BYTES]) -> [u8; SIGNATURE_BYTES] { + signed[HEADER_BYTES..].try_into().expect("a signed header ends in a signature") +} + +/// An image whose layout adds up, split into its parts. Nothing here is +/// vouched for until the header's signature ([`crate::sig::verify`]) and +/// each section's hash against its header entry are checked. +pub struct Parts<'a> { + pub header: Header, + pub signed: &'a [u8; SIGNED_BYTES], + pub kernel: &'a [u8], + pub cmdline: &'a [u8], + pub root: &'a [u8], +} + +impl<'a> Parts<'a> { + /// `bytes` as an image: a signed header and exactly the bytes its sections add up to. + pub fn split(bytes: &'a [u8]) -> Result { + let header = Header::parse(bytes)?; + let want = SIGNED_BYTES as u64 + header.payload_len(); + if bytes.len() as u64 != want { + return Err(Malformed::Length { have: bytes.len() as u64, want }); + } + let signed: &[u8; SIGNED_BYTES] = bytes[..SIGNED_BYTES].try_into().expect("checked above"); + let (k, c) = (header.kernel().len as usize, header.cmdline().len as usize); + let rest = &bytes[SIGNED_BYTES..]; + Ok(Self { header, signed, kernel: &rest[..k], cmdline: &rest[k..k + c], root: &rest[k + c..] }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn header() -> Header { + Header::of(7, b"\x7fELF kernel", b"root=x", &[0x5A; 8192]) + } + + fn signed(header: &Header) -> [u8; SIGNED_BYTES] { + let mut out = [0u8; SIGNED_BYTES]; + out[..HEADER_BYTES].copy_from_slice(&header.encode()); + out + } + + #[test] + fn a_header_reads_back_as_it_was_written() { + let h = header(); + assert_eq!(Header::parse(&signed(&h)), Ok(h)); + assert_eq!(HEADER_BYTES, 192); + } + + /// Every field a reader decides on, bent one at a time, is refused by the + /// word for it. + #[test] + fn a_bent_header_is_refused_by_name() { + let good = signed(&header()); + let bend = |at: usize, value: &[u8]| { + let mut bent = good; + bent[at..at + value.len()].copy_from_slice(value); + Header::parse(&bent) + }; + assert_eq!(Header::parse(&good[..SIGNED_BYTES - 1]), Err(Malformed::Short(SIGNED_BYTES - 1))); + assert_eq!(bend(0, b"TOYOSIMH"), Err(Malformed::Magic)); + assert_eq!(bend(8, &2u32.to_le_bytes()), Err(Malformed::Format(2))); + assert_eq!(bend(12, &4u32.to_le_bytes()), Err(Malformed::SectionCount(4))); + assert_eq!(bend(FIXED_BYTES, b"kernal"), Err(Malformed::SectionName(0))); + // The name is the whole padded field, so a longer one is not a prefix match. + assert_eq!(bend(FIXED_BYTES + ENTRY_BYTES + 7, b"x"), Err(Malformed::SectionName(1))); + let at = FIXED_BYTES + ENTRY_BYTES + NAME_BYTES; + assert_eq!(bend(at, &4097u64.to_le_bytes()), Err(Malformed::TooLong(1, 4097))); + let root = FIXED_BYTES + 2 * ENTRY_BYTES + NAME_BYTES; + assert_eq!(bend(root, &4095u64.to_le_bytes()), Err(Malformed::RootNotBlocks(4095))); + assert_eq!(bend(root, &0u64.to_le_bytes()), Err(Malformed::RootNotBlocks(0))); + } + + #[test] + fn an_image_splits_into_exactly_its_sections() { + let (kernel, cmdline, root) = (b"\x7fELF kernel".to_vec(), b"root=x".to_vec(), vec![0x5A; 8192]); + let h = Header::of(7, &kernel, &cmdline, &root); + let mut bytes = signed(&h).to_vec(); + bytes.extend_from_slice(&kernel); + bytes.extend_from_slice(&cmdline); + bytes.extend_from_slice(&root); + let parts = Parts::split(&bytes).expect("an image"); + assert_eq!((parts.kernel, parts.cmdline, parts.root), (&kernel[..], &cmdline[..], &root[..])); + let want = bytes.len() as u64; + bytes.push(0); + assert_eq!(Parts::split(&bytes).err(), Some(Malformed::Length { have: want + 1, want })); + bytes.truncate(bytes.len() - 2); + assert_eq!(Parts::split(&bytes).err(), Some(Malformed::Length { have: want - 1, want })); + } +} diff --git a/toyos-update/src/lib.rs b/toyos-update/src/lib.rs new file mode 100644 index 00000000000..c40d9b6ce2b --- /dev/null +++ b/toyos-update/src/lib.rs @@ -0,0 +1,58 @@ +//! A signed image, the slots it is installed into, and every decision the +//! loader and `/system/bin/update` make about one. Pure: no firmware, no +//! device, no allocation. +//! +//! **The contract.** An [`image`] is a header naming a monotonic version and +//! the SHA-256 of each of its sections — the kernel, its boot parameter and +//! ROOT — followed by an Ed25519 signature over that header, and then the +//! sections. The signature covers the hashes and never the bytes, so the +//! bytes may arrive by any path and in any order: an `ssh … update < image` +//! today, a pull of content-addressed chunks tomorrow, the same signature over +//! the same hashes either way. [`sig`] is the one verifier, and it verifies +//! exactly what OpenSSH's `ssh-keygen -Y sign` signs, so the owner's key is an +//! ordinary Ed25519 key and a second implementation can sign or check an image. +//! +//! A machine carries two [`slots`], each a FAT partition holding the kernel, +//! its boot parameter and the signed header, and a ROOT partition holding +//! ROOT; the slot table names both slots and marks one. The loader boots the +//! marked slot, and falls back to the other where the marked one is refused or +//! died on its last boot ([`record`]); the updater writes only the slot the +//! machine is not running, and moves the mark last. +//! +//! **What anti-rollback is here** ([`policy`]): the loader refuses an image +//! whose version is below the highest version a boot has proven, and keeps +//! that floor in a firmware variable no running kernel can write, one per +//! signing key ([`floor`]); the updater refuses an image older than what the +//! machine runs. Neither can defend a machine whose firmware variables anyone +//! with the machine in hand can reset. + +#![cfg_attr(not(test), no_std)] +#![forbid(unsafe_code)] + +pub mod floor; +pub mod image; +pub mod policy; +pub mod record; +pub mod sig; +pub mod slots; + +/// A SHA-256 digest. +pub type Digest = [u8; 32]; + +/// The SHA-256 of `bytes`: the one definition the Mac, the loader and the +/// updater share. +pub fn sha256(bytes: &[u8]) -> Digest { + use sha2::Digest as _; + sha2::Sha256::digest(bytes).into() +} + +/// A digest in lowercase hex, into `out`. +pub fn hex<'a>(digest: &[u8], out: &'a mut [u8]) -> &'a str { + const DIGITS: &[u8; 16] = b"0123456789abcdef"; + let n = digest.len().min(out.len() / 2); + for (i, byte) in digest[..n].iter().enumerate() { + out[2 * i] = DIGITS[(byte >> 4) as usize]; + out[2 * i + 1] = DIGITS[(byte & 0xF) as usize]; + } + core::str::from_utf8(&out[..2 * n]).expect("hex digits are ASCII") +} diff --git a/toyos-update/src/policy.rs b/toyos-update/src/policy.rs new file mode 100644 index 00000000000..7755ab5f41c --- /dev/null +++ b/toyos-update/src/policy.rs @@ -0,0 +1,170 @@ +//! Which slot a boot may take, which image an update may install, and every +//! reason one is refused, by the word the loader hands the kernel for it. +//! +//! **The anti-rollback floor** is the highest version a boot has proven +//! ([`crate::record::Ended::Proven`]). The loader refuses an image below it, +//! and raises it only for an image that handed the machine back on purpose — +//! never for one merely booted, because a new image that dies must leave the +//! old one bootable. The loader keeps the floor in a UEFI variable it creates +//! without runtime access, so no kernel it hands the machine to, however +//! compromised, can lower it ([`crate::floor`]). +//! +//! **What it rises to is a signed version**: the record on the log partition +//! ([`crate::record`]), which the running system can write, names only the +//! slot and the digest of the header it booted, and the pass that raises the +//! floor verifies that slot's signed header again and takes the version out +//! of it. So the running system can raise the floor to no version the owner +//! did not sign — which is no more than `update` installing that image does. +//! +//! **What that cannot defend**: firmware variables are the firmware's, so an +//! EFI program booted instead of this loader (anything, until Secure Boot is +//! on with the owner's key), the firmware's own reset of its variables, or a +//! hand clearing its NVRAM, lowers the floor to nothing. Only a monotonic +//! counter the platform refuses to lower — a TPM's NV counter — closes that. + +use crate::slots::{Table, Which}; + +/// Why a slot was not booted, or an image not installed. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Refusal { + /// The table carries no such slot. + Absent, + /// Its image died on its last boot. + Died, + /// It carries no signed header. + Unsigned, + /// Its signed header is not one. + Malformed, + /// Its signature is not this machine's key's. + Signature, + /// Its version is below the floor a boot has proven. + BelowFloor { version: u64, floor: u64 }, + /// An update older than what it would replace. + Older { version: u64, than: u64 }, + /// A section's bytes are not the ones its header names. + Hash(&'static str), + /// A section could not be read. + Unreadable(&'static str), +} + +impl Refusal { + /// The word the loader hands the kernel for this refusal: one token of the + /// boot parameter, so no comma and no space. + pub fn word(&self) -> &'static str { + match self { + Self::Absent => "absent", + Self::Died => "died", + Self::Unsigned => "unsigned", + Self::Malformed => "malformed", + Self::Signature => "signature", + Self::BelowFloor { .. } | Self::Older { .. } => "version", + Self::Hash(_) => "hash", + Self::Unreadable(_) => "unreadable", + } + } +} + +impl core::fmt::Display for Refusal { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match *self { + Self::Absent => write!(f, "the slot table carries no such slot"), + Self::Died => write!(f, "its image died on its last boot"), + Self::Unsigned => write!(f, "it carries no signed header, so it is unsigned"), + Self::Malformed => write!(f, "its signed header is not one"), + Self::Signature => write!(f, "its signature is not this machine's key's"), + Self::BelowFloor { version, floor } => { + write!(f, "its version {version} is below {floor}, the highest a boot has proven") + } + Self::Older { version, than } => write!(f, "its version {version} is older than {than}"), + Self::Hash(section) => write!(f, "its {section} is not the bytes its signed header names"), + Self::Unreadable(what) => write!(f, "its {what} could not be read"), + } + } +} + +/// Whether a boot may take an image of `version` against the proven `floor`. +pub fn admits(floor: u64, version: u64) -> Result<(), Refusal> { + if version < floor { + return Err(Refusal::BelowFloor { version, floor }); + } + Ok(()) +} + +/// The slots a boot tries, in order: the marked one, then the other where the +/// table carries it. +pub fn order(table: &Table) -> [Option; 2] { + let other = table.marked.other(); + [Some(table.marked), table.slot(other).map(|_| other)] +} + +/// The floor after a boot proved `proven`: it only rises. +pub fn raised(floor: u64, proven: u64) -> u64 { + floor.max(proven) +} + +/// Whether an update of `version` may replace the idle slot, on a machine +/// running `running` whose idle slot holds `idle`. +/// +/// **Newer than what runs, and no older than what it overwrites.** The same +/// version as the running image is refused too: two images of one version +/// cannot be told apart by any floor. The idle slot's own version may be +/// written again, which is how a torn install is finished. +pub fn installable(version: u64, running: u64, idle: Option) -> Result<(), Refusal> { + if version <= running { + return Err(Refusal::Older { version, than: running }); + } + match idle { + Some(idle) if version < idle => Err(Refusal::Older { version, than: idle }), + _ => Ok(()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::slots::Slot; + + #[test] + fn the_floor_admits_its_own_version_and_above_and_only_rises() { + assert_eq!(admits(10, 10), Ok(())); + assert_eq!(admits(10, 11), Ok(())); + assert_eq!(admits(10, 9), Err(Refusal::BelowFloor { version: 9, floor: 10 })); + assert_eq!(raised(10, 9), 10); + assert_eq!(raised(10, 12), 12); + } + + #[test] + fn an_update_must_be_newer_than_what_runs() { + assert_eq!(installable(11, 10, None), Ok(())); + assert_eq!(installable(10, 10, None), Err(Refusal::Older { version: 10, than: 10 })); + assert_eq!(installable(9, 10, None), Err(Refusal::Older { version: 9, than: 10 })); + assert_eq!(installable(11, 10, Some(12)), Err(Refusal::Older { version: 11, than: 12 })); + assert_eq!(installable(12, 10, Some(12)), Ok(())); + } + + #[test] + fn a_boot_tries_the_marked_slot_first_and_the_other_only_if_there_is_one() { + let slot = Some(Slot { boot: [1; 16], root: [2; 16], version: 1 }); + let both = Table { sequence: 1, marked: Which::B, slots: [slot, slot] }; + assert_eq!(order(&both), [Some(Which::B), Some(Which::A)]); + let one = Table { sequence: 1, marked: Which::A, slots: [slot, None] }; + assert_eq!(order(&one), [Some(Which::A), None]); + } + + /// Each word is one token of a comma-separated boot parameter. + #[test] + fn every_word_is_one_parameter_token() { + for r in [ + Refusal::Absent, + Refusal::Died, + Refusal::Unsigned, + Refusal::Malformed, + Refusal::Signature, + Refusal::BelowFloor { version: 1, floor: 2 }, + Refusal::Hash("kernel"), + Refusal::Unreadable("root"), + ] { + assert!(!r.word().is_empty() && !r.word().contains([',', ' ', ':', '=']), "{r:?}"); + } + } +} diff --git a/toyos-update/src/record.rs b/toyos-update/src/record.rs new file mode 100644 index 00000000000..8276c941582 --- /dev/null +++ b/toyos-update/src/record.rs @@ -0,0 +1,236 @@ +//! What the loader keeps between its passes about the slots: which image it +//! handed the machine last, and which images died on a boot of their own. +//! +//! It extends the loader's attempt count (`bootloader/src/attempt.rs`, a file +//! on the log partition) rather than standing beside it: the count is the +//! bound on a hang, and **a panic, a fault, a wedge, a boot that never reached +//! its kernel's panic path, and a hang of an image no boot has proven are one +//! fact about a slot** — its image had the machine and gave nothing back. The +//! pass that learns it records the image's signed-header digest as dead in its +//! slot, and every later pass prefers the other slot, until an update puts a +//! different image there. +//! +//! **A hang of a proven image is not a death**: the count cannot tell a hang +//! from a power cut, and a power cut is no reason to leave an image the +//! machine has run well for an older one. +//! +//! ```text +//! partition guid [16] | count u8 | booted u8 ('A', 'B' or 0) | 0 [6] +//! | version u64 | signed-header sha256 [32] | dead A [32] | dead B [32] +//! ``` +//! +//! A boot that hands the machine back on purpose proves its image, and the +//! pass that reads so raises the anti-rollback floor ([`crate::policy`]) — +//! **never to a version read here**: the file is on a partition the running +//! system writes, so what it names is only which slot's signed header the +//! loader verifies again, and the digest that header must hash to. + +use crate::slots::Which; +use crate::Digest; + +/// The file's length. +pub const BYTES: usize = 16 + 1 + 1 + 6 + 8 + 32 + 32 + 32; + +/// The image a pass handed the machine to. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Booted { + pub slot: Which, + pub version: u64, + /// The SHA-256 of its signed header, which names the image exactly. + pub digest: Digest, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub struct Record { + /// How many times this stick's images have had the machine without + /// reporting back: the loader's bound on a hang. + pub count: u8, + pub booted: Option, + /// Per slot, the signed-header digest of an image that died there. + pub dead: [Option; 2], +} + +/// How the last boot ended, as the loader's black box and count say. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Ended { + /// It handed the machine back on purpose. + Proven, + /// It panicked, faulted, wedged, or never reached its own panic path. + Died, + /// It had the machine and the black box says nothing, twice: a hang, or a + /// power cut, which the loader cannot tell apart. + Hung, + /// Nothing says: a first boot, a power cut on a machine with no black + /// box, or a boot this record did not see. + Unknown, +} + +/// What the loader learns from how the last boot ended. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Accounted { + /// The record this pass writes first, before it has chosen a slot: it + /// names no booted image, so a pass whose second write fails leaves no + /// earlier pass's image on the disk for the next pass to credit. + pub record: Record, + /// The image the record says the last boot ran, where that boot handed + /// the machine back on purpose: a claim the loader verifies before the + /// floor moves. + pub proven: Option, + /// The slot whose image died, now recorded dead. + pub died: Option, +} + +/// Fold how the last boot ended into the record, given the anti-rollback +/// `floor` a boot has proven: an image at or below it has run well before. +pub fn account(record: Record, ended: Ended, floor: u64) -> Accounted { + let mut out = Accounted { record: Record { booted: None, ..record }, proven: None, died: None }; + let Some(booted) = record.booted else { return out }; + let dies = match ended { + Ended::Proven => { + out.proven = Some(booted); + false + } + Ended::Died => true, + Ended::Hung => booted.version > floor, + Ended::Unknown => false, + }; + if dies { + out.record.dead[booted.slot.index()] = Some(booted.digest); + out.died = Some(booted.slot); + } + out +} + +/// Whether the image whose signed header hashes to `digest` died in `slot`. +pub fn died(record: &Record, slot: Which, digest: &Digest) -> bool { + record.dead[slot.index()].as_ref() == Some(digest) +} + +impl Record { + /// The file's bytes, for the partition `guid` names. + pub fn encode(&self, guid: &[u8; 16]) -> [u8; BYTES] { + let mut out = [0u8; BYTES]; + out[..16].copy_from_slice(guid); + out[16] = self.count; + if let Some(booted) = self.booted { + out[17] = booted.slot.letter() as u8; + out[24..32].copy_from_slice(&booted.version.to_le_bytes()); + out[32..64].copy_from_slice(&booted.digest); + } + for (i, dead) in self.dead.iter().enumerate() { + if let Some(digest) = dead { + out[64 + 32 * i..96 + 32 * i].copy_from_slice(digest); + } + } + out + } + + /// The record the file holds, or why it is not one for `guid`'s partition. + pub fn decode(bytes: &[u8], guid: &[u8; 16]) -> Result { + let bytes: &[u8; BYTES] = bytes.try_into().map_err(|_| Foreign::Length(bytes.len()))?; + if bytes[..16] != guid[..] { + return Err(Foreign::Partition(bytes[..16].try_into().expect("sixteen bytes"))); + } + let digest = |at: usize| -> Option { + let d: Digest = bytes[at..at + 32].try_into().expect("32 bytes"); + (d != [0; 32]).then_some(d) + }; + let booted = match bytes[17] { + 0 => None, + letter => Some(Booted { + slot: Which::from_letter(letter as char).ok_or(Foreign::Slot(letter))?, + version: u64::from_le_bytes(bytes[24..32].try_into().expect("eight bytes")), + digest: digest(32).ok_or(Foreign::Slot(letter))?, + }), + }; + Ok(Self { count: bytes[16], booted, dead: [digest(64), digest(96)] }) + } +} + +/// Why the file is not this partition's record. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Foreign { + Length(usize), + /// It counts for another partition. + Partition([u8; 16]), + /// It names a booted slot that is none, or one with no image. + Slot(u8), +} + +impl core::fmt::Display for Foreign { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::Length(n) => write!(f, "holds {n} bytes, wanted {BYTES}"), + Self::Partition(g) => write!(f, "counts for {g:02x?}, another partition"), + Self::Slot(b) => write!(f, "names slot {b:#04x}, which is no slot it booted"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const GUID: [u8; 16] = [7; 16]; + + fn booted(slot: Which) -> Record { + Record { count: 1, booted: Some(Booted { slot, version: 42, digest: [9; 32] }), dead: [None, Some([3; 32])] } + } + + #[test] + fn a_record_reads_back_and_anothers_is_refused() { + for r in [Record::default(), booted(Which::A), booted(Which::B)] { + assert_eq!(Record::decode(&r.encode(&GUID), &GUID), Ok(r)); + } + assert_eq!(Record::decode(&booted(Which::A).encode(&[1; 16]), &GUID), Err(Foreign::Partition([1; 16]))); + assert_eq!(Record::decode(&[0; 17], &GUID), Err(Foreign::Length(17))); + let mut bent = booted(Which::A).encode(&GUID); + bent[17] = b'C'; + assert_eq!(Record::decode(&bent, &GUID), Err(Foreign::Slot(b'C'))); + } + + /// A death marks exactly the image that died, in the slot it died in; a + /// proven boot raises the floor and marks nothing; an unknown end does neither. + #[test] + fn how_a_boot_ended_is_what_it_is_recorded_as() { + let last = booted(Which::A); + let died = account(last, Ended::Died, 42); + assert_eq!(died.died, Some(Which::A)); + assert!(super::died(&died.record, Which::A, &[9; 32])); + assert!(!super::died(&died.record, Which::A, &[8; 32]), "another image in the same slot"); + assert!(super::died(&died.record, Which::B, &[3; 32]), "the other slot's record stands"); + assert_eq!(died.proven, None); + + let proven = account(last, Ended::Proven, 0); + assert_eq!((proven.proven, proven.died), (last.booted, None)); + let unknown = account(last, Ended::Unknown, 0); + assert_eq!((unknown.proven, unknown.died), (None, None)); + assert_eq!(unknown.record.dead, last.dead); + assert_eq!(account(Record::default(), Ended::Died, 0).died, None, "nothing was booted"); + } + + /// **The first write names no image, however the last boot ended**: a pass + /// whose second write — the slot it chose — fails boots with this on the + /// disk, and a clean end of that boot must credit nothing rather than the + /// image an earlier pass booted. + #[test] + fn the_first_write_credits_no_image() { + let last = booted(Which::B); + for ended in [Ended::Proven, Ended::Died, Ended::Hung, Ended::Unknown] { + let first = account(last, ended, 0).record; + assert_eq!(first.booted, None, "{ended:?}"); + let next = account(Record::decode(&first.encode(&GUID), &GUID).expect("a record"), Ended::Proven, 0); + assert_eq!((next.proven, next.died), (None, None), "{ended:?}: the pass after credits nothing"); + } + } + + /// A hang is a death only for an image no boot has proven: at or below the + /// floor it is a power cut as far as anything here can tell. + #[test] + fn a_hang_kills_only_an_unproven_image() { + let last = booted(Which::B); + assert_eq!(account(last, Ended::Hung, 41).died, Some(Which::B), "version 42 over a floor of 41"); + assert_eq!(account(last, Ended::Hung, 42).died, None, "version 42 at a floor of 42"); + assert_eq!(account(last, Ended::Died, 100).died, Some(Which::B), "a panic is a death whatever the floor"); + } +} diff --git a/toyos-update/src/sig.rs b/toyos-update/src/sig.rs new file mode 100644 index 00000000000..6f19663be15 --- /dev/null +++ b/toyos-update/src/sig.rs @@ -0,0 +1,199 @@ +//! The signature over an image's header: Ed25519, over the message OpenSSH's +//! SSHSIG format builds (`PROTOCOL.sshsig`), in the [`NAMESPACE`] this tree +//! owns. +//! +//! ```text +//! signed data "SSHSIG" | string NAMESPACE | string "" | string "sha512" +//! | string SHA-512(header) +//! ``` +//! +//! where `string` is a big-endian `u32` length and the bytes. **Why SSHSIG +//! and not the header raw**: it is exactly what `ssh-keygen -Y sign -n +//! toyos-image` signs, so an image signed by an implementation this tree did +//! not write verifies here, and an owner may hold the key in any agent or +//! token OpenSSH can sign with. The namespace is what stops a signature the +//! owner's key made for anything else — a git commit, a file — from verifying +//! as an image. + +use ed25519_dalek::{Signature, VerifyingKey}; +use sha2::{Digest as _, Sha512}; + +use crate::image::{HEADER_BYTES, SIGNATURE_BYTES}; + +/// The SSHSIG namespace every image is signed in. +pub const NAMESPACE: &str = "toyos-image"; + +const PREAMBLE: &[u8; 6] = b"SSHSIG"; +const HASH: &str = "sha512"; + +/// The signed data's length: the preamble, four strings, and a SHA-512. +pub const MESSAGE_BYTES: usize = 6 + (4 + NAMESPACE.len()) + 4 + (4 + HASH.len()) + (4 + 64); + +/// The bytes Ed25519 signs for `header`. +pub fn message(header: &[u8; HEADER_BYTES]) -> [u8; MESSAGE_BYTES] { + let mut out = [0u8; MESSAGE_BYTES]; + let mut at = 0; + let mut put = |bytes: &[u8]| { + out[at..at + bytes.len()].copy_from_slice(bytes); + at += bytes.len(); + }; + put(PREAMBLE); + for field in [NAMESPACE.as_bytes(), b"", HASH.as_bytes()] { + put(&(field.len() as u32).to_be_bytes()); + put(field); + } + put(&64u32.to_be_bytes()); + put(&Sha512::digest(header)); + out +} + +/// Why a signature does not vouch for a header. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Refused { + /// The embedded key is not a point on the curve: the binary was built wrong. + Key, + /// The signature is not the key's over this header. + Signature, +} + +impl core::fmt::Display for Refused { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::Key => write!(f, "the embedded image key is not an Ed25519 public key"), + Self::Signature => write!(f, "the signature is not this machine's key's over the header"), + } + } +} + +/// Whether `signature` is `key`'s over `header`. +/// +/// `verify_strict`: a signature with a non-canonical `S`, or a key of small +/// order, is refused rather than accepted by one implementation and not +/// another. +pub fn verify(key: &[u8; 32], header: &[u8; HEADER_BYTES], signature: &[u8; SIGNATURE_BYTES]) -> Result<(), Refused> { + let key = VerifyingKey::from_bytes(key).map_err(|_| Refused::Key)?; + key.verify_strict(&message(header), &Signature::from_bytes(signature)) + .map_err(|_| Refused::Signature) +} + +/// The public key of the seed `seed`. +pub fn public_of(seed: &[u8; 32]) -> [u8; 32] { + ed25519_dalek::SigningKey::from_bytes(seed).verifying_key().to_bytes() +} + +/// `seed`'s signature over `header`: the host's half, and no binary on the +/// machine is built with it. +#[cfg(feature = "sign")] +pub fn sign(seed: &[u8; 32], header: &[u8; HEADER_BYTES]) -> [u8; SIGNATURE_BYTES] { + use ed25519_dalek::Signer as _; + ed25519_dalek::SigningKey::from_bytes(seed).sign(&message(header)).to_bytes() +} + +/// A 32-byte key from 64 hex digits, at compile time: how the loader and the +/// updater take the key the build embeds. Anything else fails the build. +pub const fn key_from_hex(text: &str) -> [u8; 32] { + let bytes = text.as_bytes(); + assert!(bytes.len() == 64, "the image key is 64 hex digits"); + let mut out = [0u8; 32]; + let mut i = 0; + while i < 32 { + out[i] = (digit(bytes[2 * i]) << 4) | digit(bytes[2 * i + 1]); + i += 1; + } + out +} + +const fn digit(c: u8) -> u8 { + match c { + b'0'..=b'9' => c - b'0', + b'a'..=b'f' => c - b'a' + 10, + _ => panic!("the image key is lowercase hex"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// RFC 8032 §7.1, TEST 1: the empty message. + const RFC_1: ([u8; 32], [u8; 32], &[u8], [u8; 64]) = ( + hex32("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60"), + hex32("d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a"), + b"", + hex64( + "e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b", + ), + ); + + /// RFC 8032 §7.1, TEST 2: one byte. + const RFC_2: ([u8; 32], [u8; 32], &[u8], [u8; 64]) = ( + hex32("4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6fb"), + hex32("3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c"), + &[0x72], + hex64( + "92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00", + ), + ); + + /// RFC 8032 §7.1, TEST 3: two bytes. + const RFC_3: ([u8; 32], [u8; 32], &[u8], [u8; 64]) = ( + hex32("c5aa8df43f9f837bedb7442f31dcb7b166d38535076f094b85ce3a2e0b4458f7"), + hex32("fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025"), + &[0xaf, 0x82], + hex64( + "6291d657deec24024827e69c3abe01a30ce548a284743a445e3680d7db5ac3ac18ff9b538d16f290ae67f760984dc6594a7c15e9716ed28dc027beceea1ec40a", + ), + ); + + const fn hex32(text: &str) -> [u8; 32] { + key_from_hex(text) + } + + const fn hex64(text: &str) -> [u8; 64] { + let b = text.as_bytes(); + assert!(b.len() == 128); + let mut out = [0u8; 64]; + let mut i = 0; + while i < 64 { + out[i] = (digit(b[2 * i]) << 4) | digit(b[2 * i + 1]); + i += 1; + } + out + } + + /// **The external oracle for the primitive**: the RFC's own keys, messages + /// and signatures. The key is derived from the seed, the signature checks + /// under the verifier this crate calls, and a flipped bit anywhere in the + /// signature or the message is refused. + #[test] + fn rfc_8032_vectors_verify_and_a_flipped_bit_does_not() { + for (seed, public, msg, sig) in [RFC_1, RFC_2, RFC_3] { + assert_eq!(public_of(&seed), public, "the public key derived from the RFC's seed"); + let key = VerifyingKey::from_bytes(&public).expect("the RFC's key"); + key.verify_strict(msg, &Signature::from_bytes(&sig)).expect("the RFC's signature"); + for bit in [0, 255, 256, 511] { + let mut bent = sig; + bent[bit / 8] ^= 1 << (bit % 8); + assert!(key.verify_strict(msg, &Signature::from_bytes(&bent)).is_err(), "bit {bit}"); + } + let mut longer = msg.to_vec(); + longer.push(0); + assert!(key.verify_strict(&longer, &Signature::from_bytes(&sig)).is_err()); + } + } + + /// The signed data is PROTOCOL.sshsig's layout, byte for byte. + #[test] + fn the_message_is_sshsigs_signed_data() { + let header = [0xA5u8; HEADER_BYTES]; + let m = message(&header); + assert_eq!(&m[..6], b"SSHSIG"); + assert_eq!(&m[6..10], &11u32.to_be_bytes()); + assert_eq!(&m[10..21], b"toyos-image"); + assert_eq!(&m[21..25], &0u32.to_be_bytes()); + assert_eq!(&m[25..29], &6u32.to_be_bytes()); + assert_eq!(&m[29..35], b"sha512"); + assert_eq!(&m[35..39], &64u32.to_be_bytes()); + assert_eq!(&m[39..], &Sha512::digest(header)[..]); + } +} diff --git a/toyos-update/src/slots.rs b/toyos-update/src/slots.rs new file mode 100644 index 00000000000..62850650fef --- /dev/null +++ b/toyos-update/src/slots.rs @@ -0,0 +1,417 @@ +//! The slot table: which partitions make each slot, and which slot is marked. +//! +//! It lives on its own partition of type `toyos_gpt::Guid::TOYOS_SLOTS`, in two copies at +//! blocks 0 and 1, and **a writer writes the copy that is not the current +//! one**, with a sequence one past it. A write that tears leaves that copy +//! unreadable and the current one standing, so moving the mark is atomic on a +//! device that tears a block: the machine boots the old mark or the new one, +//! never neither. +//! +//! ```text +//! magic "TOYOSLOT" | format u32 | marked u32 | sequence u64 +//! then per slot: present u32 | 0 u32 | boot guid [16] | root guid [16] | version u64 +//! then crc32 u32 over everything before it (TABLE_BYTES) +//! ``` +//! +//! The version a slot records is what its writer installed, and is the +//! updater's to compare against; the loader trusts nothing here but which +//! partitions to read and which slot is marked, and judges each slot by its +//! own signed header. + +/// The unit the table's copies are written in. +pub const BLOCK: usize = 4096; + +/// Blocks the table's partition must hold: its two copies. +pub const COPIES: u64 = 2; + +const MAGIC: [u8; 8] = *b"TOYOSLOT"; +const FORMAT: u32 = 1; +const SLOT_BYTES: usize = 4 + 4 + 16 + 16 + 8; +const BODY_BYTES: usize = 8 + 4 + 4 + 8 + 2 * SLOT_BYTES; +/// A copy's bytes, checksum included; the rest of its block is zero. +pub const TABLE_BYTES: usize = BODY_BYTES + 4; + +/// Where each slot's kernel, boot parameter and signed header are on its FAT +/// partition, as paths from the volume's root. +pub const KERNEL_FILE: &str = "toyos/kernel.elf"; +pub const CMDLINE_FILE: &str = "toyos/cmdline"; +pub const SIGNED_FILE: &str = "toyos/image.sig"; + +/// A slot's two partitions, by their unique GUIDs as a GPT entry stores them. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Slot { + pub boot: [u8; 16], + pub root: [u8; 16], + /// The version its writer installed; `0` for a slot nothing has been installed in. + pub version: u64, +} + +/// Slot `A` or slot `B`, by index. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Which { + A, + B, +} + +impl Which { + pub const fn index(self) -> usize { + match self { + Self::A => 0, + Self::B => 1, + } + } + + pub const fn other(self) -> Self { + match self { + Self::A => Self::B, + Self::B => Self::A, + } + } + + pub const fn letter(self) -> char { + match self { + Self::A => 'A', + Self::B => 'B', + } + } + + pub const fn from_letter(c: char) -> Option { + match c { + 'A' => Some(Self::A), + 'B' => Some(Self::B), + _ => None, + } + } +} + +/// One copy of the table. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Table { + pub sequence: u64, + pub marked: Which, + /// Slot `A` then slot `B`; `None` for a machine built with one slot. + pub slots: [Option; 2], +} + +/// Why a copy is not a table. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Unreadable { + Magic, + Format(u32), + Checksum, + /// It marks a slot it does not carry, or a mark that is no slot. + Mark(u32), +} + +impl core::fmt::Display for Unreadable { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match *self { + Self::Magic => write!(f, "no slot table"), + Self::Format(n) => write!(f, "a slot table of format {n}, and this reads {FORMAT}"), + Self::Checksum => write!(f, "a slot table whose checksum does not hold, which is a torn write"), + Self::Mark(n) => write!(f, "a slot table marking slot {n}, which it does not carry"), + } + } +} + +impl Table { + pub fn slot(&self, which: Which) -> Option { + self.slots[which.index()] + } + + pub fn encode(&self) -> [u8; BLOCK] { + let mut out = [0u8; BLOCK]; + out[..8].copy_from_slice(&MAGIC); + out[8..12].copy_from_slice(&FORMAT.to_le_bytes()); + out[12..16].copy_from_slice(&(self.marked.index() as u32).to_le_bytes()); + out[16..24].copy_from_slice(&self.sequence.to_le_bytes()); + for (i, slot) in self.slots.iter().enumerate() { + let at = 24 + i * SLOT_BYTES; + if let Some(slot) = slot { + out[at..at + 4].copy_from_slice(&1u32.to_le_bytes()); + out[at + 8..at + 24].copy_from_slice(&slot.boot); + out[at + 24..at + 40].copy_from_slice(&slot.root); + out[at + 40..at + 48].copy_from_slice(&slot.version.to_le_bytes()); + } + } + let crc = crc32(&out[..BODY_BYTES]); + out[BODY_BYTES..TABLE_BYTES].copy_from_slice(&crc.to_le_bytes()); + out + } + + pub fn decode(block: &[u8; BLOCK]) -> Result { + if block[..8] != MAGIC { + return Err(Unreadable::Magic); + } + let word = |at: usize| u32::from_le_bytes(block[at..at + 4].try_into().expect("four bytes")); + let format = word(8); + if format != FORMAT { + return Err(Unreadable::Format(format)); + } + if crc32(&block[..BODY_BYTES]) != word(BODY_BYTES) { + return Err(Unreadable::Checksum); + } + let mut slots = [None; 2]; + for (i, slot) in slots.iter_mut().enumerate() { + let at = 24 + i * SLOT_BYTES; + if word(at) == 1 { + *slot = Some(Slot { + boot: block[at + 8..at + 24].try_into().expect("sixteen bytes"), + root: block[at + 24..at + 40].try_into().expect("sixteen bytes"), + version: u64::from_le_bytes(block[at + 40..at + 48].try_into().expect("eight bytes")), + }); + } + } + let marked = match word(12) { + 0 if slots[0].is_some() => Which::A, + 1 if slots[1].is_some() => Which::B, + other => return Err(Unreadable::Mark(other)), + }; + let sequence = u64::from_le_bytes(block[16..24].try_into().expect("eight bytes")); + Ok(Self { sequence, marked, slots }) + } +} + +/// The table two copies make: the readable one with the higher sequence, and +/// which copy that is. Both unreadable is the first copy's reason. +pub fn current(copies: [&[u8; BLOCK]; 2]) -> Result<(Table, usize), Unreadable> { + match (Table::decode(copies[0]), Table::decode(copies[1])) { + (Ok(a), Ok(b)) if b.sequence > a.sequence => Ok((b, 1)), + (Ok(a), _) => Ok((a, 0)), + (Err(_), Ok(b)) => Ok((b, 1)), + (Err(why), Err(_)) => Err(why), + } +} + +/// What a writer puts where, to make `next` the table: the copy that is not +/// `current`'s, one sequence past it. +pub fn next_write(current: (Table, usize), mut next: Table) -> (usize, [u8; BLOCK]) { + next.sequence = current.0.sequence + 1; + (1 - current.1, next.encode()) +} + +/// The labels `/system/bin/init` endows a `slots` grant under, and +/// `/system/bin/update` takes it by: the slot table's partition, and the idle +/// slot's FAT volume and ROOT, each a partition claim. +pub const TABLE_LABEL: &str = "slots:table"; +pub const BOOT_LABEL: &str = "slots:boot"; +pub const ROOT_LABEL: &str = "slots:root"; + +/// The one program a build lets hold the grant: `/system/bin/update`. +pub const HOLDER: &str = "update"; + +/// Why a machine has no slot to write. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum NoIdle { + /// The table carries one slot, and that is the one running. + OneSlot, + /// Neither slot's ROOT is the one the kernel holds: this table is not + /// the one this boot came from. + NotThisBoot, + /// The idle slot names a partition that is no idle slot's. + Stray { part: &'static str, why: Stray }, +} + +/// Why a partition the idle slot names is not one a grant may claim. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Stray { + /// It is one of the running slot's. + Running, + /// The machine lists no partition with its GUID. + Unlisted, + /// It is on another disk than the one this boot runs from. + OtherDisk, + /// Its type is not the one a slot's partition of that kind carries. + Type, + /// Two listed partitions carry its GUID. + Duplicate, +} + +impl core::fmt::Display for NoIdle { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::OneSlot => write!(f, "the slot table carries one slot, and the machine runs it"), + Self::NotThisBoot => write!(f, "neither slot's ROOT is the one this boot runs"), + Self::Stray { part, why } => { + let why = match why { + Stray::Running => "is one of the running slot's", + Stray::Unlisted => "is no partition this machine lists", + Stray::OtherDisk => "is on another disk than the one this boot runs from", + Stray::Type => "is not of the type a slot's partition of that kind carries", + Stray::Duplicate => "names a GUID two listed partitions carry", + }; + write!(f, "the idle slot's {part} {why}") + } + } + } +} + +/// A partition as the machine's inventory lists it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Listed { + pub device: u32, + pub type_guid: [u8; 16], + pub unique_guid: [u8; 16], +} + +/// The partition types a slot's volume and its ROOT carry, as a GPT entry +/// stores them (`toyos_gpt::Guid::TOYOS_BOOT` and `TOYOS_ROOT`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Kinds { + pub boot: [u8; 16], + pub root: [u8; 16], +} + +/// The idle slot a grant may claim, given the ROOT the kernel holds +/// (`running`) and every partition the machine lists. +/// +/// **The table is the grantee's to write**, so nothing it names is taken on +/// its word: each of the idle slot's two partitions must be on the running +/// ROOT's disk, of its kind's type, and neither of the running slot's — else +/// the holder of one grant could name its next one anywhere. +pub fn grant(table: &Table, running: &Listed, listed: &[Listed], kinds: Kinds) -> Result<(Which, Slot), NoIdle> { + let (idle, slot) = idle(table, &running.unique_guid)?; + let runs = table.slot(idle.other()).expect("`idle` found the running slot in the table"); + for (part, guid, kind) in [("volume", slot.boot, kinds.boot), ("ROOT", slot.root, kinds.root)] { + let mut named = listed.iter().filter(|p| p.unique_guid == guid); + let why = match (named.next(), named.next()) { + _ if guid == runs.boot || guid == runs.root => Some(Stray::Running), + (None, _) => Some(Stray::Unlisted), + (Some(p), None) if p.device != running.device => Some(Stray::OtherDisk), + (Some(p), None) if p.type_guid != kind => Some(Stray::Type), + (Some(_), None) => None, + // The kernel refuses a claim of a GUID two tables carry; so does this. + (Some(_), Some(_)) => Some(Stray::Duplicate), + }; + if let Some(why) = why { + return Err(NoIdle::Stray { part, why }); + } + } + Ok((idle, slot)) +} + +/// The slot the machine is not running, given the ROOT the kernel holds — +/// **what makes the running slot unwritable by construction**: the grant is +/// only ever the other one. +pub fn idle(table: &Table, running_root: &[u8; 16]) -> Result<(Which, Slot), NoIdle> { + let running = [Which::A, Which::B] + .into_iter() + .find(|&w| table.slot(w).is_some_and(|s| s.root == *running_root)) + .ok_or(NoIdle::NotThisBoot)?; + let idle = running.other(); + table.slot(idle).map(|slot| (idle, slot)).ok_or(NoIdle::OneSlot) +} + +/// CRC-32 (IEEE 802.3, reflected), the checksum GPT uses; a torn write is what +/// it catches, not an adversary. +fn crc32(bytes: &[u8]) -> u32 { + let mut crc = !0u32; + for byte in bytes { + crc ^= u32::from(*byte); + for _ in 0..8 { + crc = if crc & 1 != 0 { (crc >> 1) ^ 0xEDB8_8320 } else { crc >> 1 }; + } + } + !crc +} + +#[cfg(test)] +mod tests { + use super::*; + + fn table(marked: Which, sequence: u64) -> Table { + let slot = |n: u8| Some(Slot { boot: [n; 16], root: [n + 1; 16], version: u64::from(n) }); + Table { sequence, marked, slots: [slot(1), slot(3)] } + } + + /// The check value every CRC-32 is held to. + #[test] + fn the_checksum_is_crc32() { + assert_eq!(crc32(b"123456789"), 0xCBF4_3926); + } + + #[test] + fn a_table_reads_back_and_a_bent_one_is_refused() { + let t = table(Which::B, 9); + assert_eq!(Table::decode(&t.encode()), Ok(t)); + let mut torn = t.encode(); + torn[40] ^= 1; + assert_eq!(Table::decode(&torn), Err(Unreadable::Checksum)); + assert_eq!(Table::decode(&[0; BLOCK]), Err(Unreadable::Magic)); + let one = Table { slots: [t.slots[0], None], ..t }; + let mut marks_absent = one.encode(); + // Re-encode with a mark on the absent slot, checksum and all. + marks_absent[12..16].copy_from_slice(&1u32.to_le_bytes()); + let crc = crc32(&marks_absent[..BODY_BYTES]); + marks_absent[BODY_BYTES..TABLE_BYTES].copy_from_slice(&crc.to_le_bytes()); + assert_eq!(Table::decode(&marks_absent), Err(Unreadable::Mark(1))); + } + + /// **The write that tears is never the one read**: the writer writes the + /// copy that is not current, so a torn one leaves the old table, and a + /// whole one is the new table by its sequence. + #[test] + fn moving_the_mark_is_atomic_against_a_torn_copy() { + let old = table(Which::A, 4); + let (a, b) = (old.encode(), table(Which::A, 3).encode()); + let now = current([&a, &b]).expect("a table"); + assert_eq!(now, (old, 0)); + let (copy, written) = next_write(now, table(Which::B, 0)); + assert_eq!(copy, 1); + assert_eq!(current([&a, &written]).expect("a table").0.marked, Which::B); + assert_eq!(current([&a, &written]).expect("a table").0.sequence, 5); + let mut torn = written; + torn[100] ^= 0xFF; + assert_eq!(current([&a, &torn]).expect("a table"), (old, 0)); + assert_eq!(current([&[0; BLOCK], &[0; BLOCK]]), Err(Unreadable::Magic)); + } + + /// The grant is the slot the kernel is not running from, and a table this + /// boot did not come from, or one with nothing idle, grants nothing. + #[test] + fn the_idle_slot_is_the_one_whose_root_the_kernel_does_not_hold() { + let t = table(Which::A, 1); + assert_eq!(idle(&t, &[2; 16]).map(|(w, _)| w), Ok(Which::B)); + assert_eq!(idle(&t, &[4; 16]).map(|(w, _)| w), Ok(Which::A)); + assert_eq!(idle(&t, &[9; 16]), Err(NoIdle::NotThisBoot)); + let one = Table { slots: [t.slots[0], None], ..t }; + assert_eq!(idle(&one, &[2; 16]), Err(NoIdle::OneSlot)); + } + + /// **A table the grantee wrote names nothing outside an idle slot**: a + /// partition of the running slot, another disk's, another type's — the + /// ESP's or the log partition's — or one the machine does not list is + /// refused by name, and the table the build wrote is granted. + #[test] + fn a_grant_claims_only_an_idle_slot_on_the_running_disk() { + const KINDS: Kinds = Kinds { boot: [0xB0; 16], root: [0xA0; 16] }; + const ESP: [u8; 16] = [0xE5; 16]; + let at = |device, type_guid, unique_guid| Listed { device, type_guid, unique_guid }; + let running = at(0, KINDS.root, [2; 16]); + let listed = [ + running, + at(0, KINDS.boot, [1; 16]), + at(0, KINDS.boot, [3; 16]), + at(0, KINDS.root, [4; 16]), + at(0, [0xEF; 16], ESP), + at(1, KINDS.boot, [5; 16]), + ]; + let t = table(Which::A, 1); + assert_eq!(grant(&t, &running, &listed, KINDS).map(|(w, _)| w), Ok(Which::B)); + let bent = |boot: [u8; 16], root: [u8; 16]| { + let mut t = t; + t.slots[1] = Some(Slot { boot, root, version: 0 }); + grant(&t, &running, &listed, KINDS) + }; + let stray = |part, why| Err(NoIdle::Stray { part, why }); + assert_eq!(bent([1; 16], [4; 16]), stray("volume", Stray::Running), "the running slot's volume"); + assert_eq!(bent([3; 16], [2; 16]), stray("ROOT", Stray::Running), "the running ROOT"); + assert_eq!(bent(ESP, [4; 16]), stray("volume", Stray::Type), "the ESP"); + assert_eq!(bent([3; 16], [3; 16]), stray("ROOT", Stray::Type), "a volume named as ROOT"); + assert_eq!(bent([5; 16], [4; 16]), stray("volume", Stray::OtherDisk), "another disk's"); + assert_eq!(bent([9; 16], [4; 16]), stray("volume", Stray::Unlisted)); + let mut twice = listed.to_vec(); + twice.push(at(1, KINDS.boot, [3; 16])); + assert_eq!(grant(&t, &running, &twice, KINDS), stray("volume", Stray::Duplicate), "a GUID two disks carry"); + } +} diff --git a/toyos-update/tests/fixtures/ssh-keygen-signed-header.sig b/toyos-update/tests/fixtures/ssh-keygen-signed-header.sig new file mode 100644 index 00000000000..28f128ee2ae --- /dev/null +++ b/toyos-update/tests/fixtures/ssh-keygen-signed-header.sig @@ -0,0 +1,6 @@ +-----BEGIN SSH SIGNATURE----- +U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgWDeuq6ibeR5EIpzG6ISSbfZh1g +6o8enc39xZTEnS/7IAAAALdG95b3MtaW1hZ2UAAAAAAAAABnNoYTUxMgAAAFMAAAALc3No +LWVkMjU1MTkAAABAscB9wiZe8D62StbkOswkgTxduwAmOj2jSGN3IAtZddlG8sBiLjtQ0E +SIarUv0tfed85GrCQitXdLzQ0a4f58Bg== +-----END SSH SIGNATURE----- diff --git a/toyos-update/tests/fixtures/ssh-keygen-signer.pub b/toyos-update/tests/fixtures/ssh-keygen-signer.pub new file mode 100644 index 00000000000..ab01549eb03 --- /dev/null +++ b/toyos-update/tests/fixtures/ssh-keygen-signer.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFg3rquom3keRCKcxuiEkm32YdYOqPHp3N/cWUxJ0v+y toyos-image-oracle diff --git a/toyos-update/tests/sshsig_oracle.rs b/toyos-update/tests/sshsig_oracle.rs new file mode 100644 index 00000000000..369e942c483 --- /dev/null +++ b/toyos-update/tests/sshsig_oracle.rs @@ -0,0 +1,106 @@ +//! **The independent oracle for the image signature**: a header signed by an +//! implementation this tree did not write verifies under the loader's and the +//! updater's verifier, and the same signature over one bent byte does not. +//! +//! The fixture is OpenSSH's own: `ssh-keygen -t ed25519` minted a throwaway +//! key, `ssh-keygen -Y sign -n toyos-image` signed [`oracle_header`]'s bytes, +//! and the private key was deleted — the signature and the public key are +//! committed, no secret is. The header is rebuilt here from the same inputs, so +//! the fixture is a signature over bytes this crate writes, not over a file it +//! only carries. Nothing runs `ssh-keygen` at test time. + +use toyos_update::image::{Header, HEADER_BYTES}; + +const SIGNATURE: &str = include_str!("fixtures/ssh-keygen-signed-header.sig"); +const PUBLIC: &str = include_str!("fixtures/ssh-keygen-signer.pub"); + +/// What was signed: fixed inputs, so this crate writes the same bytes again. +fn oracle_header() -> [u8; HEADER_BYTES] { + Header::of( + 20260926, + b"\x7fELF oracle kernel", + b"root=00000000-0000-0000-0000-000000000000", + &[0x5A; 4096], + ) + .encode() +} + +/// RFC 4648 base64, the armour's alphabet; test-only, so no crate for it. +fn base64(text: &str) -> Vec { + let value = |c: u8| -> u32 { + match c { + b'A'..=b'Z' => u32::from(c - b'A'), + b'a'..=b'z' => u32::from(c - b'a') + 26, + b'0'..=b'9' => u32::from(c - b'0') + 52, + b'+' => 62, + b'/' => 63, + _ => panic!("{c:#x} is not base64"), + } + }; + let digits: Vec = text.bytes().filter(|c| !c.is_ascii_whitespace() && *c != b'=').collect(); + let mut out = Vec::new(); + for chunk in digits.chunks(4) { + let n = chunk.iter().enumerate().fold(0u32, |acc, (i, &c)| acc | value(c) << (18 - 6 * i)); + out.extend_from_slice(&n.to_be_bytes()[1..chunk.len()]); + } + out +} + +/// One SSH wire `string` off the front of `bytes`. +fn string<'a>(bytes: &mut &'a [u8]) -> &'a [u8] { + let len = u32::from_be_bytes(bytes[..4].try_into().unwrap()) as usize; + let (s, rest) = bytes[4..].split_at(len); + *bytes = rest; + s +} + +/// `(public key, signature)` out of an SSHSIG blob, each field held to +/// PROTOCOL.sshsig on the way. +fn sshsig(blob: &[u8]) -> ([u8; 32], [u8; 64]) { + assert_eq!(&blob[..6], b"SSHSIG"); + assert_eq!(&blob[6..10], &1u32.to_be_bytes(), "SSHSIG version 1"); + let mut rest = &blob[10..]; + let mut public = string(&mut rest); + assert_eq!(string(&mut public), b"ssh-ed25519"); + let public: [u8; 32] = string(&mut public).try_into().unwrap(); + assert_eq!(string(&mut rest), toyos_update::sig::NAMESPACE.as_bytes()); + assert_eq!(string(&mut rest), b"", "the reserved field"); + assert_eq!(string(&mut rest), b"sha512"); + let mut signature = string(&mut rest); + assert_eq!(string(&mut signature), b"ssh-ed25519"); + let signature: [u8; 64] = string(&mut signature).try_into().unwrap(); + assert!(rest.is_empty()); + (public, signature) +} + +#[test] +fn a_header_ssh_keygen_signed_verifies_and_a_bent_one_does_not() { + let armour: String = SIGNATURE + .lines() + .filter(|line| !line.starts_with("-----")) + .collect(); + let (public, signature) = sshsig(&base64(&armour)); + + // The key the blob names is the one the `.pub` line carries. + let mut words = PUBLIC.split_whitespace(); + assert_eq!(words.next(), Some("ssh-ed25519")); + let mut blob = &base64(words.next().unwrap())[..]; + assert_eq!(string(&mut blob), b"ssh-ed25519"); + assert_eq!(string(&mut blob), public); + + let header = oracle_header(); + assert_eq!(toyos_update::sig::verify(&public, &header, &signature), Ok(())); + + for at in [0, 16, 100, HEADER_BYTES - 1] { + let mut bent = header; + bent[at] ^= 1; + assert_eq!( + toyos_update::sig::verify(&public, &bent, &signature), + Err(toyos_update::sig::Refused::Signature), + "a header bent at byte {at}" + ); + } + let mut other = public; + other[0] ^= 1; + assert!(toyos_update::sig::verify(&other, &header, &signature).is_err(), "another key"); +} diff --git a/toyos/Cargo.lock b/toyos/Cargo.lock index 54835f629c6..740426c05cf 100644 --- a/toyos/Cargo.lock +++ b/toyos/Cargo.lock @@ -10,7 +10,7 @@ checksum = "aa9c45b374136f52f2d6311062c7146bff20fec063c3f5d46a410bd937746955" [[package]] name = "toyos" -version = "0.16.0" +version = "0.17.0" dependencies = [ "rustc-std-workspace-core", "toyos-abi", @@ -18,7 +18,7 @@ dependencies = [ [[package]] name = "toyos-abi" -version = "0.14.0" +version = "0.15.0" dependencies = [ "rustc-std-workspace-core", ] diff --git a/toyos/Cargo.toml b/toyos/Cargo.toml index 19b860f64cd..747d20f1dfd 100644 --- a/toyos/Cargo.toml +++ b/toyos/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toyos" -version = "0.16.0" +version = "0.17.0" edition = "2021" license = "MIT OR Apache-2.0" description = "The ToyOS userland SDK: typed handles, IPC, ports, namespaces, surfaces and net." @@ -11,4 +11,4 @@ rustc-dep-of-std = ["core", "toyos-abi/rustc-dep-of-std"] [dependencies] core = { version = "1.0.0", optional = true, package = "rustc-std-workspace-core" } -toyos-abi = { path = "../toyos-abi", version = "0.14.0", default-features = false } +toyos-abi = { path = "../toyos-abi", version = "0.15.0", default-features = false } diff --git a/userland/Cargo.lock b/userland/Cargo.lock index 7307262455f..f6a1502c3b6 100644 --- a/userland/Cargo.lock +++ b/userland/Cargo.lock @@ -1632,9 +1632,11 @@ version = "0.1.0" dependencies = [ "toyos", "toyos-abi", + "toyos-gpt", "toyos-logstream", "toyos-manifest", "toyos-swap", + "toyos-update", ] [[package]] @@ -3805,8 +3807,6 @@ dependencies = [ "rand 0.10.0", "russh", "tokio", - "toyos", - "toyos-swap", "toyos-tmpdir", ] @@ -3847,6 +3847,14 @@ dependencies = [ "siphasher", ] +[[package]] +name = "swap" +version = "0.1.0" +dependencies = [ + "toyos", + "toyos-swap", +] + [[package]] name = "syn" version = "2.0.117" @@ -4032,14 +4040,14 @@ dependencies = [ [[package]] name = "toyos" -version = "0.16.0" +version = "0.17.0" dependencies = [ "toyos-abi", ] [[package]] name = "toyos-abi" -version = "0.14.0" +version = "0.15.0" [[package]] name = "toyos-blockhold" @@ -4063,6 +4071,13 @@ dependencies = [ name = "toyos-dns" version = "0.1.0" +[[package]] +name = "toyos-fat32" +version = "0.1.0" +dependencies = [ + "toyos-wallclock", +] + [[package]] name = "toyos-font" version = "0.2.0" @@ -4128,13 +4143,21 @@ version = "0.1.0" name = "toyos-tmpdir" version = "0.1.0" +[[package]] +name = "toyos-update" +version = "0.1.0" +dependencies = [ + "ed25519-dalek 2.2.0", + "sha2 0.10.9", +] + [[package]] name = "toyos-wallclock" version = "0.1.0" [[package]] name = "toyos-window" -version = "0.17.0" +version = "0.18.0" dependencies = [ "toyos", "toyos-abi", @@ -4224,6 +4247,17 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "update" +version = "0.1.0" +dependencies = [ + "sha2 0.10.9", + "toyos", + "toyos-abi", + "toyos-fat32", + "toyos-update", +] + [[package]] name = "ureq" version = "3.2.0" diff --git a/userland/Cargo.toml b/userland/Cargo.toml index 088795aaa15..810f0398a15 100644 --- a/userland/Cargo.toml +++ b/userland/Cargo.toml @@ -25,11 +25,13 @@ members = [ "soundd", "sprite", "sshd", + "swap", "terminal", "test-runner", "toybox", "toyos-font", "toyos-window", + "update", ] exclude = [ # no_std C runtime — uses panic=abort (own Cargo.toml profile) diff --git a/userland/init/Cargo.toml b/userland/init/Cargo.toml index 62b0bd26173..84b7a38fa87 100644 --- a/userland/init/Cargo.toml +++ b/userland/init/Cargo.toml @@ -10,3 +10,7 @@ toyos-abi = { path = "../../toyos-abi" } toyos-manifest = { path = "../../toyos-manifest" } toyos-logstream = { path = "../../toyos-logstream" } toyos-swap = { path = "../../toyos-swap" } +# The slot table and which slot is idle, for the one row that asks for it. +toyos-update = { path = "../../toyos-update" } +# The partition types that name the running ROOT and the slot table. +toyos-gpt = { path = "../../toyos-gpt" } diff --git a/userland/init/src/main.rs b/userland/init/src/main.rs index 5bd9ffa1250..085ee0b9517 100644 --- a/userland/init/src/main.rs +++ b/userland/init/src/main.rs @@ -1100,6 +1100,76 @@ fn resolve<'a>(system: &'a Manifest, path: &str) -> Resolved<'a> { Resolved::Package(system.app_row(name, path)) } +/// The slot table's partition and the idle slot's two, claimed: the grant a +/// `slots` row is endowed (`toyos_update::slots`). +/// +/// **Which slot is idle is the kernel's word, not the table's**: the running +/// ROOT is the TOYOS-ROOT partition the kernel holds, the table is the one on +/// that ROOT's disk, and the idle slot is the one whose ROOT is not it — and +/// its two partitions are claimed only as `toyos_update::slots::grant` admits +/// them. +fn slot_grant(syscap: &SysCap) -> Result<[(&'static str, toyos::Device); 3], String> { + use toyos_abi::inventory::{PartState, Partition, RawRecord, Record}; + let asked = syscap.inventory(&mut []).map_err(|e| format!("the inventory would not count: {e:?}"))?; + let mut raw = vec![RawRecord::EMPTY; asked]; + let n = syscap.inventory(&mut raw).map_err(|e| format!("the inventory would not read: {e:?}"))?; + let parts: Vec = raw[..n] + .iter() + .filter_map(|r| match Record::decode(r) { + Ok(Record::Partition(p)) => Some(p), + _ => None, + }) + .collect(); + let one = |what: &str, found: Vec<&Partition>| match found[..] { + [p] => Ok(*p), + _ => Err(format!("the machine has {} {what}, and a grant needs one", found.len())), + }; + let running = one( + "ROOT partitions the kernel holds", + parts + .iter() + .filter(|p| p.type_guid == toyos_gpt::Guid::TOYOS_ROOT.0 && p.state == PartState::Kernel) + .collect(), + )?; + let table_part = one( + "slot tables on the running ROOT's disk", + parts + .iter() + .filter(|p| p.device == running.device && p.type_guid == toyos_gpt::Guid::TOYOS_SLOTS.0) + .collect(), + )?; + let claim = |guid: [u8; 16], what: &str| { + syscap + .claim_partition::(toyos_abi::part::PartGuid(guid)) + .map_err(|e| refused(&format!("the {what}"), e)) + }; + let table_claim = claim(table_part.unique_guid, "slot table")?; + let mut copies: [toyos_abi::part::Block; 2] = [[0; toyos_abi::part::BLOCK_BYTES]; 2]; + toyos_abi::syscall::partition_read(table_claim.as_handle(), 0, &mut copies) + .map_err(|e| format!("the slot table would not read: {e:?}"))?; + let (table, _) = toyos_update::slots::current([&copies[0], &copies[1]]) + .map_err(|why| format!("the slot table's partition holds {why}"))?; + // The table is the grantee's to write, so what it names is held to the + // inventory before anything is claimed. + let listed = |p: &Partition| toyos_update::slots::Listed { + device: p.device, + type_guid: p.type_guid, + unique_guid: p.unique_guid, + }; + let kinds = toyos_update::slots::Kinds { boot: toyos_gpt::Guid::TOYOS_BOOT.0, root: toyos_gpt::Guid::TOYOS_ROOT.0 }; + let all: Vec<_> = parts.iter().map(listed).collect(); + let (idle, slot) = + toyos_update::slots::grant(&table, &listed(&running), &all, kinds).map_err(|why| why.to_string())?; + let boot = claim(slot.boot, "idle slot's volume")?; + let root = claim(slot.root, "idle slot's ROOT")?; + say!("init: the idle slot is {}, granted with the slot table", idle.letter()); + Ok([ + (toyos_update::slots::TABLE_LABEL, table_claim), + (toyos_update::slots::BOOT_LABEL, boot), + (toyos_update::slots::ROOT_LABEL, root), + ]) +} + /// What init says about a device it could not mint a claim for. /// /// One arm per refusal the kernel distinguishes (`kernel/src/device.rs`'s @@ -1322,6 +1392,22 @@ fn start<'a>( Err(e) => say!("init: {}: {}", program.name, refused(name, e)), } } + // The idle slot, for the one program whose row asks for it: minted here, + // against the ROOT the kernel holds, so the slot this boot runs is never + // among what is endowed. A machine with none says so and the program + // starts holding nothing, which it refuses by name. + if program.slots { + match slot_grant(syscap) { + Ok(claims) => { + for (label, claim) in claims { + let raw = claim.into_raw(); + command.endow(label, raw.0); + held.0.push(raw); + } + } + Err(why) => say!("init: {}: no slot to grant: {why}", program.name), + } + } // Nothing was spawned, so everything minted goes back with `held`. if let Some(unpaid) = unpaid { return Err(unpaid); diff --git a/userland/libc/Cargo.lock b/userland/libc/Cargo.lock index 2ef17227918..0c45efcff80 100644 --- a/userland/libc/Cargo.lock +++ b/userland/libc/Cargo.lock @@ -27,14 +27,14 @@ checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" [[package]] name = "toyos" -version = "0.16.0" +version = "0.17.0" dependencies = [ "toyos-abi", ] [[package]] name = "toyos-abi" -version = "0.14.0" +version = "0.15.0" [[package]] name = "toyos-libc" diff --git a/userland/sshd/Cargo.toml b/userland/sshd/Cargo.toml index 65d0122644c..ac240c61504 100644 --- a/userland/sshd/Cargo.toml +++ b/userland/sshd/Cargo.toml @@ -7,9 +7,6 @@ license = "MIT OR Apache-2.0" rand = "0.10" russh = { version = "0.60", default-features = false, features = ["rustcrypto", "flate2", "rsa"] } tokio = { version = "1", features = ["rt", "net", "macros", "io-util", "sync", "time"] } -# The swap subsystem: init's port, reached through this daemon's namespace. -toyos = { path = "../../toyos" } -toyos-swap = { path = "../../toyos-swap" } [dev-dependencies] # Test scratch that is gone on every way out; `--ci host` reds on any left behind. diff --git a/userland/sshd/src/main.rs b/userland/sshd/src/main.rs index 8a2482e5216..073072cbdf8 100644 --- a/userland/sshd/src/main.rs +++ b/userland/sshd/src/main.rs @@ -1,5 +1,6 @@ -//! The machine's SSH server: a shell, a command, files both ways, and a running -//! service's binary swapped for another (`swap`). +//! The machine's SSH server: a shell, a command, and files both ways. A +//! program run over `exec` is an ordinary one — `update` and `swap` are two — +//! and holds its own manifest row, not this daemon's. //! //! Two rules run through every path below. **Nothing this daemon starts //! outlives the connection that asked for it**: the thread feeding a program @@ -10,7 +11,6 @@ mod command; mod sftp; -mod swap; use std::fs; use std::io::{Read, Write}; @@ -179,7 +179,6 @@ impl Server for SshServer { input: None, alive: watch::channel(()).0, is_pty: false, - answered: None, } } } @@ -197,9 +196,6 @@ struct SshSession { /// session does — is what ends them and kills the program they serve. alive: watch::Sender<()>, is_pty: bool, - /// Told when the client closes a swap's channel, which it does once it has - /// read the answer and is ready for the swap to go. - answered: Option>, } /// Which of a child's two output streams a chunk came off. @@ -368,71 +364,6 @@ impl SshSession { }); } - /// Serve the `toyos-swap` subsystem on this channel: take one binary, - /// hand it to init, answer on the channel, and only then let init go — - /// [`swap`]'s own header is the order and why. - fn run_swap(&mut self) { - let Some(channel) = self.take_channel("subsystem request") else { return }; - let (_, out) = channel.split(); - let (input, mut input_rx) = mpsc::channel::>(16); - self.input = Some(input); - let (answered, taken_in) = tokio::sync::oneshot::channel(); - self.answered = Some(answered); - let peer = self.peer.clone(); - - tokio::spawn(async move { - let mut buf: Vec = Vec::new(); - let taken = loop { - match swap::take(&buf) { - Ok(swap::Taken::More) => {} - Ok(swap::Taken::Whole(header, body)) => break Ok((header, body)), - Err(why) => break Err(why.to_string()), - } - match input_rx.recv().await { - Some(chunk) => buf.extend_from_slice(&chunk), - None => { - break Err(format!( - "the channel ended after {} bytes, before the request was whole", - buf.len() - )); - } - } - }; - drop(buf); - let (service, answer, init) = match taken { - Err(why) => (String::from("?"), Err(why), None), - Ok((header, body)) => { - let service = header.service.clone(); - match tokio::task::spawn_blocking(move || swap::ask(&header, &body)).await { - Ok((answer, init)) => (service, answer, init), - Err(_) => (service, Err("the request task did not finish".into()), None), - } - } - }; - let (line, status) = match &answer { - Ok(path) => (format!("accepted {path}\n"), 0), - Err(why) => (format!("refused {why}\n"), 1), - }; - println!("{}", toyos_swap::sshd_said(&peer, &service, line.trim_end())); - out.data(line.as_bytes()).await.ok(); - out.exit_status(status).await.ok(); - out.eof().await.ok(); - // **The client's close is the go**, and this daemon does not close - // first: a client answers a peer's close with its own at once, so - // a close that followed this one would say only that the answer - // arrived, not that the client — whose own connections the service - // being swapped may carry — is ready for it to stop. Only then is - // init told to go, by this connection ending, and a client that - // never closes is waited for no longer than the bound. - if init.is_some() { - let bound = std::time::Duration::from_millis(toyos_swap::ANSWER_MS); - let _ = tokio::time::timeout(bound, taken_in).await; - } - out.close().await.ok(); - drop(init); - }); - } - /// End this channel with `why` on its stderr and [`EXIT_REFUSED`], and say /// the same on the console. The client gets an answer either way. fn refuse(&mut self, why: &str) { @@ -652,9 +583,6 @@ impl russh::server::Handler for SshSession { _session: &mut Session, ) -> Result<(), Self::Error> { self.input = None; - if let Some(answered) = self.answered.take() { - let _ = answered.send(()); - } Ok(()) } @@ -720,16 +648,7 @@ impl russh::server::Handler for SshSession { self.run_sftp(); return Ok(()); } - if name == toyos_swap::SUBSYSTEM { - session.channel_success(channel_id)?; - self.run_swap(); - return Ok(()); - } - println!( - "sshd: {}: refused the {name:?} subsystem; this daemon serves sftp and {}", - self.peer, - toyos_swap::SUBSYSTEM - ); + println!("sshd: {}: refused the {name:?} subsystem; this daemon serves sftp alone", self.peer); session.channel_failure(channel_id)?; Ok(()) } diff --git a/userland/sshd/src/swap.rs b/userland/sshd/src/swap.rs deleted file mode 100644 index f1b58e40de2..00000000000 --- a/userland/sshd/src/swap.rs +++ /dev/null @@ -1,135 +0,0 @@ -//! The `toyos-swap` subsystem: a client's replacement for a running service's -//! binary, staged and handed to `/system/bin/init`, which alone can swap it. -//! -//! The channel's input is one [`Header`] line and then exactly its length in -//! bytes. The answer is one line on the channel's stdout — `accepted ` -//! with exit status 0, or `refused ` with 1 — and it is init's answer -//! except where the request never reached init. **init is told to go by this -//! daemon hanging up on it**, which happens only once the client has closed the -//! channel behind the answer — or [`toyos_swap::ANSWER_MS`] after it, for one -//! that never does: the service being swapped may be the one carrying the -//! answer, so it is not stopped until the answer has arrived. - -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::OnceLock; - -use toyos::endow::Endowments; -use toyos::ipc::Connection; -use toyos::namespace::Namespace; -use toyos_swap::{Header, Refusal, Request}; - -/// What the channel's input has amounted to so far. -pub enum Taken { - /// The header or the binary is not whole yet. - More, - Whole(Header, Vec), -} - -/// Read a request out of what the channel has sent so far. -/// -/// **Nothing is acted on before it is whole**, and a channel that sends more -/// than its header promised is refused rather than truncated: the digest is of -/// the bytes the client meant, and a guess at which ones those were is not a -/// binary anyone can vouch for. -pub fn take(buf: &[u8]) -> Result { - let Some((header, at)) = Header::take(buf)? else { return Ok(Taken::More) }; - let body = &buf[at..]; - let want = header.len as usize; - if body.len() > want { - return Err(Refusal::Malformed(format!( - "the channel carried {} bytes after a header promising {want}", - body.len() - ))); - } - if body.len() < want { - return Ok(Taken::More); - } - Ok(Taken::Whole(header, body.to_vec())) -} - -/// The swap port's own namespace, taken out of the endowment table on first -/// use and held by this process alone: init never puts the port in `svc`, -/// which is what a program this daemon runs is given ([`toyos_swap::LABEL`]). -fn authority() -> Option<&'static Namespace> { - static HELD: OnceLock> = OnceLock::new(); - HELD.get_or_init(|| Endowments::get().take::(toyos_swap::LABEL)).as_ref() -} - -/// Every stage this daemon makes has a name of its own, so two sessions never -/// write into one file. -static STAGED: AtomicU64 = AtomicU64::new(0); - -/// Stage the binary and ask init, answering what init said and the connection -/// the caller holds until its answer to the client is closed. -/// -/// Blocking: a file write and one exchange with init, which answers at once. -pub fn ask(header: &Header, body: &[u8]) -> (Result, Option) { - let Some(held) = authority() else { - return (Err(Refusal::NoAuthority.to_string()), None); - }; - let init = match held.open(toyos_swap::PORT) { - Ok(conn) => conn, - Err(e) => return (Err(format!("init's swap port did not answer: {e:?}")), None), - }; - let staged = toyos_swap::staged_path(&header.service, STAGED.fetch_add(1, Ordering::Relaxed)); - let written = std::fs::create_dir_all(toyos_swap::STAGING) - .and_then(|()| std::fs::write(&staged, body)); - if let Err(e) = written { - let _ = std::fs::remove_file(&staged); - return (Err(format!("{staged} could not be written: {e}")), None); - } - let request = Request { service: header.service.clone(), staged, digest: header.digest }; - if let Err(e) = init.send_bytes(toyos_swap::MSG_SWAP, &request.encode()) { - let _ = std::fs::remove_file(&request.staged); - return (Err(format!("init did not take the request: {e:?}")), None); - } - let answer = match init.recv_header() { - Ok(answer) => answer, - Err(e) => return (Err(format!("init did not answer: {e:?}")), None), - }; - let mut text = vec![0u8; answer.len() as usize]; - let said = match init.recv_bytes(&answer, &mut text) { - Ok(n) => String::from_utf8_lossy(&text[..n]).into_owned(), - Err(e) => return (Err(format!("init's answer did not arrive whole: {e:?}")), None), - }; - match answer.msg_type { - toyos_swap::MSG_ACCEPTED => (Ok(said), Some(init)), - toyos_swap::MSG_REFUSED => (Err(said), None), - other => (Err(format!("init answered message {other}, which is no swap answer")), None), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn request(body: &[u8]) -> Vec { - let header = - Header { service: "netd".into(), digest: toyos_swap::digest(body), len: body.len() as u64 }; - let mut buf = header.render().into_bytes(); - buf.extend_from_slice(body); - buf - } - - #[test] - fn a_request_is_whole_only_at_its_last_byte() { - let buf = request(b"\x7fELF and the rest"); - for cut in 0..buf.len() { - assert!(matches!(take(&buf[..cut]), Ok(Taken::More)), "cut at {cut}"); - } - match take(&buf) { - Ok(Taken::Whole(header, body)) => { - assert_eq!(header.service, "netd"); - assert_eq!(body, b"\x7fELF and the rest"); - } - _ => panic!("the whole request was not taken"), - } - } - - #[test] - fn more_bytes_than_the_header_promised_are_refused() { - let mut buf = request(b"abc"); - buf.push(b'!'); - assert!(matches!(take(&buf), Err(Refusal::Malformed(_)))); - } -} diff --git a/userland/swap/Cargo.toml b/userland/swap/Cargo.toml new file mode 100644 index 00000000000..4fd877da928 --- /dev/null +++ b/userland/swap/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "swap" +version = "0.1.0" +edition = "2021" +license = "MIT OR Apache-2.0" + +[dependencies] +toyos = { path = "../../toyos" } +# The words init and the host say about a swap, and the digest it is held to. +toyos-swap = { path = "../../toyos-swap" } diff --git a/userland/swap/src/main.rs b/userland/swap/src/main.rs new file mode 100644 index 00000000000..bbbec23349d --- /dev/null +++ b/userland/swap/src/main.rs @@ -0,0 +1,156 @@ +//! `/system/bin/swap `: a running service's binary +//! replaced by the one on standard input, handed to `/system/bin/init`, which +//! alone can swap it. +//! +//! An ordinary program: `ssh swap netd < netd` +//! runs it over a plain `exec` channel, and a local shell runs it the same +//! way. It holds the one connector to init's [`toyos_swap::PORT`] the build +//! lets any program hold, so what it can do is ask; every decision is init's +//! ([`toyos_swap`]'s header is the order). +//! +//! **The length is not optional, because an input's end says nothing.** A +//! connection that drops mid-upload ends this program's input exactly as the +//! end of the file does, so a binary read to the end of its input is whatever +//! arrived. The input is exactly `` bytes, an input that ends sooner +//! is refused by name before init hears of it, and the digest is the caller's +//! end-to-end word on the bytes. +//! +//! After the answer this program waits for the input to close — the caller's +//! proof that it has the answer, which matters when the service being swapped +//! is the one carrying the caller's connection — at most +//! [`toyos_swap::ANSWER_MS`], and then lets init go. +//! +//! The answer is one line on standard output: `accepted ` with exit +//! status 0; `refused `, init's refusal, with 1; or `unasked `, this +//! program's own before init heard of the ask — so a caller knows init will +//! say nothing about it — with 1. + +use std::io::{Read, Write}; +use std::time::Duration; + +use toyos::endow::Endowments; +use toyos::namespace::Namespace; +use toyos_swap::{Refusal, Request}; + +fn main() { + let args: Vec = std::env::args().skip(1).collect(); + let (answer, held) = run(&args); + let line = match &answer { + Answer::Accepted(path) => format!("accepted {path}"), + Answer::Refused(why) => format!("refused {why}"), + Answer::Unasked(why) => format!("unasked {why}"), + }; + println!("{line}"); + std::io::stdout().flush().expect("the answer reaches standard output"); + if let Some(init) = held { + go(init); + } + std::process::exit(if matches!(answer, Answer::Accepted(_)) { 0 } else { 1 }); +} + +/// What this program answers. +enum Answer { + /// init verified and installed the binary; the path it runs from. + Accepted(String), + /// init refused, and said so in its own log. + Refused(String), + /// This program refused before init heard of the ask. + Unasked(String), +} + +/// What the caller said about the binary, and the binary. +struct Asked { + service: String, + digest: toyos_swap::Digest, + body: Vec, +} + +/// The answer, and the connection to init held until the go where init +/// accepted. +fn run(args: &[String]) -> (Answer, Option) { + match take(args) { + Ok(asked) => ask(&asked), + Err(why) => (Answer::Unasked(why.to_string()), None), + } +} + +/// The request out of the argument vector and exactly the input's promised +/// bytes. +fn take(args: &[String]) -> Result { + let [service, digest, len] = args else { + return Err(Refusal::Malformed("usage: swap ".into())); + }; + let digest = toyos_swap::parse_hex(digest) + .ok_or_else(|| Refusal::Malformed(format!("{digest:?} is not a SHA-256 in lowercase hex")))?; + let len: u64 = len.parse().map_err(|_| Refusal::Malformed(format!("{len:?} is not a length")))?; + if !toyos_swap::is_service_name(service) { + return Err(Refusal::Malformed(format!("{service:?} is not a service name"))); + } + if len > toyos_swap::MAX_BINARY_BYTES { + return Err(Refusal::TooLarge(len)); + } + let mut body = vec![0u8; len as usize]; + std::io::stdin().lock().read_exact(&mut body).map_err(|e| { + Refusal::Malformed(format!("the input ended before the {len} bytes it promised: {e}")) + })?; + Ok(Asked { service: service.clone(), digest, body }) +} + +/// Stage the binary and ask init: its answer, and the connection this program +/// holds until the go where init accepted. +fn ask(asked: &Asked) -> (Answer, Option) { + let unasked = |why: String| (Answer::Unasked(why), None); + let Some(held) = Endowments::get().take::(toyos_swap::LABEL) else { + return unasked(Refusal::NoAuthority.to_string()); + }; + let init = match held.open(toyos_swap::PORT) { + Ok(conn) => conn, + Err(e) => return unasked(format!("init's swap port did not answer: {e:?}")), + }; + let staged = toyos_swap::staged_path(&asked.service, u64::from(std::process::id())); + let written = std::fs::create_dir_all(toyos_swap::STAGING).and_then(|()| std::fs::write(&staged, &asked.body)); + if let Err(e) = written { + let _ = std::fs::remove_file(&staged); + return unasked(format!("{staged} could not be written: {e}")); + } + let request = Request { service: asked.service.clone(), staged, digest: asked.digest }; + if let Err(e) = init.send_bytes(toyos_swap::MSG_SWAP, &request.encode()) { + let _ = std::fs::remove_file(&request.staged); + return unasked(format!("init did not take the request: {e:?}")); + } + let answer = match init.recv_header() { + Ok(answer) => answer, + Err(e) => return (Answer::Refused(format!("init did not answer: {e:?}")), None), + }; + let mut text = vec![0u8; answer.len() as usize]; + let said = match init.recv_bytes(&answer, &mut text) { + Ok(n) => String::from_utf8_lossy(&text[..n]).into_owned(), + Err(e) => return (Answer::Refused(format!("init's answer did not arrive whole: {e:?}")), None), + }; + match answer.msg_type { + toyos_swap::MSG_ACCEPTED => (Answer::Accepted(said), Some(init)), + toyos_swap::MSG_REFUSED => (Answer::Refused(said), None), + other => (Answer::Refused(format!("init answered message {other}, which is no swap answer")), None), + } +} + +/// Wait for the caller's go — its input closing — and let init go by +/// dropping the connection: **this program hanging up on init is what tells +/// it to stop the old service**. +fn go(init: toyos::ipc::Connection) { + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let mut rest = Vec::new(); + let _ = tx.send(std::io::stdin().read_to_end(&mut rest).map(|_| rest.len())); + }); + match rx.recv_timeout(Duration::from_millis(toyos_swap::ANSWER_MS)) { + Ok(Ok(0)) => {} + Ok(Ok(n)) => eprintln!("swap: {n} bytes arrived after the binary; the swap goes regardless"), + Ok(Err(e)) => eprintln!("swap: the input would not read to its end ({e}); the swap goes"), + Err(_) => eprintln!( + "swap: the input was not closed within {} ms of the answer; the swap goes", + toyos_swap::ANSWER_MS + ), + } + drop(init); +} diff --git a/userland/toyos-window/Cargo.toml b/userland/toyos-window/Cargo.toml index 1f21a6ee7d9..fe216549bfe 100644 --- a/userland/toyos-window/Cargo.toml +++ b/userland/toyos-window/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toyos-window" -version = "0.17.0" +version = "0.18.0" edition = "2021" license = "MIT OR Apache-2.0" description = "Client library for ToyOS windows: create one, draw into it, read its events." @@ -8,9 +8,9 @@ repository = "https://github.com/ToyOSOrg/ToyOS" [dependencies] toyos-font = { path = "../toyos-font", version = "0.2.0" } -toyos-abi = { path = "../../toyos-abi", version = "0.14.0" } +toyos-abi = { path = "../../toyos-abi", version = "0.15.0" } toyos-keymap = { path = "../../toyos-keymap", version = "0.1.0" } -toyos = { path = "../../toyos", version = "0.16.0" } +toyos = { path = "../../toyos", version = "0.17.0" } # `window` is taken on crates.io, so the package is `toyos-window`; the lib # target keeps the name every caller spells, `toyos/src`'s citations included. diff --git a/userland/update/Cargo.toml b/userland/update/Cargo.toml new file mode 100644 index 00000000000..0a179bec84e --- /dev/null +++ b/userland/update/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "update" +version = "0.1.0" +edition = "2021" +license = "MIT OR Apache-2.0" + +[dependencies] +toyos = { path = "../../toyos" } +toyos-abi = { path = "../../toyos-abi" } +# The format, the verifier and the slot table the loader reads the same way. +toyos-update = { path = "../../toyos-update" } +# A slot's volume is FAT, written with the driver the kernel mounts FAT with. +toyos-fat32 = { path = "../../toyos-fat32" } +# ROOT is hashed as it streams onto its partition, never held whole. +sha2 = { version = "0.10", default-features = false } diff --git a/userland/update/src/main.rs b/userland/update/src/main.rs new file mode 100644 index 00000000000..3898e2adac9 --- /dev/null +++ b/userland/update/src/main.rs @@ -0,0 +1,206 @@ +//! `/system/bin/update`: a signed image on standard input, installed into the +//! slot this machine is not running, and marked to boot next. +//! +//! **It does not care how the image arrived**: `ssh update < image` +//! today, a pull from a release server tomorrow, a file on a local shell — the +//! bytes on standard input are the whole of its input, and the owner's +//! signature over them is the whole of its authority to install anything. +//! +//! **What it holds is the whole of what it can write** (`slots` in its +//! `system.toml` row): init claims the slot table's partition and the idle +//! slot's FAT volume and ROOT and endows them, and the slot this boot runs is +//! never among them (`toyos_update::slots::idle`). So it writes, in order: +//! +//! 1. nothing, until the signed header's signature is this machine's key's and +//! its version is newer than the running image and no older than the idle +//! slot's (`toyos_update::policy::installable`); +//! 2. ROOT, streamed onto the idle ROOT partition as it arrives and held to +//! the header's hash once whole; +//! 3. the kernel, its boot parameter and the signed header onto the idle FAT +//! volume, each held to its hash before it is written; +//! 4. an fsync of each claim, which answers for these writes and no other +//! process's; +//! 5. the slot table, marking the idle slot — the copy that is not current, +//! so a torn write leaves the old mark — and its fsync. +//! +//! A refusal at any step leaves the mark where it was, so the machine boots +//! what it booted before. The loader checks every byte again at the next boot +//! and refuses the slot by name if anything here was wrong. + +use std::io::Read; +use std::time::Instant; + +use toyos::endow::Endowments; +use toyos::PartitionDev; +use toyos_abi::part::{Block, BLOCK_BYTES, MAX_BLOCKS_PER_CALL}; +use toyos_update::image::{Header, HEADER_BYTES, SIGNED_BYTES}; +use toyos_update::slots::{self, Table, Which}; +use toyos_fat32::BlockAccess as _; +use toyos_update::{policy, sig}; + +/// The key an image must be signed with: the same the loader embeds. +const KEY: [u8; 32] = sig::key_from_hex(env!("TOYOS_IMAGE_KEY")); + +/// The line an install ends on, which the host reads. +const INSTALLED: &str = "update: installed"; + +fn main() { + let began = Instant::now(); + match run(began) { + Ok(line) => println!("{line}"), + Err(why) => { + println!("update: refused: {why}"); + std::process::exit(1); + } + } +} + +/// The three claims init endowed, or why this process holds none. +fn grant() -> Result<(PartitionDev, PartitionDev, PartitionDev), String> { + let take = |label: &str| { + Endowments::get() + .take::(label) + .ok_or_else(|| format!("this process holds no `{label}`: init grants the idle slot to one update at a time, and says why where it grants none")) + }; + Ok((take(slots::TABLE_LABEL)?, take(slots::BOOT_LABEL)?, take(slots::ROOT_LABEL)?)) +} + +fn run(began: Instant) -> Result { + let (table_claim, boot, root) = grant()?; + let (table, current) = read_table(&table_claim)?; + let boot_guid = boot.describe().map_err(|e| format!("the idle volume's claim: {e:?}"))?.unique_guid; + let root_info = root.describe().map_err(|e| format!("the idle ROOT's claim: {e:?}"))?; + let idle = [Which::A, Which::B] + .into_iter() + .find(|&w| table.slot(w).is_some_and(|s| s.boot == boot_guid && s.root == root_info.unique_guid)) + .ok_or("the partitions this process holds are no slot the table names")?; + let running = table.slot(idle.other()).ok_or("the table carries no running slot")?; + + let mut input = std::io::stdin().lock(); + let mut signed = [0u8; SIGNED_BYTES]; + input.read_exact(&mut signed).map_err(|e| format!("the input ended before a signed header: {e}"))?; + let header = Header::parse(&signed).map_err(|why| why.to_string())?; + let header_bytes: &[u8; HEADER_BYTES] = signed[..HEADER_BYTES].try_into().expect("a signed header begins with its header"); + sig::verify(&KEY, header_bytes, &toyos_update::image::signature_of(&signed)).map_err(|why| why.to_string())?; + let idle_version = table.slot(idle).map(|s| s.version).filter(|&v| v != 0); + policy::installable(header.version, running.version, idle_version).map_err(|why| why.to_string())?; + if header.root().len > root_info.blocks * BLOCK_BYTES as u64 { + return Err(format!( + "ROOT is {} bytes and slot {}'s ROOT partition holds {}", + header.root().len, + idle.letter(), + root_info.blocks * BLOCK_BYTES as u64 + )); + } + println!( + "update: version {} is signed by this machine's key; writing slot {} (running {} at version {})", + header.version, + idle.letter(), + idle.other().letter(), + running.version + ); + + let kernel = take(&mut input, header.kernel().len, header.kernel().sha256, "kernel")?; + let cmdline = take(&mut input, header.cmdline().len, header.cmdline().sha256, "cmdline")?; + let streamed = Instant::now(); + stream_root(&mut input, &root, header.root().len, header.root().sha256)?; + let root_ms = streamed.elapsed().as_millis(); + let mut rest = [0u8; 1]; + match input.read(&mut rest) { + Ok(0) => {} + Ok(_) => return Err("the input carries more bytes than its signed header names".into()), + Err(e) => return Err(format!("the input's end would not read: {e}")), + } + + write_volume(&boot, &kernel, &cmdline, &signed)?; + root.sync().map_err(|e| format!("slot {}'s ROOT is not durable: {e:?}", idle.letter()))?; + boot.sync().map_err(|e| format!("slot {}'s volume is not durable: {e:?}", idle.letter()))?; + + let mut next = table; + next.marked = idle; + let mut slot = table.slot(idle).expect("the idle slot is in the table"); + slot.version = header.version; + next.slots[idle.index()] = Some(slot); + let (copy, block) = slots::next_write((table, current), next); + table_claim + .write(copy as u64, &[block]) + .map_err(|e| format!("the slot table's copy {copy} would not write: {e:?}"))?; + table_claim.sync().map_err(|e| format!("the slot table is not durable: {e:?}"))?; + + Ok(format!( + "{INSTALLED} version {} in slot {} ({} bytes of ROOT in {root_ms} ms, {} ms in all); it boots at the next reboot", + header.version, + idle.letter(), + header.root().len, + began.elapsed().as_millis() + )) +} + +/// The slot table and which copy of it is current. +fn read_table(claim: &PartitionDev) -> Result<(Table, usize), String> { + let mut copies: [Block; 2] = [[0; BLOCK_BYTES]; 2]; + claim.read(0, &mut copies).map_err(|e| format!("the slot table would not read: {e:?}"))?; + slots::current([&copies[0], &copies[1]]).map_err(|why| format!("the slot table's partition holds {why}")) +} + +/// Exactly `len` bytes of the input, held to `sha256`. +fn take(input: &mut impl Read, len: u64, sha256: toyos_update::Digest, section: &str) -> Result, String> { + let mut bytes = vec![0u8; len as usize]; + input.read_exact(&mut bytes).map_err(|e| format!("the input ended inside the {section}: {e}"))?; + if toyos_update::sha256(&bytes) != sha256 { + return Err(format!("the {section} is not the bytes its signed header names")); + } + Ok(bytes) +} + +/// ROOT onto the idle ROOT partition as it arrives, a call's worth of blocks +/// at a time, and held to `sha256` once whole. +fn stream_root(input: &mut impl Read, root: &PartitionDev, len: u64, sha256: toyos_update::Digest) -> Result<(), String> { + use sha2::Digest as _; + let mut hasher = sha2::Sha256::new(); + let mut run: Vec = vec![[0; BLOCK_BYTES]; MAX_BLOCKS_PER_CALL]; + let blocks = len / BLOCK_BYTES as u64; + let mut at = 0u64; + while at < blocks { + let n = (blocks - at).min(MAX_BLOCKS_PER_CALL as u64) as usize; + for block in &mut run[..n] { + input.read_exact(block).map_err(|e| format!("the input ended inside ROOT, at block {at}: {e}"))?; + hasher.update(&block[..]); + } + root.write(at, &run[..n]).map_err(|e| format!("ROOT's blocks from {at} would not write: {e:?}"))?; + at += n as u64; + } + if <[u8; 32]>::from(hasher.finalize()) != sha256 { + return Err("ROOT is not the bytes its signed header names; the mark is not moved".into()); + } + Ok(()) +} + +/// The kernel, its boot parameter and the signed header onto the idle slot's +/// FAT volume, replacing whatever was there. +fn write_volume(boot: &PartitionDev, kernel: &[u8], cmdline: &[u8], signed: &[u8]) -> Result<(), String> { + let blocks = boot.describe().map_err(|e| format!("the idle volume's claim: {e:?}"))?.blocks; + let mut fs = toyos_fat32::Fat32::mount(volume::Cached::new(boot, blocks)) + .map_err(|e| format!("the idle slot's volume does not mount: {e:?}"))?; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |since| since.as_secs()); + let time = toyos_fat32::FatTime::from_unix_secs(now); + fs.create_dir_all("toyos", time).map_err(|e| format!("toyos/ on the idle volume: {e:?}"))?; + for (path, bytes) in [ + (slots::SIGNED_FILE, signed), + (slots::KERNEL_FILE, kernel), + (slots::CMDLINE_FILE, cmdline), + ] { + if fs.exists(path).map_err(|e| format!("{path}: {e:?}"))? { + fs.remove(path).map_err(|e| format!("removing the old {path}: {e:?}"))?; + } + let mut file = fs.create(path, time).map_err(|e| format!("creating {path}: {e:?}"))?; + fs.write(&mut file, 0, bytes).map_err(|e| format!("writing {path}: {e:?}"))?; + fs.flush_meta(&mut file, time).map_err(|e| format!("recording {path}: {e:?}"))?; + } + fs.sync().map_err(|e| format!("the idle volume's metadata: {e:?}"))?; + fs.into_device().flush().map_err(|e| format!("the idle volume's blocks: {e:?}")) +} + +mod volume; diff --git a/userland/update/src/volume.rs b/userland/update/src/volume.rs new file mode 100644 index 00000000000..67cad4703dc --- /dev/null +++ b/userland/update/src/volume.rs @@ -0,0 +1,103 @@ +//! A partition claim as `toyos-fat32` reads one: byte-addressed, over 4 KiB +//! blocks, with every write held until [`Cached::flush`] writes the blocks it +//! touched in runs of whole calls. +//! +//! **Held, because FAT writes a cluster at a time**, and a slot volume's +//! clusters are smaller than a block: written through, a kernel image would be +//! a read and a write of one block per 512 bytes. Held, the volume's writes are +//! as many calls as the blocks it touched need, and the partition's own fsync +//! is what makes them durable — which is the caller's, after this. + +use std::collections::BTreeMap; + +use toyos::PartitionDev; +use toyos_abi::part::{Block, BLOCK_BYTES, MAX_BLOCKS_PER_CALL}; +use toyos_fat32::{BlockAccess, IoError}; + +pub struct Cached<'a> { + claim: &'a PartitionDev, + blocks: u64, + /// Every block written since the last flush, by block number. + dirty: BTreeMap>, +} + +impl<'a> Cached<'a> { + pub fn new(claim: &'a PartitionDev, blocks: u64) -> Self { + Self { claim, blocks, dirty: BTreeMap::new() } + } + + /// Block `n` as the volume now reads: the held write, or the device's. + fn block(&self, n: u64) -> Result { + if let Some(held) = self.dirty.get(&n) { + return Ok(**held); + } + let mut one = [[0u8; BLOCK_BYTES]]; + self.claim.read(n, &mut one).map_err(|_| IoError::Device)?; + Ok(one[0]) + } + + /// Each block `[offset, offset + len)` touches, with the part of it that + /// range covers and where in the caller's buffer that part begins. + fn spans(&self, offset: u64, len: usize) -> Result, usize)>, IoError> { + let end = offset.checked_add(len as u64).ok_or(IoError::Device)?; + if end > self.capacity() { + return Err(IoError::Device); + } + let mut out = Vec::new(); + let mut at = offset; + while at < end { + let n = at / BLOCK_BYTES as u64; + let from = (at % BLOCK_BYTES as u64) as usize; + let to = BLOCK_BYTES.min(from + (end - at) as usize); + out.push((n, from..to, (at - offset) as usize)); + at += (to - from) as u64; + } + Ok(out) + } +} + +impl BlockAccess for Cached<'_> { + fn capacity(&self) -> u64 { + self.blocks * BLOCK_BYTES as u64 + } + + fn read_at(&mut self, offset: u64, buf: &mut [u8]) -> Result<(), IoError> { + for (n, range, into) in self.spans(offset, buf.len())? { + let block = self.block(n)?; + buf[into..into + range.len()].copy_from_slice(&block[range]); + } + Ok(()) + } + + fn write_at(&mut self, offset: u64, buf: &[u8]) -> Result<(), IoError> { + for (n, range, from) in self.spans(offset, buf.len())? { + // A whole block's write needs nothing read under it. + let mut block = if range.len() == BLOCK_BYTES { [0u8; BLOCK_BYTES] } else { self.block(n)? }; + block[range.clone()].copy_from_slice(&buf[from..from + range.len()]); + self.dirty.insert(n, Box::new(block)); + } + Ok(()) + } + + /// Write every held block, a run of consecutive ones per call. Durable is + /// the claim's fsync, which the caller asks once every volume is written. + fn flush(&mut self) -> Result<(), IoError> { + let held = std::mem::take(&mut self.dirty); + let mut run: Vec = Vec::with_capacity(MAX_BLOCKS_PER_CALL); + let mut first = 0u64; + for (n, block) in held { + if !run.is_empty() && (n != first + run.len() as u64 || run.len() == MAX_BLOCKS_PER_CALL) { + self.claim.write(first, &run).map_err(|_| IoError::Device)?; + run.clear(); + } + if run.is_empty() { + first = n; + } + run.push(*block); + } + if !run.is_empty() { + self.claim.write(first, &run).map_err(|_| IoError::Device)?; + } + Ok(()) + } +}