From fc67787090fdf321335bc07c898f5d5ee0280908 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 6 Nov 2026 14:33:18 -0800 Subject: [PATCH] feat(mobile): sign Android release builds with a dedicated release key (#38924) * feat(mobile): sign Android release builds with a dedicated release key Add an Expo config plugin that gives the generated app/build.gradle an env-driven release signing config, and have the Android release workflow require the keystore secrets, fail before building when they are missing, and check the APK's signing certificate against a pinned fingerprint. * fix(mobile): keep the signing setup hint intact in the workflow error * test(mobile): type the plugin import without a cast * feat(mobile): keep debug signing until release signing is enabled Release signing now turns on when the ORCA_ANDROID_RELEASE_CERT_SHA256 (or ORCA_ANDROID_REQUIRE_RELEASE_SIGNING) repository variable is set. Until then the workflow keeps today's debug-signed build and adds a warning and job summary note; once enabled, missing secrets fail. * feat(mobile): enable v2 and v3 APK signing for the release key --- .github/workflows/mobile-android-release.yml | 86 ++++++++++++++- mobile/README.md | 25 +++++ mobile/app.json | 1 + mobile/plugins/android-release-signing.js | 101 ++++++++++++++++++ .../android-release-signing-plugin.test.ts | 59 ++++++++++ 5 files changed, 270 insertions(+), 2 deletions(-) create mode 100644 mobile/plugins/android-release-signing.js create mode 100644 mobile/src/mobile-release/android-release-signing-plugin.test.ts diff --git a/.github/workflows/mobile-android-release.yml b/.github/workflows/mobile-android-release.yml index 15a37dd13f4..f8c00264af7 100644 --- a/.github/workflows/mobile-android-release.yml +++ b/.github/workflows/mobile-android-release.yml @@ -37,6 +37,38 @@ jobs: - name: Checkout uses: actions/checkout@v6 + # Release signing is enforced once ORCA_ANDROID_RELEASE_CERT_SHA256 (or ORCA_ANDROID_REQUIRE_RELEASE_SIGNING=true) + # is set as a repository variable. Until then the build keeps the Expo debug key, with a warning. + - name: Check release signing configuration + id: signing + env: + ORCA_ANDROID_KEYSTORE_BASE64: ${{ secrets.ORCA_ANDROID_KEYSTORE_BASE64 }} + ORCA_ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ORCA_ANDROID_KEYSTORE_PASSWORD }} + ORCA_ANDROID_KEY_ALIAS: ${{ secrets.ORCA_ANDROID_KEY_ALIAS }} + ORCA_ANDROID_KEY_PASSWORD: ${{ secrets.ORCA_ANDROID_KEY_PASSWORD }} + ORCA_ANDROID_RELEASE_CERT_SHA256: ${{ vars.ORCA_ANDROID_RELEASE_CERT_SHA256 }} + ORCA_ANDROID_REQUIRE_RELEASE_SIGNING: ${{ vars.ORCA_ANDROID_REQUIRE_RELEASE_SIGNING }} + run: | + set -euo pipefail + if [ -z "${ORCA_ANDROID_RELEASE_CERT_SHA256:-}" ] && [ "${ORCA_ANDROID_REQUIRE_RELEASE_SIGNING:-}" != 'true' ]; then + message="Android release signing is not enabled, so this APK is signed with the Expo debug key as before. Set the ORCA_ANDROID_RELEASE_CERT_SHA256 repository variable to enable it; see the Android Release Signing section of mobile/README.md." + echo "::warning::$message" + printf '### Android release signing not enabled\n\n%s\n' "$message" >> "$GITHUB_STEP_SUMMARY" + echo "mode=debug" >> "$GITHUB_OUTPUT" + exit 0 + fi + missing=() + for name in ORCA_ANDROID_KEYSTORE_BASE64 ORCA_ANDROID_KEYSTORE_PASSWORD ORCA_ANDROID_KEY_ALIAS ORCA_ANDROID_KEY_PASSWORD ORCA_ANDROID_RELEASE_CERT_SHA256; do + if [ -z "${!name:-}" ]; then + missing+=("$name") + fi + done + if [ "${#missing[@]}" -ne 0 ]; then + echo "::error::Android release signing is enabled but not fully configured. Missing secrets or variables: ${missing[*]}. See the Android Release Signing section of mobile/README.md." + exit 1 + fi + echo "mode=release" >> "$GITHUB_OUTPUT" + - name: Setup Node.js uses: actions/setup-node@v6 with: @@ -67,9 +99,54 @@ jobs: - name: Expo prebuild run: npx expo prebuild --platform android --no-install + - name: Decode release keystore + if: steps.signing.outputs.mode == 'release' + env: + ORCA_ANDROID_KEYSTORE_BASE64: ${{ secrets.ORCA_ANDROID_KEYSTORE_BASE64 }} + run: | + set -euo pipefail + umask 077 + printf '%s' "$ORCA_ANDROID_KEYSTORE_BASE64" | base64 --decode > "$RUNNER_TEMP/orca-release.keystore" + - name: Build Android release APK + env: + # Empty in debug mode, which leaves Gradle on the debug key exactly as before. + ORCA_ANDROID_REQUIRE_RELEASE_SIGNING: ${{ steps.signing.outputs.mode == 'release' && 'true' || '' }} + ORCA_ANDROID_KEYSTORE_PATH: ${{ steps.signing.outputs.mode == 'release' && format('{0}/orca-release.keystore', runner.temp) || '' }} + ORCA_ANDROID_KEYSTORE_PASSWORD: ${{ steps.signing.outputs.mode == 'release' && secrets.ORCA_ANDROID_KEYSTORE_PASSWORD || '' }} + ORCA_ANDROID_KEY_ALIAS: ${{ steps.signing.outputs.mode == 'release' && secrets.ORCA_ANDROID_KEY_ALIAS || '' }} + ORCA_ANDROID_KEY_PASSWORD: ${{ steps.signing.outputs.mode == 'release' && secrets.ORCA_ANDROID_KEY_PASSWORD || '' }} run: cd android && ./gradlew assembleRelease + - name: Remove release keystore + if: always() + run: rm -f "$RUNNER_TEMP/orca-release.keystore" + + # Why pin the certificate: a regenerated or swapped key would make every installed copy refuse the update. + - name: Verify APK signing certificate + id: signer + if: steps.signing.outputs.mode == 'release' + env: + EXPECTED_CERT_SHA256: ${{ vars.ORCA_ANDROID_RELEASE_CERT_SHA256 }} + run: | + set -euo pipefail + # Expo template debug keystore, public in expo-template-bare-minimum. + debug_cert="fac61745dc0903786fb9ede62a962b399f7348f0bb6f899b8332667591033b9c" + apksigner="$(ls -d "$ANDROID_HOME"/build-tools/*/apksigner | sort -V | tail -1)" + apks=(android/app/build/outputs/apk/release/*.apk) + certs="$("$apksigner" verify --print-certs "${apks[0]}" | sed -n 's/^Signer #[0-9]* certificate SHA-256 digest: //p' | sort -u)" + expected="$(printf '%s' "$EXPECTED_CERT_SHA256" | tr -d ': ' | tr 'A-F' 'a-f')" + if [ "$certs" = "$debug_cert" ]; then + echo "::error::The APK is signed with the public Expo debug key." + exit 1 + fi + if [ "$certs" != "$expected" ]; then + echo "::error::The APK signing certificate ($certs) does not match ORCA_ANDROID_RELEASE_CERT_SHA256 ($expected)." + exit 1 + fi + echo "cert_sha256=$(printf '%s' "$certs" | tr 'a-f' 'A-F' | sed 's/../&:/g; s/:$//')" >> "$GITHUB_OUTPUT" + echo "APK signing certificate SHA-256: $certs" + # Publish size/digest to distinguish truncated downloads (missing tail signature block) from bad APK builds. - name: Checksum the APK id: apk @@ -129,9 +206,14 @@ jobs: # printf rather than a heredoc: YAML block indentation would leak into the Markdown. # Both platforms' commands are named because a reader on macOS has no sha256sum. + # Only release-signed builds list a certificate, since only they are pinned. + signer_line='' + if [ -n '${{ steps.signer.outputs.cert_sha256 }}' ]; then + signer_line='- Signing certificate SHA-256: `${{ steps.signer.outputs.cert_sha256 }}`\n' + fi verification_section() { - printf '\n### Verify your download\n\n- Size: `%s` bytes\n- SHA-256: `%s`\n\nA "no certificate" or "problem parsing the package" install failure is usually a truncated download — check the size first, then `sha256sum -c app-release.apk.sha256` (`shasum -a 256 -c` on macOS).\n' \ - '${{ steps.apk.outputs.bytes }}' '${{ steps.apk.outputs.sha256 }}' + printf '\n### Verify your download\n\n- Size: `%s` bytes\n- SHA-256: `%s`\n%b\nA "no certificate" or "problem parsing the package" install failure is usually a truncated download — check the size first, then `sha256sum -c app-release.apk.sha256` (`shasum -a 256 -c` on macOS).\n' \ + '${{ steps.apk.outputs.bytes }}' '${{ steps.apk.outputs.sha256 }}' "$signer_line" } # Verification is last; replace from heading to EOF, accepting API-roundtripped CRLFs. diff --git a/mobile/README.md b/mobile/README.md index 2104364b359..f4df8bd58e7 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -186,6 +186,31 @@ Run `pnpm sync:mobile-localization` from the repo root to add new keys to `src/i other catalogs may lag and fall back to English. `pnpm verify:mobile-localization` and `pnpm verify:mobile-localization-coverage` run in CI. iOS permission prompts live in `locales/.json`. +## Android Release Signing + +`plugins/android-release-signing.js` adds a `release` signing config to the generated +`android/app/build.gradle`. It reads `ORCA_ANDROID_KEYSTORE_PATH`, `ORCA_ANDROID_KEYSTORE_PASSWORD`, +`ORCA_ANDROID_KEY_ALIAS` and `ORCA_ANDROID_KEY_PASSWORD` at build time; nothing is written into the +project. Without them, local release builds keep Expo's debug key. + +`.github/workflows/mobile-android-release.yml` turns release signing on once the +`ORCA_ANDROID_RELEASE_CERT_SHA256` repository variable (or `ORCA_ANDROID_REQUIRE_RELEASE_SIGNING=true`) +is set. Until then it signs with the debug key as before and adds a warning to the run. Once it is +on, the workflow fails before building if any of these is missing: + +| Name | Kind | Value | +| ---------------------------------- | -------- | ------------------------------------------------------- | +| `ORCA_ANDROID_KEYSTORE_BASE64` | secret | the release keystore, base64-encoded | +| `ORCA_ANDROID_KEYSTORE_PASSWORD` | secret | keystore password | +| `ORCA_ANDROID_KEY_ALIAS` | secret | key alias | +| `ORCA_ANDROID_KEY_PASSWORD` | secret | key password (same as the keystore password for PKCS12) | +| `ORCA_ANDROID_RELEASE_CERT_SHA256` | variable | the key's certificate SHA-256 fingerprint | + +After a release-signed build, the workflow checks that the APK's signing certificate matches +`ORCA_ANDROID_RELEASE_CERT_SHA256` and is not the debug key, and publishes the fingerprint in the +release notes. Android only installs an update signed by the same key as the installed app, so +the keystore must be backed up: losing it means no existing install can be updated in place. + ## Protocol Version Compatibility Mobile and desktop talk over a versioned protocol. Because mobile updates lag desktop by 24-48h via the App Store, both sides exchange version numbers on `status.get` so a genuinely incompatible combo can hard-block instead of silently misbehaving. diff --git a/mobile/app.json b/mobile/app.json index 2442f194d5b..61e54d2421c 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -112,6 +112,7 @@ "expo-notifications", "./plugins/android-respect-rotation-lock.js", "./plugins/android-window-density.js", + "./plugins/android-release-signing.js", "./plugins/android-navigation-bar-theme.js", "./plugins/ios-pod-deployment-target-floor.js", "./plugins/ios-scene-lifecycle.js", diff --git a/mobile/plugins/android-release-signing.js b/mobile/plugins/android-release-signing.js new file mode 100644 index 00000000000..c31da47a770 --- /dev/null +++ b/mobile/plugins/android-release-signing.js @@ -0,0 +1,101 @@ +const { withAppBuildGradle } = require('expo/config-plugins') + +// Why: Expo's template signs `release` with its public debug keystore. This adds a `release` +// signing config read from environment variables at build time, so no key or password is ever +// written into the generated project. Local release builds without the variables keep the debug +// key; once release signing is enabled, the release workflow sets ORCA_ANDROID_REQUIRE_RELEASE_SIGNING=true +// so a missing key fails the build instead of shipping a debug-signed APK. +const MARKER = 'orca: release signing' + +const DEBUG_SIGNING_CONFIG = ` debug { + storeFile file('debug.keystore') + storePassword 'android' + keyAlias 'androiddebugkey' + keyPassword 'android' + } +` + +const RELEASE_SIGNING_CONFIG = ` // ${MARKER}, generated by plugins/android-release-signing.js. + release { + // v3 alongside v2 so a later key rotation can ship a signing-certificate lineage. + enableV2Signing = true + enableV3Signing = true + def orcaKeystorePath = System.getenv('ORCA_ANDROID_KEYSTORE_PATH') + if (orcaKeystorePath) { + storeFile file(orcaKeystorePath) + storePassword System.getenv('ORCA_ANDROID_KEYSTORE_PASSWORD') + keyAlias System.getenv('ORCA_ANDROID_KEY_ALIAS') + keyPassword System.getenv('ORCA_ANDROID_KEY_PASSWORD') + } + } +` + +const RELEASE_BUILD_TYPE_SIGNING = + /(\n release \{\n(?:\s*\/\/[^\n]*\n)*)\s*signingConfig signingConfigs\.debug\n/ + +const RELEASE_SIGNING_SELECTION = `$1 signingConfig orcaReleaseSigningConfig(signingConfigs) +` + +const RELEASE_SIGNING_HELPER = ` +// ${MARKER}: pick the release key when its variables are set; required in the release workflow. +def orcaReleaseSigningConfig(signingConfigs) { + def names = [ + 'ORCA_ANDROID_KEYSTORE_PATH', + 'ORCA_ANDROID_KEYSTORE_PASSWORD', + 'ORCA_ANDROID_KEY_ALIAS', + 'ORCA_ANDROID_KEY_PASSWORD' + ] + def missing = names.findAll { !System.getenv(it) } + def keystorePath = System.getenv('ORCA_ANDROID_KEYSTORE_PATH') + if (missing.isEmpty() && !new File(keystorePath).isFile()) { + missing = ["ORCA_ANDROID_KEYSTORE_PATH (no file at \${keystorePath})"] + } + if (missing.isEmpty()) { + return signingConfigs.release + } + if (System.getenv('ORCA_ANDROID_REQUIRE_RELEASE_SIGNING') == 'true') { + throw new GradleException( + "Android release signing is required but not configured. Missing: \${missing.join(', ')}" + ) + } + if (missing.size() < names.size()) { + throw new GradleException( + "Android release signing is partially configured. Missing: \${missing.join(', ')}" + ) + } + return signingConfigs.debug +} +` + +function patchAppBuildGradle(contents) { + if (contents.includes(MARKER)) { + return contents + } + // A changed template must fail prebuild rather than leave release builds on the debug key. + if (!contents.includes(DEBUG_SIGNING_CONFIG) || !RELEASE_BUILD_TYPE_SIGNING.test(contents)) { + throw new Error( + "android-release-signing: Expo's app/build.gradle template changed; update the plugin before building." + ) + } + // Select first: the inserted signing config is also a `release {` block. + const withSelection = contents.replace(RELEASE_BUILD_TYPE_SIGNING, RELEASE_SIGNING_SELECTION) + const withSigningConfig = withSelection.replace( + DEBUG_SIGNING_CONFIG, + `${DEBUG_SIGNING_CONFIG}${RELEASE_SIGNING_CONFIG}` + ) + return `${withSigningConfig.trimEnd()}\n${RELEASE_SIGNING_HELPER}` +} + +module.exports = function withAndroidReleaseSigning(config) { + return withAppBuildGradle(config, (cfg) => { + if (cfg.modResults.language !== 'groovy') { + throw new Error( + `android-release-signing: expected a Groovy app/build.gradle, got ${cfg.modResults.language}` + ) + } + cfg.modResults.contents = patchAppBuildGradle(cfg.modResults.contents) + return cfg + }) +} + +module.exports.patchAppBuildGradle = patchAppBuildGradle diff --git a/mobile/src/mobile-release/android-release-signing-plugin.test.ts b/mobile/src/mobile-release/android-release-signing-plugin.test.ts new file mode 100644 index 00000000000..6cf419cf9db --- /dev/null +++ b/mobile/src/mobile-release/android-release-signing-plugin.test.ts @@ -0,0 +1,59 @@ +import { createRequire } from 'node:module' +import { describe, expect, it } from 'vitest' + +const require = createRequire(import.meta.url) +const plugin: { + patchAppBuildGradle: (contents: string) => string +} = require('../../plugins/android-release-signing.js') +const { patchAppBuildGradle } = plugin + +// The signing section of Expo's bare template app/build.gradle. +const TEMPLATE = `android { + signingConfigs { + debug { + storeFile file('debug.keystore') + storePassword 'android' + keyAlias 'androiddebugkey' + keyPassword 'android' + } + } + buildTypes { + debug { + signingConfig signingConfigs.debug + } + release { + // Caution! In production, you need to generate your own keystore file. + // see https://reactnative.dev/docs/signed-apk-android. + signingConfig signingConfigs.debug + minifyEnabled enableMinifyInReleaseBuilds + } + } +} +` + +describe('android-release-signing plugin', () => { + it('signs release builds through the env-driven release config', () => { + const patched = patchAppBuildGradle(TEMPLATE) + + expect(patched).toContain('storeFile file(orcaKeystorePath)') + expect(patched).toContain('enableV3Signing = true') + expect(patched).toContain('signingConfig orcaReleaseSigningConfig(signingConfigs)') + expect(patched).toContain("System.getenv('ORCA_ANDROID_REQUIRE_RELEASE_SIGNING') == 'true'") + // Debug builds keep the debug key; only the release build type changes. + expect(patched.match(/signingConfig signingConfigs\.debug/g)).toHaveLength(1) + expect(patched).not.toMatch(/storePassword '(?!android')/) + }) + + it('is idempotent across repeated prebuilds', () => { + const once = patchAppBuildGradle(TEMPLATE) + expect(patchAppBuildGradle(once)).toBe(once) + }) + + it('fails prebuild when the template no longer matches', () => { + const changed = TEMPLATE.replace( + /(release \{[\s\S]*?)signingConfig signingConfigs\.debug/, + '$1signingConfig signingConfigs.other' + ) + expect(() => patchAppBuildGradle(changed)).toThrow(/template changed/) + }) +})