From 224e0b11a763fc1586d6722636faf1cad300d635 Mon Sep 17 00:00:00 2001 From: OO <16506343+miner7222@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:17:41 +0900 Subject: [PATCH] feat(gui): Add an optional testkey bootloader step to Firmware Flash Lenovo-key firmware now resolves its signing key and model at the folder step, and a supplied testkey abl.elf lets the run re-sign without probing the device's own bootloader key. Co-authored-by: Claude (claude-opus-5) Co-authored-by: Codex (gpt-5.6-sol) --- Cargo.lock | 1 + Cargo.toml | 3 + crates/ltbox-gui/lang/en.json | 15 + crates/ltbox-gui/lang/ja.json | 15 + crates/ltbox-gui/lang/ko.json | 15 + crates/ltbox-gui/lang/ru.json | 15 + crates/ltbox-gui/lang/zh.json | 15 + crates/ltbox-gui/src/main.rs | 16 +- crates/ltbox-gui/src/message.rs | 13 +- crates/ltbox-gui/src/model/wizard.rs | 266 ++++++++- crates/ltbox-gui/src/update/flash.rs | 134 ++++- crates/ltbox-gui/src/update/mod.rs | 2 +- crates/ltbox-gui/src/view/chrome.rs | 3 + crates/ltbox-gui/src/view/flash.rs | 168 +++++- crates/ltbox-gui/src/view/popups.rs | 58 ++ crates/ltbox-gui/src/workers/flash/full.rs | 151 +++-- crates/ltbox-gui/src/workers/flash/mod.rs | 31 +- crates/ltbox-patch/Cargo.toml | 1 + crates/ltbox-patch/src/abl_key.rs | 634 +++++++++++++++++++++ crates/ltbox-patch/src/lib.rs | 1 + 20 files changed, 1414 insertions(+), 143 deletions(-) create mode 100644 crates/ltbox-patch/src/abl_key.rs diff --git a/Cargo.lock b/Cargo.lock index 2dbc68db..c9d07bf0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3284,6 +3284,7 @@ dependencies = [ "getrandom 0.4.3", "kptools", "ltbox-core", + "lzma-rust2", "magiskboot", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 4d1a9188..b413cdb8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,6 +48,9 @@ tar = { version = "0.4", default-features = false } # Zstd (decompress ported-ROM `*.img.zst` payloads, e.g. `super.img.zst`). # Already linked transitively via kptools, so this adds no new C build surface. zstd = "0.13" +# LZMA-alone decoding for compressed ABL LinuxLoader images. This is already +# present transitively through kptools/magiskboot. +lzma-rust2 = "0.16.5" # UTF-8-guaranteed paths. `camino::Utf8PathBuf` is a drop-in `PathBuf` # replacement that rejects non-UTF-8 byte sequences at construction time, diff --git a/crates/ltbox-gui/lang/en.json b/crates/ltbox-gui/lang/en.json index 4c821e7b..5b2e14b5 100644 --- a/crates/ltbox-gui/lang/en.json +++ b/crates/ltbox-gui/lang/en.json @@ -347,6 +347,11 @@ "family_skroot_desc": "SELinux-preserving stealth mode", "firmware_menu_ota": "OTA Package", "firmware_menu_qfil": "QFIL Firmware", + "flash_bootloader_analyzing": "Analyzing…", + "flash_bootloader_empty": "No bootloader selected", + "flash_bootloader_select": "Select bootloader file", + "flash_bootloader_subtitle": "(Optional) When flashing firmware signed with a new key, you can supply a bootloader that still trusts the existing key so the firmware can be re-signed and flashed.", + "flash_bootloader_title": "Select Bootloader", "flash_confirm_country": "Country Code Change", "flash_confirm_country_skip": "Do not change", "flash_confirm_data": "Data Mode", @@ -370,10 +375,19 @@ "flash_confirm_warning_wipe": "⚠ Data wipe: back up anything you need before starting.", "flash_data_subtitle": "Keep or erase user data during flash?", "flash_data_title": "Select Data Mode", + "flash_firmware_identity_error": "Could not inspect vbmeta_system.img: {error}", + "flash_firmware_identity_error_title": "Firmware Inspection Failed", + "flash_firmware_identity_key": "Signing Key", + "flash_firmware_identity_model": "Firmware Model", + "flash_firmware_identity_title": "Firmware Information", + "flash_firmware_model_unknown": "Unknown model", "flash_folder_desc": "Folder containing firmware images and rawprogram XML", "flash_folder_placeholder": "No folder selected", "flash_folder_subtitle": "Select the extracted QFIL firmware folder.", "flash_folder_title": "Select Firmware Folder", + "flash_key_lenovo": "Lenovo key", + "flash_key_testkey": "Test key", + "flash_key_unknown": "Unknown", "flash_loader_browse": "Select loader file", "flash_loader_change": "Change loader", "flash_loader_missing": "This firmware folder has no EDL loader — provide one to continue.", @@ -399,6 +413,7 @@ "flash_serial_prompt_skip": "Select manually", "flash_serial_prompt_subtitle": "The serial couldn't be read from the device. Enter it to auto-detect the region.", "flash_serial_prompt_title": "Enter Serial Number", + "flash_step_bootloader": "Bootloader", "flash_step_confirm": "Confirm", "flash_step_data": "Data", "flash_step_flash": "Flash", diff --git a/crates/ltbox-gui/lang/ja.json b/crates/ltbox-gui/lang/ja.json index b8efb135..24c1c396 100644 --- a/crates/ltbox-gui/lang/ja.json +++ b/crates/ltbox-gui/lang/ja.json @@ -347,6 +347,11 @@ "family_skroot_desc": "SELinux に触れない隠蔽方式", "firmware_menu_ota": "OTA パッケージ", "firmware_menu_qfil": "QFIL ファームウェア", + "flash_bootloader_analyzing": "確認中…", + "flash_bootloader_empty": "ファイルが選択されていません", + "flash_bootloader_select": "ブートローダーファイルを選択", + "flash_bootloader_subtitle": "(任意)新しいキーで署名されたファームウェアをフラッシュするとき、既存のキーを信じるブートローダーを指定すると、ファームウェアを再署名してフラッシュできます。", + "flash_bootloader_title": "ブートローダーの確認", "flash_confirm_country": "国コード変更", "flash_confirm_country_skip": "変更しない", "flash_confirm_data": "データモード", @@ -370,10 +375,19 @@ "flash_confirm_warning_wipe": "⚠ データを消去します: 開始する前に必要なものをすべてバックアップしてください。", "flash_data_subtitle": "フラッシュ中にユーザーデータを保持しますか、それとも消去しますか?", "flash_data_title": "データモードを選択", + "flash_firmware_identity_error": "vbmeta_system.img の確認に失敗: {error}", + "flash_firmware_identity_error_title": "ファームウェアの確認に失敗", + "flash_firmware_identity_key": "署名キー", + "flash_firmware_identity_model": "ファームウェアモデル", + "flash_firmware_identity_title": "ファームウェア情報", + "flash_firmware_model_unknown": "モデル不明", "flash_folder_desc": "ファームウェアイメージと rawprogram XML を含むフォルダ", "flash_folder_placeholder": "フォルダが選択されていません", "flash_folder_subtitle": "展開済みの QFIL ファームウェアフォルダを選択してください。", "flash_folder_title": "ファームウェアフォルダを選択", + "flash_key_lenovo": "Lenovo キー", + "flash_key_testkey": "テストキー", + "flash_key_unknown": "不明", "flash_loader_browse": "ローダーファイルを選択", "flash_loader_change": "ローダーを変更", "flash_loader_missing": "このファームウェアフォルダに EDL ローダーがありません — 続行するにはローダーを指定してください。", @@ -399,6 +413,7 @@ "flash_serial_prompt_skip": "手動で選択", "flash_serial_prompt_subtitle": "デバイスからシリアル番号を読み取れませんでした。リージョンを自動判別するには入力してください。", "flash_serial_prompt_title": "シリアル番号を入力", + "flash_step_bootloader": "ブートローダー", "flash_step_confirm": "確認", "flash_step_data": "データ", "flash_step_flash": "フラッシュ", diff --git a/crates/ltbox-gui/lang/ko.json b/crates/ltbox-gui/lang/ko.json index f3448e20..5f82df86 100644 --- a/crates/ltbox-gui/lang/ko.json +++ b/crates/ltbox-gui/lang/ko.json @@ -347,6 +347,11 @@ "family_skroot_desc": "SELinux 무변경 숨김 방식", "firmware_menu_ota": "OTA 패키지", "firmware_menu_qfil": "QFIL 펌웨어", + "flash_bootloader_analyzing": "검사 중…", + "flash_bootloader_empty": "파일 선택 없음", + "flash_bootloader_select": "부트로더 파일 선택", + "flash_bootloader_subtitle": "(선택사항) 새 키로 서명된 펌웨어를 플래싱할 때 기존 키를 사용하는 부트로더를 선택하면 펌웨어를 다시 서명해 플래싱할 수 있습니다.", + "flash_bootloader_title": "부트로더 선택", "flash_confirm_country": "국가 코드 변경", "flash_confirm_country_skip": "변경 안 함", "flash_confirm_data": "데이터 모드", @@ -370,10 +375,19 @@ "flash_confirm_warning_wipe": "⚠ 데이터 초기화: 시작하기 전에 필요한 자료를 백업하세요.", "flash_data_subtitle": "플래싱 중 사용자 데이터를 유지할지 지울지 고르세요.", "flash_data_title": "데이터 모드 선택", + "flash_firmware_identity_error": "vbmeta_system.img 검사 실패: {error}", + "flash_firmware_identity_error_title": "펌웨어 검사 실패", + "flash_firmware_identity_key": "서명 키", + "flash_firmware_identity_model": "펌웨어 모델", + "flash_firmware_identity_title": "펌웨어 정보", + "flash_firmware_model_unknown": "모델 알 수 없음", "flash_folder_desc": "펌웨어 이미지와 rawprogram XML이 들어있는 폴더", "flash_folder_placeholder": "선택된 폴더 없음", "flash_folder_subtitle": "압축 해제된 QFIL 펌웨어 폴더를 선택하세요.", "flash_folder_title": "펌웨어 폴더 선택", + "flash_key_lenovo": "레노버 키", + "flash_key_testkey": "테스트 키", + "flash_key_unknown": "알 수 없음", "flash_loader_browse": "로더 파일 선택", "flash_loader_change": "로더 변경", "flash_loader_missing": "이 펌웨어 폴더에 EDL 로더가 없습니다 — 계속하려면 로더를 지정하세요.", @@ -399,6 +413,7 @@ "flash_serial_prompt_skip": "직접 선택", "flash_serial_prompt_subtitle": "기기에서 일련번호를 읽지 못했습니다. 지역 자동 선택을 위해 직접 입력하세요.", "flash_serial_prompt_title": "일련번호 입력", + "flash_step_bootloader": "부트로더", "flash_step_confirm": "확인", "flash_step_data": "데이터", "flash_step_flash": "플래싱", diff --git a/crates/ltbox-gui/lang/ru.json b/crates/ltbox-gui/lang/ru.json index 44c7eb5d..6de75acf 100644 --- a/crates/ltbox-gui/lang/ru.json +++ b/crates/ltbox-gui/lang/ru.json @@ -347,6 +347,11 @@ "family_skroot_desc": "Скрытый режим без изменений SELinux", "firmware_menu_ota": "OTA-пакет", "firmware_menu_qfil": "Прошивка QFIL", + "flash_bootloader_analyzing": "Проверка…", + "flash_bootloader_empty": "Файл не выбран", + "flash_bootloader_select": "Выбрать файл загрузчика", + "flash_bootloader_subtitle": "(Необязательно) При прошивке ПО, подписанного новым ключом, можно указать загрузчик, который доверяет прежнему ключу, чтобы переподписать и прошить ПО.", + "flash_bootloader_title": "Проверка загрузчика", "flash_confirm_country": "Изменение кода страны", "flash_confirm_country_skip": "Не изменять", "flash_confirm_data": "Режим данных", @@ -370,10 +375,19 @@ "flash_confirm_warning_wipe": "⚠ Очистка данных: перед началом сделайте резервную копию нужных данных.", "flash_data_subtitle": "Сохранить или стереть пользовательские данные при прошивке?", "flash_data_title": "Режим данных", + "flash_firmware_identity_error": "Не удалось проверить vbmeta_system.img: {error}", + "flash_firmware_identity_error_title": "Ошибка проверки прошивки", + "flash_firmware_identity_key": "Ключ подписи", + "flash_firmware_identity_model": "Модель прошивки", + "flash_firmware_identity_title": "Данные прошивки", + "flash_firmware_model_unknown": "Модель неизвестна", "flash_folder_desc": "Папка с образами прошивки и rawprogram XML", "flash_folder_placeholder": "Папка не выбрана", "flash_folder_subtitle": "Выберите распакованную папку прошивки QFIL.", "flash_folder_title": "Выбор папки прошивки", + "flash_key_lenovo": "Ключ Lenovo", + "flash_key_testkey": "Тестовый ключ", + "flash_key_unknown": "Неизвестно", "flash_loader_browse": "Выбрать файл загрузчика", "flash_loader_change": "Сменить загрузчик", "flash_loader_missing": "В этой папке прошивки нет загрузчика EDL — укажите его, чтобы продолжить.", @@ -399,6 +413,7 @@ "flash_serial_prompt_skip": "Выбрать вручную", "flash_serial_prompt_subtitle": "Серийный номер не удалось считать с устройства. Введите его для автоопределения региона.", "flash_serial_prompt_title": "Введите серийный номер", + "flash_step_bootloader": "Загрузчик", "flash_step_confirm": "Подтверждение", "flash_step_data": "Данные", "flash_step_flash": "Прошивка", diff --git a/crates/ltbox-gui/lang/zh.json b/crates/ltbox-gui/lang/zh.json index 52885ff2..e9f3d7cf 100644 --- a/crates/ltbox-gui/lang/zh.json +++ b/crates/ltbox-gui/lang/zh.json @@ -347,6 +347,11 @@ "family_skroot_desc": "SELinux 零触碰隐藏方案", "firmware_menu_ota": "OTA 包", "firmware_menu_qfil": "QFIL 固件", + "flash_bootloader_analyzing": "正在检查…", + "flash_bootloader_empty": "未选择文件", + "flash_bootloader_select": "选择引导加载程序文件", + "flash_bootloader_subtitle": "(可选)刷写使用新密钥签名的固件时,可以提供仍信任现有密钥的引导加载程序,以便重新签名并刷写固件。", + "flash_bootloader_title": "引导加载程序检查", "flash_confirm_country": "国家码更改", "flash_confirm_country_skip": "不更改", "flash_confirm_data": "数据模式", @@ -370,10 +375,19 @@ "flash_confirm_warning_wipe": "⚠ 清除数据:开始前请备份所需的资料。", "flash_data_subtitle": "刷写时保留还是清除用户数据?", "flash_data_title": "选择数据模式", + "flash_firmware_identity_error": "无法检查 vbmeta_system.img:{error}", + "flash_firmware_identity_error_title": "固件检查失败", + "flash_firmware_identity_key": "签名密钥", + "flash_firmware_identity_model": "固件型号", + "flash_firmware_identity_title": "固件信息", + "flash_firmware_model_unknown": "型号未知", "flash_folder_desc": "包含固件镜像和 rawprogram XML 的文件夹", "flash_folder_placeholder": "未选择文件夹", "flash_folder_subtitle": "选择已解压的 QFIL 固件文件夹。", "flash_folder_title": "选择固件文件夹", + "flash_key_lenovo": "联想密钥", + "flash_key_testkey": "测试密钥", + "flash_key_unknown": "未知", "flash_loader_browse": "选择加载器文件", "flash_loader_change": "更换加载器", "flash_loader_missing": "此固件文件夹没有 EDL 加载器 — 请提供一个以继续。", @@ -399,6 +413,7 @@ "flash_serial_prompt_skip": "手动选择", "flash_serial_prompt_subtitle": "无法从设备读取序列号。请手动输入以自动判断区域。", "flash_serial_prompt_title": "输入序列号", + "flash_step_bootloader": "引导加载程序", "flash_step_confirm": "确认", "flash_step_data": "数据", "flash_step_flash": "刷写", diff --git a/crates/ltbox-gui/src/main.rs b/crates/ltbox-gui/src/main.rs index 4e5e2eb9..5ed13bd2 100644 --- a/crates/ltbox-gui/src/main.rs +++ b/crates/ltbox-gui/src/main.rs @@ -2840,6 +2840,11 @@ impl App { /// pre-fill a configured Settings default loader if it fits the model; the /// folder step otherwise requires the user to pick one before advancing. fn set_flash_firmware_folder(&mut self, path: String) { + let was_after_folder = matches!( + self.flash.current_step(), + FlashStep::Bootloader | FlashStep::Confirm + ); + self.flash.reset_firmware_identity(); // Users frequently pick the extracted firmware ROOT instead of the // `image` folder LTBox flashes; retarget to a direct `image/` child // when one exists so the common mis-selection just works. @@ -2855,11 +2860,9 @@ impl App { self.flash.loader_error = None; self.flash.firmware_folder = Some(path.clone()); self.flash.set_firmware_rollback_indices(&path); - if self.flash.step == 4 - && self.flash.loader_required - && self.flash.loader_override.is_none() + if was_after_folder || (self.flash.loader_required && self.flash.loader_override.is_none()) { - self.flash.step = 3; + self.flash.set_step(FlashStep::Folder); } } @@ -4175,6 +4178,11 @@ mod tests { let mut w = FlashWizard { step: 4, firmware_folder: Some("firmware".to_string()), + firmware_identity: Some(FirmwareIdentity { + key_class: ltbox_patch::key_map::KeyClass::Testkey, + fingerprint: None, + model_token: None, + }), loader_required: true, ..Default::default() }; diff --git a/crates/ltbox-gui/src/message.rs b/crates/ltbox-gui/src/message.rs index ace8598b..7a87ffb6 100644 --- a/crates/ltbox-gui/src/message.rs +++ b/crates/ltbox-gui/src/message.rs @@ -3,9 +3,10 @@ use crate::{ AdvAction, ConfirmField, DataMode, DevicePollResult, DeviceRegion, DumpPartsScanResult, Family, - FlashPartsScanResult, FlashTarget, GpuCellKey, Language, ManualRollbackEditor, NightlySource, - PartsSortColumn, PickerTarget, Provider, RebootTarget, RescueRegion, RollbackSetting, RootMode, - SkrootFlavor, SysUpdateAction, ThemeChoice, ThemeSeed, UnrootType, VerChoice, View, + FirmwareIdentity, FlashPartsScanResult, FlashTarget, GpuCellKey, Language, + ManualRollbackEditor, NightlySource, PartsSortColumn, PickerTarget, Provider, RebootTarget, + RescueRegion, RollbackSetting, RootMode, SkrootFlavor, SysUpdateAction, ThemeChoice, ThemeSeed, + UnrootType, VerChoice, View, }; #[derive(Debug, Clone)] @@ -222,9 +223,15 @@ pub(crate) enum FlashMsg { FlashNext, FlashBack, FlashSelectFolder, + FlashFirmwareIdentityInspected(String, Result), + FlashFirmwareIdentityDialogAction, /// Pick a standalone EDL loader when the firmware folder ships none. FlashSelectLoader, FlashLoaderChosen(Option), + FlashSelectBootloader, + FlashBootloaderChosen(Option), + FlashBootloaderAnalysed(String, ltbox_patch::key_map::KeyClass), + FlashClearBootloader, /// Confirm-step "hidden dropdown": open the option editor for a row. FlashConfirmOpen(ConfirmField), /// Dismiss the confirm-step option editor without a change. diff --git a/crates/ltbox-gui/src/model/wizard.rs b/crates/ltbox-gui/src/model/wizard.rs index b41392fc..450f4e55 100644 --- a/crates/ltbox-gui/src/model/wizard.rs +++ b/crates/ltbox-gui/src/model/wizard.rs @@ -617,6 +617,102 @@ pub(crate) enum ConfirmField { Country, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum FlashStep { + Region, + Target, + Data, + Folder, + Bootloader, + Confirm, + Flash, +} + +impl FlashStep { + pub(crate) fn label_key(self) -> &'static str { + match self { + Self::Region => "flash_step_region", + Self::Target => "flash_step_target", + Self::Data => "flash_step_data", + Self::Folder => "flash_step_folder", + Self::Bootloader => "flash_step_bootloader", + Self::Confirm => "flash_step_confirm", + Self::Flash => "flash_step_flash", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct FirmwareIdentity { + pub(crate) key_class: ltbox_patch::key_map::KeyClass, + pub(crate) fingerprint: Option, + pub(crate) model_token: Option, +} + +impl FirmwareIdentity { + pub(crate) fn from_avb_info(info: <box_patch::avb::AvbImageInfo) -> Self { + let fingerprint = ltbox_patch::avb::build_fingerprint(info); + let model_token = fingerprint.as_deref().and_then(fingerprint_model_token); + Self { + key_class: ltbox_patch::key_map::classify_pubkey(info.public_key_sha1.as_deref()), + fingerprint, + model_token, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum FirmwareIdentityDialog { + Ready, + Failed(String), +} + +fn fingerprint_model_token(fingerprint: &str) -> Option { + fingerprint + .split('/') + .nth(1) + .and_then(|product| product.split('_').next()) + .map(str::trim) + .filter(|token| !token.is_empty()) + .map(str::to_owned) +} + +pub(crate) fn firmware_needs_bootloader_step( + key_class: ltbox_patch::key_map::KeyClass, + fingerprint: Option<&str>, +) -> bool { + key_class == ltbox_patch::key_map::KeyClass::Lenovo + && ![ + "TB323FU", + ltbox_core::model::TB376FC_MODEL, + ltbox_core::model::TB390FU_MODEL, + ] + .into_iter() + .any(|model| { + fingerprint + .is_some_and(|value| ltbox_core::model::fingerprint_model_match(value, model)) + }) +} + +pub(crate) const FLASH_STEPS: &[FlashStep] = &[ + FlashStep::Region, + FlashStep::Target, + FlashStep::Data, + FlashStep::Folder, + FlashStep::Confirm, + FlashStep::Flash, +]; + +const FLASH_STEPS_WITH_BOOTLOADER: &[FlashStep] = &[ + FlashStep::Region, + FlashStep::Target, + FlashStep::Data, + FlashStep::Folder, + FlashStep::Bootloader, + FlashStep::Confirm, + FlashStep::Flash, +]; + #[derive(Default)] pub(crate) struct FlashWizard { pub(crate) step: usize, @@ -636,6 +732,12 @@ pub(crate) struct FlashWizard { /// Reason the last picked loader was rejected (e.g. a standalone `.melf` on /// TB323FU), shown in the folder step. pub(crate) loader_error: Option, + pub(crate) firmware_identity: Option, + pub(crate) firmware_identity_pending: bool, + pub(crate) firmware_identity_dialog: Option, + pub(crate) user_abl_path: Option, + pub(crate) user_abl_key_class: Option, + pub(crate) user_abl_analyzing: bool, } impl FlashWizard { @@ -649,16 +751,51 @@ impl FlashWizard { }; self.firmware_rollback_indices = Some((read("boot.img"), read("vbmeta_system.img"))); } -} -pub(crate) const FLASH_STEPS: &[&str] = &[ - "flash_step_region", - "flash_step_target", - "flash_step_data", - "flash_step_folder", - "flash_step_confirm", - "flash_step_flash", -]; + pub(crate) fn visible_steps(&self) -> &'static [FlashStep] { + if self.firmware_identity.as_ref().is_some_and(|identity| { + firmware_needs_bootloader_step(identity.key_class, identity.fingerprint.as_deref()) + }) { + FLASH_STEPS_WITH_BOOTLOADER + } else { + FLASH_STEPS + } + } + + pub(crate) fn current_step(&self) -> FlashStep { + self.visible_steps() + .get(self.step) + .copied() + .unwrap_or(FlashStep::Flash) + } + + pub(crate) fn set_step(&mut self, step: FlashStep) { + self.step = self + .visible_steps() + .iter() + .position(|candidate| *candidate == step) + .unwrap_or(0); + } + + pub(crate) fn reset_firmware_identity(&mut self) { + self.firmware_identity = None; + self.firmware_identity_pending = false; + self.firmware_identity_dialog = None; + self.user_abl_path = None; + self.user_abl_key_class = None; + self.user_abl_analyzing = false; + } + + pub(crate) fn bootloader_can_next(&self) -> bool { + match self.user_abl_path { + None => true, + Some(_) => { + !self.user_abl_analyzing + && self.user_abl_key_class == Some(ltbox_patch::key_map::KeyClass::Testkey) + } + } + } +} impl Wizard for FlashWizard { fn step(&self) -> usize { @@ -668,23 +805,26 @@ impl Wizard for FlashWizard { &mut self.step } fn step_count(&self) -> usize { - FLASH_STEPS.len() + self.visible_steps().len() } fn can_next(&self) -> bool { - match self.step { - 0 => self.device_region.is_some(), - 1 => self.target.is_some(), - 2 => self.data_mode.is_some(), + match self.current_step() { + FlashStep::Region => self.device_region.is_some(), + FlashStep::Target => self.target.is_some(), + FlashStep::Data => self.data_mode.is_some(), // Folder picked, and — when it ships no loader — a loader provided. - 3 => { + FlashStep::Folder => { self.firmware_folder.is_some() && (!self.loader_required || self.loader_override.is_some()) + && !self.firmware_identity_pending } - 4 => { - self.firmware_folder.is_some() + FlashStep::Bootloader => self.bootloader_can_next(), + FlashStep::Confirm => { + self.firmware_identity.is_some() + && self.firmware_folder.is_some() && (!self.loader_required || self.loader_override.is_some()) } - _ => false, + FlashStep::Flash => false, } } @@ -1954,6 +2094,96 @@ impl AdvWizard { } } +#[cfg(test)] +mod flash_tests { + use super::*; + use ltbox_patch::key_map::KeyClass; + + fn identity(key_class: KeyClass, fingerprint: &str) -> FirmwareIdentity { + FirmwareIdentity { + key_class, + fingerprint: Some(fingerprint.to_string()), + model_token: None, + } + } + + #[test] + fn visible_flash_steps_include_bootloader_only_when_selected_by_gate() { + let mut without = FlashWizard::default(); + assert_eq!( + without.visible_steps(), + &[ + FlashStep::Region, + FlashStep::Target, + FlashStep::Data, + FlashStep::Folder, + FlashStep::Confirm, + FlashStep::Flash, + ] + ); + without.set_step(FlashStep::Folder); + without.next(); + assert_eq!(without.current_step(), FlashStep::Confirm); + without.back(); + assert_eq!(without.current_step(), FlashStep::Folder); + + let mut with = FlashWizard { + firmware_identity: Some(identity( + KeyClass::Lenovo, + "qti/TB320FC/TB320FC:15/build:user/release-keys", + )), + ..FlashWizard::default() + }; + assert_eq!( + with.visible_steps(), + &[ + FlashStep::Region, + FlashStep::Target, + FlashStep::Data, + FlashStep::Folder, + FlashStep::Bootloader, + FlashStep::Confirm, + FlashStep::Flash, + ] + ); + with.set_step(FlashStep::Folder); + with.next(); + assert_eq!(with.current_step(), FlashStep::Bootloader); + with.next(); + assert_eq!(with.current_step(), FlashStep::Confirm); + with.back(); + assert_eq!(with.current_step(), FlashStep::Bootloader); + with.back(); + assert_eq!(with.current_step(), FlashStep::Folder); + } + + #[test] + fn firmware_bootloader_gate_matches_key_and_model_rules() { + let other = "qti/TB320FC/TB320FC:15/build:user/release-keys"; + assert!(firmware_needs_bootloader_step( + KeyClass::Lenovo, + Some(other) + )); + + for model in ["TB323FU", "TB376FC", "TB390FU"] { + let fingerprint = format!("qti/{model}/{model}:15/build:user/release-keys"); + assert!(!firmware_needs_bootloader_step( + KeyClass::Lenovo, + Some(&fingerprint) + )); + } + + assert!(!firmware_needs_bootloader_step( + KeyClass::Testkey, + Some(other) + )); + assert!(!firmware_needs_bootloader_step( + KeyClass::Unknown, + Some(other) + )); + } +} + #[cfg(test)] mod konabess_tests { use super::*; diff --git a/crates/ltbox-gui/src/update/flash.rs b/crates/ltbox-gui/src/update/flash.rs index 776fed0f..5aa76f82 100644 --- a/crates/ltbox-gui/src/update/flash.rs +++ b/crates/ltbox-gui/src/update/flash.rs @@ -101,7 +101,7 @@ impl App { } FlashMsg::FlashNext => { // Data step → build WorkflowConfig; wipe opens country popup. - if self.flash.step == 2 { + if self.flash.current_step() == FlashStep::Data { self.wf_config = WorkflowConfig { modify_region: self.flash.target == Some(FlashTarget::OtherRegion), device_region: self.flash.device_region, @@ -123,7 +123,34 @@ impl App { return Task::none(); } } - if self.flash.step == 4 { + if self.flash.current_step() == FlashStep::Folder { + let Some(folder) = self.flash.firmware_folder.clone() else { + return Task::none(); + }; + self.flash.firmware_identity = None; + self.flash.firmware_identity_pending = true; + self.flash.firmware_identity_dialog = None; + let inspected_folder = folder.clone(); + return Task::perform( + async move { + tokio::task::spawn_blocking(move || { + let image = std::path::Path::new(&folder).join("vbmeta_system.img"); + ltbox_patch::avb::extract_image_avb_info(&image) + .map(|info| FirmwareIdentity::from_avb_info(&info)) + .map_err(|error| error.to_string()) + }) + .await + .unwrap_or_else(|error| Err(error.to_string())) + }, + move |result| { + Message::Flash(FlashMsg::FlashFirmwareIdentityInspected( + inspected_folder, + result, + )) + }, + ); + } + if self.flash.current_step() == FlashStep::Confirm { self.flash.next(); return self.update(Message::Flash(FlashMsg::FlashExecStart)); } @@ -133,17 +160,19 @@ impl App { // entry would fold a prior override into the baseline, so a // Back→Next round trip would hide a change that Start still // applies. The step-2 rebuild and exec/reset clear it again. - if self.flash.step == 4 && self.confirm_baseline.is_none() { + if self.flash.current_step() == FlashStep::Confirm + && self.confirm_baseline.is_none() + { self.confirm_baseline = Some(self.wf_config.clone()); } Task::none() } FlashMsg::FlashBack => { - if self.flash.step == 4 { + if self.flash.current_step() == FlashStep::Confirm { // Leaving confirm only closes any open editor. The baseline // and picked overrides persist, so a Back→Next bounce to the - // folder step keeps power-user changes visible and applied. - // Going deeper to the data step rebuilds `wf_config` (and + // previous input step keeps power-user changes visible and applied. + // Going back through the folder to the data step rebuilds `wf_config` (and // re-opens the country popup on wipe), which resets both. self.confirm_edit_field = None; } @@ -182,6 +211,91 @@ impl App { } Task::none() } + FlashMsg::FlashFirmwareIdentityInspected(folder, result) => { + if self.flash.firmware_folder.as_deref() != Some(folder.as_str()) { + return Task::none(); + } + self.flash.firmware_identity_pending = false; + match result { + Ok(identity) => { + if !firmware_needs_bootloader_step( + identity.key_class, + identity.fingerprint.as_deref(), + ) { + self.flash.user_abl_path = None; + self.flash.user_abl_key_class = None; + self.flash.user_abl_analyzing = false; + } + self.flash.firmware_identity = Some(identity); + self.flash.firmware_identity_dialog = Some(FirmwareIdentityDialog::Ready); + } + Err(error) => { + self.flash.reset_firmware_identity(); + self.flash.firmware_identity_dialog = Some(FirmwareIdentityDialog::Failed( + tr_args!("flash_firmware_identity_error", error = error), + )); + } + } + Task::none() + } + FlashMsg::FlashFirmwareIdentityDialogAction => { + let dialog = self.flash.firmware_identity_dialog.take(); + if matches!(dialog, Some(FirmwareIdentityDialog::Ready)) + && self.flash.current_step() == FlashStep::Folder + { + self.flash.next(); + if self.flash.current_step() == FlashStep::Confirm + && self.confirm_baseline.is_none() + { + self.confirm_baseline = Some(self.wf_config.clone()); + } + } + Task::none() + } + FlashMsg::FlashSelectBootloader => pickers::pick_file_for( + pickers::FilePickSpec::single().with_filter("Bootloader ELF", &["elf"]), + &self.recent_paths, + |path| Message::Flash(FlashMsg::FlashBootloaderChosen(path)), + ), + FlashMsg::FlashBootloaderChosen(path) => { + let Some(path) = path else { + return Task::none(); + }; + self.remember_recent(pickers::PickerKind::File, &path); + self.flash.user_abl_path = Some(path.clone()); + self.flash.user_abl_key_class = None; + self.flash.user_abl_analyzing = true; + let analysed_path = path.clone(); + Task::perform( + async move { + tokio::task::spawn_blocking(move || { + ltbox_patch::abl_key::extract_abl_avb_pubkey_sha1(std::path::Path::new( + &path, + )) + .map(|sha1| ltbox_patch::key_map::classify_pubkey(Some(&sha1))) + .unwrap_or(ltbox_patch::key_map::KeyClass::Unknown) + }) + .await + .unwrap_or(ltbox_patch::key_map::KeyClass::Unknown) + }, + move |key_class| { + Message::Flash(FlashMsg::FlashBootloaderAnalysed(analysed_path, key_class)) + }, + ) + } + FlashMsg::FlashBootloaderAnalysed(path, key_class) => { + if self.flash.user_abl_path.as_deref() == Some(path.as_str()) { + self.flash.user_abl_key_class = Some(key_class); + self.flash.user_abl_analyzing = false; + } + Task::none() + } + FlashMsg::FlashClearBootloader => { + self.flash.user_abl_path = None; + self.flash.user_abl_key_class = None; + self.flash.user_abl_analyzing = false; + Task::none() + } FlashMsg::FlashExecStart => { #[cfg(feature = "demo")] if demo::blocks_flash_execution(self) { @@ -194,6 +308,12 @@ impl App { let device_model = self.device_model.clone(); let fw_folder = self.flash.firmware_folder.clone().unwrap_or_default(); let loader_override = self.flash.loader_override.clone(); + let firmware_identity = self.flash.firmware_identity.clone(); + let user_abl_path = (!self.flash.user_abl_analyzing + && self.flash.user_abl_key_class + == Some(ltbox_patch::key_map::KeyClass::Testkey)) + .then(|| self.flash.user_abl_path.clone()) + .flatten(); // `None` on every setting but Manual, and the worker refuses a // Manual run that has no targets with a message the user can // read. Bailing here instead left the phased op running with @@ -244,6 +364,8 @@ impl App { device_model, fw_folder, loader_override, + firmware_identity, + user_abl_path, rb_mode, manual_indices, ll, diff --git a/crates/ltbox-gui/src/update/mod.rs b/crates/ltbox-gui/src/update/mod.rs index b28fe4dc..90b31a92 100644 --- a/crates/ltbox-gui/src/update/mod.rs +++ b/crates/ltbox-gui/src/update/mod.rs @@ -219,7 +219,7 @@ impl App { self.country_popup_open = false; if self.adv_needs_country { self.adv_needs_country = false; - } else if self.flash.step == 3 + } else if self.flash.current_step() == FlashStep::Folder && matches!(self.wf_config.country_action, CountryAction::Unset) { // Flash wizard — back to Data so user can switch wipe off. diff --git a/crates/ltbox-gui/src/view/chrome.rs b/crates/ltbox-gui/src/view/chrome.rs index 98c6079a..6f2f0f47 100644 --- a/crates/ltbox-gui/src/view/chrome.rs +++ b/crates/ltbox-gui/src/view/chrome.rs @@ -71,6 +71,9 @@ impl App { if let Some(field) = self.confirm_edit_field { layers.push(self.flash_confirm_edit_popup(field)); } + if self.flash.firmware_identity_dialog.is_some() { + layers.push(self.flash_firmware_identity_popup()); + } if self.manual_rollback_editor.is_some() { layers.push(self.manual_rollback_popup_view()); } diff --git a/crates/ltbox-gui/src/view/flash.rs b/crates/ltbox-gui/src/view/flash.rs index 4b9cfbdd..156a25bb 100644 --- a/crates/ltbox-gui/src/view/flash.rs +++ b/crates/ltbox-gui/src/view/flash.rs @@ -10,18 +10,25 @@ impl App { if self.log_popup_open && self.flash.is_in_exec() { return self.log_popup_view(); } - let step_labels: Vec<&str> = FLASH_STEPS.iter().map(|k| self.t(k)).collect(); + let step_labels: Vec<&str> = self + .flash + .visible_steps() + .iter() + .map(|step| self.t(step.label_key())) + .collect(); let step_bar = wizard_step_bar(&step_labels, self.flash.step); - let body = match self.flash.step { - 0 => self.flash_region_step(), - 1 => self.flash_target_step(), - 2 => self.flash_data_step(), - 3 => self.flash_folder_step(), - 4 => self.flash_confirm_step(), - _ => self.flash_exec_step(), + let current_step = self.flash.current_step(); + let body = match current_step { + FlashStep::Region => self.flash_region_step(), + FlashStep::Target => self.flash_target_step(), + FlashStep::Data => self.flash_data_step(), + FlashStep::Folder => self.flash_folder_step(), + FlashStep::Bootloader => self.flash_bootloader_step(), + FlashStep::Confirm => self.flash_confirm_step(), + FlashStep::Flash => self.flash_exec_step(), }; - let nav = if self.flash.step < 5 { - let is_start = self.flash.step == 4; + let nav = if current_step != FlashStep::Flash { + let is_start = current_step == FlashStep::Confirm; let label_owned = if is_start { self.t("btn_start").to_string() } else { @@ -55,13 +62,14 @@ impl App { } fn flash_action_bar(&self) -> Option> { - let (title_key, subtitle_key) = match self.flash.step { - 0 => ("flash_region_title", Some("flash_region_subtitle")), - 1 => ("flash_target_title", Some("flash_target_subtitle")), - 2 => ("flash_data_title", Some("flash_data_subtitle")), - 3 => ("flash_folder_title", Some("flash_folder_subtitle")), - 4 => ("flash_confirm_title", Some("flash_confirm_subtitle")), - _ => return Some(self.exec_action_bar()), + let (title_key, subtitle_key) = match self.flash.current_step() { + FlashStep::Region => ("flash_region_title", Some("flash_region_subtitle")), + FlashStep::Target => ("flash_target_title", Some("flash_target_subtitle")), + FlashStep::Data => ("flash_data_title", Some("flash_data_subtitle")), + FlashStep::Folder => ("flash_folder_title", Some("flash_folder_subtitle")), + FlashStep::Bootloader => ("flash_bootloader_title", Some("flash_bootloader_subtitle")), + FlashStep::Confirm => ("flash_confirm_title", Some("flash_confirm_subtitle")), + FlashStep::Flash => return Some(self.exec_action_bar()), }; Some(wizard_action_bar( self.t(title_key).to_string(), @@ -356,6 +364,132 @@ impl App { .into() } + pub(crate) fn flash_bootloader_step(&self) -> Element<'_, Message> { + let d = self.density(); + let selected = self.flash.user_abl_path.is_some(); + let analyzing = self.flash.user_abl_analyzing; + let mut browse = button( + container( + column![ + text(self.t("flash_bootloader_select").to_string()) + .size(d.text(14.0)) + .center(), + text("abl.elf") + .size(d.text(11.0)) + .style(muted_style) + .center(), + ] + .spacing(d.space(6.0)) + .width(Length::Fill) + .align_x(iced::Alignment::Center), + ) + .padding(d.padding(20.0, 24.0)) + .width(Length::Fixed(d.width(280.0))) + .style(move |theme: &Theme| sel_card_style(theme, selected)), + ) + .padding(0) + .style(move |theme: &Theme, status| { + if analyzing { + let palette = pal_of(theme); + button::Style { + background: Some(palette.surface_container.into()), + text_color: with_alpha(palette.on_surface, 0.38), + border: iced::Border { + radius: theme::shape::LG.into(), + ..Default::default() + }, + ..Default::default() + } + } else { + sel_card_btn_style(theme, status, selected) + } + }); + if !analyzing { + browse = browse.on_press(Message::Flash(FlashMsg::FlashSelectBootloader)); + } + + let clear_slot: Element<'_, Message> = if selected { + // Same control as the Root wizard's KPM remove button: the shared + // lucide `minus` glyph in a neutral circular icon button. + m3_icon_button(icon::kpm_remove(), d.image(18.0), |theme, status| { + let palette = pal_of(theme); + button::Style { + background: Some( + with_alpha(palette.on_surface, 0.10 + theme::state_alpha(status)).into(), + ), + text_color: palette.on_surface, + border: iced::Border { + radius: theme::shape::FULL.into(), + ..Default::default() + }, + ..Default::default() + } + }) + .on_press(Message::Flash(FlashMsg::FlashClearBootloader)) + .into() + } else { + iced::widget::Space::new() + .width(Length::Fixed(M3_ICON_BUTTON_SIZE)) + .height(Length::Fixed(M3_ICON_BUTTON_SIZE)) + .into() + }; + let picker_row = row![ + iced::widget::Space::new() + .width(Length::Fixed(M3_ICON_BUTTON_SIZE)) + .height(Length::Fixed(M3_ICON_BUTTON_SIZE)), + browse, + clear_slot, + ] + .spacing(d.space(8.0)) + .align_y(iced::Alignment::Center); + + let verdict_key = if !selected { + "flash_bootloader_empty" + } else if analyzing { + "flash_bootloader_analyzing" + } else { + match self.flash.user_abl_key_class { + Some(ltbox_patch::key_map::KeyClass::Testkey) => "flash_key_testkey", + Some(ltbox_patch::key_map::KeyClass::Lenovo) => "flash_key_lenovo", + Some(ltbox_patch::key_map::KeyClass::Unknown) | None => "flash_key_unknown", + } + }; + let valid = self.flash.user_abl_key_class == Some(ltbox_patch::key_map::KeyClass::Testkey) + && !analyzing; + let verdict = text(self.t(verdict_key).to_string()) + .size(d.text(13.0)) + .style(move |theme: &Theme| iced::widget::text::Style { + color: Some(if valid { + pal_of(theme).success + } else if selected && !analyzing { + pal_of(theme).error + } else { + pal_of(theme).outline + }), + }); + + let mut content = column![picker_row, verdict] + .spacing(d.space(10.0)) + .align_x(iced::Alignment::Center); + if let Some(path) = &self.flash.user_abl_path { + content = content.push( + text(path.clone()) + .size(d.text(11.0)) + .style(muted_style) + .center() + .wrapping(iced::widget::text::Wrapping::WordOrGlyph), + ); + } + + container(content) + .padding(d.space(28.0)) + .width(Length::Fill) + .height(Length::Fill) + .center_x(Length::Fill) + .center_y(Length::Fill) + .into() + } + pub(crate) fn flash_confirm_step(&self) -> Element<'_, Message> { let d = self.density(); let dash = "—".to_string(); diff --git a/crates/ltbox-gui/src/view/popups.rs b/crates/ltbox-gui/src/view/popups.rs index 2f70ad38..6f34fe56 100644 --- a/crates/ltbox-gui/src/view/popups.rs +++ b/crates/ltbox-gui/src/view/popups.rs @@ -1280,6 +1280,64 @@ impl App { m3_dialog(popup_content) } + pub(crate) fn flash_firmware_identity_popup(&self) -> Element<'_, Message> { + let dialog = self + .flash + .firmware_identity_dialog + .as_ref() + .expect("firmware identity dialog must be open"); + let ready = matches!(dialog, FirmwareIdentityDialog::Ready); + let title_key = if ready { + "flash_firmware_identity_title" + } else { + "flash_firmware_identity_error_title" + }; + + let details: Element<'_, Message> = match dialog { + FirmwareIdentityDialog::Ready => { + let identity = self.flash.firmware_identity.as_ref(); + let key_class = identity + .map(|value| value.key_class) + .unwrap_or(ltbox_patch::key_map::KeyClass::Unknown); + let verdict_key = match key_class { + ltbox_patch::key_map::KeyClass::Testkey => "flash_key_testkey", + ltbox_patch::key_map::KeyClass::Lenovo => "flash_key_lenovo", + ltbox_patch::key_map::KeyClass::Unknown => "flash_key_unknown", + }; + let model = identity + .and_then(|value| value.model_token.as_deref()) + .unwrap_or_else(|| self.t("flash_firmware_model_unknown")); + column![ + info_kv_center(self.t("flash_firmware_identity_key"), self.t(verdict_key),), + info_kv_center(self.t("flash_firmware_identity_model"), model), + ] + .spacing(8) + .into() + } + FirmwareIdentityDialog::Failed(error) => text(error.clone()) + .size(13) + .wrapping(iced::widget::text::Wrapping::WordOrGlyph) + .into(), + }; + + let action_label = if ready { "btn_next" } else { "btn_close" }; + let popup_content: Element<'_, Message> = column![ + text(self.t(title_key).to_string()).size(16), + widget::rule::horizontal(1), + details, + row![ + Space::new().width(Length::Fill), + m3_filled_button(self.t(action_label).to_string()) + .on_press(Message::Flash(FlashMsg::FlashFirmwareIdentityDialogAction,)), + ], + ] + .spacing(12) + .padding(20) + .width(360) + .into(); + m3_dialog(popup_content) + } + pub(crate) fn rescue_region_popup_view(&self) -> Element<'_, Message> { let mk_option = |region: RescueRegion, desc_key: &'static str| { let label = self.t(region.label_key()).to_string(); diff --git a/crates/ltbox-gui/src/workers/flash/full.rs b/crates/ltbox-gui/src/workers/flash/full.rs index 8046ca67..839acfa3 100644 --- a/crates/ltbox-gui/src/workers/flash/full.rs +++ b/crates/ltbox-gui/src/workers/flash/full.rs @@ -8,6 +8,8 @@ pub(crate) fn flash_worker( mut device_model: String, fw_folder: String, loader_override: Option, + firmware_identity: Option, + user_abl_path: Option, mut rb_mode: ltbox_patch::rollback::RollbackMode, manual_rollback_indices: Option, ll: LiveLabels, @@ -17,6 +19,13 @@ pub(crate) fn flash_worker( let edl_start = matches!(conn, ConnectionStatus::Edl); let started_in_fastboot = matches!(conn, ConnectionStatus::Fastboot); let fw_dir = std::path::Path::new(&fw_folder); + let (fw_key_class, firmware_fingerprint) = firmware_identity + .map(|identity| (identity.key_class, identity.fingerprint)) + .unwrap_or(( + ltbox_patch::key_map::KeyClass::Unknown, + Option::::None, + )); + let user_abl = user_abl_path.map(std::path::PathBuf::from); // Phase 1/9 — Validate firmware inputs. live!(log, "[Flash] {}", phases.marker(1)); @@ -211,11 +220,9 @@ pub(crate) fn flash_worker( // 3. Scan firmware folder let vendor_boot = fw_dir.join("vendor_boot.img"); let vbmeta = fw_dir.join("vbmeta.img"); - let vbmeta_system = fw_dir.join("vbmeta_system.img"); let boot = fw_dir.join("boot.img"); let has_vendor_boot = vendor_boot.exists(); let has_vbmeta = vbmeta.exists(); - let has_vbmeta_system = vbmeta_system.exists(); let has_boot = boot.exists(); let found = ltbox_core::i18n::tr("live_status_found"); let not_found = ltbox_core::i18n::tr("live_status_not_found"); @@ -265,69 +272,42 @@ pub(crate) fn flash_worker( ); } - // Cross-check the firmware against the probed model before EDL via - // vbmeta_system's build fingerprint (the unified identity source), and retain - // it for SKU gates. - let mut firmware_fingerprint: Option = None; - if has_vbmeta_system { - match ltbox_patch::avb::extract_image_avb_info(&vbmeta_system) { - Ok(info) => { - // Pull the fingerprint up-front so the SKU gate below works on - // EDL-start too — there `device_model` is empty and the validate - // path would skip without populating it. - let fp_prop = ltbox_patch::avb::build_fingerprint(&info); - - if edl_start { - firmware_fingerprint = fp_prop; - } else { - use ltbox_patch::region::{ModelValidation, validate_device_model}; - match validate_device_model(&info, &device_model) { - ModelValidation::Match { fingerprint } => { - ltbox_core::live!( - log, - "[Flash] {}", - ltbox_core::i18n::tr("live_rescue_model_check_ok") - ); - firmware_fingerprint = Some(fingerprint); - } - ModelValidation::Missing => { - ltbox_core::live!( - log, - "[Flash] {}", - ltbox_core::i18n::tr("live_rescue_no_fingerprint_skip") - ); - firmware_fingerprint = fp_prop; - } - ModelValidation::Mismatch { - fingerprint, - device_model, - } => { - ltbox_core::live!( - log, - "[Flash] {}", - tr_args!( - "live_rescue_model_mismatch_abort", - device = device_model, - fingerprint = fingerprint - ) - ); - let err = ltbox_core::i18n::tr("err_flash_model_mismatch_pre_edl"); - reboot_fastboot_to_system_after_pre_edl_abort( - &mut log, - started_in_fastboot, - ); - return Err(err); - } - } - } - } - Err(e) => { + // Cross-check the cached vbmeta_system fingerprint against the probed model + // before EDL. Folder-step inspection is the single firmware identity source. + if !edl_start { + match firmware_fingerprint.as_deref() { + None => { ltbox_core::live!( log, "[Flash] {}", - tr_args!("live_rescue_avb_inspect_skip", error = e.to_string()) + ltbox_core::i18n::tr("live_rescue_no_fingerprint_skip") ); } + Some(fingerprint) => { + let normalized_model = device_model.replace(' ', ""); + if normalized_model.is_empty() + || ltbox_core::model::fingerprint_model_match(fingerprint, &normalized_model) + { + ltbox_core::live!( + log, + "[Flash] {}", + ltbox_core::i18n::tr("live_rescue_model_check_ok") + ); + } else { + ltbox_core::live!( + log, + "[Flash] {}", + tr_args!( + "live_rescue_model_mismatch_abort", + device = normalized_model, + fingerprint = fingerprint + ) + ); + let err = ltbox_core::i18n::tr("err_flash_model_mismatch_pre_edl"); + reboot_fastboot_to_system_after_pre_edl_abort(&mut log, started_in_fastboot); + return Err(err); + } + } } } @@ -499,15 +479,11 @@ pub(crate) fn flash_worker( ); // AVB root-of-trust pre-check (before region conversion, which only re-signs - // via testkeys in KEY_MAP). Classify the firmware via vbmeta_system's pubkey - // (the unified key source): an `Unknown` key aborts; a Lenovo-key + // via testkeys in KEY_MAP). Use the folder step's cached vbmeta_system key + // class (the unified key source): an `Unknown` key aborts; a Lenovo-key // firmware aborts on cross-region for now (same-region Lenovo-key is handled // after EDL opens; cross-region re-sign is a separate change). TB323FU has its // own region path (efisp GBL) and is exempt here. - let fw_key_class = match ltbox_patch::avb::extract_image_avb_info(&vbmeta_system) { - Ok(info) => ltbox_patch::key_map::classify_pubkey(info.public_key_sha1.as_deref()), - Err(_) => ltbox_patch::key_map::KeyClass::Unknown, - }; if fw_key_class == ltbox_patch::key_map::KeyClass::Unknown { ltbox_core::live!( log, @@ -875,8 +851,8 @@ pub(crate) fn flash_worker( } // Stage ARB copies; flash them after rawprogram. let mut arb_patched: Vec<(String, u8, std::path::PathBuf)> = Vec::new(); - // abl (bootloader) backup to overlay-restore onto abl_a after the flash — - // set only for the testkey-device + Lenovo-key-firmware re-sign case below. + // Bootloader to overlay onto abl_a after the flash: either the device backup + // or the user-supplied testkey ABL selected by the wizard. let mut abl_restore: Option<(u8, std::path::PathBuf)> = None; // AVB root-of-trust gate (device side). The firmware vbmeta was already @@ -904,7 +880,8 @@ pub(crate) fn flash_worker( return Err(e); } }; - match lenovo_firmware_device_policy(dev.class) { + let policy = lenovo_firmware_device_policy(dev.class, user_abl.is_some()); + match policy { LenovoFirmwareDevicePolicy::AbortUnknown => { if edl_start { let _ = session.reset_to_edl(&mut log); @@ -957,8 +934,8 @@ pub(crate) fn flash_worker( rb_mode = ltbox_patch::rollback::RollbackMode::Off; } LenovoFirmwareDevicePolicy::ResignTestkey => { - // Testkey device + Lenovo-key firmware: re-sign the install to the - // RSA-4096 testkey root and preserve the device's own abl. The + // Testkey device (or a user-supplied testkey ABL) + Lenovo-key firmware: + // re-sign the install to the RSA-4096 testkey root. The // vbmeta_system signer may itself be RSA-2048; it still indicates // a testkey-class device whose root vbmeta trusts this re-sign. // The firmware's abl would re-root the chain to the Lenovo key and @@ -1046,15 +1023,19 @@ pub(crate) fn flash_worker( overlays.insert(at, ("vendor_boot_a".to_string(), lun, vb)); } arb_patched = overlays; - match backup_device_abl(&mut session, dev.slot, &arb_work_dir, &mut log) { - Ok(backup) => abl_restore = Some(backup), - Err(e) => { - if edl_start { - let _ = session.reset_to_edl(&mut log); - } else { - let _ = session.reset(&mut log); + if let Some(path) = &user_abl { + abl_restore = Some((4, path.clone())); + } else { + match backup_device_abl(&mut session, dev.slot, &arb_work_dir, &mut log) { + Ok(backup) => abl_restore = Some(backup), + Err(e) => { + if edl_start { + let _ = session.reset_to_edl(&mut log); + } else { + let _ = session.reset(&mut log); + } + return Err(e); } - return Err(e); } } rb_mode = ltbox_patch::rollback::RollbackMode::Off; @@ -1498,9 +1479,9 @@ pub(crate) fn flash_worker( patch = patch_xmls.len().to_string() ) ); - // ABL preservation is brick-critical once the firmware's own (Lenovo-key) abl - // can land: if rawprogram or an ARB overlay fails after that point, the - // original testkey abl must still go back, or the device is left with a + // The final ABL overlay is brick-critical once the firmware's own + // (Lenovo-key) abl can land: if rawprogram or an ARB overlay fails after that point, the + // selected testkey abl must still go back, or the device is left with a // Lenovo-key bootloader on a testkey-resigned chain. Restore best-effort on // those error paths (device stays in EDL for retry); the success-path // restore below stays fatal. @@ -1531,9 +1512,9 @@ pub(crate) fn flash_worker( } } - // Restore the device's original bootloader on abl_a (Lenovo-key firmware - // re-signed for a testkey device). The firmware's own abl would re-root the - // chain to the Lenovo key and reject the re-signed images; a failed restore + // Write the selected testkey bootloader on abl_a (the device backup in the + // normal path, or the user's ABL override). The firmware's own abl would + // re-root the chain to the Lenovo key and reject the re-signed images; a failed restore // leaves the device in EDL rather than resetting into that mismatch. if let Some((lun, abl_img)) = &abl_restore { live!( diff --git a/crates/ltbox-gui/src/workers/flash/mod.rs b/crates/ltbox-gui/src/workers/flash/mod.rs index 6c4326da..75623f99 100644 --- a/crates/ltbox-gui/src/workers/flash/mod.rs +++ b/crates/ltbox-gui/src/workers/flash/mod.rs @@ -4,9 +4,9 @@ //! update_flash handler. use crate::{ - ConnectionStatus, CountryPatchProgress, LiveLabels, PhaseReporter, WorkflowConfig, - active_slot_suffix, build_tb323fu_arb_overlays, efisp_asset_suffix, find_firmware_loader, - fingerprint_token_match, is_rollback_protected_model, open_edl_session, + ConnectionStatus, CountryPatchProgress, FirmwareIdentity, LiveLabels, PhaseReporter, + WorkflowConfig, active_slot_suffix, build_tb323fu_arb_overlays, efisp_asset_suffix, + find_firmware_loader, fingerprint_token_match, is_rollback_protected_model, open_edl_session, read_device_rollback_index_via_edl, transition_to_edl, }; use ltbox_core::{live, tr_args}; @@ -63,10 +63,15 @@ enum LenovoFirmwareDevicePolicy { /// Decide how a Lenovo-key firmware should be handled from the device's /// active-slot vbmeta_system key class. vbmeta_system may use either bundled /// testkey size while the root vbmeta still uses testkey_rsa4096, so the exact -/// vbmeta_system key spec must not gate the testkey re-sign path. +/// vbmeta_system key spec must not gate the testkey re-sign path. A user-supplied +/// testkey ABL selects that path regardless of the device key class. fn lenovo_firmware_device_policy( device_vbs_class: ltbox_patch::key_map::KeyClass, + user_abl_supplied: bool, ) -> LenovoFirmwareDevicePolicy { + if user_abl_supplied { + return LenovoFirmwareDevicePolicy::ResignTestkey; + } match device_vbs_class { ltbox_patch::key_map::KeyClass::Unknown => LenovoFirmwareDevicePolicy::AbortUnknown, ltbox_patch::key_map::KeyClass::Lenovo => LenovoFirmwareDevicePolicy::KeepLenovo, @@ -1173,7 +1178,7 @@ mod tests { } #[test] - fn lenovo_firmware_resigns_for_device_with_rsa2048_vbmeta_system() { + fn lenovo_firmware_policy_uses_device_key_unless_user_abl_is_supplied() { let rsa2048 = ltbox_patch::key_map::classify_pubkey(Some("cdbb77177f731920bbe0a0f94f84d9038ae0617d")); let rsa4096 = @@ -1182,20 +1187,28 @@ mod tests { ltbox_patch::key_map::classify_pubkey(Some("8fcb864f11f53ed11284615fb67685522085d3a2")); assert_eq!( - lenovo_firmware_device_policy(rsa2048), + lenovo_firmware_device_policy(rsa2048, false), LenovoFirmwareDevicePolicy::ResignTestkey ); assert_eq!( - lenovo_firmware_device_policy(rsa4096), + lenovo_firmware_device_policy(rsa4096, false), LenovoFirmwareDevicePolicy::ResignTestkey ); assert_eq!( - lenovo_firmware_device_policy(lenovo), + lenovo_firmware_device_policy(lenovo, false), LenovoFirmwareDevicePolicy::KeepLenovo ); assert_eq!( - lenovo_firmware_device_policy(ltbox_patch::key_map::KeyClass::Unknown), + lenovo_firmware_device_policy(ltbox_patch::key_map::KeyClass::Unknown, false), LenovoFirmwareDevicePolicy::AbortUnknown ); + assert_eq!( + lenovo_firmware_device_policy(ltbox_patch::key_map::KeyClass::Lenovo, true), + LenovoFirmwareDevicePolicy::ResignTestkey + ); + assert_eq!( + lenovo_firmware_device_policy(ltbox_patch::key_map::KeyClass::Unknown, true), + LenovoFirmwareDevicePolicy::ResignTestkey + ); } } diff --git a/crates/ltbox-patch/Cargo.toml b/crates/ltbox-patch/Cargo.toml index dbea3844..48f25a58 100644 --- a/crates/ltbox-patch/Cargo.toml +++ b/crates/ltbox-patch/Cargo.toml @@ -19,6 +19,7 @@ digest = { workspace = true } getrandom = { workspace = true } zip = { workspace = true } fs-err = { workspace = true } +lzma-rust2 = { workspace = true } [dev-dependencies] tempfile = "3" diff --git a/crates/ltbox-patch/src/abl_key.rs b/crates/ltbox-patch/src/abl_key.rs new file mode 100644 index 00000000..a840f1c0 --- /dev/null +++ b/crates/ltbox-patch/src/abl_key.rs @@ -0,0 +1,634 @@ +//! Extraction of the AVB public key embedded in Qualcomm ABL images. + +use std::{collections::HashSet, io::Read, path::Path}; + +use ltbox_core::LtboxError; +use lzma_rust2::LzmaReader; +use sha1::{Digest, Sha1}; + +const LZMA_OFFSET: usize = 0x1078; +const MAX_DECOMPRESSED_SIZE: usize = 64 * 1024 * 1024; +const MAX_INPUT_SIZE: usize = MAX_DECOMPRESSED_SIZE * 4; +const RSA_KEY_BITS: [u32; 3] = [2048, 4096, 8192]; + +#[derive(Clone, Debug)] +struct Section { + name: String, + virtual_address: u32, + raw_offset: usize, + raw_size: usize, + executable: bool, +} + +#[derive(Debug)] +struct PeImage { + data: Vec, + sections: Vec
, +} + +impl PeImage { + fn raw_to_rva(&self, raw_offset: usize) -> Option { + self.sections.iter().find_map(|section| { + let end = section.raw_offset.checked_add(section.raw_size)?; + (section.raw_offset <= raw_offset && raw_offset < end).then(|| { + i64::from(section.virtual_address) + (raw_offset - section.raw_offset) as i64 + }) + }) + } + + fn text_ranges(&self) -> Vec<(usize, usize, i64)> { + let named: Vec<_> = self + .sections + .iter() + .filter(|section| section.name == ".text") + .collect(); + let executable: Vec<_> = self + .sections + .iter() + .filter(|section| section.executable) + .collect(); + let selected = if !named.is_empty() { + named + } else if !executable.is_empty() { + executable + } else { + self.sections.iter().collect() + }; + + selected + .into_iter() + .filter_map(|section| { + if section.raw_size == 0 || section.raw_offset >= self.data.len() { + return None; + } + let end = section + .raw_offset + .saturating_add(section.raw_size) + .min(self.data.len()); + Some((section.raw_offset, end, i64::from(section.virtual_address))) + }) + .collect() + } +} + +#[derive(Debug)] +struct KeyBlob { + raw_offset: usize, + blob: Vec, + references: usize, +} + +/// Extract the AVB public-key SHA-1 (lowercase hex) embedded in an ABL ELF. +pub fn extract_abl_avb_pubkey_sha1(path: &Path) -> Result { + // An ABL image is a few hundred KiB. Refuse anything that could not hold a + // decodable payload anyway rather than reading it into memory first. + let size = fs_err::metadata(path)?.len(); + if size > MAX_INPUT_SIZE as u64 { + return Err(LtboxError::Avb(format!( + "ABL image is too large to inspect ({size} bytes)" + ))); + } + let raw = fs_err::read(path)?; + extract_from_bytes(&raw) +} + +fn extract_from_bytes(raw: &[u8]) -> Result { + let pe = load_linuxloader(raw) + .ok_or_else(|| LtboxError::Avb("ABL LinuxLoader EFI not found".to_string()))?; + let mut keys = find_key_blobs(&pe); + let highest = keys + .iter() + .map(|key| key.references) + .max() + .ok_or_else(|| LtboxError::Avb("ABL AVB public-key blob not found".to_string()))?; + keys.retain(|key| key.references == highest); + keys.sort_unstable_by_key(|key| key.raw_offset); + let key = keys + .first() + .ok_or_else(|| LtboxError::Avb("ABL AVB public-key blob not found".to_string()))?; + let digest = Sha1::digest(&key.blob); + let mut fingerprint = String::with_capacity(digest.len() * 2); + const HEX: &[u8; 16] = b"0123456789abcdef"; + for byte in digest { + fingerprint.push(char::from(HEX[usize::from(byte >> 4)])); + fingerprint.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + Ok(fingerprint) +} + +fn read_u16_le(data: &[u8], offset: usize) -> Option { + let bytes: [u8; 2] = data.get(offset..offset.checked_add(2)?)?.try_into().ok()?; + Some(u16::from_le_bytes(bytes)) +} + +fn read_u32_le(data: &[u8], offset: usize) -> Option { + let bytes: [u8; 4] = data.get(offset..offset.checked_add(4)?)?.try_into().ok()?; + Some(u32::from_le_bytes(bytes)) +} + +fn read_u64_le(data: &[u8], offset: usize) -> Option { + let bytes: [u8; 8] = data.get(offset..offset.checked_add(8)?)?.try_into().ok()?; + Some(u64::from_le_bytes(bytes)) +} + +fn read_u32_be(data: &[u8], offset: usize) -> Option { + let bytes: [u8; 4] = data.get(offset..offset.checked_add(4)?)?.try_into().ok()?; + Some(u32::from_be_bytes(bytes)) +} + +fn lzma_decode(stream: &[u8]) -> Option> { + if stream.is_empty() || stream.len() > MAX_INPUT_SIZE { + return None; + } + + let mut spliced = Vec::new(); + let mut attempts = vec![stream]; + if stream.len() >= 5 && stream.first() == Some(&0x5d) { + spliced.reserve(stream.len().checked_add(8)?); + spliced.extend_from_slice(stream.get(..5)?); + spliced.extend_from_slice(&[0xff; 8]); + spliced.extend_from_slice(stream.get(5..)?); + attempts.push(&spliced); + } + + for candidate in attempts { + if candidate.len() < 13 { + continue; + } + let declared_size = read_u64_le(candidate, 5)?; + if declared_size != u64::MAX && declared_size > MAX_DECOMPRESSED_SIZE as u64 { + continue; + } + + let mut reader = + match LzmaReader::new_mem_limit(candidate, (MAX_DECOMPRESSED_SIZE / 1024) as u32, None) + { + Ok(reader) => reader, + Err(_) => continue, + }; + let mut decoded = Vec::new(); + match reader + .by_ref() + .take((MAX_DECOMPRESSED_SIZE + 1) as u64) + .read_to_end(&mut decoded) + { + Ok(_) if (64..=MAX_DECOMPRESSED_SIZE).contains(&decoded.len()) => return Some(decoded), + _ => continue, + } + } + None +} + +fn decompressed_layers(raw: &[u8]) -> Vec> { + let mut starts = Vec::new(); + if raw.len() > LZMA_OFFSET { + starts.push(LZMA_OFFSET); + } + for (offset, window) in raw.windows(3).enumerate() { + if window == [0x5d, 0, 0] { + starts.push(offset); + } + } + + let mut seen = HashSet::new(); + starts + .into_iter() + .filter(|start| seen.insert(*start)) + .filter_map(|start| lzma_decode(raw.get(start..)?)) + .filter(|layer| !find_pe_images(layer).is_empty()) + .collect() +} + +fn find_pe_images(data: &[u8]) -> Vec { + let mut images = Vec::new(); + let mut cursor = 0usize; + + while cursor < data.len() { + let Some(relative_mz) = data + .get(cursor..) + .and_then(|tail| tail.windows(2).position(|window| window == b"MZ")) + else { + break; + }; + let Some(mz) = cursor.checked_add(relative_mz) else { + break; + }; + cursor = match mz.checked_add(2) { + Some(next) => next, + None => break, + }; + + let Some(dos_end) = mz.checked_add(0x40) else { + continue; + }; + if dos_end > data.len() { + continue; + } + let Some(e_lfanew) = read_u32_le(data, mz + 0x3c).map(|value| value as usize) else { + continue; + }; + let Some(pe_header) = mz.checked_add(e_lfanew) else { + continue; + }; + let Some(pe_fixed_end) = pe_header.checked_add(24) else { + continue; + }; + if pe_header < mz || pe_fixed_end > data.len() { + continue; + } + if data.get(pe_header..pe_header + 4) != Some(b"PE\0\0".as_slice()) { + continue; + } + + let Some(number_of_sections) = read_u16_le(data, pe_header + 6).map(usize::from) else { + continue; + }; + let Some(optional_size) = read_u16_le(data, pe_header + 20).map(usize::from) else { + continue; + }; + if !(1..=96).contains(&number_of_sections) || optional_size < 0x60 { + continue; + } + let optional = pe_header + 24; + let Some(optional_end) = optional.checked_add(optional_size) else { + continue; + }; + if optional_end > data.len() || !matches!(read_u16_le(data, optional), Some(0x10b | 0x20b)) + { + continue; + } + let section_table = optional_end; + let Some(table_size) = number_of_sections.checked_mul(40) else { + continue; + }; + let Some(table_end) = section_table.checked_add(table_size) else { + continue; + }; + if table_end > data.len() { + continue; + } + + let Some(size_of_headers) = read_u32_le(data, optional + 60).map(|value| value as usize) + else { + continue; + }; + let mut real_size = size_of_headers.max(table_end - mz); + let mut sections = Vec::with_capacity(number_of_sections); + let mut valid = true; + for index in 0..number_of_sections { + let Some(section) = section_table.checked_add(index * 40) else { + valid = false; + break; + }; + let Some(raw_size) = read_u32_le(data, section + 16).map(|value| value as usize) else { + valid = false; + break; + }; + let Some(raw_offset) = read_u32_le(data, section + 20).map(|value| value as usize) + else { + valid = false; + break; + }; + let Some(virtual_address) = read_u32_le(data, section + 12) else { + valid = false; + break; + }; + let Some(characteristics) = read_u32_le(data, section + 36) else { + valid = false; + break; + }; + let Some(raw_end) = raw_offset.checked_add(raw_size) else { + valid = false; + break; + }; + if raw_size != 0 && raw_end > data.len() - mz { + valid = false; + break; + } + real_size = real_size.max(raw_end); + let Some(name_bytes) = data.get(section..section + 8) else { + valid = false; + break; + }; + let name_end = name_bytes + .iter() + .position(|byte| *byte == 0) + .unwrap_or(name_bytes.len()); + sections.push(Section { + name: String::from_utf8_lossy(&name_bytes[..name_end]).into_owned(), + virtual_address, + raw_offset, + raw_size, + executable: characteristics & 0x2000_0000 != 0, + }); + } + let Some(image_end) = mz.checked_add(real_size) else { + continue; + }; + if !valid || real_size == 0 || image_end > data.len() { + continue; + } + let Some(image_data) = data.get(mz..image_end) else { + continue; + }; + images.push(PeImage { + data: image_data.to_vec(), + sections, + }); + } + images +} + +fn load_linuxloader(raw: &[u8]) -> Option { + let images = if raw.starts_with(b"MZ") { + find_pe_images(raw) + } else { + decompressed_layers(raw) + .iter() + .flat_map(|layer| find_pe_images(layer)) + .collect() + }; + images.into_iter().max_by_key(|image| image.data.len()) +} + +fn bit_length_be(value: &[u8]) -> usize { + let Some((index, first)) = value.iter().enumerate().find(|(_, byte)| **byte != 0) else { + return 0; + }; + (value.len() - index - 1) * 8 + (8 - first.leading_zeros() as usize) +} + +fn be_bytes_to_limbs(value: &[u8], limb_count: usize) -> Vec { + let mut limbs = vec![0u32; limb_count]; + for (index, chunk) in value.rchunks(4).enumerate().take(limb_count) { + let mut bytes = [0u8; 4]; + bytes[4 - chunk.len()..].copy_from_slice(chunk); + limbs[index] = u32::from_be_bytes(bytes); + } + limbs +} + +fn limbs_cmp(left: &[u32], right: &[u32]) -> std::cmp::Ordering { + left.iter().rev().cmp(right.iter().rev()) +} + +fn limbs_sub_assign(left: &mut [u32], right: &[u32]) { + let mut borrow = 0u64; + for (left_limb, right_limb) in left.iter_mut().zip(right) { + let subtrahend = u64::from(*right_limb) + borrow; + let minuend = u64::from(*left_limb); + *left_limb = minuend.wrapping_sub(subtrahend) as u32; + borrow = u64::from(minuend < subtrahend); + } +} + +fn montgomery_rr_matches(modulus_be: &[u8], rr_be: &[u8], modulus_bits: usize) -> bool { + let limb_count = modulus_be.len().div_ceil(4); + let modulus = be_bytes_to_limbs(modulus_be, limb_count); + let expected = be_bytes_to_limbs(rr_be, limb_count); + let mut value = vec![0u32; limb_count]; + value[0] = 1; + + for _ in 0..modulus_bits.saturating_mul(2) { + let mut carry = 0u32; + for limb in &mut value { + let next = *limb >> 31; + *limb = (*limb << 1) | carry; + carry = next; + } + if carry != 0 || limbs_cmp(&value, &modulus).is_ge() { + limbs_sub_assign(&mut value, &modulus); + } + } + value == expected +} + +fn decode_key_candidate(data: &[u8], offset: usize, bits: u32) -> Option> { + let modulus_size = usize::try_from(bits / 8).ok()?; + let size = 8usize.checked_add(modulus_size.checked_mul(2)?)?; + let blob = data.get(offset..offset.checked_add(size)?)?; + if read_u32_be(blob, 0)? != bits { + return None; + } + let n0inv = read_u32_be(blob, 4)?; + let modulus_end = 8usize.checked_add(modulus_size)?; + let modulus = blob.get(8..modulus_end)?; + let rr = blob.get(modulus_end..size)?; + let modulus_bits = bit_length_be(modulus); + if !(bits as usize / 2 < modulus_bits && modulus_bits <= bits as usize) + || modulus.last().is_none_or(|byte| byte & 1 == 0) + { + return None; + } + + let low_word = read_u32_be(modulus, modulus_size - 4)?; + let mut inverse = low_word; + for _ in 0..5 { + inverse = inverse.wrapping_mul(2u32.wrapping_sub(low_word.wrapping_mul(inverse))); + } + if n0inv != inverse.wrapping_neg() || !montgomery_rr_matches(modulus, rr, modulus_bits) { + return None; + } + Some(blob.to_vec()) +} + +fn sign_extend(value: u32, bits: u32) -> i64 { + let shift = 64 - bits; + ((i64::from(value)) << shift) >> shift +} + +fn instruction_references(pe: &PeImage, key_start: usize, key_size: usize) -> usize { + let Some(key_rva) = pe.raw_to_rva(key_start) else { + return 0; + }; + let Some(key_end) = key_start.checked_add(key_size) else { + return 0; + }; + let Some(key_rva_end) = key_rva.checked_add(key_size as i64) else { + return 0; + }; + let mut references = 0usize; + + for (raw_start, raw_end, section_rva) in pe.text_ranges() { + let Some(first) = raw_start.checked_add((4 - (raw_start & 3)) & 3) else { + continue; + }; + let mut instruction = first; + while instruction.checked_add(4).is_some_and(|end| end <= raw_end) { + if key_start <= instruction && instruction < key_end { + instruction += 4; + continue; + } + let Some(word) = read_u32_le(&pe.data, instruction) else { + break; + }; + let pc = section_rva + (instruction - raw_start) as i64; + + if word & 0x9f00_0000 == 0x1000_0000 { + let immlo = (word >> 29) & 3; + let immhi = (word >> 5) & 0x7ffff; + let target = pc + sign_extend((immhi << 2) | immlo, 21); + if key_rva <= target && target < key_rva_end { + references += 1; + } + instruction += 4; + continue; + } + + if word & 0x9f00_0000 == 0x9000_0000 { + let immlo = (word >> 29) & 3; + let immhi = (word >> 5) & 0x7ffff; + let page = (pc & !0xfff) + (sign_extend((immhi << 2) | immlo, 21) << 12); + let register = word & 0x1f; + let add_limit = instruction.saturating_add(20).min(raw_end); + let mut add_offset = instruction + 4; + while add_offset < add_limit { + let Some(add) = read_u32_le(&pe.data, add_offset) else { + break; + }; + if add & 0x7f00_0000 == 0x1100_0000 + && add & 0x8000_0000 != 0 + && (add >> 5) & 0x1f == register + { + let mut immediate = i64::from((add >> 10) & 0xfff); + if (add >> 22) & 1 != 0 { + immediate <<= 12; + } + let target = page + immediate; + if key_rva <= target && target < key_rva_end { + references += 1; + } + break; + } + add_offset += 4; + } + } + instruction += 4; + } + } + references +} + +fn find_key_blobs(pe: &PeImage) -> Vec { + let mut found = Vec::new(); + let mut seen = HashSet::new(); + for bits in RSA_KEY_BITS { + let marker = bits.to_be_bytes(); + for offset in pe + .data + .windows(marker.len()) + .enumerate() + .filter_map(|(offset, window)| (window == marker).then_some(offset)) + { + let Some(blob) = decode_key_candidate(&pe.data, offset, bits) else { + continue; + }; + if pe.raw_to_rva(offset).is_none() || !seen.insert(blob.clone()) { + continue; + } + let references = instruction_references(pe, offset, blob.len()); + found.push(KeyBlob { + raw_offset: offset, + blob, + references, + }); + } + } + found +} + +#[cfg(test)] +mod tests { + use std::{collections::HashMap, fs, io::Write, path::PathBuf}; + + use lzma_rust2::{LzmaOptions, LzmaWriter}; + + use super::{extract_abl_avb_pubkey_sha1, extract_from_bytes, lzma_decode}; + + #[test] + fn garbage_and_truncated_inputs_return_errors() { + let garbage: Vec = (0..8192) + .map(|index| ((index * 73 + 41) & 0xff) as u8) + .collect(); + for input in [&[][..], b"MZ", &garbage[..], &garbage[..63]] { + assert!(extract_from_bytes(input).is_err()); + } + } + + #[test] + fn lzma_alone_unknown_size_and_headerless_size_decode() { + let payload: Vec = (0..256).map(|value| value as u8).collect(); + let mut writer = + LzmaWriter::new_use_header(Vec::new(), &LzmaOptions::default(), None).unwrap(); + writer.write_all(&payload).unwrap(); + let encoded = writer.finish().unwrap(); + assert_eq!(&encoded[5..13], &[0xff; 8]); + assert_eq!(lzma_decode(&encoded).as_deref(), Some(payload.as_slice())); + + let mut without_size = encoded[..5].to_vec(); + without_size.extend_from_slice(&encoded[13..]); + assert_eq!( + lzma_decode(&without_size).as_deref(), + Some(payload.as_slice()) + ); + } + + #[test] + #[ignore = "needs LTBOX_TEST_ABL_DIR=/path/to/abl/fixtures"] + fn extracts_local_abl_fixtures() { + let directory = PathBuf::from( + std::env::var_os("LTBOX_TEST_ABL_DIR") + .expect("LTBOX_TEST_ABL_DIR must point to the ABL fixture directory"), + ); + let expected = HashMap::from([ + ("abl_tb320.elf", "2597c218aae470a130f61162feaae70afd97f011"), + ("abl_tb321.elf", "2597c218aae470a130f61162feaae70afd97f011"), + ( + "abl_tb322_new.elf", + "8fcb864f11f53ed11284615fb67685522085d3a2", + ), + ( + "abl_tb322_old.elf", + "2597c218aae470a130f61162feaae70afd97f011", + ), + ("abl_tb520.elf", "2597c218aae470a130f61162feaae70afd97f011"), + ("abl_tb710.elf", "2597c218aae470a130f61162feaae70afd97f011"), + ( + "abl_tb710_new.elf", + "2597c218aae470a130f61162feaae70afd97f011", + ), + ]); + + let mut paths: Vec<_> = fs::read_dir(&directory) + .expect("read LTBOX_TEST_ABL_DIR") + .map(|entry| entry.expect("read fixture directory entry").path()) + .filter(|path| path.extension().is_some_and(|extension| extension == "elf")) + .collect(); + paths.sort(); + assert!( + !paths.is_empty(), + "LTBOX_TEST_ABL_DIR contains no *.elf files" + ); + + let mut fingerprints = std::collections::HashSet::new(); + for path in paths { + let fingerprint = extract_abl_avb_pubkey_sha1(&path) + .unwrap_or_else(|error| panic!("{}: {error}", path.display())); + println!( + "{} {fingerprint}", + path.file_name().unwrap().to_string_lossy() + ); + fingerprints.insert(fingerprint.clone()); + if let Some(expected) = path + .file_name() + .and_then(|name| name.to_str()) + .and_then(|name| expected.get(name)) + { + assert_eq!(&fingerprint, expected, "{}", path.display()); + } + } + assert_eq!(fingerprints.len(), 2); + assert!(fingerprints.contains("2597c218aae470a130f61162feaae70afd97f011")); + assert!(fingerprints.contains("8fcb864f11f53ed11284615fb67685522085d3a2")); + } +} diff --git a/crates/ltbox-patch/src/lib.rs b/crates/ltbox-patch/src/lib.rs index b0efbc88..b6aa5168 100644 --- a/crates/ltbox-patch/src/lib.rs +++ b/crates/ltbox-patch/src/lib.rs @@ -2,6 +2,7 @@ //! //! Wraps `avbtool-rs` + `magiskboot` library APIs in-process; no subprocesses. +pub mod abl_key; pub mod apatch; pub mod avb; pub mod boot;