diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 24d50c88..c133ff1a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -202,26 +202,76 @@ 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 and AAB 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 + env: + KEY_ANDROID_JKS_B64: ${{ secrets.KEY_ANDROID_JKS_B64 }} + run: | + if [ -z "$KEY_ANDROID_JKS_B64" ]; then + echo "No keystore secret configured; skipping." + exit 0 + fi + printf '%s' "$KEY_ANDROID_JKS_B64" | base64 --decode > android.jks + chmod 600 android.jks - name: Assemble env: JKS_STOREPASS: ${{ secrets.KEY_ANDROID_JKS_STOREPASS }} JKS_KEYPASS: ${{ secrets.KEY_ANDROID_JKS_KEYPASS }} + ANDROID_CERT_SHA256: ${{ vars.ANDROID_CERT_SHA256 }} run: | make dist/aw-android.${{ matrix.type }} + - name: Verify signer certificate + if: vars.ANDROID_CERT_SHA256 != '' + env: + ANDROID_CERT_SHA256: ${{ vars.ANDROID_CERT_SHA256 }} + run: | + artifact=dist/aw-android.${{ matrix.type }} + expected=$(printf '%s' "$ANDROID_CERT_SHA256" | tr -d ' :\n' | tr 'A-F' 'a-f') + if [ "${{ matrix.type }}" = "apk" ]; then + apksigner=$(find "$ANDROID_HOME/build-tools" -name "apksigner" -print | sort -V | tail -1) + actual=$("$apksigner" verify --print-certs "$artifact" \ + | grep "Signer #1 certificate SHA-256 digest:" \ + | awk '{print $NF}') + else + # jarsigner-signed AABs are JARs; keytool reads META-INF PKCS7 + # (same as gptme/gptme .github/workflows/tauri.yml). + actual=$(keytool -printcert -jarfile "$artifact" \ + | sed -n 's/^[[:space:]]*SHA256:[[:space:]]*//p' \ + | head -n 1) + fi + actual=$(printf '%s' "$actual" | tr -d ' :\n' | tr 'A-F' 'a-f') + if [ -z "$actual" ]; then + echo "::error::Could not extract signer certificate from $artifact" + exit 1 + fi + if [ "$actual" != "$expected" ]; then + echo "::error::Signer certificate SHA-256 mismatch — possible key rotation or wrong keystore." + echo "::error:: expected: $expected" + echo "::error:: actual: $actual" + exit 1 + fi + echo "Signer certificate verified: $actual" + - name: Upload uses: actions/upload-artifact@v4 with: @@ -421,16 +471,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..b3773f18 100755 --- a/scripts/sign_apk.sh +++ b/scripts/sign_apk.sh @@ -7,8 +7,12 @@ # 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`, lowercase hex without colons). When +# set, the script verifies the signed APK *and* AAB signer cert matches — +# fails loudly on mismatch. keytool fingerprints (colon-separated uppercase) +# are normalized to the same form. set -e @@ -31,6 +35,47 @@ if [ -z $JKS_KEYPASS ]; then exit 1 fi +# apksigner prints lowercase hex without colons; keytool prints uppercase +# colon-separated SHA256. Normalize both (and the pin) before comparing. +_normalize_sha256() { + printf '%s' "$1" | tr -d ' :\n' | tr 'A-F' 'a-f' +} + +# Fail closed when ANDROID_CERT_SHA256 is set and the signed artifact's +# signer cert does not match. APKs use apksigner; AABs use keytool because +# apksigner does not support app bundles. jarsigner-signed AABs are JARs, +# so `keytool -printcert -jarfile` reads the META-INF PKCS7 signer cert +# (same command as gptme/gptme .github/workflows/tauri.yml). +_verify_pinned_cert() { + local file=$1 + local actual expected + if [ -z "${ANDROID_CERT_SHA256:-}" ]; then + return 0 + fi + if [[ $file == *.apk ]]; then + actual=$($apksigner verify --print-certs "$file" \ + | grep "Signer #1 certificate SHA-256 digest:" \ + | awk '{print $NF}') + else + actual=$(keytool -printcert -jarfile "$file" \ + | sed -n 's/^[[:space:]]*SHA256:[[:space:]]*//p' \ + | head -n 1) + fi + actual=$(_normalize_sha256 "$actual") + expected=$(_normalize_sha256 "$ANDROID_CERT_SHA256") + if [ -z "$actual" ]; then + echo "ERROR: Could not extract signer certificate SHA-256 from $file" + exit 1 + fi + if [ "$actual" != "$expected" ]; then + echo "ERROR: Signer certificate SHA-256 mismatch — possible key rotation or wrong keystore." + echo " expected: $expected" + echo " actual: $actual" + exit 1 + fi + echo "Signer certificate verified: $actual" +} + # Zipalign # Not needed for AABs if [[ $input == *.apk ]]; then @@ -43,19 +88,34 @@ 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_pinned_cert "$input" 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. Do not use -strict: + # Android upload keys are self-signed, so PKIX chain validation fails + # with exit 4 even when the signature is valid. Identity is enforced + # by the ANDROID_CERT_SHA256 pin below (same as gptme/gptme tauri.yml). + verify_out=$(jarsigner -verify "$input") + printf '%s\n' "$verify_out" + if ! printf '%s' "$verify_out" | grep -q "jar verified"; then + echo "ERROR: AAB signature verification failed: $input" + exit 1 + fi + + _verify_pinned_cert "$input" fi # Move to output destination