diff --git a/.github/workflows/release-android.yml b/.github/workflows/release-android.yml index 7fcbb1a4..6b23ce74 100644 --- a/.github/workflows/release-android.yml +++ b/.github/workflows/release-android.yml @@ -10,6 +10,13 @@ on: required: false default: "" +# Signing is described in docs/android-release.md. +env: + # Certificate SHA-256 of android/app/debug.keystore, the stock React Native + # template key (CN=Android Debug, SHA-1 5E:8F:16:06:...:F6:25). It is public, + # so Android 9+ must never see it as the signer of a published build. + DEBUG_CERT_SHA256: fac61745dc0903786fb9ede62a962b399f7348f0bb6f899b8332667591033b9c + jobs: build: runs-on: ubuntu-latest @@ -17,6 +24,26 @@ jobs: permissions: contents: write steps: + # Fail in seconds instead of after a 20-minute build. There is no + # unsigned or debug-signed fallback: anything signed with the debug key + # can be "updated" by anyone. + - name: Require the release signing secrets + env: + KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + run: | + missing="" + [ -n "$KEYSTORE_BASE64" ] || missing="$missing ANDROID_KEYSTORE_BASE64" + [ -n "$KEYSTORE_PASSWORD" ] || missing="$missing ANDROID_KEYSTORE_PASSWORD" + [ -n "$KEY_ALIAS" ] || missing="$missing ANDROID_KEY_ALIAS" + [ -n "$KEY_PASSWORD" ] || missing="$missing ANDROID_KEY_PASSWORD" + if [ -n "$missing" ]; then + echo "::error title=Release signing is not configured::Missing repository secrets:$missing. Release APKs are never signed with the public debug key; see docs/android-release.md." + exit 1 + fi + - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -66,23 +93,38 @@ jobs: - name: Install JS dependencies run: npm ci - - name: Decode release keystore - id: keystore + # Checks the password and alias before the long build, and refuses a + # debug key that was uploaded as the release key by mistake. + - name: Decode and check the release key + id: key env: KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} run: | - if [ -n "$KEYSTORE_BASE64" ]; then - echo "$KEYSTORE_BASE64" | base64 -d > "$RUNNER_TEMP/release.keystore" - echo "path=$RUNNER_TEMP/release.keystore" >> "$GITHUB_OUTPUT" - echo "present=true" >> "$GITHUB_OUTPUT" - else - echo "present=false" >> "$GITHUB_OUTPUT" + set -euo pipefail + KEYSTORE="$RUNNER_TEMP/release.keystore" + CERT="$RUNNER_TEMP/release-cert.der" + echo "$KEYSTORE_BASE64" | base64 -d > "$KEYSTORE" + if ! keytool -exportcert -keystore "$KEYSTORE" -alias "$KEY_ALIAS" \ + -storepass:env KEYSTORE_PASSWORD -file "$CERT"; then + echo "::error title=Release key unreadable::Could not read alias ANDROID_KEY_ALIAS from ANDROID_KEYSTORE_BASE64 with ANDROID_KEYSTORE_PASSWORD." + exit 1 fi + CERT_SHA256=$(sha256sum "$CERT" | cut -d' ' -f1) + OWNER=$(keytool -printcert -file "$CERT") + if [ "$CERT_SHA256" = "$DEBUG_CERT_SHA256" ] || [[ "$OWNER" == *"CN=Android Debug"* ]]; then + echo "::error title=Release key is a debug key::ANDROID_KEYSTORE_BASE64 holds an Android debug key. Generate a release key as described in docs/android-release.md." + exit 1 + fi + echo "Release certificate SHA-256: $CERT_SHA256" + echo "store_file=$KEYSTORE" >> "$GITHUB_OUTPUT" + echo "cert_sha256=$CERT_SHA256" >> "$GITHUB_OUTPUT" - name: Build release APK working-directory: android env: - ORG_GRADLE_PROJECT_BULWARK_RELEASE_STORE_FILE: ${{ steps.keystore.outputs.path }} + ORG_GRADLE_PROJECT_BULWARK_RELEASE_STORE_FILE: ${{ steps.key.outputs.store_file }} ORG_GRADLE_PROJECT_BULWARK_RELEASE_STORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} ORG_GRADLE_PROJECT_BULWARK_RELEASE_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} ORG_GRADLE_PROJECT_BULWARK_RELEASE_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} @@ -90,6 +132,104 @@ jobs: chmod +x ./gradlew ./gradlew assembleRelease --no-daemon + - name: Put apksigner on the PATH + run: | + BUILD_TOOLS=$(ls -d "$ANDROID_HOME"/build-tools/*/ | sort -V | tail -n 1) + echo "Using $BUILD_TOOLS" + echo "$BUILD_TOOLS" >> "$GITHUB_PATH" + + # Optional v3 key rotation away from the debug key that signed every + # release up to 0.1.62. Gradle signed the APK with the release key only, + # which existing installs reject as a different app. With a lineage the + # APK is re-signed so that Android 9+ sees the release key plus proof + # that the debug key handed over to it, while Android 7-8.1 (v2 only) + # still see the debug key. docs/android-release.md has the details. + - name: Apply the signing key rotation + id: rotation + env: + LINEAGE_BASE64: ${{ secrets.ANDROID_SIGNING_LINEAGE_BASE64 }} + KEYSTORE: ${{ steps.key.outputs.store_file }} + KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + RELEASE_CERT_SHA256: ${{ steps.key.outputs.cert_sha256 }} + run: | + set -euo pipefail + if [ -z "$LINEAGE_BASE64" ]; then + echo "No ANDROID_SIGNING_LINEAGE_BASE64: keeping the release-key-only signature." + echo "rotated=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + LINEAGE="$RUNNER_TEMP/signing.lineage" + echo "$LINEAGE_BASE64" | base64 -d > "$LINEAGE" + apksigner lineage --in "$LINEAGE" --print-certs | tee "$RUNNER_TEMP/lineage.txt" + + DIGESTS=$(sed -n 's/^Signer #[0-9]* in lineage certificate SHA-256 digest: //p' "$RUNNER_TEMP/lineage.txt") + if [ "$(sed -n '1p' <<< "$DIGESTS")" != "$DEBUG_CERT_SHA256" ] \ + || [ "$(sed -n '$p' <<< "$DIGESTS")" != "$RELEASE_CERT_SHA256" ]; then + echo "::error title=Wrong signing lineage::ANDROID_SIGNING_LINEAGE_BASE64 must lead from the debug key to the key in ANDROID_KEYSTORE_BASE64." + exit 1 + fi + # Rollback on an old signer lets anything signed with it take the app + # back after the rotation, which would undo the whole point. + PAST_ROLLBACK=$(grep 'Has rollback capability' "$RUNNER_TEMP/lineage.txt" | sed -e 's/.*: *//' -e '$d') + if [[ "$PAST_ROLLBACK" == *true* ]]; then + echo "::error title=Unsafe signing lineage::An old signer in the lineage has the rollback capability. Recreate it with --set-rollback false." + exit 1 + fi + + # Oldest signer first: apksigner uses it for v2, and the rotated key + # for v3 from API 28. Without --rotation-min-sdk-version it would + # only rotate on Android 13+ (v3.1) and leave 9-12L on the debug key. + APK=android/app/build/outputs/apk/release/app-release.apk + apksigner sign \ + --v4-signing-enabled false \ + --ks android/app/debug.keystore --ks-key-alias androiddebugkey \ + --ks-pass pass:android --key-pass pass:android \ + --next-signer \ + --ks "$KEYSTORE" --ks-key-alias "$KEY_ALIAS" \ + --ks-pass env:KEYSTORE_PASSWORD --key-pass env:KEY_PASSWORD \ + --lineage "$LINEAGE" \ + --rotation-min-sdk-version 28 \ + --out "$RUNNER_TEMP/app-release-rotated.apk" \ + "$APK" + mv "$RUNNER_TEMP/app-release-rotated.apk" "$APK" + echo "rotated=true" >> "$GITHUB_OUTPUT" + + # The last gate before anything is uploaded. + - name: Verify the APK signature + env: + RELEASE_CERT_SHA256: ${{ steps.key.outputs.cert_sha256 }} + ROTATED: ${{ steps.rotation.outputs.rotated }} + run: | + set -euo pipefail + APK=android/app/build/outputs/apk/release/app-release.apk + apksigner verify --verbose --print-certs "$APK" + signers() { + apksigner verify --print-certs "$@" "$APK" \ + | sed -n 's/^Signer #[0-9]* certificate SHA-256 digest: //p' + } + MODERN=$(signers --min-sdk-version 28) + LEGACY=$(signers --max-sdk-version 27) + echo "Signer on Android 9+: $MODERN" + echo "Signer on Android 7-8.1: $LEGACY" + + if [[ "$MODERN" == *"$DEBUG_CERT_SHA256"* ]]; then + echo "::error title=APK is debug-signed::Android 9+ would see the public debug key as the signer." + exit 1 + fi + if [ "$MODERN" != "$RELEASE_CERT_SHA256" ]; then + echo "::error title=Wrong APK signer::Android 9+ would see '$MODERN', expected only the release key $RELEASE_CERT_SHA256." + exit 1 + fi + if [ "$ROTATED" = "true" ]; then + echo "::warning title=Android 7-8.1 still trust the debug key::The v2 signature is made with the debug key so that installs on Android 7-8.1 can update. Those devices remain exposed; see docs/android-release.md." + elif [ "$LEGACY" != "$RELEASE_CERT_SHA256" ]; then + echo "::error title=Wrong APK signer::Android 7-8.1 would see '$LEGACY', expected only the release key $RELEASE_CERT_SHA256." + exit 1 + fi + aapt2 dump badging "$APK" | grep '^package:' || true + - name: Rename APK with version id: artifact run: | diff --git a/.gitignore b/.gitignore index 400e3a61..15ae3d56 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,9 @@ expo-env.d.ts *.p12 *.key *.mobileprovision +*.keystore +!/android/app/debug.keystore +*.lineage # Metro / Expo dev-server logs .metro-health-check* diff --git a/android/app/build.gradle b/android/app/build.gradle index 11e2d618..a5d18bff 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -7,6 +7,37 @@ def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath() def appVersionName = file("${projectRoot}/VERSION").text.trim() +/** + * Release signing. The key never lives in the repo: CI passes it as + * ORG_GRADLE_PROJECT_BULWARK_RELEASE_* environment variables, and a local + * release build can set the same names with -P, in ~/.gradle/gradle.properties + * or as plain environment variables. See docs/android-release.md. + * + * There is deliberately no automatic fallback to the debug key. It is the + * public React Native template key, so an APK signed with it can be replaced + * by an "update" anyone can sign. + */ +def releaseSigningNames = [ + 'BULWARK_RELEASE_STORE_FILE', + 'BULWARK_RELEASE_STORE_PASSWORD', + 'BULWARK_RELEASE_KEY_ALIAS', + 'BULWARK_RELEASE_KEY_PASSWORD', +] +def releaseSigning = releaseSigningNames.collectEntries { name -> + [(name): (findProperty(name) ?: System.getenv(name))?.toString()] +} +def missingReleaseSigning = releaseSigningNames.findAll { !releaseSigning[it]?.trim() } +def releaseSigningConfigured = missingReleaseSigning.isEmpty() +def releaseStoreFile = releaseSigningConfigured ? file(releaseSigning.BULWARK_RELEASE_STORE_FILE.trim()) : null + +// Escape hatch for a local release build that never leaves the machine +// (profiling, checking R8). It is refused whenever CI is set, so a stray flag +// cannot turn a published build back into a debug-signed one. +def ciEnv = System.getenv('CI')?.trim()?.toLowerCase() +def onCi = ciEnv && !(ciEnv in ['false', '0']) +def allowDebugSignedRelease = (findProperty('allowDebugSignedRelease') ?: 'false').toString().trim().toBoolean() +def debugSignedRelease = !releaseSigningConfigured && allowDebugSignedRelease && !onCi + /** * This is the configuration block to customize your React Native Android app. * By default you don't need to apply any configuration, just uncomment the lines you need. @@ -107,13 +138,12 @@ android { keyAlias 'androiddebugkey' keyPassword 'android' } - if (project.hasProperty('BULWARK_RELEASE_STORE_FILE') - && project.property('BULWARK_RELEASE_STORE_FILE')?.trim()) { + if (releaseSigningConfigured) { release { - storeFile file(project.property('BULWARK_RELEASE_STORE_FILE')) - storePassword project.property('BULWARK_RELEASE_STORE_PASSWORD') - keyAlias project.property('BULWARK_RELEASE_KEY_ALIAS') - keyPassword project.property('BULWARK_RELEASE_KEY_PASSWORD') + storeFile releaseStoreFile + storePassword releaseSigning.BULWARK_RELEASE_STORE_PASSWORD + keyAlias releaseSigning.BULWARK_RELEASE_KEY_ALIAS + keyPassword releaseSigning.BULWARK_RELEASE_KEY_PASSWORD } } } @@ -122,7 +152,13 @@ android { signingConfig signingConfigs.debug } release { - signingConfig signingConfigs.findByName('release') ?: signingConfigs.debug + // With neither a key nor the opt-in there is no signing config at + // all; checkReleaseSigning below stops the build before packaging. + if (releaseSigningConfigured) { + signingConfig signingConfigs.release + } else if (debugSignedRelease) { + signingConfig signingConfigs.debug + } def enableShrinkResources = findProperty('android.enableShrinkResourcesInReleaseBuilds') ?: 'false' shrinkResources enableShrinkResources.toBoolean() minifyEnabled enableMinifyInReleaseBuilds @@ -142,6 +178,48 @@ android { } } +// Stop a release build that has no real key. This runs only when a release +// variant is actually built, so `expo run:android`, assembleDebug and IDE sync +// keep working with no key configured. +def releaseSigningHelp = [ + "Release signing is not configured (missing: ${missingReleaseSigning.join(', ')}).", + "", + "Release builds are never signed with the debug key: it is the public React Native", + "template key, so an APK signed with it can be replaced by anyone. Provide the release", + "key as gradle properties (-P or ~/.gradle/gradle.properties) or environment variables:", + " BULWARK_RELEASE_STORE_FILE path to the keystore", + " BULWARK_RELEASE_STORE_PASSWORD", + " BULWARK_RELEASE_KEY_ALIAS", + " BULWARK_RELEASE_KEY_PASSWORD", + "", + onCi && allowDebugSignedRelease + ? "allowDebugSignedRelease is ignored because CI is set: published builds must use the release key." + : "For a local test build that is never installed anywhere else, pass -PallowDebugSignedRelease=true.", + "See docs/android-release.md.", +].join('\n') + +def checkReleaseSigning = tasks.register('checkReleaseSigning') { + group = 'verification' + description = 'Fails a release build that has no release signing key.' + doLast { + if (releaseSigningConfigured) { + if (!releaseStoreFile.isFile()) { + throw new GradleException("BULWARK_RELEASE_STORE_FILE points to ${releaseStoreFile}, which does not exist.") + } + } else if (debugSignedRelease) { + logger.warn("WARNING: this release build is signed with the PUBLIC debug key " + + "(allowDebugSignedRelease). Do not distribute it.") + } else { + throw new GradleException(releaseSigningHelp) + } + } +} +tasks.configureEach { task -> + if (task.name == 'preReleaseBuild') { + task.dependsOn(checkReleaseSigning) + } +} + // Apply static values from `gradle.properties` to the `android.packagingOptions` // Accepts values in comma delimited lists, example: // android.packagingOptions.pickFirsts=/LICENSE,**/picasa.ini