diff --git a/.github/telegram-bot-api/Dockerfile b/.github/telegram-bot-api/Dockerfile index f96734285e..a361602441 100644 --- a/.github/telegram-bot-api/Dockerfile +++ b/.github/telegram-bot-api/Dockerfile @@ -3,7 +3,9 @@ # this container the bot token and the update files, so it must never be # replaced by a third-party image. -FROM ubuntu:22.04 AS build +# The base image is pinned by digest (ubuntu:22.04 as of 2026-08-20) so +# a rebuild of "the same" ref cannot silently pick up another base OS. +FROM ubuntu:22.04@sha256:2edbbc5dc405e9612ba3584ce95480277e3eb374407b5505fe26f17df77c7dbc AS build RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates cmake g++ git gperf libssl-dev make zlib1g-dev \ @@ -13,14 +15,16 @@ ARG TELEGRAM_BOT_API_REF RUN test -n "$TELEGRAM_BOT_API_REF" \ && git clone https://github.com/tdlib/telegram-bot-api.git /src \ && git -C /src checkout "$TELEGRAM_BOT_API_REF" \ - && git -C /src submodule update --init --recursive + && git -C /src submodule update --init --recursive \ + && echo "telegram-bot-api at $(git -C /src rev-parse HEAD)" \ + && git -C /src submodule status --recursive RUN cmake -S /src -B /build \ -DCMAKE_BUILD_TYPE=Release \ -DCMAKE_INSTALL_PREFIX=/out \ && cmake --build /build --target install --parallel -FROM ubuntu:22.04 +FROM ubuntu:22.04@sha256:2edbbc5dc405e9612ba3584ce95480277e3eb374407b5505fe26f17df77c7dbc RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates libssl3 zlib1g \ diff --git a/.github/workflows/canary-bot-api.yml b/.github/workflows/canary-bot-api.yml index 7e64bf8446..46b2790742 100644 --- a/.github/workflows/canary-bot-api.yml +++ b/.github/workflows/canary-bot-api.yml @@ -4,6 +4,10 @@ # printed digest-pinned reference. Rebuild deliberately on upgrades, # never track a third-party image: the publish job hands this container # the bot token and every published update file. +# +# The job runs in the 'canary' environment so only the refs that +# environment admits can dispatch it, exactly like the publish job that +# consumes the image. name: Canary Bot API image. @@ -11,7 +15,7 @@ on: workflow_dispatch: inputs: ref: - description: tdlib/telegram-bot-api tag or commit to build + description: full 40-hex tdlib/telegram-bot-api commit to build (the project has no tags; take the "Update version to X.Y" commit of a release) required: true permissions: @@ -22,14 +26,24 @@ jobs: build: name: Build and push - runs-on: depot-ubuntu-latest-16 + runs-on: depot-ubuntu-latest-32 + environment: canary steps: + - name: Validate the ref. + env: + REF: ${{ inputs.ref }} + run: | + if ! [[ "$REF" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::Pass a full 40-hex commit, got '$REF'." + exit 1 + fi + - name: Clone. - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Log in to GHCR. - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: registry: ghcr.io username: ${{ github.actor }} @@ -37,7 +51,7 @@ jobs: - name: Build and push. id: push - uses: docker/build-push-action@v7 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 with: context: .github/telegram-bot-api push: true diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index 615f01cdce..3383501207 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -44,7 +44,7 @@ # every published update file. # # Public repository only: -# secrets.CANARY_PUBLIC_CHANNEL_ID +# vars.CANARY_PUBLIC_CHANNEL_ID # Numeric -100... public channel id for the Bot API calls. # vars.CANARY_PUBLIC_CHANNEL_USERNAME # Public channel username compiled into canary-public builds. @@ -54,13 +54,18 @@ # Key Vault key name, must match the manifest id ("cp-2026a"). # # Private repository only: -# secrets.CANARY_PRIVATE_CHANNEL_ID +# vars.CANARY_PRIVATE_CHANNEL_ID # Bare numeric id of the private channel (no -100 prefix), also # compiled into canary-private builds for discovery. # vars.CANARY_PRIVATE_METADATA_MSG_ID # Fixed id of the pinned metadata message in the private channel. # vars.CANARY_PRIVATE_SIGNING_KEY_ID # Key Vault key name, must match the manifest id ("cx-2026a"). +# vars.CANARY_ALLOW_UNSIGNED +# Bring-up only: "1" publishes without Windows/macOS platform +# signatures (the v2 envelope is still signed). Refused on the +# public lane; remove it once KeyLocker and the Apple certificate +# exist. # # Publishing no-ops cleanly while CANARY_BOT_TOKEN is absent, so the # workflow can run before the bots/channels/KeyLocker exist. @@ -76,17 +81,24 @@ concurrency: group: canary-publish cancel-in-progress: false +# The OIDC token that mints Key Vault signatures is granted per job to +# the three packing jobs only, nothing else can request it. permissions: - id-token: write contents: read - packages: read jobs: version: name: Version - runs-on: ubuntu-latest + runs-on: depot-ubuntu-latest environment: canary + # Belt and braces next to the environment branch policy: the secrets + # are never even requested from a run that is not a push to canary. + if: github.event_name == 'push' && github.ref == 'refs/heads/canary' + + permissions: + contents: read + packages: read outputs: channel: ${{ steps.compute.outputs.channel }} @@ -95,10 +107,11 @@ jobs: counter: ${{ steps.compute.outputs.counter }} previous: ${{ steps.compute.outputs.previous }} publish: ${{ steps.compute.outputs.publish }} + unsigned: ${{ steps.compute.outputs.unsigned }} steps: - name: Clone. - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: # Full history: the changelog walks commits and the pipeline # must survive force-pushes and rebases of the canary branch. @@ -108,15 +121,26 @@ jobs: id: compute env: BOT_TOKEN: ${{ secrets.CANARY_BOT_TOKEN }} - PUBLIC_CHANNEL: ${{ secrets.CANARY_PUBLIC_CHANNEL_ID }} - PRIVATE_CHANNEL: ${{ secrets.CANARY_PRIVATE_CHANNEL_ID }} + PUBLIC_CHANNEL: ${{ vars.CANARY_PUBLIC_CHANNEL_ID }} + PRIVATE_CHANNEL: ${{ vars.CANARY_PRIVATE_CHANNEL_ID }} + PUBLIC_MSG_ID: ${{ vars.CANARY_METADATA_MSG_ID }} + PRIVATE_MSG_ID: ${{ vars.CANARY_PRIVATE_METADATA_MSG_ID }} CHANNEL_OVERRIDE: ${{ vars.CANARY_CHANNEL }} + ALLOW_UNSIGNED: ${{ vars.CANARY_ALLOW_UNSIGNED }} REPO_IS_PRIVATE: ${{ github.event.repository.private }} BOT_API_IMAGE: ${{ vars.CANARY_BOT_API_IMAGE }} TELEGRAM_API_ID: ${{ secrets.CANARY_API_ID }} TELEGRAM_API_HASH: ${{ secrets.CANARY_API_HASH }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | + # A re-run of an older run would read the live counter and + # republish old code as a newer version: only the current tip + # of the canary branch may become a canary. + if [ "$GITHUB_SHA" != "$(git rev-parse origin/canary)" ]; then + echo "::error::$GITHUB_SHA is not the tip of canary, refusing to publish old code as a new version." + exit 1 + fi + CHANNEL="$CHANNEL_OVERRIDE" if [ -z "$CHANNEL" ]; then if [ "$REPO_IS_PRIVATE" = "true" ]; then @@ -139,8 +163,10 @@ jobs: echo "channel=$CHANNEL" >> $GITHUB_OUTPUT if [ "$CHANNEL" = "public" ]; then CHAT_ID="$PUBLIC_CHANNEL" + MSG_ID="$PUBLIC_MSG_ID" else CHAT_ID="-100$PRIVATE_CHANNEL" + MSG_ID="$PRIVATE_MSG_ID" fi while IFS=' ' read -r name value; do @@ -155,15 +181,35 @@ jobs: fi echo "publish=$PUBLISH" >> $GITHUB_OUTPUT + # Bring-up escape hatch for the private lane only: publish + # builds without platform signatures (the v2 envelope is still + # signed). The public lane never accepts it. + UNSIGNED=false + if [ "$ALLOW_UNSIGNED" = "1" ]; then + if [ "$CHANNEL" != "private" ]; then + echo "::error::CANARY_ALLOW_UNSIGNED is only honoured on the private lane." + exit 1 + fi + UNSIGNED=true + echo "::warning::CANARY_ALLOW_UNSIGNED=1: this run publishes binaries WITHOUT platform signatures." + fi + echo "unsigned=$UNSIGNED" >> $GITHUB_OUTPUT + COUNTER=1 PREVIOUS="" - if [ "$PUBLISH" = "true" ] && [ -n "$CHAT_ID" ] && [ "$CHAT_ID" != "-100" ]; then + if [ "$PUBLISH" = "true" ]; then + if [ -z "$CHAT_ID" ] || [ "$CHAT_ID" = "-100" ] || [ -z "$MSG_ID" ]; then + echo "::error::The lane's channel id and metadata message id are required when publishing." + exit 1 + fi # The bot is logged out of the cloud Bot API (a requirement # for local server use), so the pinned metadata is read # through the same self-built local server the publish job - # runs. - if [ -z "$BOT_API_IMAGE" ]; then - echo "::error::vars.CANARY_BOT_API_IMAGE is required when publishing." + # runs. Bots cannot fetch a message by id, the pinned message + # is the only way to read it back, so it MUST be the fixed + # metadata message the clients use. + if ! [[ "$BOT_API_IMAGE" =~ ^ghcr\.io/[A-Za-z0-9._/-]+@sha256:[0-9a-f]{64}$ ]]; then + echo "::error::vars.CANARY_BOT_API_IMAGE must be a digest-pinned ghcr.io reference." exit 1 fi echo "$GITHUB_TOKEN" | docker login ghcr.io \ @@ -171,21 +217,50 @@ jobs: docker run -d --name bot-api -p 8081:8081 \ -e TELEGRAM_API_ID -e TELEGRAM_API_HASH -e TELEGRAM_LOCAL=1 \ "$BOT_API_IMAGE" + READY=false for i in $(seq 1 30); do sleep 2 - if curl -sf "http://localhost:8081/bot$BOT_TOKEN/getMe" > /dev/null; then + if curl -sf "http://localhost:8081/bot$BOT_TOKEN/getMe" | jq -e '.ok == true' > /dev/null; then + READY=true break fi done - PINNED=$(curl -sf "http://localhost:8081/bot$BOT_TOKEN/getChat?chat_id=$CHAT_ID" \ - | jq -r '.result.pinned_message.text // empty') + if [ "$READY" != "true" ]; then + echo "::error::The local Bot API server did not come up." + exit 1 + fi + CHAT=$(curl -sf "http://localhost:8081/bot$BOT_TOKEN/getChat?chat_id=$CHAT_ID") docker rm -f bot-api > /dev/null - if [ -n "$PINNED" ]; then + if ! echo "$CHAT" | jq -e '.ok == true' > /dev/null; then + echo "::error::getChat failed: $(echo "$CHAT" | jq -r '.description // "no response"')" + exit 1 + fi + PINNED_ID=$(echo "$CHAT" | jq -r '.result.pinned_message.message_id // empty') + if [ "$PINNED_ID" != "$MSG_ID" ]; then + echo "::error::The pinned message is '$PINNED_ID', expected the metadata message $MSG_ID; re-pin it." + exit 1 + fi + PINNED=$(echo "$CHAT" | jq -r '.result.pinned_message.text // empty') + if ! echo "$PINNED" | jq -e 'type == "object"' > /dev/null; then + echo "::error::The metadata message is not a JSON object, seed it with {} for a first publish." + exit 1 + fi + # No entry for this lane yet means the first publish of the + # lane, anything else must continue the counter sequence. + if echo "$PINNED" | jq -e ".channels.\"canary-$CHANNEL\"" > /dev/null; then OLD_BASE=$(echo "$PINNED" | jq -r ".channels.\"canary-$CHANNEL\".base // 0") OLD_COUNTER=$(echo "$PINNED" | jq -r ".channels.\"canary-$CHANNEL\".counter // 0") PREVIOUS=$(echo "$PINNED" | jq -r ".channels.\"canary-$CHANNEL\".commit // empty") if [ "$OLD_BASE" = "$BASE" ]; then COUNTER=$((OLD_COUNTER + 1)) + elif [ "$OLD_BASE" -gt "$BASE" ]; then + echo "::error::The channel is at base $OLD_BASE, this branch builds $BASE; a lower base never publishes." + exit 1 + fi + if [ -n "$PREVIOUS" ] && git cat-file -e "$PREVIOUS^{commit}" 2>/dev/null \ + && ! git merge-base --is-ancestor "$PREVIOUS" HEAD; then + echo "::error::The published commit $PREVIOUS is not an ancestor of HEAD, refusing to publish a rollback as an update." + exit 1 fi fi fi @@ -195,10 +270,14 @@ jobs: windows: name: Windows x64 (${{ needs.version.outputs.channel }}) - runs-on: depot-windows-latest-16 + runs-on: depot-windows-latest-32 needs: version environment: canary + permissions: + contents: read + id-token: write + # win-arm64 is phase 2: add an arch matrix here together with the # windows-11-arm runner and the VS ARM64 build tools steps from # win.yml when the canary channels grow an arm feed. @@ -222,12 +301,41 @@ jobs: shell: bash run: echo "REPO_NAME=${GITHUB_REPOSITORY##*/}" >> $GITHUB_ENV + - name: Git auth through the TBuild symlink. + shell: bash + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + # actions/checkout persists its auth header in the repository's + # local git config, which does not take effect for a checkout + # path that goes through the TBuild directory symlink: on the + # public repository the fetch needs no credentials, on the + # private one it fails with "could not read Username". The same + # header passed through the environment applies to every git + # call of the checkout step regardless of the path. + BASIC=$(printf 'x-access-token:%s' "$GITHUB_TOKEN" | base64 -w0) + echo "::add-mask::$BASIC" + echo "GIT_AUTH_HEADER=AUTHORIZATION: basic $BASIC" >> $GITHUB_ENV + - name: Clone. - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + env: + GIT_CONFIG_COUNT: 1 + GIT_CONFIG_KEY_0: http.https://github.com/.extraheader + GIT_CONFIG_VALUE_0: ${{ env.GIT_AUTH_HEADER }} with: - submodules: recursive path: ${{ env.TBUILD }}\${{ env.REPO_NAME }} + - name: Submodules. + shell: bash + run: | + # Separate from the clone on purpose: with submodules enabled + # actions/checkout adds its own Authorization header on top of + # the one above and GitHub rejects the duplicate. The submodules + # are public repositories and need no credentials. + cd $TBUILD/$REPO_NAME + git submodule update --init --recursive --depth=1 + - name: Read canary configuration. shell: bash run: | @@ -239,7 +347,7 @@ jobs: else echo "CANARY_TAG=canarypriv" >> $GITHUB_ENV echo "CANARY_KEY_ID=${{ vars.CANARY_PRIVATE_SIGNING_KEY_ID }}" >> $GITHUB_ENV - echo "CANARY_DEFINES=-D TDESKTOP_UPDATE_CHANNEL=canary-private -D TDESKTOP_CANARY_PRIVATE_CHANNEL_ID=${{ secrets.CANARY_PRIVATE_CHANNEL_ID }} -D TDESKTOP_CANARY_METADATA_MSG_ID=${{ vars.CANARY_PRIVATE_METADATA_MSG_ID }}" >> $GITHUB_ENV + echo "CANARY_DEFINES=-D TDESKTOP_UPDATE_CHANNEL=canary-private -D TDESKTOP_CANARY_PRIVATE_CHANNEL_ID=${{ vars.CANARY_PRIVATE_CHANNEL_ID }} -D TDESKTOP_CANARY_METADATA_MSG_ID=${{ vars.CANARY_PRIVATE_METADATA_MSG_ID }}" >> $GITHUB_ENV fi - name: First set up. @@ -259,7 +367,10 @@ jobs: git config --global user.email "you@example.com" git config --global user.name "Sample" - - uses: Eden-CI/msvc-dev-cmd@master + # Pinned to a commit (master as of 2026-04-01): this job holds the + # platform-signing credentials and the OIDC token, a floating tag + # would let a third party run code next to them. + - uses: Eden-CI/msvc-dev-cmd@1bd71f95d6f3d1b2b3395b335cc63bcdc90cf223 name: Native Tools Command Prompt. with: arch: x64 @@ -272,14 +383,14 @@ jobs: nuget sources Add -Source https://api.nuget.org/v3/index.json & exit 0 - name: ThirdParty cache. - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ${{ env.TBUILD }}\ThirdParty key: ${{ runner.OS }}-${{ runner.arch }}-third-party-${{ env.CACHE_KEY }} restore-keys: ${{ runner.OS }}-${{ runner.arch }}-third-party- - name: Libraries cache. - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: | ${{ env.LibrariesPath }}\* @@ -291,7 +402,7 @@ jobs: restore-keys: ${{ runner.OS }}-x64-libs-v2rel- - name: Qt cache. - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: | ${{ env.LibrariesPath }}\[qQ]t[_-]* @@ -332,7 +443,6 @@ jobs: -D TDESKTOP_API_ID=${{ secrets.CANARY_API_ID }} ^ -D TDESKTOP_API_HASH=${{ secrets.CANARY_API_HASH }} ^ -D CMAKE_CONFIGURATION_TYPES=Release ^ - -D CMAKE_MSVC_DEBUG_INFORMATION_FORMAT= ^ -D DESKTOP_APP_SPECIAL_TARGET=win64 ^ -D DESKTOP_APP_ENABLE_LTO=ON ^ -D DESKTOP_APP_DISABLE_AUTOUPDATE=OFF ^ @@ -372,6 +482,11 @@ jobs: run: | cd $TBUILD/$REPO_NAME/out/Release if [ -z "$SM_API_KEY" ]; then + if [ "${{ needs.version.outputs.publish }}" = "true" ] \ + && [ "${{ needs.version.outputs.unsigned }}" != "true" ]; then + echo "::error::Publishing requires the KeyLocker secrets, unsigned binaries never ship." + exit 1 + fi echo "::warning::KeyLocker secrets absent, leaving binaries unsigned." exit 0 fi @@ -381,13 +496,15 @@ jobs: export SM_CLIENT_CERT_FILE=/tmp/keylocker.p12 smctl sign --keypair-alias "$SM_KEYPAIR_ALIAS" --input Telegram.exe smctl sign --keypair-alias "$SM_KEYPAIR_ALIAS" --input Updater.exe - # TODO(canary-infra): verify both signatures here (signtool or - # smctl) — the publish job can only re-check the portable's - # Telegram.exe, Updater.exe travels inside the update envelope. + # Both binaries are verified here, on the exact bytes that get + # packed: Updater.exe travels only inside the update envelope, + # the publish job can re-check just the portable's Telegram.exe. + signtool verify /pa /all Telegram.exe + signtool verify /pa /all Updater.exe - name: Azure login for update signing. if: needs.version.outputs.publish == 'true' - uses: azure/login@v2 + uses: azure/login@7184910d9eb2b1c5e48f7073824a90609bb9b6d6 # v2 with: client-id: ${{ secrets.AZURE_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TENANT_ID }} @@ -419,8 +536,11 @@ jobs: -keys-loc ../../Telegram/Resources/update \ -unsigned update-win-x64-$CANARY_TAG-$BASE-$CANARY_COUNTER.unsigned \ -embed-signatures $CANARY_KEY_ID:canary.sig + rm update-win-x64-$CANARY_TAG-$BASE-$CANARY_COUNTER.unsigned signing-input.bin canary.sig + UPDATE=update-win-x64-$CANARY_TAG-$BASE-$CANARY_COUNTER else echo "::warning::No publish secrets, keeping the unsigned envelope only." + UPDATE=update-win-x64-$CANARY_TAG-$BASE-$CANARY_COUNTER.unsigned fi # The first-install artifact is a portable-style archive (like @@ -433,15 +553,26 @@ jobs: cp Telegram.exe portable/Telegram/ (cd portable && 7z a -mx9 ../$PORTABLE Telegram/) - mkdir artifact - mv update-win-x64-$CANARY_TAG-* artifact/ - mv $PORTABLE artifact/ + mkdir -p artifact/update artifact/portable + mv $UPDATE artifact/update/ + mv $PORTABLE artifact/portable/ - - uses: actions/upload-artifact@v7 - name: Upload artifact. + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + name: Upload the update. with: name: canary-win64 - path: ${{ env.TBUILD }}\${{ env.REPO_NAME }}\out\Release\artifact\ + path: ${{ env.TBUILD }}\${{ env.REPO_NAME }}\out\Release\artifact\update\ + retention-days: ${{ needs.version.outputs.channel == 'public' && 90 || 1 }} + + # The portable is its own artifact so its link can be handed to a + # user in an issue; on the private lane it is transport to the + # publish job only. + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + name: Upload the portable. + with: + name: canary-win64-portable + path: ${{ env.TBUILD }}\${{ env.REPO_NAME }}\out\Release\artifact\portable\ + retention-days: ${{ needs.version.outputs.channel == 'public' && 90 || 1 }} macos: name: macOS universal (${{ needs.version.outputs.channel }}) @@ -449,6 +580,10 @@ jobs: needs: version environment: canary + permissions: + contents: read + id-token: write + env: PREPARE_PATH: "Telegram/build/prepare/prepare.py" @@ -457,7 +592,7 @@ jobs: run: echo "REPO_NAME=${GITHUB_REPOSITORY##*/}" >> $GITHUB_ENV - name: Clone. - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: submodules: recursive path: ${{ env.REPO_NAME }} @@ -472,7 +607,7 @@ jobs: else echo "CANARY_TAG=canarypriv" >> $GITHUB_ENV echo "CANARY_KEY_ID=${{ vars.CANARY_PRIVATE_SIGNING_KEY_ID }}" >> $GITHUB_ENV - echo "CANARY_DEFINES=-D TDESKTOP_UPDATE_CHANNEL=canary-private -D TDESKTOP_CANARY_PRIVATE_CHANNEL_ID=${{ secrets.CANARY_PRIVATE_CHANNEL_ID }} -D TDESKTOP_CANARY_METADATA_MSG_ID=${{ vars.CANARY_PRIVATE_METADATA_MSG_ID }}" >> $GITHUB_ENV + echo "CANARY_DEFINES=-D TDESKTOP_UPDATE_CHANNEL=canary-private -D TDESKTOP_CANARY_PRIVATE_CHANNEL_ID=${{ vars.CANARY_PRIVATE_CHANNEL_ID }} -D TDESKTOP_CANARY_METADATA_MSG_ID=${{ vars.CANARY_PRIVATE_METADATA_MSG_ID }}" >> $GITHUB_ENV fi - name: First set up. @@ -488,7 +623,7 @@ jobs: sudo xcode-select -s /Applications/Xcode.app/Contents/Developer - name: Libraries cache. - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: | Libraries @@ -522,8 +657,7 @@ jobs: ./configure.sh \ -D CMAKE_CONFIGURATION_TYPES=Release \ -D CMAKE_XCODE_ATTRIBUTE_CODE_SIGNING_ALLOWED=NO \ - -D CMAKE_POLICY_DEFAULT_CMP0069=NEW \ - -D CMAKE_INTERPROCEDURAL_OPTIMIZATION_RELEASE=ON \ + -D DESKTOP_APP_ENABLE_LTO=ON \ -D CMAKE_OSX_ARCHITECTURES="x86_64;arm64" \ -D TDESKTOP_API_ID=${{ secrets.CANARY_API_ID }} \ -D TDESKTOP_API_HASH=${{ secrets.CANARY_API_HASH }} \ @@ -587,6 +721,11 @@ jobs: run: | cd $REPO_NAME/out/Release if [ -z "$CERTIFICATE_P12_B64" ]; then + if [ "${{ needs.version.outputs.publish }}" = "true" ] \ + && [ "${{ needs.version.outputs.unsigned }}" != "true" ]; then + echo "::error::Publishing requires the signing certificate, unsigned apps never ship." + exit 1 + fi echo "::warning::No signing certificate, leaving the apps unsigned." exit 0 fi @@ -611,12 +750,14 @@ jobs: --team-id "$NOTARY_TEAM_ID" \ --password "$NOTARY_PASSWORD" xcrun stapler staple "$BUNDLE" + xcrun stapler validate "$BUNDLE" + spctl --assess --type execute --verbose=2 "$BUNDLE" rm notarize.zip done - name: Azure login for update signing. if: needs.version.outputs.publish == 'true' - uses: azure/login@v2 + uses: azure/login@7184910d9eb2b1c5e48f7073824a90609bb9b6d6 # v2 with: client-id: ${{ secrets.AZURE_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TENANT_ID }} @@ -654,10 +795,12 @@ jobs: -keys-loc ../../../Telegram/Resources/update \ -unsigned update-mac-$SHORT-$CANARY_TAG-$BASE-$CANARY_COUNTER.unsigned \ -embed-signatures $CANARY_KEY_ID:canary.sig + rm update-mac-$SHORT-$CANARY_TAG-$BASE-$CANARY_COUNTER.unsigned signing-input.bin canary.sig + mv update-mac-$SHORT-$CANARY_TAG-$BASE-$CANARY_COUNTER ../ else echo "::warning::No publish secrets, keeping the unsigned envelope only." + mv update-mac-$SHORT-$CANARY_TAG-$BASE-$CANARY_COUNTER.unsigned ../ fi - mv update-mac-$SHORT-$CANARY_TAG-* ../ cd .. rm -rf update_pack done @@ -670,28 +813,40 @@ jobs: cp -R Telegram.app portable/Telegram/ (cd portable && zip -q -r ../$PORTABLE Telegram) - mkdir artifact - mv update-mac-*-$CANARY_TAG-* artifact/ - mv $PORTABLE artifact/ + mkdir -p artifact/update artifact/portable + mv update-mac-*-$CANARY_TAG-* artifact/update/ + mv $PORTABLE artifact/portable/ - - uses: actions/upload-artifact@v7 - name: Upload artifact. + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + name: Upload the updates. with: name: canary-mac - path: ${{ env.REPO_NAME }}/out/Release/artifact/ + path: ${{ env.REPO_NAME }}/out/Release/artifact/update/ + retention-days: ${{ needs.version.outputs.channel == 'public' && 90 || 1 }} + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + name: Upload the portable. + with: + name: canary-mac-portable + path: ${{ env.REPO_NAME }}/out/Release/artifact/portable/ + retention-days: ${{ needs.version.outputs.channel == 'public' && 90 || 1 }} linux: name: Linux x64 (${{ needs.version.outputs.channel }}) - runs-on: depot-ubuntu-latest-16 + runs-on: depot-ubuntu-latest-32 needs: version environment: canary + permissions: + contents: read + id-token: write + env: IMAGE_TAG: tdesktop:centos_env steps: - name: Clone. - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: submodules: recursive @@ -705,16 +860,18 @@ jobs: else echo "CANARY_TAG=canarypriv" >> $GITHUB_ENV echo "CANARY_KEY_ID=${{ vars.CANARY_PRIVATE_SIGNING_KEY_ID }}" >> $GITHUB_ENV - echo "CANARY_DEFINES=-D TDESKTOP_UPDATE_CHANNEL=canary-private -D TDESKTOP_CANARY_PRIVATE_CHANNEL_ID=${{ secrets.CANARY_PRIVATE_CHANNEL_ID }} -D TDESKTOP_CANARY_METADATA_MSG_ID=${{ vars.CANARY_PRIVATE_METADATA_MSG_ID }}" >> $GITHUB_ENV + echo "CANARY_DEFINES=-D TDESKTOP_UPDATE_CHANNEL=canary-private -D TDESKTOP_CANARY_PRIVATE_CHANNEL_ID=${{ vars.CANARY_PRIVATE_CHANNEL_ID }} -D TDESKTOP_CANARY_METADATA_MSG_ID=${{ vars.CANARY_PRIVATE_METADATA_MSG_ID }}" >> $GITHUB_ENV fi - name: First set up. run: | + # The template only needs Jinja2: the distro package replaces + # the curl-piped poetry installer linux.yml uses, so no code + # from an unpinned URL runs in a job that later signs. sudo apt update - curl -sSL https://install.python-poetry.org | python3 - + sudo apt install -y python3-jinja2 cd Telegram/build/docker/centos_env - poetry install - DOCKERFILE=$(DEBUG= poetry run gen_dockerfile) + DOCKERFILE=$(DEBUG= python3 gen_dockerfile.py) echo "$DOCKERFILE" > Dockerfile rm -rf __pycache__ @@ -725,11 +882,11 @@ jobs: - name: Set up Docker Buildx. id: setup-buildx - uses: docker/setup-buildx-action@v4 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4 - name: Libraries cache. id: cache-libs - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: | ${{ runner.temp }}/.buildx-cache @@ -746,7 +903,7 @@ jobs: skip-extraction: ${{ steps.cache-libs.outputs.cache-hit }} - name: Libraries. - uses: docker/build-push-action@v7 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 with: context: Telegram/build/docker/centos_env load: true @@ -781,7 +938,7 @@ jobs: $IMAGE_TAG \ /usr/src/tdesktop/Telegram/build/docker/centos_env/build.sh \ -D CMAKE_CONFIGURATION_TYPES=Release \ - -D CMAKE_INTERPROCEDURAL_OPTIMIZATION_RELEASE=ON \ + -D DESKTOP_APP_ENABLE_LTO=ON \ -D TDESKTOP_API_ID=${{ secrets.CANARY_API_ID }} \ -D TDESKTOP_API_HASH=${{ secrets.CANARY_API_HASH }} \ -D DESKTOP_APP_SPECIAL_TARGET=linux \ @@ -804,9 +961,17 @@ jobs: # TODO(canary-infra): upload symbols/ to R2 (see the Windows job). ../../Telegram/build/minidebug.sh Telegram + - name: Verify the update trust chain. + run: | + # The focused verification test and the Packer round-trip run + # against the exact binaries built for this canary, including + # the committed trust files against the pinned root. + out/Release/test_update_verify + Telegram/build/canary_test_fixtures.sh out/Release/Packer "$RUNNER_TEMP/fixtures" + - name: Azure login for update signing. if: needs.version.outputs.publish == 'true' - uses: azure/login@v2 + uses: azure/login@7184910d9eb2b1c5e48f7073824a90609bb9b6d6 # v2 with: client-id: ${{ secrets.AZURE_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TENANT_ID }} @@ -831,8 +996,11 @@ jobs: -keys-loc ../../Telegram/Resources/update \ -unsigned update-linux-x64-$CANARY_TAG-${{ needs.version.outputs.base }}-$CANARY_COUNTER.unsigned \ -embed-signatures $CANARY_KEY_ID:canary.sig + rm update-linux-x64-$CANARY_TAG-${{ needs.version.outputs.base }}-$CANARY_COUNTER.unsigned signing-input.bin canary.sig + UPDATE=update-linux-x64-$CANARY_TAG-${{ needs.version.outputs.base }}-$CANARY_COUNTER else echo "::warning::No publish secrets, keeping the unsigned envelope only." + UPDATE=update-linux-x64-$CANARY_TAG-${{ needs.version.outputs.base }}-$CANARY_COUNTER.unsigned fi # Portable-style first-install archive, tdata forced next to @@ -843,23 +1011,35 @@ jobs: cp Telegram Updater portable/Telegram/ tar -cJf $PORTABLE -C portable Telegram - mkdir artifact - mv update-linux-x64-$CANARY_TAG-* artifact/ - mv $PORTABLE artifact/ + mkdir -p artifact/update artifact/portable + mv $UPDATE artifact/update/ + mv $PORTABLE artifact/portable/ - - uses: actions/upload-artifact@v7 - name: Upload artifact. + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + name: Upload the update. with: name: canary-linux - path: out/Release/artifact/ + path: out/Release/artifact/update/ + retention-days: ${{ needs.version.outputs.channel == 'public' && 90 || 1 }} + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + name: Upload the portable. + with: + name: canary-linux-portable + path: out/Release/artifact/portable/ + retention-days: ${{ needs.version.outputs.channel == 'public' && 90 || 1 }} publish: name: Publish (${{ needs.version.outputs.channel }}) - runs-on: ubuntu-latest + runs-on: depot-ubuntu-latest needs: [version, windows, macos, linux] if: needs.version.outputs.publish == 'true' environment: canary + permissions: + contents: read + packages: read + services: # Built from a pinned tdlib/telegram-bot-api ref by the # canary-bot-api.yml workflow and referenced by digest: this @@ -883,25 +1063,40 @@ jobs: steps: - name: Clone. - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 - name: Download artifacts. - uses: actions/download-artifact@v7 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: path: artifacts + - name: Wait for the Bot API server. + run: | + # GitHub only waits for the container process, not for tdlib to + # accept requests. + for i in $(seq 1 30); do + if curl -sf "$BOT_API/bot$BOT_TOKEN/getMe" | jq -e '.ok == true' > /dev/null; then + exit 0 + fi + sleep 2 + done + echo "::error::The local Bot API server did not come up." + exit 1 + - name: Verify platform signatures. + if: needs.version.outputs.unsigned != 'true' run: | # Publishing unsigned binaries is never allowed: this is a hard # gate, not a warning. The binaries inside the update envelopes - # were verified right after signing in the build jobs; here the + # were verified right after signing in the build jobs (signtool + # on Windows, codesign + stapler + spctl on macOS); here the # portable archives are re-checked as the publish-side witness. sudo apt-get update && sudo apt-get install -y osslsigncode FAILED=0 - unzip -q artifacts/canary-win64/portable-win-x64-*.zip -d /tmp/winapp + unzip -q artifacts/canary-win64-portable/portable-win-x64-*.zip -d /tmp/winapp if ! osslsigncode verify /tmp/winapp/Telegram/Telegram.exe; then echo "::error::The portable Telegram.exe is not Authenticode-signed." FAILED=1 @@ -910,7 +1105,7 @@ jobs: # TODO(canary-infra): pin an apple-codesign (rcodesign) release # for full macOS signature+staple verification on Linux: # rcodesign verify /tmp/macapp/Telegram/Telegram.app - unzip -q artifacts/canary-mac/portable-mac-universal-*.zip -d /tmp/macapp + unzip -q artifacts/canary-mac-portable/portable-mac-universal-*.zip -d /tmp/macapp if [ ! -d "/tmp/macapp/Telegram/Telegram.app/Contents/_CodeSignature" ]; then echo "::error::The portable Telegram.app has no code signature." FAILED=1 @@ -921,8 +1116,8 @@ jobs: - name: Publish the channel. env: CHANNEL: ${{ needs.version.outputs.channel }} - PUBLIC_CHANNEL: ${{ secrets.CANARY_PUBLIC_CHANNEL_ID }} - PRIVATE_CHANNEL: ${{ secrets.CANARY_PRIVATE_CHANNEL_ID }} + PUBLIC_CHANNEL: ${{ vars.CANARY_PUBLIC_CHANNEL_ID }} + PRIVATE_CHANNEL: ${{ vars.CANARY_PRIVATE_CHANNEL_ID }} PUBLIC_MSG_ID: ${{ vars.CANARY_METADATA_MSG_ID }} PRIVATE_MSG_ID: ${{ vars.CANARY_PRIVATE_METADATA_MSG_ID }} run: | @@ -933,11 +1128,29 @@ jobs: CHAT_ID="-100$PRIVATE_CHANNEL" MSG_ID="$PRIVATE_MSG_ID" fi + BASE="${{ needs.version.outputs.base }}" COUNTER="${{ needs.version.outputs.counter }}" PREVIOUS="${{ needs.version.outputs.previous }}" + TAG=canarypub + if [ "$CHANNEL" = "private" ]; then TAG=canarypriv; fi + + call() { + local METHOD="$1" + shift + local RESPONSE + RESPONSE=$(curl -sf "$BOT_API/bot$BOT_TOKEN/$METHOD" "$@") + if ! echo "$RESPONSE" | jq -e '.ok == true' > /dev/null; then + echo "::error::$METHOD failed: $(echo "$RESPONSE" | jq -r '.description // "no response"')" + return 1 + fi + echo "$RESPONSE" + } CAPTION=$({ echo "Canary #$COUNTER · ${{ needs.version.outputs.commit }}" + if [ "${{ needs.version.outputs.unsigned }}" = "true" ]; then + echo "UNSIGNED test build: no Authenticode / notarization." + fi echo "" if [ -n "$PREVIOUS" ] && git cat-file -e "$PREVIOUS^{commit}" 2>/dev/null; then git log --no-merges --pretty=format:'• %s' "$PREVIOUS..HEAD" | head -20 @@ -946,42 +1159,56 @@ jobs: fi } | head -c 1000) + # Exact names only: the build jobs delete the .unsigned + # intermediates, a publish run never guesses from a glob. declare -A FILES - FILES[win64]=$(ls artifacts/canary-win64/update-win-x64-* | head -1) - FILES[mac]=$(ls artifacts/canary-mac/update-mac-x64-* | head -1) - FILES[armac]=$(ls artifacts/canary-mac/update-mac-arm-* | head -1) - FILES[linux]=$(ls artifacts/canary-linux/update-linux-x64-* | head -1) + FILES[win64]=artifacts/canary-win64/update-win-x64-$TAG-$BASE-$COUNTER + FILES[mac]=artifacts/canary-mac/update-mac-x64-$TAG-$BASE-$COUNTER + FILES[armac]=artifacts/canary-mac/update-mac-arm-$TAG-$BASE-$COUNTER + FILES[linux]=artifacts/canary-linux/update-linux-x64-$TAG-$BASE-$COUNTER + declare -A PORTABLES + PORTABLES[win64]=artifacts/canary-win64-portable/portable-win-x64-$TAG-$BASE-$COUNTER.zip + PORTABLES[mac]=artifacts/canary-mac-portable/portable-mac-universal-$TAG-$BASE-$COUNTER.zip + PORTABLES[linux]=artifacts/canary-linux-portable/portable-linux-x64-$TAG-$BASE-$COUNTER.tar.xz + for FILE in "${FILES[@]}" "${PORTABLES[@]}"; do + if [ ! -f "$FILE" ]; then + echo "::error::$FILE is missing, refusing to publish." + exit 1 + fi + done + + # The metadata is read back first so that entries other lanes + # or a human wrote (the dormancy-rescue 'stable' entry of a + # retired lane) survive: only this lane's entry and the trust + # material are replaced. + CHAT=$(call getChat -F chat_id="$CHAT_ID") + PINNED_ID=$(echo "$CHAT" | jq -r '.result.pinned_message.message_id // empty') + if [ "$PINNED_ID" != "$MSG_ID" ]; then + echo "::error::The pinned message is '$PINNED_ID', expected the metadata message $MSG_ID." + exit 1 + fi + CURRENT=$(echo "$CHAT" | jq -r '.result.pinned_message.text // empty') + if ! echo "$CURRENT" | jq -e 'type == "object"' > /dev/null; then + echo "::error::The metadata message is not a JSON object." + exit 1 + fi declare -A POSTS for PLATFORM in win64 mac armac linux; do - FILE=${FILES[$PLATFORM]} - if [ -z "$FILE" ] || [[ "$FILE" == *.unsigned ]]; then - echo "::error::$PLATFORM update is missing or unsigned, refusing to publish." - exit 1 - fi - RESPONSE=$(curl -sf "$BOT_API/bot$BOT_TOKEN/sendDocument" \ + RESPONSE=$(call sendDocument \ -F chat_id="$CHAT_ID" \ - -F document=@"$FILE" \ + -F document=@"${FILES[$PLATFORM]}" \ -F caption="$CAPTION") - POSTS[$PLATFORM]=$(echo "$RESPONSE" | jq -r '.result.message_id') + POSTS[$PLATFORM]=$(echo "$RESPONSE" | jq -e -r '.result.message_id | numbers') echo "$PLATFORM -> post ${POSTS[$PLATFORM]}" done # The portable archives are for first installs, posted as plain # documents and not referenced from the metadata. - declare -A PORTABLES - PORTABLES[win64]=$(ls artifacts/canary-win64/portable-win-x64-* | head -1) - PORTABLES[mac]=$(ls artifacts/canary-mac/portable-mac-universal-* | head -1) - PORTABLES[linux]=$(ls artifacts/canary-linux/portable-linux-x64-* | head -1) for PLATFORM in win64 mac linux; do - PORTABLE=${PORTABLES[$PLATFORM]} - if [ -z "$PORTABLE" ]; then - echo "::error::$PLATFORM portable archive is missing." - exit 1 - fi - curl -sf "$BOT_API/bot$BOT_TOKEN/sendDocument" \ + call sendDocument \ -F chat_id="$CHAT_ID" \ - -F document=@"$PORTABLE" \ + -F document=@"${PORTABLES[$PLATFORM]}" \ -F caption="Portable, $CAPTION" > /dev/null echo "$PLATFORM portable posted." done @@ -989,38 +1216,39 @@ jobs: MANIFEST_B64=$(base64 -w0 Telegram/Resources/update/manifest.min.json) MANIFEST_SIG_B64=$(base64 -w0 Telegram/Resources/update/manifest.sig) - NEW=$(jq -n \ + NEW=$(echo "$CURRENT" | jq \ --arg manifest "$MANIFEST_B64" \ --arg manifest_sig "$MANIFEST_SIG_B64" \ --arg commit "${{ needs.version.outputs.commit }}" \ - --argjson base "${{ needs.version.outputs.base }}" \ + --argjson base "$BASE" \ --argjson counter "$COUNTER" \ --argjson win64 "${POSTS[win64]}" \ --argjson mac "${POSTS[mac]}" \ --argjson armac "${POSTS[armac]}" \ --argjson linux "${POSTS[linux]}" \ - "{ + ". + { format: 1, manifest: \$manifest, - manifest_sig: \$manifest_sig, - channels: { - \"canary-$CHANNEL\": { - base: \$base, - counter: \$counter, - commit: \$commit, - posts: { - win64: \$win64, - mac: \$mac, - armac: \$armac, - linux: \$linux - } + manifest_sig: \$manifest_sig + } | .channels = ((.channels // {}) + { + \"canary-$CHANNEL\": { + base: \$base, + counter: \$counter, + commit: \$commit, + posts: { + win64: \$win64, + mac: \$mac, + armac: \$armac, + linux: \$linux } } - }") - curl -sf "$BOT_API/bot$BOT_TOKEN/editMessageText" \ + })") + if [ "${#NEW}" -gt 4096 ]; then + echo "::error::The metadata message would exceed 4096 characters, prune the manifest or old entries." + exit 1 + fi + call editMessageText \ -F chat_id="$CHAT_ID" \ -F message_id="$MSG_ID" \ - --form-string text="$NEW" || { - echo "::error::Could not edit the metadata message $MSG_ID." - exit 1 - } + --form-string text="$NEW" > /dev/null + echo "Metadata message $MSG_ID updated to canary-$CHANNEL $BASE #$COUNTER." diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml index b7608aee78..a9e240248f 100644 --- a/.github/workflows/linux.yml +++ b/.github/workflows/linux.yml @@ -2,6 +2,9 @@ name: Linux. on: push: + # Pushes to canary run only canary.yml, see there. + branches-ignore: + - canary paths-ignore: - 'docs/**' - '**.md' diff --git a/.github/workflows/mac.yml b/.github/workflows/mac.yml index 3db4420f29..7755eb7170 100644 --- a/.github/workflows/mac.yml +++ b/.github/workflows/mac.yml @@ -2,6 +2,9 @@ name: MacOS. on: push: + # Pushes to canary run only canary.yml, see there. + branches-ignore: + - canary paths-ignore: - 'docs/**' - '**.md' diff --git a/.github/workflows/mac_packaged.yml b/.github/workflows/mac_packaged.yml index 08a675c484..489971241f 100644 --- a/.github/workflows/mac_packaged.yml +++ b/.github/workflows/mac_packaged.yml @@ -2,6 +2,9 @@ name: MacOS Packaged. on: push: + # Pushes to canary run only canary.yml, see there. + branches-ignore: + - canary paths-ignore: - 'docs/**' - '**.md' diff --git a/.github/workflows/snap.yml b/.github/workflows/snap.yml index b6f7ed6348..d0943a3c1c 100644 --- a/.github/workflows/snap.yml +++ b/.github/workflows/snap.yml @@ -2,6 +2,9 @@ name: Snap. on: push: + # Pushes to canary run only canary.yml, see there. + branches-ignore: + - canary paths-ignore: - 'docs/**' - '**.md' diff --git a/.github/workflows/win.yml b/.github/workflows/win.yml index ea80ddc023..a3842fcb10 100644 --- a/.github/workflows/win.yml +++ b/.github/workflows/win.yml @@ -2,6 +2,9 @@ name: Windows. on: push: + # Pushes to canary run only canary.yml, see there. + branches-ignore: + - canary paths-ignore: - 'docs/**' - '**.md' diff --git a/Telegram/CMakeLists.txt b/Telegram/CMakeLists.txt index 8144fd7fb8..1cacfb354d 100644 --- a/Telegram/CMakeLists.txt +++ b/Telegram/CMakeLists.txt @@ -56,6 +56,10 @@ endif() include(cmake/generate_update_keys.cmake) generate_update_keys(Telegram ${res_loc}/update) +if (DESKTOP_APP_TEST_APPS OR DESKTOP_APP_SPECIAL_TARGET) + include(cmake/test_update_verify.cmake) +endif() + set_target_properties(Telegram PROPERTIES AUTOMOC ON) target_link_libraries(Telegram diff --git a/Telegram/SourceFiles/_other/packer.cpp b/Telegram/SourceFiles/_other/packer.cpp index f3476e6c85..bde6c89bcd 100644 --- a/Telegram/SourceFiles/_other/packer.cpp +++ b/Telegram/SourceFiles/_other/packer.cpp @@ -27,8 +27,7 @@ QString V2LocalKeyId; QString V2SigningInputFile; QString V2UnsignedFile; std::vector> V2EmbedSignatures; -QString V2Os; -QString V2Arch; +Core::Updates::Target V2Target; const char *PublicKey = "\ -----BEGIN RSA PUBLIC KEY-----\n\ @@ -201,8 +200,8 @@ void AppendLeU64(QByteArray &to, quint64 value) { quint32 base, quint32 counter) { auto result = QString("update-%1-%2-%3-%4" - ).arg(V2Os - ).arg(V2Arch + ).arg(QString::fromLatin1(Core::Updates::OsName(V2Target.os)) + ).arg(QString::fromLatin1(Core::Updates::ArchName(V2Target.arch)) ).arg(V2ChannelTag(channel) ).arg(base); if (counter) { @@ -239,8 +238,47 @@ struct V2Keys { return V2Keys{ rootPem, std::move(*parsed) }; } +// The build-time expiry watchdog: the client never rejects an expired +// manifest (revocation is the kill switch), so this is the only place +// the approaching dates are surfaced, ahead of the moment a channel key +// expires and packing starts failing verification. +void ReportExpiry(const Core::Updates::Manifest &manifest, Channel channel) { + constexpr auto kWarnDays = 90; + const auto now = QDateTime::currentSecsSinceEpoch(); + const auto report = [&](const QByteArray &what, qint64 expires) { + if (!expires) { + return; + } + const auto days = (expires - now) / (24 * 60 * 60); + if (days < 0) { + cout << "WARNING: " << what.constData() << " expired " + << -days << " days ago!\n"; + } else if (days < kWarnDays) { + cout << "WARNING: " << what.constData() << " expires in " + << days << " days!\n"; + } else { + cout << what.constData() << " expires in " << days << " days.\n"; + } + }; + report("The manifest", manifest.expires); + const auto i = manifest.channels.find(Core::Updates::ChannelName(channel)); + if (i == manifest.channels.end()) { + return; + } + for (const auto &group : i->second) { + for (const auto &id : group) { + for (const auto &key : manifest.keys) { + if (key.id == id) { + report("Key '" + id + "'", key.expires); + } + } + } + } +} + [[nodiscard]] QByteArray BuildV2Envelope( Channel channel, + Core::Updates::Target target, quint64 version, qint64 created, const QByteArray &manifest, @@ -251,6 +289,8 @@ struct V2Keys { result.append(Core::Updates::kEnvelopeMagic, 4); AppendLeU32(result, Core::Updates::kEnvelopeFormat); result.append(char(uchar(channel))); + result.append(char(uchar(target.os))); + result.append(char(uchar(target.arch))); AppendLeU64(result, version); AppendLeU64(result, quint64(created)); AppendLeU32(result, quint32(manifest.size())); @@ -313,12 +353,14 @@ struct V2Keys { [[nodiscard]] bool VerifyPackedV2( const QByteArray &fileBytes, const V2Keys &keys, - Channel channel) { + Channel channel, + Core::Updates::Target target) { auto error = QString(); const auto verified = Core::Updates::VerifyUpdate( fileBytes, channel, true, // betaSet + target, 0, // runningVersion keys.manifest, keys.rootPublicKeyPem, @@ -350,6 +392,7 @@ int WriteV2Update(const QByteArray &payload, quint32 baseVersion) { return -1; } const auto channel = *V2Channel; + ReportExpiry(keys->manifest, channel); const auto version = Core::Updates::MakeUpdateVersion( baseVersion, V2Counter); @@ -358,6 +401,7 @@ int WriteV2Update(const QByteArray &payload, quint32 baseVersion) { auto signatures = std::vector>(); const auto unsigned_ = BuildV2Envelope( channel, + V2Target, version, QDateTime::currentSecsSinceEpoch(), keys->manifest.bytes, @@ -400,13 +444,14 @@ int WriteV2Update(const QByteArray &payload, quint32 baseVersion) { const auto result = BuildV2Envelope( channel, + V2Target, version, envelope->created, keys->manifest.bytes, keys->manifest.signature, signatures, payload); - if (!VerifyPackedV2(result, *keys, channel) + if (!VerifyPackedV2(result, *keys, channel, V2Target) || !WriteWholeFile(name, result)) { return -1; } @@ -444,6 +489,7 @@ int EmbedV2Signatures() { cout << "The -channel param does not match the unsigned update!\n"; return -1; } + ReportExpiry(keys->manifest, envelope->channel); auto signatures = std::vector>(); for (const auto &[keyId, file] : V2EmbedSignatures) { @@ -483,6 +529,7 @@ int EmbedV2Signatures() { const auto result = BuildV2Envelope( envelope->channel, + envelope->target, envelope->version, envelope->created, envelope->manifest, @@ -491,7 +538,7 @@ int EmbedV2Signatures() { envelope->payload); const auto name = V2UnsignedFile.left( V2UnsignedFile.size() - int(strlen(".unsigned"))); - if (!VerifyPackedV2(result, *keys, envelope->channel) + if (!VerifyPackedV2(result, *keys, envelope->channel, envelope->target) || !WriteWholeFile(name, result)) { return -1; } @@ -603,20 +650,24 @@ int main(int argc, char *argv[]) return writeAlphaKey(); } + { + using Core::Updates::Os; + using Core::Updates::Arch; #ifdef Q_OS_WIN - V2Os = QString("win"); - V2Arch = targetwinarm - ? QString("arm") - : targetwin64 - ? QString("x64") - : QString("x86"); + V2Target.os = Os::Windows; + V2Target.arch = targetwinarm + ? Arch::Arm + : targetwin64 + ? Arch::X64 + : Arch::X86; #elif defined Q_OS_MAC - V2Os = QString("mac"); - V2Arch = targetarmac ? QString("arm") : QString("x64"); + V2Target.os = Os::Mac; + V2Target.arch = targetarmac ? Arch::Arm : Arch::X64; #else - V2Os = QString("linux"); - V2Arch = QString("x64"); + V2Target.os = Os::Linux; + V2Target.arch = Arch::X64; #endif + } if (!V2UnsignedFile.isEmpty()) { return EmbedV2Signatures(); @@ -632,6 +683,9 @@ int main(int argc, char *argv[]) } else if (canary != (V2Counter > 0)) { cout << "Canary channels require a positive -counter, others require none!\n"; return -1; + } else if (!V2EmbedSignatures.empty()) { + cout << "The -embed-signatures param requires -unsigned!\n"; + return -1; } else if (V2SigningInputFile.isEmpty() && (V2LocalKeyFile.isEmpty() || V2LocalKeyId.isEmpty())) { cout << "Either -emit-signing-input or -local-key with -local-key-id is required!\n"; @@ -859,14 +913,16 @@ int main(int argc, char *argv[]) stream.next_out = (uint8_t*)resultCheck.data(); res = lzma_code(&stream, LZMA_FINISH); - if (stream.avail_in) { - cout << "Error in decompression, " << stream.avail_in << " bytes left in _in of " << compressedLen << " whole.\n"; + const auto availIn = stream.avail_in; + const auto availOut = stream.avail_out; + lzma_end(&stream); + if (availIn) { + cout << "Error in decompression, " << availIn << " bytes left in _in of " << compressedLen << " whole.\n"; return -1; - } else if (stream.avail_out) { - cout << "Error in decompression, " << stream.avail_out << " bytes free left in _out of " << resultLen << " whole.\n"; + } else if (availOut) { + cout << "Error in decompression, " << availOut << " bytes free left in _out of " << resultLen << " whole.\n"; return -1; } - lzma_end(&stream); if (res != LZMA_OK && res != LZMA_STREAM_END) { const char *msg; switch (res) { diff --git a/Telegram/SourceFiles/core/update_checker.cpp b/Telegram/SourceFiles/core/update_checker.cpp index 039a239367..87ed0fad7b 100644 --- a/Telegram/SourceFiles/core/update_checker.cpp +++ b/Telegram/SourceFiles/core/update_checker.cpp @@ -252,6 +252,7 @@ private: void gotCanaryMessage( const MTPInputChannel &channel, const MTPmessages_Messages &result, + int messageId, bool fallbackToPinned); void parseCanaryMetadata( const MTPInputChannel &channel, @@ -595,6 +596,16 @@ QString ExtractFilename(const QString &url) { [[nodiscard]] bool UnpackUpdateV2( const QString &filepath, const QByteArray &content) { + // The expected target follows the feed key, not the build: an x64 + // build under Rosetta asks for armac and must accept that package. + const auto target = Updates::TargetFromPlatformKey( + Platform::AutoUpdateKey().toLatin1()); + if (!target) { + LOG(("Update Error: No v2 target for platform key '%1'." + ).arg(Platform::AutoUpdateKey())); + return false; + } + // The full verification happens before any decompression, so no // unauthenticated bytes ever reach the LZMA or QDataStream parsers. auto error = QString(); @@ -602,6 +613,7 @@ QString ExtractFilename(const QString &url) { content, BuildUpdateChannel, AppBetaVersion || cInstallBetaVersion(), + *target, RunningUpdateVersion(), HeldManifest(), Updates::RootPublicKeyPem(), @@ -690,6 +702,9 @@ bool UnpackUpdate(const QString &filepath) { if (!input.open(QIODevice::ReadOnly)) { LOG(("Update Error: cant read updates file!")); return false; + } else if (input.size() > Loader::kMaxFileSize) { + LOG(("Update Error: updates file is too large: %1").arg(input.size())); + return false; } #if defined Q_OS_WIN && !defined TDESKTOP_USE_PACKAGED // use Lzma SDK for win @@ -703,6 +718,12 @@ bool UnpackUpdate(const QString &filepath) { if (Updates::IsV2UpdateFile(compressed)) { return UnpackUpdateV2(filepath, compressed); + } else if (BuildIsCanary) { + // The channel policy lives in the v2 envelope only, a classical + // RSA package has no channel and would let any official v1 file + // posted to the canary channel jump a canary off its lane. + LOG(("Update Error: canary builds accept only v2 updates.")); + return false; } int32 compressedLen = compressed.size() - hSize; @@ -1182,7 +1203,7 @@ void HttpLoaderActor::gotMetaData() { if (QString::fromUtf8(pair.first).toLower() == "content-range") { const auto m = QRegularExpression(u"/(\\d+)([^\\d]|$)"_q).match(QString::fromUtf8(pair.second)); if (m.hasMatch()) { - _parent->writeChunk({}, m.captured(1).toInt()); + _parent->writeChunk({}, m.captured(1).toLongLong()); } } } @@ -1405,7 +1426,7 @@ void MtpChecker::requestCanaryMetadata( 1, MTP_inputMessageID(MTP_int(messageId)))), [=](const MTPmessages_Messages &result) { - gotCanaryMessage(channel, result, fallbackToPinned); + gotCanaryMessage(channel, result, messageId, fallbackToPinned); }, failHandler()); } @@ -1433,8 +1454,9 @@ void MtpChecker::requestCanaryPinnedFallback(const MTPInputChannel &channel) { void MtpChecker::gotCanaryMessage( const MTPInputChannel &channel, const MTPmessages_Messages &result, + int messageId, bool fallbackToPinned) { - const auto message = MTP::GetMessagesElement(result); + const auto message = MTP::GetMessagesElement(result, messageId); if (!message || message->type() != mtpc_message) { if (fallbackToPinned) { requestCanaryPinnedFallback(channel); @@ -1459,6 +1481,11 @@ void MtpChecker::parseCanaryMetadata( return; } const auto object = document.object(); + if (object.value(u"format"_q).toDouble() != 1.) { + LOG(("Update Error: Unknown canary metadata format.")); + fail(); + return; + } const auto decode = [](const QJsonValue &value) { if (!value.isString()) { @@ -1472,11 +1499,15 @@ void MtpChecker::parseCanaryMetadata( const auto manifest = decode(object.value(u"manifest"_q)); const auto manifestSig = decode(object.value(u"manifest_sig"_q)); if (!manifest.isEmpty() && !manifestSig.isEmpty()) { + auto error = QString(); if (auto parsed = Updates::ParseVerifiedManifest( manifest, manifestSig, - Updates::RootPublicKeyPem())) { + Updates::RootPublicKeyPem(), + &error)) { AdoptManifest(*parsed); + } else { + LOG(("Update Error: Bad canary metadata manifest: %1").arg(error)); } } @@ -1484,35 +1515,46 @@ void MtpChecker::parseCanaryMetadata( const auto platform = Platform::AutoUpdateKey(); auto bestVersion = quint64(0); auto bestPostId = 0; + const auto readU32 = [](const QJsonValue &value, quint32 *result) { + const auto number = value.toDouble(); + if (!(number >= 0.) || !(number <= 4294967295.)) { + return false; + } + *result = quint32(number); + return (double(*result) == number); + }; const auto consider = [&](const QByteArray &name) { const auto entry = channels.value(QLatin1String(name)).toObject(); if (entry.isEmpty()) { return; } - const auto base = entry.value(u"base"_q).toDouble(); - const auto counter = entry.value(u"counter"_q).toDouble(); - if (base <= 0 - || base != double(quint32(base)) - || counter < 0 - || counter != double(quint32(counter))) { + auto base = quint32(0); + auto counter = quint32(0); + auto postId = quint32(0); + if (!readU32(entry.value(u"base"_q), &base) + || !base + || !readU32(entry.value(u"counter"_q), &counter) + || !readU32( + entry.value(u"posts"_q).toObject().value(platform), + &postId) + || !postId + || postId > quint32(0x7FFFFFFF)) { return; } - const auto version = Updates::MakeUpdateVersion( - quint32(base), - quint32(counter)); - const auto postId = int(base::SafeRound( - entry.value(u"posts"_q).toObject().value(platform).toDouble())); - if (version > bestVersion && postId > 0) { + const auto version = Updates::MakeUpdateVersion(base, counter); + if (version > bestVersion) { bestVersion = version; - bestPostId = postId; + bestPostId = int(postId); } }; consider(Updates::ChannelName(BuildUpdateChannel)); if (BuildUpdateChannel == Updates::Channel::CanaryPublic) { // Dormancy rescue: a stale public canary channel may point to a - // newer stable release. Package verification still enforces the - // strictly-greater-base channel policy on whatever is downloaded. + // newer stable or beta release. Package verification still + // enforces the strictly-greater-base channel policy on whatever + // is downloaded. consider(Updates::ChannelName(Updates::Channel::Stable)); + consider(Updates::ChannelName(Updates::Channel::Beta)); } if (!bestVersion || bestVersion <= RunningUpdateVersion()) { done(nullptr); @@ -2235,6 +2277,10 @@ bool checkReadyUpdate() { ClearAll(); return false; } + } else if (BuildUpdateChannel == Updates::Channel::CanaryPrivate) { + LOG(("Update Error: cant install a non-canary version %1 on a private canary").arg(versionNum)); + ClearAll(); + return false; } else if (versionNum <= AppVersion) { LOG(("Update Error: cant install version %1 having version %2").arg(versionNum).arg(AppVersion)); ClearAll(); diff --git a/Telegram/SourceFiles/core/update_verify.cpp b/Telegram/SourceFiles/core/update_verify.cpp index 00e230dfaf..5d970cf882 100644 --- a/Telegram/SourceFiles/core/update_verify.cpp +++ b/Telegram/SourceFiles/core/update_verify.cpp @@ -112,6 +112,9 @@ void SetError(QString *error, const QString &text) { return std::nullopt; } const auto number = value.toDouble(); + if (!(number > -9007199254740992.) || !(number < 9007199254740992.)) { + return std::nullopt; + } const auto result = qint64(number); if (double(result) != number) { return std::nullopt; @@ -455,7 +458,7 @@ struct Reader { } auto result = QByteArray( reinterpret_cast(data + offset), - int(count)); + qsizetype(count)); offset += count; return result; } @@ -486,6 +489,41 @@ std::optional ChannelFromName(const QByteArray &name) { return std::nullopt; } +QByteArray OsName(Os os) { + switch (os) { + case Os::Windows: return QByteArrayLiteral("win"); + case Os::Mac: return QByteArrayLiteral("mac"); + case Os::Linux: return QByteArrayLiteral("linux"); + } + return QByteArray(); +} + +QByteArray ArchName(Arch arch) { + switch (arch) { + case Arch::X86: return QByteArrayLiteral("x86"); + case Arch::X64: return QByteArrayLiteral("x64"); + case Arch::Arm: return QByteArrayLiteral("arm"); + } + return QByteArray(); +} + +std::optional TargetFromPlatformKey(const QByteArray &key) { + if (key == "win") { + return Target{ Os::Windows, Arch::X86 }; + } else if (key == "win64") { + return Target{ Os::Windows, Arch::X64 }; + } else if (key == "winarm") { + return Target{ Os::Windows, Arch::Arm }; + } else if (key == "mac") { + return Target{ Os::Mac, Arch::X64 }; + } else if (key == "armac") { + return Target{ Os::Mac, Arch::Arm }; + } else if (key == "linux") { + return Target{ Os::Linux, Arch::X64 }; + } + return std::nullopt; +} + std::optional ParseVerifiedManifest( const QByteArray &json, const QByteArray &signature, @@ -596,6 +634,18 @@ std::optional ParseEnvelope( } result.channel = Channel(*channel); + const auto os = reader.readU8(); + if (!os || *os > quint8(Os::Linux)) { + SetError(error, QStringLiteral("Bad envelope os.")); + return std::nullopt; + } + const auto arch = reader.readU8(); + if (!arch || *arch > quint8(Arch::Arm)) { + SetError(error, QStringLiteral("Bad envelope arch.")); + return std::nullopt; + } + result.target = Target{ Os(*os), Arch(*arch) }; + const auto version = reader.readU64(); if (!version) { SetError(error, QStringLiteral("Bad envelope version.")); @@ -672,7 +722,9 @@ std::optional ParseEnvelope( } const auto payloadLength = reader.readU32(); - if (!payloadLength || !*payloadLength) { + if (!payloadLength + || !*payloadLength + || *payloadLength > kMaxPayloadSize) { SetError(error, QStringLiteral("Bad envelope payload size.")); return std::nullopt; } @@ -805,6 +857,7 @@ std::optional VerifyUpdate( const QByteArray &data, Channel buildChannel, bool betaSet, + Target expectedTarget, quint64 runningVersion, const std::optional &held, const QByteArray &rootPublicKeyPem, @@ -813,6 +866,11 @@ std::optional VerifyUpdate( auto envelope = ParseEnvelope(data, error); if (!envelope) { return std::nullopt; + } else if (!(envelope->target == expectedTarget)) { + SetError( + error, + QStringLiteral("Package target is not this platform.")); + return std::nullopt; } auto carried = ParseVerifiedManifest( diff --git a/Telegram/SourceFiles/core/update_verify.h b/Telegram/SourceFiles/core/update_verify.h index cf03c2aef7..8df2d4b330 100644 --- a/Telegram/SourceFiles/core/update_verify.h +++ b/Telegram/SourceFiles/core/update_verify.h @@ -34,6 +34,44 @@ enum class Channel : uchar { [[nodiscard]] QByteArray ChannelName(Channel channel); [[nodiscard]] std::optional ChannelFromName(const QByteArray &name); +enum class Os : uchar { + Windows = 0, + Mac = 1, + Linux = 2, +}; + +enum class Arch : uchar { + X86 = 0, + X64 = 1, + Arm = 2, +}; + +// The target a package was built for, part of the signed region: a valid +// signature for one arch can't be re-routed to clients of another one. +struct Target { + Os os = Os::Windows; + Arch arch = Arch::X86; + + friend inline bool operator==(Target a, Target b) { + return (a.os == b.os) && (a.arch == b.arch); + } +}; + +[[nodiscard]] QByteArray OsName(Os os); +[[nodiscard]] QByteArray ArchName(Arch arch); + +// The feed key of Platform::AutoUpdateKey(): win / win64 / winarm / mac +// / armac / linux. It names the package a client should RECEIVE, which is +// not always the client's own build (an x64 build under Rosetta asks for +// armac), so clients compute the expected target from it, never from +// compile-time macros. +[[nodiscard]] std::optional TargetFromPlatformKey( + const QByteArray &key); + +// The payload is the only envelope field without a tight structural cap, +// this one matches the updater download limit. +inline constexpr auto kMaxPayloadSize = quint32(256 * 1024 * 1024); + [[nodiscard]] constexpr quint64 MakeUpdateVersion( quint32 base, quint32 counter) { @@ -86,6 +124,7 @@ struct EnvelopeSignature { struct Envelope { Channel channel = Channel::Stable; + Target target; quint64 version = 0; qint64 created = 0; QByteArray manifest; @@ -153,6 +192,7 @@ struct VerifiedUpdate { const QByteArray &data, Channel buildChannel, bool betaSet, + Target expectedTarget, quint64 runningVersion, const std::optional &held, const QByteArray &rootPublicKeyPem, diff --git a/Telegram/SourceFiles/mtproto/dedicated_file_loader.cpp b/Telegram/SourceFiles/mtproto/dedicated_file_loader.cpp index 8c8954ac48..d5147291be 100644 --- a/Telegram/SourceFiles/mtproto/dedicated_file_loader.cpp +++ b/Telegram/SourceFiles/mtproto/dedicated_file_loader.cpp @@ -36,8 +36,9 @@ std::optional ExtractChannel( } std::optional ParseFile( - const MTPmessages_Messages &result) { - const auto message = GetMessagesElement(result); + const MTPmessages_Messages &result, + int postId) { + const auto message = GetMessagesElement(result, postId); if (!message || message->type() != mtpc_message) { LOG(("Update Error: MTP file message not found.")); return std::nullopt; @@ -69,8 +70,8 @@ std::optional ParseFile( return std::nullopt; } const auto size = int64(fields.vsize().v); - if (size <= 0) { - LOG(("Update Error: MTP file size is invalid.")); + if (size <= 0 || size > AbstractDedicatedLoader::kMaxFileSize) { + LOG(("Update Error: MTP file size is invalid: %1.").arg(size)); return std::nullopt; } const auto location = MTP_inputDocumentFileLocation( @@ -267,8 +268,15 @@ void AbstractDedicatedLoader::threadSafeFailed() { }); } -void AbstractDedicatedLoader::writeChunk(bytes::const_span data, int totalSize) { - const auto size = data.size(); +void AbstractDedicatedLoader::writeChunk(bytes::const_span data, int64 totalSize) { + const auto size = int64(data.size()); + if (totalSize > kMaxFileSize || alreadySize() + size > kMaxFileSize) { + LOG(("Update Error: Download exceeds the size limit: %1 / %2." + ).arg(alreadySize() + size + ).arg(totalSize)); + threadSafeFailed(); + return; + } if (size > 0) { const auto written = _output.write(QByteArray::fromRawData( reinterpret_cast(data.data()), @@ -442,13 +450,20 @@ void ResolveChannel( } std::optional GetMessagesElement( - const MTPmessages_Messages &list) { + const MTPmessages_Messages &list, + int messageId) { return list.match([&](const MTPDmessages_messagesNotModified &) { return std::optional(std::nullopt); - }, [&](const auto &data) { - return data.vmessages().v.isEmpty() - ? std::nullopt - : std::make_optional(data.vmessages().v[0]); + }, [&](const auto &data) -> std::optional { + for (const auto &message : data.vmessages().v) { + const auto id = message.match([](const auto &data) { + return data.vid().v; + }); + if (!messageId || id == messageId) { + return message; + } + } + return std::nullopt; }); } @@ -457,8 +472,9 @@ void StartDedicatedLoader( const DedicatedLoader::Location &location, const QString &folder, Fn)> ready) { + const auto postId = location.postId; const auto doneHandler = [=](const MTPmessages_Messages &result) { - const auto file = ParseFile(result); + const auto file = ParseFile(result, postId); ready(file ? std::make_unique( mtp->session(), @@ -472,7 +488,6 @@ void StartDedicatedLoader( ready(nullptr); }; - const auto postId = location.postId; const auto request = [=](const MTPInputChannel &channel) { mtp->send( MTPchannels_GetMessages( diff --git a/Telegram/SourceFiles/mtproto/dedicated_file_loader.h b/Telegram/SourceFiles/mtproto/dedicated_file_loader.h index a56e0b5274..661b926fd0 100644 --- a/Telegram/SourceFiles/mtproto/dedicated_file_loader.h +++ b/Telegram/SourceFiles/mtproto/dedicated_file_loader.h @@ -87,7 +87,7 @@ protected: void threadSafeReady(); // Single threaded. - void writeChunk(bytes::const_span data, int totalSize); + void writeChunk(bytes::const_span data, int64 totalSize); private: virtual void startLoading() = 0; @@ -162,8 +162,11 @@ void ResolveChannel( Fn done, Fn fail); +// With a non-zero messageId only the message with that exact id counts, +// the server may answer a getMessages request with a different message. std::optional GetMessagesElement( - const MTPmessages_Messages &list); + const MTPmessages_Messages &list, + int messageId = 0); void StartDedicatedLoader( not_null mtp, diff --git a/Telegram/SourceFiles/storage/localstorage.cpp b/Telegram/SourceFiles/storage/localstorage.cpp index 5033002cd4..c997b75839 100644 --- a/Telegram/SourceFiles/storage/localstorage.cpp +++ b/Telegram/SourceFiles/storage/localstorage.cpp @@ -32,6 +32,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "lang/lang_instance.h" #include +#include #ifndef Q_OS_WIN #include @@ -603,10 +604,12 @@ void writeUpdateManifest( || manifest.isEmpty()) { return; } - QFile f(updateManifestFile()); - if (f.open(QIODevice::WriteOnly)) { - f.write(signature); - f.write(manifest); + QSaveFile f(updateManifestFile()); + if (!f.open(QIODevice::WriteOnly) + || f.write(signature) != signature.size() + || f.write(manifest) != manifest.size() + || !f.commit()) { + LOG(("Storage Error: Could not write the update manifest.")); } } diff --git a/Telegram/SourceFiles/tests/test_update_verify.cpp b/Telegram/SourceFiles/tests/test_update_verify.cpp index 6e5488f737..be99b63c8c 100644 --- a/Telegram/SourceFiles/tests/test_update_verify.cpp +++ b/Telegram/SourceFiles/tests/test_update_verify.cpp @@ -9,6 +9,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL // Focused console tests for the v2 update verification: throwaway keys are // generated in-process with OpenSSL, so no fixture files and no network. +#include "core/update_keys.h" #include "core/update_verify.h" #include @@ -35,6 +36,10 @@ using namespace Core::Updates; int FailedChecks = 0; int TotalChecks = 0; +constexpr auto kTarget = Target{ Os::Linux, Arch::X64 }; +constexpr auto kOtherArch = Target{ Os::Linux, Arch::Arm }; +constexpr auto kOtherOs = Target{ Os::Mac, Arch::X64 }; + void Check(bool condition, const char *name) { ++TotalChecks; if (!condition) { @@ -283,11 +288,14 @@ void AppendLeU64(QByteArray &to, quint64 value) { const QByteArray &manifestSig, const std::vector> &signatures, const QByteArray &payload, + Target target = kTarget, quint32 format = kEnvelopeFormat) { auto result = QByteArray(); result.append(kEnvelopeMagic, 4); AppendLeU32(result, format); result.append(char(uchar(channel))); + result.append(char(uchar(target.os))); + result.append(char(uchar(target.arch))); AppendLeU64(result, version); AppendLeU64(result, quint64(1700000000)); AppendLeU32(result, quint32(manifest.size())); @@ -312,14 +320,16 @@ void AppendLeU64(QByteArray &to, quint64 value) { const QByteArray &manifest, const QByteArray &manifestSig, const std::vector &signers, - const QByteArray &payload) { + const QByteArray &payload, + Target target = kTarget) { const auto unsignedBytes = BuildEnvelope( channel, version, manifest, manifestSig, {}, - payload); + payload, + target); const auto envelope = ParseEnvelope(unsignedBytes); if (!envelope) { return QByteArray(); @@ -335,7 +345,8 @@ void AppendLeU64(QByteArray &to, quint64 value) { manifest, manifestSig, signatures, - payload); + payload, + target); } } // namespace @@ -399,6 +410,7 @@ int main(int argc, char *argv[]) { data, build, betaSet, + kTarget, running, held, rootPem, @@ -406,6 +418,21 @@ int main(int argc, char *argv[]) { error); }; + { // The committed trust files must verify with the pinned root. + auto error = QString(); + const auto embedded = ParseVerifiedManifest( + EmbeddedManifest(), + EmbeddedManifestSignature(), + RootPublicKeyPem(), + &error); + Check(embedded.has_value(), "embedded manifest verifies"); + Check(embedded && embedded->version >= 1, "embedded manifest version"); + Check(embedded && embedded->channels.size() == 4, + "embedded manifest lists all four channels"); + Check(embedded && !embedded->keys.empty(), + "embedded manifest has usable keys"); + } + { // A good stable package needs one rl AND one rc signature. const auto good = BuildSignedEnvelope( Channel::Stable, @@ -594,6 +621,7 @@ int main(int argc, char *argv[]) { byExpired, Channel::CanaryPublic, false, + kTarget, runningCanary, heldSame, rootPem, @@ -603,6 +631,7 @@ int main(int argc, char *argv[]) { byExpired, Channel::CanaryPublic, false, + kTarget, runningCanary, heldSame, rootPem, @@ -640,6 +669,7 @@ int main(int argc, char *argv[]) { oldManifestPackage, Channel::CanaryPublic, false, + kTarget, runningCanary, heldRevoking, rootPem, @@ -674,6 +704,7 @@ int main(int argc, char *argv[]) { package, Channel::CanaryPublic, false, + kTarget, runningCanary, std::nullopt, rootPem, @@ -744,9 +775,17 @@ int main(int argc, char *argv[]) { manifestSig, {}, payload, + kTarget, 3); Check(!ParseEnvelope(badFormat), "unknown format rejected"); + auto badOs = good; + badOs[9] = char(3); + Check(!ParseEnvelope(badOs), "unknown os rejected"); + auto badArch = good; + badArch[10] = char(3); + Check(!ParseEnvelope(badArch), "unknown arch rejected"); + auto badMagic = good; badMagic[0] = 'X'; Check(!IsV2UpdateFile(badMagic), "bad magic not detected as v2"); @@ -791,6 +830,96 @@ int main(int argc, char *argv[]) { Check(verify(withExtra, Channel::CanaryPublic, false, runningCanary) .has_value(), "unknown extra signature entries are ignored"); + + // A DER-encoded ECDSA signature is not the raw r||s the envelope + // stores, the fixed 64-byte check must refuse it. + auto der = QByteArray(); + der.append(char(0x30)).append(char(0x44)); + der.append(char(0x02)).append(char(0x20)).append(QByteArray(32, 'r')); + der.append(char(0x02)).append(char(0x20)).append(QByteArray(32, 's')); + const auto withDer = BuildEnvelope( + Channel::CanaryPublic, + MakeUpdateVersion(5000000, 41), + manifestJson, + manifestSig, + { { cp.id, der } }, + payload); + Check(!verify(withDer, Channel::CanaryPublic, false, runningCanary), + "DER-encoded ES256 signature rejected"); + } + + { // The signed target must be the one this client should receive. + const auto otherArch = BuildSignedEnvelope( + Channel::CanaryPublic, + MakeUpdateVersion(5000000, 41), + manifestJson, + manifestSig, + { &cp }, + payload, + kOtherArch); + Check(!verify(otherArch, Channel::CanaryPublic, false, runningCanary), + "validly signed package for another arch rejected"); + Check(VerifyUpdate( + otherArch, + Channel::CanaryPublic, + false, + kOtherArch, + runningCanary, + held, + rootPem, + kNow).has_value(), + "the same package accepted by a client expecting that arch"); + + const auto otherOs = BuildSignedEnvelope( + Channel::CanaryPublic, + MakeUpdateVersion(5000000, 41), + manifestJson, + manifestSig, + { &cp }, + payload, + kOtherOs); + Check(!verify(otherOs, Channel::CanaryPublic, false, runningCanary), + "validly signed package for another os rejected"); + + const auto good = BuildSignedEnvelope( + Channel::CanaryPublic, + MakeUpdateVersion(5000000, 41), + manifestJson, + manifestSig, + { &cp }, + payload); + auto retargeted = good; + retargeted[10] = char(uchar(Arch::Arm)); + Check(!VerifyUpdate( + retargeted, + Channel::CanaryPublic, + false, + kOtherArch, + runningCanary, + held, + rootPem, + kNow), + "target byte tampering breaks the signature"); + + Check(TargetFromPlatformKey("armac") + && *TargetFromPlatformKey("armac") == Target{ Os::Mac, Arch::Arm } + && TargetFromPlatformKey("win64") + && *TargetFromPlatformKey("win64") == Target{ Os::Windows, Arch::X64 } + && !TargetFromPlatformKey("amiga"), + "platform keys map to targets"); + } + + { // Dormancy rescue also works for beta packages on canary-public. + const auto beta = BuildSignedEnvelope( + Channel::Beta, + MakeUpdateVersion(5000001, 0), + manifestJson, + manifestSig, + { &rl, &rc }, + payload); + Check(verify(beta, Channel::CanaryPublic, false, runningCanary) + .has_value(), + "beta with greater base accepted on canary-public"); } std::cout << (TotalChecks - FailedChecks) << "/" << TotalChecks diff --git a/Telegram/build/build.bat b/Telegram/build/build.bat index 1ab7e202e1..d4b23bfaf8 100644 --- a/Telegram/build/build.bat +++ b/Telegram/build/build.bat @@ -202,7 +202,7 @@ if %AlphaVersion% neq 0 ( echo Deploy folder for version %AppVersionStr% already exists! exit /b 1 ) - if exist %ReleasePath%\tupdate%AppVersion% ( + if exist %ReleasePath%\%UpdateFile% ( echo Update file for version %AppVersion% already exists! exit /b 1 ) diff --git a/Telegram/build/canary_test_fixtures.sh b/Telegram/build/canary_test_fixtures.sh index f4e51751aa..27047b44ed 100755 --- a/Telegram/build/canary_test_fixtures.sh +++ b/Telegram/build/canary_test_fixtures.sh @@ -29,6 +29,7 @@ if ! openssl genpkey -algorithm ed25519 2>/dev/null >/dev/null; then fi echo "Working in $WORKDIR" +mkdir -p "$WORKDIR" cd "$WORKDIR" mkdir -p keys app diff --git a/Telegram/build/sign_update.py b/Telegram/build/sign_update.py index 318e242839..996b8d180c 100644 --- a/Telegram/build/sign_update.py +++ b/Telegram/build/sign_update.py @@ -35,24 +35,32 @@ def b64url_decode(text: str) -> bytes: def der_to_raw_rs(der: bytes) -> bytes: - # Minimal DER parse of SEQUENCE { INTEGER r, INTEGER s }. + # Minimal DER parse of SEQUENCE { INTEGER r, INTEGER s }, strict + # about lengths: the input is normally trusted openssl output, but a + # malformed signature must fail here rather than in the packer. def read_len(data, offset): first = data[offset] offset += 1 if first < 0x80: return first, offset count = first & 0x7F + if count == 0 or count > 2 or offset + count > len(data): + raise ValueError('bad DER length') value = int.from_bytes(data[offset:offset + count], 'big') return value, offset + count if not der or der[0] != 0x30: raise ValueError('not a DER SEQUENCE') - _, offset = read_len(der, 1) + total, offset = read_len(der, 1) + if offset + total != len(der): + raise ValueError('DER SEQUENCE length does not match the input') def read_int(data, offset): - if data[offset] != 0x02: + if offset >= len(data) or data[offset] != 0x02: raise ValueError('not a DER INTEGER') length, offset = read_len(data, offset + 1) + if length == 0 or offset + length > len(data): + raise ValueError('bad DER INTEGER length') value = data[offset:offset + length].lstrip(b'\x00') if len(value) > 32: raise ValueError('integer too long for P-256') @@ -60,6 +68,8 @@ def der_to_raw_rs(der: bytes) -> bytes: r, offset = read_int(der, offset) s, offset = read_int(der, offset) + if offset != len(der): + raise ValueError('trailing bytes after the DER signature') return r + s @@ -72,6 +82,7 @@ def sign_azure(digest: bytes, args) -> bytes: '--algorithm', 'ES256', '--digest', b64url(digest), '--output', 'json', + '--only-show-errors', ] if args.az_key_version: command += ['--version', args.az_key_version] diff --git a/Telegram/cmake/telegram_options.cmake b/Telegram/cmake/telegram_options.cmake index abaa797317..0a4b95dce6 100644 --- a/Telegram/cmake/telegram_options.cmake +++ b/Telegram/cmake/telegram_options.cmake @@ -64,6 +64,20 @@ set(TDESKTOP_CANARY_PUBLIC_CHANNEL "" CACHE STRING "Public canary channel userna set(TDESKTOP_CANARY_PRIVATE_CHANNEL_ID "0" CACHE STRING "Private canary channel numeric id (canary-private builds).") set(TDESKTOP_CANARY_METADATA_MSG_ID "0" CACHE STRING "Fixed metadata message id in the canary channel.") +# CI passes these straight from repository variables, an unset variable +# arrives as an empty string and must mean "not configured", not an +# empty macro body. +foreach(numeric_option + TDESKTOP_CANARY_COUNTER + TDESKTOP_CANARY_PRIVATE_CHANNEL_ID + TDESKTOP_CANARY_METADATA_MSG_ID) + if (${numeric_option} STREQUAL "") + set(${numeric_option} 0) + elseif (NOT ${numeric_option} MATCHES "^[0-9]+$") + message(FATAL_ERROR "${numeric_option} must be a non-negative integer, got '${${numeric_option}}'.") + endif() +endforeach() + if (TDESKTOP_UPDATE_CHANNEL STREQUAL "stable") set(tdesktop_update_channel_value 0) elseif (TDESKTOP_UPDATE_CHANNEL STREQUAL "beta") diff --git a/Telegram/cmake/test_update_verify.cmake b/Telegram/cmake/test_update_verify.cmake new file mode 100644 index 0000000000..48487bf02b --- /dev/null +++ b/Telegram/cmake/test_update_verify.cmake @@ -0,0 +1,37 @@ +# This file is part of Telegram Desktop, +# the official desktop application for the Telegram messaging service. +# +# For license and copyright information please follow this link: +# https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL + +# The focused v2 update verification test is built alongside the Packer +# for special targets as well as with the development test apps: it runs +# the exact translation unit that verifies updates in the client and it +# checks the committed trust files against the pinned root. + +add_executable(test_update_verify) +init_target(test_update_verify "(tests)") + +target_include_directories(test_update_verify PRIVATE ${src_loc}) + +nice_target_sources(test_update_verify ${src_loc} +PRIVATE + core/update_keys.cpp + core/update_keys.h + core/update_verify.cpp + core/update_verify.h + tests/test_update_verify.cpp +) + +target_include_directories(test_update_verify PRIVATE ${CMAKE_CURRENT_BINARY_DIR}/gen) +add_dependencies(test_update_verify Telegram_update_keys) + +target_link_libraries(test_update_verify +PRIVATE + desktop-app::external_qt + desktop-app::external_openssl +) + +set_target_properties(test_update_verify PROPERTIES RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}) + +add_dependencies(Telegram test_update_verify) diff --git a/Telegram/cmake/tests.cmake b/Telegram/cmake/tests.cmake index 025e89e594..a647ed9463 100644 --- a/Telegram/cmake/tests.cmake +++ b/Telegram/cmake/tests.cmake @@ -4,28 +4,6 @@ # For license and copyright information please follow this link: # https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL -add_executable(test_update_verify) -init_target(test_update_verify "(tests)") - -target_include_directories(test_update_verify PRIVATE ${src_loc}) - -nice_target_sources(test_update_verify ${src_loc} -PRIVATE - core/update_verify.cpp - core/update_verify.h - tests/test_update_verify.cpp -) - -target_link_libraries(test_update_verify -PRIVATE - desktop-app::external_qt - desktop-app::external_openssl -) - -set_target_properties(test_update_verify PROPERTIES RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}) - -add_dependencies(Telegram test_update_verify) - add_executable(test_text WIN32) init_target(test_text "(tests)")