From 6f8de8b2c4cde899e30c8d1744f8c19b5a742f6d Mon Sep 17 00:00:00 2001 From: Andrew Gunnerson Date: Sat, 21 Feb 2026 15:32:44 -0500 Subject: [PATCH] Add option to re-sign unmodified partitions during patching This is useful when the OTA contains partition images that are signed with the AOSP test keys. Issue: #569 Signed-off-by: Andrew Gunnerson --- README.md | 4 ++ avbroot/src/cli/ota.rs | 158 +++++++++++++++++++++++++++++++++++++---- e2e/e2e.toml | 4 +- e2e/src/main.rs | 4 +- 4 files changed, 153 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 4b7f01b..f7cbc7e 100644 --- a/README.md +++ b/README.md @@ -421,6 +421,10 @@ The only behavior this changes is where the partition is read from. When using ` This has no impact on what patches are applied. For example, when using Magisk, the root patch is applied to the boot partition, no matter if the partition came from the original `payload.bin` or from `--replace`. +### Re-signing partitions + +avbroot will automatically re-sign any partitions in the OTA that it modifies. However, partitions that are otherwise unmodified can also be re-signed with `--re-sign `. This is useful, for example, when the OTA contains partitions signed with the public AOSP test key. + ### Booting signed GSIs Android's [Dynamic System Updates (DSU)](https://developer.android.com/topic/dsu) feature uses a different root of trust than the regular system. Instead of using the bootloader's `avb_custom_key`, it obtains the trusted keys from the `first_stage_ramdisk/avb/*.avbpubkey` files inside the `init_boot` or `vendor_boot` ramdisk. These files are encoded in the same binary format as `avb_pkmd.bin`. diff --git a/avbroot/src/cli/ota.rs b/avbroot/src/cli/ota.rs index 3fdb2c8..3b76a68 100644 --- a/avbroot/src/cli/ota.rs +++ b/avbroot/src/cli/ota.rs @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2022-2025 Andrew Gunnerson +// SPDX-FileCopyrightText: 2022-2026 Andrew Gunnerson // SPDX-License-Identifier: GPL-3.0-only use std::{ @@ -23,7 +23,7 @@ use rawzip::{ use rayon::{iter::IntoParallelRefIterator, prelude::ParallelIterator}; use tempfile::{NamedTempFile, TempDir}; use topological_sort::TopologicalSort; -use tracing::{debug_span, error, info, warn}; +use tracing::{Span, debug_span, error, info, warn}; use x509_cert::Certificate; use crate::{ @@ -35,7 +35,6 @@ use crate::{ format::{ avb::{self, Descriptor, Header}, ota::{self, SigningWriter, ZipEntry, ZipMode}, - padding, payload::{self, CowVersion, PayloadHeader, PayloadWriter, VabcAlgo, VabcParams}, zip::{ self, ReaderAtWrapper, ZipArchiveReadAtExt, ZipEntriesSafeExt, ZipFileHeaderRecordExt, @@ -53,7 +52,7 @@ use crate::{ }, stream::{ self, FromReader, HashingWriter, MutexFile, ReadAt, ReadSeek, SectionReader, - SectionReaderAt, ToWriter, UserPosFile, WriteAt, WriteSeek, + SectionReaderAt, UserPosFile, WriteAt, WriteSeek, }, util, }; @@ -155,19 +154,23 @@ enum InputFileState { Modified, } +#[derive(Clone)] struct InputFile { file: Arc, state: InputFileState, } -/// Open all input files listed in `required_images`. If an image has a path -/// in `external_images`, that file is opened. Otherwise, the image is extracted -/// from the payload into a temporary file (that is unnamed if supported by the -/// operating system). +/// Open all input files listed in `required_images` and `re_sign_images`. If an +/// image has a path in `external_images`, that file is opened. Otherwise, the +/// image is extracted from the payload into a temporary file (that is unnamed +/// if supported by the operating system). Images that are destined to be +/// re-signed do not get copied to a temporary file until later when it can be +/// determined that it wasn't already modified and re-signed due to patching. fn open_input_files( payload: &(dyn ReadAt + Sync), required_images: &HashMap, external_images: &HashMap, + re_sign_images: &HashSet, header: &PayloadHeader, cancel_signal: &AtomicBool, ) -> Result> { @@ -178,6 +181,7 @@ fn open_input_files( let all_images = required_images .keys() .chain(external_images.keys()) + .chain(re_sign_images.iter()) .collect::>(); for name in all_images { @@ -325,6 +329,102 @@ fn patch_system_image<'a>( Ok((target, ranges)) } +/// Re-sign the images listed in `re_sign_images` if they aren't already +/// modified by the patching process. If the original image is signed, then it +/// will be re-signed with `key_avb`. Otherwise, it is left untouched. If the +/// original image is external, then it will be copied to a temporary file. +fn re_sign_unmodified_images( + re_sign_images: &HashSet, + input_files: &mut HashMap, + key_avb: &RsaSigningKey, + block_size: u64, + cancel_signal: &AtomicBool, +) -> Result<()> { + let parent_span = Span::current(); + let input_files = Mutex::new(input_files); + + re_sign_images + .par_iter() + .try_for_each(|name| -> Result<()> { + let _span = debug_span!(parent: &parent_span, "image", name).entered(); + + let mut input_file = input_files.lock().unwrap()[name].clone(); + + if input_file.state == InputFileState::Modified { + // Already re-signed by some other patching operation. + return Ok(()); + } + + let (mut header, footer, image_size) = avb::load_image(&mut input_file.file) + .with_context(|| format!("Failed to load AVB metadata: {name}"))?; + let orig_header = header.clone(); + + if !header.public_key.is_empty() { + header + .set_algo_for_key(key_avb) + .with_context(|| format!("Failed to set signature algorithm: {name}"))?; + header + .sign(key_avb) + .with_context(|| format!("Failed to sign AVB metadata: {name}"))?; + } + + if header == orig_header { + return Ok(()); + } + + if input_file.state == InputFileState::External { + info!("Copying external image for re-signing: {name}"); + + // Need to make a copy so that the user's original file doesn't + // get modified. + let mut temp_file = tempfile::tempfile() + .with_context(|| format!("Failed to create temp file for: {name}"))?; + + input_file + .file + .rewind() + .with_context(|| format!("Failed to rewind file: {name}"))?; + + stream::copy(&mut input_file.file.clone(), &mut temp_file, cancel_signal) + .with_context(|| format!("Failed to copy file: {name}"))?; + + input_file.file = Arc::new(temp_file); + } + + info!("Re-signing image: {name}"); + + let original_image_size = footer + .as_ref() + .map(|f| f.original_image_size) + .unwrap_or_default(); + input_file + .file + .rewind() + .with_context(|| format!("Failed to rewind file: {name}"))?; + input_file + .file + .set_len(original_image_size) + .with_context(|| format!("Failed to truncate file: {name}"))?; + + if let Some(mut footer) = footer { + avb::write_appended_image( + &mut input_file.file, + &header, + &mut footer, + Some(image_size), + ) + } else { + avb::write_root_image(&mut input_file.file, &header, block_size) + } + .with_context(|| format!("Failed to write AVB metadata: {name}"))?; + + input_file.state = InputFileState::Modified; + *input_files.lock().unwrap().get_mut(name).unwrap() = input_file; + + Ok(()) + }) +} + /// Load the specified vbmeta image headers. If an image has a vbmeta footer, /// then an error is returned because the vbmeta patching logic only ever writes /// root vbmeta images. @@ -723,7 +823,7 @@ fn update_vbmeta_headers( // // The root vbmeta image is always signed because it is possible to // invoke avbroot is a way that no modifications are made (rootless + - // skipping recovery otacerts.zip patch). We still want the result to be + // skipping both otacerts.zip patches). We still want the result to be // bootable. if parent_header != &orig_parent_header || name == "vbmeta" { parent_header @@ -735,13 +835,9 @@ fn update_vbmeta_headers( let mut writer = tempfile::tempfile() .with_context(|| format!("Failed to create temp file for: {name}"))?; - parent_header - .to_writer(&mut writer) + avb::write_root_image(&mut writer, parent_header, block_size) .with_context(|| format!("Failed to write vbmeta image: {name}"))?; - padding::write_zeros(&mut writer, block_size) - .with_context(|| format!("Failed to write vbmeta padding: {name}"))?; - let input_file = images.get_mut(name).unwrap(); input_file.file = Arc::new(writer); input_file.state = InputFileState::Modified; @@ -909,6 +1005,7 @@ fn patch_ota_payload( payload: &(dyn ReadAt + Sync), writer: impl Write, external_images: &HashMap, + re_sign_images: &HashSet, boot_patchers: &[Box], skip_system_ota_cert: bool, clear_vbmeta_flags: bool, @@ -949,6 +1046,12 @@ fn patch_ota_payload( } } + for name in re_sign_images { + if !all_partitions.contains(name.as_str()) { + bail!("Cannot re-sign non-existent {name} partition"); + } + } + let required_images = get_required_images(&header.manifest, required_flags); let vbmeta_images = required_images .iter() @@ -970,6 +1073,7 @@ fn patch_ota_payload( payload, &required_images, external_images, + re_sign_images, &header, cancel_signal, )?; @@ -994,6 +1098,14 @@ fn patch_ota_payload( )?) }; + re_sign_unmodified_images( + re_sign_images, + &mut input_files, + key_avb, + header.manifest.block_size().into(), + cancel_signal, + )?; + let mut vbmeta_headers = load_vbmeta_images(&mut input_files, &vbmeta_images)?; ensure_partitions_protected(&required_images, &vbmeta_headers)?; @@ -1129,6 +1241,7 @@ fn patch_ota_zip( zip_reader: &ZipArchive>, zip_writer: &mut ZipArchiveWriter, external_images: &HashMap, + re_sign_images: &HashSet, boot_patchers: &[Box], skip_system_ota_cert: bool, clear_vbmeta_flags: bool, @@ -1295,6 +1408,7 @@ fn patch_ota_zip( &payload_reader, &mut data_writer, external_images, + re_sign_images, boot_patchers, skip_system_ota_cert, clear_vbmeta_flags, @@ -1519,6 +1633,8 @@ pub fn patch_subcommand(cli: &PatchCli, cancel_signal: &AtomicBool) -> Result<() external_images.insert(name.to_owned(), path.to_owned()); } + let re_sign_images = cli.re_sign.iter().cloned().collect::>(); + let mut boot_patchers = Vec::>::new(); if let Some(magisk) = &cli.root.magisk { @@ -1582,6 +1698,7 @@ pub fn patch_subcommand(cli: &PatchCli, cancel_signal: &AtomicBool) -> Result<() &zip_reader, &mut zip_writer, &external_images, + &re_sign_images, &boot_patchers, cli.skip_system_ota_cert, cli.clear_vbmeta_flags, @@ -2276,6 +2393,19 @@ pub struct PatchCli { )] pub replace: Vec, + /// Re-sign unmodified partition image. + /// + /// Modified partition images are always re-signed. This option forces the + /// specified image to be re-signed, even if it was unmodified. When used + /// with --replace, a copy of the replacement image will be re-signed, not + /// the original file. + /// + /// This option only has an effect if the specified partition image is + /// originally signed. If it is unsigned because the AVB descriptors are + /// embedded in another signed vbmeta image, then this option is a no-op. + #[arg(long, value_names = ["PARTITION"], help_heading = HEADING_PATH)] + pub re_sign: Vec, + #[command(flatten)] pub root: RootGroup, diff --git a/e2e/e2e.toml b/e2e/e2e.toml index 64b59c0..b157f0f 100644 --- a/e2e/e2e.toml +++ b/e2e/e2e.toml @@ -53,11 +53,11 @@ data.ramdisks = [["otacerts", "first_stage", "dsu_key_dir"]] [profile.pixel_v4_gki.hashes_streaming] original = "ea96196191e3a4133db4aff45d47aa3468514e29e0a724faac8081cbf4adf808" -patched = "179923431ff94148186d8b0a457636c8a60057d047d55a4fc4da3a162e73eeed" +patched = "c073cbb271099aaf338a8c70f3565ec70db47a87b4c9bb3efffd96ccee1a0c27" [profile.pixel_v4_gki.hashes_seekable] original = "f6615ae355eba38689d24aa535981d09175d4832e7c65c04cdd89aa95d21f09d" -patched = "b006858b78cddef0e3db1d77454cd945111ce15708121e3a483ff711abedec8b" +patched = "65ce91a95574a308ffc5279696efe7f6f7e4e847795dc9ae43d9ffd012a7ce3f" # Google Pixel 6a # What's unique: boot (boot v4, no ramdisk) + vendor_boot (vendor v4, 2 ramdisks) diff --git a/e2e/src/main.rs b/e2e/src/main.rs index 38ca317..e19b69f 100644 --- a/e2e/src/main.rs +++ b/e2e/src/main.rs @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2023-2025 Andrew Gunnerson +// SPDX-FileCopyrightText: 2023-2026 Andrew Gunnerson // SPDX-FileCopyrightText: 2023 Pascal Roeleven // SPDX-License-Identifier: GPL-3.0-only @@ -1067,6 +1067,8 @@ fn patch_image( OsStr::new("--replace"), OsStr::new("system"), system_image_file.as_os_str(), + OsStr::new("--re-sign"), + OsStr::new("boot"), OsStr::new("--key-avb"), avb_key_file.as_os_str(), OsStr::new("--pass-avb-file"),