From 0921a7432ff1c514f9d8faabd1013838641ec241 Mon Sep 17 00:00:00 2001 From: fufesou Date: Sun, 11 Oct 2026 19:56:50 -0700 Subject: [PATCH] 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 * 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 * fix(update): cache TLS responses and avoid double translations Signed-off-by: fufesou * refact: reduce the diffs Signed-off-by: fufesou * 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 * 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 * 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 * 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 * 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 * refact(update): move update scripts into res/update Signed-off-by: fufesou * fix(update): secure Windows MSI handoff against junction races Signed-off-by: fufesou * fix(update): secure Windows EXE handoff against junction races Signed-off-by: fufesou * 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 * refact(update): remove some simple wrappers Signed-off-by: fufesou * refact(update): simple refactor * refact(update): comments on update.scpt Signed-off-by: fufesou * refact(update): comments Signed-off-by: fufesou * fix(update): create download destinations exclusively Signed-off-by: fufesou * fix(macos): roll back updates when final readiness checks fail Signed-off-by: fufesou * fix(msi): propagate MSI installation failures Signed-off-by: fufesou * chore Signed-off-by: fufesou * 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 * 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 * ci, gate test with PUBLISH_UPDATE_METADATA == 'true' Signed-off-by: fufesou --------- Signed-off-by: fufesou --- .github/workflows/flutter-build.yml | 272 ++++++++- .github/workflows/flutter-tag.yml | 3 +- .../lib/desktop/widgets/update_progress.dart | 153 +++-- libs/portable/Cargo.toml | 2 +- libs/portable/src/main.rs | 36 ++ res/update/generate_update_metadata.py | 297 ++++++++++ res/update/test_generate_update_metadata.py | 300 ++++++++++ res/update/test_macos_update_scripts.py | 104 ++++ res/update/test_update_metadata_cli.py | 74 +++ res/update/update_metadata_policy.py | 100 ++++ res/update/update_metadata_requirements.txt | 7 + src/common.rs | 204 +++++-- src/core_main.rs | 82 +-- src/flutter.rs | 34 +- src/flutter_ffi.rs | 204 ++++--- src/hbbs_http/downloader.rs | 335 +++++------ src/lib.rs | 4 + src/main.rs | 26 +- src/manual_update.rs | 229 ++++++++ src/platform/macos.rs | 527 +++++++++++------- src/platform/macos/update_temp.rs | 248 +++++++++ src/platform/macos/verified_dmg.rs | 240 ++++++++ src/platform/macos/verified_dmg_tests.rs | 208 +++++++ src/platform/mod.rs | 52 ++ src/platform/privileges_scripts/update.scpt | 64 ++- src/platform/windows.rs | 82 +-- src/platform/windows/verified_update.rs | 261 +++++++++ src/platform/windows/verified_update/file.rs | 314 +++++++++++ src/service.rs | 2 +- src/ui_interface.rs | 7 +- src/update_hash.rs | 98 ++++ src/update_metadata.rs | 271 +++++++++ src/update_metadata/artifact.rs | 84 +++ src/update_metadata/offline.rs | 71 +++ src/update_metadata/offline_tests.rs | 96 ++++ src/update_metadata_tests.rs | 283 ++++++++++ src/updater.rs | 389 ++++++------- src/updater/artifact.rs | 259 +++++++++ src/updater/download.rs | 227 ++++++++ src/updater/download/tests.rs | 83 +++ 40 files changed, 5416 insertions(+), 916 deletions(-) create mode 100644 res/update/generate_update_metadata.py create mode 100644 res/update/test_generate_update_metadata.py create mode 100644 res/update/test_macos_update_scripts.py create mode 100644 res/update/test_update_metadata_cli.py create mode 100644 res/update/update_metadata_policy.py create mode 100644 res/update/update_metadata_requirements.txt create mode 100644 src/manual_update.rs create mode 100644 src/platform/macos/update_temp.rs create mode 100644 src/platform/macos/verified_dmg.rs create mode 100644 src/platform/macos/verified_dmg_tests.rs create mode 100644 src/platform/windows/verified_update.rs create mode 100644 src/platform/windows/verified_update/file.rs create mode 100644 src/update_hash.rs create mode 100644 src/update_metadata.rs create mode 100644 src/update_metadata/artifact.rs create mode 100644 src/update_metadata/offline.rs create mode 100644 src/update_metadata/offline_tests.rs create mode 100644 src/update_metadata_tests.rs create mode 100644 src/updater/artifact.rs create mode 100644 src/updater/download.rs create mode 100644 src/updater/download/tests.rs diff --git a/.github/workflows/flutter-build.yml b/.github/workflows/flutter-build.yml index 85291d637..79b9d85a8 100644 --- a/.github/workflows/flutter-build.yml +++ b/.github/workflows/flutter-build.yml @@ -9,6 +9,9 @@ on: upload-tag: type: string default: "nightly" + publish-update-metadata: + type: boolean + default: false # NOTE: F-Droid builder script 'flutter/build_fdroid.sh' reads environment # variables from this workflow! @@ -51,10 +54,48 @@ env: ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}" MACOS_P12_BASE64: "${{ secrets.MACOS_P12_BASE64 }}" UPLOAD_ARTIFACT: "${{ inputs.upload-artifact }}" + PUBLISH_UPDATE_METADATA: "${{ inputs.publish-update-metadata }}" SIGN_BASE_URL: "${{ secrets.SIGN_BASE_URL }}-2" jobs: + validate-update-metadata-keys: + runs-on: ubuntu-22.04 + permissions: + contents: read + steps: + - name: Checkout source code + if: ${{ inputs.publish-update-metadata }} + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + + - name: Install Python + if: ${{ inputs.publish-update-metadata }} + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.12" + + - name: Install Python dependencies + if: ${{ inputs.publish-update-metadata }} + run: 'python -m pip install --only-binary=:all: --require-hashes -r res/update/update_metadata_requirements.txt' + + - name: Test update metadata signer + if: ${{ inputs.publish-update-metadata }} + run: | + python -m unittest \ + res/update/test_generate_update_metadata.py \ + res/update/test_update_metadata_cli.py \ + res/update/test_macos_update_scripts.py + + - name: Validate update metadata keys + if: ${{ inputs.publish-update-metadata }} + env: + RUSTDESK_UPDATE_ED25519_SEED: ${{ secrets.RUSTDESK_UPDATE_ED25519_SEED }} + RUSTDESK_UPDATE_ED25519_PUBLIC_KEY: ${{ vars.RUSTDESK_UPDATE_ED25519_PUBLIC_KEY }} + run: python res/update/generate_update_metadata.py check-key + generate-sbom: + needs: [validate-update-metadata-keys] runs-on: ubuntu-latest permissions: @@ -83,9 +124,11 @@ jobs: rustdesk.sbom.json generate-bridge: + needs: [validate-update-metadata-keys] uses: ./.github/workflows/bridge.yml build-RustDeskTempTopMostWindow: + needs: [validate-update-metadata-keys] uses: ./.github/workflows/third-party-RustDeskTempTopMostWindow.yml strategy: fail-fast: false @@ -360,6 +403,18 @@ jobs: pip3 install requests argparse BASE_URL=${{ env.SIGN_BASE_URL }} SECRET_KEY=${{ secrets.SIGN_SECRET_KEY }} python3 res/job.py sign_files ./rustdesk/ + - name: Verify Windows Flutter version + if: env.UPLOAD_ARTIFACT == 'true' && env.PUBLISH_UPDATE_METADATA == 'true' + shell: powershell + run: | + $reportedVersion = (& ".\rustdesk\rustdesk.exe" --version | Out-String).Trim() + if ($LASTEXITCODE -ne 0) { + throw "rustdesk --version exited with $LASTEXITCODE" + } + if ($reportedVersion -cne $env:VERSION) { + throw "RustDesk reports version '$reportedVersion', expected '$env:VERSION'" + } + - name: Build self-extracted executable shell: bash if: env.UPLOAD_ARTIFACT == 'true' @@ -450,9 +505,25 @@ jobs: run: | BASE_URL=${{ env.SIGN_BASE_URL }} SECRET_KEY=${{ secrets.SIGN_SECRET_KEY }} python3 res/job.py sign_files ./SignOutput + - name: Upload signed Windows Flutter EXE + if: env.UPLOAD_ARTIFACT == 'true' && env.PUBLISH_UPDATE_METADATA == 'true' && env.SIGN_BASE_URL != '-2' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: windows-flutter-${{ matrix.job.arch }}-exe + path: ./SignOutput/rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.exe + if-no-files-found: error + + - name: Upload signed Windows Flutter MSI + if: env.UPLOAD_ARTIFACT == 'true' && env.PUBLISH_UPDATE_METADATA == 'true' && env.SIGN_BASE_URL != '-2' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: windows-flutter-${{ matrix.job.arch }}-msi + path: ./SignOutput/rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.msi + if-no-files-found: error + - name: Publish Release uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1 - if: env.UPLOAD_ARTIFACT == 'true' + if: env.UPLOAD_ARTIFACT == 'true' && env.PUBLISH_UPDATE_METADATA != 'true' with: prerelease: true tag_name: ${{ env.TAG_NAME }} @@ -463,6 +534,7 @@ jobs: # The fallback for the flutter version, we use Sciter for 32bit Windows. build-for-windows-sciter: name: ${{ matrix.job.target }} (${{ matrix.job.os }}) + needs: [validate-update-metadata-keys] runs-on: ${{ matrix.job.os }} # Temporarily disable this action due to additional test is needed. # if: false @@ -595,6 +667,18 @@ jobs: pip3 install requests argparse BASE_URL=${{ env.SIGN_BASE_URL }} SECRET_KEY=${{ secrets.SIGN_SECRET_KEY }} python3 res/job.py sign_files ./Release/ + - name: Verify Windows Sciter version + if: env.UPLOAD_ARTIFACT == 'true' && env.PUBLISH_UPDATE_METADATA == 'true' + shell: powershell + run: | + $reportedVersion = (& ".\Release\rustdesk.exe" --version | Out-String).Trim() + if ($LASTEXITCODE -ne 0) { + throw "rustdesk --version exited with $LASTEXITCODE" + } + if ($reportedVersion -cne $env:VERSION) { + throw "RustDesk reports version '$reportedVersion', expected '$env:VERSION'" + } + - name: Build self-extracted executable shell: bash run: | @@ -612,9 +696,17 @@ jobs: run: | BASE_URL=${{ env.SIGN_BASE_URL }} SECRET_KEY=${{ secrets.SIGN_SECRET_KEY }} python3 res/job.py sign_files ./SignOutput/ + - name: Upload signed Windows Sciter EXE + if: env.UPLOAD_ARTIFACT == 'true' && env.PUBLISH_UPDATE_METADATA == 'true' && env.SIGN_BASE_URL != '-2' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: windows-sciter-x86-exe + path: ./SignOutput/rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}-sciter.exe + if-no-files-found: error + - name: Publish Release uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1 - if: env.UPLOAD_ARTIFACT == 'true' + if: env.UPLOAD_ARTIFACT == 'true' && env.PUBLISH_UPDATE_METADATA != 'true' with: prerelease: true tag_name: ${{ env.TAG_NAME }} @@ -755,6 +847,7 @@ jobs: target: x86_64-apple-darwin, os: macos-15-intel, #macos-latest or macos-14 use M1 now, https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners/about-github-hosted-runners#:~:text=14%20GB-,macos%2Dlatest%20or%20macos%2D14,-The%20macos%2Dlatestlabel extra-build-args: "", + extra-cargo-features: "", arch: x86_64, vcpkg-triplet: x64-osx, } @@ -763,6 +856,7 @@ jobs: os: macos-14, # extra-build-args: "--disable-flutter-texture-render", # disable this for mac, because we see a lot of users reporting flickering both on arm and x64, and we can not confirm if texture rendering has better performance if htere is no vram, https://github.com/rustdesk/rustdesk/issues/6296 extra-build-args: "--screencapturekit", + extra-cargo-features: ",screencapturekit", arch: aarch64, vcpkg-triplet: arm64-osx, } @@ -916,9 +1010,18 @@ jobs: sed -i -e "s/platform :osx, '.*'/platform :osx, '${MIN_MACOS_VERSION}'/" flutter/macos/Podfile sed -i -e "s/osx_minimum_system_version = \"[0-9]*.[0-9]*\"/osx_minimum_system_version = \"${MIN_MACOS_VERSION}\"/" Cargo.toml sed -i -e "s/MACOSX_DEPLOYMENT_TARGET = [0-9]*.[0-9]*;/MACOSX_DEPLOYMENT_TARGET = ${MIN_MACOS_VERSION};/" flutter/macos/Runner.xcodeproj/project.pbxproj + echo "MACOSX_DEPLOYMENT_TARGET=${MIN_MACOS_VERSION}" >> "$GITHUB_ENV" fi ./build.py --flutter --hwcodec --unix-file-copy-paste ${{ matrix.job.extra-build-args }} + - name: Test verified updates + if: env.PUBLISH_UPDATE_METADATA == 'true' + run: | + python3 -m unittest res/update/test_macos_update_scripts.py + cargo test --locked --release --lib platform::macos::update_tests --features "flutter,hwcodec,unix-file-copy-paste${{ matrix.job.extra-cargo-features }}" + cargo test --locked --release --lib verified_dmg_tests --features "flutter,hwcodec,unix-file-copy-paste${{ matrix.job.extra-cargo-features }}" + cargo test --locked --release --lib update_metadata::tests --features "flutter,hwcodec,unix-file-copy-paste${{ matrix.job.extra-cargo-features }}" + - name: create unsigned dmg if: env.UPLOAD_ARTIFACT == 'true' run: | @@ -951,6 +1054,13 @@ jobs: ./flutter/build/macos/Build/Products/Release/RustDesk.app \ ${{ secrets.MACOS_CODESIGN_IDENTITY }} \ ./flutter/macos/Runner/Release.entitlements + if [ "$PUBLISH_UPDATE_METADATA" = "true" ]; then + reported_version="$(./flutter/build/macos/Build/Products/Release/RustDesk.app/Contents/MacOS/RustDesk --version)" + if [ "$reported_version" != "$VERSION" ]; then + echo "::error::RustDesk reports version '$reported_version', expected '$VERSION'" + exit 1 + fi + fi create-dmg --icon "RustDesk.app" 200 190 --hide-extension "RustDesk.app" --window-size 800 400 --app-drop-link 600 185 rustdesk-${{ env.VERSION }}.dmg ./flutter/build/macos/Build/Products/Release/RustDesk.app codesign --force --options runtime -s ${{ secrets.MACOS_CODESIGN_IDENTITY }} --deep --strict rustdesk-${{ env.VERSION }}.dmg -vvv # notarize the rustdesk-${{ env.VERSION }}.dmg @@ -958,13 +1068,26 @@ jobs: - name: Rename rustdesk if: env.UPLOAD_ARTIFACT == 'true' + shell: bash + env: + VERSION: ${{ env.VERSION }} + ARCH: ${{ matrix.job.arch }} run: | - for name in rustdesk*??.dmg; do - mv "$name" "${name%%.dmg}-${{ matrix.job.arch }}.dmg" - done + if [ -f "rustdesk-${VERSION}.dmg" ]; then + mv "rustdesk-${VERSION}.dmg" "rustdesk-${VERSION}-${ARCH}.dmg" + fi + test -f "rustdesk-${VERSION}-${ARCH}.dmg" + + - name: Upload signed macOS DMG + if: env.UPLOAD_ARTIFACT == 'true' && env.PUBLISH_UPDATE_METADATA == 'true' && env.MACOS_P12_BASE64 != null + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: macos-${{ matrix.job.arch }}-dmg + path: ./rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.dmg + if-no-files-found: error - name: Publish DMG package - if: env.UPLOAD_ARTIFACT == 'true' + if: env.UPLOAD_ARTIFACT == 'true' && env.PUBLISH_UPDATE_METADATA != 'true' uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1 with: prerelease: true @@ -972,6 +1095,141 @@ jobs: files: | rustdesk*-${{ matrix.job.arch }}.dmg + publish-signed-update-metadata: + needs: [build-for-windows-flutter, build-for-windows-sciter, build-for-macOS] + runs-on: ubuntu-22.04 + if: ${{ inputs.upload-artifact && inputs.publish-update-metadata && github.repository == 'rustdesk/rustdesk' }} + concurrency: + group: signed-update-${{ inputs.upload-tag }} + cancel-in-progress: false + permissions: + # Required to inspect and publish signed release assets for the target tag. + contents: write + steps: + - name: Refuse to replace published update metadata + shell: bash + env: + GH_TOKEN: ${{ github.token }} + TAG_NAME: ${{ env.TAG_NAME }} + run: | + set -euo pipefail + releases="$(gh api --paginate --slurp "repos/${GITHUB_REPOSITORY}/releases?per_page=100")" + release_id="$( + jq -r --arg tag "$TAG_NAME" ' + [.[][] | select(.tag_name == $tag)] | + if length == 0 then "" + elif length == 1 then .[0].id + else error("multiple releases found for tag") + end + ' <<<"$releases" + )" + if [ -z "$release_id" ]; then + exit 0 + fi + assets="$(gh api --paginate --slurp "repos/${GITHUB_REPOSITORY}/releases/${release_id}/assets?per_page=100")" + if jq -e 'any(.[][]; .name == "rustdesk-update.json")' <<<"$assets" >/dev/null; then + echo "::error::Signed update metadata is already published for ${TAG_NAME}; use a new release tag for corrected artifacts." + exit 1 + fi + + - name: Checkout source code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + + - name: Install Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.12" + + - name: Install Python dependencies + run: 'python -m pip install --only-binary=:all: --require-hashes -r res/update/update_metadata_requirements.txt' + + - name: Download Windows artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: windows-* + path: ./artifacts/ + merge-multiple: true + + - name: Download macOS artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: macos-* + path: ./artifacts/ + merge-multiple: true + + - name: Sign update metadata + env: + RUSTDESK_UPDATE_ED25519_SEED: ${{ secrets.RUSTDESK_UPDATE_ED25519_SEED }} + run: | + python res/update/generate_update_metadata.py sign \ + --artifact windows x86_64 exe "./artifacts/rustdesk-${VERSION}-x86_64.exe" \ + --artifact windows x86_64 msi "./artifacts/rustdesk-${VERSION}-x86_64.msi" \ + --artifact windows aarch64 exe "./artifacts/rustdesk-${VERSION}-aarch64.exe" \ + --artifact windows aarch64 msi "./artifacts/rustdesk-${VERSION}-aarch64.msi" \ + --artifact windows x86 exe "./artifacts/rustdesk-${VERSION}-x86-sciter.exe" \ + --artifact macos aarch64 dmg "./artifacts/rustdesk-${VERSION}-aarch64.dmg" \ + --artifact macos x86_64 dmg "./artifacts/rustdesk-${VERSION}-x86_64.dmg" \ + --version "$VERSION" --release-id "$TAG_NAME" \ + --package-id rustdesk \ + --release-base-url https://github.com/rustdesk/rustdesk/releases/download \ + --published-at "$(git log -1 --format=%cI)" \ + --metadata-out ./rustdesk-update.json \ + --signature-out ./rustdesk-update.json.sig + + - name: Verify update metadata + env: + RUSTDESK_UPDATE_ED25519_PUBLIC_KEY: ${{ vars.RUSTDESK_UPDATE_ED25519_PUBLIC_KEY }} + run: | + python res/update/generate_update_metadata.py verify \ + --metadata ./rustdesk-update.json \ + --signature ./rustdesk-update.json.sig \ + --artifact "./artifacts/rustdesk-${VERSION}-x86_64.exe" \ + --artifact "./artifacts/rustdesk-${VERSION}-x86_64.msi" \ + --artifact "./artifacts/rustdesk-${VERSION}-aarch64.exe" \ + --artifact "./artifacts/rustdesk-${VERSION}-aarch64.msi" \ + --artifact "./artifacts/rustdesk-${VERSION}-x86-sciter.exe" \ + --artifact "./artifacts/rustdesk-${VERSION}-aarch64.dmg" \ + --artifact "./artifacts/rustdesk-${VERSION}-x86_64.dmg" \ + --version "$VERSION" --release-id "$TAG_NAME" \ + --package-id rustdesk \ + --release-base-url https://github.com/rustdesk/rustdesk/releases/download + + - name: Upload signed update artifacts + uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1 + with: + prerelease: true + fail_on_unmatched_files: true + tag_name: ${{ env.TAG_NAME }} + files: | + ./artifacts/rustdesk-${{ env.VERSION }}-x86_64.exe + ./artifacts/rustdesk-${{ env.VERSION }}-x86_64.msi + ./artifacts/rustdesk-${{ env.VERSION }}-aarch64.exe + ./artifacts/rustdesk-${{ env.VERSION }}-aarch64.msi + ./artifacts/rustdesk-${{ env.VERSION }}-x86-sciter.exe + ./artifacts/rustdesk-${{ env.VERSION }}-aarch64.dmg + ./artifacts/rustdesk-${{ env.VERSION }}-x86_64.dmg + + # The release action replaces same-name assets, so keep published sidecars + # until replacements are ready. + - name: Upload signed update metadata signature + uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1 + with: + prerelease: true + fail_on_unmatched_files: true + tag_name: ${{ env.TAG_NAME }} + files: ./rustdesk-update.json.sig + + # Clients discover the release through metadata, so publish it only after every referenced asset. + - name: Publish signed update metadata + uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1 + with: + prerelease: true + fail_on_unmatched_files: true + tag_name: ${{ env.TAG_NAME }} + files: ./rustdesk-update.json + publish_unsigned: needs: - build-for-macOS @@ -2172,6 +2430,7 @@ jobs: build-rustdesk-linux-sciter: if: ${{ inputs.upload-artifact }} + needs: [validate-update-metadata-keys] runs-on: ${{ matrix.job.on }} name: build-rustdesk-linux-sciter ${{ matrix.job.target }} strategy: @@ -2552,6 +2811,7 @@ jobs: build-rustdesk-web: if: False + needs: [validate-update-metadata-keys] name: build-rustdesk-web runs-on: ubuntu-22.04 permissions: diff --git a/.github/workflows/flutter-tag.yml b/.github/workflows/flutter-tag.yml index bf39db5cc..620a04c86 100644 --- a/.github/workflows/flutter-tag.yml +++ b/.github/workflows/flutter-tag.yml @@ -15,4 +15,5 @@ jobs: secrets: inherit with: upload-artifact: true - upload-tag: ${{ github.ref_name }} \ No newline at end of file + upload-tag: ${{ github.ref_name }} + publish-update-metadata: ${{ github.repository == 'rustdesk/rustdesk' }} diff --git a/flutter/lib/desktop/widgets/update_progress.dart b/flutter/lib/desktop/widgets/update_progress.dart index 93f661b7b..959d97ff0 100644 --- a/flutter/lib/desktop/widgets/update_progress.dart +++ b/flutter/lib/desktop/widgets/update_progress.dart @@ -7,36 +7,49 @@ import 'package:flutter_hbb/models/platform_model.dart'; import 'package:get/get.dart'; import 'package:url_launcher/url_launcher.dart'; -final _isExtracting = false.obs; +const _eventKeyUpdateMe = 'update-me'; -void handleUpdate(String releasePageUrl) { - _isExtracting.value = false; - String downloadUrl = releasePageUrl.replaceAll('tag', 'download'); - String version = downloadUrl.substring(downloadUrl.lastIndexOf('/') + 1); - final String downloadFile = - bind.mainGetCommonSync(key: 'download-file-$version'); - if (downloadFile.startsWith('error:')) { - final error = downloadFile.replaceFirst('error:', ''); - msgBox(gFFI.sessionId, 'custom-nocancel-nook-hasclose', 'Error', error, - releasePageUrl, gFFI.dialogManager); +Future _handleUpdateMe( + String releasePageUrl, Map evt) async { + platformFFI.unregisterEventHandler(_eventKeyUpdateMe, _eventKeyUpdateMe); + if (evt.containsKey('error')) { + _showUpdateError(releasePageUrl, evt['error'] as String); + } +} + +Future handleUpdate(String releasePageUrl) async { + final dialogClosed = _showVerifyingUpdate(); + final String? downloadUrl; + try { + downloadUrl = await waitForUpdateVerification( + bind.mainGetCommon(key: 'verified-download-url-$releasePageUrl'), + dialogClosed, + ); + } catch (error) { + _showUpdateError(releasePageUrl, error.toString()); + return; + } + if (downloadUrl == null) { + return; + } + final verifiedDownloadUrl = downloadUrl; + if (downloadUrl.startsWith('error:')) { + _showUpdateError(releasePageUrl, downloadUrl.replaceFirst('error:', '')); return; } - downloadUrl = '$downloadUrl/$downloadFile'; SimpleWrapper downloadId = SimpleWrapper(''); SimpleWrapper onCanceled = SimpleWrapper(() {}); gFFI.dialogManager.dismissAll(); gFFI.dialogManager.show((setState, close, context) { return CustomAlertDialog( - title: Obx(() => Text(translate(_isExtracting.isTrue - ? 'Preparing for installation ...' - : 'Downloading {$appName}'))), - content: - UpdateProgress(releasePageUrl, downloadUrl, downloadId, onCanceled) - .marginSymmetric(horizontal: 8) - .paddingOnly(top: 12), + title: Text(translate('Downloading {$appName}')), + content: UpdateProgress( + releasePageUrl, verifiedDownloadUrl, downloadId, onCanceled) + .marginSymmetric(horizontal: 8) + .paddingOnly(top: 12), actions: [ - if (_isExtracting.isFalse) dialogButton(translate('Cancel'), onPressed: () async { + dialogButton('Cancel', onPressed: () async { onCanceled.value(); await bind.mainSetCommon( key: 'cancel-downloader', value: downloadId.value); @@ -56,6 +69,40 @@ void handleUpdate(String releasePageUrl) { }); } +Future waitForUpdateVerification( + Future verification, + Future dialogClosed, +) async { + final dialogClosedMarker = Object(); + final result = await Future.any([ + verification.then((value) => value), + dialogClosed.then((_) => dialogClosedMarker), + ]); + return identical(result, dialogClosedMarker) ? null : result as T; +} + +Future _showVerifyingUpdate() async { + gFFI.dialogManager.dismissAll(); + await gFFI.dialogManager.show( + (setState, close, context) => CustomAlertDialog( + title: Text(translate('Preparing for installation ...')), + content: const LinearProgressIndicator(), + actions: [ + dialogButton('Close', onPressed: close, isOutline: true), + ], + onCancel: close, + ), + tag: 'verifying-update', + ); +} + +void _showUpdateError(String releasePageUrl, String error) { + debugPrint('Update error: $error'); + gFFI.dialogManager.dismissAll(); + msgBox(gFFI.sessionId, 'custom-nocancel-nook-hasclose', 'Error', error, + releasePageUrl, gFFI.dialogManager); +} + class UpdateProgress extends StatefulWidget { final String releasePageUrl; final String downloadUrl; @@ -74,9 +121,9 @@ class UpdateProgressState extends State { Timer? _timer; int? _totalSize; int _downloadedSize = 0; + bool _downloadFinished = false; int _getDataFailedCount = 0; final String _eventKeyDownloadNewVersion = 'download-new-version'; - final String _eventKeyExtractUpdateDmg = 'extract-update-dmg'; @override void initState() { @@ -88,11 +135,6 @@ class UpdateProgressState extends State { _eventKeyDownloadNewVersion, handleDownloadNewVersion, replace: true); bind.mainSetCommon(key: 'download-new-version', value: widget.downloadUrl); - if (isMacOS) { - platformFFI.registerEventHandler(_eventKeyExtractUpdateDmg, - _eventKeyExtractUpdateDmg, handleExtractUpdateDmg, - replace: true); - } } @override @@ -100,10 +142,7 @@ class UpdateProgressState extends State { cancelQueryTimer(); platformFFI.unregisterEventHandler( _eventKeyDownloadNewVersion, _eventKeyDownloadNewVersion); - if (isMacOS) { - platformFFI.unregisterEventHandler( - _eventKeyExtractUpdateDmg, _eventKeyExtractUpdateDmg); - } + platformFFI.unregisterEventHandler(_eventKeyUpdateMe, _eventKeyUpdateMe); super.dispose(); } @@ -128,35 +167,33 @@ class UpdateProgressState extends State { } } - // `isExtractDmg` is true when handling extract-update-dmg event. - // It's a rare case that the dmg file is corrupted and cannot be extracted. - void _onError(String error, {bool isExtractDmg = false}) { + void _onError(String error) { cancelQueryTimer(); - debugPrint( - '${isExtractDmg ? "Extract" : "Download"} new version error: $error'); + debugPrint('Download new version error: $error'); final msgBoxType = 'custom-nocancel-nook-hasclose'; final msgBoxTitle = 'Error'; final msgBoxText = 'download-new-version-failed-tip'; final dialogManager = gFFI.dialogManager; + final releasePageUrl = widget.releasePageUrl; close() { dialogManager.dismissAll(); } jumplink() { - launchUrl(Uri.parse(widget.releasePageUrl)); + launchUrl(Uri.parse(releasePageUrl)); dialogManager.dismissAll(); } retry() { dialogManager.dismissAll(); - handleUpdate(widget.releasePageUrl); + handleUpdate(releasePageUrl); } final List buttons = [ dialogButton('Download', onPressed: jumplink), - if (!isExtractDmg) dialogButton('Retry', onPressed: retry), + dialogButton('Retry', onPressed: retry), dialogButton('Close', onPressed: close), ]; dialogManager.dismissAll(); @@ -186,6 +223,8 @@ class UpdateProgressState extends State { } } else if (key == 'downloaded_size') { _downloadedSize = value as int; + } else if (key == 'finished') { + _downloadFinished = value as bool; } else if (key == 'error') { if (value != null) { err = value.toString(); @@ -204,7 +243,9 @@ class UpdateProgressState extends State { if (err != '') { _onError(err); } else { - if (_totalSize != null && _downloadedSize >= _totalSize!) { + if (_downloadFinished && + _totalSize != null && + _downloadedSize >= _totalSize!) { cancelQueryTimer(); bind.mainSetCommon( key: 'remove-downloader', value: widget.downloadId.value); @@ -212,13 +253,7 @@ class UpdateProgressState extends State { _onError('The download file size is 0.'); } else { setState(() {}); - if (isMacOS) { - bind.mainSetCommon( - key: 'extract-update-dmg', value: widget.downloadUrl); - _isExtracting.value = true; - } else { - updateMsgBox(); - } + updateMsgBox(); } } else { setState(() {}); @@ -227,6 +262,8 @@ class UpdateProgressState extends State { } void updateMsgBox() { + final releasePageUrl = widget.releasePageUrl; + final downloadUrl = widget.downloadUrl; msgBox( gFFI.sessionId, 'custom-nocancel', @@ -234,30 +271,30 @@ class UpdateProgressState extends State { '{$appName}-to-update-tip', '', gFFI.dialogManager, - onSubmit: () { + onSubmit: () async { debugPrint('Downloaded, update to new version now'); - bind.mainSetCommon(key: 'update-me', value: widget.downloadUrl); + platformFFI.registerEventHandler(_eventKeyUpdateMe, _eventKeyUpdateMe, + (evt) => _handleUpdateMe(releasePageUrl, evt), + replace: true); + try { + await bind.mainSetCommon(key: 'update-me', value: downloadUrl); + } catch (error) { + platformFFI.unregisterEventHandler( + _eventKeyUpdateMe, _eventKeyUpdateMe); + _showUpdateError(releasePageUrl, error.toString()); + } }, submitTimeout: 5, ); } - Future handleExtractUpdateDmg(Map evt) async { - _isExtracting.value = false; - if (evt.containsKey('err') && (evt['err'] as String).isNotEmpty) { - _onError(evt['err'] as String, isExtractDmg: true); - } else { - updateMsgBox(); - } - } - @override Widget build(BuildContext context) { getValue() => _totalSize == null ? 0.0 : (_totalSize == 0 ? 1.0 : _downloadedSize / _totalSize!); return LinearProgressIndicator( - value: _isExtracting.isTrue ? null : getValue(), + value: getValue(), minHeight: 20, borderRadius: BorderRadius.circular(5), backgroundColor: Colors.grey[300], diff --git a/libs/portable/Cargo.toml b/libs/portable/Cargo.toml index 165d98349..8ea2f100c 100644 --- a/libs/portable/Cargo.toml +++ b/libs/portable/Cargo.toml @@ -20,6 +20,7 @@ windows = { version = "0.61", features = [ "Wdk_System", "Wdk_System_SystemServices", "Win32", + "Win32_Storage_FileSystem", "Win32_System", "Win32_System_SystemInformation", ] } @@ -36,4 +37,3 @@ FileDescription = "RustDesk Remote Desktop" winres = "0.1" winapi = { version = "0.3", features = [ "winnt", "pdh", "synchapi" ] } - diff --git a/libs/portable/src/main.rs b/libs/portable/src/main.rs index 284dd5ee2..34dff54b7 100644 --- a/libs/portable/src/main.rs +++ b/libs/portable/src/main.rs @@ -19,6 +19,8 @@ const APP_METADATA_CONFIG: &str = "meta.toml"; const META_LINE_PREFIX_TIMESTAMP: &str = "timestamp = "; const META_LINE_PREFIX_FILE: &str = "file = "; const APP_PREFIX: &str = "rustdesk"; +#[cfg(windows)] +const VERIFIED_UPDATE_FILE_PREFIX: &str = "rustdesk-verified-"; const APPNAME_RUNTIME_ENV_KEY: &str = "RUSTDESK_APPNAME"; #[cfg(windows)] const SET_FOREGROUND_WINDOW_ENV_KEY: &str = "SET_FOREGROUND_WINDOW"; @@ -287,6 +289,13 @@ fn main() -> Result<(), String> { #[cfg(not(windows))] let quick_support = false; + #[cfg(windows)] + if args.first().is_some_and(|arg| arg == "--update") { + if let Err(err) = win::schedule_verified_update_cleanup() { + eprintln!("Failed to schedule verified update file cleanup: {}", err); + } + } + let mut ui = false; let reader = BinaryReader::new()?; if let Some(exe) = setup( @@ -314,6 +323,33 @@ mod win { pub const RUNTIME_BROKER_EXE: &'static str = "C:\\Windows\\System32\\RuntimeBroker.exe"; pub const WIN_TOPMOST_INJECTED_PROCESS_EXE: &'static str = "RuntimeBroker_rustdesk.exe"; + pub(super) fn schedule_verified_update_cleanup() -> std::io::Result<()> { + use windows::{ + core::{HSTRING, PCWSTR}, + Win32::Storage::FileSystem::{MoveFileExW, MOVEFILE_DELAY_UNTIL_REBOOT}, + }; + + let current_exe = std::env::current_exe()?; + let is_verified_update = current_exe + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| { + name.starts_with(super::VERIFIED_UPDATE_FILE_PREFIX) && name.ends_with(".exe") + }); + if !is_verified_update { + return Ok(()); + } + + unsafe { + MoveFileExW( + &HSTRING::from(current_exe.as_path()), + PCWSTR::null(), + MOVEFILE_DELAY_UNTIL_REBOOT, + ) + } + .map_err(std::io::Error::other) + } + pub(super) fn copy_runtime_broker(dir: &Path) { let src = RUNTIME_BROKER_EXE; let tgt = WIN_TOPMOST_INJECTED_PROCESS_EXE; diff --git a/res/update/generate_update_metadata.py b/res/update/generate_update_metadata.py new file mode 100644 index 000000000..d3436e9f1 --- /dev/null +++ b/res/update/generate_update_metadata.py @@ -0,0 +1,297 @@ +#!/usr/bin/env python3 +import argparse +import base64 +import binascii +import hashlib +import json +import os +import re +from pathlib import Path + +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ed25519 + +from update_metadata_policy import ( + release_artifact_url, + validate_artifact_file_name, + validate_package_id, + validate_published_at, + validate_release, + validate_release_base_url, +) + +APP_NAME = "rustdesk" +SCHEMA_VERSION = 1 +SIGNATURE_ALGORITHM = "ed25519" +SIGNATURE_CONTEXT = b"RustDesk update metadata v1\n" +KEY_ID = "2026-ed25519-main" +SEED_ENV = "RUSTDESK_UPDATE_ED25519_SEED" +PUBLIC_KEY_ENV = "RUSTDESK_UPDATE_ED25519_PUBLIC_KEY" + + +def fail(message): + raise SystemExit(message) + + +def stable_json(value): + return json.dumps(value, separators=(",", ":"), sort_keys=True).encode("utf-8") + + +def write_bytes(path, data): + output = Path(path) + output.parent.mkdir(parents=True, exist_ok=True) + output.write_bytes(data) + + +def sha256_hex(path): + hasher = hashlib.sha256() + with Path(path).open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + hasher.update(chunk) + return hasher.hexdigest() + + +def decode_env(name, expected_size): + encoded = os.environ.get(name) + if not encoded: + fail(f"missing environment variable: {name}") + try: + value = base64.b64decode(encoded, validate=True) + except (ValueError, binascii.Error) as error: + fail(f"invalid base64 in {name}: {error}") + if len(value) != expected_size: + fail(f"{name} must decode to {expected_size} bytes") + if base64.b64encode(value).decode("ascii") != encoded: + fail(f"{name} must use canonical base64") + return value + + +def validate_or_fail(function, *args): + try: + return function(*args) + except ValueError as error: + fail(str(error)) + + +def artifact_metadata(spec, release_id, release_base_url): + platform, arch, file_format, raw_path = spec + if not all(value.strip() for value in (platform, arch, file_format)): + fail("artifact selector fields must not be empty") + path = Path(raw_path) + file_name = path.name + validate_or_fail(validate_artifact_file_name, file_name) + size = path.stat().st_size + if size == 0: + fail(f"artifact must not be empty: {file_name}") + return { + "platform": platform, + "arch": arch, + "format": file_format, + "url": release_artifact_url(release_base_url, release_id, file_name), + "file_name": file_name, + "size": size, + "sha256": sha256_hex(path), + } + + +def command_sign(args): + metadata_out = Path(args.metadata_out).resolve() + signature_out = Path(args.signature_out).resolve() + if metadata_out == signature_out: + fail("metadata and signature outputs must be different files") + validate_or_fail(validate_release, args.release_id, args.version) + validate_or_fail(validate_published_at, args.published_at) + package_id = validate_or_fail(validate_package_id, args.package_id) + release_base_url = validate_or_fail( + validate_release_base_url, args.release_base_url + ) + artifacts = [ + artifact_metadata(spec, args.release_id, release_base_url) + for spec in args.artifact + ] + selectors = [(item["platform"], item["arch"], item["format"]) for item in artifacts] + names = [item["file_name"] for item in artifacts] + if len(set(selectors)) != len(selectors): + fail("duplicate artifact selector") + if len(set(names)) != len(names): + fail("duplicate artifact file name") + artifacts.sort(key=lambda item: (item["platform"], item["arch"], item["format"])) + metadata = { + "schema_version": SCHEMA_VERSION, + "app": APP_NAME, + "package_id": package_id, + "version": args.version, + "release_id": args.release_id, + "published_at": args.published_at, + "signature_key_id": KEY_ID, + "artifacts": artifacts, + } + private_key = ed25519.Ed25519PrivateKey.from_private_bytes(decode_env(SEED_ENV, 32)) + metadata_bytes = stable_json(metadata) + signature = { + "schema_version": SCHEMA_VERSION, + "algorithm": SIGNATURE_ALGORITHM, + "key_id": KEY_ID, + "signature": base64.b64encode( + private_key.sign(SIGNATURE_CONTEXT + metadata_bytes) + ).decode("ascii"), + } + write_bytes(metadata_out, metadata_bytes) + write_bytes(signature_out, stable_json(signature)) + + +def verify_metadata_signature(metadata_bytes, signature): + if ( + signature.get("schema_version") != SCHEMA_VERSION + or signature.get("algorithm") != SIGNATURE_ALGORITHM + or signature.get("key_id") != KEY_ID + ): + fail("invalid signature metadata") + public_key = ed25519.Ed25519PublicKey.from_public_bytes(decode_env(PUBLIC_KEY_ENV, 32)) + try: + public_key.verify( + base64.b64decode(signature["signature"], validate=True), + SIGNATURE_CONTEXT + metadata_bytes, + ) + except (InvalidSignature, ValueError, binascii.Error): + fail("invalid metadata signature") + + +def validated_artifacts(metadata, local_artifacts): + artifacts = metadata.get("artifacts") + if not isinstance(artifacts, list) or not artifacts: + fail("metadata must contain artifacts") + selector_fields = ("platform", "arch", "format") + required_fields = selector_fields + ("file_name", "url", "size", "sha256") + if any( + not isinstance(artifact, dict) + or any(field not in artifact for field in required_fields) + for artifact in artifacts + ): + fail("artifact metadata is missing required fields") + names = [artifact.get("file_name") for artifact in artifacts] + for file_name in names: + validate_or_fail(validate_artifact_file_name, file_name) + selectors = [ + tuple(artifact[field] for field in selector_fields) + for artifact in artifacts + ] + if any( + not all(isinstance(value, str) and value.strip() for value in selector) + for selector in selectors + ): + fail("artifact selector fields must be non-empty strings") + if len(set(names)) != len(names) or set(names) != set(local_artifacts): + fail("artifact file set mismatch") + if len(set(selectors)) != len(selectors): + fail("duplicate artifact selector") + return artifacts + + +def command_verify(args): + metadata_bytes = Path(args.metadata).read_bytes() + metadata = json.loads(metadata_bytes) + signature = json.loads(Path(args.signature).read_bytes()) + verify_metadata_signature(metadata_bytes, signature) + package_id = validate_or_fail(validate_package_id, args.package_id) + release_base_url = validate_or_fail( + validate_release_base_url, args.release_base_url + ) + expected_fields = { + "schema_version": SCHEMA_VERSION, + "app": APP_NAME, + "package_id": package_id, + "version": args.version, + "release_id": args.release_id, + "signature_key_id": KEY_ID, + } + if any(metadata.get(key) != value for key, value in expected_fields.items()): + fail("metadata does not match the release") + validate_or_fail(validate_release, args.release_id, args.version) + validate_or_fail(validate_published_at, metadata.get("published_at", "")) + local_paths = [Path(path) for path in args.artifact] + local_artifacts = {path.name: path for path in local_paths} + if len(local_artifacts) != len(local_paths): + fail("duplicate local artifact file name") + artifacts = validated_artifacts(metadata, local_artifacts) + for artifact in artifacts: + file_name = artifact["file_name"] + expected_url = release_artifact_url( + release_base_url, args.release_id, file_name + ) + if artifact["url"] != expected_url: + fail(f"artifact URL mismatch for {file_name}") + path = local_artifacts[file_name] + if path.stat().st_size != artifact["size"]: + fail(f"artifact size mismatch for {file_name}") + if sha256_hex(path) != artifact["sha256"]: + fail(f"artifact sha256 mismatch for {file_name}") + + +def command_check_key(args): + seed = decode_env(SEED_ENV, 32) + configured_key = decode_env(PUBLIC_KEY_ENV, 32) + derived_key = ed25519.Ed25519PrivateKey.from_private_bytes(seed).public_key() + derived_key_bytes = derived_key.public_bytes( + serialization.Encoding.Raw, + serialization.PublicFormat.Raw, + ) + if derived_key_bytes != configured_key: + fail(f"{SEED_ENV} does not match {PUBLIC_KEY_ENV}") + + source = Path(args.rust_source).read_text(encoding="utf-8") + match = re.search( + rf'TrustedUpdateKey\s*\{{[^{{}}]*key_id:\s*"{KEY_ID}"[^{{}}]*public_key:\s*\[([^\]]+)\]', + source, + re.S, + ) + if not match: + fail("failed to find embedded update public key") + try: + embedded_key = bytes( + int(part) for part in match.group(1).split(",") if part.strip() + ) + except ValueError as error: + fail(f"invalid embedded update public key: {error}") + if embedded_key != configured_key: + fail(f"embedded update public key does not match {PUBLIC_KEY_ENV}") + + +def build_parser(): + parser = argparse.ArgumentParser() + commands = parser.add_subparsers(dest="command", required=True) + sign = commands.add_parser("sign") + sign.add_argument("--artifact", action="append", nargs=4, required=True) + sign.add_argument("--version", required=True) + sign.add_argument("--release-id", required=True) + sign.add_argument("--package-id", required=True) + sign.add_argument("--release-base-url", required=True) + sign.add_argument("--published-at", required=True) + sign.add_argument("--metadata-out", required=True) + sign.add_argument("--signature-out", required=True) + sign.set_defaults(func=command_sign) + verify = commands.add_parser("verify") + verify.add_argument("--metadata", required=True) + verify.add_argument("--signature", required=True) + verify.add_argument("--artifact", action="append", required=True) + verify.add_argument("--version", required=True) + verify.add_argument("--release-id", required=True) + verify.add_argument("--package-id", required=True) + verify.add_argument("--release-base-url", required=True) + verify.set_defaults(func=command_verify) + check_key = commands.add_parser("check-key") + check_key.add_argument( + "--rust-source", default="src/update_metadata.rs" + ) + check_key.set_defaults(func=command_check_key) + return parser + + +def main(): + args = build_parser().parse_args() + args.func(args) + + +if __name__ == "__main__": + main() diff --git a/res/update/test_generate_update_metadata.py b/res/update/test_generate_update_metadata.py new file mode 100644 index 000000000..e632deefd --- /dev/null +++ b/res/update/test_generate_update_metadata.py @@ -0,0 +1,300 @@ +import base64 +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ed25519 + +SCRIPT = Path(__file__).with_name("generate_update_metadata.py") +OFFICIAL_RELEASE_BASE_URL = "https://github.com/rustdesk/rustdesk/releases/download" + + +class GenerateUpdateMetadataTest(unittest.TestCase): + def setUp(self): + self.temp_dir = tempfile.TemporaryDirectory() + self.root = Path(self.temp_dir.name) + private_key = ed25519.Ed25519PrivateKey.generate() + seed = private_key.private_bytes( + serialization.Encoding.Raw, + serialization.PrivateFormat.Raw, + serialization.NoEncryption(), + ) + public_key = private_key.public_key().public_bytes( + serialization.Encoding.Raw, + serialization.PublicFormat.Raw, + ) + self.seed = base64.b64encode(seed).decode("ascii") + self.public_key = base64.b64encode(public_key).decode("ascii") + + def tearDown(self): + self.temp_dir.cleanup() + + def run_script(self, *args, seed=None, public_key=None): + env = os.environ.copy() + env.pop("RUSTDESK_UPDATE_ED25519_SEED", None) + env.pop("RUSTDESK_UPDATE_ED25519_PUBLIC_KEY", None) + if seed is not None: + env["RUSTDESK_UPDATE_ED25519_SEED"] = seed + if public_key is not None: + env["RUSTDESK_UPDATE_ED25519_PUBLIC_KEY"] = public_key + return subprocess.run( + [sys.executable, str(SCRIPT), *args], + cwd=SCRIPT.parents[2], + env=env, + text=True, + capture_output=True, + ) + + def artifact(self, name="rustdesk-1.4.6-x86_64.exe", data=b"rustdesk"): + path = self.root / name + path.write_bytes(data) + return path + + def sign( + self, + artifacts, + *, + version="1.4.6", + release_id="v1.4.6", + package_id="rustdesk", + release_base_url=OFFICIAL_RELEASE_BASE_URL, + seed=None, + ): + metadata = self.root / "rustdesk-update.json" + signature = self.root / "rustdesk-update.json.sig" + args = ["sign"] + for platform, arch, file_format, path in artifacts: + args.extend(["--artifact", platform, arch, file_format, str(path)]) + args.extend( + [ + "--version", + version, + "--release-id", + release_id, + "--package-id", + package_id, + "--release-base-url", + release_base_url, + "--published-at", + "2026-05-14T00:00:00Z", + "--metadata-out", + str(metadata), + "--signature-out", + str(signature), + ] + ) + result = self.run_script(*args, seed=self.seed if seed is None else seed) + return metadata, signature, result + + def verify( + self, + metadata, + signature, + artifacts, + *, + public_key=None, + package_id="rustdesk", + release_base_url=OFFICIAL_RELEASE_BASE_URL, + ): + args = [ + "verify", + "--metadata", + str(metadata), + "--signature", + str(signature), + "--version", + "1.4.6", + "--release-id", + "v1.4.6", + "--package-id", + package_id, + "--release-base-url", + release_base_url, + ] + for artifact in artifacts: + args.extend(["--artifact", str(artifact)]) + return self.run_script( + *args, + public_key=public_key or self.public_key, + ) + + def rust_source(self, public_key=None): + key_bytes = base64.b64decode(public_key or self.public_key) + source = self.root / "update_metadata.rs" + source.write_text( + 'TrustedUpdateKey { key_id: "2026-ed25519-main", public_key: [' + + ",".join(str(byte) for byte in key_bytes) + + "] }", + encoding="utf-8", + ) + return source + + def test_signs_and_verifies_release_artifacts(self): + exe = self.artifact() + dmg = self.artifact("rustdesk-1.4.6-aarch64.dmg", b"dmg") + specs = [("windows", "x86_64", "exe", exe), ("macos", "aarch64", "dmg", dmg)] + + metadata, signature, signed = self.sign(specs) + verified = self.verify(metadata, signature, [exe, dmg]) + + self.assertEqual(signed.returncode, 0, signed.stderr) + self.assertEqual(verified.returncode, 0, verified.stderr) + data = json.loads(metadata.read_text(encoding="utf-8")) + self.assertEqual(data["release_id"], "v1.4.6") + self.assertEqual({item["file_name"] for item in data["artifacts"]}, {exe.name, dmg.name}) + + def test_verification_rejects_tampering(self): + artifact = self.artifact() + metadata, signature, _ = self.sign([("windows", "x86_64", "exe", artifact)]) + artifact.write_bytes(b"tampered") + self.assertNotEqual(self.verify(metadata, signature, [artifact]).returncode, 0) + + artifact.write_bytes(b"rustdesk") + data = json.loads(metadata.read_text(encoding="utf-8")) + data["published_at"] = "2026-05-15T00:00:00Z" + metadata.write_text(json.dumps(data), encoding="utf-8") + self.assertNotEqual(self.verify(metadata, signature, [artifact]).returncode, 0) + + def test_verification_rejects_wrong_public_key(self): + artifact = self.artifact() + metadata, signature, _ = self.sign([("windows", "x86_64", "exe", artifact)]) + wrong_key = base64.b64encode(b"x" * 32).decode("ascii") + self.assertNotEqual( + self.verify( + metadata, + signature, + [artifact], + public_key=wrong_key, + ).returncode, + 0, + ) + + def test_sign_rejects_invalid_release_inputs(self): + artifact = self.artifact() + spec = [("windows", "x86_64", "exe", artifact)] + self.assertNotEqual(self.sign(spec, version="1.4.7")[2].returncode, 0) + for release_id in ("bad/tag", "bad\tid", "bad%zz"): + self.assertNotEqual(self.sign(spec, release_id=release_id)[2].returncode, 0) + self.assertNotEqual(self.sign(spec, seed="invalid")[2].returncode, 0) + duplicate = spec + [("windows", "x86_64", "exe", artifact)] + self.assertNotEqual(self.sign(duplicate)[2].returncode, 0) + + def test_signs_and_verifies_custom_release_identity(self): + package_id = "com.example.rustdesk-custom" + release_base_url = "https://updates.example.com/releases/download" + artifact = self.artifact("rustdesk-custom-1.4.6-aarch64.dmg", b"dmg") + + metadata, signature, signed = self.sign( + [("macos", "aarch64", "dmg", artifact)], + package_id=package_id, + release_base_url=release_base_url, + ) + verified = self.verify( + metadata, + signature, + [artifact], + package_id=package_id, + release_base_url=release_base_url, + ) + + self.assertEqual(signed.returncode, 0, signed.stderr) + self.assertEqual(verified.returncode, 0, verified.stderr) + data = json.loads(metadata.read_text(encoding="utf-8")) + self.assertEqual(data["package_id"], package_id) + self.assertEqual( + data["artifacts"][0]["url"], + f"{release_base_url}/v1.4.6/{artifact.name}", + ) + + def test_rejects_unsafe_custom_release_policy(self): + artifact = self.artifact("rustdesk-custom-1.4.6-aarch64.dmg", b"dmg") + spec = [("macos", "aarch64", "dmg", artifact)] + + for package_id in ("", "com.example/custom", "com.example custom"): + with self.subTest(package_id=package_id): + self.assertNotEqual( + self.sign(spec, package_id=package_id)[2].returncode, + 0, + ) + for release_base_url in ( + "http://updates.example.com/releases", + "https://user@updates.example.com/releases", + "https://updates.example.com/releases/", + "https://updates.example.com/releases?channel=stable", + "https://updates.example.com/relea\nses", + ): + with self.subTest(release_base_url=release_base_url): + self.assertNotEqual( + self.sign(spec, release_base_url=release_base_url)[2].returncode, + 0, + ) + + def test_checks_embedded_public_key(self): + source = self.rust_source() + result = self.run_script( + "check-key", + "--rust-source", + str(source), + seed=self.seed, + public_key=self.public_key, + ) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_check_key_rejects_invalid_or_mismatched_seed(self): + source = self.rust_source() + invalid_seeds = [ + None, + "invalid", + base64.b64encode(b"x" * 31).decode("ascii"), + base64.b64encode(b"x" * 32).decode("ascii"), + ] + + for seed in invalid_seeds: + with self.subTest(seed=seed): + result = self.run_script( + "check-key", + "--rust-source", + str(source), + seed=seed, + public_key=self.public_key, + ) + self.assertNotEqual(result.returncode, 0) + + def test_check_key_rejects_invalid_public_key(self): + source = self.rust_source() + invalid_keys = [ + None, + "invalid", + base64.b64encode(b"x" * 31).decode("ascii"), + ] + + for public_key in invalid_keys: + with self.subTest(public_key=public_key): + result = self.run_script( + "check-key", + "--rust-source", + str(source), + seed=self.seed, + public_key=public_key, + ) + self.assertNotEqual(result.returncode, 0) + + def test_check_key_rejects_mismatched_embedded_key(self): + embedded_key = base64.b64encode(b"x" * 32).decode("ascii") + result = self.run_script( + "check-key", + "--rust-source", + str(self.rust_source(embedded_key)), + seed=self.seed, + public_key=self.public_key, + ) + + self.assertNotEqual(result.returncode, 0) + +if __name__ == "__main__": + unittest.main() diff --git a/res/update/test_macos_update_scripts.py b/res/update/test_macos_update_scripts.py new file mode 100644 index 000000000..dce09e14d --- /dev/null +++ b/res/update/test_macos_update_scripts.py @@ -0,0 +1,104 @@ +import plistlib +import socket +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[2] +MACOS_SOURCE = (REPOSITORY_ROOT / "src/platform/macos.rs").read_text( + encoding="utf-8" +) +DAEMON_SCRIPT = ( + REPOSITORY_ROOT / "src/platform/privileges_scripts/update.scpt" +).read_text(encoding="utf-8") +MANUAL_SCRIPT = MACOS_SOURCE.split( + 'const PRIVILEGED_UPDATE_BODY: &str = r#"', 1 +)[1].split('"#;', 1)[0] + + +def write_bundle_id(app, bundle_id): + if bundle_id is None: + return + info = app / "Contents/Info.plist" + info.parent.mkdir(parents=True) + info.write_bytes(plistlib.dumps({"CFBundleIdentifier": bundle_id})) + + +class MacosUpdateScriptTests(unittest.TestCase): + @unittest.skipUnless(sys.platform == "darwin", "requires macOS AppleScript") + def test_daemon_update_rejects_stale_ipc_sockets(self): + with tempfile.TemporaryDirectory(dir="/tmp") as directory: + for check in ("check_service", "check_agent"): + with self.subTest(check=check), socket.socket( + socket.AF_UNIX, socket.SOCK_STREAM + ) as listener: + path = str(Path(directory) / check) + listener.bind(path) + listener.listen(1) + script = DAEMON_SCRIPT.split(" set sh to", 1)[0] + script += f" return {check}\nend run\n" + script = script.replace("/tmp/RustDesk-service/ipc_service", path) + script = script.replace("/tmp/RustDesk-$uid/ipc", path) + command = subprocess.check_output( + ["osascript", "-e", script, "test", "0", "", ""], + text=True, + timeout=10, + ) + command = "launchctl() { echo 'state = running'; }; " + command + args = ["/bin/sh", "-c", command] + self.assertEqual(subprocess.call(args, timeout=10), 0) + listener.setblocking(False) + with self.assertRaises(BlockingIOError): + connection, _ = listener.accept() + connection.close() + listener.close() + self.assertNotEqual(subprocess.call(args, timeout=10), 0) + + @unittest.skipUnless(sys.platform == "darwin", "requires macOS AppleScript") + def test_manual_update_requires_matching_bundle_identity(self): + validation = next( + line for line in MANUAL_SCRIPT.splitlines() + if "set validate_verified_app" in line + ) + script = "\n".join([ + "on run {app_bundle, expected_bundle_id}", + "set app_bundle_q to quoted form of app_bundle", + 'set installed_info_q to quoted form of (app_bundle & "/Contents/Info.plist")', + "set expected_bundle_id_q to quoted form of expected_bundle_id", + validation, + "return validate_verified_app", + "end run", + ]) + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + root = Path(directory.name) + bundle_id = "com.carriez.rustdesk" + cases = ( + ("new", None, bundle_id, 0), + ("installed", bundle_id, bundle_id, 0), + ("wrong_candidate", None, "example.other", 1), + ("wrong_destination", "example.other", bundle_id, 1), + ) + for name, installed_id, candidate_id, exit_code in cases: + with self.subTest(case=name): + installed = root / name / "installed.app" + candidate = root / name / "candidate.app" + write_bundle_id(installed, installed_id) + write_bundle_id(candidate, candidate_id) + command = subprocess.check_output( + ["osascript", "-e", script, str(installed), bundle_id], + text=True, timeout=10, + ) + result = subprocess.run( + ["/bin/sh", "-c", 'set -e; verified_app="$1"; ' + command, + "update-test", str(candidate)], + text=True, capture_output=True, timeout=10, + ) + self.assertEqual(result.returncode, exit_code, result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/res/update/test_update_metadata_cli.py b/res/update/test_update_metadata_cli.py new file mode 100644 index 000000000..b55fa5250 --- /dev/null +++ b/res/update/test_update_metadata_cli.py @@ -0,0 +1,74 @@ +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +SCRIPT = Path(__file__).with_name("generate_update_metadata.py") + + +class UpdateMetadataCliTests(unittest.TestCase): + def test_sign_and_verify_require_update_identity_policy(self): + commands = ( + [ + "sign", + "--artifact", "macos", "aarch64", "dmg", "missing.dmg", + "--version", "1.4.6", + "--release-id", "v1.4.6", + "--published-at", "invalid", + "--metadata-out", "metadata.json", + "--signature-out", "signature.json", + ], + [ + "verify", + "--metadata", "missing.json", + "--signature", "missing.sig", + "--artifact", "missing.dmg", + "--version", "1.4.6", + "--release-id", "v1.4.6", + ], + ) + + for command in commands: + with self.subTest(command=command[0]): + result = subprocess.run( + [sys.executable, str(SCRIPT), *command], + text=True, + capture_output=True, + ) + self.assertIn("the following arguments are required", result.stderr) + self.assertIn("--package-id", result.stderr) + self.assertIn("--release-base-url", result.stderr) + + def test_sign_rejects_empty_artifact(self): + with tempfile.TemporaryDirectory() as directory: + artifact = Path(directory) / "rustdesk-1.4.6-aarch64.dmg" + artifact.touch() + result = subprocess.run( + [ + sys.executable, + str(SCRIPT), + "sign", + "--artifact", "macos", "aarch64", "dmg", str(artifact), + "--version", "1.4.6", + "--release-id", "v1.4.6", + "--package-id", "rustdesk", + "--release-base-url", + "https://github.com/rustdesk/rustdesk/releases/download", + "--published-at", "2026-05-14T00:00:00Z", + "--metadata-out", str(Path(directory) / "metadata.json"), + "--signature-out", str(Path(directory) / "metadata.json.sig"), + ], + env=os.environ.copy(), + text=True, + capture_output=True, + ) + + self.assertNotEqual(0, result.returncode) + self.assertIn("artifact must not be empty", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/res/update/update_metadata_policy.py b/res/update/update_metadata_policy.py new file mode 100644 index 000000000..23c306a3d --- /dev/null +++ b/res/update/update_metadata_policy.py @@ -0,0 +1,100 @@ +import re +from datetime import datetime +from string import ascii_letters, digits +from urllib.parse import urlsplit + +PACKAGE_ID_CHARACTERS = frozenset(ascii_letters + digits + ".-") +RFC3339_TIMESTAMP = re.compile( + r"[0-9]{4}-[0-9]{2}-[0-9]{2}[Tt][0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]+)?(?:[Zz]|[+-][0-9]{2}:[0-9]{2})" +) +ARTIFACT_FILE_NAME_PATTERN = re.compile(r"[A-Za-z0-9._-]+") + + +def validate_release(release_id, version): + if ( + not release_id + or not release_id.isascii() + or release_id in {".", ".."} + or any( + char.isspace() or not char.isprintable() or char in "/\\?%#" + for char in release_id + ) + ): + raise ValueError(f"invalid release id: {release_id}") + match = re.fullmatch(r"v?([0-9]+)\.([0-9]+)\.([0-9]+)", release_id) + if not match: + return + segments = [int(part) for part in match.groups()] + if any(part > 65535 for part in segments) or not any(segments): + raise ValueError(f"invalid release version: {release_id}") + display_version = ".".join(match.groups()) + if display_version != version: + raise ValueError( + f"release id {release_id} maps to {display_version}, not {version}" + ) + + +def validate_published_at(value): + if not RFC3339_TIMESTAMP.fullmatch(value): + raise ValueError("published_at must be an RFC 3339 timestamp") + normalized = value[:-1] + "+00:00" if value[-1] in "Zz" else value + try: + datetime.fromisoformat(normalized) + except ValueError as error: + raise ValueError( + "published_at must be an RFC 3339 timestamp" + ) from error + + +def validate_artifact_file_name(file_name): + if ( + not isinstance(file_name, str) + or file_name in {".", ".."} + or not ARTIFACT_FILE_NAME_PATTERN.fullmatch(file_name) + ): + raise ValueError(f"invalid artifact file name: {file_name}") + + +def validate_package_id(value): + if ( + not isinstance(value, str) + or not value + or not value.isascii() + or any(char not in PACKAGE_ID_CHARACTERS for char in value) + or not any(char.isalnum() for char in value) + ): + raise ValueError(f"invalid package id: {value}") + return value + + +def validate_release_base_url(value): + if ( + not isinstance(value, str) + or not value + or not value.isascii() + or any(char.isspace() or not char.isprintable() for char in value) + or value.endswith("/") + or "\\" in value + or "?" in value + or "#" in value + ): + raise ValueError(f"invalid release base URL: {value}") + try: + parsed = urlsplit(value) + parsed.port + except ValueError as error: + raise ValueError(f"invalid release base URL: {value}") from error + if ( + parsed.scheme != "https" + or parsed.hostname is None + or parsed.username is not None + or parsed.password is not None + ): + raise ValueError( + "release base URL must be HTTPS and must not contain credentials" + ) + return value + + +def release_artifact_url(release_base_url, release_id, file_name): + return f"{release_base_url}/{release_id}/{file_name}" diff --git a/res/update/update_metadata_requirements.txt b/res/update/update_metadata_requirements.txt new file mode 100644 index 000000000..1f07aa535 --- /dev/null +++ b/res/update/update_metadata_requirements.txt @@ -0,0 +1,7 @@ +# Locked for the CPython 3.12 Linux x86_64 release-signing runner. +cryptography==50.0.0 \ + --hash=sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f +cffi==2.1.0 \ + --hash=sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66 +pycparser==3.0 \ + --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992 diff --git a/src/common.rs b/src/common.rs index 3e761322a..fa5c20334 100644 --- a/src/common.rs +++ b/src/common.rs @@ -37,7 +37,7 @@ use hbb_common::{ use crate::{ hbbs_http::{create_http_client_async, get_url_for_tls}, - ui_interface::{get_api_server as ui_get_api_server, get_option, is_installed, set_option}, + ui_interface::{get_api_server as ui_get_api_server, get_option, set_option}, }; #[derive(Debug, Eq, PartialEq)] @@ -1020,39 +1020,94 @@ pub fn check_software_update() { } } -// No need to check `danger_accept_invalid_cert` for now. -// Because the url is always `https://api.rustdesk.com/version/latest`. -#[tokio::main(flavor = "current_thread")] -pub async fn do_check_software_update() -> hbb_common::ResultType<()> { - let (request, url) = - hbb_common::version_check_request(hbb_common::VER_TYPE_RUSTDESK_CLIENT.to_string()); - let proxy_conf = Config::get_socks(); - let tls_url = get_url_for_tls(&url, &proxy_conf); - let tls_type = get_cached_tls_type(tls_url); - let is_tls_not_cached = tls_type.is_none(); - let tls_type = tls_type.unwrap_or(TlsType::Rustls); - let client = create_http_client_async(tls_type, false); - let latest_release_response = match client.post(&url).json(&request).send().await { - Ok(resp) => { - upsert_tls_cache(tls_url, tls_type, false); - resp - } - Err(err) => { - if is_tls_not_cached && err.is_request() { - let tls_type = TlsType::NativeTls; - let client = create_http_client_async(tls_type, false); - let resp = client.post(&url).json(&request).send().await?; - upsert_tls_cache(tls_url, tls_type, false); - resp - } else { - return Err(err.into()); - } - } +pub(crate) fn release_id_from_update_url(update_url: &str) -> ResultType { + let url = url::Url::parse(update_url)?; + if url.scheme() != "https" || url.host_str() != Some("github.com") { + bail!( + "Update URL is not a GitHub HTTPS release URL: {}", + update_url + ); + } + if url_has_explicit_port(update_url) + || url.port().is_some() + || !url.username().is_empty() + || url.password().is_some() + { + bail!( + "Update URL must not contain credentials or an explicit port: {}", + update_url + ); + } + if url.query().is_some() || url.fragment().is_some() { + bail!( + "Update URL must not contain query or fragment: {}", + update_url + ); + } + + let Some(segments) = url.path_segments() else { + bail!("Update URL has no path: {}", update_url); }; - let bytes = latest_release_response.bytes().await?; + let segments = segments.collect::>(); + let release_id = match segments.as_slice() { + ["rustdesk", "rustdesk", "releases", "tag", release_id] => *release_id, + ["rustdesk", "rustdesk", "releases", "tag", release_id, ""] => *release_id, + _ => bail!( + "Update URL is not a RustDesk release tag URL: {}", + update_url + ), + }; + if release_id.is_empty() + || release_id == "." + || release_id == ".." + || release_id.contains([' ', '/', '\\', '?', '#']) + || release_id.contains('%') + { + bail!("Update URL has no release id: {}", update_url); + } + Ok(release_id.to_owned()) +} + +pub(crate) fn url_has_explicit_port(raw_url: &str) -> bool { + let Some((_, rest)) = raw_url.split_once("://") else { + return false; + }; + let authority = rest + .split(|c| matches!(c, '/' | '?' | '#')) + .next() + .unwrap_or_default(); + let host_port = authority.rsplit('@').next().unwrap_or(authority); + host_port.rsplit_once(':').is_some() +} + +pub(crate) fn display_version_from_release_id(release_id: &str) -> ResultType { + let display_version = release_id.strip_prefix('v').unwrap_or(release_id); + let segments = display_version.split('.').collect::>(); + if segments.len() != 3 + || segments.iter().any(|segment| { + segment.is_empty() + || !segment.chars().all(|c| c.is_ascii_digit()) + || segment.parse::().is_err() + }) + { + bail!("Release id is not a stable display version: {}", release_id); + } + if get_version_number(display_version) <= 0 { + bail!("Release id has invalid display version: {}", release_id); + } + Ok(display_version.to_owned()) +} + +fn clear_software_update_url() { + let mut update_url = SOFTWARE_UPDATE_URL.lock().unwrap(); + *update_url = String::new(); +} + +fn process_software_update_check_response(bytes: Bytes) -> ResultType<()> { let resp: hbb_common::VersionCheckResponse = serde_json::from_slice(&bytes)?; let response_url = resp.url; - let latest_release_version = response_url.rsplit('/').next().unwrap_or_default(); + let release_id = release_id_from_update_url(&response_url)?; + let latest_release_version = display_version_from_release_id(&release_id)?; if get_version_number(&latest_release_version) > get_version_number(crate::VERSION) { #[cfg(feature = "flutter")] @@ -1066,11 +1121,60 @@ pub async fn do_check_software_update() -> hbb_common::ResultType<()> { } *SOFTWARE_UPDATE_URL.lock().unwrap() = response_url; } else { - *SOFTWARE_UPDATE_URL.lock().unwrap() = "".to_string(); + clear_software_update_url(); } Ok(()) } +// No need to check `danger_accept_invalid_cert` for now. +// Because the url is always `https://api.rustdesk.com/version/latest`. +#[tokio::main(flavor = "current_thread")] +pub async fn do_check_software_update() -> hbb_common::ResultType<()> { + let (request, url) = + hbb_common::version_check_request(hbb_common::VER_TYPE_RUSTDESK_CLIENT.to_string()); + let proxy_conf = Config::get_socks(); + let tls_url = get_url_for_tls(&url, &proxy_conf); + let tls_type = get_cached_tls_type(tls_url); + let is_tls_not_cached = tls_type.is_none(); + let tls_type = tls_type.unwrap_or(TlsType::Rustls); + let client = create_http_client_async(tls_type, false); + let latest_release_response: ResultType<_> = match client.post(&url).json(&request).send().await + { + Ok(resp) => Ok((tls_type, resp)), + Err(err) => { + if is_tls_not_cached && err.is_request() { + let tls_type = TlsType::NativeTls; + let client = create_http_client_async(tls_type, false); + match client.post(&url).json(&request).send().await { + Ok(resp) => Ok((tls_type, resp)), + Err(err) => Err(err.into()), + } + } else { + Err(err.into()) + } + } + }; + let response_bytes = match latest_release_response { + Ok((used_tls_type, response)) => { + upsert_tls_cache(tls_url, used_tls_type, false); + let status = response.status(); + if !status.is_success() { + Err(anyhow!( + "Software update check failed with HTTP status: {status}" + )) + } else { + response.bytes().await.map_err(|err| err.into()) + } + } + Err(err) => Err(err), + }; + let result = response_bytes.and_then(process_software_update_check_response); + if result.is_err() { + clear_software_update_url(); + } + result +} + #[inline] pub fn get_app_name() -> String { hbb_common::config::APP_NAME.read().unwrap().clone() @@ -2947,6 +3051,42 @@ mod tests { }; use std::collections::HashSet; + #[test] + fn parses_stable_rustdesk_release_url() { + let update_url = "https://github.com/rustdesk/rustdesk/releases/tag/v1.4.6"; + + assert_eq!(release_id_from_update_url(update_url).unwrap(), "v1.4.6"); + assert_eq!(display_version_from_release_id("v1.4.6").unwrap(), "1.4.6"); + assert_eq!( + release_id_from_update_url(&format!("{update_url}/")).unwrap(), + "v1.4.6" + ); + } + + #[test] + fn rejects_untrusted_release_urls_and_unstable_versions() { + for update_url in [ + "http://github.com/rustdesk/rustdesk/releases/tag/v1.4.6", + "https://github.com:8443/rustdesk/rustdesk/releases/tag/v1.4.6", + "https://github.com:443/rustdesk/rustdesk/releases/tag/v1.4.6", + "https://user@github.com/rustdesk/rustdesk/releases/tag/v1.4.6", + "https://example.com/rustdesk/rustdesk/releases/tag/v1.4.6", + "https://github.com/other/rustdesk/releases/tag/v1.4.6", + "https://github.com/rustdesk/other/releases/tag/v1.4.6", + "https://github.com/rustdesk/rustdesk/releases/download/v1.4.6/rustdesk.exe", + "https://github.com/rustdesk/rustdesk/releases/tag/v1.4.6?x=1", + "https://github.com/rustdesk/rustdesk/releases/tag/v1.4.6#asset", + "https://github.com/rustdesk/rustdesk/releases/tag/v1.4.6/extra", + "https://github.com/rustdesk/rustdesk/releases/tag/v1%2F4%2F6", + "https://github.com/rustdesk/rustdesk/releases/tag/%2e%2e", + ] { + assert!(release_id_from_update_url(update_url).is_err()); + } + for release_id in ["v1.4.6-rc1", "nightly", "v9223372036854775807.1.1"] { + assert!(display_version_from_release_id(release_id).is_err()); + } + } + #[test] fn a_cursor_content_id_fits_a_web_client_number() { const MAX_SAFE_INTEGER: u64 = (1 << 53) - 1; diff --git a/src/core_main.rs b/src/core_main.rs index b5a451ed8..424f2c6ef 100644 --- a/src/core_main.rs +++ b/src/core_main.rs @@ -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), + ExitFailure(hbb_common::anyhow::Error), +} + +#[cfg(target_os = "macos")] +fn finish_macos_update(result: hbb_common::ResultType<()>) -> Option { + 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> { +pub fn core_main() -> Option { if !crate::common::global_init() { return None; } @@ -118,7 +131,7 @@ pub fn core_main() -> Option> { } #[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> { // 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> { } 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> { } #[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> { } //_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. diff --git a/src/flutter.rs b/src/flutter.rs index 35fa8cc5e..f83f18beb 100644 --- a/src/flutter.rs +++ b/src/flutter.rs @@ -37,6 +37,11 @@ pub(crate) const APP_TYPE_CM: &str = "cm"; #[cfg(any(target_os = "android", target_os = "ios"))] pub(crate) const APP_TYPE_CM: &str = "main"; +#[cfg(target_os = "macos")] +const CORE_MAIN_EXIT_SUCCESS: i32 = 0; +#[cfg(target_os = "macos")] +const CORE_MAIN_EXIT_FAILURE: i32 = 1; + // Do not remove the following constants. // Uncomment them when they are used. // pub(crate) const APP_TYPE_DESKTOP_REMOTE: &str = "remote"; @@ -102,6 +107,8 @@ fn load_plugin_in_app_path(dll_name: &str) -> Result { /// FFI for rustdesk core's main entry. /// Return true if the app should continue running with UI(possibly Flutter), false if the app should exit. +/// On macOS, command-only paths exit here because the Swift caller ignores +/// the return value; update failures must therefore use a non-zero exit status. #[cfg(not(windows))] #[no_mangle] pub extern "C" fn rustdesk_core_main() -> bool { @@ -116,11 +123,18 @@ pub extern "C" fn rustdesk_core_main() -> bool { } } #[cfg(not(any(target_os = "android", target_os = "ios")))] - if crate::core_main::core_main().is_some() { - return true; - } else { - #[cfg(target_os = "macos")] - std::process::exit(0); + match crate::core_main::core_main() { + Some(crate::core_main::CoreMainAction::StartUi(_)) => return true, + Some(crate::core_main::CoreMainAction::ExitFailure(err)) => { + eprintln!("Command failed: {err}"); + log::error!("Command failed: {err}"); + #[cfg(target_os = "macos")] + std::process::exit(CORE_MAIN_EXIT_FAILURE); + } + None => { + #[cfg(target_os = "macos")] + std::process::exit(CORE_MAIN_EXIT_SUCCESS); + } } #[cfg(not(target_os = "macos"))] false @@ -138,8 +152,14 @@ pub extern "C" fn rustdesk_core_main_args(args_len: *mut c_int) -> *mut *mut c_c unsafe { std::ptr::write(args_len, 0) }; #[cfg(not(any(target_os = "android", target_os = "ios")))] { - if let Some(args) = crate::core_main::core_main() { - return rust_args_to_c_args(args, args_len); + match crate::core_main::core_main() { + Some(crate::core_main::CoreMainAction::StartUi(args)) => { + return rust_args_to_c_args(args, args_len); + } + Some(crate::core_main::CoreMainAction::ExitFailure(err)) => { + log::error!("Command failed: {err}"); + } + None => {} } return std::ptr::null_mut() as _; } diff --git a/src/flutter_ffi.rs b/src/flutter_ffi.rs index f9cc2d66c..41968c185 100644 --- a/src/flutter_ffi.rs +++ b/src/flutter_ffi.rs @@ -24,12 +24,11 @@ use base::{ }; use std::{ collections::HashMap, - path::PathBuf, sync::{ atomic::{AtomicI32, Ordering}, Arc, }, - time::{Duration, SystemTime}, + time::SystemTime, }; pub type SessionID = uuid::Uuid; @@ -2727,36 +2726,12 @@ pub fn main_get_common(key: String) -> String { format!("error:{}", e) } } - } else if key.starts_with("download-file-") { - let _version = key.replace("download-file-", ""); - #[cfg(target_os = "windows")] - return match ( - crate::platform::windows::is_msi_installed(), - crate::common::is_custom_client(), - ) { - (Ok(true), false) => match crate::platform::windows::release_arch_suffix() { - Some(arch) => format!("rustdesk-{_version}-{arch}.msi"), - None => "error:unsupported".to_owned(), - }, - (Ok(true), true) | (Ok(false), _) => { - match crate::platform::windows::release_arch_suffix() { - Some(arch) => format!("rustdesk-{_version}-{arch}.exe"), - None => "error:unsupported".to_owned(), - } - } - (Err(e), _) => { - log::error!("Failed to check if is msi: {}", e); - format!("error:update-failed-check-msi-tip") - } - }; - #[cfg(target_os = "macos")] + } else if let Some(release_page_url) = key.strip_prefix("verified-download-url-") { + #[cfg(any(target_os = "windows", target_os = "macos"))] { - return if cfg!(target_arch = "x86_64") { - format!("rustdesk-{_version}-x86_64.dmg") - } else if cfg!(target_arch = "aarch64") { - format!("rustdesk-{_version}-aarch64.dmg") - } else { - "error:unsupported".to_owned() + return match verified_update_artifact_for_release_page_url(release_page_url) { + Ok(artifact) => artifact.url, + Err(e) => format!("error:{}", e), }; } #[cfg(not(any(target_os = "windows", target_os = "macos")))] @@ -2770,9 +2745,35 @@ pub fn main_get_common(key: String) -> String { } pub fn main_get_common_sync(key: String) -> SyncReturn { + // Metadata verification performs blocking network requests. Keep this key on + // the asynchronous FFI path to avoid blocking the calling Flutter isolate. + if key.starts_with("verified-download-url-") { + return SyncReturn("error:unsupported".to_owned()); + } SyncReturn(main_get_common(key)) } +#[cfg(any(target_os = "windows", target_os = "macos"))] +fn push_update_me_error(error: String) { + let data = HashMap::from([("name", "update-me".to_owned()), ("error", error)]); + let _res = flutter::push_global_event( + flutter::APP_TYPE_MAIN, + serde_json::ser::to_string(&data).unwrap_or("".to_owned()), + ); +} + +#[cfg(any(target_os = "windows", target_os = "macos"))] +fn verified_update_artifact_for_release_page_url( + release_page_url: &str, +) -> ResultType { + #[cfg(target_os = "windows")] + let update_msi = crate::platform::windows::is_msi_installed()? && !crate::is_custom_client(); + #[cfg(target_os = "macos")] + let update_msi = false; + let query = crate::updater::current_update_artifact_query(update_msi); + crate::updater::verified_update_artifact_for_release_page_url(release_page_url, query) +} + pub fn main_set_common(_key: String, _value: String) { #[cfg(target_os = "windows")] if _key == "install-printer" && crate::platform::is_win_10_or_greater() { @@ -2808,65 +2809,94 @@ pub fn main_set_common(_key: String, _value: String) { } #[cfg(any(target_os = "windows", target_os = "macos"))] { - use crate::updater::get_download_file_from_url; + use crate::updater::get_update_download_file_from_url; if _key == "download-new-version" { let download_url = _value.clone(); let event_key = "download-new-version".to_owned(); - let data = if let Some(download_file) = get_download_file_from_url(&download_url) { - std::fs::remove_file(&download_file).ok(); - match crate::hbbs_http::downloader::download_file( - download_url, - Some(PathBuf::from(download_file)), - Some(Duration::from_secs(3)), - ) { - Ok(id) => HashMap::from([("name", event_key), ("id", id)]), - Err(e) => HashMap::from([("name", event_key), ("error", e.to_string())]), - } - } else { - HashMap::from([ - ("name", event_key), - ("error", "Invalid download url".to_string()), - ]) - }; + let data = + match crate::updater::verified_update_artifact_for_download_url(&download_url) { + Ok(artifact) => { + if let Some(download_file) = + get_update_download_file_from_url(&artifact.url) + { + let options = crate::hbbs_http::downloader::DownloadOptions { + expected_size: artifact.size, + artifact_sha256: artifact.sha256, + }; + match crate::hbbs_http::downloader::download_file( + artifact.url, + Some(download_file), + options, + ) { + Ok(id) => HashMap::from([("name", event_key), ("id", id)]), + Err(e) => { + HashMap::from([("name", event_key), ("error", e.to_string())]) + } + } + } else { + HashMap::from([ + ("name", event_key), + ("error", "Invalid download url".to_owned()), + ]) + } + } + Err(e) => HashMap::from([ + ("name", event_key), + ("error", format!("Failed to verify update metadata, {}", e)), + ]), + }; let _res = flutter::push_global_event( flutter::APP_TYPE_MAIN, serde_json::ser::to_string(&data).unwrap_or("".to_owned()), ); } else if _key == "update-me" { - if let Some(new_version_file) = get_download_file_from_url(&_value) { - log::debug!( - "New version file is downloaded, update begin, {:?}", - new_version_file.to_str() - ); - if let Some(f) = new_version_file.to_str() { - // 1.4.0 does not support "--update" - // But we can assume that the new version supports it. - - #[cfg(any(target_os = "windows", target_os = "macos"))] - match crate::platform::update_to(f) { - Ok(_) => { - log::info!("Update process is launched successfully!"); - } - Err(e) => { - log::error!("Failed to update to new version, {}", e); - fs::remove_file(f).ok(); - } - } - } - } - } else if _key == "extract-update-dmg" { - #[cfg(target_os = "macos")] + let artifact = match crate::updater::verified_update_artifact_for_download_url(&_value) { - if let Some(new_version_file) = get_download_file_from_url(&_value) { - if let Some(f) = new_version_file.to_str() { - crate::platform::macos::extract_update_dmg(f); - } else { - // unreachable!() - log::error!("Failed to get the new version file path"); - } - } else { - // unreachable!() - log::error!("Failed to get the new version file from url: {}", _value); + Ok(artifact) => artifact, + Err(e) => { + let error = format!("Failed to verify update metadata, {}", e); + log::error!("{}", error); + push_update_me_error(error); + return; + } + }; + let Some(new_version_file) = get_update_download_file_from_url(&artifact.url) else { + let error = "Failed to resolve downloaded update file path".to_owned(); + log::error!("{}", error); + push_update_me_error(error); + return; + }; + let Some(new_version_file_str) = new_version_file.to_str() else { + let error = "Invalid UTF-8 path for downloaded update file".to_owned(); + log::error!("{}", error); + push_update_me_error(error); + return; + }; + log::debug!( + "New version file is downloaded, update begin, {:?}", + new_version_file_str + ); + #[cfg(target_os = "windows")] + let update_res = crate::platform::update_to_verified( + new_version_file_str, + &artifact.sha256, + artifact.size, + ); + #[cfg(target_os = "macos")] + let update_res = crate::platform::macos::update_to_verified_dmg( + new_version_file_str, + &artifact.sha256, + Some(artifact.size), + ); + if update_res.is_err() { + crate::updater::remove_update_file(&new_version_file); + } + match update_res { + Ok(_) => log::info!("Update process is launched successfully!"), + Err(e) => { + let error = format!("Failed to update to new version, {}", e); + log::error!("{}", error); + push_update_me_error(error); } } } @@ -3056,3 +3086,15 @@ pub mod server_side { jboolean::from(crate::server::is_clipboard_service_ok()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sync_common_rejects_verified_download_url() { + let result = main_get_common_sync("verified-download-url-".to_owned()); + + assert_eq!(result.0, "error:unsupported"); + } +} diff --git a/src/hbbs_http/downloader.rs b/src/hbbs_http/downloader.rs index a4fd1fa32..7cb4bcdb6 100644 --- a/src/hbbs_http/downloader.rs +++ b/src/hbbs_http/downloader.rs @@ -5,14 +5,14 @@ use hbb_common::{ log, tokio::{ self, - fs::File, + fs::{File, OpenOptions}, io::AsyncWriteExt, sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender}, }, ResultType, }; use serde_derive::Serialize; -use std::{collections::HashMap, path::PathBuf, sync::Mutex, time::Duration}; +use std::{collections::HashMap, path::PathBuf, sync::Mutex}; lazy_static! { static ref DOWNLOADERS: Mutex> = Default::default(); @@ -31,6 +31,7 @@ pub struct DownloadData { #[serde(skip_serializing_if = "Option::is_none")] pub total_size: Option, pub downloaded_size: u64, + pub finished: bool, #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, } @@ -38,181 +39,187 @@ pub struct DownloadData { struct Downloader { data: Vec, path: Option, - // Some file may be empty, so we use Option to indicate if the size is known - total_size: Option, + total_size: u64, + artifact_sha256: String, downloaded_size: u64, error: Option, finished: bool, tx_cancel: UnboundedSender<()>, } +impl Downloader { + fn matches_request(&self, path: &Option, options: &DownloadOptions) -> bool { + self.path.as_ref() == path.as_ref() + && self.total_size == options.expected_size + && self.artifact_sha256 == options.artifact_sha256 + } +} + +pub struct DownloadOptions { + pub expected_size: u64, + pub artifact_sha256: String, +} + +struct DownloadRequest { + id: String, + url: String, + path: Option, + expected_size: u64, +} + // The caller should check if the file is downloaded successfully and remove the job from the map. pub fn download_file( url: String, path: Option, - auto_del_dur: Option, + options: DownloadOptions, ) -> ResultType { let id = url.clone(); - // First pass: if a non-error downloader exists for this URL, reuse it. - // If an errored downloader exists, remove it so this call can retry. - let mut stale_path = None; - { - let mut downloaders = DOWNLOADERS.lock().unwrap(); - if let Some(downloader) = downloaders.get(&id) { - if downloader.error.is_none() { - return Ok(id); - } - stale_path = downloader.path.clone(); - downloaders.remove(&id); - } - } - if let Some(p) = stale_path { - if p.exists() { - if let Err(e) = std::fs::remove_file(&p) { - log::warn!("Failed to remove stale download file {}: {}", p.display(), e); - } - } - } - - if let Some(path) = path.as_ref() { - if path.exists() { - bail!("File {} already exists", path.display()); - } - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - } let (tx, rx) = unbounded_channel(); - let downloader = Downloader { - data: Vec::new(), - path: path.clone(), - total_size: None, - downloaded_size: 0, - error: None, - tx_cancel: tx, - finished: false, - }; - // Second pass (atomic with insert) to avoid race with another concurrent caller. - let mut stale_path_after_check = None; { let mut downloaders = DOWNLOADERS.lock().unwrap(); if let Some(existing) = downloaders.get(&id) { if existing.error.is_none() { - return Ok(id); - } - stale_path_after_check = existing.path.clone(); - downloaders.remove(&id); - } - downloaders.insert(id.clone(), downloader); - } - if let Some(p) = stale_path_after_check { - if p.exists() { - if let Err(e) = std::fs::remove_file(&p) { - log::warn!("Failed to remove stale download file {}: {}", p.display(), e); + if existing.matches_request(&path, &options) { + return Ok(id); + } + bail!("Existing download job does not match requested artifact"); } } - } - let id2 = id.clone(); - std::thread::spawn( - move || match do_download(&id2, url, path, auto_del_dur, rx) { - Ok(is_all_downloaded) => { - let mut downloaded_size = 0; - let mut total_size = 0; - DOWNLOADERS.lock().unwrap().get_mut(&id2).map(|downloader| { - downloaded_size = downloader.downloaded_size; - total_size = downloader.total_size.unwrap_or(0); - }); - log::info!( - "Download {} end, {}/{}, {:.2} %", - &id2, - downloaded_size, - total_size, - if total_size == 0 { - 0.0 - } else { - downloaded_size as f64 / total_size as f64 * 100.0 + let stale_path = downloaders + .remove(&id) + .and_then(|downloader| downloader.path); + if stale_path.as_ref() != path.as_ref() { + if let Some(p) = stale_path { + if p.exists() { + if let Err(e) = std::fs::remove_file(&p) { + log::warn!( + "Failed to remove stale download file {}: {}", + p.display(), + e + ); } - ); + } + } + } - let is_canceled = !is_all_downloaded; - if is_canceled { - if let Some(downloader) = DOWNLOADERS.lock().unwrap().remove(&id2) { - if let Some(p) = downloader.path { - if p.exists() { - std::fs::remove_file(p).ok(); - } + if let Some(path) = path.as_ref() { + prepare_download_path(path)?; + } + + downloaders.insert( + id.clone(), + Downloader { + data: Vec::new(), + path: path.clone(), + total_size: options.expected_size, + artifact_sha256: options.artifact_sha256, + downloaded_size: 0, + error: None, + tx_cancel: tx, + finished: false, + }, + ); + } + + let request = DownloadRequest { + id: id.clone(), + url, + path, + expected_size: options.expected_size, + }; + let worker_id = id.clone(); + std::thread::spawn(move || match do_download(request, rx) { + Ok(is_all_downloaded) => { + let mut downloaded_size = 0; + let mut total_size = 0; + DOWNLOADERS + .lock() + .unwrap() + .get_mut(&worker_id) + .map(|downloader| { + downloaded_size = downloader.downloaded_size; + total_size = downloader.total_size; + }); + log::info!( + "Download {} end, {}/{}, {:.2} %", + &worker_id, + downloaded_size, + total_size, + if total_size == 0 { + 0.0 + } else { + downloaded_size as f64 / total_size as f64 * 100.0 + } + ); + + let is_canceled = !is_all_downloaded; + if is_canceled { + if let Some(downloader) = DOWNLOADERS.lock().unwrap().remove(&worker_id) { + if let Some(p) = downloader.path { + if p.exists() { + std::fs::remove_file(p).ok(); } } } } - Err(e) => { - let err = e.to_string(); - log::error!("Download {}, failed: {}", &id2, &err); - DOWNLOADERS.lock().unwrap().get_mut(&id2).map(|downloader| { + } + Err(e) => { + let err = e.to_string(); + log::error!("Download {}, failed: {}", &worker_id, &err); + DOWNLOADERS + .lock() + .unwrap() + .get_mut(&worker_id) + .map(|downloader| { downloader.error = Some(err); }); - } - }, - ); + } + }); Ok(id) } +fn prepare_download_path(path: &PathBuf) -> ResultType<()> { + match std::fs::remove_file(path) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e.into()), + } + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + Ok(()) +} + #[tokio::main(flavor = "current_thread")] async fn do_download( - id: &str, - url: String, - path: Option, - auto_del_dur: Option, + request: DownloadRequest, mut rx_cancel: UnboundedReceiver<()>, ) -> ResultType { - let client = create_http_client_async_with_url_strict(&url).await?; + let client = create_http_client_async_with_url_strict(&request.url).await?; let mut is_all_downloaded = false; - tokio::select! { - _ = rx_cancel.recv() => { - return Ok(is_all_downloaded); - } - head_resp = client.head(&url).send() => { - match head_resp { - Ok(resp) => { - if resp.status().is_success() { - let total_size = resp - .headers() - .get(reqwest::header::CONTENT_LENGTH) - .and_then(|ct_len| ct_len.to_str().ok()) - .and_then(|ct_len| ct_len.parse::().ok()); - let Some(total_size) = total_size else { - bail!("Failed to get content length"); - }; - DOWNLOADERS.lock().unwrap().get_mut(id).map(|downloader| { - downloader.total_size = Some(total_size); - }); - } else { - bail!("Failed to get content length: {}", resp.status()); - } - } - Err(e) => { - return Err(e.into()); - } - } - } - } - + let mut downloaded_size = 0_u64; let mut response; tokio::select! { _ = rx_cancel.recv() => { return Ok(is_all_downloaded); } - resp = client.get(url).send() => { - response = resp?; + resp = client.get(request.url).send() => { + response = resp?.error_for_status()?; } } let mut dest: Option = None; - if let Some(p) = path { - dest = Some(File::create(p).await?); + if let Some(p) = request.path { + dest = Some( + OpenOptions::new() + .write(true) + .create_new(true) + .open(p) + .await?, + ); } loop { @@ -223,28 +230,42 @@ async fn do_download( chunk = response.chunk() => { match chunk { Ok(Some(chunk)) => { + let next_size = downloaded_size + .checked_add(chunk.len() as u64) + .ok_or_else(|| hbb_common::anyhow::anyhow!("Download size overflow"))?; + if next_size > request.expected_size { + bail!( + "Download size exceeds expected size: expected {}, got {}", + request.expected_size, + next_size + ); + } match dest { Some(ref mut f) => { f.write_all(&chunk).await?; f.flush().await?; - DOWNLOADERS.lock().unwrap().get_mut(id).map(|downloader| { - downloader.downloaded_size += chunk.len() as u64; - }); } None => { - DOWNLOADERS.lock().unwrap().get_mut(id).map(|downloader| { - downloader.data.extend_from_slice(&chunk); - downloader.downloaded_size += chunk.len() as u64; - }); + let mut downloaders = DOWNLOADERS.lock().unwrap(); + let Some(downloader) = downloaders.get_mut(&request.id) else { + bail!("Downloader not found"); + }; + downloader.data.extend_from_slice(&chunk); } } + downloaded_size = next_size; + let mut downloaders = DOWNLOADERS.lock().unwrap(); + let Some(downloader) = downloaders.get_mut(&request.id) else { + bail!("Downloader not found"); + }; + downloader.downloaded_size = downloaded_size; } Ok(None) => { is_all_downloaded = true; break; }, Err(e) => { - log::error!("Download {} failed: {}", id, e); + log::error!("Download {} failed: {}", request.id, e); return Err(e.into()); } } @@ -256,17 +277,19 @@ async fn do_download( f.flush().await?; } - if let Some(ref mut downloader) = DOWNLOADERS.lock().unwrap().get_mut(id) { - downloader.finished = true; + if is_all_downloaded && downloaded_size != request.expected_size { + bail!( + "Download size mismatch: expected {}, got {}", + request.expected_size, + downloaded_size + ); } if is_all_downloaded { - let id_del = id.to_string(); - if let Some(dur) = auto_del_dur { - tokio::spawn(async move { - tokio::time::sleep(dur).await; - DOWNLOADERS.lock().unwrap().remove(&id_del); - }); - } + let mut downloaders = DOWNLOADERS.lock().unwrap(); + let Some(downloader) = downloaders.get_mut(&request.id) else { + bail!("Downloader not found"); + }; + downloader.finished = true; } Ok(is_all_downloaded) } @@ -274,21 +297,18 @@ async fn do_download( pub fn get_download_data(id: &str) -> ResultType { let downloaders = DOWNLOADERS.lock().unwrap(); if let Some(downloader) = downloaders.get(id) { - let downloaded_size = downloader.downloaded_size; - let total_size = downloader.total_size.clone(); - let error = downloader.error.clone(); - let data = if total_size.unwrap_or(0) == downloaded_size && downloader.path.is_none() { + let data = if downloader.finished && downloader.path.is_none() { downloader.data.clone() } else { Vec::new() }; - let path = downloader.path.clone(); let download_data = DownloadData { data, - path, - total_size, - downloaded_size, - error, + path: downloader.path.clone(), + total_size: Some(downloader.total_size), + downloaded_size: downloader.downloaded_size, + finished: downloader.finished, + error: downloader.error.clone(), }; Ok(download_data) } else { @@ -298,8 +318,7 @@ pub fn get_download_data(id: &str) -> ResultType { pub fn cancel(id: &str) { if let Some(downloader) = DOWNLOADERS.lock().unwrap().get(id) { - // downloader.is_canceled.store(true, Ordering::SeqCst); - // The receiver may not be able to receive the cancel signal, so we also set the atomic bool to true + // The receiver may not be able to receive the cancel signal, so keep the job until it exits. let _ = downloader.tx_cancel.send(()); } } diff --git a/src/lib.rs b/src/lib.rs index 649f3ef95..69ca7faf3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -29,6 +29,10 @@ pub mod ipc; feature = "flutter" )))] pub mod ui; +pub(crate) mod update_metadata; +pub(crate) mod update_hash; +#[cfg(target_os = "macos")] +mod manual_update; mod version; pub use version::*; #[cfg(any(target_os = "android", target_os = "ios", feature = "flutter"))] diff --git a/src/main.rs b/src/main.rs index dbafa3d0c..336d729b0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,18 +16,26 @@ fn main() { common::global_clean(); } -#[cfg(not(any( - target_os = "android", - target_os = "ios", - feature = "flutter" -)))] -fn main() { +#[cfg(not(any(target_os = "android", target_os = "ios", feature = "flutter")))] +fn main() -> std::process::ExitCode { #[cfg(all(windows, not(feature = "inline")))] unsafe { winapi::um::shellscalingapi::SetProcessDpiAwareness(2); } - if let Some(args) = crate::core_main::core_main().as_mut() { - ui::start(args); - } + // Preserve command failures, notably verified macOS offline update failures, + // as a non-zero process exit status for CLI callers. + let exit_code = match crate::core_main::core_main() { + Some(crate::core_main::CoreMainAction::StartUi(mut args)) => { + ui::start(&mut args); + std::process::ExitCode::SUCCESS + } + Some(crate::core_main::CoreMainAction::ExitFailure(err)) => { + eprintln!("Command failed: {err}"); + hbb_common::log::error!("Command failed: {err}"); + std::process::ExitCode::FAILURE + } + None => std::process::ExitCode::SUCCESS, + }; common::global_clean(); + exit_code } diff --git a/src/manual_update.rs b/src/manual_update.rs new file mode 100644 index 000000000..d903f5864 --- /dev/null +++ b/src/manual_update.rs @@ -0,0 +1,229 @@ +use hbb_common::{ + anyhow::{anyhow, Context}, + bail, log, ResultType, +}; +use std::{io::Read, path::Path}; + +const UPDATE_SIDECAR_MAX_BYTES: u64 = 1024 * 1024; +const PUBLIC_MACOS_BUNDLE_ID: &str = "com.carriez.rustdesk"; +const MACOS_UPDATE_USAGE: &str = "Usage: --update [update.dmg --metadata rustdesk-update.json --signature rustdesk-update.json.sig]"; +const LEGACY_UPDATE_WARNING: &str = + "legacy offline update: signed metadata not found; release hash was not verified"; + +#[derive(Debug, Eq, PartialEq)] +enum MacosUpdateArguments<'a> { + CurrentApp, + LegacyDmg(&'a str), + VerifiedDmg { + dmg_path: &'a str, + metadata_path: &'a str, + signature_path: &'a str, + }, +} + +fn parse_macos_update_args(args: &[String]) -> ResultType> { + match args { + [command] if command == "--update" => Ok(MacosUpdateArguments::CurrentApp), + [command, dmg_path] if command == "--update" && dmg_path.ends_with(".dmg") => { + Ok(MacosUpdateArguments::LegacyDmg(dmg_path)) + } + [command, dmg_path, metadata_flag, metadata_path, signature_flag, signature_path] + if command == "--update" + && dmg_path.ends_with(".dmg") + && metadata_flag == "--metadata" + && signature_flag == "--signature" => + { + Ok(MacosUpdateArguments::VerifiedDmg { + dmg_path, + metadata_path, + signature_path, + }) + } + _ => bail!(MACOS_UPDATE_USAGE), + } +} + +fn expected_update_package_id( + is_custom_client: bool, + bundle_id: Option, +) -> ResultType { + let bundle_id = bundle_id + .filter(|value| !value.is_empty()) + .ok_or_else(|| anyhow!("client bundle identifier is missing"))?; + if !is_custom_client && bundle_id == PUBLIC_MACOS_BUNDLE_ID { + return Ok(crate::update_metadata::PUBLIC_UPDATE_PACKAGE_ID.to_owned()); + } + Ok(bundle_id) +} + +fn installed_update_package_id() -> ResultType { + expected_update_package_id( + crate::common::is_custom_client(), + crate::platform::macos::get_bundle_id(), + ) +} + +fn read_update_sidecar(path: &str) -> ResultType> { + let path_metadata = std::fs::symlink_metadata(path) + .with_context(|| format!("failed to inspect update sidecar {path}"))?; + if !path_metadata.file_type().is_file() { + bail!("update sidecar is not a regular file: {path}"); + } + if path_metadata.len() > UPDATE_SIDECAR_MAX_BYTES { + bail!("update sidecar exceeds maximum size: {path}"); + } + let file = std::fs::File::open(path) + .with_context(|| format!("failed to open update sidecar {path}"))?; + let mut bytes = Vec::new(); + file.take(UPDATE_SIDECAR_MAX_BYTES.saturating_add(1)) + .read_to_end(&mut bytes)?; + if bytes.len() as u64 > UPDATE_SIDECAR_MAX_BYTES { + bail!("update sidecar exceeds maximum size: {path}"); + } + Ok(bytes) +} + +fn update_current_app() -> ResultType<()> { + println!("Starting update process..."); + log::info!("Starting update process..."); + crate::platform::update_me()?; + println!( + "{}", + crate::client::translate("Updated successfully!".to_owned()) + ); + log::info!("Updated successfully!"); + Ok(()) +} + +fn update_legacy_dmg(dmg_path: &str) -> ResultType<()> { + eprintln!("Warning: {LEGACY_UPDATE_WARNING}"); + log::warn!("{LEGACY_UPDATE_WARNING}"); + println!("Updating from DMG: {dmg_path}"); + crate::platform::update_from_dmg(dmg_path)?; + println!("Update process from DMG started successfully."); + Ok(()) +} + +fn update_verified_dmg( + dmg_path: &str, + metadata_path: &str, + signature_path: &str, +) -> ResultType<()> { + let file_name = Path::new(dmg_path) + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| anyhow!("invalid update DMG file name"))?; + let metadata = read_update_sidecar(metadata_path)?; + let signature = read_update_sidecar(signature_path)?; + let expected_package_id = installed_update_package_id()?; + let artifact = crate::update_metadata::verify_offline_update_metadata( + &metadata, + &signature, + crate::update_metadata::OfflineUpdateMetadataRequirements { + local_file_name: file_name, + expected_package_id: &expected_package_id, + artifact: crate::updater::current_update_artifact_query(false), + }, + )?; + println!("Updating from DMG verified by signed metadata: {dmg_path}"); + crate::platform::macos::update_to_verified_dmg( + dmg_path, + &artifact.sha256, + Some(artifact.size), + )?; + println!("Verified update process started"); + Ok(()) +} + +pub(crate) fn execute_macos_update(args: &[String]) -> ResultType<()> { + match parse_macos_update_args(args)? { + MacosUpdateArguments::CurrentApp => update_current_app(), + MacosUpdateArguments::LegacyDmg(dmg_path) => update_legacy_dmg(dmg_path), + MacosUpdateArguments::VerifiedDmg { + dmg_path, + metadata_path, + signature_path, + } => update_verified_dmg(dmg_path, metadata_path, signature_path), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn args(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).to_owned()).collect() + } + + #[test] + fn parses_current_legacy_and_verified_macos_updates() { + assert_eq!( + MacosUpdateArguments::CurrentApp, + parse_macos_update_args(&args(&["--update"])).unwrap() + ); + assert_eq!( + MacosUpdateArguments::LegacyDmg("update.dmg"), + parse_macos_update_args(&args(&["--update", "update.dmg"])).unwrap() + ); + assert_eq!( + MacosUpdateArguments::VerifiedDmg { + dmg_path: "update.dmg", + metadata_path: "rustdesk-update.json", + signature_path: "rustdesk-update.json.sig", + }, + parse_macos_update_args(&args(&[ + "--update", + "update.dmg", + "--metadata", + "rustdesk-update.json", + "--signature", + "rustdesk-update.json.sig", + ])) + .unwrap() + ); + } + + #[test] + fn rejects_partial_reordered_or_extra_macos_update_arguments() { + for invalid in [ + args(&["--update", "update.zip"]), + args(&["--update", "update.dmg", "--metadata", "update.json"]), + args(&[ + "--update", + "update.dmg", + "--signature", + "update.sig", + "--metadata", + "update.json", + ]), + args(&["--update", "update.dmg", "unexpected"]), + ] { + assert!(parse_macos_update_args(&invalid).is_err(), "{invalid:?}"); + } + } + + #[test] + fn resolves_public_and_custom_update_package_identity() { + assert_eq!( + "rustdesk", + expected_update_package_id(false, Some("com.carriez.rustdesk".to_owned())).unwrap() + ); + assert_eq!( + "com.example.rustdesk-custom", + expected_update_package_id(true, Some("com.example.rustdesk-custom".to_owned())) + .unwrap() + ); + assert_eq!( + "com.example.same-name-custom", + expected_update_package_id(false, Some("com.example.same-name-custom".to_owned())) + .unwrap() + ); + assert_eq!( + "com.carriez.rustdesk", + expected_update_package_id(true, Some("com.carriez.rustdesk".to_owned())).unwrap() + ); + assert!(expected_update_package_id(false, None).is_err()); + assert!(expected_update_package_id(true, None).is_err()); + assert!(expected_update_package_id(true, Some(String::new())).is_err()); + } +} diff --git a/src/platform/macos.rs b/src/platform/macos.rs index 4386e07ee..334ae40e2 100644 --- a/src/platform/macos.rs +++ b/src/platform/macos.rs @@ -2,7 +2,14 @@ // https://github.com/servo/core-foundation-rs // https://github.com/rust-windowing/winit -use super::{CursorData, ResultType}; +mod update_temp; +mod verified_dmg; + +#[cfg(test)] +#[path = "macos/verified_dmg_tests.rs"] +mod verified_dmg_tests; + +use super::{validate_install_app_name, CursorData, ResultType}; use cocoa::{ appkit::{NSApp, NSApplication, NSApplicationActivationPolicy::*}, base::{id, nil, BOOL, NO, YES}, @@ -28,12 +35,17 @@ use objc::rc::autoreleasepool; use objc::{class, msg_send, sel, sel_impl}; use scrap::{libc::c_void, quartz::ffi::*}; use std::{ - collections::HashMap, os::unix::process::CommandExt, path::{Path, PathBuf}, process::{Command, Stdio}, sync::Mutex, }; +pub use update_temp::try_remove_temp_update_dir; +use update_temp::{copy_dmg_to_update_temp_file, get_update_temp_dir_string}; +use verified_dmg::{ + attach_dmg, copy_and_verify_dmg_file, extract_dmg_inner, verified_dmg_path, verify_stored_dmg, + VerifiedDmg, +}; // macOS boolean_t is defined as `int` in type BooleanT = hbb_common::libc::c_int; @@ -41,17 +53,42 @@ type BooleanT = hbb_common::libc::c_int; static PRIVILEGES_SCRIPTS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/src/platform/privileges_scripts"); static mut LATEST_SEED: i32 = 0; +const UPDATE_CLEANUP_FAILED_AFTER_COMMIT: &str = "UPDATE_CLEANUP_FAILED_AFTER_COMMIT"; +// `kill -9` may not work without administrator privileges. +// App-only update transaction used when launchd jobs are absent or intentionally stopped. +// Keep its verification, staging, bundle rollback, and cleanup aligned with update.scpt. +const PRIVILEGED_UPDATE_BODY: &str = r#" + on run {app_name, cur_pid, source_path, user_name, restore_owner, expected_sha256, expected_bundle_id} + set app_bundle to "/Applications/" & app_name & ".app" + set app_bundle_q to quoted form of app_bundle + set installed_info_q to quoted form of (app_bundle & "/Contents/Info.plist") + set source_path_q to quoted form of source_path + set user_name_q to quoted form of user_name + set expected_sha256_q to quoted form of expected_sha256 + set expected_bundle_id_q to quoted form of expected_bundle_id -#[inline] -fn get_update_temp_dir() -> PathBuf { - let euid = unsafe { hbb_common::libc::geteuid() }; - Path::new("/tmp").join(format!(".rustdeskupdate-{}", euid)) -} + set check_source to "if [ -n " & expected_sha256_q & " ]; then test -f " & source_path_q & "; else test -d " & source_path_q & "; fi;" + set kill_others to "pids=$(pgrep -x '" & app_name & "' | grep -vx " & cur_pid & " || true); if [ -n \"$pids\" ]; then echo \"$pids\" | xargs kill -9 || true; fi;" + -- Rehash the root-owned copy in a clean environment before staging bytes. + set prepare_verified to "verified_dir=$(/usr/bin/mktemp -d /tmp/.rustdeskupdate-verified.XXXXXX); /bin/chmod 0700 \"$verified_dir\"; verified_app=\"$verified_dir/" & app_name & ".app\"; dmg_attached=0; if [ -n " & expected_sha256_q & " ]; then verified_dmg=\"$verified_dir/update.dmg\"; /bin/cp " & source_path_q & " \"$verified_dmg\"; /usr/sbin/chown root:wheel \"$verified_dmg\"; /bin/chmod 0400 \"$verified_dmg\"; actual_sha256=$(/usr/bin/env -i /usr/bin/shasum -a 256 \"$verified_dmg\"); actual_sha256=${actual_sha256%% *}; if [ \"$actual_sha256\" != " & expected_sha256_q & " ]; then echo 'Update DMG SHA256 mismatch' >&2; exit 1; fi; dmg_mount=\"$verified_dir/mount\"; /bin/mkdir \"$dmg_mount\"; dmg_attached=1; /usr/bin/hdiutil attach -readonly -nobrowse -mountpoint \"$dmg_mount\" \"$verified_dmg\" >/dev/null; /usr/bin/ditto \"$dmg_mount/" & app_name & ".app\" \"$verified_app\"; /usr/bin/hdiutil detach \"$dmg_mount\" -force >/dev/null; dmg_attached=0; /bin/rm -f \"$verified_dmg\"; else /usr/bin/ditto " & source_path_q & " \"$verified_app\"; fi; /usr/sbin/chown -R root:wheel \"$verified_app\"; /bin/chmod -R go-w \"$verified_app\";" + set validate_verified_app to "candidate_bundle_id=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' \"$verified_app/Contents/Info.plist\"); if [ -z " & expected_bundle_id_q & " ] || [ \"$candidate_bundle_id\" != " & expected_bundle_id_q & " ]; then echo 'Update app bundle identifier mismatch' >&2; exit 1; fi; if [ -e " & app_bundle_q & " ]; then installed_bundle_id=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' " & installed_info_q & "); if [ \"$installed_bundle_id\" != " & expected_bundle_id_q & " ]; then echo 'Installed app bundle identifier mismatch' >&2; exit 1; fi; fi;" + set prepare_swap_paths to "temp_bundle=" & app_bundle_q & ".new.$$; old_bundle=" & app_bundle_q & ".old.$$;" + set cleanup_swap_paths to "rm -rf \"$temp_bundle\" \"$old_bundle\";" + set stage_bundle to "ditto \"$verified_app\" \"$temp_bundle\";" + set protect_staged_bundle to "chown -R root:wheel \"$temp_bundle\"; chmod -R go-w \"$temp_bundle\"; (xattr -r -d com.apple.quarantine \"$temp_bundle\" || true);" + set move_current_bundle to "if [ -e " & app_bundle_q & " ]; then mv " & app_bundle_q & " \"$old_bundle\"; bundle_backed_up=1; fi;" + set install_staged_bundle to "mv \"$temp_bundle\" " & app_bundle_q & "; bundle_swapped=1;" + set restore_installed_owner to "if [ " & quoted form of restore_owner & " = '1' ]; then chown -R " & user_name_q & ":staff " & app_bundle_q & "; fi;" + set rollback_bundle to "if [ \"${bundle_backed_up:-0}\" -eq 1 ]; then if [ ! -e \"$old_bundle\" ]; then rollback_status=1; elif ! rm -rf " & app_bundle_q & "; then rollback_status=1; elif ! mv \"$old_bundle\" " & app_bundle_q & "; then rollback_status=1; fi; elif [ \"${bundle_swapped:-0}\" -eq 1 ]; then rm -rf " & app_bundle_q & " || rollback_status=1; fi;" + set cleanup_verified to "if [ \"${dmg_attached:-0}\" -eq 1 ]; then /usr/bin/hdiutil detach \"$dmg_mount\" -force >/dev/null 2>&1 || cleanup_status=1; fi; if [ -n \"${temp_bundle:-}\" ]; then rm -rf \"$temp_bundle\" || cleanup_status=1; fi; if [ -n \"${verified_dir:-}\" ]; then rm -rf \"$verified_dir\" || cleanup_status=1; fi;" + set rollback_update to "status=$?; trap - EXIT; set +e; cleanup_status=0; if [ \"${transaction_started:-0}\" -eq 1 ] && [ \"${transaction_committed:-0}\" -ne 1 ]; then rollback_status=0;" & rollback_bundle & "if [ \"$rollback_status\" -ne 0 ]; then status=1; fi; fi; if [ \"${rollback_status:-0}\" -eq 0 ]; then " & cleanup_verified & "fi; if [ \"$cleanup_status\" -ne 0 ] && [ \"${transaction_committed:-0}\" -ne 1 ]; then status=1; elif [ \"$cleanup_status\" -ne 0 ]; then echo 'UPDATE_CLEANUP_FAILED_AFTER_COMMIT'; fi; exit \"$status\";" + set commit_update to "transaction_committed=1; if ! rm -rf \"$old_bundle\"; then echo 'UPDATE_CLEANUP_FAILED_AFTER_COMMIT'; fi;" + set copy_files to prepare_swap_paths & cleanup_swap_paths & stage_bundle & protect_staged_bundle & "transaction_started=1;" & move_current_bundle & install_staged_bundle & restore_installed_owner & commit_update + set sh to "set -e; transaction_started=0; transaction_committed=0; bundle_backed_up=0; bundle_swapped=0; trap " & quoted form of rollback_update & " EXIT;" & check_source & prepare_verified & validate_verified_app & kill_others & copy_files -#[inline] -fn get_update_temp_dir_string() -> String { - get_update_temp_dir().to_string_lossy().into_owned() -} + do shell script sh with prompt app_name & " wants to update itself" with administrator privileges + end run + "#; /// Global mutex to serialize CoreGraphics cursor operations. /// This prevents race conditions between cursor visibility (hide depth tracking) @@ -250,56 +287,79 @@ pub fn is_installed_daemon(prompt: bool) -> bool { false } -fn update_daemon_agent(agent_plist_file: String, update_source_dir: String, sync: bool) { +enum UpdateSource { + AppDir(String), + VerifiedDmg { + path: String, + expected_sha256: String, + }, +} + +fn log_update_cleanup_warning(output: &std::process::Output) { + if String::from_utf8_lossy(&output.stdout).contains(UPDATE_CLEANUP_FAILED_AFTER_COMMIT) { + log::warn!("Update committed, but temporary update cleanup failed"); + } +} + +impl UpdateSource { + fn into_script_args(self) -> (String, String) { + match self { + Self::AppDir(path) => (path, String::new()), + Self::VerifiedDmg { + path, + expected_sha256, + } => (path, expected_sha256), + } + } +} + +fn update_daemon_agent(agent_plist_file: String, update_source: UpdateSource) -> ResultType<()> { let update_script_file = "update.scpt"; let Some(update_script) = PRIVILEGES_SCRIPTS_DIR.get_file(update_script_file) else { - return; + bail!("Failed to find {}", update_script_file); }; let Some(update_script_body) = update_script.contents_utf8().map(correct_app_name) else { - return; + bail!("Failed to read {}", update_script_file); }; - let Some(daemon_plist) = PRIVILEGES_SCRIPTS_DIR.get_file("daemon.plist") else { - return; - }; - let Some(daemon_plist_body) = daemon_plist.contents_utf8().map(correct_app_name) else { - return; - }; - let Some(agent_plist) = PRIVILEGES_SCRIPTS_DIR.get_file("agent.plist") else { - return; - }; - let Some(agent_plist_body) = agent_plist.contents_utf8().map(correct_app_name) else { - return; - }; - - let func = move || { + let current_pid = std::process::id().to_string(); + let (update_source_path, expected_sha256) = update_source.into_script_args(); + let func = move || -> ResultType<()> { let mut binding = std::process::Command::new("osascript"); let cmd = binding .arg("-e") .arg(update_script_body) - .arg(daemon_plist_body) - .arg(agent_plist_body) .arg(&get_active_username()) - .arg(std::process::id().to_string()) - .arg(update_source_dir); - match cmd.status() { + .arg(¤t_pid) + .arg(update_source_path) + .arg(expected_sha256); + match cmd.output() { Err(e) => { log::error!("run osascript failed: {}", e); + bail!("run osascript failed: {}", e); } - Ok(status) if !status.success() => { - log::warn!("run osascript failed with status: {}", status); + Ok(output) if !output.status.success() => { + let stderr = String::from_utf8_lossy(&output.stderr); + log::warn!( + "run osascript failed with status: {}, stderr: {}", + output.status, + stderr.trim() + ); + bail!( + "run osascript failed with status: {}, stderr: {}", + output.status, + stderr.trim() + ); } - _ => { + Ok(output) => { + log_update_cleanup_warning(&output); let installed = std::path::Path::new(&agent_plist_file).exists(); log::info!("Agent file {} installed: {}", &agent_plist_file, installed); } } + Ok(()) }; - if sync { - func(); - } else { - std::thread::spawn(func); - } + func() } fn correct_app_name(s: &str) -> String { @@ -882,23 +942,7 @@ pub fn quit_gui() { }; } -#[inline] -pub fn try_remove_temp_update_dir(dir: Option<&str>) { - let target_path_buf = dir.map(PathBuf::from).unwrap_or_else(get_update_temp_dir); - let target_path = target_path_buf.as_path(); - if target_path.exists() { - std::fs::remove_dir_all(target_path).ok(); - } -} - pub fn update_me() -> ResultType<()> { - let is_installed_daemon = is_installed_daemon(false); - let option_stop_service = "stop-service"; - let is_service_stopped = hbb_common::config::option2bool( - option_stop_service, - &crate::ui_interface::get_option(option_stop_service), - ); - let cmd = std::env::current_exe()?; // RustDesk.app/Contents/MacOS/RustDesk let app_dir = cmd @@ -909,53 +953,78 @@ pub fn update_me() -> ResultType<()> { let Some(app_dir) = app_dir else { bail!("Unknown app directory of current exe file: {:?}", cmd); }; + update_me_from_app_dir(app_dir) +} +fn update_me_from_app_dir(app_dir: String) -> ResultType<()> { + update_me_from_source(UpdateSource::AppDir(app_dir)) +} + +fn update_me_from_source(update_source: UpdateSource) -> ResultType<()> { let app_name = crate::get_app_name(); + validate_install_app_name(&app_name)?; + let is_installed_daemon = is_installed_daemon(false); + let option_stop_service = "stop-service"; + let is_service_stopped = hbb_common::config::option2bool( + option_stop_service, + &crate::ui_interface::get_option(option_stop_service), + ); + + // Include launchd jobs in the transaction only while the installed service is enabled. if is_installed_daemon && !is_service_stopped { let agent = format!("{}_server.plist", crate::get_full_name()); let agent_plist_file = format!("/Library/LaunchAgents/{}", agent); - update_daemon_agent(agent_plist_file, app_dir, true); + update_daemon_agent(agent_plist_file, update_source)?; } else { - // `kill -9` may not work without "administrator privileges" - let update_body = r#" -on run {app_name, cur_pid, app_dir, user_name} - set app_bundle to "/Applications/" & app_name & ".app" - set app_bundle_q to quoted form of app_bundle - set app_dir_q to quoted form of app_dir - set user_name_q to quoted form of user_name - - set check_source to "test -d " & app_dir_q & " || exit 1;" - set kill_others to "pids=$(pgrep -x '" & app_name & "' | grep -vx " & cur_pid & " || true); if [ -n \"$pids\" ]; then echo \"$pids\" | xargs kill -9 || true; fi;" - set copy_files to "rm -rf " & app_bundle_q & " && ditto " & app_dir_q & " " & app_bundle_q & " && chown -R " & user_name_q & ":staff " & app_bundle_q & " && (xattr -r -d com.apple.quarantine " & app_bundle_q & " || true);" - set sh to "set -e;" & check_source & kill_others & copy_files - - do shell script sh with prompt app_name & " wants to update itself" with administrator privileges -end run - "#; - let active_user = get_active_username(); - let status = Command::new("osascript") + let (update_source_path, expected_sha256) = update_source.into_script_args(); + let expected_bundle_id = get_bundle_id() + .filter(|value| !value.is_empty()) + .ok_or_else(|| anyhow!("client bundle identifier is missing"))?; + let output = Command::new("osascript") .arg("-e") - .arg(update_body) + .arg(PRIVILEGED_UPDATE_BODY) .arg(app_name.to_string()) .arg(std::process::id().to_string()) - .arg(app_dir) - .arg(active_user) - .status(); - match status { - Ok(status) if !status.success() => { - log::error!("osascript execution failed with status: {}", status); + .arg(update_source_path) + .arg(get_active_username()) + .arg(if is_installed_daemon { "0" } else { "1" }) + .arg(expected_sha256) + .arg(expected_bundle_id) + .output(); + match output { + Ok(output) if !output.status.success() => { + let stderr = String::from_utf8_lossy(&output.stderr); + log::error!( + "osascript execution failed with status: {}, stderr: {}", + output.status, + stderr.trim() + ); + bail!( + "osascript execution failed with status: {}, stderr: {}", + output.status, + stderr.trim() + ); } Err(e) => { log::error!("run osascript failed: {}", e); + bail!("run osascript failed: {}", e); } - _ => {} + Ok(output) => log_update_cleanup_warning(&output), } } - std::process::Command::new("open") + match Command::new("open") .arg("-n") .arg(&format!("/Applications/{}.app", app_name)) - .spawn() - .ok(); + .status() + { + Ok(status) if !status.success() => { + log::warn!("Failed to relaunch updated app: {}", status); + } + Err(err) => { + log::warn!("Failed to relaunch updated app: {}", err); + } + _ => {} + } // leave open a little time std::thread::sleep(std::time::Duration::from_millis(300)); Ok(()) @@ -964,16 +1033,38 @@ end run pub fn update_from_dmg(dmg_path: &str) -> ResultType<()> { let update_temp_dir = get_update_temp_dir_string(); println!("Starting update from DMG: {}", dmg_path); - extract_dmg(dmg_path, &update_temp_dir)?; - println!("DMG extracted"); - update_extracted(&update_temp_dir)?; + let update_result = (|| { + let copied_dmg = copy_dmg_to_update_temp_file(dmg_path)?; + let guard = attach_dmg(&copied_dmg.to_string_lossy())?; + update_me_from_app_dir(format!( + "{}/{}.app", + guard.mount_point(), + crate::get_app_name() + )) + })(); + try_remove_temp_update_dir(Some(&update_temp_dir)); + update_result?; println!("Update process started"); Ok(()) } -pub fn update_to(_file: &str) -> ResultType<()> { - let update_temp_dir = get_update_temp_dir_string(); - update_extracted(&update_temp_dir)?; +pub fn update_to_verified_dmg( + file: &str, + expected_sha256: &str, + expected_size: Option, +) -> ResultType<()> { + let verified_dmg = copy_and_verify_dmg_file(file, expected_sha256, expected_size)?; + update_from_verified_dmg(&verified_dmg)?; + // Remove the private DMG copy before this process exits. + drop(verified_dmg); + quit_gui(); + Ok(()) +} + +fn update_from_verified_dmg(verified_dmg: &VerifiedDmg) -> ResultType<()> { + println!("Starting update from verified DMG"); + update_me_from_source(verified_dmg_update_source(verified_dmg)?)?; + println!("Update process started"); Ok(()) } @@ -1021,6 +1112,7 @@ fn validate_update_tree(path: &Path, framework_root: Option<&Path>) -> ResultTyp .file_name() .and_then(|name| name.to_str()) .is_some_and(|name| name.ends_with(".framework")) + && std::fs::symlink_metadata(&child)?.file_type().is_dir() { Some(child.as_path()) } else { @@ -1029,7 +1121,40 @@ fn validate_update_tree(path: &Path, framework_root: Option<&Path>) -> ResultTyp validate_update_tree(&child, child_framework_root)?; } } else if !metadata.file_type().is_file() { - bail!("[root-update] unsupported file in update bundle: {}", path.display()); + bail!( + "[root-update] unsupported file in update bundle: {}", + path.display() + ); + } + Ok(()) +} + +fn read_bundle_plist_value(app: &Path, key: &str) -> ResultType { + let info_plist = app.join("Contents/Info.plist"); + let plist_command = format!("Print :{key}"); + let output = Command::new("/usr/libexec/PlistBuddy") + .args(["-c", plist_command.as_str()]) + .arg(info_plist) + .output()?; + if !output.status.success() { + bail!( + "[root-update] failed to read bundle plist value {key}: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + let value = String::from_utf8(output.stdout) + .map_err(|err| anyhow!("[root-update] bundle plist value {key} is not UTF-8: {err}"))?; + if value.trim().is_empty() { + bail!("[root-update] bundle plist value {key} is empty"); + } + Ok(value.trim().to_owned()) +} + +fn verify_update_app_identity(installed: &Path, candidate: &Path) -> ResultType<()> { + let installed_id = read_bundle_plist_value(installed, "CFBundleIdentifier")?; + let candidate_id = read_bundle_plist_value(candidate, "CFBundleIdentifier")?; + if installed_id != candidate_id { + bail!("[root-update] candidate bundle identifier mismatch"); } Ok(()) } @@ -1038,13 +1163,7 @@ fn validate_update_tree(path: &Path, framework_root: Option<&Path>) -> ResultTyp /// Must be called from a process running as root (e.g. the service binary). pub fn update_from_dmg_as_root(dmg_path: &str, expected_version: &str) -> ResultType<()> { let app_name = crate::get_app_name(); - if app_name.is_empty() - || !app_name - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) - { - bail!("[root-update] unsafe application name"); - } + validate_install_app_name(&app_name)?; let app_bundle = format!("/Applications/{}.app", app_name); let tmp_dir_output = std::process::Command::new("/usr/bin/mktemp") .args(&["-d", "/tmp/.rustdeskupdate-root-XXXXXX"]) @@ -1073,6 +1192,7 @@ pub fn update_from_dmg_as_root(dmg_path: &str, expected_version: &str) -> Result let src_app = format!("{}/{}.app", tmp_dir, app_name); log::info!("[root-update] DMG extracted to {}", tmp_dir); validate_update_tree(Path::new(&src_app), None)?; + verify_update_app_identity(Path::new(&app_bundle), Path::new(&src_app))?; // Bind the downloaded asset to the version returned by the update // service before changing plists or executing anything from the staged @@ -1731,127 +1851,23 @@ rm -rf {tmp_dir} Ok(()) } -pub fn extract_update_dmg(file: &str) { - let update_temp_dir = get_update_temp_dir_string(); - let mut evt: HashMap<&str, String> = - HashMap::from([("name", "extract-update-dmg".to_string())]); - match extract_dmg(file, &update_temp_dir) { - Ok(_) => { - log::info!("Extracted dmg file to {}", update_temp_dir); - } - Err(e) => { - evt.insert("err", e.to_string()); - log::error!("Failed to extract dmg file {}: {}", file, e); - } - } - let evt = serde_json::ser::to_string(&evt).unwrap_or("".to_owned()); - #[cfg(feature = "flutter")] - crate::flutter::push_global_event(crate::flutter::APP_TYPE_MAIN, evt); -} - -fn extract_dmg(dmg_path: &str, target_dir: &str) -> ResultType<()> { - let target_path = Path::new(target_dir); - if target_path.exists() { - std::fs::remove_dir_all(target_path)?; - } - std::fs::create_dir_all(target_path)?; - extract_dmg_inner(dmg_path, target_dir) -} - fn extract_dmg_into_existing_dir(dmg_path: &str, target_dir: &str) -> ResultType<()> { let target_path = Path::new(target_dir); if !target_path.exists() { - bail!("[root-update] Temp directory does not exist: {:?}", target_path); + bail!( + "[root-update] Temp directory does not exist: {:?}", + target_path + ); } extract_dmg_inner(dmg_path, target_dir) } -fn extract_dmg_inner(dmg_path: &str, target_dir: &str) -> ResultType<()> { - let mount_output = Command::new("/usr/bin/mktemp") - .args(["-d", "/tmp/.rustdeskmount-XXXXXX"]) - .output()?; - if !mount_output.status.success() { - bail!("Failed to create a private DMG mount directory"); - } - let mount_point = String::from_utf8(mount_output.stdout) - .map_err(|e| anyhow!("Invalid DMG mount directory: {}", e))? - .trim() - .to_owned(); - if mount_point.is_empty() { - bail!("Failed to create a private DMG mount directory"); - } - let status = Command::new("/usr/bin/hdiutil") - .args(["attach", "-nobrowse", "-mountpoint"]) - .arg(&mount_point) - .arg(dmg_path) - .status()?; - - if !status.success() { - let _ = std::fs::remove_dir(&mount_point); - bail!("Failed to attach DMG image at {}: {:?}", dmg_path, status); - } - - struct DmgGuard(String); - impl Drop for DmgGuard { - fn drop(&mut self) { - let _ = Command::new("/usr/bin/hdiutil") - .args(["detach", self.0.as_str(), "-force"]) - .status(); - let _ = std::fs::remove_dir(&self.0); - } - } - let _guard = DmgGuard(mount_point.clone()); - - let app_name = format!("{}.app", crate::get_app_name()); - let src_path = format!("{}/{}", mount_point, app_name); - let dest_path = format!("{}/{}", target_dir, app_name); - - let copy_status = Command::new("/usr/bin/ditto") - .args(&[&src_path, &dest_path]) - .status()?; - - if !copy_status.success() { - bail!( - "Failed to copy application from {} to {}: {:?}", - src_path, - dest_path, - copy_status - ); - } - - if !Path::new(&dest_path).exists() { - bail!( - "Copy operation failed - destination not found at {}", - dest_path - ); - } - - Ok(()) -} - -fn update_extracted(target_dir: &str) -> ResultType<()> { - let app_name = crate::get_app_name(); - let exe_path = format!( - "{}/{}.app/Contents/MacOS/{}", - target_dir, app_name, app_name - ); - let _child = unsafe { - if let Err(e) = Command::new(&exe_path) - .arg("--update") - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .pre_exec(|| { - hbb_common::libc::setsid(); - Ok(()) - }) - .spawn() - { - try_remove_temp_update_dir(Some(target_dir)); - bail!(e); - } - }; - Ok(()) +fn verified_dmg_update_source(verified_dmg: &VerifiedDmg) -> ResultType { + verify_stored_dmg(verified_dmg)?; + Ok(UpdateSource::VerifiedDmg { + path: verified_dmg_path(verified_dmg)?.to_owned(), + expected_sha256: verified_dmg.expected_sha256.clone(), + }) } pub fn get_double_click_time() -> u32 { @@ -2050,7 +2066,7 @@ impl WakeLock { } } -fn get_bundle_id() -> Option { +pub(crate) fn get_bundle_id() -> Option { unsafe { let bundle: id = msg_send![class!(NSBundle), mainBundle]; if bundle.is_null() { @@ -2073,3 +2089,82 @@ fn get_bundle_id() -> Option { Some(bundle_id_str) } } + +#[cfg(test)] +mod update_tests { + use super::*; + + #[test] + fn update_tree_rejects_framework_root_symlink() { + let test_dir = std::env::temp_dir().join(format!( + "rustdesk-update-tree-framework-link-test-{}-{}", + std::process::id(), + hbb_common::rand::random::() + )); + let app_dir = test_dir.join("RustDesk.app"); + let frameworks_dir = app_dir.join("Contents/Frameworks"); + let external_framework = test_dir.join("External.framework"); + std::fs::create_dir_all(&frameworks_dir).unwrap(); + std::fs::create_dir_all(&external_framework).unwrap(); + std::os::unix::fs::symlink( + &external_framework, + frameworks_dir.join("External.framework"), + ) + .unwrap(); + + let result = validate_update_tree(&app_dir, None); + + std::fs::remove_dir_all(test_dir).unwrap(); + assert!(result.is_err()); + } + + #[test] + fn update_tree_allows_internal_framework_symlink() { + let test_dir = std::env::temp_dir().join(format!( + "rustdesk-update-tree-internal-link-test-{}-{}", + std::process::id(), + hbb_common::rand::random::() + )); + let framework_dir = test_dir.join("RustDesk.app/Contents/Frameworks/Test.framework"); + let versions_dir = framework_dir.join("Versions"); + std::fs::create_dir_all(versions_dir.join("A")).unwrap(); + std::os::unix::fs::symlink("A", versions_dir.join("Current")).unwrap(); + + let result = validate_update_tree(&test_dir.join("RustDesk.app"), None); + + std::fs::remove_dir_all(test_dir).unwrap(); + assert!(result.is_ok()); + } + + #[test] + fn update_app_identity_requires_matching_bundles() { + let test_dir = std::env::temp_dir().join(format!( + "rustdesk-update-identity-test-{}-{}", + std::process::id(), + hbb_common::rand::random::() + )); + let installed = test_dir.join("installed.app"); + let candidate = test_dir.join("candidate.app"); + let info = |bundle_id| { + format!("CFBundleIdentifier{bundle_id}") + }; + for app in [&installed, &candidate] { + std::fs::create_dir_all(app.join("Contents")).unwrap(); + } + std::fs::write( + installed.join("Contents/Info.plist"), + info("com.carriez.rustdesk"), + ) + .unwrap(); + + for (bundle_id, accepted) in [("com.carriez.rustdesk", true), ("example.other", false)] { + std::fs::write(candidate.join("Contents/Info.plist"), info(bundle_id)).unwrap(); + assert_eq!( + verify_update_app_identity(&installed, &candidate).is_ok(), + accepted + ); + } + + std::fs::remove_dir_all(test_dir).unwrap(); + } +} diff --git a/src/platform/macos/update_temp.rs b/src/platform/macos/update_temp.rs new file mode 100644 index 000000000..52bd4626b --- /dev/null +++ b/src/platform/macos/update_temp.rs @@ -0,0 +1,248 @@ +use super::ResultType; +use hbb_common::{bail, log}; +use std::{ + io::{self, Write}, + os::unix::fs::{OpenOptionsExt, PermissionsExt}, + path::{Path, PathBuf}, + sync::OnceLock, +}; + +static UPDATE_TEMP_DIR: OnceLock = OnceLock::new(); +const UPDATE_TEMP_DMG_CREATE_ATTEMPTS: usize = 16; +const STALE_UPDATE_TEMP_DIR_SECS: u64 = 24 * 60 * 60; + +#[inline] +fn get_update_temp_dir() -> PathBuf { + UPDATE_TEMP_DIR.get_or_init(new_update_temp_dir).clone() +} + +fn new_update_temp_dir() -> PathBuf { + let euid = unsafe { hbb_common::libc::geteuid() }; + Path::new("/tmp").join(format!( + ".rustdeskupdate-{}-{}-{}", + euid, + std::process::id(), + hbb_common::rand::random::() + )) +} + +fn legacy_update_temp_dir() -> PathBuf { + let euid = unsafe { hbb_common::libc::geteuid() }; + Path::new("/tmp").join(format!(".rustdeskupdate-{}", euid)) +} + +fn stale_update_temp_dir_prefix() -> String { + let euid = unsafe { hbb_common::libc::geteuid() }; + format!(".rustdeskupdate-{}-", euid) +} + +fn is_stale_update_temp_dir_name(name: &str) -> bool { + let Some(suffix) = name.strip_prefix(&stale_update_temp_dir_prefix()) else { + return false; + }; + let mut parts = suffix.split('-'); + matches!( + (parts.next(), parts.next(), parts.next()), + (Some(pid), Some(random), None) + if !pid.is_empty() + && !random.is_empty() + && pid.bytes().all(|byte| byte.is_ascii_digit()) + && random.bytes().all(|byte| byte.is_ascii_digit()) + ) +} + +#[inline] +pub(super) fn get_update_temp_dir_string() -> String { + get_update_temp_dir().to_string_lossy().into_owned() +} + +fn ensure_real_update_temp_dir(path: &Path) -> ResultType<()> { + let metadata = std::fs::symlink_metadata(path)?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + bail!( + "Update temp path is not a real directory: {}", + path.display() + ); + } + Ok(()) +} + +pub(super) fn create_update_temp_dmg_file() -> ResultType<(std::fs::File, PathBuf)> { + let update_temp_dir = get_update_temp_dir(); + std::fs::create_dir_all(&update_temp_dir)?; + ensure_real_update_temp_dir(&update_temp_dir)?; + std::fs::set_permissions(&update_temp_dir, std::fs::Permissions::from_mode(0o700))?; + + let dmg_dir = update_temp_dir.join("dmgdir"); + std::fs::create_dir_all(&dmg_dir)?; + ensure_real_update_temp_dir(&dmg_dir)?; + std::fs::set_permissions(&dmg_dir, std::fs::Permissions::from_mode(0o700))?; + + for _ in 0..UPDATE_TEMP_DMG_CREATE_ATTEMPTS { + let file_path = dmg_dir.join(format!( + "{}-{}-{}.dmg", + crate::get_app_name(), + std::process::id(), + hbb_common::rand::random::() + )); + let file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .mode(0o600) + .open(&file_path); + match file { + Ok(file) => return Ok((file, file_path)), + Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {} + Err(err) => return Err(err.into()), + } + } + + bail!("Failed to create update DMG file") +} + +#[inline] +pub fn try_remove_temp_update_dir(dir: Option<&str>) { + if let Some(dir) = dir { + remove_temp_update_dir(Path::new(dir)); + } else { + remove_temp_update_dir(&legacy_update_temp_dir()); + remove_stale_update_temp_dirs(); + } +} + +fn remove_stale_update_temp_dirs() { + use std::os::unix::fs::MetadataExt as _; + + let current_update_temp_dir = get_update_temp_dir(); + let legacy_update_temp_dir = legacy_update_temp_dir(); + let euid = unsafe { hbb_common::libc::geteuid() }; + let Ok(entries) = std::fs::read_dir("/tmp") else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path == current_update_temp_dir || path == legacy_update_temp_dir { + continue; + } + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + if !is_stale_update_temp_dir_name(name) { + continue; + } + let Ok(metadata) = std::fs::symlink_metadata(&path) else { + continue; + }; + if !metadata.is_dir() || metadata.uid() != euid || !is_old_update_temp_dir(&metadata) { + continue; + } + remove_temp_update_dir(&path); + } +} + +fn is_old_update_temp_dir(metadata: &std::fs::Metadata) -> bool { + let Ok(modified) = metadata.modified() else { + return false; + }; + modified + .elapsed() + .map(|age| age.as_secs() >= STALE_UPDATE_TEMP_DIR_SECS) + .unwrap_or(false) +} + +fn remove_temp_update_dir(path: &Path) { + match std::fs::remove_dir_all(path) { + Ok(()) => {} + Err(err) + if err.kind() == io::ErrorKind::NotFound + || err.raw_os_error() == Some(hbb_common::libc::ENOTDIR) => {} + Err(err) => { + log::warn!( + "Failed to remove update temp dir {}: {}", + path.display(), + err + ); + } + } +} + +pub(super) fn copy_dmg_to_update_temp_file(dmg_path: &str) -> ResultType { + let metadata = std::fs::symlink_metadata(dmg_path)?; + if !metadata.file_type().is_file() { + bail!("Update DMG path is not a regular file: {}", dmg_path); + } + let mut source_file = std::fs::OpenOptions::new() + .read(true) + .custom_flags(hbb_common::libc::O_NOFOLLOW | hbb_common::libc::O_NONBLOCK) + .open(dmg_path)?; + if !source_file.metadata()?.is_file() { + bail!("Update DMG path is not a regular file: {}", dmg_path); + } + let (mut dmg_file, file_path) = create_update_temp_dmg_file()?; + io::copy(&mut source_file, &mut dmg_file)?; + dmg_file.flush()?; + Ok(file_path) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn temp_dir_cleanup_does_not_follow_symlink() { + let test_dir = std::env::temp_dir().join(format!( + "rustdesk-macos-cleanup-symlink-test-{}-{}", + std::process::id(), + hbb_common::rand::random::() + )); + let target_dir = test_dir.join("target"); + let link_path = test_dir.join("link"); + let target_file = target_dir.join("file"); + std::fs::create_dir_all(&target_dir).unwrap(); + std::fs::write(&target_file, b"target").unwrap(); + std::os::unix::fs::symlink(&target_dir, &link_path).unwrap(); + + remove_temp_update_dir(&link_path); + + assert!(std::fs::symlink_metadata(&link_path).is_err()); + assert!(target_file.exists()); + std::fs::remove_dir_all(test_dir).unwrap(); + } + + #[test] + fn update_temp_dir_rejects_symlink() { + let test_dir = std::env::temp_dir().join(format!( + "rustdesk-macos-temp-dir-symlink-test-{}-{}", + std::process::id(), + hbb_common::rand::random::() + )); + let target_dir = test_dir.join("target"); + let link_path = test_dir.join("link"); + std::fs::create_dir_all(&target_dir).unwrap(); + std::os::unix::fs::symlink(&target_dir, &link_path).unwrap(); + + assert!(ensure_real_update_temp_dir(&link_path).is_err()); + assert!(ensure_real_update_temp_dir(&target_dir).is_ok()); + + std::fs::remove_dir_all(test_dir).unwrap(); + } + + #[test] + fn stale_update_temp_dir_name_requires_owned_randomized_dir() { + let euid = unsafe { hbb_common::libc::geteuid() }; + + assert!(is_stale_update_temp_dir_name(&format!( + ".rustdeskupdate-{euid}-123-456" + ))); + for name in [ + format!(".rustdeskupdate-{euid}"), + format!(".rustdeskupdate-{euid}-abc-456"), + format!(".rustdeskupdate-{euid}-123"), + ".rustdeskupdate-999999-123-456".to_owned(), + "rustdeskupdate-123-456".to_owned(), + ] { + assert!(!is_stale_update_temp_dir_name(&name), "{name}"); + } + } +} diff --git a/src/platform/macos/verified_dmg.rs b/src/platform/macos/verified_dmg.rs new file mode 100644 index 000000000..8fbd802d9 --- /dev/null +++ b/src/platform/macos/verified_dmg.rs @@ -0,0 +1,240 @@ +use super::{update_temp::create_update_temp_dmg_file, ResultType}; +use hbb_common::{anyhow::anyhow, bail, log}; +use std::{ + io::{self, Write}, + os::unix::fs::OpenOptionsExt, + path::{Path, PathBuf}, + process::Command, +}; + +const UPDATE_DMG_MOUNT_TEMPLATE: &str = "/tmp/.rustdeskmount-XXXXXX"; + +#[derive(Debug)] +pub(super) struct VerifiedDmg { + pub(super) file: std::fs::File, + pub(super) path: PathBuf, + pub(super) expected_sha256: String, +} + +impl Drop for VerifiedDmg { + fn drop(&mut self) { + if let Err(err) = std::fs::remove_file(&self.path) { + if err.kind() != io::ErrorKind::NotFound { + log::warn!( + "Failed to remove verified DMG copy {}: {}", + self.path.display(), + err + ); + } + } + } +} + +// Retain the named copy until the privileged installer has verified its root-owned copy. +pub(super) fn copy_and_verify_dmg_file( + file: &str, + expected_sha256: &str, + expected_size: Option, +) -> ResultType { + let mut source_file = open_dmg_file(file, expected_size)?; + let mut verified_dmg = create_verified_dmg_file(expected_sha256)?; + let copied_size = io::copy(&mut source_file, &mut verified_dmg.file)?; + if expected_size.is_some_and(|size| size != copied_size) { + bail!("DMG size mismatch for {}", file); + } + verified_dmg.file.flush()?; + verify_dmg_contents(&mut verified_dmg.file, expected_sha256, file)?; + Ok(verified_dmg) +} + +fn open_dmg_file(file: &str, expected_size: Option) -> ResultType { + let path_metadata = std::fs::symlink_metadata(file)?; + if path_metadata.file_type().is_symlink() || !path_metadata.is_file() { + bail!("Update DMG path is not a regular file: {}", file); + } + if expected_size.is_some_and(|size| size != path_metadata.len()) { + bail!("DMG size mismatch for {}", file); + } + let dmg_file = std::fs::OpenOptions::new() + .read(true) + .custom_flags(hbb_common::libc::O_NOFOLLOW | hbb_common::libc::O_NONBLOCK) + .open(file)?; + let opened_metadata = dmg_file.metadata()?; + if !opened_metadata.is_file() || expected_size.is_some_and(|size| size != opened_metadata.len()) + { + bail!("DMG changed while opening {}", file); + } + Ok(dmg_file) +} + +fn create_verified_dmg_file(expected_sha256: &str) -> ResultType { + let (file, path) = create_update_temp_dmg_file()?; + Ok(VerifiedDmg { + file, + path, + expected_sha256: expected_sha256.trim().to_ascii_lowercase(), + }) +} + +fn verify_dmg_contents( + dmg_file: &mut std::fs::File, + expected_sha256: &str, + file: &str, +) -> ResultType<()> { + use crate::update_hash::{verify_sha256_reader, Sha256VerificationError}; + + match verify_sha256_reader(dmg_file, expected_sha256) { + Ok(()) => Ok(()), + Err(Sha256VerificationError::InvalidExpected) => { + bail!("Expected DMG SHA256 is malformed for {}", file) + } + Err(Sha256VerificationError::Mismatch { .. }) => { + bail!("SHA256 mismatch for {}", file) + } + Err(Sha256VerificationError::Io(err)) => Err(err.into()), + } +} + +pub(super) struct DmgGuard(String); + +impl DmgGuard { + pub(super) fn mount_point(&self) -> &str { + &self.0 + } +} + +impl Drop for DmgGuard { + fn drop(&mut self) { + match Command::new("/usr/bin/hdiutil") + .args(["detach", self.0.as_str(), "-force"]) + .status() + { + Ok(status) if !status.success() => { + log::warn!("Failed to detach DMG mount {}: {}", self.0, status); + } + Err(err) => log::warn!("Failed to detach DMG mount {}: {}", self.0, err), + _ => {} + } + remove_dmg_mount_point(&self.0); + } +} + +pub(super) fn attach_dmg(dmg_path: &str) -> ResultType { + let mount_point = create_dmg_mount_point()?; + // Update images are input only, so never mount them writable. + let output = match Command::new("/usr/bin/hdiutil") + .args([ + "attach", + "-readonly", + "-nobrowse", + "-mountpoint", + mount_point.as_str(), + dmg_path, + ]) + .output() + { + Ok(output) => output, + Err(err) => { + remove_dmg_mount_point(&mount_point); + return Err(err.into()); + } + }; + if !output.status.success() { + remove_dmg_mount_point(&mount_point); + bail!( + "Failed to attach DMG image at {dmg_path} to {mount_point}: {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + ); + } + Ok(DmgGuard(mount_point)) +} + +pub(super) fn create_dmg_mount_point() -> ResultType { + let output = Command::new("/usr/bin/mktemp") + .args(["-d", UPDATE_DMG_MOUNT_TEMPLATE]) + .output()?; + if !output.status.success() { + bail!( + "Failed to create a private DMG mount directory: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + let mount_point = String::from_utf8(output.stdout) + .map_err(|e| anyhow!("Invalid DMG mount directory: {}", e))? + .trim() + .to_owned(); + if mount_point.is_empty() { + bail!("Failed to create a private DMG mount directory"); + } + Ok(mount_point) +} + +fn remove_dmg_mount_point(mount_point: &str) { + if let Err(err) = std::fs::remove_dir(mount_point) { + if err.kind() != io::ErrorKind::NotFound { + log::warn!( + "Failed to remove DMG mount directory {}: {}", + mount_point, + err + ); + } + } +} + +pub(super) fn verified_dmg_path(verified_dmg: &VerifiedDmg) -> ResultType<&str> { + use std::os::unix::fs::MetadataExt as _; + + let path_metadata = std::fs::symlink_metadata(&verified_dmg.path)?; + let file_metadata = verified_dmg.file.metadata()?; + if !path_metadata.is_file() + || path_metadata.dev() != file_metadata.dev() + || path_metadata.ino() != file_metadata.ino() + { + bail!("Verified DMG path changed: {}", verified_dmg.path.display()); + } + verified_dmg + .path + .to_str() + .ok_or_else(|| anyhow!("Invalid verified DMG path: {}", verified_dmg.path.display())) +} + +pub(super) fn verify_stored_dmg(verified_dmg: &VerifiedDmg) -> ResultType<()> { + let mut file = verified_dmg.file.try_clone()?; + verify_dmg_contents( + &mut file, + &verified_dmg.expected_sha256, + &verified_dmg.path.to_string_lossy(), + ) +} + +pub(super) fn extract_dmg_inner(dmg_path: &str, target_dir: &str) -> ResultType<()> { + let guard = attach_dmg(dmg_path)?; + extract_attached_dmg(guard, target_dir) +} + +fn extract_attached_dmg(guard: DmgGuard, target_dir: &str) -> ResultType<()> { + let mount_point = guard.mount_point(); + let app_name = format!("{}.app", crate::get_app_name()); + let src_path = format!("{}/{}", mount_point, app_name); + let dest_path = format!("{}/{}", target_dir, app_name); + let copy_status = Command::new("/usr/bin/ditto") + .args(&[&src_path, &dest_path]) + .status()?; + + if !copy_status.success() { + bail!( + "Failed to copy application from {} to {}: {:?}", + src_path, + dest_path, + copy_status + ); + } + if !Path::new(&dest_path).exists() { + bail!( + "Copy operation failed - destination not found at {}", + dest_path + ); + } + Ok(()) +} diff --git a/src/platform/macos/verified_dmg_tests.rs b/src/platform/macos/verified_dmg_tests.rs new file mode 100644 index 000000000..8c9ca0e2e --- /dev/null +++ b/src/platform/macos/verified_dmg_tests.rs @@ -0,0 +1,208 @@ +use super::{ + verified_dmg::{ + copy_and_verify_dmg_file, create_dmg_mount_point, extract_dmg_inner, verified_dmg_path, + verify_stored_dmg, VerifiedDmg, + }, + verified_dmg_update_source, ResultType, +}; +use std::{ + path::{Path, PathBuf}, + process::Command, +}; + +const TEST_DMG_MARKER: &[u8] = b"verified DMG"; + +fn create_test_dmg(test_dir: &Path) -> PathBuf { + let source_dir = test_dir.join("source"); + let app_dir = source_dir + .join(format!("{}.app", crate::get_app_name())) + .join("Contents"); + std::fs::create_dir_all(&app_dir).unwrap(); + std::fs::write(app_dir.join("marker"), TEST_DMG_MARKER).unwrap(); + let dmg_path = test_dir.join("update.dmg"); + let output = Command::new("/usr/bin/hdiutil") + .args(["create", "-quiet", "-format", "UDZO", "-srcfolder"]) + .arg(&source_dir) + .arg(&dmg_path) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + dmg_path +} + +fn extract_verified_dmg(verified_dmg: &VerifiedDmg, target_dir: &str) -> ResultType<()> { + let target_path = Path::new(target_dir); + if target_path.exists() { + std::fs::remove_dir_all(target_path)?; + } + std::fs::create_dir_all(target_path)?; + verify_stored_dmg(verified_dmg)?; + extract_dmg_inner(verified_dmg_path(verified_dmg)?, target_dir) +} + +#[test] +fn update_dmg_mount_points_are_unique() { + let first = create_dmg_mount_point().unwrap(); + let second = create_dmg_mount_point().unwrap(); + let are_unique = first != second; + for mount_point in [&first, &second] { + if mount_point.starts_with("/tmp/.rustdeskmount-") { + std::fs::remove_dir(mount_point).unwrap(); + } + } + + assert!(are_unique); +} + +#[test] +fn verified_dmg_extracts_from_named_copy() { + let test_dir = std::env::temp_dir().join(format!( + "rustdesk-verified-dmg-extract-test-{}-{}", + std::process::id(), + hbb_common::rand::random::() + )); + std::fs::create_dir_all(&test_dir).unwrap(); + let dmg_path = create_test_dmg(&test_dir); + let mut dmg_file = std::fs::File::open(&dmg_path).unwrap(); + let sha256 = crate::update_hash::sha256_reader_hex(&mut dmg_file).unwrap(); + let size = dmg_file.metadata().unwrap().len(); + let verified_dmg = + copy_and_verify_dmg_file(&dmg_path.to_string_lossy(), &sha256, Some(size)).unwrap(); + let target_dir = test_dir.join("target"); + + let result = extract_verified_dmg(&verified_dmg, &target_dir.to_string_lossy()); + let marker = std::fs::read( + target_dir + .join(format!("{}.app", crate::get_app_name())) + .join("Contents/marker"), + ); + std::fs::remove_dir_all(test_dir).unwrap(); + + assert!(result.is_ok(), "{result:?}"); + assert_eq!(marker.unwrap(), TEST_DMG_MARKER); +} + +#[test] +fn verified_dmg_rejects_replaced_path() { + let test_dir = std::env::temp_dir().join(format!( + "rustdesk-verified-dmg-replacement-test-{}-{}", + std::process::id(), + hbb_common::rand::random::() + )); + let dmg_path = create_test_dmg(&test_dir.join("trusted")); + let mut dmg_file = std::fs::File::open(&dmg_path).unwrap(); + let sha256 = crate::update_hash::sha256_reader_hex(&mut dmg_file).unwrap(); + let size = dmg_file.metadata().unwrap().len(); + let verified_dmg = + copy_and_verify_dmg_file(&dmg_path.to_string_lossy(), &sha256, Some(size)).unwrap(); + let replacement_dmg = create_test_dmg(&test_dir.join("replacement")); + std::fs::remove_file(&verified_dmg.path).unwrap(); + std::fs::rename(replacement_dmg, &verified_dmg.path).unwrap(); + + let result = verified_dmg_update_source(&verified_dmg); + drop(verified_dmg); + std::fs::remove_dir_all(test_dir).unwrap(); + + assert!(result.is_err()); +} + +#[test] +fn verified_dmg_rejects_in_place_mutation() { + let test_dir = std::env::temp_dir().join(format!( + "rustdesk-verified-dmg-mutation-test-{}-{}", + std::process::id(), + hbb_common::rand::random::() + )); + let dmg_path = create_test_dmg(&test_dir); + let mut dmg_file = std::fs::File::open(&dmg_path).unwrap(); + let sha256 = crate::update_hash::sha256_reader_hex(&mut dmg_file).unwrap(); + let size = dmg_file.metadata().unwrap().len(); + let verified_dmg = + copy_and_verify_dmg_file(&dmg_path.to_string_lossy(), &sha256, Some(size)).unwrap(); + std::fs::write(&verified_dmg.path, b"tampered").unwrap(); + + let result = verified_dmg_update_source(&verified_dmg); + drop(verified_dmg); + std::fs::remove_dir_all(test_dir).unwrap(); + + assert!(result + .err() + .unwrap() + .to_string() + .contains("SHA256 mismatch")); +} + +#[test] +fn verified_dmg_rejects_sha256_mismatch() { + let file_path = + std::env::temp_dir().join(format!("rustdesk-verified-dmg-test-{}", std::process::id())); + std::fs::write(&file_path, b"rustdesk").unwrap(); + let result = copy_and_verify_dmg_file( + &file_path.to_string_lossy(), + "0000000000000000000000000000000000000000000000000000000000000000", + Some(8), + ); + + std::fs::remove_file(file_path).unwrap(); + assert!(result.is_err()); +} + +#[test] +fn verified_dmg_rejects_symlink() { + let test_dir = std::env::temp_dir().join(format!( + "rustdesk-verified-dmg-symlink-test-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&test_dir); + std::fs::create_dir_all(&test_dir).unwrap(); + let target_path = test_dir.join("target.dmg"); + let link_path = test_dir.join("update.dmg"); + std::fs::write(&target_path, b"rustdesk").unwrap(); + std::os::unix::fs::symlink(&target_path, &link_path).unwrap(); + let result = copy_and_verify_dmg_file( + &link_path.to_string_lossy(), + "304ca1638c5effa6832e0e15b958a8f74847efe4df9c3f3187216e921c168fed", + Some(8), + ); + + std::fs::remove_dir_all(test_dir).unwrap(); + assert!(result + .unwrap_err() + .to_string() + .contains("not a regular file")); +} + +#[test] +fn verified_dmg_handle_survives_path_replacement() { + use std::io::{Read as _, Seek as _}; + + let test_dir = std::env::temp_dir().join(format!( + "rustdesk-verified-dmg-handle-test-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&test_dir); + std::fs::create_dir_all(&test_dir).unwrap(); + let file_path = test_dir.join("update.dmg"); + let original_path = test_dir.join("original.dmg"); + std::fs::write(&file_path, b"rustdesk").unwrap(); + + let mut verified_dmg = copy_and_verify_dmg_file( + &file_path.to_string_lossy(), + "304ca1638c5effa6832e0e15b958a8f74847efe4df9c3f3187216e921c168fed", + Some(8), + ) + .unwrap(); + std::fs::rename(&file_path, &original_path).unwrap(); + std::fs::write(&file_path, b"tampered").unwrap(); + + verified_dmg.file.seek(std::io::SeekFrom::Start(0)).unwrap(); + let mut contents = Vec::new(); + verified_dmg.file.read_to_end(&mut contents).unwrap(); + + std::fs::remove_dir_all(test_dir).unwrap(); + assert_eq!(contents, b"rustdesk"); +} diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 2e6061664..665c4fc51 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -33,6 +33,33 @@ use hbb_common::sysinfo::System; #[cfg(not(any(target_os = "android", target_os = "ios")))] use hbb_common::{sysinfo::Pid, ResultType}; use std::sync::{Arc, Mutex}; + +// Restrict names because installers interpolate them into privileged scripts and paths. +#[cfg(any(windows, target_os = "macos"))] +fn validate_install_app_name(app_name: &str) -> ResultType<()> { + let mut bytes = app_name.bytes(); + let Some(first) = bytes.next() else { + hbb_common::bail!("Application name must not be empty."); + }; + // Keep leading hyphens valid on Windows for existing custom clients. macOS + // rejects them because pgrep interprets a leading hyphen as an option. + let valid_first = + first.is_ascii_alphanumeric() || first == b'_' || (cfg!(windows) && first == b'-'); + if !valid_first + || !bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_') + { + let valid_prefix = if cfg!(windows) { + "a letter, number, underscore, or hyphen" + } else { + "a letter, number, or underscore" + }; + hbb_common::bail!( + "Application name must start with {valid_prefix} and contain only letters, numbers, hyphens, and underscores." + ); + } + Ok(()) +} + #[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] pub const SERVICE_INTERVAL: u64 = 300; @@ -212,6 +239,31 @@ pub fn get_pids_of_process_with_first_arg, S2: AsRef>( #[cfg(test)] mod tests { use super::*; + + #[cfg(any(windows, target_os = "macos"))] + #[test] + fn install_app_names_enforce_ascii_command_safety() { + for app_name in [ + "RustDesk", + "RustDesk-2", + "A", + "A-0", + "1RustDesk", + "_RustDesk", + "Rust_Desk", + ] { + assert!(validate_install_app_name(app_name).is_ok(), "{app_name}"); + } + for app_name in ["", "Rust.Desk", "Rust Desk", "RüstDesk", "RustDesk;touch"] { + assert!(validate_install_app_name(app_name).is_err(), "{app_name}"); + } + + #[cfg(windows)] + assert!(validate_install_app_name("-RustDesk").is_ok()); + #[cfg(target_os = "macos")] + assert!(validate_install_app_name("-RustDesk").is_err()); + } + #[test] fn test_cursor_data() { for _ in 0..30 { diff --git a/src/platform/privileges_scripts/update.scpt b/src/platform/privileges_scripts/update.scpt index 0484c257a..8c02918f6 100644 --- a/src/platform/privileges_scripts/update.scpt +++ b/src/platform/privileges_scripts/update.scpt @@ -1,26 +1,68 @@ -on run {daemon_file, agent_file, user, cur_pid, source_dir} +on run {user, cur_pid, source_path, expected_sha256} + -- RustDesk identifiers in this script are templates. correct_app_name() + -- rewrites them for the installed client before osascript executes it. set agent_plist to "/Library/LaunchAgents/com.carriez.RustDesk_server.plist" set daemon_plist to "/Library/LaunchDaemons/com.carriez.RustDesk_service.plist" set app_bundle to "/Applications/RustDesk.app" + set installed_info_q to quoted form of (app_bundle & "/Contents/Info.plist") + set service_executable to app_bundle & "/Contents/MacOS/service" + set daemon_label to "com.carriez.RustDesk_service" + set daemon_socket to "/tmp/RustDesk-service/ipc_service" + set readiness_attempts to "30" + set write_plist_attempts to "60" - set check_source to "test -d " & quoted form of source_dir & " || exit 1;" - set resolve_uid to "uid=$(id -u " & quoted form of user & " 2>/dev/null || true);" + set source_path_q to quoted form of source_path + set expected_sha256_q to quoted form of expected_sha256 + set daemon_plist_q to quoted form of daemon_plist + set daemon_target_q to quoted form of ("system/" & daemon_label) + set check_source to "if [ -n " & expected_sha256_q & " ]; then test -f " & source_path_q & "; else test -d " & source_path_q & "; fi;" + -- Rehash the root-owned copy in a clean environment before staging bytes. + set prepare_verified to "verified_dir=$(/usr/bin/mktemp -d /tmp/.rustdeskupdate-verified.XXXXXX); /bin/chmod 0700 \"$verified_dir\"; verified_app=\"$verified_dir/RustDesk.app\"; dmg_attached=0; if [ -n " & expected_sha256_q & " ]; then verified_dmg=\"$verified_dir/update.dmg\"; /bin/cp " & source_path_q & " \"$verified_dmg\"; /usr/sbin/chown root:wheel \"$verified_dmg\"; /bin/chmod 0400 \"$verified_dmg\"; actual_sha256=$(/usr/bin/env -i /usr/bin/shasum -a 256 \"$verified_dmg\"); actual_sha256=${actual_sha256%% *}; if [ \"$actual_sha256\" != " & expected_sha256_q & " ]; then echo 'Update DMG SHA256 mismatch' >&2; exit 1; fi; dmg_mount=\"$verified_dir/mount\"; /bin/mkdir \"$dmg_mount\"; dmg_attached=1; /usr/bin/hdiutil attach -readonly -nobrowse -mountpoint \"$dmg_mount\" \"$verified_dmg\" >/dev/null; /usr/bin/ditto \"$dmg_mount/RustDesk.app\" \"$verified_app\"; /usr/bin/hdiutil detach \"$dmg_mount\" -force >/dev/null; dmg_attached=0; /bin/rm -f \"$verified_dmg\"; else /usr/bin/ditto " & source_path_q & " \"$verified_app\"; fi; /usr/sbin/chown -R root:wheel \"$verified_app\"; /bin/chmod -R go-w \"$verified_app\";" + set validate_verified_app to "installed_bundle_id=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' " & installed_info_q & "); candidate_bundle_id=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' \"$verified_app/Contents/Info.plist\"); if [ -z \"$installed_bundle_id\" ] || [ -z \"$candidate_bundle_id\" ] || [ \"$installed_bundle_id\" != \"$candidate_bundle_id\" ]; then echo 'Update app bundle identifier mismatch' >&2; exit 1; fi;" + set resolve_uid to "uid=$(id -u " & quoted form of user & ");" set unload_agent to "if [ -n \"$uid\" ]; then launchctl bootout gui/$uid " & quoted form of agent_plist & " 2>/dev/null || launchctl bootout user/$uid " & quoted form of agent_plist & " 2>/dev/null || launchctl unload -w " & quoted form of agent_plist & " || true; else launchctl unload -w " & quoted form of agent_plist & " || true; fi;" - set unload_service to "launchctl unload -w " & daemon_plist & " || true;" + set unload_service to "launchctl unload -w " & daemon_plist_q & " || true;" set kill_others to "pids=$(pgrep -x 'RustDesk' | grep -vx " & cur_pid & " || true); if [ -n \"$pids\" ]; then echo \"$pids\" | xargs kill -9 || true; fi;" - set copy_files to "(rm -rf " & quoted form of app_bundle & " && ditto " & quoted form of source_dir & " " & quoted form of app_bundle & " && chown -R " & quoted form of user & ":staff " & quoted form of app_bundle & " && (xattr -r -d com.apple.quarantine " & quoted form of app_bundle & " || true)) || exit 1;" + -- Update transaction: + -- source -> root-owned verified_app -> .new -> installed app + -- old installed app -> .old + -- failure before transaction starts: clean temporary files; installed state is unchanged + -- failure after transaction starts but before commit: restore app, plists, service, and agent + -- failure after commit: keep the new app and report cleanup errors only + set prepare_swap_paths to "temp_bundle=" & quoted form of app_bundle & ".new.$$; old_bundle=" & quoted form of app_bundle & ".old.$$;" + set cleanup_swap_paths to "rm -rf \"$temp_bundle\" \"$old_bundle\";" + set backup_plists to "daemon_plist_backup=\"$verified_dir/daemon.plist\"; agent_plist_backup=\"$verified_dir/agent.plist\"; daemon_plist_existed=0; agent_plist_existed=0; if [ -e " & daemon_plist_q & " ]; then cp -p " & daemon_plist_q & " \"$daemon_plist_backup\"; daemon_plist_existed=1; fi; if [ -e " & quoted form of agent_plist & " ]; then cp -p " & quoted form of agent_plist & " \"$agent_plist_backup\"; agent_plist_existed=1; fi;" + set stage_bundle to "ditto \"$verified_app\" \"$temp_bundle\";" + set protect_staged_bundle to "chown -R root:wheel \"$temp_bundle\"; chmod -R go-w \"$temp_bundle\"; (xattr -r -d com.apple.quarantine \"$temp_bundle\" || true);" + set move_current_bundle to "if [ -e " & quoted form of app_bundle & " ]; then mv " & quoted form of app_bundle & " \"$old_bundle\"; bundle_backed_up=1; fi;" + set install_staged_bundle to "mv \"$temp_bundle\" " & quoted form of app_bundle & "; bundle_swapped=1;" + set rollback_bundle to "if [ \"${bundle_backed_up:-0}\" -eq 1 ]; then if [ ! -e \"$old_bundle\" ]; then rollback_status=1; elif ! rm -rf " & quoted form of app_bundle & "; then rollback_status=1; elif ! mv \"$old_bundle\" " & quoted form of app_bundle & "; then rollback_status=1; fi; elif [ \"${bundle_swapped:-0}\" -eq 1 ]; then rm -rf " & quoted form of app_bundle & " || rollback_status=1; fi;" + set rollback_plists to "if [ \"${daemon_plist_existed:-0}\" -eq 1 ]; then cp -p \"$daemon_plist_backup\" " & daemon_plist_q & " || rollback_status=1; else rm -f " & daemon_plist_q & " || rollback_status=1; fi; if [ \"${agent_plist_existed:-0}\" -eq 1 ]; then cp -p \"$agent_plist_backup\" " & quoted form of agent_plist & " || rollback_status=1; else rm -f " & quoted form of agent_plist & " || rollback_status=1; fi;" + set cleanup_verified to "if [ \"${dmg_attached:-0}\" -eq 1 ]; then /usr/bin/hdiutil detach \"$dmg_mount\" -force >/dev/null 2>&1 || cleanup_status=1; fi; if [ -n \"${temp_bundle:-}\" ]; then rm -rf \"$temp_bundle\" || cleanup_status=1; fi; if [ -n \"${verified_dir:-}\" ]; then rm -rf \"$verified_dir\" || cleanup_status=1; fi;" - set write_daemon_plist to "echo " & quoted form of daemon_file & " > " & daemon_plist & " && chown root:wheel " & daemon_plist & ";" - set write_agent_plist to "echo " & quoted form of agent_file & " > " & agent_plist & " && chown root:wheel " & agent_plist & ";" - set load_service to "launchctl load -w " & daemon_plist & ";" + -- Generate plist definitions from the installed release that will run them. + set write_new_plists to "write_new_plists() { " & quoted form of service_executable & " --write-plists & write_pid=$!; for _ in $(/usr/bin/seq 1 " & write_plist_attempts & "); do if ! kill -0 \"$write_pid\" 2>/dev/null; then wait \"$write_pid\"; return $?; fi; sleep 1; done; kill -TERM \"$write_pid\" 2>/dev/null || true; sleep 1; kill -KILL \"$write_pid\" 2>/dev/null || true; wait \"$write_pid\" 2>/dev/null || true; return 124; }; write_new_plists;" + set load_service to "launchctl load -w " & daemon_plist_q & ";" set agent_label_cmd to "agent_label=$(basename " & quoted form of agent_plist & " .plist);" - set bootstrap_agent to "if [ -n \"$uid\" ]; then launchctl bootstrap gui/$uid " & quoted form of agent_plist & " 2>/dev/null || launchctl bootstrap user/$uid " & quoted form of agent_plist & " 2>/dev/null || launchctl load -w " & quoted form of agent_plist & " || true; else launchctl load -w " & quoted form of agent_plist & " || true; fi;" - set kickstart_agent to "if [ -n \"$uid\" ]; then launchctl kickstart -k gui/$uid/$agent_label 2>/dev/null || launchctl kickstart -k user/$uid/$agent_label 2>/dev/null || true; fi;" + -- Agent load failure must trigger rollback; restoration remains best effort. + set bootstrap_agent to "if [ -n \"$uid\" ]; then launchctl bootstrap gui/$uid " & quoted form of agent_plist & " 2>/dev/null || launchctl bootstrap user/$uid " & quoted form of agent_plist & " 2>/dev/null || launchctl load -w " & quoted form of agent_plist & "; else launchctl load -w " & quoted form of agent_plist & "; fi;" + set kickstart_agent to "if [ -n \"$uid\" ]; then launchctl kickstart -k \"gui/$uid/$agent_label\" 2>/dev/null || launchctl kickstart -k \"user/$uid/$agent_label\" 2>/dev/null || true; fi;" set load_agent to agent_label_cmd & bootstrap_agent & kickstart_agent + -- Registration can succeed before a job is ready; keep rollback until both IPC sockets are live. + -- A stale socket still passes -S; inspect open sockets without triggering IPC authentication. + set check_service to "service_info=$(launchctl print " & daemon_target_q & " 2>/dev/null || true); printf '%s\n' \"$service_info\" | grep -E '^[[:space:]]*state = running[[:space:]]*$' >/dev/null && [ -S " & quoted form of daemon_socket & " ] && /usr/sbin/lsof -a -U -- " & quoted form of daemon_socket & " >/dev/null" + set check_agent to "agent_info=$(launchctl print \"gui/$uid/$agent_label\" 2>/dev/null || launchctl print \"user/$uid/$agent_label\" 2>/dev/null || launchctl print \"system/$agent_label\" 2>/dev/null || true); printf '%s\n' \"$agent_info\" | grep -E '^[[:space:]]*state = running[[:space:]]*$' >/dev/null && [ -S \"/tmp/RustDesk-$uid/ipc\" ] && /usr/sbin/lsof -a -U -- \"/tmp/RustDesk-$uid/ipc\" >/dev/null" + set wait_for_service to "service_ready=0; for _ in $(/usr/bin/seq 1 " & readiness_attempts & "); do if " & check_service & "; then service_ready=1; break; fi; sleep 1; done; [ \"$service_ready\" -eq 1 ];" + set wait_for_agent to "agent_ready=0; for _ in $(/usr/bin/seq 1 " & readiness_attempts & "); do if " & check_agent & "; then agent_ready=1; break; fi; sleep 1; done; [ \"$agent_ready\" -eq 1 ];" + set verify_readiness to check_service & " || exit 1;" & check_agent & " || exit 1;" + set restore_service to "launchctl load -w " & daemon_plist_q & " || rollback_status=1;" + set restore_agent to "if [ -n \"$uid\" ]; then launchctl bootstrap gui/$uid " & quoted form of agent_plist & " 2>/dev/null || launchctl bootstrap user/$uid " & quoted form of agent_plist & " 2>/dev/null || launchctl load -w " & quoted form of agent_plist & " || rollback_status=1; else launchctl load -w " & quoted form of agent_plist & " || rollback_status=1; fi;" + set rollback_update to "status=$?; trap - EXIT; set +e; cleanup_status=0; if [ \"${transaction_started:-0}\" -eq 1 ] && [ \"${transaction_committed:-0}\" -ne 1 ]; then rollback_status=0;" & unload_agent & unload_service & rollback_bundle & rollback_plists & restore_service & restore_agent & "if [ \"$rollback_status\" -ne 0 ]; then status=1; fi; fi; if [ \"${rollback_status:-0}\" -eq 0 ]; then " & cleanup_verified & "fi; if [ \"$cleanup_status\" -ne 0 ] && [ \"${transaction_committed:-0}\" -ne 1 ]; then status=1; elif [ \"$cleanup_status\" -ne 0 ]; then echo 'UPDATE_CLEANUP_FAILED_AFTER_COMMIT'; fi; exit \"$status\";" + set commit_update to "transaction_committed=1; if ! rm -rf \"$old_bundle\"; then echo 'UPDATE_CLEANUP_FAILED_AFTER_COMMIT'; fi;" - set sh to "set -e;" & check_source & resolve_uid & unload_agent & unload_service & kill_others & copy_files & write_daemon_plist & write_agent_plist & load_service & load_agent + set sh to "set -e; transaction_started=0; transaction_committed=0; bundle_backed_up=0; bundle_swapped=0; trap " & quoted form of rollback_update & " EXIT;" & check_source & prepare_verified & validate_verified_app & resolve_uid & prepare_swap_paths & cleanup_swap_paths & backup_plists & stage_bundle & protect_staged_bundle & "transaction_started=1;" & unload_agent & unload_service & kill_others & move_current_bundle & install_staged_bundle & write_new_plists & load_service & wait_for_service & load_agent & wait_for_agent & verify_readiness & commit_update do shell script sh with prompt "RustDesk wants to update itself" with administrator privileges end run diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 438baf729..6eb21327f 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -1,4 +1,4 @@ -use super::{CursorData, ResultType}; +use super::{validate_install_app_name, CursorData, ResultType}; use crate::{ common::PORTABLE_APPNAME_RUNTIME_ENV_KEY, custom_server::*, @@ -99,6 +99,7 @@ use winreg::{enums::*, RegKey}; mod acl; mod installer_handoff; mod installer_shell; +mod verified_update; mod msi_registry; pub(crate) use acl::current_process_user_sid_string; pub use acl::{ @@ -111,6 +112,10 @@ use installer_shell::{ embedded_shortcut_commands, embedded_tray_shortcut_commands, escape_nested_cmd_ampersands, shortcut_bytes, validate_install_value, }; +pub use verified_update::{ + copy_and_verify_update_file_sha256, schedule_current_verified_update_file_cleanup, + update_to_verified, VerifiedUpdateFile, +}; pub const FLUTTER_RUNNER_WIN32_WINDOW_CLASS: &'static str = "FLUTTER_RUNNER_WIN32_WINDOW"; // main window, install window pub const EXPLORER_EXE: &'static str = "explorer.exe"; @@ -127,17 +132,6 @@ const MSI_EXIT_SUCCESS_REBOOT_INITIATED: u32 = 1641; const MSI_EXIT_SUCCESS_REBOOT_REQUIRED: u32 = 3010; const HKLM_PREFIX: &str = "HKEY_LOCAL_MACHINE\\"; -fn validate_install_app_name(app_name: &str) -> ResultType<()> { - if app_name.is_empty() - || !app_name - .chars() - .all(|character| character.is_ascii_alphanumeric() || character == '-') - { - bail!("Application name must match [a-zA-Z0-9-]+"); - } - Ok(()) -} - pub fn get_focused_display(displays: Vec) -> Option { unsafe { let hwnd = GetForegroundWindow(); @@ -3880,33 +3874,6 @@ pub fn handle_custom_client_staging_dir_before_update( Ok(()) } -// Used for auto update and manual update in the main window. -pub fn update_to(file: &str) -> ResultType<()> { - if file.ends_with(".exe") { - let custom_client_staging_dir = get_custom_client_staging_dir(); - if crate::is_custom_client() { - handle_custom_client_staging_dir_before_update(&custom_client_staging_dir)?; - } else { - // Clean up any residual staging directory from previous custom client - allow_err!(remove_custom_client_staging_dir(&custom_client_staging_dir)); - } - if !run_uac(file, "--update")? { - bail!( - "Failed to run the update exe with UAC, error: {:?}", - std::io::Error::last_os_error() - ); - } - } else if file.ends_with(".msi") { - if let Err(e) = update_me_msi(file, false) { - bail!("Failed to run the update msi: {}", e); - } - } else { - // unreachable!() - bail!("Unsupported update file format: {}", file); - } - Ok(()) -} - // Don't launch tray app when running with `\qn`. // 1. Because `/qn` requires administrator permission and the tray app should be launched with user permission. // Or launching the main window from the tray app will cause the main window to be launched with administrator permission. @@ -3917,26 +3884,8 @@ pub fn update_to(file: &str) -> ResultType<()> { // 3. Restore the tray app sessions. // `1` and `3` must be done in custom actions. // We need also to handle the command line parsing to find the tray processes. -pub fn update_me_msi(msi: &str, quiet: bool) -> ResultType<()> { - let quiet_args = if quiet { " /qn LAUNCH_TRAY_APP=N" } else { "" }; - let app_name = crate::get_app_name(); - let subkey = format!(".{}", app_name.to_lowercase()); - let printer_installed = get_reg_of_hkcr(&subkey, REG_NAME_INSTALL_PRINTER).as_deref() - != Some("0") - && remote_printer::is_rd_printer_installed(&app_name).unwrap_or_else(|err| { - log::warn!( - "Failed to check printer installation status: {err}; skipping printer installation" - ); - false - }); - let printer_args = if printer_installed { - "" - } else { - " INSTALLPRINTER=0" - }; - let cmds = format!("chcp 65001 && msiexec /i \"{msi}\"{quiet_args}{printer_args} REBOOT=ReallySuppress /norestart"); - run_cmds(cmds, false, "update-msi")?; - Ok(()) +pub fn update_me_msi(msi: &str, expected_sha256: &str, quiet: bool) -> ResultType<()> { + verified_update::install_verified_msi(msi, expected_sha256, quiet) } fn get_import_config(exe: &str) -> String { @@ -4005,9 +3954,10 @@ pub fn try_remove_temp_update_files() { if let Ok(entry) = entry { let path = entry.path(); if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) { - // Match files like rustdesk-*.msi or rustdesk-*.exe - if file_name.starts_with("rustdesk-") - && (file_name.ends_with(".msi") || file_name.ends_with(".exe")) + // Match downloaded installers, verified copies, and abandoned handoff scripts. + if (file_name.starts_with("rustdesk-") + && (file_name.ends_with(".msi") || file_name.ends_with(".exe"))) + || (file_name.starts_with("rustdesk_install_") && file_name.ends_with(".bat")) { // Skip files modified within the last hour to avoid deleting files being downloaded if let Ok(metadata) = std::fs::metadata(&path) { @@ -4202,14 +4152,6 @@ pub fn is_x64() -> bool { unsafe { sys_info.u.s().wProcessorArchitecture == PROCESSOR_ARCHITECTURE_AMD64 } } -pub fn release_arch_suffix() -> Option<&'static str> { - match std::env::consts::ARCH { - "x86_64" => Some("x86_64"), - "aarch64" => Some("aarch64"), - _ => None, - } -} - pub fn try_kill_rustdesk_main_window_process() -> ResultType<()> { // Kill rustdesk.exe without extra arg, should only be called by --server // We can find the exact process which occupies the ipc, see more from https://github.com/winsiderss/systeminformer diff --git a/src/platform/windows/verified_update.rs b/src/platform/windows/verified_update.rs new file mode 100644 index 000000000..1cfe95044 --- /dev/null +++ b/src/platform/windows/verified_update.rs @@ -0,0 +1,261 @@ +mod file; + +use super::{ + get_custom_client_staging_dir, get_reg_of_hkcr, + handle_custom_client_staging_dir_before_update, installer_handoff::run_cmds, + installer_shell::path_for_cmd_environment, remove_custom_client_staging_dir, ResultType, + REG_NAME_INSTALL_PRINTER, +}; +use hbb_common::{allow_err, anyhow::anyhow, bail, log}; +use std::path::Path; + +pub use file::{copy_and_verify_update_file_sha256, VerifiedUpdateFile}; + +const VERIFIED_UPDATE_FILE_PREFIX: &str = "rustdesk-verified-"; +const VERIFIED_EXE_FILENAME: &str = "update.exe"; +const VERIFIED_MSI_FILENAME: &str = "update.msi"; +const VERIFIED_UPDATE_COPY_FAILURE_EXIT_CODE: u32 = 0x5253_0009; +const VERIFIED_UPDATE_HASH_FAILURE_EXIT_CODE: u32 = 0x5253_000A; +const VERIFIED_UPDATE_HASH_MISMATCH_EXIT_CODE: u32 = 0x5253_000B; +const VERIFIED_EXE_LAUNCH_FAILURE_EXIT_CODE: u32 = 0x5253_000C; +const SHA256_HASH_LENGTH: usize = 32; + +pub fn update_to_verified(file: &str, expected_sha256: &str, expected_size: u64) -> ResultType<()> { + let extension = file::update_file_extension(file).unwrap_or_default(); + if extension != "exe" && extension != "msi" { + bail!("Unsupported update file format: {}", file); + } + + let update_file = copy_and_verify_update_file_sha256(file, expected_sha256)?; + if let Err(err) = update_file.verify_size(expected_size) { + update_file.cleanup(); + return Err(err); + } + let update_path = match update_file.path_str() { + Ok(path) => path.to_owned(), + Err(err) => { + update_file.cleanup(); + return Err(err); + } + }; + let custom_client_staging_dir = get_custom_client_staging_dir(); + if crate::is_custom_client() { + if let Err(err) = handle_custom_client_staging_dir_before_update(&custom_client_staging_dir) + { + update_file.cleanup(); + return Err(err); + } + } else { + // Clean up any residual staging directory from previous custom client. + allow_err!(remove_custom_client_staging_dir(&custom_client_staging_dir)); + } + + let result = launch_verified_update(&extension, &update_path, expected_sha256); + clear_custom_client_staging_after_launch_failure(&custom_client_staging_dir, &result); + update_file.cleanup(); + result +} + +fn launch_verified_update( + extension: &str, + update_path: &str, + expected_sha256: &str, +) -> ResultType<()> { + match extension { + "exe" => { + if let Err(err) = launch_verified_exe(update_path, expected_sha256) { + bail!("Failed to run the update exe: {}", err); + } + } + "msi" => { + if let Err(err) = install_verified_msi(update_path, expected_sha256, false) { + bail!("Failed to run the update msi: {}", err); + } + } + _ => bail!("Unsupported update file format: {}", update_path), + } + Ok(()) +} + +fn verified_update_hash_pattern(expected_sha256: &str) -> ResultType { + let hash = hex::decode(expected_sha256.trim()) + .map_err(|err| anyhow!("Invalid expected update SHA-256: {err}"))?; + if hash.len() != SHA256_HASH_LENGTH { + bail!("Invalid expected update SHA-256 length"); + } + // FINDSTR /R /I /X /C matches the complete certutil hash line; ` *` allows + // optional spaces between byte pairs. + Ok(hash + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::>() + .join(" *")) +} + +fn launch_verified_exe(exe: &str, expected_sha256: &str) -> ResultType<()> { + let source = path_for_cmd_environment(Path::new(exe))?; + let hash_pattern = verified_update_hash_pattern(expected_sha256)?; + // Keep the hash outside the EXE directory because update_me() copies that + // entire directory into the installation. + let commands = format!( + "set \"RUSTDESK_VERIFIED_EXE_SOURCE={source}\"\r\n\ + set \"RUSTDESK_VERIFIED_EXE=%RUSTDESK_OUTPUT_DIR%\\{VERIFIED_EXE_FILENAME}\"\r\n\ + copy /B /Y \"%RUSTDESK_VERIFIED_EXE_SOURCE%\" \"%RUSTDESK_VERIFIED_EXE%\" > nul || exit /b {VERIFIED_UPDATE_COPY_FAILURE_EXIT_CODE}\r\n\ + certutil.exe -hashfile \"%RUSTDESK_VERIFIED_EXE%\" SHA256 > \"%~f0.exe.sha256\" || exit /b {VERIFIED_UPDATE_HASH_FAILURE_EXIT_CODE}\r\n\ + findstr.exe /R /I /X /C:\"{hash_pattern}\" \"%~f0.exe.sha256\" > nul || exit /b {VERIFIED_UPDATE_HASH_MISMATCH_EXIT_CODE}\r\n\ + start \"\" /wait \"%RUSTDESK_VERIFIED_EXE%\" --update || exit /b {VERIFIED_EXE_LAUNCH_FAILURE_EXIT_CODE}" + ); + run_cmds(commands, false, "update-exe") +} + +fn verified_msi_install_commands( + msi: &Path, + expected_sha256: &str, + quiet: bool, +) -> ResultType { + use windows::Win32::Foundation::{ + ERROR_SUCCESS, ERROR_SUCCESS_REBOOT_INITIATED, ERROR_SUCCESS_REBOOT_REQUIRED, + }; + + // The open source handle prevents replacing that file, but not retargeting an + // ancestor junction. Copy and reverify it in the protected runner directory. + let source = path_for_cmd_environment(msi)?; + let hash_pattern = verified_update_hash_pattern(expected_sha256)?; + let quiet_args = if quiet { " /qn LAUNCH_TRAY_APP=N" } else { "" }; + let app_name = crate::get_app_name(); + let subkey = format!(".{}", app_name.to_lowercase()); + let printer_installed = get_reg_of_hkcr(&subkey, REG_NAME_INSTALL_PRINTER).as_deref() + != Some("0") + && remote_printer::is_rd_printer_installed(&app_name).unwrap_or_else(|err| { + log::warn!( + "Failed to check printer installation status: {err}; skipping printer installation" + ); + false + }); + let printer_args = if printer_installed { + "" + } else { + " INSTALLPRINTER=0" + }; + Ok(format!( + "set \"RUSTDESK_VERIFIED_MSI_SOURCE={source}\"\r\n\ + set \"RUSTDESK_VERIFIED_MSI=%RUSTDESK_OUTPUT_DIR%\\{VERIFIED_MSI_FILENAME}\"\r\n\ + copy /B /Y \"%RUSTDESK_VERIFIED_MSI_SOURCE%\" \"%RUSTDESK_VERIFIED_MSI%\" > nul || exit /b {VERIFIED_UPDATE_COPY_FAILURE_EXIT_CODE}\r\n\ + certutil.exe -hashfile \"%RUSTDESK_VERIFIED_MSI%\" SHA256 > \"%RUSTDESK_VERIFIED_MSI%.sha256\" || exit /b {VERIFIED_UPDATE_HASH_FAILURE_EXIT_CODE}\r\n\ + findstr.exe /R /I /X /C:\"{hash_pattern}\" \"%RUSTDESK_VERIFIED_MSI%.sha256\" > nul || exit /b {VERIFIED_UPDATE_HASH_MISMATCH_EXIT_CODE}\r\n\ + msiexec.exe /i \"%RUSTDESK_VERIFIED_MSI%\"{quiet_args}{printer_args} REBOOT=ReallySuppress /norestart\r\n\ + set \"RUSTDESK_MSI_EXIT_CODE=%ERRORLEVEL%\"\r\n\ + if \"%RUSTDESK_MSI_EXIT_CODE%\"==\"{reboot_initiated}\" exit /b {success}\r\n\ + if \"%RUSTDESK_MSI_EXIT_CODE%\"==\"{reboot_required}\" exit /b {success}\r\n\ + exit /b %RUSTDESK_MSI_EXIT_CODE%", + success = ERROR_SUCCESS.0, + reboot_initiated = ERROR_SUCCESS_REBOOT_INITIATED.0, + reboot_required = ERROR_SUCCESS_REBOOT_REQUIRED.0, + )) +} + +pub(super) fn install_verified_msi( + msi: &str, + expected_sha256: &str, + quiet: bool, +) -> ResultType<()> { + run_cmds( + verified_msi_install_commands(Path::new(msi), expected_sha256, quiet)?, + false, + "update-msi", + ) +} + +fn clear_custom_client_staging_after_launch_failure( + staging_dir: &Path, + launch_result: &ResultType<()>, +) { + if launch_result.is_ok() { + return; + } + if let Err(err) = remove_custom_client_staging_dir(staging_dir) { + log::warn!( + "Failed to clear custom client staging directory {:?}: {}", + staging_dir, + err + ); + } +} + +pub fn schedule_current_verified_update_file_cleanup() -> ResultType<()> { + let current_exe = std::env::current_exe()?; + if should_schedule_verified_update_cleanup(¤t_exe) { + schedule_file_removal_on_reboot(¤t_exe)?; + } + Ok(()) +} + +fn should_schedule_verified_update_cleanup(path: &Path) -> bool { + // UAC may run under another account whose TEMP directory differs from the caller's. + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with(VERIFIED_UPDATE_FILE_PREFIX) && name.ends_with(".exe")) +} + +fn schedule_file_removal_on_reboot(path: &Path) -> ResultType<()> { + use windows::{ + core::{HSTRING, PCWSTR}, + Win32::Storage::FileSystem::{MoveFileExW, MOVEFILE_DELAY_UNTIL_REBOOT}, + }; + + unsafe { + MoveFileExW( + &HSTRING::from(path), + PCWSTR::null(), + MOVEFILE_DELAY_UNTIL_REBOOT, + ) + } + .map_err(|err| { + anyhow!( + "Failed to schedule verified update file cleanup for {}: {}", + path.display(), + err + ) + })?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn schedules_cleanup_for_verified_exe_only() { + assert!(should_schedule_verified_update_cleanup(Path::new( + r"C:\Temp\rustdesk-verified-123-456.exe" + ))); + for path in [ + r"C:\Temp\rustdesk-verified-123-456.msi", + r"C:\Temp\rustdesk.exe", + r"C:\Temp\rustdesk-verified-123-456.exe.bak", + ] { + assert!(!should_schedule_verified_update_cleanup(Path::new(path))); + } + } + + #[test] + fn failed_verified_update_launch_clears_staged_custom_client_file() { + let staging_dir = std::env::temp_dir().join(format!( + "rustdesk-update-staging-cleanup-test-{}-{}", + std::process::id(), + hbb_common::rand::random::() + )); + std::fs::create_dir_all(&staging_dir).unwrap(); + let staged_file = staging_dir.join("custom.txt"); + std::fs::write(&staged_file, b"custom client").unwrap(); + let successful_launch: ResultType<()> = Ok(()); + clear_custom_client_staging_after_launch_failure(&staging_dir, &successful_launch); + assert!(staged_file.exists()); + let launch_result: ResultType<()> = Err(hbb_common::anyhow::anyhow!("launch failed")); + + clear_custom_client_staging_after_launch_failure(&staging_dir, &launch_result); + + assert!(!staged_file.exists()); + std::fs::remove_dir_all(staging_dir).unwrap(); + } +} diff --git a/src/platform/windows/verified_update/file.rs b/src/platform/windows/verified_update/file.rs new file mode 100644 index 000000000..527afb263 --- /dev/null +++ b/src/platform/windows/verified_update/file.rs @@ -0,0 +1,314 @@ +use super::{ResultType, VERIFIED_UPDATE_FILE_PREFIX}; +use hbb_common::{anyhow::anyhow, bail, log}; +use std::{ + io::Write, + os::windows::fs::{MetadataExt, OpenOptionsExt}, + path::{Path, PathBuf}, +}; +use winapi::um::{ + winbase::FILE_FLAG_OPEN_REPARSE_POINT, + winnt::{FILE_ATTRIBUTE_REPARSE_POINT, FILE_SHARE_READ}, +}; + +const UPDATE_FILE_COPY_ATTEMPTS: usize = 16; +const UNSAFE_VERIFIED_MSI_PATH_CHARS: &[char] = &['"', '|', '<', '>', '%']; + +pub struct VerifiedUpdateFile { + _file: std::fs::File, + path: PathBuf, +} + +impl VerifiedUpdateFile { + pub fn path_str(&self) -> ResultType<&str> { + let Some(path) = self.path.to_str() else { + bail!("Invalid update file path: {}", self.path.display()); + }; + Ok(path) + } + + pub fn cleanup(self) { + let Self { _file, path } = self; + drop(_file); + remove_verified_update_file(&path); + } + + pub(super) fn verify_size(&self, expected_size: u64) -> ResultType<()> { + let actual_size = self._file.metadata()?.len(); + if actual_size != expected_size { + bail!( + "Update artifact size mismatch for {}: expected {}, got {}", + self.path.display(), + expected_size, + actual_size + ); + } + Ok(()) + } +} + +pub(super) fn update_file_extension(file: &str) -> Option { + Path::new(file) + .extension() + .and_then(|extension| extension.to_str()) + .map(|extension| extension.to_ascii_lowercase()) +} + +fn verified_update_file_path(file: &str) -> ResultType { + let extension = update_file_extension(file).unwrap_or_default(); + if extension != "exe" && extension != "msi" { + bail!("Unsupported update file format: {}", file); + } + let path = std::env::temp_dir().join(format!( + "{}{}-{}.{}", + VERIFIED_UPDATE_FILE_PREFIX, + std::process::id(), + hbb_common::rand::random::(), + extension + )); + if extension == "msi" { + validate_verified_msi_path(&path)?; + } + Ok(path) +} + +fn validate_verified_msi_path(path: &Path) -> ResultType<()> { + let Some(path_str) = path.to_str() else { + bail!("Invalid verified update file path: {}", path.display()); + }; + if path_str.contains(UNSAFE_VERIFIED_MSI_PATH_CHARS) { + bail!( + "Refusing to use unsafe verified update file path: {}", + path_str + ); + } + Ok(()) +} + +// Reject symlinks, junctions, and other reparse points so verification and +// execution operate on a regular installer file, not a redirected target. +fn is_update_file_attributes_trusted(attributes: u32) -> bool { + attributes & FILE_ATTRIBUTE_REPARSE_POINT == 0 +} + +// Open reparse points themselves instead of following them, so the +// attribute check can reject redirected update paths. +fn update_file_open_flags() -> u32 { + FILE_FLAG_OPEN_REPARSE_POINT +} + +fn open_update_file_for_verification(file: &str) -> ResultType { + let update_file = std::fs::OpenOptions::new() + .read(true) + .share_mode(FILE_SHARE_READ) + .custom_flags(update_file_open_flags()) + .open(file) + .map_err(|err| anyhow!("Failed to lock update file {}: {}", file, err))?; + let metadata = update_file + .metadata() + .map_err(|err| anyhow!("Failed to read update file metadata {}: {}", file, err))?; + if !metadata.is_file() || !is_update_file_attributes_trusted(metadata.file_attributes()) { + bail!("Refusing to verify untrusted update file: {}", file); + } + Ok(update_file) +} + +fn copy_update_file_for_verification(file: &str) -> ResultType { + let mut source_file = open_update_file_for_verification(file)?; + for _ in 0..UPDATE_FILE_COPY_ATTEMPTS { + let path = verified_update_file_path(file)?; + let mut copy_file = match std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .share_mode(FILE_SHARE_READ) + .custom_flags(update_file_open_flags()) + .open(&path) + { + Ok(file) => file, + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(err) => { + return Err(anyhow!( + "Failed to create verified update file {}: {}", + path.display(), + err + ) + .into()) + } + }; + if let Err(err) = std::io::copy(&mut source_file, &mut copy_file) { + drop(copy_file); + remove_verified_update_file(&path); + return Err(err.into()); + } + if let Err(err) = copy_file.flush() { + drop(copy_file); + remove_verified_update_file(&path); + return Err(err.into()); + } + drop(copy_file); + return Ok(path); + } + bail!("Failed to create verified update file for {}", file); +} + +// Keep the verified copy open without write/delete sharing until the installer starts. +pub fn copy_and_verify_update_file_sha256( + file: &str, + expected_sha256: &str, +) -> ResultType { + let path = copy_update_file_for_verification(file)?; + let Some(update_path) = path.to_str().map(str::to_owned) else { + remove_verified_update_file(&path); + bail!("Invalid update file path: {}", path.display()); + }; + + let mut read_file = match open_update_file_for_verification(&update_path) { + Ok(file) => file, + Err(err) => { + remove_verified_update_file(&path); + return Err(err); + } + }; + if let Err(err) = verify_update_file_sha256(&mut read_file, expected_sha256, &update_path) { + drop(read_file); + remove_verified_update_file(&path); + return Err(err); + } + Ok(VerifiedUpdateFile { + _file: read_file, + path, + }) +} + +fn verify_update_file_sha256( + update_file: &mut std::fs::File, + expected_sha256: &str, + file: &str, +) -> ResultType<()> { + use crate::update_hash::{verify_sha256_reader, Sha256VerificationError}; + + match verify_sha256_reader(update_file, expected_sha256) { + Ok(()) => Ok(()), + Err(Sha256VerificationError::InvalidExpected) => { + bail!("Expected update file SHA256 is malformed for {}", file) + } + Err(Sha256VerificationError::Mismatch { + expected_sha256, + actual_sha256, + }) => bail!( + "SHA256 mismatch for {}: expected {}, got {}", + file, + expected_sha256, + actual_sha256 + ), + Err(Sha256VerificationError::Io(err)) => Err(err.into()), + } +} + +fn remove_verified_update_file(path: &Path) { + match std::fs::remove_file(path) { + Ok(()) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => log::warn!( + "Failed to remove verified update file {}: {}", + path.display(), + err + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mismatched_sha256_removes_verified_copy() { + let source_path = std::env::temp_dir().join(format!( + "rustdesk-update-source-{}-{}.exe", + std::process::id(), + hbb_common::rand::random::() + )); + std::fs::write(&source_path, b"update").unwrap(); + let unrelated_path = std::env::temp_dir().join(format!( + "{}{}-{}.exe", + VERIFIED_UPDATE_FILE_PREFIX, + std::process::id(), + hbb_common::rand::random::() + )); + std::fs::write(&unrelated_path, b"unrelated update").unwrap(); + + let result = + copy_and_verify_update_file_sha256(source_path.to_str().unwrap(), &"0".repeat(64)); + let error = match result { + Ok(update_file) => { + update_file.cleanup(); + panic!("mismatched SHA256 should fail"); + } + Err(error) => error.to_string(), + }; + let verified_path = error + .strip_prefix("SHA256 mismatch for ") + .and_then(|message| message.split_once(": expected ")) + .map(|(path, _)| PathBuf::from(path)) + .expect("mismatch error did not contain verified path"); + + assert!(!verified_path.exists()); + assert!(unrelated_path.exists()); + std::fs::remove_file(source_path).unwrap(); + std::fs::remove_file(unrelated_path).unwrap(); + } + + #[test] + fn verified_update_file_size_must_match_expected_size() { + let source_path = std::env::temp_dir().join(format!( + "rustdesk-update-size-test-{}-{}.exe", + std::process::id(), + hbb_common::rand::random::() + )); + std::fs::write(&source_path, b"rustdesk").unwrap(); + let verified_file = copy_and_verify_update_file_sha256( + source_path.to_str().unwrap(), + "304ca1638c5effa6832e0e15b958a8f74847efe4df9c3f3187216e921c168fed", + ) + .unwrap(); + + assert!(verified_file.verify_size(8).is_ok()); + assert!(verified_file.verify_size(7).is_err()); + + verified_file.cleanup(); + std::fs::remove_file(source_path).unwrap(); + } + + #[test] + fn rejects_reparse_point_update_file_attributes() { + use winapi::um::winnt::FILE_ATTRIBUTE_NORMAL; + + assert!(is_update_file_attributes_trusted(FILE_ATTRIBUTE_NORMAL)); + assert!(!is_update_file_attributes_trusted( + FILE_ATTRIBUTE_REPARSE_POINT + )); + assert!(!is_update_file_attributes_trusted( + FILE_ATTRIBUTE_NORMAL | FILE_ATTRIBUTE_REPARSE_POINT + )); + } + + #[test] + fn verified_msi_path_rejects_unsafe_cmd_characters() { + for character in ['"', '|', '<', '>', '%'] { + let path = PathBuf::from(format!(r"C:\Temp\unsafe{character}\update.msi")); + assert!( + validate_verified_msi_path(&path).is_err(), + "accepted unsafe character {character:?}" + ); + } + + for character in ['&', '^'] { + let path = PathBuf::from(format!(r"C:\Temp\safe{character}path\update.msi")); + assert!( + validate_verified_msi_path(&path).is_ok(), + "rejected quoted-safe character {character:?}" + ); + } + + assert!(validate_verified_msi_path(Path::new(r"C:\Temp\safe path\update.msi")).is_ok()); + } +} diff --git a/src/service.rs b/src/service.rs index 65be58302..fc22ab772 100644 --- a/src/service.rs +++ b/src/service.rs @@ -6,6 +6,7 @@ fn main() {} #[cfg(target_os = "macos")] fn main() { let args: Vec = std::env::args().collect(); + crate::common::load_custom_client(); if args.len() > 1 && args[1] == "--write-plists" { if let Err(e) = librustdesk::platform::write_plists() { eprintln!("Failed to write plists: {}", e); @@ -13,7 +14,6 @@ fn main() { } std::process::exit(0); } - crate::common::load_custom_client(); hbb_common::init_log(false, "service"); crate::start_os_service(); } diff --git a/src/ui_interface.rs b/src/ui_interface.rs index b1eeefde2..734cb5d79 100644 --- a/src/ui_interface.rs +++ b/src/ui_interface.rs @@ -748,13 +748,14 @@ pub fn current_is_wayland() -> bool { #[inline] pub fn get_new_version() -> String { - (*SOFTWARE_UPDATE_URL + SOFTWARE_UPDATE_URL .lock() .unwrap() + .trim_end_matches('/') .rsplit('/') .next() - .unwrap_or("")) - .to_string() + .unwrap_or("") + .to_owned() } #[inline] diff --git a/src/update_hash.rs b/src/update_hash.rs new file mode 100644 index 000000000..5a672e6c8 --- /dev/null +++ b/src/update_hash.rs @@ -0,0 +1,98 @@ +use sha2::{Digest, Sha256}; +use std::io::{self, Read, Seek, SeekFrom}; + +const SHA256_HEX_LENGTH: usize = 64; +const UPDATE_HASH_BUFFER_SIZE: usize = 8192; + +#[derive(Debug)] +pub(crate) enum Sha256VerificationError { + InvalidExpected, + Mismatch { + expected_sha256: String, + actual_sha256: String, + }, + Io(io::Error), +} + +impl From for Sha256VerificationError { + fn from(error: io::Error) -> Self { + Self::Io(error) + } +} + +pub(crate) fn sha256_reader_hex(reader: &mut R) -> io::Result { + let mut hasher = Sha256::new(); + let mut buffer = [0_u8; UPDATE_HASH_BUFFER_SIZE]; + loop { + let count = match reader.read(&mut buffer) { + Ok(count) => count, + Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, + Err(error) => return Err(error), + }; + if count == 0 { + break; + } + hasher.update(&buffer[..count]); + } + Ok(format!("{:x}", hasher.finalize())) +} + +pub(crate) fn verify_sha256_reader( + reader: &mut R, + expected_sha256: &str, +) -> Result<(), Sha256VerificationError> { + let expected_sha256 = expected_sha256.trim().to_ascii_lowercase(); + if expected_sha256.len() != SHA256_HEX_LENGTH + || !expected_sha256.chars().all(|c| c.is_ascii_hexdigit()) + { + return Err(Sha256VerificationError::InvalidExpected); + } + reader.seek(SeekFrom::Start(0))?; + let actual_sha256 = match sha256_reader_hex(reader) { + Ok(actual_sha256) => actual_sha256, + Err(error) => { + reader.seek(SeekFrom::Start(0))?; + return Err(error.into()); + } + }; + reader.seek(SeekFrom::Start(0))?; + if actual_sha256 != expected_sha256 { + return Err(Sha256VerificationError::Mismatch { + expected_sha256, + actual_sha256, + }); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + const RUSTDESK_SHA256: &str = + "304ca1638c5effa6832e0e15b958a8f74847efe4df9c3f3187216e921c168fed"; + + #[test] + fn verifies_and_rejects_sha256() { + let mut reader = Cursor::new(b"rustdesk".to_vec()); + reader.set_position(reader.get_ref().len() as u64); + + verify_sha256_reader( + &mut reader, + &format!(" {} ", RUSTDESK_SHA256.to_uppercase()), + ) + .unwrap(); + + assert_eq!(reader.position(), 0); + assert!(matches!( + verify_sha256_reader(&mut reader, "invalid"), + Err(Sha256VerificationError::InvalidExpected) + )); + assert!(matches!( + verify_sha256_reader(&mut reader, &"0".repeat(64)), + Err(Sha256VerificationError::Mismatch { .. }) + )); + assert_eq!(reader.position(), 0); + } +} diff --git a/src/update_metadata.rs b/src/update_metadata.rs new file mode 100644 index 000000000..9d2c9ad2d --- /dev/null +++ b/src/update_metadata.rs @@ -0,0 +1,271 @@ +use hbb_common::{ + anyhow::{anyhow, Context}, + base64::{engine::general_purpose::STANDARD, Engine as _}, + sodiumoxide::{ + self, + crypto::sign::{verify_detached, PublicKey, Signature}, + }, + ResultType, +}; +use serde_derive::Deserialize; +use std::sync::OnceLock; + +mod artifact; + +#[cfg(target_os = "macos")] +mod offline; + +use artifact::{validate_artifact, validate_artifact_file_name, ArtifactUrlPolicy}; + +#[cfg(target_os = "macos")] +pub(crate) use offline::{verify_offline_update_metadata, OfflineUpdateMetadataRequirements}; + +const UPDATE_METADATA_SIGNATURE_CONTEXT: &[u8] = b"RustDesk update metadata v1\n"; +const UPDATE_APP: &str = "rustdesk"; +pub(crate) const PUBLIC_UPDATE_PACKAGE_ID: &str = "rustdesk"; +const METADATA_SCHEMA_VERSION: u32 = 1; +const SIGNATURE_SCHEMA_VERSION: u32 = 1; +const SIGNATURE_ALGORITHM: &str = "ed25519"; + +const TRUSTED_UPDATE_KEYS: &[TrustedUpdateKey] = &[TrustedUpdateKey { + key_id: "2026-ed25519-main", + algorithm: SIGNATURE_ALGORITHM, + public_key: [ + 118, 252, 116, 215, 49, 217, 220, 109, 145, 119, 210, 101, 225, 198, 63, 2, 41, 127, 197, + 127, 52, 251, 6, 204, 125, 117, 96, 204, 187, 166, 85, 59, + ], +}]; + +#[derive(Deserialize)] +struct UpdateMetadata { + schema_version: u32, + app: String, + package_id: String, + version: String, + release_id: String, + #[serde(rename = "published_at")] + _published_at: String, + signature_key_id: String, + artifacts: Vec, +} + +#[derive(Deserialize)] +struct UpdateArtifact { + platform: String, + arch: String, + format: String, + url: String, + file_name: String, + size: u64, + sha256: String, +} + +#[derive(Deserialize)] +struct UpdateSignature { + schema_version: u32, + algorithm: String, + key_id: String, + signature: String, +} + +struct TrustedUpdateKey { + key_id: &'static str, + algorithm: &'static str, + public_key: [u8; 32], +} + +#[derive(Clone, Copy)] +pub(crate) struct UpdateArtifactQuery<'a> { + pub platform: &'a str, + pub arch: &'a str, + pub format: &'a str, +} + +#[derive(Clone, Copy)] +pub(crate) struct UpdateMetadataRequirements<'a> { + pub expected_version: &'a str, + pub expected_release_id: &'a str, + pub expected_artifact_url_prefix: &'a str, + pub artifact: UpdateArtifactQuery<'a>, +} + +#[derive(Clone)] +pub(crate) struct VerifiedUpdateArtifact { + pub version: String, + pub url: String, + #[cfg(target_os = "macos")] + pub file_name: String, + pub size: u64, + pub sha256: String, +} + +struct VerificationOptions<'a> { + requirements: MetadataRequirements<'a>, + expected_package_id: &'a str, + artifact_url_policy: ArtifactUrlPolicy<'a>, + trusted_keys: &'a [TrustedUpdateKey], +} + +#[derive(Clone, Copy)] +struct MetadataRequirements<'a> { + expected_version: &'a str, + expected_release_id: &'a str, + artifact: UpdateArtifactQuery<'a>, +} + +fn verify_update_metadata_with_options( + metadata_bytes: &[u8], + signature_bytes: &[u8], + options: VerificationOptions<'_>, +) -> ResultType { + let update_signature: UpdateSignature = + serde_json::from_slice(signature_bytes).context("invalid update signature JSON")?; + validate_signature_schema(&update_signature)?; + let signature = decode_signature(&update_signature.signature)?; + let trusted_key = options + .trusted_keys + .iter() + .find(|key| { + key.key_id == update_signature.key_id && key.algorithm == update_signature.algorithm + }) + .ok_or_else(|| anyhow!("unsupported update signature key"))?; + verify_metadata_signature(metadata_bytes, &signature, trusted_key)?; + let metadata: UpdateMetadata = + serde_json::from_slice(metadata_bytes).context("invalid update metadata JSON")?; + validate_metadata(&metadata, &update_signature, &options)?; + let artifact = select_artifact(&metadata, &options.requirements.artifact)?; + validate_artifact(artifact, options.artifact_url_policy)?; + Ok(VerifiedUpdateArtifact { + version: metadata.version.clone(), + url: artifact.url.clone(), + #[cfg(target_os = "macos")] + file_name: artifact.file_name.clone(), + size: artifact.size, + sha256: artifact.sha256.clone(), + }) +} + +pub(crate) fn verify_update_metadata( + metadata_bytes: &[u8], + signature_bytes: &[u8], + requirements: UpdateMetadataRequirements<'_>, +) -> ResultType { + let artifact_url_policy = + ArtifactUrlPolicy::ExactPrefix(requirements.expected_artifact_url_prefix); + let metadata_requirements = MetadataRequirements { + expected_version: requirements.expected_version, + expected_release_id: requirements.expected_release_id, + artifact: requirements.artifact, + }; + verify_update_metadata_with_options( + metadata_bytes, + signature_bytes, + VerificationOptions { + requirements: metadata_requirements, + expected_package_id: PUBLIC_UPDATE_PACKAGE_ID, + artifact_url_policy, + trusted_keys: TRUSTED_UPDATE_KEYS, + }, + ) +} + +fn validate_signature_schema(signature: &UpdateSignature) -> ResultType<()> { + if signature.schema_version != SIGNATURE_SCHEMA_VERSION { + return Err(anyhow!("unsupported update signature schema version")); + } + if signature.algorithm != SIGNATURE_ALGORITHM { + return Err(anyhow!("unsupported update signature algorithm")); + } + Ok(()) +} + +fn decode_signature(encoded: &str) -> ResultType { + let decoded = STANDARD + .decode(encoded) + .context("invalid update signature base64")?; + if decoded.len() != 64 || STANDARD.encode(&decoded) != encoded { + return Err(anyhow!("invalid update signature length or encoding")); + } + let mut signature = [0u8; 64]; + signature.copy_from_slice(&decoded); + Signature::from_bytes(&signature).map_err(|_| anyhow!("invalid update signature bytes")) +} + +fn verify_metadata_signature( + metadata_bytes: &[u8], + signature: &Signature, + trusted_key: &TrustedUpdateKey, +) -> ResultType<()> { + init_sodiumoxide()?; + let mut signed_bytes = + Vec::with_capacity(UPDATE_METADATA_SIGNATURE_CONTEXT.len() + metadata_bytes.len()); + signed_bytes.extend_from_slice(UPDATE_METADATA_SIGNATURE_CONTEXT); + signed_bytes.extend_from_slice(metadata_bytes); + if !verify_detached(signature, &signed_bytes, &PublicKey(trusted_key.public_key)) { + return Err(anyhow!("invalid update metadata signature")); + } + Ok(()) +} + +fn init_sodiumoxide() -> ResultType<()> { + static INIT: OnceLock> = OnceLock::new(); + let result = INIT.get_or_init(|| { + sodiumoxide::init().map_err(|_| "failed to initialize sodiumoxide".to_string()) + }); + match result { + Ok(()) => Ok(()), + Err(err) => Err(anyhow!(err.clone())), + } +} + +fn validate_metadata( + metadata: &UpdateMetadata, + signature: &UpdateSignature, + options: &VerificationOptions<'_>, +) -> ResultType<()> { + if metadata.schema_version != METADATA_SCHEMA_VERSION { + return Err(anyhow!("unsupported update metadata schema version")); + } + if metadata.signature_key_id != signature.key_id { + return Err(anyhow!("update metadata signature key id mismatch")); + } + if metadata.app != UPDATE_APP { + return Err(anyhow!("update metadata app mismatch")); + } + if metadata.package_id != options.expected_package_id { + return Err(anyhow!("update metadata package id is not allowed")); + } + if options.requirements.expected_version != metadata.version { + return Err(anyhow!("update metadata version mismatch")); + } + if options.requirements.expected_release_id != metadata.release_id { + return Err(anyhow!("update metadata release id mismatch")); + } + for artifact in &metadata.artifacts { + validate_artifact_file_name(&artifact.file_name)?; + } + Ok(()) +} + +fn select_artifact<'a>( + metadata: &'a UpdateMetadata, + query: &UpdateArtifactQuery<'_>, +) -> ResultType<&'a UpdateArtifact> { + let mut matches = metadata.artifacts.iter().filter(|artifact| { + artifact.platform == query.platform + && artifact.arch == query.arch + && artifact.format == query.format + }); + let artifact = matches + .next() + .ok_or_else(|| anyhow!("matching update artifact not found"))?; + if matches.next().is_some() { + return Err(anyhow!("multiple matching update artifacts found")); + } + Ok(artifact) +} + +#[cfg(target_os = "macos")] +#[cfg(test)] +#[path = "update_metadata_tests.rs"] +mod tests; diff --git a/src/update_metadata/artifact.rs b/src/update_metadata/artifact.rs new file mode 100644 index 000000000..a90282078 --- /dev/null +++ b/src/update_metadata/artifact.rs @@ -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) +} diff --git a/src/update_metadata/offline.rs b/src/update_metadata/offline.rs new file mode 100644 index 000000000..3177f082a --- /dev/null +++ b/src/update_metadata/offline.rs @@ -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 { + 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 { + verify_offline_update_metadata_with_options( + metadata_bytes, + signature_bytes, + OfflineVerificationOptions { + requirements, + trusted_keys: super::TRUSTED_UPDATE_KEYS, + }, + ) +} diff --git a/src/update_metadata/offline_tests.rs b/src/update_metadata/offline_tests.rs new file mode 100644 index 000000000..a5b848152 --- /dev/null +++ b/src/update_metadata/offline_tests.rs @@ -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 { + 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 { + 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()); +} diff --git a/src/update_metadata_tests.rs b/src/update_metadata_tests.rs new file mode 100644 index 000000000..911388f8d --- /dev/null +++ b/src/update_metadata_tests.rs @@ -0,0 +1,283 @@ +use super::*; +use hbb_common::{ + base64::engine::general_purpose::STANDARD, + sodiumoxide::{self, crypto::sign}, + ResultType, +}; +use serde_json::{json, Value}; + +const KEY_ID: &str = "test-ed25519-main"; +const SHA256: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +struct Fixture { + metadata: Vec, + signature: Vec, + public_key: [u8; 32], +} + +fn artifact() -> Value { + json!({"platform":"windows","arch":"x86_64","format":"exe","url":"https://github.com/rustdesk/rustdesk/releases/download/v1.4.6/rustdesk-1.4.6-x86_64.exe","file_name":"rustdesk-1.4.6-x86_64.exe","size":123456,"sha256":SHA256}) +} + +fn metadata() -> Value { + json!({"schema_version":1,"app":"rustdesk","package_id":"rustdesk","version":"1.4.6","release_id":"v1.4.6","published_at":"2026-05-14T00:00:00Z","signature_key_id":KEY_ID,"artifacts":[artifact()]}) +} + +fn set_value(metadata: &mut Value, key: &str, value: Value, artifact_field: bool) { + let target = if artifact_field { + &mut metadata["artifacts"][0] + } else { + metadata + }; + target + .as_object_mut() + .expect("JSON object") + .insert(key.to_string(), value); +} + +fn sign_fixture(metadata: Value, signature_key_id: &str) -> Fixture { + sodiumoxide::init().expect("test sodiumoxide init"); + let metadata = serde_json::to_vec(&metadata).expect("metadata JSON"); + let (public_key, secret_key) = sign::gen_keypair(); + let mut signed = UPDATE_METADATA_SIGNATURE_CONTEXT.to_vec(); + signed.extend_from_slice(&metadata); + let sig = sign::sign_detached(&signed, &secret_key); + let sig_json = json!({"schema_version":1,"algorithm":"ed25519","key_id":signature_key_id,"signature":STANDARD.encode(sig.to_bytes())}); + Fixture { + metadata, + signature: serde_json::to_vec(&sig_json).expect("signature JSON"), + public_key: public_key.0, + } +} + +fn valid_fixture() -> Fixture { + sign_fixture(metadata(), KEY_ID) +} +fn key(public_key: [u8; 32]) -> TrustedUpdateKey { + TrustedUpdateKey { + key_id: KEY_ID, + algorithm: "ed25519", + public_key, + } +} +fn query<'a>() -> UpdateArtifactQuery<'a> { + UpdateArtifactQuery { + platform: "windows", + arch: "x86_64", + format: "exe", + } +} +fn requirements<'a>(artifact: UpdateArtifactQuery<'a>) -> MetadataRequirements<'a> { + MetadataRequirements { + expected_version: "1.4.6", + expected_release_id: "v1.4.6", + artifact, + } +} +fn verify_with_query( + fixture: &Fixture, + query: &UpdateArtifactQuery<'_>, +) -> ResultType { + let trusted_keys = [key(fixture.public_key)]; + verify_update_metadata_with_options( + &fixture.metadata, + &fixture.signature, + VerificationOptions { + requirements: requirements(*query), + expected_package_id: PUBLIC_UPDATE_PACKAGE_ID, + artifact_url_policy: ArtifactUrlPolicy::ExactPrefix( + "https://github.com/rustdesk/rustdesk/releases/download/v1.4.6/", + ), + trusted_keys: &trusted_keys, + }, + ) +} + +fn verify(fixture: &Fixture) -> ResultType { + verify_with_query(fixture, &query()) +} + +fn verify_for_package( + fixture: &Fixture, + expected_package_id: &str, +) -> ResultType { + let trusted_keys = [key(fixture.public_key)]; + verify_update_metadata_with_options( + &fixture.metadata, + &fixture.signature, + VerificationOptions { + requirements: requirements(query()), + expected_package_id, + artifact_url_policy: ArtifactUrlPolicy::ExactPrefix( + "https://github.com/rustdesk/rustdesk/releases/download/v1.4.6/", + ), + trusted_keys: &trusted_keys, + }, + ) +} + +#[test] +fn accepts_valid_metadata_and_signature() { + let artifact = verify(&valid_fixture()).expect("verified 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 accepts_only_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_for_package(&fixture, CUSTOM_PACKAGE_ID).is_ok()); + assert!(verify_for_package(&fixture, "rustdesk").is_err()); +} + +#[test] +fn rejects_bad_signature_or_tampered_metadata() { + let mut fixture = valid_fixture(); + fixture.metadata.push(b' '); + assert!(verify(&fixture).is_err()); + for (key, value) in [ + ("schema_version", json!(2)), + ("algorithm", json!("rsa")), + ("signature", json!("not base64")), + ("signature", json!(STANDARD.encode([1u8; 63]))), + ] { + let mut fixture = valid_fixture(); + let mut sig: Value = serde_json::from_slice(&fixture.signature).expect("signature"); + sig.as_object_mut() + .expect("signature object") + .insert(key.to_string(), value); + fixture.signature = serde_json::to_vec(&sig).expect("signature bytes"); + assert!(verify(&fixture).is_err(), "{}", key); + } +} + +#[test] +fn rejects_schema_policy_artifact_and_url_mismatches() { + let cases = [ + ("schema_version", json!(2), false), ("app", json!("other"), false), + ("version", json!("1.4.7"), false), ("release_id", json!("v1.4.7"), false), + ("package_id", json!("custom"), false), ("signature_key_id", json!("different-key"), false), + ("arch", json!("x86"), true), + ("sha256", json!("not-a-sha256"), true), + ("url", json!("https://github.com/rustdesk/rustdesk/releases/download/v1.4.7/rustdesk-1.4.6-x86_64.exe"), true), + ("url", json!("https://github.com/rustdesk/rustdesk/releases/download/v1.4.6/other.exe"), true), + ]; + for (key, value, artifact_field) in cases { + let mut metadata = metadata(); + set_value(&mut metadata, key, value, artifact_field); + assert!(verify(&sign_fixture(metadata, KEY_ID)).is_err(), "{}", key); + } +} + +#[test] +fn rejects_artifact_url_query_or_fragment() { + for suffix in ["?download=1", "#hash"] { + let mut invalid_metadata = metadata(); + invalid_metadata["artifacts"][0]["url"] = json!(format!( + "https://github.com/rustdesk/rustdesk/releases/download/v1.4.6/rustdesk-1.4.6-x86_64.exe{suffix}" + )); + let fixture = sign_fixture(invalid_metadata, KEY_ID); + let error = verify(&fixture).err().expect("invalid artifact URL"); + + assert_eq!( + "update artifact URL must not contain query or fragment", + error.to_string() + ); + } +} + +#[test] +fn rejects_duplicate_artifact_matches() { + let mut duplicate_metadata = metadata(); + duplicate_metadata["artifacts"] + .as_array_mut() + .expect("artifacts") + .push(artifact()); + assert!(verify(&sign_fixture(duplicate_metadata, KEY_ID)).is_err()); +} + +#[test] +fn rejects_artifact_file_name_path_components() { + for file_name in [ + "", + "nested/rustdesk.exe", + r"nested\rustdesk.exe", + "..", + "rust desk.exe", + "rustdesk*.exe", + "rustdesk\n.exe", + "rüstdesk.exe", + ] { + let mut invalid_metadata = metadata(); + invalid_metadata["artifacts"][0]["file_name"] = json!(file_name); + invalid_metadata["artifacts"][0]["url"] = json!(format!( + "https://github.com/rustdesk/rustdesk/releases/download/v1.4.6/{file_name}" + )); + let fixture = sign_fixture(invalid_metadata, KEY_ID); + + let error = verify_with_query(&fixture, &query()) + .err() + .expect("invalid file name"); + + assert_eq!("invalid update artifact file name", error.to_string()); + } +} + +#[test] +fn rejects_path_components_in_unselected_artifact_file_name() { + let mut invalid_metadata = metadata(); + let mut unselected_artifact = artifact(); + unselected_artifact["platform"] = json!("linux"); + unselected_artifact["file_name"] = json!("nested/rustdesk-linux.exe"); + invalid_metadata["artifacts"] + .as_array_mut() + .expect("artifacts") + .push(unselected_artifact); + + let error = verify(&sign_fixture(invalid_metadata, KEY_ID)) + .err() + .expect("invalid file name"); + + assert_eq!("invalid update artifact file name", error.to_string()); +} + +#[test] +fn verifies_metadata_generated_by_python_release_script_fixture() { + const PYTHON_KEY_ID: &str = "python-test-ed25519-main"; + let metadata = br#"{"schema_version":1,"app":"rustdesk","package_id":"rustdesk","version":"1.4.6","release_id":"v1.4.6","published_at":"2026-05-14T00:00:00Z","signature_key_id":"python-test-ed25519-main","artifacts":[{"platform":"windows","arch":"x86_64","format":"exe","url":"https://github.com/rustdesk/rustdesk/releases/download/v1.4.6/rustdesk-1.4.6-x86_64.exe","file_name":"rustdesk-1.4.6-x86_64.exe","size":8,"sha256":"304ca1638c5effa6832e0e15b958a8f74847efe4df9c3f3187216e921c168fed"}]}"#; + let signature = br#"{"schema_version":1,"algorithm":"ed25519","key_id":"python-test-ed25519-main","signature":"ZMcod9VNaEGTYK0gIfGmMQ44HAvrYAkYDyIL9JTKjzUH+hVFYs8KpvnGSlteAHwqiJuJDdiKBVCPEdtcRRAJCA=="}"#; + let trusted_key = TrustedUpdateKey { + key_id: PYTHON_KEY_ID, + algorithm: "ed25519", + public_key: [ + 3, 161, 7, 191, 243, 206, 16, 190, 29, 112, 221, 24, 231, 75, 192, 153, 103, 228, 214, + 48, 155, 165, 13, 95, 29, 220, 134, 100, 18, 85, 49, 184, + ], + }; + + let trusted_keys = [trusted_key]; + let artifact = verify_update_metadata_with_options( + metadata, + signature, + VerificationOptions { + requirements: requirements(query()), + expected_package_id: PUBLIC_UPDATE_PACKAGE_ID, + artifact_url_policy: ArtifactUrlPolicy::ExactPrefix( + "https://github.com/rustdesk/rustdesk/releases/download/v1.4.6/", + ), + trusted_keys: &trusted_keys, + }, + ) + .expect("Python-generated update metadata verifies"); + + assert_eq!("rustdesk-1.4.6-x86_64.exe", artifact.file_name); +} + +#[path = "update_metadata/offline_tests.rs"] +mod offline_tests; diff --git a/src/updater.rs b/src/updater.rs index 0c1784000..533743928 100644 --- a/src/updater.rs +++ b/src/updater.rs @@ -1,9 +1,19 @@ -use crate::{common::do_check_software_update, hbbs_http::create_http_client_with_url_strict}; -use hbb_common::{bail, config, log, ResultType}; +mod artifact; +mod download; + +use crate::common::do_check_software_update; +use artifact::verified_update_artifact_from_release_page_url; +pub(crate) use artifact::{current_update_arch, current_update_format, current_update_platform}; +pub use artifact::{ + current_update_artifact_query, get_update_download_file_from_url, + verified_update_artifact_for_download_url, verified_update_artifact_for_release_page_url, +}; use base::config::keys; +use download::ensure_verified_update_artifact; +pub(crate) use download::remove_update_file; +use hbb_common::{bail, config, log, ResultType}; use std::{ - io::Write, - path::{Component, Path, PathBuf}, + path::Path, sync::{ atomic::{AtomicUsize, Ordering}, mpsc::{channel, Receiver, Sender}, @@ -12,6 +22,9 @@ use std::{ time::{Duration, Instant}, }; +#[cfg(target_os = "windows")] +use std::path::PathBuf; + #[cfg(target_os = "macos")] use std::os::{ fd::AsRawFd, @@ -182,91 +195,89 @@ fn check_update(manually: bool) -> ResultType<()> { } #[cfg(target_os = "windows")] let update_msi = crate::platform::is_msi_installed()? && !crate::is_custom_client(); + #[cfg(not(target_os = "windows"))] + let update_msi = false; if !(manually || config::Config::get_bool_option(keys::OPTION_ALLOW_AUTO_UPDATE)) { return Ok(()); } - if do_check_software_update().is_err() { - // ignore - return Ok(()); - } + do_check_software_update()?; let update_url = crate::common::SOFTWARE_UPDATE_URL.lock().unwrap().clone(); if update_url.is_empty() { log::debug!("No update available."); } else { - let download_url = update_url.replace("tag", "download"); - let version = download_url.split('/').last().unwrap_or_default(); - #[cfg(target_os = "windows")] - let download_url = if cfg!(feature = "flutter") { - let Some(arch) = crate::platform::windows::release_arch_suffix() else { - bail!( - "Unsupported Windows release architecture: {}", - std::env::consts::ARCH - ); - }; - format!( - "{}/rustdesk-{}-{}.{}", - download_url, - version, - arch, - if update_msi { "msi" } else { "exe" } - ) - } else { - format!("{}/rustdesk-{}-x86-sciter.exe", download_url, version) + let update_format = current_update_format(update_msi); + if update_format == "unknown" { + log::debug!("Automatic update is not supported on this platform."); + return Ok(()); + } + let query = crate::update_metadata::UpdateArtifactQuery { + platform: current_update_platform(), + arch: current_update_arch(), + format: update_format, }; + let artifact = verified_update_artifact_from_release_page_url(&update_url, &query)?; + let download_url = artifact.url.as_str(); + #[cfg(target_os = "windows")] + let version = artifact.version.as_str(); + #[cfg(target_os = "windows")] log::debug!("New version available: {}", &version); - let client = create_http_client_with_url_strict(&download_url)?; - let Some(file_path) = get_download_file_from_url(&download_url) else { + let Some(file_path) = get_update_download_file_from_url(download_url) else { bail!("Failed to get the file path from the URL: {}", download_url); }; - let mut is_file_exists = false; - if file_path.exists() { - // Check if the file size is the same as the server file size - // If the file size is the same, we don't need to download it again. - let file_size = std::fs::metadata(&file_path)?.len(); - let response = client.head(&download_url).send()?; - if !response.status().is_success() { - bail!("Failed to get the file size: {}", response.status()); - } - let total_size = response - .headers() - .get(reqwest::header::CONTENT_LENGTH) - .and_then(|ct_len| ct_len.to_str().ok()) - .and_then(|ct_len| ct_len.parse::().ok()); - let Some(total_size) = total_size else { - bail!("Failed to get content length"); - }; - if file_size == total_size { - is_file_exists = true; - } else { - std::fs::remove_file(&file_path)?; - } - } - if !is_file_exists { - let response = client.get(&download_url).send()?; - if !response.status().is_success() { - bail!( - "Failed to download the new version file: {}", - response.status() - ); - } - let file_data = response.bytes()?; - let mut file = std::fs::File::create(&file_path)?; - file.write_all(&file_data)?; - } + ensure_verified_update_artifact(download_url, &file_path, artifact.size, &artifact.sha256)?; // We have checked if the `conns` is empty before, but we need to check again. // No need to care about the downloaded file here, because it's rare case that the `conns` are empty // before the download, but not empty after the download. if has_no_active_conns() { #[cfg(target_os = "windows")] - update_new_version(update_msi, &version, &file_path); + update_new_version(update_msi, version, &file_path, &artifact.sha256); + #[cfg(target_os = "macos")] + { + let Some(file_path) = file_path.to_str() else { + bail!("Invalid UTF-8 path: {}", file_path.display()); + }; + crate::platform::macos::update_to_verified_dmg( + file_path, + &artifact.sha256, + Some(artifact.size), + )?; + } } } Ok(()) } #[cfg(target_os = "windows")] -fn update_new_version(update_msi: bool, version: &str, file_path: &PathBuf) { +fn verified_update_path( + p: &str, + expected_sha256: &str, + kind: &str, + file_path: &Path, +) -> Option<(crate::platform::VerifiedUpdateFile, String)> { + let update_file = match crate::platform::copy_and_verify_update_file_sha256(p, expected_sha256) + { + Ok(update_file) => update_file, + Err(e) => { + log::error!("Refusing to update from invalid {}: {}", kind, e); + remove_update_file(file_path); + return None; + } + }; + let update_path = match update_file.path_str() { + Ok(path) => path.to_owned(), + Err(e) => { + log::error!("Failed to get verified {} path: {}", kind, e); + update_file.cleanup(); + remove_update_file(file_path); + return None; + } + }; + Some((update_file, update_path)) +} + +#[cfg(target_os = "windows")] +fn update_new_version(update_msi: bool, version: &str, file_path: &PathBuf, expected_sha256: &str) { log::debug!( "New version is downloaded, update begin, update msi: {update_msi}, version: {version}, file: {:?}", file_path.to_str() @@ -274,7 +285,14 @@ fn update_new_version(update_msi: bool, version: &str, file_path: &PathBuf) { if let Some(p) = file_path.to_str() { if let Some(session_id) = crate::platform::get_current_process_session_id() { if update_msi { - match crate::platform::update_me_msi(p, true) { + let Some((update_file, update_path)) = + verified_update_path(p, expected_sha256, "msi", file_path) + else { + return; + }; + let result = crate::platform::update_me_msi(&update_path, expected_sha256, true); + update_file.cleanup(); + match result { Ok(_) => { log::debug!("New version \"{}\" updated.", version); } @@ -284,10 +302,15 @@ fn update_new_version(update_msi: bool, version: &str, file_path: &PathBuf) { version, e ); - std::fs::remove_file(&file_path).ok(); + remove_update_file(file_path); } } } else { + let Some((update_file, update_path)) = + verified_update_path(p, expected_sha256, "exe", file_path) + else { + return; + }; let custom_client_staging_dir = if crate::is_custom_client() { let custom_client_staging_dir = crate::platform::get_custom_client_staging_dir(); @@ -298,7 +321,8 @@ fn update_new_version(update_msi: bool, version: &str, file_path: &PathBuf) { "Failed to handle custom client staging dir before update: {}", e ); - std::fs::remove_file(&file_path).ok(); + update_file.cleanup(); + remove_update_file(file_path); return; } Some(custom_client_staging_dir) @@ -312,7 +336,7 @@ fn update_new_version(update_msi: bool, version: &str, file_path: &PathBuf) { }; let update_launched = match crate::platform::launch_privileged_process( session_id, - &format!("{} --update", p), + &format!("\"{}\" --update", update_path), ) { Ok(h) => { if h.is_null() { @@ -320,6 +344,9 @@ fn update_new_version(update_msi: bool, version: &str, file_path: &PathBuf) { false } else { log::debug!("New version \"{}\" is launched.", version); + unsafe { + winapi::um::handleapi::CloseHandle(h); + } true } } @@ -334,7 +361,8 @@ fn update_new_version(update_msi: bool, version: &str, file_path: &PathBuf) { &dir )); } - std::fs::remove_file(&file_path).ok(); + update_file.cleanup(); + remove_update_file(file_path); } } } else { @@ -342,7 +370,7 @@ fn update_new_version(update_msi: bool, version: &str, file_path: &PathBuf) { "Failed to get the current process session id, Error {}", std::io::Error::last_os_error() ); - std::fs::remove_file(&file_path).ok(); + remove_update_file(file_path); } } else { // unreachable!() @@ -353,63 +381,6 @@ fn update_new_version(update_msi: bool, version: &str, file_path: &PathBuf) { } } -pub fn get_update_download_file_from_url(url: &str) -> Option { - let parsed = url::Url::parse(url).ok()?; - // Check the raw prefix before Url normalizes default ports. - if !url.starts_with("https://github.com/") - || parsed.scheme() != "https" - || parsed.host_str() != Some("github.com") - || !parsed.username().is_empty() - || parsed.password().is_some() - || parsed.port().is_some() - || parsed.query().is_some() - || parsed.fragment().is_some() - { - return None; - } - - let mut segments = parsed.path_segments()?; - let owner = segments.next()?; - let repo = segments.next()?; - let releases = segments.next()?; - let download = segments.next()?; - let tag = segments.next()?; - let filename = segments.next()?; - - if owner != "rustdesk" - || repo != "rustdesk" - || releases != "releases" - || download != "download" - || tag.is_empty() - || segments.next().is_some() - || !is_plain_update_filename(filename) - { - return None; - } - - Some(std::env::temp_dir().join(filename)) -} - -fn is_plain_update_filename(filename: &str) -> bool { - if filename.is_empty() - || filename.contains('/') - || filename.contains('\\') - || filename.contains(':') - { - return false; - } - - let mut components = Path::new(filename).components(); - matches!( - components.next(), - Some(Component::Normal(name)) if name.to_str() == Some(filename) - ) && components.next().is_none() -} - -pub fn get_download_file_from_url(url: &str) -> Option { - get_update_download_file_from_url(url) -} - /// Queries all active connections (remote, file-transfer, port-forward, camera, terminal) /// from every logged-in user's --server process via IPC. /// The root service cannot read connection state directly since connections @@ -419,39 +390,47 @@ pub fn get_download_file_from_url(url: &str) -> Option { /// an unknown session state. #[cfg(target_os = "macos")] pub fn has_no_active_conns_ipc() -> bool { - let rt = match hbb_common::tokio::runtime::Runtime::new() { - Ok(rt) => rt, - Err(_) => return false, + let result = match hbb_common::tokio::runtime::Runtime::new() { + Ok(rt) => rt.block_on(query_no_active_conns_ipc()), + Err(err) => Err(hbb_common::anyhow::anyhow!( + "failed to create IPC runtime: {err}" + )), }; - rt.block_on(async { - // Use the same GUI-domain-filtered UID set as the update script. - // Shell-only SSH/TTY users are excluded, while an empty GUI set maps - // to UID 0 so the LoginWindow server is queried rather than assumed idle. - let uids = crate::platform::get_logged_in_uids(); - // Check each user's server — fail closed if any has active connections - for uid in uids { - if let Ok(mut conn) = crate::ipc::connect_for_uid(1000, uid, "").await { - if conn.send(&crate::ipc::Data::HasNoActiveConns(None)).await.is_ok() { - match conn.next_timeout(1000).await { - Ok(Some(crate::ipc::Data::HasNoActiveConns(Some(true)))) => { - // Explicit no active connections — safe to continue - } - Ok(Some(crate::ipc::Data::HasNoActiveConns(Some(false)))) => { - return false; // Explicit active connections - } - _ => { - return false; // Timeout/error/unexpected — fail closed - } - } - } else { - return false; // Send failed — fail closed - } - } else { - return false; // Connection failed — fail closed - } + match result { + Ok(no_active_conns) => no_active_conns, + Err(err) => { + log::warn!( + "[root-update] Unable to determine active connection state; deferring update: {}", + err + ); + false } - true // All users explicitly confirmed no active connections - }) + } +} + +#[cfg(target_os = "macos")] +async fn query_no_active_conns_ipc() -> ResultType { + const IPC_TIMEOUT_MS: u64 = 1_000; + // An empty GUI user set maps to UID 0 so LoginWindow must also confirm it is idle. + let uids = crate::platform::get_logged_in_uids(); + for uid in uids { + let mut conn = crate::ipc::connect_for_uid(IPC_TIMEOUT_MS, uid, "") + .await + .map_err(|err| { + hbb_common::anyhow::anyhow!("IPC connection failed for uid {uid}: {err}") + })?; + conn.send(&crate::ipc::Data::HasNoActiveConns(None)) + .await + .map_err(|err| hbb_common::anyhow::anyhow!("IPC send failed for uid {uid}: {err}"))?; + match conn.next_timeout(IPC_TIMEOUT_MS).await { + Ok(Some(crate::ipc::Data::HasNoActiveConns(Some(true)))) => {} + Ok(Some(crate::ipc::Data::HasNoActiveConns(Some(false)))) => return Ok(false), + Ok(Some(_)) => bail!("unexpected active-connection IPC response for uid {uid}"), + Ok(None) => bail!("active-connection IPC closed for uid {uid}"), + Err(err) => bail!("active-connection IPC failed for uid {uid}: {err}"), + } + } + Ok(true) } #[cfg(target_os = "macos")] @@ -586,17 +565,19 @@ pub fn check_update_as_root() -> ResultType { log::info!("[root-update] No update available."); return Ok(false); } - let download_url = update_url.replace("tag", "download"); - let version = download_url.split('/').last().unwrap_or_default().to_string(); - let arch = if std::env::consts::ARCH == "aarch64" { "aarch64" } else { "x86_64" }; - let dmg_url = format!("{}/rustdesk-{}-{}.dmg", download_url, version, arch); - log::info!("[root-update] New version: {}, downloading from {}", version, dmg_url); + let query = current_update_artifact_query(false); + let artifact = verified_update_artifact_from_release_page_url(&update_url, &query)?; + let dmg_url = artifact.url.as_str(); + log::info!( + "[root-update] New version: {}, downloading from {}", + artifact.version, + dmg_url + ); // Validate URL against GitHub release allowlist before downloading as root - let Some(file_path_validated) = get_update_download_file_from_url(&dmg_url) else { + let Some(file_path_validated) = get_update_download_file_from_url(dmg_url) else { bail!("[root-update] URL failed allowlist check: {}", dmg_url); }; drop(file_path_validated); - let client = create_http_client_with_url_strict(&dmg_url)?; // Use mktemp so a local user cannot pre-create a predictable path and // permanently deny updates for a reused service PID. let private_tmp_output = std::process::Command::new("/usr/bin/mktemp") @@ -619,26 +600,21 @@ pub fn check_update_as_root() -> ResultType { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(&private_tmp, std::fs::Permissions::from_mode(0o700))?; } - let filename = dmg_url.split('/').last().unwrap_or("rustdesk.dmg"); - let file_path = std::path::PathBuf::from(format!("{}/{}", private_tmp, filename)); + let file_path = Path::new(&private_tmp).join(&artifact.file_name); let tmp_path = file_path.to_string_lossy().to_string(); - // Download - let mut response = client.get(&dmg_url).send()?; - if !response.status().is_success() { - let _ = std::fs::remove_dir_all(&private_tmp); - bail!("[root-update] Failed to download: {}", response.status()); - } - // Create file exclusively (O_EXCL) and stream response directly into it + if let Err(err) = + ensure_verified_update_artifact(dmg_url, &file_path, artifact.size, &artifact.sha256) { - let mut file = std::fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(&file_path) - .map_err(|e| { let _ = std::fs::remove_dir_all(&private_tmp); e })?; - std::io::copy(&mut response, &mut file) - .map_err(|e| { let _ = std::fs::remove_dir_all(&private_tmp); e })?; + if let Err(cleanup_err) = std::fs::remove_dir_all(&private_tmp) { + log::warn!( + "[root-update] Failed to remove temp dir {}: {}", + private_tmp, + cleanup_err + ); + } + return Err(err); } - log::info!("[root-update] Downloaded to {}", tmp_path); + log::info!("[root-update] Downloaded and verified at {}", tmp_path); // Recheck active sessions before installing — download can take minutes if !has_no_active_conns_ipc() { if let Err(e) = std::fs::remove_dir_all(&private_tmp) { @@ -647,47 +623,14 @@ pub fn check_update_as_root() -> ResultType { bail!("[root-update] Active session started during download, deferring update."); } // Install silently as root - let result = crate::platform::update_from_dmg_as_root(&tmp_path, &version); + let result = crate::platform::update_from_dmg_as_root(&tmp_path, &artifact.version); // Clean up download directory if let Err(e) = std::fs::remove_dir_all(&private_tmp) { - log::warn!("[root-update] Failed to remove temp dir {}: {}", private_tmp, e); + log::warn!( + "[root-update] Failed to remove temp dir {}: {}", + private_tmp, + e + ); } result.map(|_| true) } - -#[cfg(test)] -mod tests { - use super::get_download_file_from_url; - - #[test] - fn update_download_file_accepts_expected_github_asset_urls() { - let file = get_download_file_from_url( - "https://github.com/rustdesk/rustdesk/releases/download/1.4.0/rustdesk-1.4.0-x86_64.dmg", - ) - .expect("valid GitHub release asset URL"); - - assert_eq!( - file.file_name().and_then(|name| name.to_str()), - Some("rustdesk-1.4.0-x86_64.dmg") - ); - } - - #[test] - fn update_download_file_rejects_untrusted_or_malformed_urls() { - for url in [ - "http://github.com/rustdesk/rustdesk/releases/download/1/rustdesk.exe", - "https://example.com/rustdesk.exe", - "https://github.com/other/project/releases/download/1/rustdesk.exe", - "https://github.com/rustdesk/rustdesk/releases/download/1/", - "https://github.com/rustdesk/rustdesk/releases/download/1/nested/rustdesk.exe", - "https://github.com/rustdesk/rustdesk/releases/download/1/C:rustdesk.exe", - "https://user@github.com/rustdesk/rustdesk/releases/download/1/rustdesk.exe", - "https://github.com:443/rustdesk/rustdesk/releases/download/1/rustdesk.exe", - "https://github.com/rustdesk/rustdesk/releases/download/1/rustdesk.exe?download=1", - "https://github.com/rustdesk/rustdesk/releases/download/1/rustdesk.exe#download", - "not a url", - ] { - assert!(get_download_file_from_url(url).is_none(), "{url}"); - } - } -} diff --git a/src/updater/artifact.rs b/src/updater/artifact.rs new file mode 100644 index 000000000..9759b1f66 --- /dev/null +++ b/src/updater/artifact.rs @@ -0,0 +1,259 @@ +use crate::{ + common::{display_version_from_release_id, release_id_from_update_url}, + hbbs_http::create_http_client_with_url_strict, + update_metadata::{UpdateArtifactQuery, UpdateMetadataRequirements, VerifiedUpdateArtifact}, +}; +use hbb_common::{bail, ResultType}; +use std::{ + io::Read, + path::{Component, Path, PathBuf}, + sync::Mutex, + time::Duration, +}; + +const UPDATE_SIDECAR_HTTP_REQUEST_TIMEOUT: Duration = Duration::from_secs(30); +const UPDATE_METADATA_SIDECAR_MAX_BYTES: u64 = 1024 * 1024; + +lazy_static::lazy_static! { + static ref VERIFIED_UPDATE_ARTIFACT: Mutex> = Mutex::new(None); +} + +pub(crate) fn current_update_platform() -> &'static str { + #[cfg(target_os = "windows")] + { + "windows" + } + #[cfg(target_os = "macos")] + { + "macos" + } + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + { + std::env::consts::OS + } +} + +pub(crate) fn current_update_arch() -> &'static str { + #[cfg(all(target_os = "windows", not(feature = "flutter")))] + { + "x86" + } + #[cfg(not(all(target_os = "windows", not(feature = "flutter"))))] + { + std::env::consts::ARCH + } +} + +pub(crate) fn current_update_format(update_msi: bool) -> &'static str { + #[cfg(any( + not(target_os = "windows"), + all(target_os = "windows", not(feature = "flutter")) + ))] + let _ = update_msi; + #[cfg(all(target_os = "windows", feature = "flutter"))] + { + if update_msi { + return "msi"; + } + "exe" + } + #[cfg(all(target_os = "windows", not(feature = "flutter")))] + { + "exe" + } + #[cfg(target_os = "macos")] + { + "dmg" + } + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + { + "unknown" + } +} + +pub fn current_update_artifact_query(update_msi: bool) -> UpdateArtifactQuery<'static> { + UpdateArtifactQuery { + platform: current_update_platform(), + arch: current_update_arch(), + format: current_update_format(update_msi), + } +} + +pub fn verified_update_artifact_for_release_page_url( + release_page_url: &str, + query: UpdateArtifactQuery<'_>, +) -> ResultType { + let artifact = verified_update_artifact_from_release_page_url(release_page_url, &query)?; + *VERIFIED_UPDATE_ARTIFACT.lock().unwrap() = Some(artifact.clone()); + Ok(artifact) +} + +pub fn verified_update_artifact_for_download_url( + download_url: &str, +) -> ResultType { + VERIFIED_UPDATE_ARTIFACT + .lock() + .unwrap() + .as_ref() + .filter(|artifact| artifact.url == download_url) + .cloned() + .ok_or_else(|| hbb_common::anyhow::anyhow!("update artifact was not verified")) +} + +pub(super) fn verified_update_artifact_from_release_page_url( + update_url: &str, + query: &UpdateArtifactQuery<'_>, +) -> ResultType { + let release_id = release_id_from_update_url(update_url)?; + let display_version = display_version_from_release_id(&release_id)?; + let expected_artifact_url_prefix = + format!("https://github.com/rustdesk/rustdesk/releases/download/{release_id}/"); + let metadata_url = format!("{expected_artifact_url_prefix}rustdesk-update.json"); + let signature_url = format!("{metadata_url}.sig"); + let metadata_bytes = fetch_update_sidecar_bytes(&metadata_url)?; + let signature_bytes = fetch_update_sidecar_bytes(&signature_url)?; + let requirements = UpdateMetadataRequirements { + expected_version: display_version.as_str(), + expected_release_id: release_id.as_str(), + expected_artifact_url_prefix: expected_artifact_url_prefix.as_str(), + artifact: *query, + }; + crate::update_metadata::verify_update_metadata(&metadata_bytes, &signature_bytes, requirements) +} + +fn fetch_update_sidecar_bytes(url: &str) -> ResultType> { + let client = create_http_client_with_url_strict(url)?; + let mut response = client + .get(url) + .timeout(UPDATE_SIDECAR_HTTP_REQUEST_TIMEOUT) + .send()?; + if !response.status().is_success() { + bail!( + "Failed to download update metadata sidecar: {}", + response.status() + ); + } + read_limited_response_bytes( + &mut response, + UPDATE_METADATA_SIDECAR_MAX_BYTES, + "Update metadata sidecar", + ) +} + +fn read_limited_response_bytes( + reader: &mut R, + limit: u64, + what: &str, +) -> ResultType> { + let mut limited_reader = reader.take(limit.saturating_add(1)); + let mut bytes = Vec::new(); + limited_reader.read_to_end(&mut bytes)?; + if bytes.len() as u64 > limit { + bail!("{what} exceeds maximum allowed size of {limit} bytes"); + } + Ok(bytes) +} + +pub fn get_update_download_file_from_url(url: &str) -> Option { + let parsed = url::Url::parse(url).ok()?; + // Check the raw prefix before Url normalizes default ports. + if !url.starts_with("https://github.com/") + || parsed.scheme() != "https" + || parsed.host_str() != Some("github.com") + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.port().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some() + { + return None; + } + + let mut segments = parsed.path_segments()?; + let owner = segments.next()?; + let repo = segments.next()?; + let releases = segments.next()?; + let download = segments.next()?; + let tag = segments.next()?; + let filename = segments.next()?; + + if owner != "rustdesk" + || repo != "rustdesk" + || releases != "releases" + || download != "download" + || tag.is_empty() + || segments.next().is_some() + || !is_plain_update_filename(filename) + { + return None; + } + + Some(std::env::temp_dir().join(filename)) +} + +fn is_plain_update_filename(filename: &str) -> bool { + if filename.is_empty() + || filename.contains('/') + || filename.contains('\\') + || filename.contains(':') + { + return false; + } + + let mut components = Path::new(filename).components(); + matches!( + components.next(), + Some(Component::Normal(name)) if name.to_str() == Some(filename) + ) && components.next().is_none() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn update_download_file_accepts_expected_github_asset_urls() { + let file = get_update_download_file_from_url( + "https://github.com/rustdesk/rustdesk/releases/download/1.4.0/rustdesk-1.4.0-x86_64.dmg", + ) + .expect("valid GitHub release asset URL"); + + assert_eq!( + file.file_name().and_then(|name| name.to_str()), + Some("rustdesk-1.4.0-x86_64.dmg") + ); + } + + #[test] + fn update_download_file_rejects_untrusted_or_malformed_urls() { + for url in [ + "http://github.com/rustdesk/rustdesk/releases/download/1/rustdesk.exe", + "https://example.com/rustdesk.exe", + "https://github.com/other/project/releases/download/1/rustdesk.exe", + "https://github.com/rustdesk/rustdesk/releases/download/1/", + "https://github.com/rustdesk/rustdesk/releases/download/1/nested/rustdesk.exe", + "https://github.com/rustdesk/rustdesk/releases/download/1/C:rustdesk.exe", + "https://user@github.com/rustdesk/rustdesk/releases/download/1/rustdesk.exe", + "https://github.com:443/rustdesk/rustdesk/releases/download/1/rustdesk.exe", + "https://github.com/rustdesk/rustdesk/releases/download/1/rustdesk.exe?download=1", + "https://github.com/rustdesk/rustdesk/releases/download/1/rustdesk.exe#download", + "not a url", + ] { + assert!(get_update_download_file_from_url(url).is_none(), "{url}"); + } + } + + #[test] + fn limited_sidecar_reader_rejects_oversized_payloads() { + let mut payload: &[u8] = b"rustdesk"; + assert_eq!( + read_limited_response_bytes(&mut payload, 8, "sidecar") + .unwrap() + .len(), + 8 + ); + + let mut oversized: &[u8] = b"too-large"; + assert!(read_limited_response_bytes(&mut oversized, 4, "sidecar").is_err()); + } +} diff --git a/src/updater/download.rs b/src/updater/download.rs new file mode 100644 index 000000000..0aaaadb6a --- /dev/null +++ b/src/updater/download.rs @@ -0,0 +1,227 @@ +use crate::hbbs_http::create_http_client_with_url_strict; +use hbb_common::{bail, log, ResultType}; +use std::{ + io::{Read, Seek, Write}, + path::{Path, PathBuf}, +}; + +const UPDATE_FILE_CREATE_ATTEMPTS: usize = 16; + +pub(super) fn ensure_verified_update_artifact( + download_url: &str, + file_path: &Path, + expected_size: u64, + expected_sha256: &str, +) -> ResultType<()> { + if let Some(file_size) = cached_update_artifact_size(file_path)? { + if file_size == expected_size { + match verify_file_sha256(file_path, expected_sha256) { + Ok(()) => return Ok(()), + Err(e) => { + log::warn!("Removing cached update file with invalid SHA256: {}", e); + remove_cached_update_artifact(file_path)?; + } + } + } else { + log::warn!( + "Removing cached update file with size mismatch for {}: expected {}, got {}", + file_path.display(), + expected_size, + file_size + ); + remove_cached_update_artifact(file_path)?; + } + } + let client = create_http_client_with_url_strict(download_url)?; + let response = client.get(download_url).send()?; + if !response.status().is_success() { + bail!( + "Failed to download the new version file: {}", + response.status() + ); + } + let mut limited_response = response.take(expected_size.saturating_add(1)); + write_verified_update_artifact( + file_path, + &mut limited_response, + expected_size, + expected_sha256, + ) +} + +fn cached_update_artifact_size(file_path: &Path) -> ResultType> { + let metadata = match std::fs::symlink_metadata(file_path) { + Ok(metadata) => metadata, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(e.into()), + }; + if metadata.file_type().is_file() { + return Ok(Some(metadata.len())); + } + if metadata.file_type().is_symlink() { + log::warn!("Removing cached update symlink: {}", file_path.display()); + remove_cached_update_artifact(file_path)?; + return Ok(None); + } + bail!( + "Refusing to use update cache path that is not a regular file: {}", + file_path.display() + ) +} + +fn remove_cached_update_artifact(file_path: &Path) -> ResultType<()> { + let metadata = match std::fs::symlink_metadata(file_path) { + Ok(metadata) => metadata, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(e) => return Err(e.into()), + }; + let file_type = metadata.file_type(); + if file_type.is_file() { + std::fs::remove_file(file_path)?; + } else if file_type.is_symlink() { + #[cfg(target_os = "windows")] + { + use std::os::windows::fs::FileTypeExt; + if file_type.is_symlink_dir() { + std::fs::remove_dir(file_path)?; + } else { + std::fs::remove_file(file_path)?; + } + } + #[cfg(not(target_os = "windows"))] + std::fs::remove_file(file_path)?; + } else { + bail!( + "Refusing to remove update cache path that is not a file: {}", + file_path.display() + ); + } + Ok(()) +} + +pub(crate) fn remove_update_file(file_path: &Path) { + match std::fs::remove_file(file_path) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => log::warn!( + "Failed to remove update file {}: {}", + file_path.display(), + e + ), + } +} + +fn create_download_temp_file(final_path: &Path) -> ResultType<(std::fs::File, PathBuf)> { + let Some(download_dir) = final_path.parent() else { + bail!( + "Update file has no parent directory: {}", + final_path.display() + ); + }; + let Some(file_name) = final_path.file_name() else { + bail!("Update file has no file name: {}", final_path.display()); + }; + let file_name = file_name.to_string_lossy(); + for _ in 0..UPDATE_FILE_CREATE_ATTEMPTS { + let temp_path = download_dir.join(format!( + ".{}.{}.{}.download", + file_name, + std::process::id(), + hbb_common::rand::random::() + )); + match std::fs::OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .open(&temp_path) + { + Ok(file) => return Ok((file, temp_path)), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(e) => return Err(e.into()), + } + } + bail!("Failed to create temporary update file"); +} + +fn write_verified_update_artifact( + final_path: &Path, + reader: &mut R, + expected_size: u64, + expected_sha256: &str, +) -> ResultType<()> { + let (mut file, temp_path) = create_download_temp_file(final_path)?; + if let Err(e) = copy_and_verify_update_artifact( + &mut file, + &temp_path, + reader, + expected_size, + expected_sha256, + ) { + remove_update_file(&temp_path); + return Err(e); + } + drop(file); + if let Err(e) = remove_cached_update_artifact(final_path) { + remove_update_file(&temp_path); + return Err(e); + } + if let Err(e) = std::fs::rename(&temp_path, final_path) { + remove_update_file(&temp_path); + return Err(e.into()); + } + Ok(()) +} + +fn copy_and_verify_update_artifact( + file: &mut std::fs::File, + temp_path: &Path, + reader: &mut R, + expected_size: u64, + expected_sha256: &str, +) -> ResultType<()> { + let bytes_written = std::io::copy(reader, file)?; + file.flush()?; + if bytes_written != expected_size { + bail!( + "Update artifact size mismatch for {}: expected {}, got {}", + temp_path.display(), + expected_size, + bytes_written + ); + } + verify_update_file_sha256(file, temp_path, expected_sha256) +} + +fn verify_file_sha256(path: &Path, expected_sha256: &str) -> ResultType<()> { + let mut file = std::fs::File::open(path)?; + verify_update_file_sha256(&mut file, path, expected_sha256) +} + +fn verify_update_file_sha256( + reader: &mut R, + path: &Path, + expected_sha256: &str, +) -> ResultType<()> { + use crate::update_hash::{verify_sha256_reader, Sha256VerificationError}; + + match verify_sha256_reader(reader, expected_sha256) { + Ok(()) => Ok(()), + Err(Sha256VerificationError::InvalidExpected) => bail!( + "Expected update file SHA256 is malformed for {}", + path.display() + ), + Err(Sha256VerificationError::Mismatch { + expected_sha256, + actual_sha256, + }) => bail!( + "SHA256 mismatch for {}: expected {}, got {}", + path.display(), + expected_sha256, + actual_sha256 + ), + Err(Sha256VerificationError::Io(err)) => Err(err.into()), + } +} + +#[cfg(test)] +mod tests; diff --git a/src/updater/download/tests.rs b/src/updater/download/tests.rs new file mode 100644 index 000000000..661c51153 --- /dev/null +++ b/src/updater/download/tests.rs @@ -0,0 +1,83 @@ +use super::*; + +#[test] +fn copy_and_verify_update_artifact_hashes_open_file_handle() { + let test_dir = std::env::temp_dir().join(format!( + "rustdesk-updater-open-handle-sha256-test-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&test_dir); + std::fs::create_dir_all(&test_dir).unwrap(); + let final_path = test_dir.join("rustdesk-update.exe"); + let (mut file, _) = create_download_temp_file(&final_path).unwrap(); + let display_path = test_dir.join("path-must-not-be-opened.download"); + let mut data: &[u8] = b"rustdesk"; + + let result = copy_and_verify_update_artifact( + &mut file, + &display_path, + &mut data, + 8, + "304ca1638c5effa6832e0e15b958a8f74847efe4df9c3f3187216e921c168fed", + ); + let position = result + .as_ref() + .ok() + .map(|_| std::io::Seek::stream_position(&mut file).unwrap()); + drop(file); + std::fs::remove_dir_all(&test_dir).unwrap(); + + assert!(result.is_ok(), "{:?}", result.err()); + assert_eq!(position, Some(0)); +} + +#[test] +fn remove_cached_update_artifact_rejects_directory() { + let test_dir = std::env::temp_dir().join(format!( + "rustdesk-updater-cache-dir-test-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&test_dir); + std::fs::create_dir_all(&test_dir).unwrap(); + let cache_path = test_dir.join("rustdesk-update.exe"); + std::fs::create_dir(&cache_path).unwrap(); + std::fs::write(cache_path.join("stale"), b"stale").unwrap(); + + let result = remove_cached_update_artifact(&cache_path); + + assert!(result.is_err()); + assert!(cache_path.exists()); + std::fs::remove_dir_all(&test_dir).unwrap(); +} + +#[cfg(unix)] +#[test] +fn verified_download_replaces_symlink_without_touching_target() { + let test_dir = std::env::temp_dir().join(format!( + "rustdesk-updater-symlink-test-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&test_dir); + std::fs::create_dir_all(&test_dir).unwrap(); + let final_path = test_dir.join("rustdesk-update.exe"); + let victim_path = test_dir.join("victim"); + std::fs::write(&victim_path, b"victim").unwrap(); + std::os::unix::fs::symlink(&victim_path, &final_path).unwrap(); + let mut data: &[u8] = b"update"; + + write_verified_update_artifact( + &final_path, + &mut data, + 6, + "2937013f2181810606b2a799b05bda2849f3e369a20982a4138f0e0a55984ce4", + ) + .unwrap(); + + assert_eq!(std::fs::read(&victim_path).unwrap(), b"victim"); + assert_eq!(std::fs::read(&final_path).unwrap(), b"update"); + assert!(!std::fs::symlink_metadata(&final_path) + .unwrap() + .file_type() + .is_symlink()); + std::fs::remove_dir_all(&test_dir).unwrap(); +}