From 04dce7bf845840b1288d7987c9e3ae4a342348df Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Sat, 23 May 2026 18:26:04 -0500 Subject: [PATCH] Enforce signed iOS IPA reproducibility proofs --- .github/workflows/mobile-build.yml | 2 + .github/workflows/release.yml | 2 + scripts/ci/canonical-ios-app-hash.py | 18 ++++++++ scripts/ci/ios-release.sh | 61 +++++++++++++++++++------- scripts/ci/verify-release-artifacts.sh | 11 ++--- 5 files changed, 72 insertions(+), 22 deletions(-) diff --git a/.github/workflows/mobile-build.yml b/.github/workflows/mobile-build.yml index 20afe5906..5d5172c2f 100644 --- a/.github/workflows/mobile-build.yml +++ b/.github/workflows/mobile-build.yml @@ -128,6 +128,8 @@ jobs: - name: Verify iOS artifact reproducibility proofs run: nix develop .#ci -c ./scripts/ci/verify-release-artifacts.sh artifacts ios + env: + MAPLE_ENFORCE_IOS_SIGNED_REPRODUCIBILITY: "1" submit-ios-testflight: if: github.event_name == 'push' && github.ref == 'refs/heads/master' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d8dcdf73a..aa6209776 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -556,3 +556,5 @@ jobs: - name: Verify release artifact reproducibility proofs run: nix develop .#ci -c ./scripts/ci/verify-release-artifacts.sh artifacts linux macos ios web latest-json + env: + MAPLE_ENFORCE_IOS_SIGNED_REPRODUCIBILITY: "1" diff --git a/scripts/ci/canonical-ios-app-hash.py b/scripts/ci/canonical-ios-app-hash.py index 2d0e9d33d..35da9aef5 100644 --- a/scripts/ci/canonical-ios-app-hash.py +++ b/scripts/ci/canonical-ios-app-hash.py @@ -14,7 +14,10 @@ "BuildMachineOSBuild", "DTCompiler", "DTPlatformBuild", + "DTPlatformName", + "DTPlatformVersion", "DTSDKBuild", + "DTSDKName", "DTXcode", "DTXcodeBuild", } @@ -75,6 +78,21 @@ def canonical_info_plist(path): value = plistlib.load(f) for key in VOLATILE_INFO_PLIST_KEYS: value.pop(key, None) + if "iPhoneOS" in value.get("CFBundleSupportedPlatforms", []): + # App Store Connect export can rewrite this; the final IPA hash still covers it. + value.pop("CFBundleVersion", None) + url_types = value.get("CFBundleURLTypes") + if isinstance(url_types, list): + # Xcode export can add empty URL type entries, which do not register a scheme. + url_types = [ + entry + for entry in url_types + if not isinstance(entry, dict) or entry.get("CFBundleURLSchemes") + ] + if url_types: + value["CFBundleURLTypes"] = url_types + else: + value.pop("CFBundleURLTypes", None) return plistlib.dumps(value, fmt=plistlib.FMT_XML, sort_keys=True) diff --git a/scripts/ci/ios-release.sh b/scripts/ci/ios-release.sh index 466ea0d6d..5865e3904 100755 --- a/scripts/ci/ios-release.sh +++ b/scripts/ci/ios-release.sh @@ -126,6 +126,25 @@ write_ios_canonical_app_file_manifest() { python3 "${REPO_ROOT}/scripts/ci/canonical-ios-app-hash.py" --manifest "${app}" > "${out}" } +write_ios_ipa_payload_canonical_file_manifest() { + local ipa="$1" + local out="$2" + local tmp app + + tmp="$(mktemp -d)" + unzip -qq "${ipa}" -d "${tmp}" + app="$(find "${tmp}/Payload" -mindepth 1 -maxdepth 1 -type d -name '*.app' 2>/dev/null | LC_ALL=C sort | head -n 1)" + if [ -z "${app}" ]; then + rm -rf "${tmp}" + echo "Could not find a Payload/*.app bundle in ${ipa}" >&2 + return 1 + fi + + remove_apple_signing_metadata "${app}" + write_ios_canonical_app_file_manifest "${app}" "${out}" + rm -rf "${tmp}" +} + write_ios_canonical_app_manifest_diff() { local unsigned_manifest="$1" local signed_manifest="$2" @@ -176,9 +195,6 @@ if [ "${signed_app_canonical_hash}" != "${unsigned_app_canonical_hash}" ]; then echo "First canonical iOS file manifest differences:" >&2 sed -n '1,80p' "${repro_dir}/ios-release-signed-vs-unsigned-canonical.diff.txt" >&2 fi - if [ "${MAPLE_ENFORCE_IOS_SIGNED_REPRODUCIBILITY:-0}" = "1" ]; then - exit 1 - fi else printf 'verified-ios-signed-app %s %s\n' "${signed_app_canonical_hash}" "$(repo_relative_path "${signed_app}")" fi @@ -191,23 +207,38 @@ done < <(find "${TAURI_DIR}/gen/apple/build" -type f -name '*.ipa' -print0 | LC_ write_sha256_manifest "${repro_dir}/ios-release-final.sha256" "${ios_artifacts[@]}" print_file_hashes "${ios_artifacts[@]}" +if [ "${#ios_artifacts[@]}" -ne 1 ]; then + echo "Expected exactly one iOS IPA artifact, found ${#ios_artifacts[@]}." >&2 + exit 1 +fi + : > "${repro_dir}/ios-release-canonical-payload.sha256" for artifact in "${ios_artifacts[@]}"; do - ipa_canonical_hash="$(print_canonical_ipa_payload_hash "${artifact}" "$(repo_relative_path "${artifact}")" | tee -a "${repro_dir}/ios-release-canonical-payload.sha256" | awk '{ print $2 }')" + payload_file_manifest="${repro_dir}/ios-release-ipa-payload-canonical-files.sha256" + payload_diff="${repro_dir}/ios-release-ipa-vs-unsigned-canonical.diff.txt" - if [ -n "${signed_app_canonical_hash}" ]; then - if [ "${ipa_canonical_hash}" != "${signed_app_canonical_hash}" ]; then - echo "warning-ios-exported-payload-canonical-mismatch exported iOS IPA payload does not strip back to the signed app build product." >&2 - echo "signed_app=${signed_app_canonical_hash}" >&2 - echo "ipa_payload=${ipa_canonical_hash}" >&2 - if [ "${MAPLE_ENFORCE_IOS_SIGNED_REPRODUCIBILITY:-0}" = "1" ]; then - exit 1 - fi - continue + ipa_canonical_hash="$(print_canonical_ipa_payload_hash "${artifact}" "$(repo_relative_path "${artifact}")" | tee -a "${repro_dir}/ios-release-canonical-payload.sha256" | awk '{ print $2 }')" + write_ios_ipa_payload_canonical_file_manifest "${artifact}" "${payload_file_manifest}" + write_ios_canonical_app_manifest_diff \ + "${repro_dir}/ios-release-unsigned-app-canonical-files.sha256" \ + "${payload_file_manifest}" \ + "${payload_diff}" + + if [ "${ipa_canonical_hash}" != "${unsigned_app_canonical_hash}" ]; then + echo "warning-ios-exported-payload-canonical-mismatch exported iOS IPA payload does not strip back to the unsigned app build product." >&2 + echo "unsigned=${unsigned_app_canonical_hash}" >&2 + echo "ipa_payload=${ipa_canonical_hash}" >&2 + if [ -s "${payload_diff}" ]; then + echo "First canonical iOS IPA payload differences:" >&2 + sed -n '1,80p' "${payload_diff}" >&2 fi - - printf 'verified-ios-exported-payload %s %s\n' "${ipa_canonical_hash}" "$(repo_relative_path "${artifact}")" + if [ "${MAPLE_ENFORCE_IOS_SIGNED_REPRODUCIBILITY:-0}" = "1" ]; then + exit 1 + fi + continue fi + + printf 'verified-ios-exported-payload %s %s\n' "${ipa_canonical_hash}" "$(repo_relative_path "${artifact}")" done verify_frontend_dist_unchanged diff --git a/scripts/ci/verify-release-artifacts.sh b/scripts/ci/verify-release-artifacts.sh index 09898b93a..02e3a3902 100755 --- a/scripts/ci/verify-release-artifacts.sh +++ b/scripts/ci/verify-release-artifacts.sh @@ -507,9 +507,6 @@ verify_ios() { echo "iOS signed app canonical proof does not match unsigned proof." >&2 echo "unsigned=${unsigned_digest:-missing}" >&2 echo "signed=${signed_digest:-missing}" >&2 - if [ "${MAPLE_ENFORCE_IOS_SIGNED_REPRODUCIBILITY:-0}" = "1" ]; then - return 1 - fi printf 'warning-ios-signed-app-proof-mismatch unsigned=%s signed=%s\n' "${unsigned_digest}" "${signed_digest}" else printf 'verified-ios-signed-app-proof %s\n' "${signed_digest}" @@ -521,12 +518,12 @@ verify_ios() { while IFS= read -r payload_digest; do [ -n "${payload_digest}" ] || continue payload_seen=1 - if [ "${payload_digest}" = "${signed_digest}" ]; then + if [ "${payload_digest}" = "${unsigned_digest}" ]; then printf 'verified-ios-exported-payload-proof %s\n' "${payload_digest}" else payload_mismatch=1 - echo "iOS IPA payload canonical proof does not match signed app proof." >&2 - echo "signed=${signed_digest:-missing}" >&2 + echo "iOS IPA payload canonical proof does not match unsigned app proof." >&2 + echo "unsigned=${unsigned_digest:-missing}" >&2 echo "payload=${payload_digest}" >&2 fi done < <(manifest_digests "${payload_manifest}") @@ -538,7 +535,7 @@ verify_ios() { if [ "${MAPLE_ENFORCE_IOS_SIGNED_REPRODUCIBILITY:-0}" = "1" ]; then return 1 fi - printf 'warning-ios-exported-payload-proof-mismatch signed=%s\n' "${signed_digest}" + printf 'warning-ios-exported-payload-proof-mismatch unsigned=%s\n' "${unsigned_digest}" fi verify_ios_signatures }