From b676a3d0124f7ce2ccd6021edcc712e2c5cfdd46 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 15 Jun 2026 12:30:03 +0200 Subject: [PATCH] chore: add release reproducibility config --- .github/workflows/reproducible-release.yml | 117 +++++++ README.md | 2 + app/build.gradle.kts | 8 +- docs/reproducible-builds.md | 111 +++++++ scripts/reproduce-release.sh | 347 +++++++++++++++++++++ 5 files changed, 584 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/reproducible-release.yml create mode 100644 docs/reproducible-builds.md create mode 100755 scripts/reproduce-release.sh diff --git a/.github/workflows/reproducible-release.yml b/.github/workflows/reproducible-release.yml new file mode 100644 index 000000000..9011166be --- /dev/null +++ b/.github/workflows/reproducible-release.yml @@ -0,0 +1,117 @@ +name: Reproducible Release + +on: + workflow_dispatch: + inputs: + comparison_artifact_name: + description: Optional artifact name to compare against with diffoscope + required: false + default: '' + comparison_run_id: + description: Workflow run id that produced the comparison artifact + required: false + default: '' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +env: + TERM: xterm-256color + FORCE_COLOR: 1 + +jobs: + reproduce-mainnet: + runs-on: ubuntu-latest + timeout-minutes: 60 + environment: release + + permissions: + actions: read + contents: read + packages: read + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Java + uses: actions/setup-java@v5 + with: + java-version: '17' + distribution: 'adopt' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v5 + + - name: Decode mainnet release google-services.json + env: + MAINNET_RELEASE_GOOGLE_SERVICES_JSON_BASE64: ${{ secrets.MAINNET_RELEASE_GOOGLE_SERVICES_JSON_BASE64 }} + run: | + set -euo pipefail + test -n "$MAINNET_RELEASE_GOOGLE_SERVICES_JSON_BASE64" + mkdir -p app/src/mainnetRelease + printf '%s' "$MAINNET_RELEASE_GOOGLE_SERVICES_JSON_BASE64" | base64 --decode > app/src/mainnetRelease/google-services.json + + - name: Decode release keystore + env: + BITKIT_KEYSTORE_BASE64: ${{ secrets.BITKIT_KEYSTORE_BASE64 }} + run: | + set -euo pipefail + test -n "$BITKIT_KEYSTORE_BASE64" + umask 077 + keystore_path="$RUNNER_TEMP/bitkit.keystore" + printf '%s' "$BITKIT_KEYSTORE_BASE64" | base64 --decode > "$keystore_path" + echo "KEYSTORE_FILE=$keystore_path" >> "$GITHUB_ENV" + + - name: Validate comparison inputs + if: ${{ github.event_name == 'workflow_dispatch' && inputs.comparison_artifact_name != '' }} + env: + COMPARISON_RUN_ID: ${{ inputs.comparison_run_id }} + run: | + set -euo pipefail + test -n "$COMPARISON_RUN_ID" + + - name: Download comparison artifact + if: ${{ github.event_name == 'workflow_dispatch' && inputs.comparison_artifact_name != '' }} + uses: actions/download-artifact@v6 + with: + name: ${{ inputs.comparison_artifact_name }} + path: ${{ runner.temp }}/comparison + github-token: ${{ github.token }} + repository: ${{ github.repository }} + run-id: ${{ inputs.comparison_run_id }} + + - name: Install diffoscope + if: ${{ github.event_name == 'workflow_dispatch' && inputs.comparison_artifact_name != '' }} + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y diffoscope + + - name: Build reproducibility artifacts + env: + GPR_USER: ${{ secrets.GPR_USER || github.actor }} + GPR_TOKEN: ${{ secrets.GPR_TOKEN || github.token }} + GITHUB_ACTOR: ${{ secrets.GPR_USER || github.actor }} + GITHUB_TOKEN: ${{ secrets.GPR_TOKEN || github.token }} + KEYSTORE_PASSWORD: ${{ secrets.BITKIT_KEYSTORE_PASSWORD }} + KEY_ALIAS: ${{ secrets.BITKIT_KEY_ALIAS }} + KEY_PASSWORD: ${{ secrets.BITKIT_KEY_PASSWORD }} + OUTPUT_DIR: ${{ runner.temp }}/reproducible-release + run: | + set -euo pipefail + if [ -d "$RUNNER_TEMP/comparison/extracted-apks" ]; then + export DIFFOSCOPE_COMPARE_DIR="$RUNNER_TEMP/comparison/extracted-apks" + elif [ -d "$RUNNER_TEMP/comparison" ]; then + export DIFFOSCOPE_COMPARE_DIR="$RUNNER_TEMP/comparison" + fi + scripts/reproduce-release.sh + + - name: Upload reproducibility artifacts + if: ${{ always() }} + uses: actions/upload-artifact@v6 + with: + name: bitkit-reproducible-release-${{ github.run_number }} + path: ${{ runner.temp }}/reproducible-release + retention-days: 30 diff --git a/README.md b/README.md index 8f1fd45e7..cbb53c126 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,8 @@ just release `just release` builds both the mainnet APK and Play Store AAB. AAB is generated in `app/build/outputs/bundle/mainnetRelease/`. +See [release reproducibility tests](docs/reproducible-builds.md) for the release reproducibility flow. + ### Build for E2E Testing Pass `E2E=true` and build any flavor. By default, E2E uses a local Electrum override. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5542ddbde..ddd883515 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -25,8 +25,14 @@ plugins { val keystoreProperties by lazy { val keystorePropertiesFile = rootProject.file("keystore.properties") val keystoreProperties = Properties() + val envStoreFile = System.getenv("KEYSTORE_FILE")?.takeIf { it.isNotBlank() } - if (keystorePropertiesFile.exists()) { + if (envStoreFile != null) { + keystoreProperties["storeFile"] = envStoreFile + keystoreProperties["storePassword"] = System.getenv("KEYSTORE_PASSWORD") ?: "" + keystoreProperties["keyAlias"] = System.getenv("KEY_ALIAS") ?: "" + keystoreProperties["keyPassword"] = System.getenv("KEY_PASSWORD") ?: "" + } else if (keystorePropertiesFile.exists()) { keystoreProperties.load(FileInputStream(keystorePropertiesFile)) } else { keystoreProperties["storeFile"] = System.getenv("KEYSTORE_FILE") ?: "" diff --git a/docs/reproducible-builds.md b/docs/reproducible-builds.md new file mode 100644 index 000000000..0eb87de73 --- /dev/null +++ b/docs/reproducible-builds.md @@ -0,0 +1,111 @@ +# Reproducible builds + +This document captures the current Bitkit Android release reproducibility test flow. + +## Current release target + +- Flavor/build type: `mainnetRelease` +- Gradle wrapper: `8.13` +- Android Gradle Plugin: `8.13.2` +- Java: `17` +- Compile SDK: `36` +- Bundletool: `1.18.1` +- AAB output: `app/build/outputs/bundle/mainnetRelease/` +- APK output: `app/build/outputs/apk/mainnet/release/` + +The release build needs the private mainnet Firebase config at `app/src/mainnetRelease/google-services.json` and release signing material. CI writes those files from protected GitHub environment secrets; external verifiers need an equivalent file from the project or an agreed public/non-secret release config strategy. + +## Local reproduction + +Configure GitHub Packages credentials without committing secrets: + +```sh +export GITHUB_ACTOR=YOUR_GITHUB_USERNAME +export GITHUB_TOKEN=YOUR_READ_PACKAGES_TOKEN +``` + +Configure release signing without `keystore.properties`: + +```sh +export KEYSTORE_FILE=/absolute/path/to/bitkit.keystore +export KEYSTORE_PASSWORD=... +export KEY_ALIAS=... +export KEY_PASSWORD=... +``` + +Release reproducibility requires an RSA signing key. EC/ECDSA signatures are not byte-stable across signing runs, so the script fails if `KEY_ALIAS` does not resolve to an RSA key. To check the key algorithm locally without sharing the keystore, run: + +```sh +keytool -list -v -keystore "$KEYSTORE_FILE" -alias "$KEY_ALIAS" | grep -Ei 'Public Key Algorithm|Signature algorithm' +``` + +Build the mainnet release bundle and recreate APK splits: + +```sh +scripts/reproduce-release.sh +``` + +The script writes artifacts under `.ai/reproducible-release/` by default: + +- `artifacts/*.aab` +- `artifacts/*.apks` +- `extracted-apks/` +- `checksums/release-artifacts.sha256` +- `checksums/extracted-apks.sha256` +- `checksums/arm64-native-libs.sha256` +- `arm64-apks.txt` +- `arm64-native-libs.txt` + +To reuse an existing AAB: + +```sh +SKIP_GRADLE_BUILD=true AAB_PATH=/path/to/bitkit-mainnet-release-181.aab scripts/reproduce-release.sh +``` + +## GitHub workflow + +The manual `Reproducible Release` workflow builds `bundleMainnetRelease`, recreates APK splits with bundletool, extracts the `arm64-v8a` native libraries, and uploads checksums plus reproduction artifacts. Workflow behavior can only be fully verified after merge because GitHub Actions workflow changes are only active for PRs opened after the workflow change is merged. + +If a comparison artifact from this repository is available in GitHub Actions, pass its artifact name and source workflow run id to the manual workflow inputs. The workflow installs `diffoscope`, writes `diffoscope.html` and `diffoscope.txt`, and fails when the generated APK split tree differs from the comparison artifact. + +## Manual diffoscope checks + +Compare generated APK splits against a downloaded or previously generated APK set: + +```sh +diffoscope path/to/reference-apks .ai/reproducible-release/extracted-apks \ + --html .ai/reproducible-release/diffoscope.html +``` + +Compare only the arm64 native libraries: + +```sh +diffoscope path/to/reference-native-libs .ai/reproducible-release/native-libs \ + --html .ai/reproducible-release/native-libs-diffoscope.html +``` + +## Known release reproducibility gaps + +Issue `synonymdev/bitkit-android#953` previously reported that most release APK contents reproduced, with remaining differences in native libraries inside the `arm64-v8a` split. + +Known mappings: + +- `libbitkitcore.so` and `libpubky_app_specs...so` come from `com.synonym:bitkit-core-android:0.1.58` +- `libdatastore_shared_counter.so` comes from `androidx.datastore:datastore-core:1.2.0` +- `libjnidispatch.so` comes from `net.java.dev.jna:jna:5.18.1` + +The app repository can provide a stable release recipe and artifact checksums. The remaining native reproducibility work is upstream artifact provenance, especially for Rust-produced Android libraries. + +## Upstream native follow-ups + +For Rust/native Android artifacts, the upstream repositories should publish reproducible AAR/native library builds with: + +- pinned Rust toolchain +- pinned Android NDK +- committed `Cargo.lock` +- stable build paths +- `SOURCE_DATE_EPOCH` +- `codegen-units = 1` +- path remapping +- deterministic stripping +- published AAR and native `.so` checksums diff --git a/scripts/reproduce-release.sh b/scripts/reproduce-release.sh new file mode 100755 index 000000000..8affabe52 --- /dev/null +++ b/scripts/reproduce-release.sh @@ -0,0 +1,347 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root=$(git rev-parse --show-toplevel 2>/dev/null || pwd) +cd "$repo_root" + +output_dir=${OUTPUT_DIR:-.ai/reproducible-release} +bundletool_version=${BUNDLETOOL_VERSION:-1.18.1} +bundletool_sha256=${BUNDLETOOL_SHA256:-675786493983787ffa11550bdb7c0715679a44e1643f3ff980a529e9c822595c} +bundletool_url=${BUNDLETOOL_URL:-https://github.com/google/bundletool/releases/download/${bundletool_version}/bundletool-all-${bundletool_version}.jar} +bundletool_jar=${BUNDLETOOL_JAR:-${output_dir}/tools/bundletool-all-${bundletool_version}.jar} + +artifacts_dir="$output_dir/artifacts" +checksums_dir="$output_dir/checksums" +extracted_dir="$output_dir/extracted-apks" +native_dir="$output_dir/native-libs" +bundle_output_dir=app/build/outputs/bundle/mainnetRelease + +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$@" + else + shasum -a 256 "$@" + fi +} + +sha256_value() { + sha256_file "$1" | awk '{ print $1 }' +} + +checksum_tree() { + local root=$1 + local output=$2 + + mkdir -p "$(dirname "$output")" + if [[ ! -d "$root" ]]; then + : > "$output" + return + fi + + ( + cd "$root" + find . -type f | LC_ALL=C sort | while IFS= read -r file; do + file=${file#./} + sha256_file "$file" + done + ) > "$output" +} + +file_mtime() { + if stat -f %m "$1" >/dev/null 2>&1; then + stat -f %m "$1" + else + stat -c %Y "$1" + fi +} + +latest_file() { + local root=$1 + local pattern=$2 + local latest= + local latest_mtime=0 + local candidate + local candidate_mtime + + while IFS= read -r -d '' candidate; do + candidate_mtime=$(file_mtime "$candidate") + if [[ -z "$latest" || "$candidate_mtime" -gt "$latest_mtime" ]]; then + latest=$candidate + latest_mtime=$candidate_mtime + fi + done < <(find "$root" -type f -name "$pattern" -print0) + + printf '%s\n' "$latest" +} + +download_bundletool() { + if [[ -f "$bundletool_jar" ]]; then + local actual + actual=$(sha256_value "$bundletool_jar") + if [[ "$actual" == "$bundletool_sha256" ]]; then + return + fi + rm -f "$bundletool_jar" + fi + + mkdir -p "$(dirname "$bundletool_jar")" + local tmp + tmp=$(mktemp) + curl --fail --location --silent --show-error "$bundletool_url" --output "$tmp" + + local actual + actual=$(sha256_value "$tmp") + if [[ "$actual" != "$bundletool_sha256" ]]; then + echo "bundletool checksum mismatch: expected '$bundletool_sha256', got '$actual'" >&2 + rm -f "$tmp" + exit 1 + fi + + mv "$tmp" "$bundletool_jar" +} + +password_files=() +temp_dirs=() +password_file_result= +cleanup_files() { + if [[ "${#password_files[@]}" -gt 0 ]]; then + rm -f "${password_files[@]}" + fi + if [[ "${#temp_dirs[@]}" -gt 0 ]]; then + rm -rf "${temp_dirs[@]}" + fi +} +trap cleanup_files EXIT + +write_password_file() { + local value=$1 + local file + + file=$(mktemp) + chmod 600 "$file" + printf '%s' "$value" > "$file" + password_files+=("$file") + password_file_result=$file +} + +verify_rsa_signing_key() { + local verifier_dir + local verifier_source + local key_algorithm + + verifier_dir=$(mktemp -d) + temp_dirs+=("$verifier_dir") + verifier_source="$verifier_dir/VerifySigningKeyAlgorithm.java" + + cat > "$verifier_source" <<'JAVA' +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.Key; +import java.security.KeyStore; +import java.security.cert.Certificate; +import java.util.Arrays; + +public class VerifySigningKeyAlgorithm { + public static void main(String[] args) throws Exception { + if (args.length != 4) { + throw new IllegalArgumentException("Expected keystore, alias, store password file, and key password file."); + } + + char[] storePassword = Files.readString(Path.of(args[2])).toCharArray(); + char[] keyPassword = Files.readString(Path.of(args[3])).toCharArray(); + try { + KeyStore keyStore = KeyStore.getInstance(new File(args[0]), storePassword); + Key key = keyStore.getKey(args[1], keyPassword); + if (key != null) { + System.out.println(key.getAlgorithm()); + return; + } + + Certificate certificate = keyStore.getCertificate(args[1]); + if (certificate == null) { + throw new IllegalArgumentException("Signing key alias was not found."); + } + System.out.println(certificate.getPublicKey().getAlgorithm()); + } finally { + Arrays.fill(storePassword, '\0'); + Arrays.fill(keyPassword, '\0'); + } + } +} +JAVA + + if ! key_algorithm=$(java "$verifier_source" "$KEYSTORE_FILE" "$KEY_ALIAS" "$keystore_password_file" "$key_password_file"); then + echo "Failed to inspect release signing key algorithm." >&2 + exit 1 + fi + + if [[ "$key_algorithm" != "RSA" ]]; then + echo "Release reproducibility requires an RSA signing key; '$KEY_ALIAS' uses '$key_algorithm'." >&2 + echo "EC/ECDSA signatures are not byte-stable across signing runs." >&2 + exit 1 + fi +} + +preserve_overlapping_comparison_dir() { + local compare_real + local output_real + local preserved_compare_dir + + if [[ -z "${DIFFOSCOPE_COMPARE_DIR:-}" || ! -d "$DIFFOSCOPE_COMPARE_DIR" || ! -d "$output_dir" ]]; then + return + fi + + compare_real=$(cd "$DIFFOSCOPE_COMPARE_DIR" && pwd -P) + output_real=$(cd "$output_dir" && pwd -P) + if [[ "$compare_real" != "$output_real" && "$compare_real" != "$output_real/"* ]]; then + return + fi + + preserved_compare_dir=$(mktemp -d) + temp_dirs+=("$preserved_compare_dir") + cp -a "$DIFFOSCOPE_COMPARE_DIR"/. "$preserved_compare_dir"/ + export DIFFOSCOPE_COMPARE_DIR="$preserved_compare_dir" +} + +if [[ -z "${KEYSTORE_FILE:-}" || -z "${KEYSTORE_PASSWORD:-}" || -z "${KEY_ALIAS:-}" ]]; then + echo "Release signing requires KEYSTORE_FILE, KEYSTORE_PASSWORD, and KEY_ALIAS." >&2 + exit 1 +fi +if [[ ! -f "$KEYSTORE_FILE" ]]; then + echo "KEYSTORE_FILE not found: '$KEYSTORE_FILE'." >&2 + exit 1 +fi +export KEY_PASSWORD="${KEY_PASSWORD:-$KEYSTORE_PASSWORD}" + +write_password_file "$KEYSTORE_PASSWORD" +keystore_password_file=$password_file_result +write_password_file "$KEY_PASSWORD" +key_password_file=$password_file_result +verify_rsa_signing_key +signing_args=( + "--ks=$KEYSTORE_FILE" + "--ks-pass=file:$keystore_password_file" + "--ks-key-alias=$KEY_ALIAS" + "--key-pass=file:$key_password_file" +) + +aab_path= +aab_name= +if [[ "${SKIP_GRADLE_BUILD:-false}" == "true" ]]; then + aab_path=${AAB_PATH:-} + if [[ -z "$aab_path" ]]; then + echo "AAB_PATH is required when SKIP_GRADLE_BUILD=true." >&2 + exit 1 + fi + if [[ ! -f "$aab_path" ]]; then + echo "AAB not found. Set AAB_PATH or run bundleMainnetRelease first." >&2 + exit 1 + fi + + aab_name=$(basename "$aab_path") + preserved_aab_dir=$(mktemp -d) + temp_dirs+=("$preserved_aab_dir") + cp "$aab_path" "$preserved_aab_dir/$aab_name" + aab_path="$preserved_aab_dir/$aab_name" +fi + +preserve_overlapping_comparison_dir +rm -rf "$artifacts_dir" "$checksums_dir" "$extracted_dir" "$native_dir" +rm -f \ + "$output_dir/README.txt" \ + "$output_dir/apks.txt" \ + "$output_dir/arm64-apks.txt" \ + "$output_dir/arm64-native-libs.txt" \ + "$output_dir/diffoscope.html" \ + "$output_dir/diffoscope.txt" +mkdir -p "$artifacts_dir" "$checksums_dir" "$extracted_dir" "$native_dir" + +if [[ "${SKIP_GRADLE_BUILD:-false}" != "true" ]]; then + rm -rf "$bundle_output_dir" + empty_maven_local_dir=$(mktemp -d) + temp_dirs+=("$empty_maven_local_dir") + env \ + E2E=false \ + E2E_BACKEND=local \ + E2E_HOMEGATE_URL=http://127.0.0.1:6288 \ + GEO=true \ + PAYKIT_UI_DISABLED=false \ + TREZOR_BRIDGE=false \ + TREZOR_BRIDGE_URL=http://10.0.2.2:21325 \ + ./gradlew \ + -Dmaven.repo.local="$empty_maven_local_dir" \ + bundleMainnetRelease \ + --no-daemon \ + --stacktrace +fi + +if [[ -z "$aab_path" ]]; then + aab_path=$(latest_file "$bundle_output_dir" 'bitkit-mainnet-release-*.aab') +fi +if [[ ! -f "$aab_path" ]]; then + echo "AAB not found. Set AAB_PATH or run bundleMainnetRelease first." >&2 + exit 1 +fi + +download_bundletool + +if [[ -z "$aab_name" ]]; then + aab_name=$(basename "$aab_path") +fi +apks_path="$artifacts_dir/${aab_name%.aab}.apks" +cp "$aab_path" "$artifacts_dir/$aab_name" + +java -jar "$bundletool_jar" build-apks \ + --bundle="$aab_path" \ + --output="$apks_path" \ + --mode=default \ + --overwrite \ + "${signing_args[@]}" + +unzip -q "$apks_path" -d "$extracted_dir" + +find "$extracted_dir" -type f -name '*.apk' | LC_ALL=C sort > "$output_dir/apks.txt" +grep -E 'arm64[-_]v8a' "$output_dir/apks.txt" > "$output_dir/arm64-apks.txt" || true + +while IFS= read -r apk; do + apk_name=$(basename "$apk" .apk) + mkdir -p "$native_dir/$apk_name" + unzip -q -o "$apk" 'lib/arm64-v8a/*.so' -d "$native_dir/$apk_name" 2>/dev/null || true +done < "$output_dir/apks.txt" + +find "$native_dir" -type f -name '*.so' | LC_ALL=C sort > "$output_dir/arm64-native-libs.txt" + +checksum_tree "$artifacts_dir" "$checksums_dir/release-artifacts.sha256" +checksum_tree "$extracted_dir" "$checksums_dir/extracted-apks.sha256" +checksum_tree "$native_dir" "$checksums_dir/arm64-native-libs.sha256" + +diffoscope_status=0 +if [[ -n "${DIFFOSCOPE_COMPARE_DIR:-}" && -d "$DIFFOSCOPE_COMPARE_DIR" ]]; then + if command -v diffoscope >/dev/null 2>&1; then + diffoscope "$DIFFOSCOPE_COMPARE_DIR" "$extracted_dir" \ + --html "$output_dir/diffoscope.html" \ + > "$output_dir/diffoscope.txt" || diffoscope_status=$? + else + echo "diffoscope is not installed; skipping comparison." > "$output_dir/diffoscope.txt" + fi +fi + +cat > "$output_dir/README.txt" <&2 + exit "$diffoscope_status" +fi