diff --git a/Cargo.lock b/Cargo.lock index 13dd732cf84..db8bda18873 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", @@ -6004,6 +6006,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 8bd6a1e6d22..3abd87ecde8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,6 +57,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..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,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 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` (`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-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..d5f35cb8adb --- /dev/null +++ b/issues/the-boots-last-word-can-miss-the-console-when-klogd-holds-the-wire.md @@ -0,0 +1,84 @@ +--- +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 `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 `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** 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 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/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 ced759b4440..6f98b613235 100644 --- a/issues/toyos-runs-on-arm64.md +++ b/issues/toyos-runs-on-arm64.md @@ -76,6 +76,17 @@ Every x86 guest on this host runs under TCG emulation instead — there is no HVF on this Mac?", he answered: "Yes, once ARM userland boots (Recommended)". +## The default architecture + +`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), and +the owner's word of 2026-10-09 makes the default the host's own, which +`src/arch.rs`'s `Arch::HOST` already knows. It cannot be flipped 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.** @@ -277,17 +288,39 @@ 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, and + `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, 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 + 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`, 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 @@ -317,13 +350,21 @@ 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` 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; + 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 - 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 @@ -350,7 +391,8 @@ Each stage names its exit; "measured" means a number from a run. **Exit**: the desktop comes up on virtio-gpu; `calc`, `snake` and `doom`, each started on it, each map a window and present a frame, read by a test that is red when one of them does not; `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. 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..9246c289c1e --- /dev/null +++ b/kernel/src/random.rs @@ -0,0 +1,122 @@ +//! 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}"), + }; + + // 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 { + 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/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 69d22342e12..758ee8281a2 100644 --- a/tests/common/qemu.rs +++ b/tests/common/qemu.rs @@ -698,10 +698,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. @@ -712,9 +718,9 @@ pub enum Profile { /// 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. + /// 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, } @@ -722,7 +728,7 @@ 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 | Self::VirtTcg => Arch::Aarch64, Self::Headless | Self::HeadlessNoIommu | Self::HeadlessNoUsb @@ -742,6 +748,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()), } } @@ -840,6 +847,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, } /// Where a machine's image and its DATA are. A size is stated because a @@ -885,6 +898,7 @@ impl Profile { fn shape(self) -> Shape { match self { Self::VirtEl2 | Self::VirtEl2NoVhe | Self::VirtTcg => Self::Virt.shape(), + Self::VirtNoRng => Shape { rng: false, ..Self::Virt.shape() }, Self::Virt => Shape { vga: "std", panel: None, @@ -894,6 +908,7 @@ impl Profile { usb: &[], storage: Storage::Stick { nvme_bytes: 0 }, iommu: None, + rng: true, }, Self::Headless => Shape { vga: "none", @@ -904,6 +919,7 @@ impl Profile { usb: &["usb-kbd,bus=xhci.0"], storage: Storage::Disk { data_bytes: NVME_SMALL }, iommu: Some(IOMMU_DEFAULT), + rng: false, }, Self::Metal => Shape { vga: "std", @@ -918,6 +934,7 @@ impl Profile { usb: &[], storage: Storage::Stick { nvme_bytes: NVME_SMALL }, iommu: Some(IOMMU_DEFAULT), + rng: false, }, Self::HeadlessNoIommu => Shape { iommu: None, ..Self::Headless.shape() }, Self::HeadlessNoUsb => Shape { xhci: &[], usb: &[], ..Self::Headless.shape() }, @@ -2155,6 +2172,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. 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 b9e778df55c..0f6c2952c7f 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -208,6 +208,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 @@ -235,24 +239,35 @@ 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_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), + // 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::VirtTcg), - ("virt_failed_ap_leaves_no_hole", qemu::Profile::VirtEl2), - ("virt_fatal_halts_the_others_first", qemu::Profile::VirtEl2), + ("virt_failed_ap_leaves_no_hole", qemu::Profile::Virt), + ("virt_fatal_halts_the_others_first", 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::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. + ("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. @@ -1652,14 +1667,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, &[], &[], @@ -1668,10 +1704,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 @@ -1885,6 +1930,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. @@ -2568,26 +2689,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", @@ -2601,23 +2720,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 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..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, @@ -108,8 +109,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 +236,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 +333,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..f9cb2952353 --- /dev/null +++ b/toyos-random/src/lib.rs @@ -0,0 +1,184 @@ +//! 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)) + } + + /// 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 { + 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..8e9f70c8241 --- /dev/null +++ b/toyos-random/src/tests.rs @@ -0,0 +1,260 @@ +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 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. +#[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]); +}