From 4fd37670e28c990bc56abea2bfe45114fbdf7fc6 Mon Sep 17 00:00:00 2001 From: Alois Wohlschlager Date: Sun, 13 Aug 2023 18:29:40 +0200 Subject: [PATCH] tool: stop most overwriting in the ESP Since most files (stubs, kernels and initrds) on the ESP are properly input-addressed or content-addressed now, there is no point in overwriting them any more. Hence we detect what generations are already properly installed, and don't reinstall them any more. This approach leads to two distinct improvements: * Rollbacks are more reliable, because initrd secrets and stubs do not change any more for existing generations (with the necessary exception of stubs in case of signature key rotation). In particular, the risk of a newer stub breaking (for example, because of bad interactions with certain firmware) old and previously working generations is avoided. * Kernels and initrds that are not going to be (re)installed anyway are not read and hashed any more. This significantly reduces the I/O and CPU time required for the installation process, particularly when there is a large number of generations. The following drawbacks are noted: * The first time installation is performed after these changes, most of the ESP is re-written at a different path; as a result, the disk usage increases to roughly the double until the GC is performed. * If multiple generations share a bare initrd, but have different secrets scripts, the final initrds will now be separated, leading to increased disk usage. However, this situation should be rare, and the previous behavior was arguably incorrect anyway. * If the files on the ESP are corrupted, running the installation again will not overwrite them with the correct versions. Since the files are written atomically, this situation should not happen except in case of file system corruption, and it is questionable whether overwriting really fixes the problem in this case. --- rust/tool/systemd/src/install.rs | 93 ++++++++++++++++++++++-------- rust/tool/systemd/tests/install.rs | 4 +- 2 files changed, 72 insertions(+), 25 deletions(-) diff --git a/rust/tool/systemd/src/install.rs b/rust/tool/systemd/src/install.rs index dc12cf8..b04ad1b 100644 --- a/rust/tool/systemd/src/install.rs +++ b/rust/tool/systemd/src/install.rs @@ -184,6 +184,11 @@ impl Installer { /// Hence, this function cannot overwrite files of other generations with different contents. /// All installed files are added as garbage collector roots. fn install_generation(&mut self, generation: &Generation) -> Result<()> { + // If the generation is already properly installed, don't overwrite it. + if self.register_installed_generation(generation).is_ok() { + return Ok(()); + } + let tempdir = TempDir::new().context("Failed to create temporary directory.")?; let bootspec = &generation.spec.bootspec.bootspec; @@ -244,29 +249,10 @@ impl Installer { &self.esp_paths.esp, ) .context("Failed to assemble lanzaboote image.")?; - let stub_inputs = [ - // Generation numbers can be reused if the latest generation was deleted. - // To detect this, the stub path depends on the actual toplevel used. - ("toplevel", bootspec.toplevel.0.as_os_str().as_bytes()), - // If the key is rotated, the signed stubs must be re-generated. - // So we make their path depend on the public key used for signature. - ("public_key", &fs::read(&self.key_pair.public_key)?), - ]; - let stub_input_hash = Base32Unpadded::encode_string(&Sha256::digest( - serde_json::to_string(&stub_inputs).unwrap(), - )); - let stub_name = if let Some(specialisation_name) = generation.is_specialised() { - PathBuf::from(format!( - "nixos-generation-{}-specialisation-{}-{}.efi", - generation, specialisation_name, stub_input_hash - )) - } else { - PathBuf::from(format!( - "nixos-generation-{}-{}.efi", - generation, stub_input_hash - )) - }; - let stub_target = self.esp_paths.linux.join(stub_name); + let stub_target = self + .esp_paths + .linux + .join(stub_name(generation, &self.key_pair.public_key)?); self.gc_roots.extend([&stub_target]); install_signed(&self.key_pair, &lanzaboote_image, &stub_target) .context("Failed to install the Lanzaboote stub.")?; @@ -274,6 +260,33 @@ impl Installer { Ok(()) } + /// Register the files of an already installed generation as garbage collection roots. + /// + /// An error should not be considered fatal; the generation should be (re-)installed instead. + fn register_installed_generation(&mut self, generation: &Generation) -> Result<()> { + let stub_target = self + .esp_paths + .linux + .join(stub_name(generation, &self.key_pair.public_key)?); + let stub = fs::read(&stub_target)?; + let kernel_path = resolve_efi_path( + &self.esp_paths.esp, + pe::read_section_data(&stub, ".kernelp").context("Missing kernel path.")?, + )?; + let initrd_path = resolve_efi_path( + &self.esp_paths.esp, + pe::read_section_data(&stub, ".initrdp").context("Missing initrd path.")?, + )?; + + if !kernel_path.exists() && !initrd_path.exists() { + anyhow::bail!("Missing kernel or initrd."); + } + self.gc_roots + .extend([&stub_target, &kernel_path, &initrd_path]); + + Ok(()) + } + /// Install a content-addressed file to the `EFI/nixos` directory on the ESP. /// /// It is automatically added to the garbage collector roots. @@ -340,6 +353,40 @@ impl Installer { } } +/// Translate an EFI path to an absolute path on the mounted ESP. +fn resolve_efi_path(esp: &Path, efi_path: &[u8]) -> Result { + Ok(esp.join(std::str::from_utf8(&efi_path[1..])?.replace('\\', "/"))) +} + +/// Compute the file name to be used for the stub of a certain generation, signed with the given key. +/// +/// The generated name is input-addressed by the toplevel corresponding to the generation and the public part of the signing key. +fn stub_name(generation: &Generation, public_key: &Path) -> Result { + let bootspec = &generation.spec.bootspec.bootspec; + let stub_inputs = [ + // Generation numbers can be reused if the latest generation was deleted. + // To detect this, the stub path depends on the actual toplevel used. + ("toplevel", bootspec.toplevel.0.as_os_str().as_bytes()), + // If the key is rotated, the signed stubs must be re-generated. + // So we make their path depend on the public key used for signature. + ("public_key", &fs::read(public_key)?), + ]; + let stub_input_hash = Base32Unpadded::encode_string(&Sha256::digest( + serde_json::to_string(&stub_inputs).unwrap(), + )); + if let Some(specialisation_name) = generation.is_specialised() { + Ok(PathBuf::from(format!( + "nixos-generation-{}-specialisation-{}-{}.efi", + generation, specialisation_name, stub_input_hash + ))) + } else { + Ok(PathBuf::from(format!( + "nixos-generation-{}-{}.efi", + generation, stub_input_hash + ))) + } +} + /// Install a PE file. The PE gets signed in the process. /// /// If the file already exists at the destination, it is overwritten. diff --git a/rust/tool/systemd/tests/install.rs b/rust/tool/systemd/tests/install.rs index 5348530..9093114 100644 --- a/rust/tool/systemd/tests/install.rs +++ b/rust/tool/systemd/tests/install.rs @@ -36,7 +36,7 @@ fn do_not_install_duplicates() -> Result<()> { } #[test] -fn overwrite_unsigned_images() -> Result<()> { +fn do_not_overwrite_images() -> Result<()> { let esp = tempdir()?; let tmpdir = tempdir()?; let profiles = tempdir()?; @@ -59,7 +59,7 @@ fn overwrite_unsigned_images() -> Result<()> { let output2 = common::lanzaboote_install(0, esp.path(), generation_links)?; assert!(output2.status.success()); - assert!(verify_signature(&image1)?); + assert!(!verify_signature(&image1)?); assert!(verify_signature(&image2)?); Ok(())