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

+84
View File
@@ -0,0 +1,84 @@
use super::UpdateArtifact;
use hbb_common::{
anyhow::{anyhow, Context},
ResultType,
};
use url::Url;
#[derive(Clone, Copy)]
pub(super) enum ArtifactUrlPolicy<'a> {
ExactPrefix(&'a str),
#[cfg(target_os = "macos")]
SignedHttps,
}
pub(super) fn validate_artifact(
artifact: &UpdateArtifact,
url_policy: ArtifactUrlPolicy<'_>,
) -> ResultType<()> {
validate_artifact_file_name(&artifact.file_name)?;
if !is_sha256_hex(&artifact.sha256) {
return Err(anyhow!("invalid update artifact sha256"));
}
let parsed_url = Url::parse(&artifact.url).context("invalid update artifact URL")?;
if parsed_url.query().is_some() || parsed_url.fragment().is_some() {
return Err(anyhow!(
"update artifact URL must not contain query or fragment"
));
}
validate_artifact_url_policy(artifact, &parsed_url, url_policy)?;
let basename = parsed_url
.path_segments()
.and_then(|mut segments| segments.next_back())
.ok_or_else(|| anyhow!("update artifact URL has no basename"))?;
if basename != artifact.file_name {
return Err(anyhow!("update artifact URL basename mismatch"));
}
Ok(())
}
fn validate_artifact_url_policy(
artifact: &UpdateArtifact,
_parsed_url: &Url,
policy: ArtifactUrlPolicy<'_>,
) -> ResultType<()> {
match policy {
ArtifactUrlPolicy::ExactPrefix(prefix) => {
let expected_url = format!("{}{}", prefix, artifact.file_name);
if artifact.url != expected_url {
return Err(anyhow!(
"update artifact URL is outside expected release prefix"
));
}
}
#[cfg(target_os = "macos")]
ArtifactUrlPolicy::SignedHttps => {
if _parsed_url.scheme() != "https"
|| _parsed_url.host_str().is_none()
|| !_parsed_url.username().is_empty()
|| _parsed_url.password().is_some()
{
return Err(anyhow!(
"signed update artifact URL must be an HTTPS URL without credentials"
));
}
}
}
Ok(())
}
pub(super) fn validate_artifact_file_name(file_name: &str) -> ResultType<()> {
if file_name.is_empty()
|| matches!(file_name, "." | "..")
|| !file_name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
{
return Err(anyhow!("invalid update artifact file name"));
}
Ok(())
}
fn is_sha256_hex(value: &str) -> bool {
value.len() == 64 && value.as_bytes().iter().all(u8::is_ascii_hexdigit)
}
+71
View File
@@ -0,0 +1,71 @@
use super::{
verify_update_metadata_with_options, ArtifactUrlPolicy, MetadataRequirements, TrustedUpdateKey,
UpdateArtifactQuery, VerificationOptions, VerifiedUpdateArtifact,
};
use hbb_common::{anyhow::Context, bail, ResultType};
use serde_derive::Deserialize;
#[derive(Clone, Copy)]
pub(crate) struct OfflineUpdateMetadataRequirements<'a> {
pub local_file_name: &'a str,
pub expected_package_id: &'a str,
pub artifact: UpdateArtifactQuery<'a>,
}
pub(super) struct OfflineVerificationOptions<'a> {
pub(super) requirements: OfflineUpdateMetadataRequirements<'a>,
pub(super) trusted_keys: &'a [TrustedUpdateKey],
}
#[derive(Deserialize)]
struct OfflineMetadataHeader {
version: String,
release_id: String,
}
pub(super) fn verify_offline_update_metadata_with_options(
metadata_bytes: &[u8],
signature_bytes: &[u8],
options: OfflineVerificationOptions<'_>,
) -> ResultType<VerifiedUpdateArtifact> {
let header: OfflineMetadataHeader =
serde_json::from_slice(metadata_bytes).context("invalid update metadata JSON")?;
let expected_version = crate::common::display_version_from_release_id(&header.release_id)?;
if header.version != expected_version {
bail!("offline update metadata version and release id mismatch");
}
let artifact = verify_update_metadata_with_options(
metadata_bytes,
signature_bytes,
VerificationOptions {
requirements: MetadataRequirements {
expected_version: &expected_version,
expected_release_id: &header.release_id,
artifact: options.requirements.artifact,
},
expected_package_id: options.requirements.expected_package_id,
artifact_url_policy: ArtifactUrlPolicy::SignedHttps,
trusted_keys: options.trusted_keys,
},
)?;
if artifact.file_name != options.requirements.local_file_name {
bail!("local update file name does not match signed metadata");
}
Ok(artifact)
}
#[cfg(target_os = "macos")]
pub(crate) fn verify_offline_update_metadata(
metadata_bytes: &[u8],
signature_bytes: &[u8],
requirements: OfflineUpdateMetadataRequirements<'_>,
) -> ResultType<VerifiedUpdateArtifact> {
verify_offline_update_metadata_with_options(
metadata_bytes,
signature_bytes,
OfflineVerificationOptions {
requirements,
trusted_keys: super::TRUSTED_UPDATE_KEYS,
},
)
}
+96
View File
@@ -0,0 +1,96 @@
use super::*;
use crate::update_metadata::offline::{
verify_offline_update_metadata_with_options, OfflineVerificationOptions,
};
fn verify_offline(fixture: &Fixture, local_file_name: &str) -> ResultType<VerifiedUpdateArtifact> {
verify_offline_for_package(fixture, local_file_name, PUBLIC_UPDATE_PACKAGE_ID)
}
fn verify_offline_for_package(
fixture: &Fixture,
local_file_name: &str,
expected_package_id: &str,
) -> ResultType<VerifiedUpdateArtifact> {
let trusted_keys = [key(fixture.public_key)];
verify_offline_update_metadata_with_options(
&fixture.metadata,
&fixture.signature,
OfflineVerificationOptions {
requirements: OfflineUpdateMetadataRequirements {
local_file_name,
expected_package_id,
artifact: query(),
},
trusted_keys: &trusted_keys,
},
)
}
#[test]
fn accepts_valid_signed_offline_artifact() {
let artifact = verify_offline(&valid_fixture(), "rustdesk-1.4.6-x86_64.exe")
.expect("verified offline artifact");
assert_eq!("rustdesk-1.4.6-x86_64.exe", artifact.file_name);
assert_eq!(123456, artifact.size);
assert_eq!(SHA256, artifact.sha256);
assert_eq!("1.4.6", artifact.version);
}
#[test]
fn offline_metadata_requires_the_expected_package_identity() {
const CUSTOM_PACKAGE_ID: &str = "com.example.rustdesk-custom";
let mut custom_metadata = metadata();
custom_metadata["package_id"] = json!(CUSTOM_PACKAGE_ID);
let fixture = sign_fixture(custom_metadata, KEY_ID);
assert!(
verify_offline_for_package(&fixture, "rustdesk-1.4.6-x86_64.exe", CUSTOM_PACKAGE_ID)
.is_ok()
);
assert!(verify_offline(&fixture, "rustdesk-1.4.6-x86_64.exe").is_err());
}
#[test]
fn accepts_signed_offline_artifact_from_custom_https_release() {
let mut custom_metadata = metadata();
custom_metadata["artifacts"][0]["url"] =
json!("https://updates.example.com/releases/v1.4.6/rustdesk-1.4.6-x86_64.exe");
let fixture = sign_fixture(custom_metadata, KEY_ID);
let artifact = verify_offline(&fixture, "rustdesk-1.4.6-x86_64.exe")
.expect("verified custom HTTPS artifact");
assert_eq!("rustdesk-1.4.6-x86_64.exe", artifact.file_name);
}
#[test]
fn rejects_signed_offline_artifact_from_unsafe_url() {
for url in [
"http://updates.example.com/rustdesk-1.4.6-x86_64.exe",
"https://user@updates.example.com/rustdesk-1.4.6-x86_64.exe",
] {
let mut invalid_metadata = metadata();
invalid_metadata["artifacts"][0]["url"] = json!(url);
let fixture = sign_fixture(invalid_metadata, KEY_ID);
assert!(
verify_offline(&fixture, "rustdesk-1.4.6-x86_64.exe").is_err(),
"{url}"
);
}
}
#[test]
fn rejects_offline_artifact_name_or_release_mismatch() {
assert!(verify_offline(&valid_fixture(), "renamed-update.exe").is_err());
let mut invalid_metadata = metadata();
invalid_metadata["version"] = json!("1.4.7");
assert!(verify_offline(
&sign_fixture(invalid_metadata, KEY_ID),
"rustdesk-1.4.6-x86_64.exe"
)
.is_err());
}