From 61dfb5ab0a8eeb71e09ad7f869ddd3875ee54789 Mon Sep 17 00:00:00 2001 From: Florian Date: Wed, 4 Nov 2026 00:20:06 +0100 Subject: [PATCH] feat(mobile): sign Android releases with the operator's key Expo's generated app/build.gradle signs the release build type with the template's debug.keystore, whose password is public, and the release script copied the signing properties into the project without Gradle reading them. A release prebuild now signs releases from release-signing.properties, and leaves them unsigned when that file is absent, never falling back to the debug key. Development builds keep Expo's default. The release script gains --apk for a signed APK to install on a test phone, removes a stale signing copy for --unsigned, and finds Gradle's -unsigned APK name. --- apps/mobile/plugins/nativeConfigTransforms.ts | 68 +++++++++++++++++++ .../plugins/withOpenMapXNativeConfig.test.ts | 64 +++++++++++++++++ .../plugins/withOpenMapXNativeConfig.ts | 16 +++++ apps/mobile/release/build-android.mts | 19 ++++-- docs/docs/developer/mobile-release.md | 27 +++++--- 5 files changed, 182 insertions(+), 12 deletions(-) diff --git a/apps/mobile/plugins/nativeConfigTransforms.ts b/apps/mobile/plugins/nativeConfigTransforms.ts index 472ed5c0d..20be2fd89 100644 --- a/apps/mobile/plugins/nativeConfigTransforms.ts +++ b/apps/mobile/plugins/nativeConfigTransforms.ts @@ -349,3 +349,71 @@ export function buildNetworkSecurityConfigXml(input: NativeConfigInput): string lines.push("", ""); return lines.join("\n"); } + +/* ------------------------------------------------------ release signing ---- */ + +/** Marks the signing block this transform inserts, so a second pass is a no-op. */ +const RELEASE_SIGNING_MARKER = "// OpenMapX release signing"; + +/** + * The signing configuration a release reads from `release-signing.properties` + * in the generated Android project, which `release/build-android.mts` copies + * there from the operator's mode-0600 file outside the repository. Without that + * file no `release` configuration exists and the build is left unsigned. + */ +const RELEASE_SIGNING_CONFIG = ` ${RELEASE_SIGNING_MARKER}: keystore and passwords come from + // release-signing.properties, which release/build-android.mts copies here + // from a file outside the repository. Without it the release stays unsigned. + def openmapxSigningFile = rootProject.file("release-signing.properties") + if (openmapxSigningFile.exists()) { + def openmapxSigning = new Properties() + openmapxSigningFile.withInputStream { openmapxSigning.load(it) } + def missing = ["storeFile", "storePassword", "keyAlias", "keyPassword"].findAll { + !openmapxSigning.getProperty(it) + } + if (!missing.isEmpty()) { + throw new GradleException("release-signing.properties lacks " + missing.join(", ")) + } + release { + storeFile file(openmapxSigning.getProperty("storeFile")) + storePassword openmapxSigning.getProperty("storePassword") + keyAlias openmapxSigning.getProperty("keyAlias") + keyPassword openmapxSigning.getProperty("keyPassword") + } + } +`; + +/** + * Signs the release build type with the operator's own key instead of the + * template's debug key. + * + * Expo's generated `app/build.gradle` signs releases with `debug.keystore`, + * whose password is public: an APK signed with it could be replaced on a phone + * by anyone's build. A release is now signed only from the external properties + * file, and is unsigned when there is none. The debug build type is unchanged. + */ +export function applyAndroidReleaseSigning(gradle: string): string { + if (gradle.includes(RELEASE_SIGNING_MARKER)) return gradle; + + const signingConfigs = /^([^\S\n]*)signingConfigs \{\n/m.exec(gradle); + const releaseType = + /(buildTypes \{[\s\S]*?\n[^\S\n]*release \{[\s\S]*?)signingConfig signingConfigs\.debug/.exec( + gradle, + ); + if (!signingConfigs || !releaseType) { + throw new Error( + "app/build.gradle has no signingConfigs block or release signing line to replace; " + + "Expo's template changed, so update applyAndroidReleaseSigning", + ); + } + + const withReleaseType = + gradle.slice(0, releaseType.index) + + releaseType[1] + + 'signingConfig signingConfigs.findByName("release")' + + gradle.slice(releaseType.index + releaseType[0].length); + const insertAt = signingConfigs.index + signingConfigs[0].length; + return ( + withReleaseType.slice(0, insertAt) + RELEASE_SIGNING_CONFIG + withReleaseType.slice(insertAt) + ); +} diff --git a/apps/mobile/plugins/withOpenMapXNativeConfig.test.ts b/apps/mobile/plugins/withOpenMapXNativeConfig.test.ts index 383a2e1be..703697254 100644 --- a/apps/mobile/plugins/withOpenMapXNativeConfig.test.ts +++ b/apps/mobile/plugins/withOpenMapXNativeConfig.test.ts @@ -4,6 +4,7 @@ import { type AndroidManifestLike, applyAndroidApplicationSecurity, applyAndroidPermissions, + applyAndroidReleaseSigning, applyAppBoundDomains, applyAppTransportSecurity, applyAssociatedDomains, @@ -342,3 +343,66 @@ describe("iOS storage protection", () => { expect(JSON.stringify(applyDataProtection(once))).toBe(JSON.stringify(once)); }); }); + +describe("Android release signing", () => { + // The signing and build-type blocks of Expo's generated `app/build.gradle`. + const EXPO_APP_GRADLE = `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 + } + } +} +`; + + function releaseBlock(gradle: string): string { + const start = gradle.indexOf(" release {", gradle.indexOf("buildTypes {")); + return gradle.slice(start, gradle.indexOf("\n }", start)); + } + + it("signs the release build type from the external properties file", () => { + const gradle = applyAndroidReleaseSigning(EXPO_APP_GRADLE); + expect(gradle).toContain('rootProject.file("release-signing.properties")'); + for (const key of ["storeFile", "storePassword", "keyAlias", "keyPassword"]) { + expect(gradle).toContain(`"${key}"`); + } + expect(releaseBlock(gradle)).toContain('signingConfig signingConfigs.findByName("release")'); + }); + + it("never signs a release with the template's public debug key", () => { + expect(releaseBlock(applyAndroidReleaseSigning(EXPO_APP_GRADLE))).not.toContain( + "signingConfigs.debug", + ); + }); + + it("keeps the debug build type on the debug key", () => { + const gradle = applyAndroidReleaseSigning(EXPO_APP_GRADLE); + const debugStart = gradle.indexOf(" debug {", gradle.indexOf("buildTypes {")); + expect(gradle.slice(debugStart, gradle.indexOf("}", debugStart))).toContain( + "signingConfig signingConfigs.debug", + ); + }); + + it("is idempotent", () => { + const once = applyAndroidReleaseSigning(EXPO_APP_GRADLE); + expect(applyAndroidReleaseSigning(once)).toBe(once); + }); + + it("refuses a build file whose signing blocks it does not recognise", () => { + expect(() => applyAndroidReleaseSigning("android {}\n")).toThrow(/signing/); + }); +}); diff --git a/apps/mobile/plugins/withOpenMapXNativeConfig.ts b/apps/mobile/plugins/withOpenMapXNativeConfig.ts index c415f2d19..bd2897916 100644 --- a/apps/mobile/plugins/withOpenMapXNativeConfig.ts +++ b/apps/mobile/plugins/withOpenMapXNativeConfig.ts @@ -4,6 +4,7 @@ import type { ExpoConfig } from "expo/config"; import type { ConfigPlugin } from "expo/config-plugins"; import { withAndroidManifest, + withAppBuildGradle, withDangerousMod, withEntitlementsPlist, withInfoPlist, @@ -16,6 +17,7 @@ import { type AndroidManifestLike, applyAndroidApplicationSecurity, applyAndroidPermissions, + applyAndroidReleaseSigning, applyAppBoundDomains, applyAppTransportSecurity, applyAssociatedDomains, @@ -102,6 +104,20 @@ export const withOpenMapXNativeConfig: ConfigPlugin = (config) => { return manifestConfig; }); + // Release builds only: a development build's release variant keeps Expo's + // debug signing, so a contributor can still install it locally. + if (input.release) { + next = withAppBuildGradle(next, (gradleConfig) => { + if (gradleConfig.modResults.language !== "groovy") { + throw new Error("release signing expects the generated app/build.gradle to be Groovy"); + } + gradleConfig.modResults.contents = applyAndroidReleaseSigning( + gradleConfig.modResults.contents, + ); + return gradleConfig; + }); + } + next = withDangerousMod(next, [ "android", (danger) => { diff --git a/apps/mobile/release/build-android.mts b/apps/mobile/release/build-android.mts index 58640c76d..f2d07219e 100644 --- a/apps/mobile/release/build-android.mts +++ b/apps/mobile/release/build-android.mts @@ -2,6 +2,9 @@ /** * Builds and signs the Android App Bundle locally. * + * `--apk` builds a signed APK instead, to install directly on a test phone; + * `--unsigned` builds an unsigned APK as a tooling rehearsal. + * * Keystore path, alias and passwords come from a mode-0600 properties file * outside the repository, never from command-line arguments — arguments land in * shell history and in every process listing on the machine. @@ -11,7 +14,7 @@ */ import { execFileSync } from "node:child_process"; -import { copyFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs"; +import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, statSync } from "node:fs"; import { homedir } from "node:os"; import { resolve } from "node:path"; import { distDir, mobileRoot, readToolchains, readVersion } from "./context.ts"; @@ -33,6 +36,7 @@ const propertiesPath = resolve(homedir(), ".openmapx/android-release.properties"); const unsigned = process.argv.includes("--unsigned"); +const apk = unsigned || process.argv.includes("--apk"); if (!unsigned) { if (!existsSync(propertiesPath)) { @@ -54,6 +58,9 @@ if (!unsigned) { process.exit(1); } copyFileSync(propertiesPath, resolve(androidDir, "release-signing.properties")); +} else { + // A copy left by an earlier signed build would sign this one after all. + rmSync(resolve(androidDir, "release-signing.properties"), { force: true }); } const javaHome = process.env.JAVA_HOME; @@ -65,7 +72,7 @@ if (!javaHome) { process.exit(1); } -const task = unsigned ? "assembleRelease" : "bundleRelease"; +const task = apk ? "assembleRelease" : "bundleRelease"; console.log(`[release:android] gradle :app:${task}`); execFileSync("./gradlew", [`:app:${task}`, "--no-daemon"], { cwd: androidDir, @@ -73,8 +80,12 @@ execFileSync("./gradlew", [`:app:${task}`, "--no-daemon"], { env: { ...process.env, JAVA_HOME: javaHome }, }); -const built = unsigned - ? resolve(androidDir, "app/build/outputs/apk/release/app-release.apk") +// Gradle names an APK without a signing configuration `-unsigned`. +const built = apk + ? resolve( + androidDir, + `app/build/outputs/apk/release/app-release${unsigned ? "-unsigned" : ""}.apk`, + ) : resolve(androidDir, "app/build/outputs/bundle/release/app-release.aab"); if (!existsSync(built)) { diff --git a/docs/docs/developer/mobile-release.md b/docs/docs/developer/mobile-release.md index 4f1dce8f5..51a78d167 100644 --- a/docs/docs/developer/mobile-release.md +++ b/docs/docs/developer/mobile-release.md @@ -111,10 +111,22 @@ keyPassword=REPLACE_LOCALLY ``` Keep it outside the repository. `OPENMAPX_ANDROID_SIGNING_PROPERTIES` overrides -its path. The script copies it into the ignored generated Android directory; -protect and clean that copy as secret material. Never pass passwords as command -arguments. `pnpm mobile:release:android --unsigned` selects an APK rehearsal -instead of the signed AAB path; it is not a store candidate. +its path. The script copies it into the ignored generated Android directory as +`release-signing.properties`; protect and clean that copy as secret material. +Never pass passwords as command arguments. + +A release prebuild signs the release build type from that copy. It never falls +back to the template's `debug.keystore`, whose password is public, so a release +built without the file is unsigned. A development build's release variant keeps +the debug key. + +The script builds a signed AAB by default. `--apk` builds a signed APK instead, +to install directly on a test phone. `--unsigned` builds an unsigned APK as a +tooling rehearsal. Neither APK is a store candidate. + +To test a release build on your own phone, sign it with a separate local test +key, not the upload key. Android refuses to update an app with an APK signed by +another key, so uninstall the test build before installing a store build. ### Version and signing checks before building @@ -126,10 +138,9 @@ absence or an unreadable tag is treated by the script as no previous release. The descriptor is **not wired into all native build inputs**: `app.config.ts` hardcodes `version: "1.0.0"` and does not set iOS `buildNumber` or Android -`versionCode` from it. The committed Android build script copies signing -properties but does not itself configure Gradle to consume them. Confirm and fix -native version/signing configuration before release; a manifest copied from the -descriptor cannot establish the values or signing identity inside the binary. +`versionCode` from it. Confirm and fix native version configuration before +release; a manifest copied from the descriptor cannot establish the values +inside the binary. Inspect the signing identity of the built artifact as well. ### What the manifest promises