diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 24d50c88..7692c044 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -202,18 +202,32 @@ jobs: # release-android job + docs/contributing.rst "Android release signing" # there); signing itself happens in scripts/sign_apk.sh via the Makefile. # Keep the two implementations consistent when changing either. - # gptme delivers the keystore as a base64 secret instead of age-encrypted - # in-repo, pins the signer cert SHA-256, and fails closed on missing - # secrets — planned to be adopted here too (see issue tracker). - - uses: adnsio/setup-age-action@v1.2.0 - - name: Load Android secrets - if: env.KEY_ANDROID_JKS != null + # Keystore is delivered as a base64 secret (KEY_ANDROID_JKS_B64) and decoded + # here; the signer cert SHA-256 is pinned in ANDROID_CERT_SHA256 (repo variable) + # and verified after each APK is signed. + - name: Require signing configuration on release tags + if: startsWith(github.ref, 'refs/tags/v') env: - KEY_ANDROID_JKS: ${{ secrets.KEY_ANDROID_JKS }} + HAS_SIGNING: >- + ${{ secrets.KEY_ANDROID_JKS_B64 != '' && + secrets.KEY_ANDROID_JKS_STOREPASS != '' && + secrets.KEY_ANDROID_JKS_KEYPASS != '' && + vars.ANDROID_CERT_SHA256 != '' }} run: | - printf "$KEY_ANDROID_JKS" > android.jks.key - cat android.jks.age | age -d -i android.jks.key -o android.jks - rm android.jks.key + if [ "$HAS_SIGNING" != "true" ]; then + echo "::error::Android release signing is not fully configured." + echo "::error::Provision KEY_ANDROID_JKS_B64, KEY_ANDROID_JKS_STOREPASS, KEY_ANDROID_JKS_KEYPASS" + echo "::error::secrets and ANDROID_CERT_SHA256 repo variable before cutting a release tag." + exit 1 + fi + + - name: Load Android keystore + if: ${{ secrets.KEY_ANDROID_JKS_B64 != '' }} + env: + KEY_ANDROID_JKS_B64: ${{ secrets.KEY_ANDROID_JKS_B64 }} + run: | + printf '%s' "$KEY_ANDROID_JKS_B64" | base64 --decode > android.jks + chmod 600 android.jks - name: Assemble env: @@ -222,6 +236,28 @@ jobs: run: | make dist/aw-android.${{ matrix.type }} + - name: Verify APK signer certificate + if: matrix.type == 'apk' && vars.ANDROID_CERT_SHA256 != '' + env: + ANDROID_CERT_SHA256: ${{ vars.ANDROID_CERT_SHA256 }} + run: | + apksigner=$(find "$ANDROID_HOME/build-tools" -name "apksigner" -print | sort -V | tail -1) + apk=dist/aw-android.apk + actual=$("$apksigner" verify --print-certs "$apk" \ + | grep "Signer #1 certificate SHA-256 digest:" \ + | awk '{print $NF}') + if [ -z "$actual" ]; then + echo "::error::Could not extract signer certificate from $apk" + exit 1 + fi + if [ "$actual" != "$ANDROID_CERT_SHA256" ]; then + echo "::error::Signer certificate SHA-256 mismatch — possible key rotation or wrong keystore." + echo "::error:: expected: $ANDROID_CERT_SHA256" + echo "::error:: actual: $actual" + exit 1 + fi + echo "Signer certificate verified: $actual" + - name: Upload uses: actions/upload-artifact@v4 with: @@ -421,16 +457,13 @@ jobs: fi echo "SUPPLY_TRACK=${SUPPLY_TRACK}" >> $GITHUB_ENV - - uses: adnsio/setup-age-action@v1.2.0 - - name: Load Android secrets + - name: Load Fastlane API credentials env: - KEY_FASTLANE_API: ${{ secrets.KEY_FASTLANE_API }} + KEY_FASTLANE_API_B64: ${{ secrets.KEY_FASTLANE_API_B64 }} run: | - printf "$KEY_FASTLANE_API" > fastlane/api-8546008605074111507-287154-450dc77b365f.json.key - cat fastlane/api-8546008605074111507-287154-450dc77b365f.json.age \ - | age -d -i fastlane/api-8546008605074111507-287154-450dc77b365f.json.key \ - -o fastlane/api-8546008605074111507-287154-450dc77b365f.json - rm fastlane/api-8546008605074111507-287154-450dc77b365f.json.key + printf '%s' "$KEY_FASTLANE_API_B64" | base64 --decode \ + > fastlane/api-8546008605074111507-287154-450dc77b365f.json + chmod 600 fastlane/api-8546008605074111507-287154-450dc77b365f.json - name: Release with fastlane run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 435e9fce..8b517750 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -83,17 +83,13 @@ jobs: with: bundler-cache: true - - uses: adnsio/setup-age-action@v1.2.0 - - - name: Load Fastlane secrets + - name: Load Fastlane API credentials env: - KEY_FASTLANE_API: ${{ secrets.KEY_FASTLANE_API }} + KEY_FASTLANE_API_B64: ${{ secrets.KEY_FASTLANE_API_B64 }} run: | - printf "$KEY_FASTLANE_API" > fastlane/api-8546008605074111507-287154-450dc77b365f.json.key - cat fastlane/api-8546008605074111507-287154-450dc77b365f.json.age \ - | age -d -i fastlane/api-8546008605074111507-287154-450dc77b365f.json.key \ - -o fastlane/api-8546008605074111507-287154-450dc77b365f.json - rm fastlane/api-8546008605074111507-287154-450dc77b365f.json.key + printf '%s' "$KEY_FASTLANE_API_B64" | base64 --decode \ + > fastlane/api-8546008605074111507-287154-450dc77b365f.json + chmod 600 fastlane/api-8546008605074111507-287154-450dc77b365f.json - name: Update versionCode uses: Wandalen/wretry.action@v3.8.0_js_action diff --git a/android.jks.age b/android.jks.age deleted file mode 100644 index 7a1d3290..00000000 Binary files a/android.jks.age and /dev/null differ diff --git a/fastlane/api-8546008605074111507-287154-450dc77b365f.json.age b/fastlane/api-8546008605074111507-287154-450dc77b365f.json.age deleted file mode 100644 index 835d5edd..00000000 Binary files a/fastlane/api-8546008605074111507-287154-450dc77b365f.json.age and /dev/null differ diff --git a/scripts/sign_apk.sh b/scripts/sign_apk.sh index 282b7c90..08e8423f 100755 --- a/scripts/sign_apk.sh +++ b/scripts/sign_apk.sh @@ -7,8 +7,10 @@ # release signing (gptme/gptme .github/workflows/tauri.yml release-android job, # documented in docs/contributing.rst "Android release signing" there). # Keep the two implementations consistent when changing either. -# gptme additionally pins the signer cert SHA-256 and fails closed when -# signing secrets are missing — planned to be adopted here too. +# +# Optional: set ANDROID_CERT_SHA256 to the expected signer cert SHA-256 digest +# (from `apksigner verify --print-certs`). When set, the script verifies the +# APK's actual signer cert matches — fails loudly on mismatch. set -e @@ -43,19 +45,42 @@ fi # Using apksigner for APKs instead of jarsigner since API 30+: https://stackoverflow.com/a/69473649 # Using jarsigner for AABs since apksigner doesn't support them if [[ $input == *.apk ]]; then - apksigner=$(find $ANDROID_HOME/build-tools -name "apksigner" -print | head -n 1) + apksigner=$(find $ANDROID_HOME/build-tools -name "apksigner" -print | sort -V | tail -n 1) $apksigner sign --ks android.jks --ks-key-alias activitywatch \ --ks-pass env:JKS_STOREPASS --key-pass env:JKS_KEYPASS \ $input - # Verify + # Verify signature integrity $apksigner verify $input + + # Verify signer cert SHA-256 if pinned (set ANDROID_CERT_SHA256 to pin) + if [ -n "${ANDROID_CERT_SHA256:-}" ]; then + actual=$($apksigner verify --print-certs "$input" \ + | grep "Signer #1 certificate SHA-256 digest:" \ + | awk '{print $NF}') + if [ -z "$actual" ]; then + echo "ERROR: Could not extract signer certificate SHA-256 from $input" + exit 1 + fi + if [ "$actual" != "$ANDROID_CERT_SHA256" ]; then + echo "ERROR: Signer certificate SHA-256 mismatch — possible key rotation or wrong keystore." + echo " expected: $ANDROID_CERT_SHA256" + echo " actual: $actual" + exit 1 + fi + echo "Signer certificate verified: $actual" + fi fi if [[ $input == *.aab ]]; then jarsigner -verbose \ -keystore android.jks \ -storepass $JKS_STOREPASS -keypass $JKS_KEYPASS \ $input activitywatch + + # Verify the bundle before it can be uploaded. `-strict` turns signer and + # certificate problems that jarsigner otherwise reports as warnings into a + # non-zero exit status. + jarsigner -verify -strict "$input" fi # Move to output destination