feat(update): verify signed update metadata (#15914)

* feat(update): verify signed update metadata

* fix(update): harden metadata publishing and app names

- share strict app-name validation across Windows and macOS
- preserve published sidecars until replacements are ready
- select macOS test features by target architecture
- update the signing dependency and URL validation coverage

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(update): prevent replacing published release assets

- block reruns after signed update metadata is published
- clarify the security rationale for app-name validation

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(update): cache TLS responses and avoid double translations

Signed-off-by: fufesou <linlong1266@gmail.com>

* refact: reduce the diffs

Signed-off-by: fufesou <linlong1266@gmail.com>

* refact(update): remove obsolete update paths

- remove unused FFI update branches
- delete legacy Windows and macOS update helpers
- simplify macOS daemon updates and redundant gating

Signed-off-by: fufesou <linlong1266@gmail.com>

* feat(update): secure same-identity macOS offline updates

- verify DMGs using signed metadata, size, and SHA-256
- enforce package and bundle identity plus macOS signature checks
- support custom release policies and propagate manual update failures

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(update): verify Windows installers with Authenticode

Reject unsigned or untrusted EXE/MSI updates
before elevation, clean up failed verified copies,
and fix the macOS manual updater logging import.

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(update): clear stale URL on response body errors

Keep body-read failures in the common cleanup path and document the
cache-only Authenticode verification policy.

Signed-off-by: fufesou <linlong1266@gmail.com>

* refactor(update): remove OS signature sanity checks

Remove Windows Authenticode and macOS codesign/Gatekeeper verification.
Keep metadata, artifact integrity, bundle identity, and transaction checks.

Signed-off-by: fufesou <linlong1266@gmail.com>

* refact(update): move update scripts into res/update

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(update): secure Windows MSI handoff against junction races

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(update): secure Windows EXE handoff against junction races

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(update): preserve legacy custom client app names

- allow names starting with digits or underscores
- preserve leading hyphens on Windows for compatibility
- reject leading hyphens on macOS to avoid pgrep option parsing

Signed-off-by: fufesou <linlong1266@gmail.com>

* refact(update): remove some simple wrappers

Signed-off-by: fufesou <linlong1266@gmail.com>

* refact(update): simple refactor

* refact(update): comments on update.scpt

Signed-off-by: fufesou <linlong1266@gmail.com>

* refact(update): comments

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(update): create download destinations exclusively

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(macos): roll back updates when final readiness checks fail

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(msi): propagate MSI installation failures

Signed-off-by: fufesou <linlong1266@gmail.com>

* chore

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(macos): reject stale IPC sockets during updates

Require open IPC sockets in addition to launchd running state before committing the privileged update transaction. Inspect sockets with lsof to avoid triggering IPC authentication.

Add native socket regression coverage to macOS CI. Remove the unused Windows helper, combine offline metadata exports, and restore unrelated formatting changed by the PR.

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(macos): allow offline updates without an installed bundle

Validate app-only updates against the running client's bundle identifier and check the destination identity when it exists. Replace source-text assertions with behavioral identity tests and run the macOS update tests in CI.

Signed-off-by: fufesou <linlong1266@gmail.com>

* ci, gate test with PUBLISH_UPDATE_METADATA == 'true'

Signed-off-by: fufesou <linlong1266@gmail.com>

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
This commit is contained in:
fufesou authored and GitHub committed 2026-10-12 10:56:50 +08:00
1 parent c8532719c0
commit 0921a7432f
40 files changed
+5416 -916

No files matched your search

+43 -39
View File
@@ -1,4 +1,4 @@
#[cfg(any(target_os = "windows", target_os = "macos"))]
#[cfg(target_os = "windows")]
use crate::client::translate;
#[cfg(not(debug_assertions))]
#[cfg(not(any(target_os = "android", target_os = "ios")))]
@@ -23,13 +23,26 @@ macro_rules! my_println{
};
}
pub enum CoreMainAction {
StartUi(Vec<String>),
ExitFailure(hbb_common::anyhow::Error),
}
#[cfg(target_os = "macos")]
fn finish_macos_update(result: hbb_common::ResultType<()>) -> Option<CoreMainAction> {
match result {
Ok(()) => None,
Err(err) => Some(CoreMainAction::ExitFailure(err)),
}
}
/// shared by flutter and sciter main function
///
/// [Note]
/// If it returns [`None`], then the process will terminate, and flutter gui will not be started.
/// If it returns [`Some`], then the process will continue, and flutter gui will be started.
/// If it returns [`Some`], the action determines whether to start the UI or report failure.
#[cfg(not(any(target_os = "android", target_os = "ios")))]
pub fn core_main() -> Option<Vec<String>> {
pub fn core_main() -> Option<CoreMainAction> {
if !crate::common::global_init() {
return None;
}
@@ -118,7 +131,7 @@ pub fn core_main() -> Option<Vec<String>> {
}
#[cfg(feature = "flutter")]
if _is_flutter_invoke_new_connection {
return core_main_invoke_new_connection(std::env::args());
return core_main_invoke_new_connection(std::env::args()).map(CoreMainAction::StartUi);
}
let click_setup = cfg!(windows) && args.is_empty() && crate::common::is_setup(&arg_exe);
if click_setup && !config::is_disable_installation() {
@@ -171,7 +184,7 @@ pub fn core_main() -> Option<Vec<String>> {
// linux uni (url) go here.
#[cfg(all(target_os = "linux", feature = "flutter"))]
if args.len() > 0 && args[0].starts_with(&crate::get_uri_prefix()) {
return try_send_by_dbus(args[0].clone());
return try_send_by_dbus(args[0].clone()).map(CoreMainAction::StartUi);
}
#[cfg(windows)]
@@ -227,6 +240,10 @@ pub fn core_main() -> Option<Vec<String>> {
}
return None;
} else if args[0] == "--update" {
// The elevated updater cannot delete its running verified copy.
if let Err(err) = platform::schedule_current_verified_update_file_cleanup() {
log::warn!("Failed to schedule verified update file cleanup: {}", err);
}
if config::is_disable_installation() {
return None;
}
@@ -336,38 +353,8 @@ pub fn core_main() -> Option<Vec<String>> {
}
#[cfg(target_os = "macos")]
{
use crate::platform;
if args[0] == "--update" {
if args.len() > 1 && args[1].ends_with(".dmg") {
// Version check is unnecessary unless downgrading to an older version
// that lacks "update dmg" support. This is a special case since we cannot
// detect the version before extracting the DMG, so we skip the check.
let dmg_path = &args[1];
println!("Updating from DMG: {}", dmg_path);
match platform::update_from_dmg(dmg_path) {
Ok(_) => {
println!("Update process from DMG started successfully.");
// The new process will handle the rest. We can exit.
}
Err(err) => {
eprintln!("Failed to start update from DMG: {}", err);
}
}
} else {
println!("Starting update process...");
log::info!("Starting update process...");
let _text = match platform::update_me() {
Ok(_) => {
println!("{}", translate("Updated successfully!".to_string()));
log::info!("Updated successfully!");
}
Err(err) => {
eprintln!("Update failed with error: {}", err);
log::error!("Update failed with error: {err}");
}
};
}
return None;
return finish_macos_update(crate::manual_update::execute_macos_update(&args));
}
}
if args[0] == "--remove" {
@@ -731,9 +718,9 @@ pub fn core_main() -> Option<Vec<String>> {
}
//_async_logger_holder.map(|x| x.flush());
#[cfg(feature = "flutter")]
return Some(flutter_args);
return Some(CoreMainAction::StartUi(flutter_args));
#[cfg(not(feature = "flutter"))]
return Some(args);
return Some(CoreMainAction::StartUi(args));
}
fn import_config(path: &str) {
@@ -873,7 +860,7 @@ fn is_root() -> bool {
crate::platform::is_root()
}
#[cfg(any(target_os = "linux", target_os = "macos", test))]
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn is_user_main_ipc_scope_cli_command(args: &[String]) -> bool {
matches!(
args.first().map(String::as_str),
@@ -921,6 +908,7 @@ mod tests {
values.iter().map(|value| value.to_string()).collect()
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn user_main_ipc_scope_cli_command_matches_management_commands_only() {
for command in [
@@ -947,6 +935,22 @@ mod tests {
assert!(!is_user_main_ipc_scope_cli_command(&args(&[command])));
}
}
#[cfg(target_os = "macos")]
#[test]
fn macos_manual_update_result_preserves_failure() {
let failure =
finish_macos_update(Err(hbb_common::anyhow::anyhow!("administrator canceled")))
.expect("failure action");
match failure {
CoreMainAction::ExitFailure(err) => {
assert_eq!("administrator canceled", err.to_string());
}
CoreMainAction::StartUi(_) => panic!("update failure started the UI"),
}
assert!(finish_macos_update(Ok(())).is_none());
}
}
/// Check if the executable is a Quick Support version.