diff --git a/nvx-rs/README.md b/nvx-rs/README.md index 4fbff00ec..0b3dc9dd8 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -118,9 +118,9 @@ on Linux and Windows. | `nvx::guest::cgroup` | `Layout`: the agent's cgroup and the workload cgroup, which the caller names, and which may not hold the agent's. `ExecutionCgroup`: the cgroup of one execution, the workload cgroup itself or a child of it, which it prepares with the caller's resource values, places processes in, checks for occupancy through `pids.current`, kills with `cgroup.kill`, and settles, over the execution's cgroup or the whole workload subtree, as the caller's `Scope` chooses. It holds its cgroups' directories open, and the process's removals and preparations of cgroups take turns, so that it never reaches another cgroup that takes one of their paths. Settling calls the caller's status collector between its checks, and collects no status itself | | `nvx::guest::exec` | `Execution`: drives one program that the caller spawned with the program's ends of its `Pipes`, or that the caller started itself and adopts with its own ends of the program's pipes and a `Child` that names it, without blocking, from the caller's own loop or reactor, and reports each step as an `Event`: the launch report, which an adopted program has at once, the output of each stream, kill failures, and the `Outcome`. It reads a stream only as far as the caller's `grant` allows, so a stalled sink stalls its own execution alone, writes the program's standard input without `SIGPIPE`, and ends the program on a cancellation, a timeout, or a disconnection, with `SIGKILL` through the program's pidfd to the program and, if the caller asks, its process group, while no collector has taken its status, and, through the opened directory of a cgroup that the caller names, to that cgroup. It closes the program's input once the program has ended. It waits for its program alone, by a pidfd, or takes the status from the caller's collector with `observe`; with `Options::caller_reaps`, that collector is the program's only one. NVX's managed agent is one caller | | `nvx::guest::exec::binding` | `Binding`: an `nvx::exec::Runner` bound to an `Execution`, the glue that a guest adapter would otherwise write. The runner's read allowances become grants, its discards discards, the input that it accepted writes and then a close, its terminations a cancellation, a disconnection, or a termination, and its settlement a kill of the execution's cgroup and checks until no process occupies it; without a cgroup, nothing proves that the program's descendants are gone, so the execution is fenced. It feeds the execution's events back to the runner, statuses that a caller who reaps hands over with `observe` included, and never blocks: the caller polls its descriptors and its input writer until its deadline, and each call takes a bounded number of steps, so that no execution holds the caller's loop | -| `nvx::guest::time` | x86_64: `TimeAbi`, the steps of NVX time ABI v1 as typed calls in the caller's process, for any guest init or agent: the cold-boot time step and, separately, the start of the time daemon in a child process that the caller supervises, the VM generation ID, a `CaptureRequest` that repairs a `Restore`, and a `RestoreFinish`. A call takes the permission for OpenVMM's ports on the calling thread for each port transaction alone, and fails with `TimeError::PortRelease` if the thread may still hold some of it, which a process that it started would inherit, and from which `nvx::guest::spawn` starts none. No call waits for a child that it did not start. `nvx-time` runs its steps through them | +| `nvx::guest::time` | x86_64: `TimeAbi`, the steps of NVX time ABI v1 as typed calls in the caller's process, for any guest init or agent: the cold-boot time step and, separately, the start of the time daemon in a child process that the caller supervises, the VM generation ID, a `CaptureRequest` that repairs a `Restore`, and a `RestoreFinish`. The request's mode selects how a restore hands over its fresh entropy: the file mode, which `nvx-time` keeps, writes it to a file, and the in-memory mode returns it as the `Restore`'s owned `Seed`, and writes no file. A call takes the permission for OpenVMM's ports on the calling thread for each port transaction alone, and fails with `TimeError::PortRelease` if the thread may still hold some of it, which a process that it started would inherit, and from which `nvx::guest::spawn` starts none. No call waits for a child that it did not start. `nvx-time` runs its steps through them | | `nvx::guest::clone` | x86_64: the preparation of a template's guest for a capture before its root's assembly, for a guest init or agent, whether or not it is PID 1. `CapturePreparation`, of the caller's `AdmissionFence` and the `Devices` that the template's clones replace, the `distro` and scratch block devices, which their MMIO windows name, and the host filesystem device's virtio-fs tag: `prepare` closes the fence, so that no execution starts, refuses with a `Mounted` the first mount from those devices, a distro partition included, or from an overlay, bind mounts included, in a bounded read of `/proc/self/mountinfo` of at most `MOUNT_TABLE_LIMIT` bytes, then waits for the time ABI's boot checks, runs its capture checks, and suppresses the stall detectors, as `nvx-time pre-capture` does, and returns an owned `CaptureBarrier`. `CaptureBarrier::capture` requests the capture with the platform tier's request, through `TimeAbi::capture`, and returns `Captured::Continued` once it opened the fence and restored the detectors' settings, or, in a clone, `Captured::Restored`, with the fence still closed, since nothing else may run before repair acknowledges the restore, and the detectors suppressed until the time daemon restores their settings: `Restored::into_parts` hands the `Restore` that repair takes next and the closed fence to the caller, which opens it once repair acknowledged the restore. `CaptureBarrier::cancel`, a dropped barrier, and a failure to read the VM generation ID open the fence and restore the detectors' settings; a capture request that fails restores the settings but leaves the fence closed, since it may fail in a clone whose restore it could not repair. Each failure is a `CaptureError`, with its `CaptureStage` and `CaptureCause` | -| `nvx::guest::reseed` | `Seed::read` and `reseed`: a restore's fresh entropy, which starts with its `GenerationId`, and the reseed of the kernel's random number generator with it, in the caller's process. `nvx-reseed` runs them | +| `nvx::guest::reseed` | `Seed`, `Seed::read`, and `reseed`: a restore's fresh entropy, which starts with its `GenerationId`, and the reseed of the kernel's random number generator with it, in the caller's process. A `Seed` keeps its bytes in one allocation, which no move copies, zeroes them with volatile writes when it is dropped, can't be cloned, and formats none of them. `nvx-reseed` runs them | | `nvx::guest::root` | The operations of a layered root, for a guest init or agent, whether or not it is PID 1. `bootstrap`: the agent's and the workload's sibling cgroups of a `Layout`, with every process of their parent moved into the agent's, the agent's `memory.low`, and the workload cgroup prepared through `ExecutionCgroup::prepare`, with its `memory.max` and `pids.max`, and user namespaces kept unavailable. `assemble`: the `Assembly`'s block devices, found by their MMIO windows, checked against their roles' access modes and their expected sizes, and flushed, and the `Root` that it mounts, at a runtime directory, a canonical path that no symbolic link leads through, that it holds exclusively until its teardown, where nothing is mounted already, once it has checked everything: the read-only layers of the `Image` that the caller names, which it never probes: an EROFS image, whose expected `nvx::uuid::Uuid` it checks in its superblock, or a GPT disk whose partition table it reads and checks with `nvx::guest::gpt::read_disk`, against the caller's `nvx::gpt::Expected` values, whose first partition it reads, up to the caller's bound, as opaque bytes that it never mounts, and whose later partitions, at least one, hold ext4 layers that it mounts `ro,nosuid,nodev` with `errors=remount-ro`, so that an error that the kernel finds in a layer fails the operation that found it, rather than panic the guest, as the layer's superblock may ask, from the block devices that the kernel created when it found the disk, once their superblocks show that no journal needs a recovery, which their read-only device can't take, partition 2's at the overlay's base and the last partition's at its top, under an overlay mounted `nosuid,nodev` with the one option set of the caller's `OverlayMode`, `metacopy=on,xino=on` or, for lowers that may carry untrusted overlay xattrs, `metacopy=off,redirect_dir=nofollow`, with no fallback, whose upper and work directories are on the `Upper`: an ext4 scratch mounted `nosuid,nodev`, whose superblock it checks for a journal that the caller's `Journal` policy forbids, or a tmpfs mounted `nosuid,nodev` at the kernel's default size. It refuses overlay options longer than `MOUNT_OPTIONS_LIMIT`, the most that a mount passes, which, below `/run/nvx`, fit up to 195 layers of a GPT disk, and an overlay that the kernel's mount table shows read-only or without the mode's options in effect, since the kernel mounts one so, rather than fail, when its upper can't hold its work directory or its extended attributes. The `Root` reports the first partition's bytes, its `Lower` layers, its `UpperKind`, and its `OverlayMode`. `Root::mount_share` and `Root::mount_aggregate`: virtio-fs live shares at targets in the root that it checks, against the targets that `protocol::share` reserves among others, with one aggregate per root, which it attaches through its `mappings::Mapper` beneath the overlay, with NVX's container root's reserved targets: a share's device mounted out of the root, then moved onto its target, and a copy of each child's entry of the aggregate. `Root::mount_export`: the host export, the aggregate device of `protocol::export`, one per root, mounted `nosuid,nodev` with the access of its `ExportMode` at `hostfs/root` in the runtime directory, out of the root, below a directory that only root may enter, whose root it returns as the `SourceRoot` of the caller's mappings. `Root::map`: a caller's host mappings, through the same mapper. A share, a child, the export, or a mapping that fails latches the root. `Root::teardown`: the shares and mappings in reverse order, then the mounts of an aggregate and of the export, the later first, the overlay, the layers from the top down, and the upper, with every failed unmount reported. Each failure is a typed `Error`. The operations collect no child's status, change neither the caller's root nor its credentials, start no async runtime, and never power the VM off | | `nvx::guest::gpt` | `read_disk`: the partition table of a GPT disk, read and checked with `nvx::gpt`'s `Disk::read` through the disk's block device, which the caller opened, at the logical block size and size that `BLKSSZGET` and `BLKGETSIZE64` report, with the caller's `Expected` values, such as the disk GUID that a boot parameter names. It only reads the device, at explicit offsets, so it never moves the file's position. It neither compares the partitions that the kernel created with the table nor asks the kernel to reread the table. Each failure is a typed `DeviceError` | diff --git a/nvx-rs/src/guest/clone/tests.rs b/nvx-rs/src/guest/clone/tests.rs index 7a880c4a2..52b0ae1a3 100644 --- a/nvx-rs/src/guest/clone/tests.rs +++ b/nvx-rs/src/guest/clone/tests.rs @@ -802,7 +802,10 @@ fn a_restored_clone_gets_the_fence_closed_with_the_detectors_suppressed() { assert_eq!(clone.restore().generation_id(), RESTORED); assert_eq!(clone.entropy_path(), Path::new("/run/nvx/restore-entropy")); let (restore, mut fence) = clone.into_parts(); - assert_eq!(restore, Restore::to(RESTORED)); + assert_eq!( + format!("{restore:?}"), + format!("{:?}", Restore::to(RESTORED)) + ); assert_eq!(calls.take(), Vec::::new()); // A preparation behind another handle of the fence cannot close it, so it opens nothing. let error = prepare(fence.handle(), FakeSystem::new(&calls)).unwrap_err(); diff --git a/nvx-rs/src/guest/reseed.rs b/nvx-rs/src/guest/reseed.rs index 17763b2d3..1f7654fd3 100644 --- a/nvx-rs/src/guest/reseed.rs +++ b/nvx-rs/src/guest/reseed.rs @@ -2,10 +2,17 @@ //! init or agent, and `nvx-reseed`, the program that does it from a seed file. //! //! A restore of a snapshot hands each clone 64 bytes of fresh entropy, which start with the -//! clone's new VM generation ID, and which the time ABI's capture writes to a seed file. -//! [`Seed::read`] reads such a file, and [`reseed`] adds a seed to the kernel's entropy pool -//! with full credit and reseeds its CRNG, so that the clones of one snapshot draw different -//! random numbers. Neither call prints anything or starts a process. +//! clone's new VM generation ID: a [`Seed`]. The time ABI's capture returns it in its in-memory +//! mode, or writes it to a seed file in its file mode, from which [`Seed::read`] reads it. +//! [`reseed`] adds a seed to the kernel's entropy pool with full credit and reseeds its CRNG, so +//! that the clones of one snapshot draw different random numbers. Neither call prints anything +//! or starts a process. +//! +//! A seed keeps its bytes in one allocation, which moving the seed leaves in place, and +//! overwrites them with zeros, with volatile writes, when it is dropped. It can't be cloned, and +//! formats none of its bytes: only its generation ID leaves it, and [`reseed`] hands the rest to +//! the kernel. A caller of the file mode reads and checks the seed, then drops it once it +//! reseeded: //! //! ```no_run //! use nvx::guest::GenerationId; @@ -53,22 +60,43 @@ const USAGE: &[u8] = /// A restore's fresh entropy: 64 bytes that start with the restored VM's new generation ID. /// -/// Its `Debug` form shows only the generation ID. -pub struct Seed([u8; Seed::LENGTH]); +/// The seed keeps its bytes in one heap allocation for its whole life, so that no move of the +/// seed copies them, and overwrites them with zeros, with volatile writes, when it is dropped. +/// It is neither `Clone` nor `Copy`, and formats none of its bytes: its `Debug` form is +/// `Seed { .. }`. Only its generation ID leaves it, through +/// [`generation_id`](Self::generation_id), and [`reseed`] hands the rest to the kernel. +pub struct Seed(Box<[u8; Seed::LENGTH]>); impl Seed { /// The seed's length in bytes. pub const LENGTH: usize = 64; - /// Returns the seed whose bytes are `bytes`. - pub const fn from_bytes(bytes: [u8; Self::LENGTH]) -> Self { - Self(bytes) + /// Returns the seed whose bytes are `bytes`. The call zeroes its own copy of `bytes`; the + /// caller zeroes any copy that it keeps. + pub fn from_bytes(mut bytes: [u8; Self::LENGTH]) -> Self { + let seed = Self::copy_from(&bytes); + sys::zeroize(&mut bytes); + seed + } + + /// Returns the seed whose bytes are a copy of `bytes`, which go straight into its + /// allocation. + pub(crate) fn copy_from(bytes: &[u8; Self::LENGTH]) -> Self { + let mut seed = Self::zeroed(); + seed.0.copy_from_slice(bytes); + seed + } + + /// Returns a seed of zeros, whose allocation a read then fills. + fn zeroed() -> Self { + Self(Box::new([0; Self::LENGTH])) } /// Reads the seed from the file at `path`, which must hold at least [`Seed::LENGTH`] bytes, - /// of which it reads the first. A read that a signal interrupts fails, with an error of kind - /// [`io::ErrorKind::Interrupted`], and a file that is too short fails with an error of kind - /// [`io::ErrorKind::UnexpectedEof`]. + /// of which it reads the first, straight into the seed's allocation. A read that a signal + /// interrupts fails, with an error of kind [`io::ErrorKind::Interrupted`], and a file that + /// is too short fails with an error of kind [`io::ErrorKind::UnexpectedEof`]; either way, the + /// bytes that were read are zeroed. pub fn read(path: impl AsRef) -> Result { let path = path.as_ref(); let Ok(path) = CString::new(path.as_os_str().as_bytes()) else { @@ -86,15 +114,29 @@ impl Seed { id.copy_from_slice(&self.0[..GenerationId::LENGTH]); GenerationId::from_bytes(id) } + + /// Returns the seed's bytes, which the crate reads only to hand them on: the time ABI's + /// capture, in its file mode, writes a restore's to the entropy file. + #[cfg(target_arch = "x86_64")] + pub(crate) fn bytes(&self) -> &[u8; Self::LENGTH] { + &self.0 + } +} + +impl Drop for Seed { + /// Overwrites the seed's bytes with zeros, with volatile writes, before its allocation is + /// freed. + fn drop(&mut self) { + sys::zeroize(self.0.as_mut_slice()); + #[cfg(test)] + tests::observe_drop(self); + } } impl fmt::Debug for Seed { - /// Writes the generation ID, and none of the entropy that follows it. + /// Writes `Seed { .. }`, and none of the seed's bytes. fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("Seed") - .field("generation_id", &self.generation_id()) - .finish_non_exhaustive() + formatter.debug_struct("Seed").finish_non_exhaustive() } } @@ -163,8 +205,8 @@ impl std::error::Error for ReseedError { } } -/// Reads the first [`Seed::LENGTH`] bytes of the file at `path`, without retrying after -/// `EINTR`. +/// Reads the first [`Seed::LENGTH`] bytes of the file at `path` into a seed's allocation, +/// without retrying after `EINTR`. A failed read drops the seed, which zeroes what it read. fn read_seed(path: &CStr) -> Result { let owned = || PathBuf::from(OsStr::from_bytes(path.to_bytes())); let descriptor = sys::open( @@ -176,10 +218,10 @@ fn read_seed(path: &CStr) -> Result { path: owned(), source: errno.into(), })?; - let mut bytes = [0; Seed::LENGTH]; + let mut seed = Seed::zeroed(); let mut offset = 0; - while offset < bytes.len() { - let source = match descriptor.read(&mut bytes[offset..]) { + while offset < Seed::LENGTH { + let source = match descriptor.read(&mut seed.0[offset..]) { Ok(0) => io::ErrorKind::UnexpectedEof.into(), Ok(count) => { offset += count; @@ -192,7 +234,7 @@ fn read_seed(path: &CStr) -> Result { source, }); } - Ok(Seed(bytes)) + Ok(seed) } /// Adds `seed` to the entropy pool through `random`, which stands for `/dev/random`, and reseeds @@ -371,6 +413,7 @@ fn print_random_sample(out: &mut dyn Write, err: &mut dyn Write) -> Result<(), C #[cfg(test)] mod tests { + use std::cell::RefCell; use std::fs; use super::*; @@ -380,6 +423,18 @@ mod tests { /// A seed whose bytes count up from 0, so its generation ID is 000102...0f. const GENERATION_ID: &str = "000102030405060708090a0b0c0d0e0f"; + thread_local! { + /// The bytes of each seed that the test's thread dropped, as its drop left them just + /// before its allocation was freed. + static DROPPED: RefCell> = const { RefCell::new(Vec::new()) }; + } + + /// Records the bytes of `seed`, which its drop has overwritten, while its allocation still + /// holds them. + pub(super) fn observe_drop(seed: &Seed) { + DROPPED.with_borrow_mut(|dropped| dropped.push(*seed.0)); + } + struct Fixture { directory: tempfile::TempDir, } @@ -588,7 +643,7 @@ mod tests { } #[test] - fn a_seed_reads_the_first_bytes_of_its_file_and_shows_only_its_generation_id() { + fn a_seed_reads_the_first_bytes_of_its_file_and_formats_none_of_them() { let fixture = Fixture::new(); let long = fixture.directory.path().join("long"); let bytes: Vec = (0..=Seed::LENGTH as u8).map(|byte| byte ^ 0xa5).collect(); @@ -597,18 +652,37 @@ mod tests { assert_eq!(seed.0[..], bytes[..Seed::LENGTH]); let id = seed.generation_id(); assert_eq!(id.to_bytes()[..], bytes[..GenerationId::LENGTH]); - assert_eq!( - format!("{seed:?}"), - format!("Seed {{ generation_id: {id:?}, .. }}") - ); - let from_bytes = Seed::from_bytes(seed.0); + assert_eq!(format!("{seed:?}"), "Seed { .. }"); + assert_eq!(format!("{seed:#?}"), "Seed { .. }"); + let from_bytes = Seed::from_bytes(*seed.0); assert_eq!( Seed::read(fixture.path("seed")).unwrap().generation_id(), GenerationId::from_bytes(std::array::from_fn(|index| index as u8)) ); + assert_eq!(from_bytes.0, seed.0); assert_eq!(from_bytes.generation_id(), id); } + #[test] + fn a_seed_moves_without_its_bytes_and_zeroes_them_when_dropped() { + let fixture = Fixture::new(); + let seed = Seed::from_bytes([0xa5; Seed::LENGTH]); + let bytes = seed.0.as_ptr(); + // A move, such as into a restore and out of it again, moves only the allocation's address. + let seed = std::hint::black_box(seed); + assert_eq!(seed.0.as_ptr(), bytes); + let read = Seed::read(fixture.path("seed")).unwrap(); + DROPPED.take(); + drop((seed, read)); + assert_eq!(DROPPED.take(), [[0; Seed::LENGTH]; 2]); + + // A read that fails drops the seed that it was filling. + let short = fixture.directory.path().join("short"); + fs::write(&short, [0xa5; Seed::LENGTH - 1]).unwrap(); + Seed::read(&short).unwrap_err(); + assert_eq!(DROPPED.take(), [[0; Seed::LENGTH]]); + } + #[test] fn seed_errors_keep_the_path_and_the_cause() { let fixture = Fixture::new(); diff --git a/nvx-rs/src/guest/sys/ioctl.rs b/nvx-rs/src/guest/sys/ioctl.rs index d7f425787..8047e0a19 100644 --- a/nvx-rs/src/guest/sys/ioctl.rs +++ b/nvx-rs/src/guest/sys/ioctl.rs @@ -9,7 +9,7 @@ use super::{Errno, FileDescriptor}; /// Number of bytes that `nvx-reseed` adds to the entropy pool. const SEED_BYTES: usize = 64; -/// C layout required by `RNDADDENTROPY`. +/// C layout required by `RNDADDENTROPY`, whose copy of the seed it zeroes when it is dropped. #[repr(C)] struct SeedPayload { /// Number of entropy bits credited to the payload. @@ -20,6 +20,13 @@ struct SeedPayload { bytes: [u8; SEED_BYTES], } +impl Drop for SeedPayload { + /// Overwrites the payload's copy of the seed with zeros, with volatile writes. + fn drop(&mut self) { + super::zeroize(&mut self.bytes); + } +} + nix::ioctl_read_bad!( /// Reads the number of bytes waiting to be read: Linux `FIONREAD`, whose number `nix` /// takes from its `libc` dependency because it defines no constant of its own. @@ -79,18 +86,20 @@ pub(crate) fn console_pending(descriptor: &FileDescriptor) -> Result Result<(), Errno> { - let payload = SeedPayload { + let mut payload = SeedPayload { entropy_count: (SEED_BYTES * 8) as c_int, buf_size: SEED_BYTES as c_int, - bytes: *seed, + bytes: [0; SEED_BYTES], }; + payload.bytes.copy_from_slice(seed); // SAFETY: `payload` has the kernel ABI's layout and outlives this synchronous ioctl. unsafe { rnd_add_entropy(descriptor.raw(), &raw const payload) }?; // SAFETY: `RNDRESEEDCRNG` reads no argument. @@ -150,6 +159,22 @@ mod tests { assert_eq!(size_of::(), 8 + SEED_BYTES); } + #[test] + fn a_seed_payload_zeroes_its_copy_of_the_seed_when_dropped() { + let mut payload = std::mem::ManuallyDrop::new(SeedPayload { + entropy_count: 512, + buf_size: 64, + bytes: [0xa5; SEED_BYTES], + }); + // SAFETY: the payload is dropped once, and owns no resource, so its memory still holds a + // valid payload, which the test reads, once its destructor ran. + unsafe { std::mem::ManuallyDrop::drop(&mut payload) }; + assert_eq!( + (payload.entropy_count, payload.buf_size, payload.bytes), + (512, 64, [0; SEED_BYTES]) + ); + } + #[test] fn the_buffer_flush_ioctl_number_matches_linux() { // `BLKFLSBUF` in linux/fs.h. diff --git a/nvx-rs/src/guest/sys/mod.rs b/nvx-rs/src/guest/sys/mod.rs index dbd90957f..de638151f 100644 --- a/nvx-rs/src/guest/sys/mod.rs +++ b/nvx-rs/src/guest/sys/mod.rs @@ -7,7 +7,8 @@ //! pointer, lifetime, and ownership invariants. Errors are `nix`'s [`Errno`], whose descriptions //! the programs print. It also builds and decodes the kernel's binary interfaces that `nix` does //! not model, such as the netlink messages of socket diagnostics, as explicit byte layouts, so -//! that the programs handle only typed values. +//! that the programs handle only typed values, and erases secrets, such as a restore's seed, +//! with the volatile writes of `zeroize`, which the compiler cannot drop as dead stores. //! //! A descriptor that this module opens keeps one owner, its wrapper here, such as //! `FileDescriptor`, and the programs read and write it only through that wrapper, never through @@ -63,6 +64,7 @@ mod port; mod process; mod rooted; mod scheduler; +mod secret; mod signal; mod sock_diag; mod socket; @@ -142,6 +144,7 @@ pub(crate) use scheduler::{ pin_current_thread, promote_all_threads, scheduling_policy, set_scheduling_policy, thread_count, }; +pub(crate) use secret::zeroize; #[cfg(test)] pub(crate) use signal::default_sigpipe; pub(crate) use signal::{ diff --git a/nvx-rs/src/guest/sys/secret.rs b/nvx-rs/src/guest/sys/secret.rs new file mode 100644 index 000000000..283cd5a9c --- /dev/null +++ b/nvx-rs/src/guest/sys/secret.rs @@ -0,0 +1,30 @@ +//! The erasure of secrets, such as a restore's seed, from memory that the process still owns. + +use std::ptr; +use std::sync::atomic::{Ordering, compiler_fence}; + +/// Overwrites `bytes` with zeros, with one volatile write per byte, which the compiler keeps +/// even when nothing reads the bytes again, as before their memory is freed, and a compiler +/// fence, which keeps the writes before the code that follows, such as that free. +pub(crate) fn zeroize(bytes: &mut [u8]) { + for byte in bytes { + // SAFETY: `byte` is an exclusive reference, so it points to one initialized, aligned, + // and writable byte. + unsafe { ptr::write_volatile(byte, 0) }; + } + compiler_fence(Ordering::SeqCst); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn zeroize_overwrites_every_byte_of_its_slice_alone() { + let mut bytes: [u8; 67] = std::array::from_fn(|index| index as u8 | 1); + zeroize(&mut bytes[1..66]); + assert_eq!((bytes[0], bytes[66]), (1, 67)); + assert!(bytes[1..66].iter().all(|&byte| byte == 0)); + zeroize(&mut []); + } +} diff --git a/nvx-rs/src/guest/time/api.rs b/nvx-rs/src/guest/time/api.rs index c621ac763..0fc72ea83 100644 --- a/nvx-rs/src/guest/time/api.rs +++ b/nvx-rs/src/guest/time/api.rs @@ -1,6 +1,6 @@ //! The time ABI's typed calls, through which a guest init or agent runs its steps in process. -use std::ffi::{CStr, CString}; +use std::ffi::CString; use std::fmt::{self, Display}; use std::io; use std::os::unix::ffi::OsStrExt; @@ -9,9 +9,7 @@ use std::path::PathBuf; use super::checks::{self, Checks}; use super::cpu_mask::CpuMask; use super::daemon; -use super::decoders::{ - self, GENERATION_ID_SIZE, PACKET_HEADER_SIZE, PacketError, PacketHeader, RestorePacket, -}; +use super::decoders::{self, PACKET_HEADER_SIZE, PacketError, PacketHeader, RestorePacket}; use super::events::{CheckId, ViolationCode}; use super::state::{ CheckState, CheckStatus, Discontinuity, DowntimeSource, SampleRejection, TimeState, @@ -24,6 +22,7 @@ use super::system::{ }; use super::{NSEC_PER_USEC, Phase}; use crate::guest::GenerationId; +use crate::guest::reseed::Seed; use crate::guest::sys::{ self, Errno, FileDescriptor, ForkResult, OpenFlags, Port, PortAccess, PortError, kernel, }; @@ -132,14 +131,18 @@ impl TimeAbi { /// Requests a capture through the snapshot port, preparation step 5, and, in a VM that a /// snapshot restored, repairs the restore: reads and validates the restore packet, steps and - /// slews the wall clock, writes the packet's fresh entropy to the request's entropy file, - /// and records the restore, repair steps 1 to 3. + /// slews the wall clock, hands over the packet's fresh entropy, and records the restore, + /// repair steps 1 to 3. /// - /// The call creates, or truncates, the entropy file before it requests the capture, and - /// removes it again when the VM continues without a restore. A capture that succeeds ends - /// the VM process that requested it, in which the call does not return. With - /// [`CaptureRequest::finish`], [`Restore::finished`] tells whether the call finished the - /// restore, and how it reports a failed handoff of repair step 7. + /// The request's mode selects how the entropy is handed over. In the file mode of + /// [`CaptureRequest::new`], the call creates, or truncates, the entropy file before it + /// requests the capture, writes the entropy to it, and removes it again when the VM + /// continues without a restore. In the in-memory mode of [`CaptureRequest::in_memory`], it + /// writes no file: the [`Restore`] holds the entropy, as an owned [`Seed`], until + /// [`Restore::take_seed`] hands it over. A capture that succeeds ends the VM process that + /// requested it, in which the call does not return. With [`CaptureRequest::finish`], + /// [`Restore::finished`] tells whether the call finished the restore, and how it reports a + /// failed handoff of repair step 7. pub fn capture(&self, request: CaptureRequest) -> Result { released(capture(&self.runtime(), request)) } @@ -417,29 +420,58 @@ impl ScratchPolicy { } } -/// A capture request of [`TimeAbi::capture`]. +/// A capture request of [`TimeAbi::capture`], in one of two modes, which differ in how a restore +/// hands over the fresh entropy of its restore packet: the file mode of [`new`](Self::new), +/// which writes it to a file, as `nvx-time capture` does, and the in-memory mode of +/// [`in_memory`](Self::in_memory), which writes no file, and returns it as the [`Restore`]'s +/// owned [`Seed`]. #[derive(Clone, Debug, PartialEq, Eq)] pub struct CaptureRequest { /// The snapshot's scratch policy. scratch: ScratchPolicy, /// The VM generation ID before the capture, which a restore must replace. generation_id: GenerationId, - /// The file to which a restore writes its fresh entropy. - entropy_path: PathBuf, + /// How a restore hands over its fresh entropy. + entropy: EntropyMode, /// Whether to finish a restore that has nothing to activate. finish: bool, } +/// How a restore hands over the fresh entropy of its restore packet: the mode of a +/// [`CaptureRequest`]. +#[derive(Clone, Debug, PartialEq, Eq)] +enum EntropyMode { + /// The file mode: the restore writes the entropy to the file at this path, which only its + /// owner may read and write. + File(PathBuf), + /// The in-memory mode: the restore returns the entropy, as its owned seed. + InMemory, +} + impl CaptureRequest { - /// Describes a capture with the `scratch` policy, by the VM process whose generation ID, - /// from [`TimeAbi::generation_id`], is `generation_id`. A restore writes the 64 bytes of - /// fresh entropy of its restore packet, which start with the new generation ID, to a file - /// at `entropy_path` that only its owner may read and write. + /// Describes a capture in the file mode, with the `scratch` policy, by the VM process whose + /// generation ID, from [`TimeAbi::generation_id`], is `generation_id`. A restore writes the + /// 64 bytes of fresh entropy of its restore packet, which start with the new generation ID, + /// to a file at `entropy_path` that only its owner may read and write. pub fn new(scratch: ScratchPolicy, generation_id: GenerationId, entropy_path: PathBuf) -> Self { Self { scratch, generation_id, - entropy_path, + entropy: EntropyMode::File(entropy_path), + finish: false, + } + } + + /// Describes a capture in the in-memory mode, with the `scratch` policy, by the VM process + /// whose generation ID, from [`TimeAbi::generation_id`], is `generation_id`. A restore + /// writes no file: its [`Restore`] holds the 64 bytes of fresh entropy of its restore + /// packet, which start with the new generation ID, as an owned [`Seed`], which + /// [`Restore::take_seed`] hands over. + pub fn in_memory(scratch: ScratchPolicy, generation_id: GenerationId) -> Self { + Self { + scratch, + generation_id, + entropy: EntropyMode::InMemory, finish: false, } } @@ -457,17 +489,22 @@ impl CaptureRequest { } /// How a capture request ended, in the VM process in which it returned. -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Debug)] pub enum CaptureOutcome { /// The VM continued without a restore: no snapshot was taken, or the restore packet was of - /// the recorded generation. The entropy file is removed. + /// the recorded generation. The entropy file of the file mode is removed. Continued, - /// A snapshot restored the VM, and the call repaired its time and wrote its entropy. + /// A snapshot restored the VM, and the call repaired its time and handed over its entropy: + /// to the entropy file in the file mode, and to the restore's seed in the in-memory mode. Restored(Restore), } /// A restore that a capture request repaired, as its restore packet describes it. -#[derive(Clone, Debug, PartialEq, Eq)] +/// +/// In the in-memory mode, it also holds the restore's fresh entropy, as an owned [`Seed`], until +/// [`take_seed`](Self::take_seed) hands it over, so it is neither `Clone` nor comparable: the +/// seed has one owner. +#[derive(Debug)] pub struct Restore { /// The packet's flags. flags: u8, @@ -479,6 +516,8 @@ pub struct Restore { memory_ranges: Vec, /// Whether the call finished the restore. finished: bool, + /// The restore's fresh entropy, in the in-memory mode, until the caller takes it. + seed: Option, } impl Restore { @@ -516,11 +555,22 @@ impl Restore { &self.memory_ranges } - /// Returns the new VM generation ID, with which the entropy file starts. + /// Returns the new VM generation ID, with which the restore's fresh entropy starts. pub fn generation_id(&self) -> GenerationId { self.generation_id } + /// Takes the restore's fresh entropy: 64 bytes that start with the new VM generation ID, + /// which a capture in the in-memory mode of [`CaptureRequest::in_memory`] returns instead of + /// writing them to a file, or `None` in the file mode, or once the seed was taken. + /// + /// The seed moves to the caller without a copy of its bytes, which it zeroes when it is + /// dropped, so the caller drops it once it reseeded the kernel's random number generator + /// with it, through [`reseed`](crate::guest::reseed::reseed). + pub fn take_seed(&mut self) -> Option { + self.seed.take() + } + /// Returns the restore packet's flags, which `nvx-time capture` prints. pub(crate) fn flags(&self) -> u8 { self.flags @@ -543,6 +593,7 @@ impl Restore { generation_id, memory_ranges: Vec::new(), finished: false, + seed: None, } } } @@ -568,8 +619,8 @@ impl MemoryRange { } } -/// The flags with which a capture creates the entropy file: write-only, created or truncated, -/// and never through a symbolic link. +/// The flags with which a capture in the file mode creates the entropy file: write-only, created +/// or truncated, and never through a symbolic link. const ENTROPY_FLAGS: OpenFlags = OpenFlags::O_WRONLY .union(OpenFlags::O_CREAT) .union(OpenFlags::O_TRUNC) @@ -581,14 +632,20 @@ fn capture(runtime: &Runtime<'_>, request: CaptureRequest) -> Result { + let Ok(path) = CString::new(entropy_path.as_os_str().as_bytes()) else { + return Err(TimeError::EntropyFile { + path: entropy_path, + source: io::ErrorKind::InvalidInput.into(), + }); + }; + Some((path, entropy_path)) + } + EntropyMode::InMemory => None, }; if state_load().is_err() { return Err(TimeError::StateUnavailable); @@ -598,16 +655,22 @@ fn capture(runtime: &Runtime<'_>, request: CaptureRequest) -> Result match sys::open(&path, ENTROPY_FLAGS, PRIVATE_FILE_MODE) { + Ok(file) => EntropySink::File { path, file }, + Err(error) => { + return Err(TimeError::EntropyFile { + path: entropy_path, + source: error.into(), + }); + } + }, + None => EntropySink::Seed, + }; let _lock = match system::lock_portb() { Ok(lock) => lock, Err(error) => { - discard_entropy(entropy_file, &path); + entropy.discard(); return Err(TimeError::PortLock(error.into())); } }; @@ -617,13 +680,12 @@ fn capture(runtime: &Runtime<'_>, request: CaptureRequest) -> Result) { } } -/// Closes and removes the restored entropy file, of a capture that restored no packet. -fn discard_entropy(file: FileDescriptor, path: &CStr) { - let _ = file.close(); - let _ = sys::unlink(path); +/// Where a capture hands over a restore's fresh entropy, in its request's mode. +enum EntropySink { + /// The file mode's entropy file, which the capture created. + File { + /// The file's path. + path: CString, + /// The created file. + file: FileDescriptor, + }, + /// The in-memory mode's seed, which the restore returns. + Seed, +} + +impl EntropySink { + /// Closes and removes the file mode's entropy file, of a capture that restored no packet. + fn discard(self) { + if let Self::File { path, file } = self { + let _ = file.close(); + let _ = sys::unlink(&path); + } + } + + /// Hands over `entropy`, a restore's: writes it to the entropy file and drops it, which + /// zeroes it, or returns it, as the restore's seed. A failure to write or close the file is + /// reported with the file's path. + fn deliver(self, entropy: Seed) -> Result, Vec> { + match self { + Self::File { path, file } => { + system::write_entropy(file, &path, entropy.bytes()).map(|()| None) + } + Self::Seed => Ok(Some(entropy)), + } + } } /// What repair step 2's port reads returned, which repair validates once their transaction @@ -684,21 +775,18 @@ fn read_packet(ports: &PortAccess<'_>, recorded: u32) -> PacketRead { } let mut body = vec![0_u8; header.body_size()]; portb_read(ports, Port::PortbData, &mut body); - PacketRead::Packet { - t0, - t1, - packet: RestorePacket::parse(header, &body), - } + let packet = RestorePacket::parse(header, &body); + // The packet owns its entropy now, in the seed's allocation. + sys::zeroize(&mut body); + PacketRead::Packet { t0, t1, packet } } /// What a restore transaction writes, and where. -struct RepairTarget<'a> { +struct RepairTarget { /// The VM generation ID before the snapshot, which the packet must replace. previous_id: GenerationId, - /// The path of the restored entropy file. - entropy_path: &'a CStr, - /// The restored entropy file. - entropy_file: FileDescriptor, + /// Where the restore's fresh entropy goes. + entropy: EntropySink, /// Whether to hand repair steps 6 and 7 to the daemon. finish: bool, /// The daemon that would run step 7. @@ -706,11 +794,11 @@ struct RepairTarget<'a> { } /// Repair steps 2 and 3, once the port transaction that read the packet ended: validates it, -/// steps and slews the clock, and records the restore. +/// steps and slews the clock, hands over its entropy, and records the restore. fn repair_restore( runtime: &Runtime<'_>, read: PacketRead, - target: RepairTarget<'_>, + target: RepairTarget, start_ns: i64, ) -> Result { let (t0, t1, packet) = match read { @@ -722,7 +810,7 @@ fn repair_restore( )); } PacketRead::Stale => { - discard_entropy(target.entropy_file, target.entropy_path); + target.entropy.discard(); return Ok(CaptureOutcome::Continued); } PacketRead::Generation(generation) => { @@ -740,8 +828,13 @@ fn repair_restore( }; let packet = packet .map_err(|error| repair_failed(runtime, ViolationCode::RepairPacket, error.to_string()))?; - let header = packet.header; - if packet.generation_id() == target.previous_id.to_bytes() { + let RestorePacket { + header, + ranges, + entropy, + } = packet; + let generation_id = entropy.generation_id(); + if generation_id == target.previous_id { return Err(repair_failed( runtime, ViolationCode::RepairGeneration, @@ -794,11 +887,10 @@ fn repair_restore( && header.online_vp_count == 0 && header.range_count == 0 && !header.memory_target(); - if let Err(detail) = - system::write_entropy(target.entropy_file, target.entropy_path, &packet.entropy) - { - return Err(repair_failed(runtime, ViolationCode::RepairPacket, detail)); - } + let seed = match target.entropy.deliver(entropy) { + Ok(seed) => seed, + Err(detail) => return Err(repair_failed(runtime, ViolationCode::RepairPacket, detail)), + }; if let Err(error) = state_apply(|state| { apply_repair( state, @@ -827,18 +919,16 @@ fn repair_restore( let finished = daemon.is_some_and(|daemon| { daemon::finish_restore(runtime, daemon, header.acknowledgement_required(), start_ns) }); - let mut generation_id = [0_u8; GENERATION_ID_SIZE]; - generation_id.copy_from_slice(packet.generation_id()); Ok(CaptureOutcome::Restored(Restore { flags: header.flags, online_vp_count: header.online_vp_count, - generation_id: GenerationId::from_bytes(generation_id), - memory_ranges: packet - .ranges + generation_id, + memory_ranges: ranges .iter() .map(|&(start, length)| MemoryRange { start, length }) .collect(), finished, + seed, })) } @@ -1177,6 +1267,9 @@ impl std::error::Error for TimeError { #[cfg(test)] mod tests { use std::ffi::OsStr; + use std::fs; + use std::os::unix::fs::PermissionsExt; + use std::path::Path; use super::*; use crate::guest::time::system::tests::FakeOps; @@ -1411,6 +1504,7 @@ mod tests { generation_id: GenerationId::from_bytes([7; GenerationId::LENGTH]), memory_ranges: vec![range], finished: false, + seed: None, }; assert!(restore.acknowledgement_required() && restore.memory_target()); assert!(!restore.finished()); @@ -1460,4 +1554,185 @@ mod tests { assert_eq!(RestoreFinish::new(), RestoreFinish::default()); assert!(TimeAbi::report_only().is_report_only()); } + + #[test] + fn capture_requests_select_how_a_restore_hands_over_its_entropy() { + let id = GenerationId::from_bytes([1; GenerationId::LENGTH]); + let path = PathBuf::from("/run/nvx/restore-entropy"); + let file = CaptureRequest::new(ScratchPolicy::Paired, id, path.clone()); + assert_eq!(file.entropy, EntropyMode::File(path)); + let in_memory = CaptureRequest::in_memory(ScratchPolicy::Paired, id); + assert_eq!( + (in_memory.scratch, in_memory.generation_id, in_memory.finish), + (ScratchPolicy::Paired, id, false) + ); + assert_eq!(in_memory.entropy, EntropyMode::InMemory); + let finishing = in_memory.clone().finish(); + assert_eq!( + (finishing.entropy, finishing.finish), + (EntropyMode::InMemory, true) + ); + assert_ne!(file, in_memory); + } + + #[test] + fn a_restore_hands_over_its_seed_once_and_formats_none_of_it() { + let seed = Seed::from_bytes([7; Seed::LENGTH]); + let mut restore = Restore { + flags: 0, + online_vp_count: 0, + generation_id: seed.generation_id(), + memory_ranges: Vec::new(), + finished: false, + seed: Some(seed), + }; + let text = format!("{restore:?}"); + assert!(text.ends_with(", seed: Some(Seed { .. }) }"), "{text}"); + let seed = restore.take_seed().unwrap(); + assert_eq!(seed.generation_id(), restore.generation_id()); + assert_eq!(*seed.bytes(), [7; Seed::LENGTH]); + assert!(restore.take_seed().is_none()); + assert!(format!("{restore:?}").ends_with(", seed: None }")); + } + + #[test] + fn the_file_mode_writes_the_entropy_and_the_in_memory_mode_returns_it() { + let entropy: [u8; Seed::LENGTH] = std::array::from_fn(|index| index as u8 ^ 0x5a); + let directory = tempfile::tempdir().unwrap(); + let file_path = directory.path().join("restore-entropy"); + let path = sys::path_c_string(&file_path).unwrap(); + let open = || sys::open(&path, ENTROPY_FLAGS, PRIVATE_FILE_MODE).unwrap(); + let sink = EntropySink::File { + path: path.clone(), + file: open(), + }; + assert!(sink.deliver(Seed::from_bytes(entropy)).unwrap().is_none()); + assert_eq!(fs::read(&file_path).unwrap(), entropy); + let mode = fs::metadata(&file_path).unwrap().permissions().mode(); + assert_eq!(mode & 0o7777, 0o600); + // The in-memory mode returns the seed, and writes nothing. + let seed = EntropySink::Seed.deliver(Seed::from_bytes(entropy)); + assert_eq!(seed.unwrap().map(|seed| *seed.bytes()), Some(entropy)); + EntropySink::Seed.discard(); + assert_eq!(fs::read_dir(directory.path()).unwrap().count(), 1); + // A capture that continues removes the file mode's file. + let file = open(); + EntropySink::File { path, file }.discard(); + assert_eq!(fs::read_dir(directory.path()).unwrap().count(), 0); + } + + /// Returns the port input with which a restore answers a capture: port B's status, then a + /// packet of `generation`, which carries `entropy` and nothing to activate, in four-byte + /// reads. + fn restore_input(generation: u32, entropy: &[u8; Seed::LENGTH]) -> Vec { + let mut packet = [0_u8; PACKET_HEADER_SIZE + Seed::LENGTH]; + packet[..4].copy_from_slice(b"OVR\x04"); + packet[8..12].copy_from_slice(&generation.to_le_bytes()); + let utc_ns = clock_ns(sys::Clock::Realtime).unsigned_abs(); + packet[24..32].copy_from_slice(&utc_ns.to_le_bytes()); + packet[PACKET_HEADER_SIZE..].copy_from_slice(entropy); + let words = packet + .chunks(size_of::()) + .map(|word| u32::from_ne_bytes(word.try_into().unwrap())); + std::iter::once(u32::from(PORTB_PACKET_AVAILABLE)) + .chain(words) + .collect() + } + + /// Returns whether a file below `directory` holds `secret`, as bytes or in hexadecimal. + fn holds(directory: &Path, secret: &[u8]) -> bool { + let hex = decoders::format_hex(secret); + fs::read_dir(directory).unwrap().any(|entry| { + let path = entry.unwrap().path(); + if path.is_dir() { + return holds(&path, secret); + } + let contents = fs::read(&path).unwrap(); + contents.windows(secret.len()).any(|bytes| bytes == secret) + || contents + .windows(hex.len()) + .any(|text| text == hex.as_bytes()) + }) + } + + /// Captures through the fakes, in the in-memory mode and then in the file mode, a restore, + /// then a VM that continues, on a tmpfs at `/run` in a mount namespace of the process's own. + fn capture_in_each_mode() { + sys::enter_private_mount_namespace().unwrap(); + let _run = sys::TmpfsMount::mount(c"nvx-test", c"/run", c"mode=0755").unwrap(); + system::make_runtime_directories(); + state_store(&TimeState::default()).unwrap(); + let previous = GenerationId::from_bytes([1; GenerationId::LENGTH]); + let entropy: [u8; Seed::LENGTH] = std::array::from_fn(|index| index as u8 + 2); + let secret = &entropy[GenerationId::LENGTH..]; + let path = Path::new("/run/nvx/restore-entropy"); + // Returns the capture's outcome, and what it wrote to the kernel log and the console. + let capture_with = |input: Vec, request| { + let ops = FakeOps::new(input); + let outcome = capture(&Runtime::new(&ops, true), request).unwrap(); + let mut logged = ops.kernel_log.lock().unwrap().clone(); + logged.extend_from_slice(&ops.console.lock().unwrap()); + (outcome, logged) + }; + + // The in-memory mode returns the entropy as the restore's seed, and writes no file. + let request = CaptureRequest::in_memory(ScratchPolicy::Fresh, previous); + let (CaptureOutcome::Restored(mut restore), logged) = + capture_with(restore_input(1, &entropy), request) + else { + panic!("the in-memory capture restored nothing"); + }; + assert_eq!(logged, b""); + let id = restore.generation_id().to_bytes(); + assert_eq!(id[..], entropy[..GenerationId::LENGTH]); + assert_eq!(restore.take_seed().map(|seed| *seed.bytes()), Some(entropy)); + assert!(!path.exists() && !holds(Path::new("/run"), secret)); + let request = CaptureRequest::in_memory(ScratchPolicy::Fresh, previous); + let (outcome, _) = capture_with(vec![0], request); + assert!(matches!(outcome, CaptureOutcome::Continued), "{outcome:?}"); + + // The file mode, which the tools keep, writes the entropy to a file that only its owner + // may read and write, and the restore holds no seed. + let request = CaptureRequest::new(ScratchPolicy::Paired, previous, path.to_owned()); + let (CaptureOutcome::Restored(mut restore), logged) = + capture_with(restore_input(2, &entropy), request) + else { + panic!("the file mode's capture restored nothing"); + }; + assert_eq!(logged, b""); + assert!(restore.take_seed().is_none()); + assert_eq!(fs::read(path).unwrap(), entropy); + let mode = fs::metadata(path).unwrap().permissions().mode(); + assert_eq!(mode & 0o7777, 0o600); + // A VM that continues has the file removed. + let request = CaptureRequest::new(ScratchPolicy::Fresh, previous, path.to_owned()); + let (outcome, _) = capture_with(vec![0], request); + assert!(matches!(outcome, CaptureOutcome::Continued), "{outcome:?}"); + assert!(!path.exists()); + } + + #[test] + fn privileged_captures_hand_over_the_entropy_in_their_mode() { + if crate::guest::managed_agent::isolate_process_test( + "NVX_TIME_CAPTURE_PRIVILEGED_TEST_CHILD", + "guest::time::api::tests::privileged_captures_hand_over_the_entropy_in_their_mode", + ) { + return; + } + if !sys::holds_capability(sys::CAP_SYS_ADMIN).unwrap() { + return; + } + // A capture keeps its state, its locks, and the file mode's entropy file in `/run/nvx`, + // so a child of the harness's process makes the calls alone in a mount namespace of its + // own, which only a process with one thread may enter. + match sys::fork_isolated_test().unwrap() { + ForkResult::Child => { + let outcome = std::panic::catch_unwind(capture_in_each_mode); + sys::exit_immediately(u8::from(outcome.is_err())); + } + ForkResult::Parent(child) => { + assert_eq!(sys::wait(child), Some(sys::ChildStatus::Exited(0))); + } + } + } } diff --git a/nvx-rs/src/guest/time/commands.rs b/nvx-rs/src/guest/time/commands.rs index b7fba92de..44e9e0057 100644 --- a/nvx-rs/src/guest/time/commands.rs +++ b/nvx-rs/src/guest/time/commands.rs @@ -194,6 +194,31 @@ fn parse_capture_request(text: &[u8]) -> Option { .map(|request| request as u8) } +/// Reads the request of `capture 0|1 GENERATION_ID ENTROPY_FILE [--finish]`, in the file mode, +/// in which the restore's entropy goes to `ENTROPY_FILE`, or returns `None` for an invalid +/// command line. +fn capture_request(arguments: &[&[u8]]) -> Option { + let (request, previous_id, entropy_path, finish) = match arguments { + [request, id, path] => (request, id, path, false), + [request, id, path, b"--finish"] => (request, id, path, true), + _ => return None, + }; + let scratch = match parse_capture_request(request)? { + 0 => ScratchPolicy::Fresh, + _ => ScratchPolicy::Paired, + }; + let previous_id = decoders::parse_hex::(previous_id)?; + if entropy_path.contains(&0) { + return None; + } + let request = CaptureRequest::new( + scratch, + GenerationId::from_bytes(previous_id), + PathBuf::from(OsStr::from_bytes(entropy_path)), + ); + Some(if finish { request.finish() } else { request }) +} + /// Requests a capture, and repairs a restore, which it prints: `0` when the VM continued, or /// `1` or `2`, for a restore that it left unfinished or finished, then the restore packet's /// flags, online VP count, range count, and new generation ID, and each range's start and @@ -205,28 +230,9 @@ fn cmd_capture( err: &mut dyn Write, ) -> Result<(), CommandError> { const LINE: &[u8] = b"usage: nvx-time capture 0|1 GENERATION_ID ENTROPY_FILE [--finish]\n"; - let (request, previous_id, entropy_path, finish) = match arguments { - [request, id, path] => (request, id, path, false), - [request, id, path, b"--finish"] => (request, id, path, true), - _ => return usage(err, LINE), - }; - let scratch = match parse_capture_request(request) { - Some(0) => ScratchPolicy::Fresh, - Some(_) => ScratchPolicy::Paired, - None => return usage(err, LINE), - }; - let Some(previous_id) = decoders::parse_hex::(previous_id) else { + let Some(request) = capture_request(arguments) else { return usage(err, LINE); }; - if entropy_path.contains(&0) { - return usage(err, LINE); - } - let request = CaptureRequest::new( - scratch, - GenerationId::from_bytes(previous_id), - PathBuf::from(OsStr::from_bytes(entropy_path)), - ); - let request = if finish { request.finish() } else { request }; let restore = match abi.capture(request) { Ok(CaptureOutcome::Continued) => { let _ = out.write_all(b"0\n"); @@ -454,4 +460,31 @@ mod tests { assert_eq!(parse_capture_request(text), expected, "{text:?}"); } } + + #[test] + fn capture_keeps_the_file_mode_and_its_entropy_file() { + let id = b"000102030405060708090a0b0c0d0e0f"; + let previous = GenerationId::from_bytes(std::array::from_fn(|index| index as u8)); + let path = b"/run/nvx/restore-entropy"; + let file = |scratch| { + CaptureRequest::new(scratch, previous, PathBuf::from("/run/nvx/restore-entropy")) + }; + assert_eq!( + capture_request(&[b"0", id, path]), + Some(file(ScratchPolicy::Fresh)) + ); + assert_eq!( + capture_request(&[b"1", id, path, b"--finish"]), + Some(file(ScratchPolicy::Paired).finish()) + ); + for arguments in [ + &[b"0".as_slice(), id, path, b"--fin"][..], + &[b"2", id, path], + &[b"0", &id[1..], path], + &[b"0", id, b"restore\0entropy"], + &[b"0", id], + ] { + assert_eq!(capture_request(arguments), None, "{arguments:?}"); + } + } } diff --git a/nvx-rs/src/guest/time/decoders.rs b/nvx-rs/src/guest/time/decoders.rs index a96dc9247..36b4402ca 100644 --- a/nvx-rs/src/guest/time/decoders.rs +++ b/nvx-rs/src/guest/time/decoders.rs @@ -10,6 +10,7 @@ use crate::guest::kernel_text::{ self, ClockEventState, SyslogLine, TickDevice, TickMode, TimerList, }; use crate::guest::parse::parse_decimal; +use crate::guest::reseed::Seed; use crate::guest::sys::ClockAdjustment; use crate::guest::sys::x86::{CpuidResult, cpuid}; @@ -17,8 +18,10 @@ use crate::guest::sys::x86::{CpuidResult, cpuid}; pub(crate) const PACKET_HEADER_SIZE: usize = 32; /// Encoded length of one memory range. pub(crate) const PACKET_RANGE_SIZE: usize = 16; -/// Entropy payload length appended to every restore packet. +/// Entropy payload length appended to every restore packet: a restore's [`Seed`]. pub(crate) const PACKET_ENTROPY_SIZE: usize = 64; +// The packet's entropy is decoded straight into a seed. +const _: () = assert!(PACKET_ENTROPY_SIZE == Seed::LENGTH); /// Fixed host time-sample record length. pub(crate) const SAMPLE_SIZE: usize = 16; /// VM generation identifier length. @@ -95,9 +98,14 @@ impl<'a, E: Copy> Fields<'a, E> { /// Reads the next `N` bytes. fn bytes(&mut self) -> Result<[u8; N], E> { + self.chunk().copied() + } + + /// Reads the next `N` bytes, which it borrows from the record rather than copies. + fn chunk(&mut self) -> Result<&'a [u8; N], E> { let (field, rest) = self.rest.split_first_chunk().ok_or(self.incomplete)?; self.rest = rest; - Ok(*field) + Ok(field) } /// Reads the next 16-bit field. @@ -282,14 +290,17 @@ impl PacketHeader { } /// A validated version-one restore packet. -#[derive(Clone, Debug, PartialEq, Eq)] +/// +/// It is neither `Clone` nor comparable, since its entropy is a [`Seed`], which has one owner. +#[derive(Debug)] pub(crate) struct RestorePacket { /// The packet's header. pub(crate) header: PacketHeader, /// Memory ranges as `(start, length)` pairs. pub(crate) ranges: Vec<(u64, u64)>, - /// Fresh entropy, which starts with the new VM generation ID. - pub(crate) entropy: [u8; PACKET_ENTROPY_SIZE], + /// Fresh entropy, which starts with the new VM generation ID, decoded straight into the + /// seed's allocation. + pub(crate) entropy: Seed, } impl RestorePacket { @@ -308,13 +319,13 @@ impl RestorePacket { Ok(Self { header, ranges, - entropy: fields.bytes()?, + entropy: Seed::copy_from(fields.chunk()?), }) } /// Returns the new VM generation ID, which leads the entropy. pub(crate) fn generation_id(&self) -> &[u8] { - &self.entropy[..GENERATION_ID_SIZE] + &self.entropy.bytes()[..GENERATION_ID_SIZE] } } @@ -944,11 +955,11 @@ mod tests { assert_eq!(header.body_size(), PACKET_ENTROPY_SIZE); let entropy: Vec = (0..64).collect(); let packet = RestorePacket::parse(header, &entropy).unwrap(); - assert_eq!(packet.entropy.as_slice(), entropy); + assert_eq!(packet.entropy.bytes().as_slice(), entropy); assert_eq!(packet.generation_id(), &entropy[..GENERATION_ID_SIZE]); assert_eq!( - RestorePacket::parse(header, &entropy[1..]), - Err(PacketError::IncompleteBody) + RestorePacket::parse(header, &entropy[1..]).unwrap_err(), + PacketError::IncompleteBody ); assert_eq!( PacketHeader::parse(&valid_header()[..31]), diff --git a/nvx-rs/src/guest/time/mod.rs b/nvx-rs/src/guest/time/mod.rs index d263bb158..57be2ae87 100644 --- a/nvx-rs/src/guest/time/mod.rs +++ b/nvx-rs/src/guest/time/mod.rs @@ -8,10 +8,12 @@ //! call waits for a child that it did not start. `nvx-time` runs those steps through the same //! calls, and the rest of the ABI itself. //! -//! ```no_run -//! use std::path::PathBuf; +//! A restore hands over its fresh entropy in the [`CaptureRequest`]'s mode: the file mode, which +//! `nvx-time capture` keeps, writes it to a file, and the in-memory mode returns it as the +//! [`Restore`]'s owned [`Seed`](crate::guest::reseed::Seed), and writes no file: //! -//! use nvx::guest::reseed::{self, Seed}; +//! ```no_run +//! use nvx::guest::reseed; //! use nvx::guest::time::{CaptureOutcome, CaptureRequest, RestoreFinish, ScratchPolicy, TimeAbi}; //! //! # fn main() -> Result<(), Box> { @@ -21,11 +23,13 @@ //! // The caller supervises the process `daemon`. //! //! // A snapshot's capture, after `nvx-time pre-capture`. -//! let entropy = PathBuf::from("/run/nvx/restore-entropy"); -//! let request = CaptureRequest::new(ScratchPolicy::Fresh, abi.generation_id()?, entropy.clone()); -//! if let CaptureOutcome::Restored(restore) = abi.capture(request)? { -//! // A clone of the snapshot continues here, with its wall clock repaired. -//! reseed::reseed(&Seed::read(&entropy)?)?; +//! let request = CaptureRequest::in_memory(ScratchPolicy::Fresh, abi.generation_id()?); +//! if let CaptureOutcome::Restored(mut restore) = abi.capture(request)? { +//! // A clone of the snapshot continues here, with its wall clock repaired. The seed zeroes +//! // its bytes when it is dropped, after the reseed. +//! if let Some(seed) = restore.take_seed() { +//! reseed::reseed(&seed)?; +//! } //! let mut finish = RestoreFinish::new(); //! if restore.acknowledgement_required() { //! finish = finish.acknowledge();