fix: stop signing release apks with the public debug key

Every published APK up to 0.1.62 was signed with the React Native
template's debug keystore, whose key is public. Anyone could sign an
"update" that Android installs over the app, with access to its
SecureStore credentials and mail cache. The release build type fell
back to the debug signing config whenever no release key was passed,
and the workflow carried on silently when `ANDROID_KEYSTORE_BASE64`
was empty.

- `android/app/build.gradle` reads the release key from the
  `BULWARK_RELEASE_*` gradle properties or environment variables and
  has no fallback. Without them a release build fails in the new
  `checkReleaseSigning` task. It only runs for release variants, so
  debug builds and `expo run:android` are unchanged. A local test
  build can opt in with `-PallowDebugSignedRelease=true`, which is
  ignored whenever `CI` is set.
- The release workflow fails in its first step when a signing secret
  is missing, checks the keystore, alias and password before the
  build, and refuses a debug key uploaded as the release key.
- An optional `ANDROID_SIGNING_LINEAGE_BASE64` secret re-signs the APK
  with APK Signature Scheme v3 key rotation from the debug key to the
  release key, from API 28, so existing installs on Android 9+ can
  update and stop trusting the debug key afterwards.
- Before anything is uploaded, `apksigner verify` has to show the
  release key as the Android 9+ signer, never the debug certificate.
- `*.keystore` and `*.lineage` files are gitignored.

Audit item S2 in `docs/audit-2026-09.md`.
This commit is contained in:
Linus Rath
2026-09-24 18:52:15 +02:00
parent 1d1d6a5bfc
commit 6f0abb4384
3 changed files with 237 additions and 16 deletions
+149 -9
View File
@@ -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: |
+3
View File
@@ -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*
+85 -7
View File
@@ -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