Harden authenticated Desktop release recovery

This commit is contained in:
SivanCola committed 2026-08-01 18:07:17 +08:00
1 parent 37d18c872b
commit d1385aa47e
4 files changed
+136 -8

No files matched your search

+24 -6
View File
@@ -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"
+54 -1
View File
@@ -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"
+15 -1
View File
@@ -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
+43
View File
@@ -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"