From 16daa4155f11b5ca653b16e93bdf4e94d2512f74 Mon Sep 17 00:00:00 2001 From: tianma-if Date: Fri, 31 Jul 2026 11:41:32 +0800 Subject: [PATCH] fix(android): enforce consistent APK signing --- .github/workflows/mobile-build.yml | 47 +++++++--- docs/mobile-build.md | 50 +++++++--- package.json | 3 +- scripts/build-android-local.sh | 46 +++++---- scripts/plan-native-release.mjs | 7 +- scripts/plan-native-release.test.mjs | 2 + scripts/verify-android-apk-signature.mjs | 93 +++++++++++++++++++ scripts/verify-android-apk-signature.test.mjs | 48 ++++++++++ 8 files changed, 250 insertions(+), 46 deletions(-) create mode 100644 scripts/verify-android-apk-signature.mjs create mode 100644 scripts/verify-android-apk-signature.test.mjs diff --git a/.github/workflows/mobile-build.yml b/.github/workflows/mobile-build.yml index 2f0448e0..041bbb49 100644 --- a/.github/workflows/mobile-build.yml +++ b/.github/workflows/mobile-build.yml @@ -17,7 +17,9 @@ on: - "packages/client/**" - "packages/shared/**" - "bun.lock" + - ".github/workflows/mobile-build.yml" - "scripts/build-android-local.sh" + - "scripts/verify-android-apk-signature.mjs" permissions: contents: write @@ -117,6 +119,21 @@ jobs: exit 1 fi + - name: Verify existing APK uses the pinned signer + if: steps.plan.outputs.asset_ready == 'true' + env: + CURRENT_TAG: ${{ inputs.release_tag || github.event.release.tag_name }} + GH_TOKEN: ${{ github.token }} + run: | + mkdir -p "$RUNNER_TEMP/native-assets" + gh release download "$CURRENT_TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --pattern 'edgeever-android-v*-arm64-v8a.apk' \ + --dir "$RUNNER_TEMP/native-assets" + apk_path="$(find "$RUNNER_TEMP/native-assets" -type f -name 'edgeever-android-v*-arm64-v8a.apk' -print -quit)" + test -n "$apk_path" + node scripts/verify-android-apk-signature.mjs "$apk_path" + reuse-release-apk: needs: release-plan if: >- @@ -130,6 +147,9 @@ jobs: name: Reuse Android release APK runs-on: ubuntu-latest steps: + - name: Checkout + uses: actions/checkout@v5 + - name: Copy latest compatible APK without renaming env: CURRENT_TAG: ${{ inputs.release_tag || github.event.release.tag_name }} @@ -142,6 +162,8 @@ jobs: --pattern 'edgeever-android-v*-arm64-v8a.apk' \ --dir "$RUNNER_TEMP/native-assets" test "$(find "$RUNNER_TEMP/native-assets" -type f -name 'edgeever-android-v*-arm64-v8a.apk' | wc -l | tr -d ' ')" = "1" + apk_path="$(find "$RUNNER_TEMP/native-assets" -type f -name 'edgeever-android-v*-arm64-v8a.apk' -print -quit)" + node scripts/verify-android-apk-signature.mjs "$apk_path" gh release upload "$CURRENT_TAG" "$RUNNER_TEMP/native-assets/"* \ --repo "$GITHUB_REPOSITORY" \ --clobber @@ -190,12 +212,7 @@ jobs: - name: Reset Metro cache for this checkout run: bun --cwd apps/mobile expo export --platform android --clear --max-workers 1 --output-dir "$RUNNER_TEMP/edgeever-metro-warmup" - - name: Build fast test APK - if: github.event_name == 'push' - run: bun run build:android:fast - - - name: Restore Android upload keystore - if: github.event_name == 'workflow_dispatch' + - name: Restore Android signing keystore env: ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} run: | @@ -203,6 +220,16 @@ jobs: printf '%s' "$ANDROID_KEYSTORE_BASE64" | openssl base64 -d -A > "$RUNNER_TEMP/edgeever-upload.p12" chmod 600 "$RUNNER_TEMP/edgeever-upload.p12" + - name: Build fast main-branch APK + if: github.event_name == 'push' + env: + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + ANDROID_KEYSTORE_FILE: ${{ runner.temp }}/edgeever-upload.p12 + ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + EDGE_EVER_ANDROID_APK_ARCHS: arm64-v8a + run: bun run build:android:fast + - name: Build signed release APK for GitHub Release if: github.event_name == 'workflow_dispatch' env: @@ -215,15 +242,13 @@ jobs: - name: Verify release APK signature if: github.event_name == 'workflow_dispatch' - run: | - "$ANDROID_SDK_ROOT/build-tools/36.0.0/apksigner" verify --verbose app/build/outputs/apk/release/app-release.apk - working-directory: apps/mobile/android + run: node scripts/verify-android-apk-signature.mjs apps/mobile/android/app/build/outputs/apk/release/app-release.apk - name: Upload APK if: github.event_name == 'push' uses: actions/upload-artifact@v7 with: - name: edgeever-android-test-release-apk + name: edgeever-android-main-apk path: apps/mobile/android/app/build/outputs/apk/release/*.apk if-no-files-found: error compression-level: 0 @@ -239,5 +264,5 @@ jobs: gh release upload "$RELEASE_TAG" "$release_asset" --repo "$GITHUB_REPOSITORY" --clobber - name: Remove temporary upload keystore - if: always() && github.event_name == 'workflow_dispatch' + if: always() run: rm -f "$RUNNER_TEMP/edgeever-upload.p12" diff --git a/docs/mobile-build.md b/docs/mobile-build.md index 0e0a4229..021b764a 100644 --- a/docs/mobile-build.md +++ b/docs/mobile-build.md @@ -1,6 +1,6 @@ # EdgeEver Mobile Builds -EdgeEver Mobile is built with Expo and React Native. Daily Android test packages are built directly on GitHub Actions, without using EAS Build quota. +EdgeEver Mobile is built with Expo and React Native. Main-branch Android packages are built directly on GitHub Actions, without using EAS Build quota. ## App Updates @@ -15,22 +15,32 @@ bunx eas-cli update --channel production --environment production --message "Des The update runtime uses the app-version policy. Any native dependency, Expo SDK, native module, or native configuration change requires incrementing `expo.version` and shipping a new store build before publishing code that depends on that native change. EAS Update does not replace Google Play or App Store updates for native binaries. -## Android Debug APK +## Main-Branch Android APK -The `Build EdgeEver Mobile` workflow runs on GitHub Actions and produces a debug APK artifact. +The `Build EdgeEver Mobile` workflow runs on GitHub Actions and produces a +fast arm64 APK after mobile changes land on `main`. This APK uses the same +pinned production signing certificate as formal GitHub Release APKs. The fast +build only disables R8, resource shrinking, and PNG crunching; it must never +fall back to `debug.keystore`. It runs: ```sh bun install --frozen-lockfile bun run typecheck:mobile -cd apps/mobile -bunx expo prebuild --platform android --non-interactive --clean -cd android -./gradlew assembleDebug +bun run build:android:fast ``` -The APK is uploaded as a GitHub Actions artifact named `edgeever-android-debug-apk`. +The APK is uploaded as a GitHub Actions artifact named +`edgeever-android-main-apk`. If the stable signing credentials are unavailable, +the workflow fails and does not upload an APK with a different signer. + +For a local fast build, load the same signing environment used for release +builds: + +```sh +bun run build:android:fast:local +``` ## Release Builds @@ -42,15 +52,27 @@ Every formal GitHub Release must include a directly installable APK. Build the p bun run build:android:apk:local ``` -This produces `apps/mobile/android/app/build/outputs/apk/release/app-release.apk`. Verify its application version, signer, and SHA-256 before uploading it to the matching GitHub Release. Additional ABIs should only be published for an explicit compatibility need; the Play AAB continues to include all supported architectures. A release whose audited change range affects mobile runtime code, shared code used by mobile, mobile dependencies, native configuration, or APK build tooling must rebuild the APK from that release commit. +This produces `apps/mobile/android/app/build/outputs/apk/release/app-release.apk`. +Before upload, automation requires exactly one signer and pins its certificate +SHA-256 to +`22bf52a9501c89020f5acc966960152c826bfa64f31e578e858d088f8cd75d87`. +Any other signer fails the build. Verify the application version and APK +SHA-256 as well. Additional ABIs should only be published for an explicit +compatibility need; the Play AAB continues to include all supported +architectures. A release whose audited change range affects mobile runtime +code, shared code used by mobile, mobile dependencies, native configuration, +APK build tooling, or signer verification must rebuild the APK from that +release commit. If the audited change range does not affect the mobile binary, the most recent compatible, verified APK may be attached again without rebuilding. Keep its original versioned filename and checksum, and state the source release explicitly; never rename an older binary to the current release version. -The release workflow performs this audit automatically. Web-only releases use -an Ubuntu planning job and copy the single verified `arm64-v8a` APK from the -previous formal Release. The self-hosted Android runner is scheduled only when -the audited range includes mobile runtime code, shared mobile packages, -dependencies, native configuration, or Android build tooling. +The release workflow performs this audit automatically and verifies the pinned +signer for both newly built and reused APKs. Web-only releases use an Ubuntu +planning job and copy the single verified `arm64-v8a` APK from the previous +formal Release. The self-hosted Android runner is scheduled only when the +audited range includes mobile runtime code, shared mobile packages, +dependencies, native configuration, Android build tooling, or signer +verification. ### Recommended local Play build diff --git a/package.json b/package.json index dfe80125..2eb824bf 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "dev:mobile": "bun --cwd apps/mobile expo start", "prepare:mobile:icons": "bun scripts/prepare-mobile-icons.mjs", "build:android:fast": "bash scripts/build-android-local.sh fast", + "build:android:fast:local": "bun --env-file=${EDGE_EVER_ANDROID_ENV_FILE:-$HOME/.config/edgeever/android/signing.env} run build:android:fast", "build:android:apk": "bash scripts/build-android-local.sh apk", "build:android:apk:local": "bun --env-file=${EDGE_EVER_ANDROID_ENV_FILE:-$HOME/.config/edgeever/android/signing.env} run build:android:apk", "build:android:play": "bash scripts/build-android-local.sh play", @@ -38,7 +39,7 @@ "build:desktop": "EDGE_EVER_DESKTOP_BUILD=1 bun run build:web && bun run build:desktop:sidecar && bun run --cwd apps/desktop dist", "benchmark:desktop:sidecar": "bun scripts/benchmark-desktop-sidecar.mjs", "verify:web-performance": "bun scripts/verify-web-performance.mjs", - "test:release-planning": "bun test scripts/plan-native-release.test.mjs scripts/check-native-release-assets.test.mjs scripts/create-mac-update-metadata.test.mjs scripts/release.test.mjs scripts/validate-store-delivery.test.mjs scripts/store-delivery.test.mjs apps/web/src/lib/version-check.test.mjs apps/mobile/src/lib/mobile-release.test.ts", + "test:release-planning": "bun test scripts/plan-native-release.test.mjs scripts/check-native-release-assets.test.mjs scripts/create-mac-update-metadata.test.mjs scripts/release.test.mjs scripts/validate-store-delivery.test.mjs scripts/store-delivery.test.mjs scripts/verify-android-apk-signature.test.mjs apps/web/src/lib/version-check.test.mjs apps/mobile/src/lib/mobile-release.test.ts", "release": "bun scripts/release.mjs", "publish:stores": "bun scripts/store-delivery.mjs", "test:desktop:sidecar": "bun scripts/test-desktop-sidecar.mjs", diff --git a/scripts/build-android-local.sh b/scripts/build-android-local.sh index 0206d17a..94275a7c 100755 --- a/scripts/build-android-local.sh +++ b/scripts/build-android-local.sh @@ -77,22 +77,6 @@ COMMON_ARGS=( -Dorg.gradle.jvmargs=-Xmx6g\ -XX:MaxMetaspaceSize=1g\ -Dfile.encoding=UTF-8 ) -if [[ "$MODE" == "fast" ]]; then - echo "构建 arm64 真机测试 Release APK(关闭 R8、资源压缩和 PNG crunch)..." - run_timed "Gradle 快速 APK 构建" ./gradlew assembleRelease \ - "${COMMON_ARGS[@]}" \ - -PreactNativeArchitectures=arm64-v8a \ - -Pandroid.enableMinifyInReleaseBuilds=false \ - -Pandroid.enableShrinkResourcesInReleaseBuilds=false \ - -Pandroid.enablePngCrunchInReleaseBuilds=false \ - -Pandroid.injected.signing.store.file="$ANDROID_DIR/app/debug.keystore" \ - -Pandroid.injected.signing.store.password=android \ - -Pandroid.injected.signing.key.alias=androiddebugkey \ - -Pandroid.injected.signing.key.password=android - echo "完成: $ANDROID_DIR/app/build/outputs/apk/release/app-release.apk" - exit 0 -fi - : "${ANDROID_KEYSTORE_FILE:?请设置 ANDROID_KEYSTORE_FILE(本地上传密钥路径)}" : "${ANDROID_KEYSTORE_PASSWORD:?请设置 ANDROID_KEYSTORE_PASSWORD}" : "${ANDROID_KEY_ALIAS:?请设置 ANDROID_KEY_ALIAS}" @@ -101,6 +85,30 @@ fi PLAY_ARCHS="${EDGE_EVER_ANDROID_ARCHS:-armeabi-v7a,arm64-v8a,x86,x86_64}" APK_ARCHS="${EDGE_EVER_ANDROID_APK_ARCHS:-arm64-v8a}" KEYSTORE_FILE="$(cd "$(dirname "$ANDROID_KEYSTORE_FILE")" && pwd)/$(basename "$ANDROID_KEYSTORE_FILE")" +APK_PATH="$ANDROID_DIR/app/build/outputs/apk/release/app-release.apk" +APKSIGNER_PATH="$ANDROID_SDK_ROOT/build-tools/36.0.0/apksigner" + +if [[ "$MODE" == "fast" ]]; then + echo "构建生产签名 arm64 快速 APK(关闭 R8、资源压缩和 PNG crunch)..." + run_timed "Gradle 快速 APK 构建" ./gradlew assembleRelease \ + "${COMMON_ARGS[@]}" \ + -PreactNativeArchitectures=arm64-v8a \ + -Pandroid.enableMinifyInReleaseBuilds=false \ + -Pandroid.enableShrinkResourcesInReleaseBuilds=false \ + -Pandroid.enablePngCrunchInReleaseBuilds=false \ + -Pandroid.injected.signing.store.file="$KEYSTORE_FILE" \ + -Pandroid.injected.signing.store.password="$ANDROID_KEYSTORE_PASSWORD" \ + -Pandroid.injected.signing.key.alias="$ANDROID_KEY_ALIAS" \ + -Pandroid.injected.signing.key.password="$ANDROID_KEY_PASSWORD" \ + -Pandroid.injected.signing.store.type=PKCS12 + test -s "$APK_PATH" + test -x "$APKSIGNER_PATH" + run_timed "校验 APK 固定签名" \ + node "$PROJECT_ROOT/scripts/verify-android-apk-signature.mjs" \ + "$APK_PATH" "$APKSIGNER_PATH" + echo "完成: $APK_PATH" + exit 0 +fi if [[ "$MODE" == "apk" ]]; then echo "构建生产签名 APK(${APK_ARCHS})..." @@ -112,13 +120,13 @@ if [[ "$MODE" == "apk" ]]; then -Pandroid.injected.signing.key.alias="$ANDROID_KEY_ALIAS" \ -Pandroid.injected.signing.key.password="$ANDROID_KEY_PASSWORD" \ -Pandroid.injected.signing.store.type=PKCS12 - APK_PATH="$ANDROID_DIR/app/build/outputs/apk/release/app-release.apk" - APKSIGNER_PATH="$ANDROID_SDK_ROOT/build-tools/36.0.0/apksigner" AAPT2_PATH="$ANDROID_SDK_ROOT/build-tools/36.0.0/aapt2" test -s "$APK_PATH" test -x "$APKSIGNER_PATH" test -x "$AAPT2_PATH" - run_timed "校验 APK 签名" "$APKSIGNER_PATH" verify --verbose "$APK_PATH" + run_timed "校验 APK 固定签名" \ + node "$PROJECT_ROOT/scripts/verify-android-apk-signature.mjs" \ + "$APK_PATH" "$APKSIGNER_PATH" run_timed "读取 APK 信息" bash -c '"$1" dump badging "$2" | sed -n "1p"' _ "$AAPT2_PATH" "$APK_PATH" run_timed "计算 APK SHA-256" shasum -a 256 "$APK_PATH" echo "完成: $APK_PATH" diff --git a/scripts/plan-native-release.mjs b/scripts/plan-native-release.mjs index 74db360a..6e29381c 100644 --- a/scripts/plan-native-release.mjs +++ b/scripts/plan-native-release.mjs @@ -24,7 +24,12 @@ export const planNativeRelease = (platform, changedFiles) => { const relevantFiles = platform === "mobile" - ? new Set(["bun.lock", "scripts/build-android-local.sh"]) + ? new Set([ + ".github/workflows/mobile-build.yml", + "bun.lock", + "scripts/build-android-local.sh", + "scripts/verify-android-apk-signature.mjs", + ]) : new Set([ ".github/workflows/desktop-build.yml", "bun.lock", diff --git a/scripts/plan-native-release.test.mjs b/scripts/plan-native-release.test.mjs index b12ca06d..8d201bb9 100644 --- a/scripts/plan-native-release.test.mjs +++ b/scripts/plan-native-release.test.mjs @@ -19,6 +19,8 @@ describe("native release planning", () => { "packages/shared/src/index.ts", "bun.lock", "scripts/build-android-local.sh", + "scripts/verify-android-apk-signature.mjs", + ".github/workflows/mobile-build.yml", ]; expect(planNativeRelease("mobile", changedFiles)).toEqual({ rebuild: true, diff --git a/scripts/verify-android-apk-signature.mjs b/scripts/verify-android-apk-signature.mjs new file mode 100644 index 00000000..4bc17bea --- /dev/null +++ b/scripts/verify-android-apk-signature.mjs @@ -0,0 +1,93 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, readdirSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const EDGE_EVER_ANDROID_SIGNER_SHA256 = + "22bf52a9501c89020f5acc966960152c826bfa64f31e578e858d088f8cd75d87"; + +const normalizeFingerprint = (value) => + value.toLowerCase().replaceAll(":", "").trim(); + +export const verifyAndroidSignerOutput = (output) => { + const signerDigests = [ + ...output.matchAll( + /Signer #\d+ certificate SHA-256 digest:\s*([0-9a-f:]+)/gi, + ), + ].map((match) => normalizeFingerprint(match[1])); + + if (signerDigests.length !== 1) { + throw new Error( + `Expected exactly one Android signer, found ${signerDigests.length}.`, + ); + } + + if (signerDigests[0] !== EDGE_EVER_ANDROID_SIGNER_SHA256) { + throw new Error( + `Android signer mismatch: expected ${EDGE_EVER_ANDROID_SIGNER_SHA256}, received ${signerDigests[0]}.`, + ); + } + + return signerDigests[0]; +}; + +const findApkSigner = (explicitPath) => { + if (explicitPath) return explicitPath; + + const sdkRoot = process.env.ANDROID_SDK_ROOT || process.env.ANDROID_HOME; + if (!sdkRoot) { + throw new Error( + "ANDROID_SDK_ROOT or ANDROID_HOME is required to locate apksigner.", + ); + } + + const buildToolsRoot = join(sdkRoot, "build-tools"); + const versions = readdirSync(buildToolsRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort((left, right) => + right.localeCompare(left, undefined, { numeric: true }), + ); + + for (const version of versions) { + const candidate = join(buildToolsRoot, version, "apksigner"); + if (existsSync(candidate)) return candidate; + } + + throw new Error(`Could not find apksigner under ${buildToolsRoot}.`); +}; + +const run = () => { + const [apkPath, explicitApkSigner] = process.argv.slice(2); + if (!apkPath) { + console.error( + "Usage: node scripts/verify-android-apk-signature.mjs [apksigner-path]", + ); + process.exit(1); + } + + const apkSigner = findApkSigner(explicitApkSigner); + const result = spawnSync( + apkSigner, + ["verify", "--verbose", "--print-certs", apkPath], + { + encoding: "utf8", + }, + ); + const output = `${result.stdout || ""}\n${result.stderr || ""}`; + + if (result.status !== 0) { + process.stderr.write(output); + throw new Error(`apksigner rejected ${apkPath}.`); + } + + const signerSha256 = verifyAndroidSignerOutput(output); + process.stdout.write(`Android signer SHA-256: ${signerSha256}\n`); +}; + +if ( + process.argv[1] && + resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + run(); +} diff --git a/scripts/verify-android-apk-signature.test.mjs b/scripts/verify-android-apk-signature.test.mjs new file mode 100644 index 00000000..21d863ec --- /dev/null +++ b/scripts/verify-android-apk-signature.test.mjs @@ -0,0 +1,48 @@ +import { describe, expect, test } from "bun:test"; +import { + EDGE_EVER_ANDROID_SIGNER_SHA256, + verifyAndroidSignerOutput, +} from "./verify-android-apk-signature.mjs"; + +describe("Android APK signer verification", () => { + test("accepts the single pinned EdgeEver signer", () => { + expect( + verifyAndroidSignerOutput( + `Signer #1 certificate SHA-256 digest: ${EDGE_EVER_ANDROID_SIGNER_SHA256}`, + ), + ).toBe(EDGE_EVER_ANDROID_SIGNER_SHA256); + }); + + test("normalizes colon-separated uppercase fingerprints", () => { + const fingerprint = EDGE_EVER_ANDROID_SIGNER_SHA256.toUpperCase() + .match(/.{2}/g) + .join(":"); + expect( + verifyAndroidSignerOutput( + `Signer #1 certificate SHA-256 digest: ${fingerprint}`, + ), + ).toBe(EDGE_EVER_ANDROID_SIGNER_SHA256); + }); + + test("rejects an APK signed by a different certificate", () => { + expect(() => + verifyAndroidSignerOutput( + `Signer #1 certificate SHA-256 digest: ${"0".repeat(64)}`, + ), + ).toThrow("Android signer mismatch"); + }); + + test("rejects missing or multiple signers", () => { + expect(() => verifyAndroidSignerOutput("Verified")).toThrow( + "Expected exactly one Android signer", + ); + expect(() => + verifyAndroidSignerOutput( + [ + `Signer #1 certificate SHA-256 digest: ${EDGE_EVER_ANDROID_SIGNER_SHA256}`, + `Signer #2 certificate SHA-256 digest: ${EDGE_EVER_ANDROID_SIGNER_SHA256}`, + ].join("\n"), + ), + ).toThrow("Expected exactly one Android signer"); + }); +});