From d1385aa47e17e2d34f86361a871b32618f8845de Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:07:17 +0800 Subject: [PATCH] Harden authenticated Desktop release recovery --- .github/workflows/release-desktop.yml | 30 ++++++++-- scripts/release-workflows.test.sh | 55 ++++++++++++++++++- scripts/verify-desktop-release-directory.sh | 16 +++++- .../verify-desktop-release-manifest-assets.sh | 43 +++++++++++++++ 4 files changed, 136 insertions(+), 8 deletions(-) create mode 100755 scripts/verify-desktop-release-manifest-assets.sh diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index ae9ed5eac6..91cbee53d4 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -869,7 +869,18 @@ jobs: "$signature_verifier" verify "$payload" done < <(find "$directory" -type f -name '*.minisig' -print0) } + require_signature_coverage() { + local directory="$1" + local payload + while IFS= read -r -d '' payload; do + if [ ! -s "$payload.minisig" ]; then + echo "::error::Desktop payload has no non-empty signature: $payload" + return 1 + fi + done < <(find "$directory" -type f ! -name '*.minisig' ! -name 'latest.json' -print0) + } verify_signature_directory assets + require_signature_coverage assets # A version directory is immutable once written. Recovery may fill an # incomplete candidate subset, but it may never replace conflicting or @@ -889,22 +900,26 @@ jobs: verify_signature_directory "$existing_directory" bash release-control/scripts/verify-desktop-release-directory.sh \ --allow-missing --allow-legacy-manifest \ - --allow-signature-differences assets "$existing_directory" + --allow-authenticated-payload-differences assets "$existing_directory" if [ -f "$existing_directory/latest.json" ]; then existing_manifest=true bash release-control/scripts/validate-desktop-release-manifest.sh \ "legacy-${validation_channel}" "$VERSION" "$asset_base" \ "$existing_directory/latest.json" "$NOTES_VERSION" - bash release-control/scripts/compare-desktop-release-manifests.sh \ - assets/latest.json "$existing_directory/latest.json" + bash release-control/scripts/verify-desktop-release-manifest-assets.sh \ + "$existing_directory/latest.json" "$existing_directory" + cp "$existing_directory/latest.json" assets/latest.json fi - # Preserve already-published valid signature bytes. Minisign - # signatures are intentionally non-deterministic, so a recovery - # may fill missing signatures but must not replace existing ones. + # Preserve every already-published authenticated payload/signature + # pair. Platform signing and packaging are non-deterministic, so a + # recovery may fill missing pairs but must not replace valid ones. while IFS= read -r -d '' signature; do relative="${signature#"$existing_directory"/}" + payload="${signature%.minisig}" + payload_relative="${payload#"$existing_directory"/}" mkdir -p "assets/$(dirname "$relative")" + cp "$payload" "assets/$payload_relative" cp "$signature" "assets/$relative" done < <(find "$existing_directory" -type f -name '*.minisig' -print0) fi @@ -926,6 +941,9 @@ jobs: bash release-control/scripts/verify-desktop-release-directory.sh \ --allow-legacy-manifest assets "$published_directory" verify_signature_directory "$published_directory" + require_signature_coverage "$published_directory" + bash release-control/scripts/verify-desktop-release-manifest-assets.sh \ + "$published_directory/latest.json" "$published_directory" aws s3 cp "s3://${R2_BUCKET}/${TAG}/latest.json" \ /tmp/reasonix-desktop-tag-latest.json --endpoint-url "$ENDPOINT" diff --git a/scripts/release-workflows.test.sh b/scripts/release-workflows.test.sh index ccebbbe57a..dafcb5b641 100755 --- a/scripts/release-workflows.test.sh +++ b/scripts/release-workflows.test.sh @@ -175,9 +175,11 @@ grep -Fq 'go -C release-control/desktop build -o "$signature_verifier" ./cmd/sig grep -Fq 'verify_signature_directory assets' "$repo_root/.github/workflows/release-desktop.yml" grep -Fq 'verify_signature_directory "$existing_directory"' \ "$repo_root/.github/workflows/release-desktop.yml" -grep -Fq -- '--allow-signature-differences' \ +grep -Fq -- '--allow-authenticated-payload-differences' \ "$repo_root/.github/workflows/release-desktop.yml" +grep -Fq 'cp "$payload" "assets/$payload_relative"' "$repo_root/.github/workflows/release-desktop.yml" grep -Fq 'cp "$signature" "assets/$relative"' "$repo_root/.github/workflows/release-desktop.yml" +grep -Fq 'verify-desktop-release-manifest-assets.sh' "$repo_root/.github/workflows/release-desktop.yml" grep -Fq 'verify_signature_directory "$published_directory"' \ "$repo_root/.github/workflows/release-desktop.yml" grep -Fq 'download_optional "preview/latest.json"' "$repo_root/.github/workflows/release-desktop.yml" @@ -614,6 +616,25 @@ if bash "$desktop_directory_verifier" "$candidate_directory" "$existing_director fi bash "$desktop_directory_verifier" --allow-signature-differences \ "$candidate_directory" "$existing_directory" +printf 'candidate-payload\n' >"$candidate_directory/signed" +printf 'existing-payload\n' >"$existing_directory/signed" +printf 'candidate-signature\n' >"$candidate_directory/signed.minisig" +printf 'existing-signature\n' >"$existing_directory/signed.minisig" +if bash "$desktop_directory_verifier" --allow-signature-differences \ + "$candidate_directory" "$existing_directory" >/dev/null 2>&1; then + echo "Desktop release directory verifier accepted a byte-distinct payload without authenticated-payload opt-in" >&2 + exit 1 +fi +bash "$desktop_directory_verifier" --allow-authenticated-payload-differences \ + "$candidate_directory" "$existing_directory" +printf '' >"$existing_directory/signed.minisig" +if bash "$desktop_directory_verifier" --allow-authenticated-payload-differences \ + "$candidate_directory" "$existing_directory" >/dev/null 2>&1; then + echo "Desktop release directory verifier accepted a byte-distinct payload with an empty signature" >&2 + exit 1 +fi +cp "$candidate_directory/signed" "$existing_directory/signed" +cp "$candidate_directory/signed.minisig" "$existing_directory/signed.minisig" printf '' >"$existing_directory/a.minisig" if bash "$desktop_directory_verifier" --allow-signature-differences \ "$candidate_directory" "$existing_directory" >/dev/null 2>&1; then @@ -661,6 +682,38 @@ if bash "$desktop_directory_verifier" --allow-legacy-manifest \ exit 1 fi +desktop_manifest_asset_verifier="$repo_root/scripts/verify-desktop-release-manifest-assets.sh" +test -x "$desktop_manifest_asset_verifier" +manifest_asset_directory="$test_root/desktop-manifest-assets" +mkdir -p "$manifest_asset_directory" +printf 'manifest-bound-payload\n' >"$manifest_asset_directory/payload.zip" +printf 'manifest-bound-native\n' >"$manifest_asset_directory/payload.deb" +manifest_asset_sha="$(shasum -a 256 "$manifest_asset_directory/payload.zip" | awk '{print $1}')" +manifest_asset_size="$(wc -c <"$manifest_asset_directory/payload.zip" | tr -d '[:space:]')" +manifest_native_sha="$(shasum -a 256 "$manifest_asset_directory/payload.deb" | awk '{print $1}')" +manifest_native_size="$(wc -c <"$manifest_asset_directory/payload.deb" | tr -d '[:space:]')" +jq -n \ + --arg url "https://dl.reasonix.io/desktop-v1.2.3/payload.zip" \ + --arg sha "$manifest_asset_sha" \ + --argjson size "$manifest_asset_size" \ + --arg native_url "https://dl.reasonix.io/desktop-v1.2.3/payload.deb" \ + --arg native_sha "$manifest_native_sha" \ + --argjson native_size "$manifest_native_size" \ + '{ + platforms: {test: {url: $url, sha256: $sha, size: $size}}, + native_packages: {test: {url: $native_url, sha256: $native_sha, size: $native_size}}, + downloads: {} + }' \ + >"$manifest_asset_directory/latest.json" +bash "$desktop_manifest_asset_verifier" \ + "$manifest_asset_directory/latest.json" "$manifest_asset_directory" +printf 'corrupted-payload\n' >"$manifest_asset_directory/payload.zip" +if bash "$desktop_manifest_asset_verifier" \ + "$manifest_asset_directory/latest.json" "$manifest_asset_directory" >/dev/null 2>&1; then + echo "Desktop release manifest asset verifier accepted a mismatched payload" >&2 + exit 1 +fi + # GitHub release recovery uses the same immutable-subset rule: an interrupted # release may fill missing assets, while conflicting or extra assets fail closed. desktop_github_publisher="$repo_root/scripts/publish-desktop-github-release.sh" diff --git a/scripts/verify-desktop-release-directory.sh b/scripts/verify-desktop-release-directory.sh index 995725222e..90186d8268 100755 --- a/scripts/verify-desktop-release-directory.sh +++ b/scripts/verify-desktop-release-directory.sh @@ -4,6 +4,7 @@ set -euo pipefail allow_missing=false allow_legacy_manifest=false allow_signature_differences=false +allow_authenticated_payload_differences=false while [ "$#" -gt 0 ]; do case "$1" in --allow-missing) @@ -21,11 +22,20 @@ while [ "$#" -gt 0 ]; do allow_signature_differences=true shift ;; + --allow-authenticated-payload-differences) + # Callers must cryptographically verify both complete payload/signature + # sets before using this comparison mode. Signed Desktop packages can be + # byte-distinct across rebuilds because platform signing and packaging + # embed timestamps and other non-deterministic data. + allow_authenticated_payload_differences=true + allow_signature_differences=true + shift + ;; *) break ;; esac done if [ "$#" -ne 2 ]; then - echo "usage: $0 [--allow-missing] [--allow-legacy-manifest] [--allow-signature-differences] CANDIDATE_DIRECTORY EXISTING_DIRECTORY" >&2 + echo "usage: $0 [--allow-missing] [--allow-legacy-manifest] [--allow-signature-differences] [--allow-authenticated-payload-differences] CANDIDATE_DIRECTORY EXISTING_DIRECTORY" >&2 exit 2 fi @@ -63,6 +73,10 @@ verify_subset() { fi continue fi + if [ "$allow_authenticated_payload_differences" = "true" ] && + [ -s "$source_file.minisig" ] && [ -s "$target_file.minisig" ]; then + continue + fi if ! cmp -s "$source_file" "$target_file"; then echo "Desktop release directory has conflicting content for $relative" >&2 return 1 diff --git a/scripts/verify-desktop-release-manifest-assets.sh b/scripts/verify-desktop-release-manifest-assets.sh new file mode 100755 index 0000000000..7f4e51ee39 --- /dev/null +++ b/scripts/verify-desktop-release-manifest-assets.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -euo pipefail + +manifest="${1:-}" +asset_dir="${2:-}" + +if [ ! -f "$manifest" ] || [ ! -d "$asset_dir" ]; then + echo "usage: $0 MANIFEST ASSET_DIRECTORY" >&2 + exit 2 +fi + +entries="$(mktemp)" +trap 'rm -f "$entries"' EXIT +jq -er ' + [.platforms, .native_packages, (.downloads // {})] + | map(to_entries) + | add[] + | [.value.url, .value.sha256, (.value.size | tostring)] + | @tsv +' "$manifest" >"$entries" + +while IFS=$'\t' read -r url expected_sha expected_size; do + name="${url##*/}" + if [[ ! "$name" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then + echo "Desktop release manifest has an unsafe asset URL: $url" >&2 + exit 1 + fi + asset="$asset_dir/$name" + if [ ! -f "$asset" ]; then + echo "Desktop release manifest asset is missing: $name" >&2 + exit 1 + fi + actual_size="$(wc -c <"$asset" | tr -d '[:space:]')" + if [ "$actual_size" != "$expected_size" ]; then + echo "Desktop release manifest size does not match $name" >&2 + exit 1 + fi + actual_sha="$(shasum -a 256 "$asset" | awk '{print $1}')" + if [ "$actual_sha" != "$expected_sha" ]; then + echo "Desktop release manifest digest does not match $name" >&2 + exit 1 + fi +done <"$entries"