From 25c04508039a82d54aa88446f71b6116c158287d Mon Sep 17 00:00:00 2001 From: japabu Date: Fri, 9 Oct 2026 12:21:10 +0200 Subject: [PATCH 1/3] The kernel's random bytes come from a ChaCha20 generator keyed from the loader's seed and the CPU's sources, and the AArch64 guest tests run under HVF An HVF guest has no RNDR, and the AArch64 kernel had no other entropy: its hash seed panicked there and sys_random executed RNDR unconditionally, so 19 of the 21 virt_ tests ran emulated. toyos-random is the generator, pure: the ChaCha20 block function of RFC 8439, held to its test vectors; a Seed that 32 bytes become only when their four words are not one value; a Generator that exists only keyed, mixes every further seed by XOR into the key and a block, and replaces its key at every draw. The loader reads 32 bytes from EFI_RNG_PROTOCOL before ExitBootServices, judges them with the kernel's own judgment and hands them in KernelArgs, saying in one line whether it got them. The kernel mixes them with RDSEED and RDRAND, or RNDR, zeroes the field in the loader's copy and its own, and refuses by name a machine where nothing was mixed. hasher::seed and SYS_RANDOM draw from it on both architectures. The virt shape gains a virtio-rng, which edk2 drives for the protocol. 17 tests move to Profile::Virt, VirtTcg goes, the PSCI trace is taken under HVF too, and one boot of the job case stays at EL2 with every job judged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RvnWQFcMuGqTHYhvSnTe8A --- Cargo.lock | 6 + Cargo.toml | 1 + bootloader/Cargo.toml | 2 + bootloader/src/main.rs | 5 + bootloader/src/seed.rs | 42 ++++ ...608-hangs-at-exitbootservices-under-hvf.md | 5 + issues/every-random-byte-is-one-rdrand.md | 22 -- ...erator-is-keyed-once-and-never-reseeded.md | 33 +++ issues/toyos-runs-on-arm64.md | 50 +++- kernel/Cargo.toml | 1 + kernel/src/arch/aarch64/entropy.rs | 63 ++--- kernel/src/arch/x86_64/entropy.rs | 53 +++- kernel/src/hasher.rs | 20 +- kernel/src/main.rs | 14 +- kernel/src/random.rs | 125 ++++++++++ kernel/src/syscall/io.rs | 13 +- src/qemu.rs | 7 + tests/common/qemu.rs | 41 ++- .../toyos-rust-tests/src/bin/random_draws.rs | 57 +++++ tests/toyos.rs | 220 +++++++++++----- tests/virtjobcase/system.toml | 4 +- toyos-abi/src/boot.rs | 16 +- toyos-random/Cargo.toml | 7 + toyos-random/src/chacha.rs | 50 ++++ toyos-random/src/lib.rs | 169 +++++++++++++ toyos-random/src/tests.rs | 235 ++++++++++++++++++ 26 files changed, 1083 insertions(+), 178 deletions(-) create mode 100644 bootloader/src/seed.rs delete mode 100644 issues/every-random-byte-is-one-rdrand.md create mode 100644 issues/the-kernels-generator-is-keyed-once-and-never-reseeded.md create mode 100644 kernel/src/random.rs create mode 100644 tests/toyos-rust-tests/src/bin/random_draws.rs create mode 100644 toyos-random/Cargo.toml create mode 100644 toyos-random/src/chacha.rs create mode 100644 toyos-random/src/lib.rs create mode 100644 toyos-random/src/tests.rs diff --git a/Cargo.lock b/Cargo.lock index c8cce35baf7..2ecc6844bdc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -381,6 +381,7 @@ dependencies = [ "toyos-bootmap", "toyos-elf", "toyos-gpt", + "toyos-random", "toyos-rootimage", "toyos-tco", "toyos-tsc", @@ -3051,6 +3052,7 @@ dependencies = [ "toyos-pci", "toyos-ps2", "toyos-quiesce", + "toyos-random", "toyos-rootimage", "toyos-symbols", "toyos-tco", @@ -5995,6 +5997,10 @@ version = "0.1.0" name = "toyos-quiesce" version = "0.1.0" +[[package]] +name = "toyos-random" +version = "0.1.0" + [[package]] name = "toyos-rootimage" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 788315a9e7a..9d7690fba8d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,6 +56,7 @@ members = [ "toyos-net-wire", "toyos-osrelease", "toyos-quiesce", + "toyos-random", "toyos-rootimage", "toyos-swap", "toyos-symbols", diff --git a/bootloader/Cargo.toml b/bootloader/Cargo.toml index 961a994f34c..aaf27e400f4 100644 --- a/bootloader/Cargo.toml +++ b/bootloader/Cargo.toml @@ -27,6 +27,8 @@ toyos-gpt = { path = "../toyos-gpt" } toyos-rootimage = { path = "../toyos-rootimage" } bcachefs = { path = "../bcachefs", default-features = false } toyos-tco = { path = "../toyos-tco" } +# What bytes are a seed: the judgment the kernel makes of what this hands it. +toyos-random = { path = "../toyos-random" } # The counter's rate each line's stamp is converted at, decoded as the kernel # decodes it. toyos-tsc = { path = "../toyos-tsc" } diff --git a/bootloader/src/main.rs b/bootloader/src/main.rs index 9849a95a9eb..f8c07cfc548 100644 --- a/bootloader/src/main.rs +++ b/bootloader/src/main.rs @@ -43,6 +43,7 @@ mod gcd; mod loaderlog; mod rootbridge; mod rootimage; +mod seed; mod slot; mod stamp; mod watchdog; @@ -627,7 +628,11 @@ fn start_kernel(kernel: LoadedKernel, kernel_elf_bytes: vec::Vec, cmdline: v loader_entry_counter: entry_counter, loader_handoff_counter: 0, root_read_ticks, + // Read into this struct below, and nowhere else: the kernel zeroes it here. + loader_seed: [0; toyos_abi::boot::SEED_LEN], + loader_seed_len: 0, }; + kernel_args.loader_seed_len = seed::read(&system_table, &mut kernel_args.loader_seed); report_reach( "Kernel arguments", &kernel_args as *const KernelArgs as u64, diff --git a/bootloader/src/seed.rs b/bootloader/src/seed.rs new file mode 100644 index 00000000000..890f8887ae4 --- /dev/null +++ b/bootloader/src/seed.rs @@ -0,0 +1,42 @@ +//! The seed firmware gives the kernel's random generator: 32 bytes from +//! `EFI_RNG_PROTOCOL` (UEFI 2.11 §37.5), asked for once, before +//! `ExitBootServices`. Firmware without the protocol is a machine and not an +//! error: the kernel is handed none and keys its generator from what its CPU +//! has, or refuses. One line says which, and no line carries a byte. + +use toyos_abi::boot::SEED_LEN; +use toyos_random::{wipe, Seed}; +use uefi::prelude::*; +use uefi::proto::rng::Rng; + +use crate::protocol; + +/// Fill `into` with firmware's seed and answer its length: [`SEED_LEN`], or 0 +/// with `into` zero. Judged with the kernel's own [`Seed::judge`], so the line +/// here says what the kernel will find. +pub fn read(system_table: &SystemTable, into: &mut [u8; SEED_LEN]) -> u64 { + let bs = system_table.boot_services(); + let none = |why: core::fmt::Arguments| { + println!("Seed: {why}, so the kernel's generator is handed none"); + 0 + }; + let Ok(handle) = bs.get_handle_for_protocol::() else { + return none(format_args!("firmware has no EFI_RNG_PROTOCOL")); + }; + // GET_PROTOCOL: an exclusive open would stop the driver behind it. + let mut rng = match protocol::get::(bs, handle) { + Ok(rng) => rng, + Err(e) => return none(format_args!("EFI_RNG_PROTOCOL would not open ({e})")), + }; + // No algorithm named: firmware's default (§37.5.2). + if let Err(e) = rng.get_rng(None, into) { + wipe(into); + return none(format_args!("EFI_RNG_PROTOCOL's GetRNG failed ({e})")); + } + if let Err(why) = Seed::judge(into) { + wipe(into); + return none(format_args!("EFI_RNG_PROTOCOL's {SEED_LEN} bytes are refused, {why}")); + } + println!("Seed: {SEED_LEN} bytes from EFI_RNG_PROTOCOL for the kernel's generator"); + SEED_LEN as u64 +} diff --git a/issues/edk2-stable202502-to-202608-hangs-at-exitbootservices-under-hvf.md b/issues/edk2-stable202502-to-202608-hangs-at-exitbootservices-under-hvf.md index 0888190aeb2..dccad33ac91 100644 --- a/issues/edk2-stable202502-to-202608-hangs-at-exitbootservices-under-hvf.md +++ b/issues/edk2-stable202502-to-202608-hangs-at-exitbootservices-under-hvf.md @@ -10,6 +10,11 @@ When a host's QEMU installation ships an edk2 from stable202502 to stable202608, `virt` guests under HVF never return from `ExitBootServices`. Owner: the orchestrator. +Every `virt_` test but the three that need EL2 (`virt_el2_drop`, `virt_smp` +and `virt_jobs_at_el2`) boots under HVF on an Apple host, so a QEMU upgrade +there that bundles an edk2 from this range reds all of them, where it once +would have red two. + Under QEMU 11.1.1's HVF a `virt` guest reads `ID_AA64PFR0_EL1.GIC` as 0, though its GICv3's system registers answer. `hvf_arch_init_vcpu` (`target/arm/hvf/hvf.c:1481`) writes that register once, setting `GIC` only if diff --git a/issues/every-random-byte-is-one-rdrand.md b/issues/every-random-byte-is-one-rdrand.md deleted file mode 100644 index 73ed637b3d9..00000000000 --- a/issues/every-random-byte-is-one-rdrand.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -status: open -kind: defect -opened: 2026-09-24 ---- - -# Every random byte is one RDRAND, with no generator behind it - -`SYS_RANDOM` (`kernel/src/syscall/io.rs`) answers every request with -`RDRAND` values copied straight to the caller. The kernel has no random -generator of its own. Every key sshd mints, every future TLS session and every -nonce therefore rests on one instruction from one vendor. There is no second -source to mix with it, no reseed, and no health check. That single source is -where real failures have been: some AMD parts returned all-ones from `RDRAND` -after a resume. Linux, Fuchsia and the BSDs all feed the hardware source into a -kernel generator instead of handing it out raw. What the tree gets right: a -DRNG with nothing to give is refused loudly, never waited on or papered over. - -**Exit**: a ChaCha20-based kernel generator answers `SYS_RANDOM`. It is seeded -from `RDSEED`, `RDRAND` and a jitter source, and reseeded on a schedule. At -boot a health test refuses a source that repeats or is stuck, by name. A test -feeds the generator a stuck source and shows the boot refused. diff --git a/issues/the-kernels-generator-is-keyed-once-and-never-reseeded.md b/issues/the-kernels-generator-is-keyed-once-and-never-reseeded.md new file mode 100644 index 00000000000..4c2b6f39ea9 --- /dev/null +++ b/issues/the-kernels-generator-is-keyed-once-and-never-reseeded.md @@ -0,0 +1,33 @@ +--- +status: open +kind: defect +opened: 2026-09-24 +--- + +# The kernel's generator is keyed once at boot and never reseeded, and a source is checked only for a constant + +`kernel/src/random.rs` keys a ChaCha20 generator (`toyos-random`) once, before +the first hash container, from the seed the loader read from firmware's +`EFI_RNG_PROTOCOL` and from the CPU's own sources: `RDSEED` and `RDRAND`, or +`RNDR`. The hash seed and every `SYS_RANDOM` byte descend from that key. What +is still owed: + +- **Nothing reseeds.** Each draw replaces the key, so the generator's memory + read later gives no earlier draw; but a key read once gives every later draw + until the machine restarts. Nothing mixes a fresh draw in on a schedule or + after a resume. +- **The sources are firmware and the CPU, and nothing else.** There is no + jitter source. A guest under HVF has no CPU source, so its key is whatever + the host's generator gave QEMU's virtio-rng when edk2 read it, once. +- **The health test is one comparison.** A source whose 32 bytes are four + equal words is refused by name: that is the all-ones `RDRAND` some AMD parts + returned after a resume, and a source stuck on one value. A source that + repeats with a longer period, or is biased, is mixed. +- **A key's copies outside the named buffers are not wiped.** The generator, + a draw's stream and every block buffer are zeroed with volatile writes; what + the compiler spilled of them to a stack frame or left in a register is not. + +**Exit**: the generator is reseeded on a schedule from every source the +machine has, a jitter source among them. At boot and at each reseed a health +test refuses a source that repeats or is stuck, by name. A host test feeds a +reseed a stuck source and shows it refused and the key it had kept. diff --git a/issues/toyos-runs-on-arm64.md b/issues/toyos-runs-on-arm64.md index 1f1f1ec64cf..bb69a6b2b83 100644 --- a/issues/toyos-runs-on-arm64.md +++ b/issues/toyos-runs-on-arm64.md @@ -53,6 +53,18 @@ Every x86 guest on this host runs under TCG emulation instead — there is no - **TLS**: each architecture uses its ABI's variant (x86-64 keeps variant II; AArch64 uses variant I with TLSDESC), and the loader handles both. +## Owner ruling, 2026-10-09 + +"i still want arm hosts to run toyos under arm per default and same goes for +x86." In the tree as it stands, `cargo run` and the build pick x86-64 when no +architecture is named, whatever the host (`src/build.rs`'s `arch_for`, the +`None => Arch::X86_64` arm). The ruling makes the default the host's own +architecture, which `src/arch.rs`'s `Arch::HOST` already knows. It cannot be +flipped today without taking the desktop away from an Apple-silicon host: the +AArch64 image has no virtio devices and no userland servers yet (stages 6 and +7). The flip is an exit item of stage 7, and until then the default staying +x86-64 on an ARM host is a weakness of this track, not a choice. + ## Measured, on `main` at `03b1b4db` **Size.** @@ -254,18 +266,26 @@ Each stage names its exit; "measured" means a number from a run. the kernel's own tables (`TTBR1_EL1` holding memory and nothing else, each user space on `TTBR0_EL1` under a 16-bit ASID from `kernel/pure/pcid`), the GICv3's SGIs and the virtual timer's PPI, the EL0 entry, and the context - switch carrying FP/SIMD; the `virt_` tests other than `virt_early_panic`, - `virt_early_fault` and `virt_el2_drop` judge it under the EL2 profile, emulated, because HVF - exposes no RNDR and the kernel's hash seed refuses there until stage 6's - virtio-rng. Each judges an event, never a rate: no QEMU test measures time. + switch carrying FP/SIMD. The `virt_` tests judge it under HVF on an Apple + host and emulated at EL1 elsewhere, the kernel's generator keyed from the + seed the loader reads from firmware's `EFI_RNG_PROTOCOL`, which edk2 + answers from a virtio-rng (`kernel/src/random.rs`); `virt_el2_drop`, + `virt_smp` and `virt_jobs_at_el2`, one boot of the job case with the timer + and FP jobs in it, stay emulated at EL2, which HVF gives no guest. Each + judges an event, never a rate: no QEMU test measures time. Owed before the exit holds: the interrupts-off window against x86's, a measurement only metal can make, with no instrument on either arch yet; the instruction-cache maintenance before a mapping is executable (`cache::make_executable`), the break-before-make ordering of a live entry's replacement, and the TLB flush before a reclaimed ASID is issued - again, which QEMU's TCG, the only oracle this stage has, cannot fail on: - the first HVF run, once stage 6 gives HVF its RNDR, is their exit; and the - three deletions shown red. They are shown red on a machine whose + again, which QEMU's TCG cannot fail on. Under HVF since the entropy stage: + every program the `virt_` tests run at EL0 is mapped executable through + `cache::make_executable`, and whatever those tests reach of a live entry's + replacement runs there too, with no test red; that is no proof of either, + since a stale instruction or a TLB conflict is not certain to show in one + boot, and which tests replace a live entry is not measured. No test issues + enough address spaces to reclaim an ASID, so that flush has still run on + no oracle that can fail it. And the three deletions shown red. They are shown red on a machine whose firmware leaves the registers otherwise, or by a loader that writes the opposite values before the handoff. The ITS moves to stage 6: a claimed function is its only consumer the small-kernel track leaves, and it needs that @@ -294,13 +314,18 @@ Each stage names its exit; "measured" means a number from a run. (`sched/dump.rs`'s `probe_silent`), which reaches `irqchip::send_nmi`'s `owed!` on a machine of more than one CPU, and which nothing but the `dump-deaf-cpu` actuator asks for until AArch64 has a keyboard; and, for - the first HVF run, the clean of an AP's start block to the point of - coherency, which TCG cannot fail on. + the clean of an AP's start block to the point of coherency, which TCG + cannot fail on: `virt_el1_smp`, `virt_mask_windows` and + `virt_off_names_the_cpus_left_on` start eight CPUs through PSCI under HVF + and are green there, and nothing shows the clean deleted red. 6. **Virtio on `virt`.** virtio-pci (ECAM from MCFG) for blk, net, gpu, sound, input and rng. virtio-input replaces the i8042 as the - key-transition source. virtio-rng, or SMCCC TRNG, feeds `sys_random` - alongside RNDR. SMMUv3 is on `virt` (`-M virt,iommu=smmuv3`), decoded from + key-transition source. The rng is done, and is no ToyOS driver: edk2 + drives the virtio-rng, the loader reads the seed once, and the kernel's + generator is keyed from it and RNDR (`kernel/src/random.rs`); SMCCC TRNG + is absent from QEMU 11.1.1's `virt` under HVF and TCG alike + (`TRNG_VERSION` answers -1 through HVC). SMMUv3 is on `virt` (`-M virt,iommu=smmuv3`), decoded from IORT. **Exit**: netd claims its NIC through an SMMUv3 domain; a foreign-DMA test faults into a `DMA FAULT` record, not a crash. Stage 0's three DMA-ordering fixes (NVMe's phase before its body, xHCI @@ -323,7 +348,8 @@ Each stage names its exit; "measured" means a number from a run. built for `aarch64-unknown-toyos`. C programs stay x86-only until this stage runs one. **Exit**: the desktop comes up on virtio-gpu; `ssh` works from the host; `/log` survives a reboot; the same `system.toml` - drives both arches. + drives both arches; `cargo run` with no architecture named boots the + host's (owner ruling, 2026-10-09). 8. **The harness boots aarch64.** `tests/common/qemu.rs` takes an `Arch`: `virt`, edk2-aarch64, HVF on Apple hosts (TCG otherwise). diff --git a/kernel/Cargo.toml b/kernel/Cargo.toml index 102130680c1..bc74e4b7650 100644 --- a/kernel/Cargo.toml +++ b/kernel/Cargo.toml @@ -498,6 +498,7 @@ toyos-tsc = { path = "../toyos-tsc" } toyos-ps2 = { path = "ps2" } toyos-rootimage = { path = "../toyos-rootimage" } toyos-quiesce = { path = "../toyos-quiesce" } +toyos-random = { path = "../toyos-random" } toyos-symbols = { path = "../toyos-symbols" } toyos-untrusted = { path = "../toyos-untrusted" } toyos-userbound = { path = "../toyos-userbound" } diff --git a/kernel/src/arch/aarch64/entropy.rs b/kernel/src/arch/aarch64/entropy.rs index fb564800f1b..565a075b12f 100644 --- a/kernel/src/arch/aarch64/entropy.rs +++ b/kernel/src/arch/aarch64/entropy.rs @@ -1,43 +1,48 @@ -//! The CPU's own random source: `RNDR`, where `ID_AA64ISAR0_EL1.RNDR` says -//! there is one. A machine without it (QEMU under HVF is one) draws from -//! virtio-rng instead, which the port's stage 6 brings up. +//! The CPU's own random source, which `crate::random` mixes into the +//! generator's key: `RNDR`, where `ID_AA64ISAR0_EL1.RNDR` says there is one. +//! A guest under HVF has none, and its generator is keyed from the loader's +//! seed alone. -/// How often a caller may ask before taking "no data" as the answer: `RNDR` +pub use crate::random::Source; + +pub const SOURCES: &[Source] = &[Source { name: "RNDR", available, draw }]; + +/// How often [`draw`] asks before taking "no data" as the answer: `RNDR` /// reports a transient failure through `NZCV`, as `RDRAND` does through `CF`. -pub const ATTEMPTS: u32 = 10; +const ATTEMPTS: u32 = 10; -fn has_rndr() -> bool { +fn available() -> Result<(), &'static str> { let isar0: u64; // SAFETY: reads an ID register; touches nothing. unsafe { core::arch::asm!("mrs {}, id_aa64isar0_el1", out(reg) isar0, options(nomem, nostack, preserves_flags)) }; - (isar0 >> 60) & 0xF != 0 -} - -/// Whether this CPU can draw at all, or why not. -pub fn available() -> Result<(), &'static str> { - if has_rndr() { + if (isar0 >> 60) & 0xF != 0 { Ok(()) } else { - Err("ID_AA64ISAR0_EL1.RNDR is zero, so this CPU has no RNDR, and virtio-rng is the port's stage 6") + Err("ID_AA64ISAR0_EL1.RNDR is zero, so this CPU has no RNDR") } } -/// One drawn `u64`, or `None` when the source had nothing to give; never waits. -pub fn draw() -> Option { - let value: u64; - let failed: u64; - // SAFETY: `RNDR` (`S3_3_C2_C4_0`) reads a random number, setting `Z` on - // failure; `available` said the register exists before any caller draws. - unsafe { - core::arch::asm!( - "mrs {value}, s3_3_c2_c4_0", - "cset {failed}, eq", - value = out(reg) value, - failed = out(reg) failed, - options(nomem, nostack), - ) - }; - (failed == 0).then_some(value) +/// One drawn `u64`, or `None` after [`ATTEMPTS`] reported no data; never waits. +fn draw() -> Option { + for _ in 0..ATTEMPTS { + let value: u64; + let failed: u64; + // SAFETY: `RNDR` (`S3_3_C2_C4_0`) reads a random number, setting `Z` on + // failure; `available` said the register exists before any draw. + unsafe { + core::arch::asm!( + "mrs {value}, s3_3_c2_c4_0", + "cset {failed}, eq", + value = out(reg) value, + failed = out(reg) failed, + options(nomem, nostack), + ) + }; + if failed == 0 { + return Some(value); + } + } + None } diff --git a/kernel/src/arch/x86_64/entropy.rs b/kernel/src/arch/x86_64/entropy.rs index 9f8ca1149a0..f59891c02e3 100644 --- a/kernel/src/arch/x86_64/entropy.rs +++ b/kernel/src/arch/x86_64/entropy.rs @@ -1,12 +1,20 @@ -//! The CPU's own random source: `RDRAND`. +//! The CPU's own random sources, which `crate::random` mixes into the +//! generator's key: `RDSEED`, the conditioned entropy source itself, and +//! `RDRAND`, the DRBG it seeds (SDM Vol. 1, "Random Number Generator +//! Instructions"). + +use core::arch::asm; use super::cpu; -/// How often a caller may ask before taking "no data" as the answer. -pub const ATTEMPTS: u32 = cpu::RDRAND_ATTEMPTS; +pub use crate::random::Source; + +pub const SOURCES: &[Source] = &[ + Source { name: "RDSEED", available: has_rdseed, draw: rdseed }, + Source { name: "RDRAND", available: has_rdrand, draw: cpu::rdrand }, +]; -/// Whether this CPU can draw at all, or why not. -pub fn available() -> Result<(), &'static str> { +fn has_rdrand() -> Result<(), &'static str> { if cpu::has_rdrand() { Ok(()) } else { @@ -14,7 +22,36 @@ pub fn available() -> Result<(), &'static str> { } } -/// One drawn `u64`, or `None` when the source had nothing to give; never waits. -pub fn draw() -> Option { - cpu::rdrand() +/// `CPUID.(EAX=07H,ECX=0):EBX[18]`; without it `RDSEED` is `#UD`. +fn has_rdseed() -> Result<(), &'static str> { + if cpu::cpuid(7, 0).1 & (1 << 18) != 0 { + Ok(()) + } else { + Err("CPUID.07H:EBX[18] is clear, so this CPU has no RDSEED") + } +} + +/// One drawn `u64`, or `None` after [`cpu::RDRAND_ATTEMPTS`] reported no data: +/// `RDSEED` clears CF while the entropy source refills, and never spins here. +fn rdseed() -> Option { + for _ in 0..cpu::RDRAND_ATTEMPTS { + let val: u64; + let ok: u8; + // SAFETY: writes one register and CF, which `setc` reads back into the + // second declared output; `has_rdseed` answered before any draw. + unsafe { + asm!( + "rdseed {val}", + "setc {ok}", + val = out(reg) val, + ok = out(reg_byte) ok, + options(nomem, nostack), + ); + } + if ok != 0 { + return Some(val); + } + core::hint::spin_loop(); + } + None } diff --git a/kernel/src/hasher.rs b/kernel/src/hasher.rs index 69927a4af33..7940b32b540 100644 --- a/kernel/src/hasher.rs +++ b/kernel/src/hasher.rs @@ -1,5 +1,5 @@ //! The `BuildHasher` every kernel hash container is built on, seeded from -//! the CPU's own random source before any container exists — `kernel/Cargo.toml` takes hashbrown +//! the kernel's random generator before any container exists — `kernel/Cargo.toml` takes hashbrown //! without `default-hasher`, so a container must name a hasher. //! //! **A container built before [`seed`], or on a constant, is the wrong answer @@ -12,8 +12,6 @@ use core::hash::{BuildHasher, Hasher}; use core::sync::atomic::{AtomicU64, Ordering}; -use crate::arch::entropy; - /// `0` until [`seed`] runs, and a value it refuses to draw. static SEED: AtomicU64 = AtomicU64::new(0); @@ -24,20 +22,10 @@ pub const UNSEEDED: &str = /// Two seeds in one boot means a container was built against the first. const RESEEDED: &str = "kernel hasher: seed() ran twice in one boot"; -pub const NO_ENTROPY: &str = - "kernel hasher: the CPU's random source gave no usable value, so the seed would be a \ - constant on every boot"; - -/// Called once, before any container. `0` and all-ones are what a failing -/// source leaves behind, so neither may become a seed. +/// Called once, after `random::key` and before any container. pub fn seed() { - if let Err(why) = entropy::available() { - panic!("kernel hasher: {why}, and the seed has no other source"); - } - let drawn = (0..entropy::ATTEMPTS) - .filter_map(|_| entropy::draw()) - .find(|&v| v != 0 && v != u64::MAX) - .unwrap_or_else(|| panic!("{NO_ENTROPY}")); + let drawn = crate::random::word(); + assert!(drawn != 0, "kernel hasher: the generator drew the one word that means unseeded"); assert_eq!(SEED.swap(drawn, Ordering::Release), 0, "{RESEEDED}"); } diff --git a/kernel/src/main.rs b/kernel/src/main.rs index 1b835cdca24..64464538a19 100644 --- a/kernel/src/main.rs +++ b/kernel/src/main.rs @@ -33,6 +33,7 @@ mod params; mod blackbox; mod deadline; mod quiesce; +mod random; mod hardlockup; mod mm; mod panic; @@ -229,12 +230,12 @@ fn report_log_destination() { /// The architecture's entry calls this once, on the kernel's own stack, with /// the loader's arguments. /// # Safety -/// `kernel_args` is the loader's live [`KernelArgs`], and nothing has run before this. -pub(crate) unsafe extern "C" fn kernel_main(kernel_args: &KernelArgs) -> ! { +/// `loader_args` is the loader's live [`KernelArgs`], which nothing else names, and nothing has run before this. +pub(crate) unsafe extern "C" fn kernel_main(loader_args: &mut KernelArgs) -> ! { // Before the first record, which is stamped at the rate it states. clock::state(); // Copied onto the kernel stack: the original lives on the UEFI stack, unreachable once mm::init drops the identity map. - let kernel_args = *kernel_args; + let mut kernel_args = *loader_args; let entry_count = kernel_args.memory_map_size as usize / core::mem::size_of::(); let maps = core::slice::from_raw_parts( @@ -354,6 +355,9 @@ pub(crate) unsafe extern "C" fn kernel_main(kernel_args: &KernelArgs) -> ! { DirectMap::from_phys(kernel_args.kernel_elf_addr).as_ptr::(), kernel_args.kernel_elf_size as usize, ); + // After the boot's own lines, so its refusal reaches a channel that says + // what machine this is, and while the loader's arguments are still mapped. + random::key(loader_args, &mut kernel_args); let kernel_args = &kernel_args; // `kernel_stack_addr` is an offset into the image, so the image's region is what keeps the stack. @@ -401,9 +405,7 @@ pub(crate) unsafe extern "C" fn kernel_main(kernel_args: &KernelArgs) -> ! { let [image, elf, black_box, root, map] = loader; let reserved = [image, elf, black_box, root, map, arch::boot::reserved()]; - // The last point before the first hash container (`mm::init`'s address - // space), and not earlier: seeding fails only by panicking, and a panic - // before the boot's own log lines reaches no channel at all. + // Before the first hash container, `mm::init`'s address space. hasher::seed(); mm::init(maps, &reserved); diff --git a/kernel/src/random.rs b/kernel/src/random.rs new file mode 100644 index 00000000000..41329331bd8 --- /dev/null +++ b/kernel/src/random.rs @@ -0,0 +1,125 @@ +//! The kernel's one source of random bytes, on every architecture: +//! `toyos_random`'s generator, keyed once at boot and drawn from by the hash +//! seed and by `SYS_RANDOM`. +//! +//! **The key** is every source this machine has, mixed: the seed the loader +//! read from firmware, and each of the architecture's CPU sources +//! ([`Source`]). A source that is absent, gave no data or gave a constant is +//! said by name and not mixed; a machine where nothing is mixed is refused by +//! name. The loader's seed is trusted for being secret and unpredictable, as +//! firmware and a hypervisor are already trusted with the whole machine, and a +//! CPU source is mixed into it, never in its place. Nothing reseeds: the key +//! descends from boot alone. +//! +//! **A draw** takes [`GENERATOR`] for one ChaCha20 block, which replaces the +//! key and keys the draw's own stream, and expands that stream with the lock +//! given back, on the calling thread. So no two draws share a key, a caller +//! holds no state a clone could duplicate, what one process drew says nothing +//! of another's bytes, and the generator's memory read later gives no earlier +//! draw. + +use toyos_abi::boot::KernelArgs; +use toyos_random::{wipe, Generator, Refusal, Seed, Stream, SEED_LEN}; + +use crate::arch::entropy; +use crate::sync::Lock; +use crate::user_ptr::UserBytesMut; + +const _: () = assert!(toyos_abi::boot::SEED_LEN == SEED_LEN); + +/// A random source of the CPU's own. +pub struct Source { + pub name: &'static str, + /// Whether this CPU has it, or why not. + pub available: fn() -> Result<(), &'static str>, + /// One drawn word, or `None` where it had none to give; never waits. + pub draw: fn() -> Option, +} + +/// `None` until [`key`]. +static GENERATOR: Lock> = Lock::new(None); + +const LOADER: &str = "the loader's seed"; + +/// Key the generator, once, before the first draw. `loader` is the loader's +/// own arguments and `copy` the kernel's copy of them: the seed leaves both +/// here. +pub fn key(loader: &mut KernelArgs, copy: &mut KernelArgs) { + let mut generator: Option = None; + let mut mixed = 0u32; + let mut mix = |name: &str, seed: Result| match seed { + Ok(seed) => { + match &mut generator { + Some(generator) => generator.mix(seed), + None => generator = Some(Generator::keyed(seed)), + } + mixed += 1; + log!("random: {name} is mixed into the generator's key"); + } + Err(why) => log!("random: {name} is not mixed: {why}"), + }; + + match usize::try_from(copy.loader_seed_len).ok().and_then(|len| copy.loader_seed.get(..len)) { + Some([]) => log!("random: {LOADER} is not mixed: the loader handed none"), + Some(bytes) => mix(LOADER, Seed::judge(bytes)), + None => mix(LOADER, Err(Refusal::Length(copy.loader_seed_len as usize))), + } + for args in [loader, copy] { + wipe(&mut args.loader_seed); + args.loader_seed_len = 0; + } + + for source in entropy::SOURCES { + if let Err(why) = (source.available)() { + log!("random: {} is not mixed: {why}", source.name); + continue; + } + let mut bytes = [0u8; SEED_LEN]; + let drawn = bytes + .as_chunks_mut::<8>() + .0 + .iter_mut() + .all(|word| (source.draw)().map(|drawn| *word = drawn.to_ne_bytes()).is_some()); + if drawn { + mix(source.name, Seed::judge(&bytes)); + } else { + log!("random: {} is not mixed: it had no data to give", source.name); + } + wipe(&mut bytes); + } + + let Some(generator) = generator else { + panic!( + "random: nothing keyed the generator: the loader handed no seed and this CPU has no random \ + source this kernel draws from, so no byte it gave out would be random" + ) + }; + log!("random: the generator is keyed from {mixed} source(s), and every random byte is its ChaCha20"); + assert!(GENERATOR.lock().replace(generator).is_none(), "random: key() ran twice in one boot"); +} + +/// One draw's stream. The lock is held for the one block that makes it. +fn stream() -> Stream { + GENERATOR.lock().as_mut().expect("random: a draw before random::key()").stream() +} + +/// A drawn word, for the kernel's own use. +pub fn word() -> u64 { + let mut bytes = [0u8; 8]; + stream().fill(&mut bytes); + u64::from_ne_bytes(bytes) +} + +/// Fill a caller's window with one draw. +pub fn fill_user(out: &mut UserBytesMut) { + let mut stream = stream(); + let mut chunk = [0u8; 256]; + let mut at = 0; + while at < out.len() { + let n = (out.len() - at).min(chunk.len()); + stream.fill(&mut chunk[..n]); + out.write_at(at, &chunk[..n]); + at += n; + } + wipe(&mut chunk); +} diff --git a/kernel/src/syscall/io.rs b/kernel/src/syscall/io.rs index 684d6f1233c..89a4a03d3cd 100644 --- a/kernel/src/syscall/io.rs +++ b/kernel/src/syscall/io.rs @@ -274,18 +274,7 @@ pub(super) fn sys_write_nonblock(h: RawHandle, buf: &UserBytes) -> u64 { } } -/// A DRNG with nothing to give is refused here, never waited on. pub(super) fn sys_random(out: &mut UserBytesMut) -> u64 { - let mut i = 0; - while i + 8 <= out.len() { - let Some(drawn) = crate::arch::entropy::draw() else { return SyscallError::Io.to_u64() }; - out.write_at(i, &drawn.to_ne_bytes()); - i += 8; - } - let remaining = out.len() - i; - if remaining > 0 { - let Some(drawn) = crate::arch::entropy::draw() else { return SyscallError::Io.to_u64() }; - out.write_at(i, &drawn.to_ne_bytes()[..remaining]); - } + crate::random::fill_user(out); 0 } diff --git a/src/qemu.rs b/src/qemu.rs index 32800acce2a..b23c16cb150 100644 --- a/src/qemu.rs +++ b/src/qemu.rs @@ -215,6 +215,13 @@ pub fn launch(opts: &Options) { }; } + // Firmware's alone: edk2's driver puts `EFI_RNG_PROTOCOL` behind it for the + // loader's seed. `virt` needs it because a guest under HVF has no RNDR; a + // q35's firmware answers the protocol from RDRAND. + if arch == Arch::Aarch64 { + qemu.arg("-device").arg("virtio-rng-pci"); + } + if shape.virtio { qemu.arg("-netdev") .arg("user,id=net0,hostfwd=tcp::2222-:22") diff --git a/tests/common/qemu.rs b/tests/common/qemu.rs index 22ad5752d3e..e4f4b3fd789 100644 --- a/tests/common/qemu.rs +++ b/tests/common/qemu.rs @@ -694,10 +694,16 @@ pub enum Profile { /// it away for the one test that certifies the T14's literal shape. Metal, /// QEMU `virt` on AArch64 (GICv3, AAVMF): a GOP from `ramfb`, the boot - /// stick on an xHCI, the PL011, and nothing else — no virtio, NIC, NVMe or - /// IOMMU. The machine the AArch64 port reaches its console on, and the only - /// profile that is not a q35. + /// stick on an xHCI, the PL011, a virtio-rng for firmware's + /// `EFI_RNG_PROTOCOL`, and nothing else — no NIC, NVMe or IOMMU. The + /// machine the AArch64 port runs on, under HVF on an Apple host and + /// emulated at EL1 elsewhere, and the only profile that is not a q35. Virt, + /// [`Profile::Virt`] with no virtio-rng, on a CPU with no RNDR: the host's + /// under HVF, and `cortex-a72` where it is emulated. Firmware then has no + /// `EFI_RNG_PROTOCOL`, so the kernel's generator has nothing to be keyed + /// from. + VirtNoRng, /// [`Profile::Virt`] with EL2 (`virtualization=on`), emulated on `-cpu max`: /// firmware then hands the loader the CPU at EL2, and the kernel's entry /// has to drop from it. HVF gives a guest EL1 only. @@ -707,18 +713,13 @@ pub enum Profile { /// register name is EL1's own, so the loader's EL2 arm alone turns EL2's /// MMU off. VirtEl2NoVhe, - /// [`Profile::Virt`] emulated on `-cpu max` whatever the host: firmware - /// hands the loader the CPU at EL1, as HVF does, and QEMU's FADT names - /// PSCI's conduit `HVC`; unlike HVF's, the CPU has RNDR for the kernel's - /// hash seed. - VirtTcg, } impl Profile { /// The architecture this machine is. pub fn arch(self) -> Arch { match self { - Self::Virt | Self::VirtEl2 | Self::VirtEl2NoVhe | Self::VirtTcg => Arch::Aarch64, + Self::Virt | Self::VirtNoRng | Self::VirtEl2 | Self::VirtEl2NoVhe => Arch::Aarch64, Self::Headless | Self::HeadlessNoIommu | Self::Metal => Arch::X86_64, @@ -728,7 +729,7 @@ impl Profile { /// How this host provides the machine. pub fn accel(self) -> Accel { match self { - Self::VirtEl2 | Self::VirtEl2NoVhe | Self::VirtTcg => Accel::Tcg, + Self::VirtEl2 | Self::VirtEl2NoVhe => Accel::Tcg, _ => self.arch().accel(), } } @@ -737,6 +738,7 @@ impl Profile { fn cpu(self) -> &'static str { match self { Self::VirtEl2NoVhe => "cortex-a72", + Self::VirtNoRng if !self.accel().is_hardware() => "cortex-a72", _ => self.arch().cpu(self.accel()), } } @@ -839,6 +841,12 @@ struct Shape { /// profile because absence is a shape and because the unit's own /// capabilities are what the kernel reads at boot. iommu: Option, + /// A virtio-rng, which is firmware's alone: edk2's driver puts + /// `EFI_RNG_PROTOCOL` behind it for the loader's seed, and the kernel + /// drives no such device. `virt` has it because an HVF guest's CPU has no + /// RNDR for firmware or the kernel to draw from; a q35's firmware answers + /// the protocol from RDRAND without one. + rng: bool, } /// The boot stick's device id: the removal the owner's machine dies on is the @@ -858,7 +866,8 @@ pub const NVME_SMALL: u64 = 128 * 1024 * 1024; impl Profile { fn shape(self) -> Shape { match self { - Self::VirtEl2 | Self::VirtEl2NoVhe | Self::VirtTcg => Self::Virt.shape(), + Self::VirtEl2 | Self::VirtEl2NoVhe => Self::Virt.shape(), + Self::VirtNoRng => Shape { rng: false, ..Self::Virt.shape() }, Self::Virt => Shape { vga: "std", panel: None, @@ -868,6 +877,7 @@ impl Profile { usb: &[], nvme_bytes: 0, iommu: None, + rng: true, }, Self::Headless => Shape { vga: "none", @@ -878,6 +888,7 @@ impl Profile { usb: &["usb-kbd,bus=xhci.0"], nvme_bytes: NVME_SMALL, iommu: Some(IOMMU_DEFAULT), + rng: false, }, Self::Metal => Shape { vga: "std", @@ -892,6 +903,7 @@ impl Profile { usb: &[], nvme_bytes: NVME_SMALL, iommu: Some(IOMMU_DEFAULT), + rng: false, }, Self::HeadlessNoIommu => Shape { iommu: None, ..Self::Headless.shape() }, } @@ -2117,6 +2129,9 @@ fn qemu_command( for dev in shape.usb { qemu.arg("-device").arg(*dev); } + if shape.rng { + qemu.arg("-device").arg("virtio-rng-pci"); + } // The NIC before the virtio block, so a profile that has one and not the // other still creates it after the unit and before everything else. @@ -2174,8 +2189,8 @@ fn qemu_command( } if let Some(trace) = &options.psci_trace { assert!( - arch == Arch::Aarch64 && accel == Accel::Tcg, - "a PSCI trace is TCG's `arm_psci_call`, and this profile's PSCI is not QEMU's TCG" + arch == Arch::Aarch64 && accel != Accel::Kvm, + "a PSCI trace is QEMU's `arm_psci_call`, and under KVM the host's kernel answers PSCI" ); qemu.arg("-trace").arg("arm_psci_call").arg("-D").arg(trace); } diff --git a/tests/toyos-rust-tests/src/bin/random_draws.rs b/tests/toyos-rust-tests/src/bin/random_draws.rs new file mode 100644 index 00000000000..5ac55b8a428 --- /dev/null +++ b/tests/toyos-rust-tests/src/bin/random_draws.rs @@ -0,0 +1,57 @@ +//! What `SYS_RANDOM` answers, from inside the machine. +//! +//! What it asserts itself is what holds of one boot on every machine: a draw +//! fills exactly its window, no two draws are alike however many threads draw +//! at once, and the bytes are not grossly biased. Its one line carries a draw +//! for the host, which boots twice and compares: only it can see that two +//! boots differ. + +use std::collections::HashSet; + +use toyos_abi::syscall::random; + +/// What a window holds before a draw, and what the bytes past it still hold after. +const UNDRAWN: u8 = 0xa5; + +/// Threads drawing at once, and the draws each makes. +const THREADS: usize = 8; +const DRAWS: usize = 1000; + +fn draw32() -> [u8; 32] { + let mut bytes = [UNDRAWN; 32]; + random(&mut bytes).expect("SYS_RANDOM refused a 32-byte window"); + bytes +} + +fn main() { + random(&mut []).expect("SYS_RANDOM refused an empty window"); + + // A length no multiple of a ChaCha20 block or of the kernel's own chunk: + // every byte of the window is drawn and none past it is written. + let mut odd = [UNDRAWN; 1031 + 16]; + random(&mut odd[..1031]).expect("SYS_RANDOM refused a 1031-byte window"); + assert!(odd[1031..].iter().all(|&b| b == UNDRAWN), "a draw wrote past its window"); + assert!(odd[1015..1031].iter().any(|&b| b != UNDRAWN), "a draw left the end of its window undrawn"); + let blocks: HashSet<&[u8]> = odd[..1024].chunks(64).collect(); + assert_eq!(blocks.len(), 16, "two 64-byte blocks of one draw are alike"); + + // 32768 bits, each a coin: the ones are within eight standard deviations + // (8 * sqrt(32768) / 2 = 724) of half, or the bytes are not a keystream. + let mut page = [0u8; 4096]; + random(&mut page).expect("SYS_RANDOM refused a 4096-byte window"); + let ones: u32 = page.iter().map(|b| b.count_ones()).sum(); + assert!(ones.abs_diff(16384) < 724, "{ones} of 32768 drawn bits are ones"); + + let drawers: Vec<_> = (0..THREADS) + .map(|_| std::thread::spawn(|| (0..DRAWS).map(|_| draw32()).collect::>())) + .collect(); + let mut seen = HashSet::new(); + for drawer in drawers { + for bytes in drawer.join().expect("a drawing thread panicked") { + assert!(seen.insert(bytes), "two draws gave the same 32 bytes"); + } + } + + let hex: String = draw32().iter().map(|b| format!("{b:02x}")).collect(); + println!("random_draws: {THREADS} threads drew {DRAWS} times each and no two draws were alike; one more: {hex}"); +} diff --git a/tests/toyos.rs b/tests/toyos.rs index e67da326128..c1365e6f691 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -203,6 +203,10 @@ const DRIVEN_AND_SHARED: &[&str] = &[ "fault_gates", "sched_stress", "std_alloc", + // Its shared run asserts what holds of one boot's draws on x86-64; + // `virt_random_differs` builds it for AArch64, runs it on that + // architecture's job case, and compares two boots' draws. + "random_draws", ]; /// What `test-early-panic` panics with (`kernel/src/main.rs`): the last line its @@ -230,24 +234,29 @@ const SCREEN_TESTS: &[(&str, qemu::Profile)] = &[ ("virt_early_panic", qemu::Profile::Virt), ("virt_early_fault", qemu::Profile::Virt), ("virt_el2_drop", qemu::Profile::VirtEl2NoVhe), - ("virt_user_mode", qemu::Profile::VirtEl2), - ("virt_timer_preempts", qemu::Profile::VirtEl2), - ("virt_irq_storm", qemu::Profile::VirtEl2), - ("virt_timer_floor", qemu::Profile::VirtEl2), - ("virt_fp_isolation", qemu::Profile::VirtEl2), - ("virt_first_entry", qemu::Profile::VirtEl2), - ("virt_unmap_touch", qemu::Profile::VirtEl2), - ("virt_debug_refused", qemu::Profile::VirtEl2), - ("virt_readonly_copyout", qemu::Profile::VirtEl2), - ("virt_ring0_timer_in_syscall", qemu::Profile::VirtEl2), - ("virt_mask_windows", qemu::Profile::VirtEl2), + ("virt_user_mode", qemu::Profile::Virt), + ("virt_timer_preempts", qemu::Profile::Virt), + ("virt_irq_storm", qemu::Profile::Virt), + ("virt_timer_floor", qemu::Profile::Virt), + ("virt_fp_isolation", qemu::Profile::Virt), + ("virt_first_entry", qemu::Profile::Virt), + ("virt_unmap_touch", qemu::Profile::Virt), + ("virt_debug_refused", qemu::Profile::Virt), + ("virt_readonly_copyout", qemu::Profile::Virt), + ("virt_ring0_timer_in_syscall", qemu::Profile::Virt), + ("virt_mask_windows", qemu::Profile::Virt), ("virt_smp", qemu::Profile::VirtEl2), - ("virt_el1_smp", qemu::Profile::VirtTcg), - ("virt_failed_ap_leaves_no_hole", qemu::Profile::VirtEl2), - ("virt_fatal_halts_the_others_first", qemu::Profile::VirtEl2), - ("virt_reboot", qemu::Profile::VirtEl2), - ("virt_off_names_the_cpus_left_on", qemu::Profile::VirtEl2), - ("virt_reboot_refused_without_psci", qemu::Profile::VirtEl2), + ("virt_el1_smp", qemu::Profile::Virt), + ("virt_failed_ap_leaves_no_hole", qemu::Profile::Virt), + ("virt_fatal_halts_the_others_first", qemu::Profile::Virt), + ("virt_reboot", qemu::Profile::Virt), + ("virt_off_names_the_cpus_left_on", qemu::Profile::Virt), + ("virt_reboot_refused_without_psci", qemu::Profile::Virt), + // The job case entered at EL2, once: the entry's EL2 writes for the timer + // and FP, which no boot under HVF runs. + ("virt_jobs_at_el2", qemu::Profile::VirtEl2), + ("virt_random_differs", qemu::Profile::Virt), + ("virt_no_seed_refused", qemu::Profile::VirtNoRng), ]; /// The tests whose machine shape *is* the test, each on a boot of its own. @@ -1659,14 +1668,35 @@ fn suite_bin(arch: toyos_build::arch::Arch, name: &'static str) -> (String, Vec< (format!("bin/test_rs_{name}"), bytes) } -/// Boot `tests/virtjobcase` on one CPU and judge its job `job`: it ends with -/// exit 0, having said `said`. One CPU because `preempt` and `fp_isolation` +/// The same for its job `test_rs_random_draws`. +const VIRT_RANDOM: &str = "random_draws"; + +/// `tests/virtjobcase`'s jobs, in the order its job list runs them, and what +/// each says once the kernel kept what it asks about. +const VIRT_JOBS: &[(&str, &str)] = &[ + ("preempt", "preempt: the counting thread was preempted twice"), + ("fp_isolation", "fp_isolation: v0-v31, FPCR and FPSR survived"), + ("first_entry", "first_entry: x1-x30 were zero"), + ("unmap_touch", UNMAP_TOUCH_SAID), + ("debug_refused", "debug_refused: SYS_DEBUG's double fault and TLB acknowledgement delay were refused"), + ("test_rs_ring0_timer_in_syscall", "the timer interrupted the syscall's body and re-armed a quantum"), + ("test_rs_random_draws", RANDOM_DRAWS_SAID), + ("test_rs_abuse_readonly_copyout", "a syscall writes only where its caller could store"), +]; + +/// What `random_draws` says before the draw its line carries. +const RANDOM_DRAWS_SAID: &str = "random_draws: 8 threads drew 1000 times each and no two draws were alike; one more: "; + +/// What the kernel says once the loader's seed is in its generator's key. +const LOADER_SEED_MIXED: &str = "random: the loader's seed is mixed into the generator's key"; + +/// Boot `tests/virtjobcase` on one CPU, because `preempt` and `fp_isolation` /// see a sibling run only when it took theirs. The kernel carries `SYS_DEBUG` /// for `debug_refused`, and every job runs in every boot of the case. -fn virt_job(profile: qemu::Profile, job: &str, said: &str) -> Result<(), String> { +fn boot_virt_jobs(profile: qemu::Profile) -> QemuInstance { let config = compile::repo_root().join("tests/virtjobcase/system.toml"); let case = config.parent().expect("system.toml has a directory"); - let mut qemu = QemuInstance::boot_with_options( + QemuInstance::boot_with_options( case, &[], &[], @@ -1675,10 +1705,19 @@ fn virt_job(profile: qemu::Profile, job: &str, said: &str) -> Result<(), String> smp: 1, kernel_features: toyos_build::build::TEST_KERNEL, ready_marker: "control registers: SCTLR_EL1=", - extra_root_files: vec![suite_bin(profile.arch(), VIRT_COPYOUT), suite_bin(profile.arch(), VIRT_RING0_TIMER)], + extra_root_files: [VIRT_COPYOUT, VIRT_RING0_TIMER, VIRT_RANDOM] + .map(|name| suite_bin(profile.arch(), name)) + .to_vec(), ..Default::default() }, - ); + ) +} + +/// Boot [`boot_virt_jobs`]' case and judge its job `job`: it ends with exit 0, +/// having said what [`VIRT_JOBS`] has it say. +fn virt_job(profile: qemu::Profile, job: &str) -> Result<(), String> { + let (_, said) = VIRT_JOBS.iter().find(|(name, _)| *name == job).expect("a job of the case"); + let mut qemu = boot_virt_jobs(profile); let mut serial = virt_console(&qemu); judge_virt_job(&mut qemu, &mut serial, job, said)?; // The kernel's record `one_clock` reads beside the supervisor's line: it @@ -1892,6 +1931,82 @@ fn mask_windows(capture: &str, cpus: u32) -> Result<(), String> { Ok(()) } +/// One boot of the job case entered at EL2, every job judged: the kernel's +/// entry there writes `CNTHCTL_EL2`, `CNTVOFF_EL2` and `CPTR_EL2` for the +/// timer and FP the jobs then use at EL0, which a boot under HVF never runs. +fn virt_jobs_at_el2(profile: qemu::Profile) -> Result<(), String> { + let mut qemu = boot_virt_jobs(profile); + let mut serial = virt_console(&qemu); + const ENTERED: &str = "as declared; entered at EL2"; + if !serial.contains(ENTERED) { + return Err(format!("{ENTERED:?} not on the PL011, so this boot judges no EL2 entry\nserial:\n{serial}")); + } + for (job, said) in VIRT_JOBS { + judge_virt_job(&mut qemu, &mut serial, job, said)?; + } + Ok(()) +} + +/// Two boots of the job case, each keyed from the loader's seed, and the draw +/// `random_draws` prints on each: the two differ. A generator keyed from +/// anything an image carries prints one draw on every boot. +fn virt_random_differs(profile: qemu::Profile) -> Result<(), String> { + let mut draws = Vec::new(); + for boot in 1..=2 { + let mut qemu = boot_virt_jobs(profile); + let mut serial = virt_console(&qemu); + judge_virt_job(&mut qemu, &mut serial, "test_rs_random_draws", RANDOM_DRAWS_SAID)?; + if !serial.contains(LOADER_SEED_MIXED) { + return Err(format!("{LOADER_SEED_MIXED:?} not on the PL011 of boot {boot}\nserial:\n{serial}")); + } + let draw = serial + .lines() + .find_map(|l| l.split_once(RANDOM_DRAWS_SAID).map(|(_, draw)| draw.trim().to_string())) + .expect("judge_virt_job found the line"); + if draw.len() != 64 || !draw.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err(format!("boot {boot} printed no 32-byte draw: {draw:?}")); + } + draws.push(draw); + } + if draws[0] == draws[1] { + return Err("two boots of one image printed the same 32-byte draw".to_string()); + } + eprintln!(" [virt] two boots keyed from the loader's seed printed two draws"); + Ok(()) +} + +/// A machine with no source at all: no virtio-rng, so firmware has no +/// `EFI_RNG_PROTOCOL` and the loader hands no seed, and a CPU with no RNDR. +/// The kernel says each and refuses by name before its first hash container. +fn virt_no_seed_refused(profile: qemu::Profile, test_config: &Path) -> Result<(), String> { + const REFUSED: &str = "random: nothing keyed the generator"; + let options = BootOptions { profile, ready_marker: REFUSED, ..Default::default() }; + // The premise: the machine the test names is the one QEMU is asked for. + let argv = qemu::profile_argv(&options); + if let Some(rng) = argv.iter().find(|a| a.contains("virtio-rng")) { + return Err(format!("the machine with no seed has {rng}: {argv:?}")); + } + let mut qemu = QemuInstance::boot_with_options(test_config, &[], &[], options); + let rest = qemu.drain_until(Duration::from_secs(6), |l| l.contains("EARLY PANIC: panicked at")); + let serial = format!("{}\n{rest}", qemu.boot_log()); + for want in [ + "random: the loader's seed is not mixed: the loader handed none", + "random: RNDR is not mixed: ID_AA64ISAR0_EL1.RNDR is zero, so this CPU has no RNDR", + REFUSED, + "EARLY PANIC: panicked at", + ] { + if !serial.contains(want) { + return Err(format!("{want:?} not on the PL011\nserial:\n{serial}")); + } + } + for never in ["random: the generator is keyed", "paging: the direct map holds memory below"] { + if serial.contains(never) { + return Err(format!("{never:?} on the PL011 of a machine with no source\nserial:\n{serial}")); + } + } + Ok(()) +} + /// Everything the PL011 has carried on `qemu`'s boot: the capture each /// [`judge_virt_job`] after the first goes on from, since a drain reads past /// the marker it waited for. @@ -2575,26 +2690,24 @@ fn run_screen_test(name: &str, profile: qemu::Profile, test_config: &Path) -> Re Ok(()) } "virt_user_mode" => { - // The port's stage 4, under the EL2 profile whose - // entry also writes what the drop leaves EL2 holding: the kernel's - // own tables, the GIC and the timer, and a process at EL0 — the supervisor, - // whose every page arrives by a demand fault and whose spawn of - // `logkeeper` is a syscall the kernel answered. Emulated, and not under - // HVF, which exposes no RNDR for the kernel's hash seed. - let mut qemu = QemuInstance::boot_with_options( - test_config, - &[], - &[], - BootOptions { - profile, - ready_marker: "control registers: SCTLR_EL1=", - ..Default::default() - }, - ); + // The port's stage 4: the generator keyed from the seed the loader + // read from firmware's virtio-rng, the kernel's own tables, the GIC + // and the timer, and a process at EL0 — the supervisor, whose every + // page arrives by a demand fault and whose spawn of `logkeeper` is + // a syscall the kernel answered. + let options = + BootOptions { profile, ready_marker: "control registers: SCTLR_EL1=", ..Default::default() }; + let argv = qemu::profile_argv(&options); + if !argv.iter().any(|a| a.starts_with("virtio-rng-pci")) { + return Err(format!("`virt` has no virtio-rng for firmware's EFI_RNG_PROTOCOL: {argv:?}")); + } + let mut qemu = QemuInstance::boot_with_options(test_config, &[], &[], options); const SPAWNED: &str = "spawn: /system/bin/logkeeper pid="; let rest = qemu.drain_until(Duration::from_secs(30), |l| l.contains(SPAWNED)); let serial = format!("{}\n{rest}", qemu.boot_log()); for want in [ + LOADER_SEED_MIXED, + "random: the generator is keyed from", "paging: the direct map holds memory below", "percpu: BSP cpu_id=0", "GIC: v", @@ -2608,23 +2721,16 @@ fn run_screen_test(name: &str, profile: qemu::Profile, test_config: &Path) -> Re } Ok(()) } - "virt_timer_preempts" => virt_job(profile, "preempt", "preempt: the counting thread was preempted twice"), - "virt_fp_isolation" => virt_job(profile, "fp_isolation", "fp_isolation: v0-v31, FPCR and FPSR survived"), - "virt_first_entry" => virt_job(profile, "first_entry", "first_entry: x1-x30 were zero"), - "virt_unmap_touch" => virt_job(profile, "unmap_touch", UNMAP_TOUCH_SAID), - "virt_debug_refused" => virt_job( - profile, - "debug_refused", - "debug_refused: SYS_DEBUG's double fault and TLB acknowledgement delay were refused", - ), - "virt_readonly_copyout" => { - virt_job(profile, &format!("test_rs_{VIRT_COPYOUT}"), "a syscall writes only where its caller could store") - } - "virt_ring0_timer_in_syscall" => virt_job( - profile, - &format!("test_rs_{VIRT_RING0_TIMER}"), - "the timer interrupted the syscall's body and re-armed a quantum", - ), + "virt_timer_preempts" => virt_job(profile, "preempt"), + "virt_fp_isolation" => virt_job(profile, "fp_isolation"), + "virt_first_entry" => virt_job(profile, "first_entry"), + "virt_unmap_touch" => virt_job(profile, "unmap_touch"), + "virt_debug_refused" => virt_job(profile, "debug_refused"), + "virt_readonly_copyout" => virt_job(profile, &format!("test_rs_{VIRT_COPYOUT}")), + "virt_ring0_timer_in_syscall" => virt_job(profile, &format!("test_rs_{VIRT_RING0_TIMER}")), + "virt_jobs_at_el2" => virt_jobs_at_el2(profile), + "virt_random_differs" => virt_random_differs(profile), + "virt_no_seed_refused" => virt_no_seed_refused(profile, test_config), "virt_mask_windows" => virt_mask_windows(profile), "virt_irq_storm" => { // The CPU floods itself with SGIs until the timer has fired a @@ -2636,7 +2742,7 @@ fn run_screen_test(name: &str, profile: qemu::Profile, test_config: &Path) -> Re "virt_timer_floor" => virt_selftest(profile, test_config, &["timer-floor"]), "virt_smp" => virt_smp(profile, "SMC", 2), // The EL1 entry's own arm, which fetches at a physical address under - // the bring-up root, and PSCI through `HVC`: the path HVF takes. + // the bring-up root, and PSCI through `HVC`. "virt_el1_smp" => virt_smp(profile, "HVC", 1), "virt_failed_ap_leaves_no_hole" => virt_failed_ap_leaves_no_hole(profile), "virt_fatal_halts_the_others_first" => virt_fatal_halts_the_others_first(profile), diff --git a/tests/virtjobcase/system.toml b/tests/virtjobcase/system.toml index bc852a4360c..8b8cb7bb0f5 100644 --- a/tests/virtjobcase/system.toml +++ b/tests/virtjobcase/system.toml @@ -12,9 +12,9 @@ syscap = ["logread"] [programs.test-runner] receives = ["power"] -# Four minutes from boot rather than one: every job here runs on an emulated +# Four minutes from boot rather than one: a job here may run on an emulated # CPU, and a job past the bound reboots the guest with the job named. -args = ["--bound-ms=240000", "preempt", "fp_isolation", "first_entry", "unmap_touch", "debug_refused", "test_rs_ring0_timer_in_syscall", "test_rs_abuse_readonly_copyout"] +args = ["--bound-ms=240000", "preempt", "fp_isolation", "first_entry", "unmap_touch", "debug_refused", "test_rs_ring0_timer_in_syscall", "test_rs_random_draws", "test_rs_abuse_readonly_copyout"] [programs.kernelprobe] diff --git a/toyos-abi/src/boot.rs b/toyos-abi/src/boot.rs index 33c34e9f158..8546826518e 100644 --- a/toyos-abi/src/boot.rs +++ b/toyos-abi/src/boot.rs @@ -108,8 +108,18 @@ pub struct KernelArgs { pub loader_entry_counter: u64, pub loader_handoff_counter: u64, pub root_read_ticks: u64, + /// What firmware's `EFI_RNG_PROTOCOL` gave the loader for the kernel's + /// random generator, and how many of these bytes it is: [`SEED_LEN`], or + /// zero where firmware has no such protocol or gave nothing usable, which + /// is a machine and not an error. Secret: the kernel zeroes both fields + /// once it has read them, and neither side prints a byte of them. + pub loader_seed: [u8; SEED_LEN], + pub loader_seed_len: u64, } +/// The bytes of [`KernelArgs::loader_seed`]. +pub const SEED_LEN: usize = 32; + /// [`KernelArgs::layout`] for the struct this file declares: the struct's own /// size folded in. Never within -1440..=1440 as an `i32`: a loader older than /// the word wrote a firmware zone in minutes at its offset. @@ -225,7 +235,9 @@ const _: () = { assert!(offset_of!(KernelArgs, loader_entry_counter) == 1248); assert!(offset_of!(KernelArgs, loader_handoff_counter) == 1256); assert!(offset_of!(KernelArgs, root_read_ticks) == 1264); - assert!(size_of::() == 1272); + assert!(offset_of!(KernelArgs, loader_seed) == 1272); + assert!(offset_of!(KernelArgs, loader_seed_len) == 1304); + assert!(size_of::() == 1312); assert!(LAYOUT as i32 > 1440 || (LAYOUT as i32) < -1440); assert!(align_of::() == 8); assert!(size_of::() == 16); @@ -320,6 +332,8 @@ mod tests { loader_entry_counter: 0, loader_handoff_counter: 0, root_read_ticks: 0, + loader_seed: [0; SEED_LEN], + loader_seed_len: 0, }; #[test] diff --git a/toyos-random/Cargo.toml b/toyos-random/Cargo.toml new file mode 100644 index 00000000000..19fecdc9662 --- /dev/null +++ b/toyos-random/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "toyos-random" +description = "The kernel's random generator, ChaCha20 with its key replaced at every draw, and the judgment of the bytes that may key it, which the loader makes of firmware's too; pure." +version = "0.1.0" +edition = "2024" +license = "MIT OR Apache-2.0" +publish = false diff --git a/toyos-random/src/chacha.rs b/toyos-random/src/chacha.rs new file mode 100644 index 00000000000..7f7624d3f84 --- /dev/null +++ b/toyos-random/src/chacha.rs @@ -0,0 +1,50 @@ +//! The ChaCha20 block function, as RFC 8439 §2.3 states it. + +use crate::wipe; + +/// "expand 32-byte k", the constant of §2.3's first four words. +const CONSTANT: [u32; 4] = [0x6170_7865, 0x3320_646e, 0x7962_2d32, 0x6b20_6574]; + +/// §2.1's quarter round, on four words of the state. +#[inline(always)] +pub(crate) fn quarter_round(s: &mut [u32; 16], a: usize, b: usize, c: usize, d: usize) { + s[a] = s[a].wrapping_add(s[b]); + s[d] = (s[d] ^ s[a]).rotate_left(16); + s[c] = s[c].wrapping_add(s[d]); + s[b] = (s[b] ^ s[c]).rotate_left(12); + s[a] = s[a].wrapping_add(s[b]); + s[d] = (s[d] ^ s[a]).rotate_left(8); + s[c] = s[c].wrapping_add(s[d]); + s[b] = (s[b] ^ s[c]).rotate_left(7); +} + +/// The 64-byte block `key`, `counter` and `nonce` give, into `out`. Both +/// states this works in hold the key, and are wiped before it returns. +pub(crate) fn block(key: &[u8; 32], counter: u32, nonce: &[u8; 12], out: &mut [u8; 64]) { + let mut initial = [0u32; 16]; + initial[..4].copy_from_slice(&CONSTANT); + for (at, bytes) in key.as_chunks::<4>().0.iter().enumerate() { + initial[4 + at] = u32::from_le_bytes(*bytes); + } + initial[12] = counter; + for (at, bytes) in nonce.as_chunks::<4>().0.iter().enumerate() { + initial[13 + at] = u32::from_le_bytes(*bytes); + } + + let mut state = initial; + for _ in 0..10 { + quarter_round(&mut state, 0, 4, 8, 12); + quarter_round(&mut state, 1, 5, 9, 13); + quarter_round(&mut state, 2, 6, 10, 14); + quarter_round(&mut state, 3, 7, 11, 15); + quarter_round(&mut state, 0, 5, 10, 15); + quarter_round(&mut state, 1, 6, 11, 12); + quarter_round(&mut state, 2, 7, 8, 13); + quarter_round(&mut state, 3, 4, 9, 14); + } + for (at, bytes) in out.as_chunks_mut::<4>().0.iter_mut().enumerate() { + *bytes = state[at].wrapping_add(initial[at]).to_le_bytes(); + } + wipe(&mut initial); + wipe(&mut state); +} diff --git a/toyos-random/src/lib.rs b/toyos-random/src/lib.rs new file mode 100644 index 00000000000..c9274f601a4 --- /dev/null +++ b/toyos-random/src/lib.rs @@ -0,0 +1,169 @@ +//! The kernel's random generator, and the judgment of what may key it. +//! +//! **A [`Generator`] exists only keyed.** Its one constructor takes a [`Seed`], +//! and a `Seed` is 32 bytes [`Seed::judge`] did not refuse: bytes whose four +//! words are one value are what a source that failed or stuck leaves, zeros +//! and ones among them, and a length other than 32 is no seed either. The +//! bytes are never parsed: a seed is trusted for being secret and +//! unpredictable, which nothing here can check, and for nothing about its +//! form. +//! +//! **Every source is mixed in, none is substituted.** [`Generator::mix`] XORs +//! a seed into the key and replaces the key with the ChaCha20 block of the +//! result, so the key is unpredictable while any one seed mixed was, provided +//! no seed was chosen by somebody who knew the key it met. +//! +//! **A key gives one draw.** [`Generator::stream`] is fast key erasure: one +//! block under the key, whose first half replaces the key and whose second +//! keys the [`Stream`] the draw's bytes come from. The key a draw was made +//! under is gone when the draw begins, so the generator's memory read later +//! does not give an earlier draw, and one draw's bytes are ChaCha20 output +//! under a key no other draw has. +//! +//! Pure: the caller draws from the machine and holds the lock. + +#![no_std] + +mod chacha; + +#[cfg(test)] +mod tests; + +use core::fmt; +use core::sync::atomic::{Ordering, compiler_fence}; + +/// The bytes of a seed, and of a key. +pub const SEED_LEN: usize = 32; + +/// Zero `buf` with writes the compiler may not drop, though nothing reads +/// `buf` again. +pub fn wipe(buf: &mut [T]) { + for slot in buf.iter_mut() { + // SAFETY: `slot` is a live, aligned, exclusive `T`, and `T: Copy` + // has no destructor the overwrite skips. + unsafe { core::ptr::write_volatile(slot, T::default()) }; + } + compiler_fence(Ordering::SeqCst); +} + +/// Why bytes are no seed. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Refusal { + /// Not [`SEED_LEN`] bytes, but this many. + Length(usize), + /// The four 8-byte words are one value. + Constant, +} + +impl fmt::Display for Refusal { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Refusal::Length(got) => write!(f, "{got} bytes where a seed is {SEED_LEN}"), + Refusal::Constant => { + f.write_str("its four words are one value, which is what a source that failed leaves") + } + } + } +} + +/// 32 bytes [`Seed::judge`] did not refuse. Neither printed nor copied, and +/// wiped when it goes. +pub struct Seed([u8; SEED_LEN]); + +impl Seed { + pub fn judge(bytes: &[u8]) -> Result { + let Ok(bytes) = <&[u8; SEED_LEN]>::try_from(bytes) else { + return Err(Refusal::Length(bytes.len())); + }; + let (words, _) = bytes.as_chunks::<8>(); + if words.iter().all(|word| *word == words[0]) { + return Err(Refusal::Constant); + } + Ok(Seed(*bytes)) + } +} + +impl Drop for Seed { + fn drop(&mut self) { + wipe(&mut self.0); + } +} + +/// The key before the first seed, so that the first [`Generator::mix`] is the +/// same step as every later one. +const UNKEYED: [u8; SEED_LEN] = *b"ToyOS kernel random generator v1"; + +/// The nonce of a block that replaces the key by a mix, and of one that +/// replaces it by a draw: no block of one is a block of the other. +const MIX: [u8; 12] = *b"toyos-mix\0\0\0"; +const DRAW: [u8; 12] = *b"toyos-draw\0\0"; + +/// The key every draw descends from. +pub struct Generator { + key: [u8; SEED_LEN], +} + +impl Generator { + pub fn keyed(seed: Seed) -> Generator { + let mut generator = Generator { key: UNKEYED }; + generator.mix(seed); + generator + } + + pub fn mix(&mut self, seed: Seed) { + for (key, seed) in self.key.iter_mut().zip(&seed.0) { + *key ^= seed; + } + let mut block = [0u8; 64]; + chacha::block(&self.key, 0, &MIX, &mut block); + self.key.copy_from_slice(&block[..SEED_LEN]); + wipe(&mut block); + } + + /// One draw's bytes. The key this was made under is replaced before it + /// returns. + pub fn stream(&mut self) -> Stream { + let mut block = [0u8; 64]; + chacha::block(&self.key, 0, &DRAW, &mut block); + self.key.copy_from_slice(&block[..SEED_LEN]); + let mut stream = Stream { key: [0; SEED_LEN], next: 0 }; + stream.key.copy_from_slice(&block[SEED_LEN..]); + wipe(&mut block); + stream + } +} + +impl Drop for Generator { + fn drop(&mut self) { + wipe(&mut self.key); + } +} + +/// One draw: the ChaCha20 keystream of a key of its own, under a 64-bit block +/// counter no draw can exhaust. +pub struct Stream { + key: [u8; SEED_LEN], + next: u64, +} + +impl Stream { + /// The stream's next bytes. Each call begins a block, and what `out` leaves + /// of its last one is discarded. + pub fn fill(&mut self, out: &mut [u8]) { + for chunk in out.chunks_mut(64) { + let mut nonce = [0u8; 12]; + nonce[..4].copy_from_slice(&((self.next >> 32) as u32).to_le_bytes()); + let mut block = [0u8; 64]; + chacha::block(&self.key, self.next as u32, &nonce, &mut block); + self.next += 1; + chunk.copy_from_slice(&block[..chunk.len()]); + wipe(&mut block); + } + } +} + +impl Drop for Stream { + fn drop(&mut self) { + wipe(&mut self.key); + } +} diff --git a/toyos-random/src/tests.rs b/toyos-random/src/tests.rs new file mode 100644 index 00000000000..9b45827eda0 --- /dev/null +++ b/toyos-random/src/tests.rs @@ -0,0 +1,235 @@ +extern crate std; + +use std::vec::Vec; + +use super::*; + +/// The bytes of a hex dump as RFC 8439 prints one, without its offsets. +fn hex(dump: &str) -> Vec { + dump.split_whitespace().map(|byte| u8::from_str_radix(byte, 16).expect("a hex byte")).collect() +} + +fn block_of(key: &[u8], counter: u32, nonce: &[u8]) -> [u8; 64] { + let mut out = [0u8; 64]; + chacha::block(key.try_into().expect("a 32-byte key"), counter, nonce.try_into().expect("a 12-byte nonce"), &mut out); + out +} + +/// RFC 8439 §2.1.1. +#[test] +fn the_quarter_round_is_rfc_8439s() { + let mut state = [0u32; 16]; + state[..4].copy_from_slice(&[0x1111_1111, 0x0102_0304, 0x9b8d_6f43, 0x0123_4567]); + chacha::quarter_round(&mut state, 0, 1, 2, 3); + assert_eq!(state[..4], [0xea2a_92f4, 0xcb1c_f8ce, 0x4581_472e, 0x5881_c4bb]); +} + +/// RFC 8439 §2.2.1. +#[test] +fn a_quarter_round_moves_its_four_words_of_the_state_and_no_other() { + let mut state: [u32; 16] = [ + 0x8795_31e0, 0xc5ec_f37d, 0x5164_61b1, 0xc9a6_2f8a, + 0x44c2_0ef3, 0x3390_af7f, 0xd9fc_690b, 0x2a5f_714c, + 0x5337_2767, 0xb00a_5631, 0x974c_541a, 0x359e_9963, + 0x5c97_1061, 0x3d63_1689, 0x2098_d9d6, 0x91db_d320, + ]; + chacha::quarter_round(&mut state, 2, 7, 8, 13); + assert_eq!( + state, + [ + 0x8795_31e0, 0xc5ec_f37d, 0xbdb8_86dc, 0xc9a6_2f8a, + 0x44c2_0ef3, 0x3390_af7f, 0xd9fc_690b, 0xcfac_afd2, + 0xe46b_ea80, 0xb00a_5631, 0x974c_541a, 0x359e_9963, + 0x5c97_1061, 0xccc0_7c79, 0x2098_d9d6, 0x91db_d320, + ] + ); +} + +/// RFC 8439 §2.3.2. +#[test] +fn the_block_function_is_rfc_8439s() { + let key: Vec = (0u8..32).collect(); + let block = block_of(&key, 1, &hex("00 00 00 09 00 00 00 4a 00 00 00 00")); + assert_eq!( + block[..], + hex("10 f1 e7 e4 d1 3b 59 15 50 0f dd 1f a3 20 71 c4 + c7 d1 f4 c7 33 c0 68 03 04 22 aa 9a c3 d4 6c 4e + d2 82 64 46 07 9f aa 09 14 c2 d7 05 d9 8b 02 a2 + b5 12 9c d1 de 16 4e b9 cb d0 83 e8 a2 50 3c 4e")[..] + ); +} + +/// RFC 8439 appendix A.1, its five vectors: a key, a block counter, a nonce +/// and the keystream block they give. +#[test] +fn the_block_function_gives_appendix_a_1s_keystreams() { + let zeros = [0u8; 32]; + let mut last_one = [0u8; 32]; + last_one[31] = 1; + let mut second_ff = [0u8; 32]; + second_ff[1] = 0xff; + let zero_nonce = [0u8; 12]; + let mut nonce_two = [0u8; 12]; + nonce_two[11] = 2; + let vectors: [(&[u8; 32], u32, &[u8; 12], &str); 5] = [ + ( + &zeros, + 0, + &zero_nonce, + "76 b8 e0 ad a0 f1 3d 90 40 5d 6a e5 53 86 bd 28 + bd d2 19 b8 a0 8d ed 1a a8 36 ef cc 8b 77 0d c7 + da 41 59 7c 51 57 48 8d 77 24 e0 3f b8 d8 4a 37 + 6a 43 b8 f4 15 18 a1 1c c3 87 b6 69 b2 ee 65 86", + ), + ( + &zeros, + 1, + &zero_nonce, + "9f 07 e7 be 55 51 38 7a 98 ba 97 7c 73 2d 08 0d + cb 0f 29 a0 48 e3 65 69 12 c6 53 3e 32 ee 7a ed + 29 b7 21 76 9c e6 4e 43 d5 71 33 b0 74 d8 39 d5 + 31 ed 1f 28 51 0a fb 45 ac e1 0a 1f 4b 79 4d 6f", + ), + ( + &last_one, + 1, + &zero_nonce, + "3a eb 52 24 ec f8 49 92 9b 9d 82 8d b1 ce d4 dd + 83 20 25 e8 01 8b 81 60 b8 22 84 f3 c9 49 aa 5a + 8e ca 00 bb b4 a7 3b da d1 92 b5 c4 2f 73 f2 fd + 4e 27 36 44 c8 b3 61 25 a6 4a dd eb 00 6c 13 a0", + ), + ( + &second_ff, + 2, + &zero_nonce, + "72 d5 4d fb f1 2e c4 4b 36 26 92 df 94 13 7f 32 + 8f ea 8d a7 39 90 26 5e c1 bb be a1 ae 9a f0 ca + 13 b2 5a a2 6c b4 a6 48 cb 9b 9d 1b e6 5b 2c 09 + 24 a6 6c 54 d5 45 ec 1b 73 74 f4 87 2e 99 f0 96", + ), + ( + &zeros, + 0, + &nonce_two, + "c2 c6 4d 37 8c d5 36 37 4a e2 04 b9 ef 93 3f cd + 1a 8b 22 88 b3 df a4 96 72 ab 76 5b 54 ee 27 c7 + 8a 97 0e 0e 95 5c 14 f3 a8 8e 74 1b 97 c2 86 f7 + 5f 8f c2 99 e8 14 83 62 fa 19 8a 39 53 1b ed 6d", + ), + ]; + for (at, (key, counter, nonce, keystream)) in vectors.into_iter().enumerate() { + assert_eq!(block_of(key, counter, nonce)[..], hex(keystream)[..], "test vector #{}", at + 1); + } +} + +/// Bytes [`Seed::judge`] takes: no two of its words alike. +fn seed_bytes(tag: u8) -> [u8; SEED_LEN] { + core::array::from_fn(|at| tag ^ (at as u8).wrapping_mul(37)) +} + +fn seed(tag: u8) -> Seed { + Seed::judge(&seed_bytes(tag)).expect("a seed") +} + +fn drawn(generator: &mut Generator, len: usize) -> Vec { + let mut out = std::vec![0u8; len]; + generator.stream().fill(&mut out); + out +} + +#[test] +fn bytes_a_failed_source_leaves_are_no_seed() { + assert_eq!(Seed::judge(&[0u8; 32]).err(), Some(Refusal::Constant)); + assert_eq!(Seed::judge(&[0xffu8; 32]).err(), Some(Refusal::Constant)); + // A source stuck on one draw: four words, one value. + let stuck: Vec = 0x0123_4567_89ab_cdefu64.to_ne_bytes().repeat(4); + assert_eq!(Seed::judge(&stuck).err(), Some(Refusal::Constant)); + // One word of the four its own is a seed. + let mut three_alike = [0u8; 32]; + three_alike[31] = 1; + assert!(Seed::judge(&three_alike).is_ok()); + assert!(Seed::judge(&seed_bytes(0)).is_ok()); +} + +#[test] +fn bytes_of_another_length_are_no_seed() { + let bytes = seed_bytes(0); + assert_eq!(Seed::judge(&[]).err(), Some(Refusal::Length(0))); + assert_eq!(Seed::judge(&bytes[..31]).err(), Some(Refusal::Length(31))); + let mut long = bytes.to_vec(); + long.push(7); + assert_eq!(Seed::judge(&long).err(), Some(Refusal::Length(33))); +} + +/// The construction, block by block: the key is the mix block of the unkeyed +/// constant XOR the seed; a draw's block under it gives the next key and the +/// stream's; the stream is ChaCha20 under its own key from block 0. +#[test] +fn a_draw_is_the_blocks_the_module_states() { + let bytes = seed_bytes(0x5a); + let mixed: Vec = UNKEYED.iter().zip(bytes).map(|(k, s)| k ^ s).collect(); + let key = block_of(&mixed, 0, &MIX); + let draw = block_of(&key[..32], 0, &DRAW); + let (next_key, stream_key) = draw.split_at(32); + let mut want = block_of(stream_key, 0, &[0; 12]).to_vec(); + want.extend_from_slice(&block_of(stream_key, 1, &[0; 12])[..36]); + + let mut generator = Generator::keyed(seed(0x5a)); + assert_eq!(drawn(&mut generator, 100), want); + assert_eq!(generator.key[..], *next_key); +} + +#[test] +fn the_key_a_draw_was_made_under_is_gone_when_it_returns() { + let mut generator = Generator::keyed(seed(1)); + let before = generator.key; + let first = drawn(&mut generator, 64); + assert_ne!(generator.key, before); + // The key left behind is not the stream's, and gives another draw. + let second = drawn(&mut generator, 64); + assert_ne!(first, second); + let mut replayed = Generator { key: before }; + assert_eq!(drawn(&mut replayed, 64), first); +} + +#[test] +fn every_seed_mixed_moves_the_draw_and_none_replaces_another() { + let draw_of = |tags: &[u8]| { + let mut generator = Generator::keyed(seed(tags[0])); + for &tag in &tags[1..] { + generator.mix(seed(tag)); + } + drawn(&mut generator, 32) + }; + let both = draw_of(&[1, 2]); + assert_ne!(both, draw_of(&[1])); + assert_ne!(both, draw_of(&[2])); + // The later seed did not replace the earlier: the earlier still decides. + assert_ne!(both, draw_of(&[3, 2])); + assert_ne!(both, draw_of(&[1, 3])); +} + +/// The block counter is 64 bits: the block after `u32::MAX` is counter 0 under +/// the next high half, not block 0 again. +#[test] +fn a_stream_does_not_repeat_where_32_bits_of_counter_end() { + let mut generator = Generator::keyed(seed(9)); + let mut stream = generator.stream(); + let key = stream.key; + stream.next = u64::from(u32::MAX); + let mut out = [0u8; 128]; + stream.fill(&mut out); + assert_eq!(out[..64], block_of(&key, u32::MAX, &[0; 12])); + let mut high = [0u8; 12]; + high[0] = 1; + assert_eq!(out[64..], block_of(&key, 0, &high)); + assert_ne!(out[64..], block_of(&key, 0, &[0; 12])); +} + +#[test] +fn a_wipe_leaves_zeros() { + let mut bytes = seed_bytes(3); + wipe(&mut bytes); + assert_eq!(bytes, [0; SEED_LEN]); +} From 7c05a8dd4724399ca60a7ce26d31b7e9b24ab8bf Mon Sep 17 00:00:00 2001 From: japabu Date: Fri, 9 Oct 2026 13:57:04 +0200 Subject: [PATCH 2/3] Three tests that wait for the boot's last word stay emulated: under HVF it can miss the console virt_el1_smp, virt_mask_windows and virt_off_names_the_cpus_left_on were red under HVF six times in this stage's runs, each stalled waiting for "Shutting down.". A capture of one shows QEMU traced seven CPU_OFF and one SYSTEM_OFF from the guest: it powered off with its console's last line the supervisor's stop request and klogd about 100 ms behind. quiesce's drain after the last word is a try-lock on the wire that declines while klogd holds it, and the power-off does not wait. That is the console's and the stop's, outside this stage's fence, and is filed with the evidence. The three keep their emulated profiles, and Profile::VirtTcg stays for virt_el1_smp. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RvnWQFcMuGqTHYhvSnTe8A --- ...608-hangs-at-exitbootservices-under-hvf.md | 8 +-- ...s-the-console-when-klogd-holds-the-wire.md | 70 +++++++++++++++++++ issues/toyos-runs-on-arm64.md | 14 ++-- tests/common/qemu.rs | 11 ++- tests/toyos.rs | 12 ++-- 5 files changed, 99 insertions(+), 16 deletions(-) create mode 100644 issues/the-boots-last-word-can-miss-the-console-when-klogd-holds-the-wire.md diff --git a/issues/edk2-stable202502-to-202608-hangs-at-exitbootservices-under-hvf.md b/issues/edk2-stable202502-to-202608-hangs-at-exitbootservices-under-hvf.md index dccad33ac91..126040ddb10 100644 --- a/issues/edk2-stable202502-to-202608-hangs-at-exitbootservices-under-hvf.md +++ b/issues/edk2-stable202502-to-202608-hangs-at-exitbootservices-under-hvf.md @@ -10,10 +10,10 @@ When a host's QEMU installation ships an edk2 from stable202502 to stable202608, `virt` guests under HVF never return from `ExitBootServices`. Owner: the orchestrator. -Every `virt_` test but the three that need EL2 (`virt_el2_drop`, `virt_smp` -and `virt_jobs_at_el2`) boots under HVF on an Apple host, so a QEMU upgrade -there that bundles an edk2 from this range reds all of them, where it once -would have red two. +Every `virt_` test whose profile is `Profile::Virt` or `Profile::VirtNoRng` +(`tests/toyos.rs`), which is most of them, boots under HVF on an Apple host, +so a QEMU upgrade there that bundles an edk2 from this range reds all of +those, where it once would have red two. Under QEMU 11.1.1's HVF a `virt` guest reads `ID_AA64PFR0_EL1.GIC` as 0, though its GICv3's system registers answer. `hvf_arch_init_vcpu` diff --git a/issues/the-boots-last-word-can-miss-the-console-when-klogd-holds-the-wire.md b/issues/the-boots-last-word-can-miss-the-console-when-klogd-holds-the-wire.md new file mode 100644 index 00000000000..88024642513 --- /dev/null +++ b/issues/the-boots-last-word-can-miss-the-console-when-klogd-holds-the-wire.md @@ -0,0 +1,70 @@ +--- +status: open +kind: defect +opened: 2026-10-09 +--- + +# The boot's last word can miss the console: the stop's drain declines while `klogd` holds the wire + +A boot that was asked to stop can power off without `Shutting down.` on its +console. Seen on 8-CPU `virt` guests under HVF, six times, each a test red as +`STALLED: waiting for the boot's last word — it went quiet`: the guest was not +quiet but gone. + +**What the code does, by reading.** `quiesce` (`kernel/src/syscall/machine.rs`) +logs the last word and calls `log::console::drain_inline`, which takes the +wire with `serial::try_wire` and returns at once where it is held: "whoever +holds the wire drains it too". The holder is `klogd`, on another CPU, part-way +through its backlog. Nothing waits for it. The caller goes on to the power-off +or the reset, which turns `klogd`'s CPU off before it reaches the record. The +stop's record and the census, logged above the last word, go the same way. + +**What was measured.** The entropy stage's branch at `7522ec61e`, an +Apple-silicon host of 14 cores at 1-minute load 28 to 46, QEMU 11.1.1, +`tests/virtsmpcase` on eight CPUs under `Profile::Virt` (HVF), whose first job +`unmap_touch` has the kernel report eight faults, each at length: + +- `virt_el1_smp`, `virt_mask_windows` and `virt_off_names_the_cpus_left_on` + each wait for the last word. Side by side with `virt_smp` and + `virt_failed_ap_leaves_no_hole`, 131 runs: five reds, two of `virt_el1_smp`, + two of `virt_mask_windows`, one of `virt_off_names_the_cpus_left_on`. A + sixth, `virt_el1_smp`, in the whole suite at load 41 to 47. +- The same five tests with `Profile::Virt` emulated at EL1 (`-cpu max`), the + same session: 50 runs, no red. Five in 393 guests against none in 150 does + not separate the two by itself (Fisher's exact test, p = 0.33): the place + the reds stop in does. +- One red `virt_el1_smp`, captured: QEMU's `arm_psci_call` trace holds seven + `CPU_OFF` and one `SYSTEM_OFF`, so the kernel powered the machine off. Its + console's last line is the supervisor's `power: the machine stops, and + logkeeper makes the log whole first (Shutdown)`, stamped 2.361, in among + the kernel's fault-report records stamped 2.264: `klogd` was about 100 ms + of guest clock behind when the stop began. No stop record, no census, no + `Shutting down.`. +- The red `virt_off_names_the_cpus_left_on` said, after the wait began, + `power: cpu6 is not off by PSCI's answer inside the budget; SYSTEM_OFF + regardless` and the same of cpu7, which `arch::power::off` writes straight + to the UART after `quiesce` has returned, and no `Shutting down.`. + +The probe that captured it is a patch on the entropy stage's pull request. + +**Not known.** Whether the wire's holder was `klogd`: no capture names it. +Whether an x86-64 guest with a 16550 loses its last word the same way. +Whether the two stalls of `virt_mask_windows` that +`issues/a-counters-read-under-host-load-can-go-silent-for-15-s.md` records +with no console, each `waiting for the boot's last word` under TCG, are this. +`virt_reboot` waits for `Rebooting.` through the same `quiesce` under HVF, on +a case that reports no fault before it, and has not been seen red. + +**Until it is fixed** those three tests stay emulated (`tests/toyos.rs`), and +`Profile::VirtTcg` stays for `virt_el1_smp`. + +**Exit**: the stop puts its last word on the wire before it takes a CPU down, +waiting for the wire's holder as `drain_for_the_stop` does, or the cause is +shown to be another from a capture. It is shown by a test that reds without +the fix on every boot, the wire held across the stop by an actuator, and by +the three tests under `Profile::Virt` on an Apple host: 300 side-by-side +runs at load 30 or more with no red. They then move to `Profile::Virt` and +`Profile::VirtTcg` is deleted. + +Owner: the kernel's AArch64 bring-up, `issues/toyos-runs-on-arm64.md`, held +by the orchestrator's next kernel worker. diff --git a/issues/toyos-runs-on-arm64.md b/issues/toyos-runs-on-arm64.md index bb69a6b2b83..05c235d4263 100644 --- a/issues/toyos-runs-on-arm64.md +++ b/issues/toyos-runs-on-arm64.md @@ -271,8 +271,10 @@ Each stage names its exit; "measured" means a number from a run. seed the loader reads from firmware's `EFI_RNG_PROTOCOL`, which edk2 answers from a virtio-rng (`kernel/src/random.rs`); `virt_el2_drop`, `virt_smp` and `virt_jobs_at_el2`, one boot of the job case with the timer - and FP jobs in it, stay emulated at EL2, which HVF gives no guest. Each - judges an event, never a rate: no QEMU test measures time. + and FP jobs in it, stay emulated at EL2, which HVF gives no guest, and + three more stay emulated until + `issues/the-boots-last-word-can-miss-the-console-when-klogd-holds-the-wire.md` + is fixed. Each judges an event, never a rate: no QEMU test measures time. Owed before the exit holds: the interrupts-off window against x86's, a measurement only metal can make, with no instrument on either arch yet; the instruction-cache maintenance before a mapping is executable @@ -315,9 +317,11 @@ Each stage names its exit; "measured" means a number from a run. `owed!` on a machine of more than one CPU, and which nothing but the `dump-deaf-cpu` actuator asks for until AArch64 has a keyboard; and, for the clean of an AP's start block to the point of coherency, which TCG - cannot fail on: `virt_el1_smp`, `virt_mask_windows` and - `virt_off_names_the_cpus_left_on` start eight CPUs through PSCI under HVF - and are green there, and nothing shows the clean deleted red. + cannot fail on: `virt_el1_smp` under HVF started eight CPUs through PSCI + in each of 131 boots of the entropy stage's measurement, all eight online + and scheduling every time, and `virt_failed_ap_leaves_no_hole` and + `virt_fatal_halts_the_others_first` start theirs under HVF in the suite; + nothing shows the clean deleted red. 6. **Virtio on `virt`.** virtio-pci (ECAM from MCFG) for blk, net, gpu, sound, input and rng. virtio-input replaces the i8042 as the diff --git a/tests/common/qemu.rs b/tests/common/qemu.rs index fa0694c81ad..a4273e5353d 100644 --- a/tests/common/qemu.rs +++ b/tests/common/qemu.rs @@ -713,13 +713,18 @@ pub enum Profile { /// register name is EL1's own, so the loader's EL2 arm alone turns EL2's /// MMU off. VirtEl2NoVhe, + /// [`Profile::Virt`] emulated on `-cpu max` whatever the host: firmware + /// hands the loader the CPU at EL1 and QEMU's FADT names PSCI's conduit + /// `HVC`, as under HVF. `virt_el1_smp`'s machine while a boot's last word + /// can miss the console under HVF. + VirtTcg, } impl Profile { /// The architecture this machine is. pub fn arch(self) -> Arch { match self { - Self::Virt | Self::VirtNoRng | Self::VirtEl2 | Self::VirtEl2NoVhe => Arch::Aarch64, + Self::Virt | Self::VirtNoRng | Self::VirtEl2 | Self::VirtEl2NoVhe | Self::VirtTcg => Arch::Aarch64, Self::Headless | Self::HeadlessNoIommu | Self::Metal => Arch::X86_64, @@ -729,7 +734,7 @@ impl Profile { /// How this host provides the machine. pub fn accel(self) -> Accel { match self { - Self::VirtEl2 | Self::VirtEl2NoVhe => Accel::Tcg, + Self::VirtEl2 | Self::VirtEl2NoVhe | Self::VirtTcg => Accel::Tcg, _ => self.arch().accel(), } } @@ -866,7 +871,7 @@ pub const NVME_SMALL: u64 = 128 * 1024 * 1024; impl Profile { fn shape(self) -> Shape { match self { - Self::VirtEl2 | Self::VirtEl2NoVhe => Self::Virt.shape(), + Self::VirtEl2 | Self::VirtEl2NoVhe | Self::VirtTcg => Self::Virt.shape(), Self::VirtNoRng => Shape { rng: false, ..Self::Virt.shape() }, Self::Virt => Shape { vga: "std", diff --git a/tests/toyos.rs b/tests/toyos.rs index 0a19a9dc987..1b65eee2f3c 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -247,13 +247,17 @@ const SCREEN_TESTS: &[(&str, qemu::Profile)] = &[ ("virt_debug_refused", qemu::Profile::Virt), ("virt_readonly_copyout", qemu::Profile::Virt), ("virt_ring0_timer_in_syscall", qemu::Profile::Virt), - ("virt_mask_windows", qemu::Profile::Virt), + // Emulated, with `virt_el1_smp` and `virt_off_names_the_cpus_left_on`: each + // waits for the boot's last word behind `unmap_touch`'s fault reports, and + // under HVF the power-off can come before `klogd` has put it on the wire + // (issues/the-boots-last-word-can-miss-the-console-when-klogd-holds-the-wire.md). + ("virt_mask_windows", qemu::Profile::VirtEl2), ("virt_smp", qemu::Profile::VirtEl2), - ("virt_el1_smp", qemu::Profile::Virt), + ("virt_el1_smp", qemu::Profile::VirtTcg), ("virt_failed_ap_leaves_no_hole", qemu::Profile::Virt), ("virt_fatal_halts_the_others_first", qemu::Profile::Virt), ("virt_reboot", qemu::Profile::Virt), - ("virt_off_names_the_cpus_left_on", qemu::Profile::Virt), + ("virt_off_names_the_cpus_left_on", qemu::Profile::VirtEl2), ("virt_reboot_refused_without_psci", qemu::Profile::Virt), // The job case entered at EL2, once: the entry's EL2 writes for the timer // and FP, which no boot under HVF runs. @@ -2745,7 +2749,7 @@ fn run_screen_test(name: &str, profile: qemu::Profile, test_config: &Path) -> Re "virt_timer_floor" => virt_selftest(profile, test_config, &["timer-floor"]), "virt_smp" => virt_smp(profile, "SMC", 2), // The EL1 entry's own arm, which fetches at a physical address under - // the bring-up root, and PSCI through `HVC`. + // the bring-up root, and PSCI through `HVC`: the path HVF takes. "virt_el1_smp" => virt_smp(profile, "HVC", 1), "virt_failed_ap_leaves_no_hole" => virt_failed_ap_leaves_no_hole(profile), "virt_fatal_halts_the_others_first" => virt_fatal_halts_the_others_first(profile), From eb9b2bd25a3700e447c4fc107aa49f64e022f79c Mon Sep 17 00:00:00 2001 From: japabu Date: Fri, 9 Oct 2026 14:57:09 +0200 Subject: [PATCH 3/3] The seed's take is toyos-random's and tested; RDRAND keyed is asserted on x86-64; virt_reboot stays emulated at EL2 Round 1's review of the entropy stage, finding by finding: - `Seed::take` judges a handed seed and zeroes the bytes and length it was handed in on every arm: accepted, refused for its bytes, refused for its length, and none handed. The kernel takes both copies through it, the loader's own arguments and its copy of them. A host test holds the zeros on each arm, so deleting the wipe is now red. - `iommu_virtio_platform`'s netcase boot, a Headless machine whose CPU is `qemu64,+rdrand` under TCG and the host's under KVM, says `random: RDRAND is mixed into the generator's key`. On this host firmware answers EFI_RNG_PROTOCOL, so its seed alone would key the generator and no x86-64 test saw a CPU source go in. - `virt_reboot` goes back to `Profile::VirtEl2`: it ends through the same stop whose last word HVF lost on three sibling tests, it was moved unmeasured, and it is the one test of SYSTEM_RESET through the SMC conduit. The last-word issue names it in its list, and its exit covers it. - With no PSCI-traced test left under HVF, the trace's assertion is TCG's again, as on main; the last-word issue's exit says to widen it when the four move. - `KernelArgs` derives no `Debug`: a `{:?}` of it would print the seed. - The last-word issue is told apart from the counters-read silences, names what the next capture has to show, and cites the probe's comment. The track's stage 4 accepts the EL2 entries the move to HVF gave up and names the test that closes each owed cache and TLB step; stage 5's AP clean gets the same. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017cSFvbD35xJ2kGANVdm23C --- ...s-the-console-when-klogd-holds-the-wire.md | 36 +++++++++++++------ issues/toyos-runs-on-arm64.md | 31 +++++++++++----- kernel/src/random.rs | 13 +++---- tests/common/iommu.rs | 3 ++ tests/common/qemu.rs | 4 +-- tests/toyos.rs | 4 ++- toyos-abi/src/boot.rs | 3 +- toyos-random/src/lib.rs | 15 ++++++++ toyos-random/src/tests.rs | 25 +++++++++++++ 9 files changed, 102 insertions(+), 32 deletions(-) diff --git a/issues/the-boots-last-word-can-miss-the-console-when-klogd-holds-the-wire.md b/issues/the-boots-last-word-can-miss-the-console-when-klogd-holds-the-wire.md index 88024642513..d5f35cb8adb 100644 --- a/issues/the-boots-last-word-can-miss-the-console-when-klogd-holds-the-wire.md +++ b/issues/the-boots-last-word-can-miss-the-console-when-klogd-holds-the-wire.md @@ -45,26 +45,40 @@ Apple-silicon host of 14 cores at 1-minute load 28 to 46, QEMU 11.1.1, regardless` and the same of cpu7, which `arch::power::off` writes straight to the UART after `quiesce` has returned, and no `Shutting down.`. -The probe that captured it is a patch on the entropy stage's pull request. +The probe that captured it is `debug-shutdown-regs.patch` in +https://github.com/ToyOSOrg/ToyOS/pull/802#issuecomment-6080912601. + +**Not the counters-read silences.** The third and fourth silent guests of +`issues/a-counters-read-under-host-load-can-go-silent-for-15-s.md` stop at +another step: each console ends at the kernel's `spawn:` record of +`test_rs_counters_read`, with no `===TEST_END test_rs_counters_read` and no +supervisor stop line. In this defect's capture every userland line through +`power: the machine stops …` reached the wire, as `drain_for_the_stop` waits +for the wire and drains the queue; only the kernel's own records after it +went missing. The two `virt_mask_windows` stalls that issue records with no +console, and no PSCI trace, are not decidable either way. What tells the +next capture's silence apart: whether `===TEST_END test_rs_counters_read` +and the supervisor's stop line are on its console, and QEMU's PSCI trace of +it, a powered-off guest's `CPU_OFF` and `SYSTEM_OFF` being this defect's. **Not known.** Whether the wire's holder was `klogd`: no capture names it. Whether an x86-64 guest with a 16550 loses its last word the same way. -Whether the two stalls of `virt_mask_windows` that -`issues/a-counters-read-under-host-load-can-go-silent-for-15-s.md` records -with no console, each `waiting for the boot's last word` under TCG, are this. -`virt_reboot` waits for `Rebooting.` through the same `quiesce` under HVF, on -a case that reports no fault before it, and has not been seen red. +Whether `virt_reboot`, which waits for `Rebooting.` through the same +`quiesce`, loses it under HVF: it was moved there and back unmeasured. -**Until it is fixed** those three tests stay emulated (`tests/toyos.rs`), and -`Profile::VirtTcg` stays for `virt_el1_smp`. +**Until it is fixed** these stay emulated (`tests/toyos.rs`): the three +tests above, and `virt_reboot`; `Profile::VirtTcg` stays for +`virt_el1_smp`. **Exit**: the stop puts its last word on the wire before it takes a CPU down, waiting for the wire's holder as `drain_for_the_stop` does, or the cause is shown to be another from a capture. It is shown by a test that reds without the fix on every boot, the wire held across the stop by an actuator, and by -the three tests under `Profile::Virt` on an Apple host: 300 side-by-side -runs at load 30 or more with no red. They then move to `Profile::Virt` and -`Profile::VirtTcg` is deleted. +the four tests under `Profile::Virt` on an Apple host: 300 side-by-side +runs at load 30 or more with no red, the harness's PSCI trace allowed under +HVF as the probe allowed it. They then move to `Profile::Virt`, +`virt_reboot` with an EL2 row of its own kept for `SYSTEM_RESET` through +the SMC conduit, and `Profile::VirtTcg` is deleted. Owner: the kernel's AArch64 bring-up, `issues/toyos-runs-on-arm64.md`, held by the orchestrator's next kernel worker. diff --git a/issues/toyos-runs-on-arm64.md b/issues/toyos-runs-on-arm64.md index fad24777ae0..6f98b613235 100644 --- a/issues/toyos-runs-on-arm64.md +++ b/issues/toyos-runs-on-arm64.md @@ -294,22 +294,34 @@ Each stage names its exit; "measured" means a number from a run. answers from a virtio-rng (`kernel/src/random.rs`); `virt_el2_drop`, `virt_smp` and `virt_jobs_at_el2`, one boot of the job case with the timer and FP jobs in it, stay emulated at EL2, which HVF gives no guest, and - three more stay emulated until + `virt_reboot` and three more stay emulated at EL2 until `issues/the-boots-last-word-can-miss-the-console-when-klogd-holds-the-wire.md` is fixed. Each judges an event, never a rate: no QEMU test measures time. + **Accepted with the move to HVF:** `virt_user_mode`, `virt_irq_storm`, + `virt_timer_floor`, `virt_failed_ap_leaves_no_hole` and + `virt_fatal_halts_the_others_first` no longer boot entered at EL2. The + entry's drop from EL2 stays judged by `virt_el2_drop`, `virt_smp` and + `virt_jobs_at_el2`, and PSCI through the SMC conduit by `virt_smp` + (`CPU_ON`, `CPU_OFF`, `SYSTEM_OFF`) and `virt_reboot` (`SYSTEM_RESET`); + what those five judge after the entry is the same kernel at EL1 either way. Owed before the exit holds: the interrupts-off window against x86's, a measurement only metal can make, with no instrument on either arch yet; the instruction-cache maintenance before a mapping is executable (`cache::make_executable`), the break-before-make ordering of a live entry's replacement, and the TLB flush before a reclaimed ASID is issued - again, which QEMU's TCG cannot fail on. Under HVF since the entropy stage: + again, which QEMU's TCG cannot fail on. Under HVF since the entropy stage every program the `virt_` tests run at EL0 is mapped executable through - `cache::make_executable`, and whatever those tests reach of a live entry's - replacement runs there too, with no test red; that is no proof of either, - since a stale instruction or a TLB conflict is not certain to show in one - boot, and which tests replace a live entry is not measured. No test issues - enough address spaces to reclaim an ASID, so that flush has still run on - no oracle that can fail it. And the three deletions shown red. They are shown red on a machine whose + `cache::make_executable`, with no test red, which is no proof: a stale + instruction is not certain to show in one boot. Each of the three closes + on a guest test under HVF that is red with its step deleted, and stays + owed with that test until one is: for `make_executable`, a program that + runs code it wrote over code it ran at the same address, on a host whose + `CTR_EL0.DIC` the test reads and says clear; for break-before-make, two + CPUs, one reading a page whose live entry the other replaces with + another frame's, every read the old frame's value or the new one's; for + the ASID flush, a test kernel whose ASIDs an actuator bounds to two, and + three processes each reading back its own frame at one address. And the + three deletions shown red. They are shown red on a machine whose firmware leaves the registers otherwise, or by a loader that writes the opposite values before the handoff. The ITS moves to stage 6: a claimed function is its only consumer the small-kernel track leaves, and it needs that @@ -343,7 +355,8 @@ Each stage names its exit; "measured" means a number from a run. in each of 131 boots of the entropy stage's measurement, all eight online and scheduling every time, and `virt_failed_ap_leaves_no_hole` and `virt_fatal_halts_the_others_first` start theirs under HVF in the suite; - nothing shows the clean deleted red. + it stays owed until `virt_el1_smp` under HVF is shown red with the clean + deleted. 6. **Virtio on `virt`.** virtio-pci (ECAM from MCFG) for blk, net, gpu, sound, input and rng. virtio-input replaces the i8042 as the diff --git a/kernel/src/random.rs b/kernel/src/random.rs index 41329331bd8..9246c289c1e 100644 --- a/kernel/src/random.rs +++ b/kernel/src/random.rs @@ -59,14 +59,11 @@ pub fn key(loader: &mut KernelArgs, copy: &mut KernelArgs) { Err(why) => log!("random: {name} is not mixed: {why}"), }; - match usize::try_from(copy.loader_seed_len).ok().and_then(|len| copy.loader_seed.get(..len)) { - Some([]) => log!("random: {LOADER} is not mixed: the loader handed none"), - Some(bytes) => mix(LOADER, Seed::judge(bytes)), - None => mix(LOADER, Err(Refusal::Length(copy.loader_seed_len as usize))), - } - for args in [loader, copy] { - wipe(&mut args.loader_seed); - args.loader_seed_len = 0; + // The loader's own arguments hold the same bytes: taken only to zero them. + drop(Seed::take(&mut loader.loader_seed, &mut loader.loader_seed_len)); + match Seed::take(&mut copy.loader_seed, &mut copy.loader_seed_len) { + None => log!("random: {LOADER} is not mixed: the loader handed none"), + Some(seed) => mix(LOADER, seed), } for source in entropy::SOURCES { diff --git a/tests/common/iommu.rs b/tests/common/iommu.rs index f2bac2cebb8..a82220cb98b 100644 --- a/tests/common/iommu.rs +++ b/tests/common/iommu.rs @@ -87,6 +87,9 @@ pub fn iommu_virtio_platform(test_config: &Path) -> Result<(), String> { log.must_be_clean()?; log.must_say("Boot: complete")?; log.must_say("supervisor: started netstack")?; + // The CPU's own source in the generator's key, which firmware's seed + // alone would key without it wherever firmware answers EFI_RNG_PROTOCOL. + log.must_say("random: RDRAND is mixed into the generator's key")?; // **Whether the NIC's function is handed to a process at all is the // machine's answer, not a choice.** A process driving a device writes diff --git a/tests/common/qemu.rs b/tests/common/qemu.rs index 24d46506e23..758ee8281a2 100644 --- a/tests/common/qemu.rs +++ b/tests/common/qemu.rs @@ -2232,8 +2232,8 @@ fn qemu_command( } if let Some(trace) = &options.psci_trace { assert!( - arch == Arch::Aarch64 && accel != Accel::Kvm, - "a PSCI trace is QEMU's `arm_psci_call`, and under KVM the host's kernel answers PSCI" + arch == Arch::Aarch64 && accel == Accel::Tcg, + "a PSCI trace is TCG's `arm_psci_call`, and this profile's PSCI is not QEMU's TCG" ); qemu.arg("-trace").arg("arm_psci_call").arg("-D").arg(trace); } diff --git a/tests/toyos.rs b/tests/toyos.rs index 1206f8af9bb..0f6c2952c7f 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -258,7 +258,9 @@ const SCREEN_TESTS: &[(&str, qemu::Profile)] = &[ ("virt_el1_smp", qemu::Profile::VirtTcg), ("virt_failed_ap_leaves_no_hole", qemu::Profile::Virt), ("virt_fatal_halts_the_others_first", qemu::Profile::Virt), - ("virt_reboot", qemu::Profile::Virt), + // Emulated at EL2: its last word comes through the same stop, and it is + // the one test of `SYSTEM_RESET` through the SMC conduit. + ("virt_reboot", qemu::Profile::VirtEl2), ("virt_off_names_the_cpus_left_on", qemu::Profile::VirtEl2), ("virt_reboot_refused_without_psci", qemu::Profile::Virt), // The job case entered at EL2, once: the entry's EL2 writes for the timer diff --git a/toyos-abi/src/boot.rs b/toyos-abi/src/boot.rs index 8546826518e..de38b31ab78 100644 --- a/toyos-abi/src/boot.rs +++ b/toyos-abi/src/boot.rs @@ -1,5 +1,6 @@ #[repr(C)] -#[derive(Debug, Clone, Copy)] +// No `Debug`: one `{:?}` of these would print the loader's seed. +#[derive(Clone, Copy)] pub struct KernelArgs { pub memory_map_addr: u64, pub memory_map_size: u64, diff --git a/toyos-random/src/lib.rs b/toyos-random/src/lib.rs index c9274f601a4..f9cb2952353 100644 --- a/toyos-random/src/lib.rs +++ b/toyos-random/src/lib.rs @@ -81,6 +81,21 @@ impl Seed { } Ok(Seed(*bytes)) } + + /// The seed another program handed over as `len` of `bytes`, judged, with + /// both zero when this returns whatever the judgment: `None` where `len` is + /// zero, which hands nothing and is no refusal. + pub fn take(bytes: &mut [u8; SEED_LEN], len: &mut u64) -> Option> { + let handed = usize::try_from(*len).unwrap_or(usize::MAX); + let taken = match bytes.get(..handed) { + Some([]) => None, + Some(handed) => Some(Seed::judge(handed)), + None => Some(Err(Refusal::Length(handed))), + }; + wipe(bytes); + wipe(core::slice::from_mut(len)); + taken + } } impl Drop for Seed { diff --git a/toyos-random/src/tests.rs b/toyos-random/src/tests.rs index 9b45827eda0..8e9f70c8241 100644 --- a/toyos-random/src/tests.rs +++ b/toyos-random/src/tests.rs @@ -162,6 +162,31 @@ fn bytes_of_another_length_are_no_seed() { assert_eq!(Seed::judge(&long).err(), Some(Refusal::Length(33))); } +/// The place a seed was handed in holds zeros once it is taken, on every arm: +/// accepted, refused for its bytes, refused for its length, and none handed. +#[test] +fn a_taken_seed_leaves_zeros_where_it_was_handed() { + /// What is handed, its length, and the judgment the take owes it. + type Arm = ([u8; SEED_LEN], u64, Option>); + let judged = |taken: &Option>| taken.as_ref().map(|seed| seed.as_ref().map(|_| ()).map_err(|why| *why)); + let arms: [Arm; 5] = [ + (seed_bytes(4), 32, Some(Ok(()))), + ([0xab; SEED_LEN], 32, Some(Err(Refusal::Constant))), + (seed_bytes(5), 16, Some(Err(Refusal::Length(16)))), + (seed_bytes(6), 33, Some(Err(Refusal::Length(33)))), + (seed_bytes(7), 0, None), + ]; + for (handed, len, want) in arms { + let (mut bytes, mut at) = (handed, len); + let taken = Seed::take(&mut bytes, &mut at); + assert_eq!(judged(&taken), want, "{len} bytes handed"); + assert_eq!((bytes, at), ([0; SEED_LEN], 0), "{len} bytes handed"); + if let Some(Ok(seed)) = taken { + assert_eq!(seed.0, handed, "the seed taken is the bytes handed"); + } + } +} + /// The construction, block by block: the key is the mix block of the unkeyed /// constant XOR the seed; a draw's block under it gives the next key and the /// stream's; the stream is ChaCha20 under its own key from block 0.