From eaa75d7e44791cfbc0353a04d4341706f7c83195 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Romero?= Date: Tue, 20 Oct 2026 21:18:42 +0200 Subject: [PATCH] fix(android): share signing identity across all release channels --- .github/workflows/android-production.yml | 42 ++--- .github/workflows/native-platforms.yml | 73 ++++---- .github/workflows/quality.yml | 5 + README.md | 27 +-- docs/android-signing.md | 151 +++++++++++++++ ....sha256 => app-signing-certificate.sha256} | 0 scripts/android-version-code.py | 33 ++++ scripts/build-android-release.sh | 66 ++++--- scripts/build-android-test-apk.sh | 9 +- scripts/configure-android-signing.py | 156 ++++++++++++++++ scripts/prepare-release.py | 14 +- scripts/restore-android-keystore.sh | 19 ++ scripts/test-android-oauth.py | 33 ++-- scripts/test-android-signing.py | 176 ++++++++++++++++++ scripts/test-release.py | 131 +++++++++---- scripts/verify-android-apk.sh | 37 ++-- scripts/verify-android-keystore.sh | 47 +++++ scripts/verify-android-signing.py | 12 +- scripts/verify-android-test-keystore.sh | 22 --- 19 files changed, 843 insertions(+), 210 deletions(-) create mode 100644 docs/android-signing.md rename platform/android/{test-signing-certificate.sha256 => app-signing-certificate.sha256} (100%) create mode 100644 scripts/android-version-code.py create mode 100644 scripts/configure-android-signing.py create mode 100644 scripts/restore-android-keystore.sh create mode 100644 scripts/test-android-signing.py create mode 100644 scripts/verify-android-keystore.sh delete mode 100644 scripts/verify-android-test-keystore.sh diff --git a/.github/workflows/android-production.yml b/.github/workflows/android-production.yml index f38796dc..93827bff 100644 --- a/.github/workflows/android-production.yml +++ b/.github/workflows/android-production.yml @@ -3,10 +3,6 @@ name: Android production candidate on: workflow_dispatch: inputs: - version_code: - description: Monotonically increasing Play version code - required: true - type: string version_name: description: Public release version, for example 1.2.3 required: true @@ -19,14 +15,22 @@ jobs: build: name: Signed Android APK and AAB runs-on: ubuntu-24.04 - # Configure this environment with required reviewers before adding secrets. - environment: android-production + # Same environment, key, and version sequence as prerelease APKs. + environment: android-release steps: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 - uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4 with: distribution: temurin java-version: "21" + - name: Restore shared app signing key + env: + FLECTAR_ANDROID_KEYSTORE_BASE64: ${{ secrets.FLECTAR_ANDROID_KEYSTORE_BASE64 }} + FLECTAR_ANDROID_KEYSTORE_PASSWORD: ${{ secrets.FLECTAR_ANDROID_KEYSTORE_PASSWORD }} + FLECTAR_ANDROID_KEY_ALIAS: ${{ secrets.FLECTAR_ANDROID_KEY_ALIAS }} + FLECTAR_ANDROID_KEY_PASSWORD: ${{ secrets.FLECTAR_ANDROID_KEY_PASSWORD }} + run: bash scripts/restore-android-keystore.sh + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: toolchain: stable @@ -48,19 +52,8 @@ jobs: echo "CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER=$toolchain/aarch64-linux-android26-clang" } >> "$GITHUB_ENV" - - name: Materialize protected upload keystore - env: - KEYSTORE_BASE64: ${{ secrets.FLECTAR_ANDROID_KEYSTORE_BASE64 }} - run: | - test -n "$KEYSTORE_BASE64" - keystore="$RUNNER_TEMP/flectar-upload.keystore" - printf '%s' "$KEYSTORE_BASE64" | base64 --decode > "$keystore" - chmod 600 "$keystore" - echo "FLECTAR_ANDROID_KEYSTORE=$keystore" >> "$GITHUB_ENV" - - name: Build and validate production candidate env: - FLECTAR_ANDROID_VERSION_CODE: ${{ inputs.version_code }} FLECTAR_ANDROID_VERSION_NAME: ${{ inputs.version_name }} FLECTAR_GOOGLE_ANDROID_CLIENT_ID: ${{ secrets.FLECTAR_GOOGLE_ANDROID_CLIENT_ID }} FLECTAR_MICROSOFT_ANDROID_CLIENT_ID: ${{ secrets.FLECTAR_MICROSOFT_ANDROID_CLIENT_ID }} @@ -73,8 +66,13 @@ jobs: - name: Record public signing identities run: | ./scripts/android-signing-identities.sh \ - target/android/production/flectar-mail.apk \ - > target/android/production/signing-identities.txt + target/android/release/flectar-mail.apk \ + > target/android/release/signing-identities.txt + + - name: Remove restored signing key + if: always() + run: | + if [[ -n "${FLECTAR_ANDROID_KEYSTORE:-}" ]]; then rm -f "$FLECTAR_ANDROID_KEYSTORE"; fi - name: Enable Android emulator acceleration run: sudo chmod a+rw /dev/kvm @@ -106,8 +104,8 @@ jobs: with: name: flectar-mail-android-production-${{ inputs.version_name }} path: | - target/android/production/flectar-mail.apk - target/android/production/flectar-mail.aab - target/android/production/signing-identities.txt + target/android/release/flectar-mail.apk + target/android/release/flectar-mail.aab + target/android/release/signing-identities.txt if-no-files-found: error retention-days: 30 diff --git a/.github/workflows/native-platforms.yml b/.github/workflows/native-platforms.yml index a8a0223a..ff350225 100644 --- a/.github/workflows/native-platforms.yml +++ b/.github/workflows/native-platforms.yml @@ -20,7 +20,7 @@ on: type: boolean default: true include_android: - description: Build the experimental Android test APK + description: Build the signed Android APK type: boolean default: true include_ios: @@ -471,10 +471,11 @@ jobs: android: needs: metadata if: >- - (github.event_name == 'push' && needs.metadata.outputs.prerelease == 'true') || + github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.include_android) - name: Android test-signed APK (arm64) + name: Android signed APK (arm64) runs-on: ubuntu-24.04 + environment: android-release steps: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 - uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4 @@ -482,19 +483,13 @@ jobs: distribution: temurin java-version: "21" - - name: Restore required persistent alpha signing key + - name: Restore shared app signing key env: - TEST_KEYSTORE_BASE64: ${{ secrets.FLECTAR_ANDROID_TEST_KEYSTORE_BASE64 }} - run: | - if [[ -z "$TEST_KEYSTORE_BASE64" ]]; then - echo '::error::FLECTAR_ANDROID_TEST_KEYSTORE_BASE64 is required. Restore the original alpha signing key; a new key breaks installed-app updates.' - exit 1 - fi - keystore="$RUNNER_TEMP/flectar-android-test.keystore" - umask 077 - printf '%s' "$TEST_KEYSTORE_BASE64" | base64 --decode > "$keystore" - FLECTAR_ANDROID_TEST_KEYSTORE="$keystore" bash scripts/verify-android-test-keystore.sh - echo "FLECTAR_ANDROID_TEST_KEYSTORE=$keystore" >> "$GITHUB_ENV" + FLECTAR_ANDROID_KEYSTORE_BASE64: ${{ secrets.FLECTAR_ANDROID_KEYSTORE_BASE64 }} + FLECTAR_ANDROID_KEYSTORE_PASSWORD: ${{ secrets.FLECTAR_ANDROID_KEYSTORE_PASSWORD }} + FLECTAR_ANDROID_KEY_ALIAS: ${{ secrets.FLECTAR_ANDROID_KEY_ALIAS }} + FLECTAR_ANDROID_KEY_PASSWORD: ${{ secrets.FLECTAR_ANDROID_KEY_PASSWORD }} + run: bash scripts/restore-android-keystore.sh - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: @@ -519,29 +514,41 @@ jobs: echo "CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER=$toolchain/aarch64-linux-android26-clang" } >> "$GITHUB_ENV" - - name: Build test-signed release APK + - name: Build signed release APK env: + RELEASE_PRERELEASE: ${{ needs.metadata.outputs.prerelease }} FLECTAR_ANDROID_VERSION_NAME: ${{ needs.metadata.outputs.version }} - FLECTAR_ANDROID_VERSION_CODE: ${{ github.run_number }} + FLECTAR_ANDROID_KEYSTORE_PASSWORD: ${{ secrets.FLECTAR_ANDROID_KEYSTORE_PASSWORD }} + FLECTAR_ANDROID_KEY_ALIAS: ${{ secrets.FLECTAR_ANDROID_KEY_ALIAS }} + FLECTAR_ANDROID_KEY_PASSWORD: ${{ secrets.FLECTAR_ANDROID_KEY_PASSWORD }} FLECTAR_GOOGLE_ANDROID_CLIENT_ID: ${{ secrets.FLECTAR_GOOGLE_ANDROID_CLIENT_ID }} FLECTAR_MICROSOFT_ANDROID_CLIENT_ID: ${{ secrets.FLECTAR_MICROSOFT_ANDROID_CLIENT_ID }} FLECTAR_MICROSOFT_ANDROID_REDIRECT_URI: ${{ secrets.FLECTAR_MICROSOFT_ANDROID_REDIRECT_URI }} - run: ./scripts/build-android-test-apk.sh + run: | + args=() + if [[ "$RELEASE_PRERELEASE" == true ]]; then args+=(--preview); fi + bash scripts/build-android-release.sh "${args[@]}" - name: Verify release APK manifest, ABI, and signature env: FLECTAR_ANDROID_VERSION_NAME: ${{ needs.metadata.outputs.version }} - FLECTAR_ANDROID_VERSION_CODE: ${{ github.run_number }} - run: ./scripts/verify-android-apk.sh + run: bash scripts/verify-android-apk.sh target/android/release/flectar-mail.apk production - - name: Record public test signing identity - run: ./scripts/android-signing-identities.sh target/android/release/apk/flectar-mail.apk > target/android/release/apk/signing-identities.txt + - name: Record public app signing identity + run: ./scripts/android-signing-identities.sh target/android/release/flectar-mail.apk > target/android/release/signing-identities.txt + + - name: Remove restored signing key + if: always() + run: | + if [[ -n "${FLECTAR_ANDROID_KEYSTORE:-}" ]]; then rm -f "$FLECTAR_ANDROID_KEYSTORE"; fi - name: Enable Android emulator acceleration run: sudo chmod a+rw /dev/kvm - name: Exercise packaged JNI callbacks uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2 + env: + FLECTAR_ANDROID_KEYSTORE: "" with: # This Google APIs image supports ARM64 native translation, so tests # exercise the same stripped Rust library staged for the release APK. @@ -560,16 +567,12 @@ jobs: target/android/gradle/app/reports/androidTests/ target/android/gradle/app/outputs/androidTest-results/ - - name: Remove restored signing key - if: always() - run: rm -f "$RUNNER_TEMP/flectar-android-test.keystore" - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: - name: flectar-mail-android-test-signed-arm64 + name: flectar-mail-android-signed-arm64 path: | - target/android/release/apk/flectar-mail.apk - target/android/release/apk/signing-identities.txt + target/android/release/flectar-mail.apk + target/android/release/signing-identities.txt if-no-files-found: error ios: @@ -623,16 +626,13 @@ jobs: release: name: Publish GitHub Release - # Explicit results let stable releases proceed with Android skipped while - # requiring a successful Android build for every prerelease. + # Every published channel must include an APK with the shared identity. if: >- !cancelled() && github.event_name == 'push' && github.ref_type == 'tag' && needs.metadata.result == 'success' && needs.quality.result == 'success' && needs.linux.result == 'success' && needs.rpm.result == 'success' && needs.flatpak.result == 'success' && needs.windows.result == 'success' && - needs.macos.result == 'success' && - (needs.android.result == 'success' || - (needs.metadata.outputs.prerelease == 'false' && needs.android.result == 'skipped')) + needs.macos.result == 'success' && needs.android.result == 'success' needs: [metadata, quality, linux, rpm, flatpak, windows, macos, android] runs-on: ubuntu-24.04 permissions: @@ -680,11 +680,10 @@ jobs: name: flectar-mail-macos-x64 path: release-assets/macos/x64 - - name: Download Android test APK - if: needs.metadata.outputs.prerelease == 'true' + - name: Download Android signed APK uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: - name: flectar-mail-android-test-signed-arm64 + name: flectar-mail-android-signed-arm64 path: release-assets/android - name: Prepare release downloads diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index c8465e8d..a480d862 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -87,12 +87,17 @@ jobs: run: | python3 scripts/verify-parley.py cargo test --manifest-path crates/parley/Cargo.toml --lib --locked --target-dir target/parley-tests + - uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4 + with: + distribution: temurin + java-version: "21" - name: Test release validation and publication run: | python3 scripts/test-release.py python3 scripts/test-pdf-packaging.py python3 scripts/test-android-jni.py python3 scripts/test-android-oauth.py + python3 scripts/test-android-signing.py - name: Validate Android workspace lockfile run: cargo metadata --manifest-path platform/android/Cargo.toml --locked --format-version 1 > /dev/null - name: Reject unused direct dependencies diff --git a/README.md b/README.md index 1aaaab60..9ff5ecd3 100644 --- a/README.md +++ b/README.md @@ -396,7 +396,7 @@ When preview builds are published, download them from sideloaded Flatpak preview bundle - **Windows x64:** Setup `.exe` or portable ZIP - **macOS Apple silicon or Intel (macOS 14+):** DMG or application ZIP -- **Android arm64 (Android 8.0+):** Experimental test APK in prereleases +- **Android arm64 (Android 8.0+):** Signed APK; previews and stable releases share one app identity Windows previews are unsigned; macOS previews are ad-hoc signed and not notarized, so operating-system security prompts are expected. Install updates @@ -417,18 +417,21 @@ Each GitHub release includes `SHA256SUMS` and signed build-provenance attestations. With GitHub CLI installed, verify a download with `gh attestation verify --repo flectar/mail`. -Starting with alpha 8, Android preview releases use a persistent signing key so -later previews can update the app in place and retain local data. Alpha 6 and -alpha 7 used different keys and cannot update to this new signing identity. +Starting with alpha 8, Android alpha, beta, release-candidate, and stable builds +use one package (`com.flectar.mail`) and one persistent signing identity. Later +versions can update the app in place and retain local data. Alpha 6 and alpha 7 +used different keys and cannot update to this signing identity. -CI requires the `FLECTAR_ANDROID_TEST_KEYSTORE_BASE64` repository secret and checks -its public certificate against `platform/android/test-signing-certificate.sha256` -before building and when verifying the final APK. A missing or different key -blocks publication. Keep a secure backup of the original keystore; restore it if -the secret is lost. **Do not generate a replacement key or change the pinned -fingerprint for a routine release.** The keystore uses alias `androiddebugkey` -and password `android`, matching the preview build script. Its private key must -remain outside the repository. Production signing is managed separately. +Both Android release workflows use the `android-release` GitHub environment. +They require the same keystore and password secrets, verify the public +certificate against `platform/android/app-signing-certificate.sha256`, and +derive Android version codes from the release version. A missing or different +key blocks publication. **Do not generate a replacement key or change the +pinned fingerprint for a routine release.** Password changes preserve the +identity when the original private key and certificate are retained. + +See [Android signing setup and recovery](docs/android-signing.md) for the +one-time migration command, required secrets, backups, and Play App Signing. Local developer builds can still generate a local test key. They only update installations signed with that same local key. diff --git a/docs/android-signing.md b/docs/android-signing.md new file mode 100644 index 00000000..4517cef3 --- /dev/null +++ b/docs/android-signing.md @@ -0,0 +1,151 @@ +# Android signing and updates + +Every published APK uses `com.flectar.mail` and the certificate pinned in +`platform/android/app-signing-certificate.sha256`. This includes alpha, beta, +release-candidate, and stable versions, starting with alpha 8. Keep the original +private key **and certificate** for the lifetime of this update path. Alpha 6 +and alpha 7 used different identities and cannot update to this one. + +The certificate's historical subject (`Flectar Mail Test`) and alias +(`androiddebugkey`) are names, not a measure of key security. The pin identifies +the actual certificate. Re-encrypting the existing keystore with a strong +password keeps the certificate and private key unchanged. + +## One-time setup from the existing alpha key + +Requirements: Python 3.11+, a JDK with `keytool` (Java 21 recommended), and +authenticated GitHub CLI access with permission to manage repository +environments and secrets. Keep keystores outside the checkout. + +From the repository root: + +```bash +python3 scripts/configure-android-signing.py \ + "$HOME/.local/share/flectar-mail/android/alpha-signing.keystore" \ + --output "$HOME/.local/share/flectar-mail/android/app-signing.p12" \ + --upload +``` + +Use the path to your **existing backup** if it is elsewhere. The current +legacy keystore password is `android`; press Enter for the same key password. +Choose and save a new password of at least 16 characters in your password +manager. The script does not display or save passwords in plaintext. + +The script verifies the original certificate and access to its private key, +imports that same entry into a new PKCS12 file with the new password, and +verifies it again. It refuses to overwrite an existing destination. The +original file is retained until you have verified your secure backup. + +With `--upload`, it creates the `android-release` environment if absent, +restricts that environment to the repository's default branch and `v*` tags, +and sets these four **environment secrets**: + +| Secret | Value | +| --- | --- | +| `FLECTAR_ANDROID_KEYSTORE_BASE64` | Base64 encoding of the re-encrypted keystore | +| `FLECTAR_ANDROID_KEYSTORE_PASSWORD` | New keystore password | +| `FLECTAR_ANDROID_KEY_ALIAS` | Existing alias, normally `androiddebugkey` | +| `FLECTAR_ANDROID_KEY_PASSWORD` | New private-key password; same as the store password for this PKCS12 file | + +An existing environment's protection rules are preserved. An environment with +no branch restrictions is rejected. Review the environment's allowed branches +and tags when configuring a different repository. An empty edit field on +GitHub does not show whether a stored secret has a value: saved secret values +are not returned for inspection. Base64 is encoding, not encryption. + +The old repository secret `FLECTAR_ANDROID_TEST_KEYSTORE_BASE64` belongs to +older workflow revisions. After the four environment secrets are configured +and the new workflows are merged, remove the old repository secret so the +same private key is no longer available outside the protected environment: + +```bash +gh secret delete FLECTAR_ANDROID_TEST_KEYSTORE_BASE64 --repo flectar/mail +``` + +Do not dispatch releases from older workflow revisions after this migration; +some of those revisions could generate a replacement key when a secret was +missing. The setup script leaves the old secret in place so it can be run +before merging without breaking the workflow currently on `main`. + +If upload fails after the new file was created, retry without re-encrypting: + +```bash +python3 scripts/configure-android-signing.py \ + "$HOME/.local/share/flectar-mail/android/app-signing.p12" --upload +``` + +Enter the **new** password this time. To update the secrets manually after +configuring the environment: + +```bash +set -o pipefail +base64 < "$HOME/.local/share/flectar-mail/android/app-signing.p12" | + gh secret set FLECTAR_ANDROID_KEYSTORE_BASE64 --repo flectar/mail --env android-release +gh secret set FLECTAR_ANDROID_KEYSTORE_PASSWORD --repo flectar/mail --env android-release +gh secret set FLECTAR_ANDROID_KEY_ALIAS --repo flectar/mail --env android-release +gh secret set FLECTAR_ANDROID_KEY_PASSWORD --repo flectar/mail --env android-release +``` + +The final three commands prompt for the corresponding value. Do not pass +passwords on the command line or paste them into issues, chat, or logs. + +## Release behavior + +Both workflows restore and validate the shared key before compilation, use +`scripts/build-android-release.sh`, verify the finished APK against the same +pin, and remove the runner's keystore before emulator tests. Signing passwords +are supplied only to the restore and build steps. Pull request checks use +temporary fixture keys and never receive the release environment's secrets. + +The native artifact workflow publishes the signed APK for both prereleases +and stable tags. `--preview` permits missing bundled OAuth registrations only +for prerelease versions; it never changes the signing key. The production +candidate workflow requires complete OAuth registrations. Both emit an APK +and an AAB into `target/android/release/`; the native release publishes the APK. + +Version codes are derived by `scripts/android-version-code.py`: + +```text +major × 100,000,000 + minor × 1,000,000 + patch × 10,000 + channel +channel: alpha.N = N; beta.N = 1,000 + N; rc.N = 2,000 + N; stable = 9,999 +``` + +For example, `0.1.0-alpha.8` is `1000008` and `0.1.0` is `1009999`. +The supported ranges are major 0–20, minor and patch 0–99, and prerelease +number 1–999; values outside these ranges fail rather than collide. Every +released version must be greater than the previous release on the update path. +Rebuilding the same version produces the same code; use a new version for a +new Play upload. Do not supply unrelated workflow run numbers or reset codes +when moving to stable. + +Local `scripts/build-android-test-apk.sh` builds may still use the conventional +`android` password for a developer-only key. CI redirects that entry point to +the pinned release builder, and no published workflow uses the developer key. + +## Backups, recovery, and Google Play + +Store the re-encrypted keystore in a secure backup outside this machine and +GitHub, and store its password in your password manager. Test the backup with +the verification/setup command before retiring the legacy copy. GitHub +secrets are not a downloadable backup. If a secret is deleted, restore the +same keystore and credentials. Never regenerate the key or change the pin to +make a failed release pass. + +When enrolling in Play App Signing, supply this existing **app signing key** +so that Play and GitHub APKs have the same signing identity. Having Google +generate a different app key would break this update path. Configure a +separate **upload key** for later AAB submissions; that key authorizes uploads +and must not replace the key signing GitHub APKs. The current AAB is signed +with the app key for initial enrollment; separate upload-key automation must +be configured when Play is introduced. + +Before shipping, verify an actual update on an emulator or test device: +install a first signed APK, create test app data, and install the newer APK +without uninstalling (`adb install -r newer.apk`). Confirm that the data and +account settings survive and that both APK certificate fingerprints match +the committed pin. The keystore regression tests alone do not exercise the +Android package manager or application data migrations. + +References: [Android app signing](https://developer.android.com/studio/publish/app-signing), +[version codes](https://developer.android.com/studio/publish/versioning), and +[GitHub Actions secrets](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-secrets). diff --git a/platform/android/test-signing-certificate.sha256 b/platform/android/app-signing-certificate.sha256 similarity index 100% rename from platform/android/test-signing-certificate.sha256 rename to platform/android/app-signing-certificate.sha256 diff --git a/scripts/android-version-code.py b/scripts/android-version-code.py new file mode 100644 index 00000000..060c7215 --- /dev/null +++ b/scripts/android-version-code.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +"""Derive one Android version sequence for every release channel and workflow.""" + +import argparse +import re + + +def version_code(version: str) -> int: + match = re.fullmatch( + r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)" + r"(?:-(alpha|beta|rc)\.([1-9][0-9]*))?", + version, + ) + if not match: + raise ValueError("Android versions must be X.Y.Z or X.Y.Z-{alpha,beta,rc}.N") + major, minor, patch = map(int, match.group(1, 2, 3)) + stage, number = match.group(4, 5) + if major > 20 or minor > 99 or patch > 99 or (number and int(number) > 999): + raise ValueError("Android versions support major 0–20, minor/patch 0–99, and prerelease 1–999") + # Reserve 10,000 codes for each patch. This stays below Play's 2.1B limit. + # alpha.999 < beta.1 < rc.1 < stable < next patch's alpha.1. + suffix = {"alpha": 0, "beta": 1000, "rc": 2000}.get(stage, 9999) + return major * 100_000_000 + minor * 1_000_000 + patch * 10_000 + suffix + int(number or 0) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("version") + args = parser.parse_args() + try: + print(version_code(args.version)) + except ValueError as error: + raise SystemExit(str(error)) from error diff --git a/scripts/build-android-release.sh b/scripts/build-android-release.sh index 501ea0d8..a15fda89 100755 --- a/scripts/build-android-release.sh +++ b/scripts/build-android-release.sh @@ -7,11 +7,15 @@ android_sdk="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}" android_ndk="${ANDROID_NDK_ROOT:-${ANDROID_NDK_HOME:-}}" gradle_command="${GRADLE_CMD:-}" +preview=false +if [[ "${1:-}" == --preview && $# == 1 ]]; then + preview=true +elif [[ $# != 0 ]]; then + printf 'Usage: %s [--preview]\n' "$0" >&2 + exit 1 +fi + required=( - FLECTAR_GOOGLE_ANDROID_CLIENT_ID - FLECTAR_MICROSOFT_ANDROID_CLIENT_ID - FLECTAR_MICROSOFT_ANDROID_REDIRECT_URI - FLECTAR_ANDROID_VERSION_CODE FLECTAR_ANDROID_VERSION_NAME FLECTAR_ANDROID_KEYSTORE FLECTAR_ANDROID_KEYSTORE_PASSWORD @@ -20,27 +24,33 @@ required=( ) for name in "${required[@]}"; do if [[ -z "${!name:-}" ]]; then - printf 'Production Android build requires %s.\n' "$name" >&2 + printf 'Published Android build requires %s.\n' "$name" >&2 exit 1 fi done -if [[ ! -f "$FLECTAR_ANDROID_KEYSTORE" ]]; then - printf 'Production keystore not found: %s\n' "$FLECTAR_ANDROID_KEYSTORE" >&2 +version_code="$(python3 "$project_dir/scripts/android-version-code.py" "$FLECTAR_ANDROID_VERSION_NAME")" +if [[ -n "${FLECTAR_ANDROID_VERSION_CODE:-}" && "$FLECTAR_ANDROID_VERSION_CODE" != "$version_code" ]]; then + printf 'FLECTAR_ANDROID_VERSION_CODE must match the shared release sequence (%s).\n' "$version_code" >&2 exit 1 fi -python3 "$project_dir/scripts/verify-android-oauth.py" --require-all -if [[ ! "$FLECTAR_MICROSOFT_ANDROID_REDIRECT_URI" =~ ^msauth://com\.flectar\.mail/.+ ]]; then - printf 'Microsoft redirect must be msauth://com.flectar.mail/.\n' >&2 - exit 1 +export FLECTAR_ANDROID_VERSION_CODE="$version_code" +bash "$project_dir/scripts/verify-android-keystore.sh" >/dev/null + +if [[ "$preview" == true ]]; then + if [[ "$FLECTAR_ANDROID_VERSION_NAME" != *-* ]]; then + printf 'Stable Android releases cannot use --preview.\n' >&2 + exit 1 + fi + python3 "$project_dir/scripts/verify-android-oauth.py" +else + python3 "$project_dir/scripts/verify-android-oauth.py" --require-all fi -if [[ ! "$FLECTAR_ANDROID_VERSION_CODE" =~ ^[1-9][0-9]*$ ]]; then - printf 'FLECTAR_ANDROID_VERSION_CODE must be a positive Play version code.\n' >&2 - exit 1 -fi -if [[ ! "$FLECTAR_ANDROID_VERSION_NAME" =~ ^[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?$ ]]; then - printf 'FLECTAR_ANDROID_VERSION_NAME must be a release version such as 1.2.3.\n' >&2 - exit 1 +if [[ "$preview" == false || -n "${FLECTAR_MICROSOFT_ANDROID_REDIRECT_URI:-}" ]]; then + if [[ ! "${FLECTAR_MICROSOFT_ANDROID_REDIRECT_URI:-}" =~ ^msauth://com\.flectar\.mail/.+ ]]; then + printf 'Microsoft redirect must be msauth://com.flectar.mail/.\n' >&2 + exit 1 + fi fi if [[ -z "$android_sdk" || ! -d "$android_sdk" ]]; then printf 'Android SDK not found. Set ANDROID_HOME.\n' >&2 @@ -92,7 +102,7 @@ python3 "$project_dir/scripts/stage-pdfium.py" android-arm64 "$jni_dir" \ -p "$project_dir/platform/android/gradle" \ :app:assembleRelease :app:bundleRelease -artifact_dir="$target_dir/android/production" +artifact_dir="$target_dir/android/release" mkdir -p "$artifact_dir" cp "$target_dir/android/gradle/app/outputs/apk/release/app-release.apk" \ "$artifact_dir/flectar-mail.apk" @@ -105,16 +115,18 @@ if ! command -v jarsigner >/dev/null 2>&1 \ exit 1 fi -actual_redirect="$(ANDROID_HOME="$android_sdk" "$project_dir/scripts/android-signing-identities.sh" \ - "$artifact_dir/flectar-mail.apk" | sed -n 's/^Microsoft redirect URI: //p')" -if [[ "$actual_redirect" != "$FLECTAR_MICROSOFT_ANDROID_REDIRECT_URI" ]]; then - printf 'The Entra redirect does not match the APK signing certificate.\n' >&2 - printf 'Configured: %s\nActual: %s\n' \ - "$FLECTAR_MICROSOFT_ANDROID_REDIRECT_URI" "$actual_redirect" >&2 - exit 1 +if [[ -n "${FLECTAR_MICROSOFT_ANDROID_REDIRECT_URI:-}" ]]; then + actual_redirect="$(ANDROID_HOME="$android_sdk" "$project_dir/scripts/android-signing-identities.sh" \ + "$artifact_dir/flectar-mail.apk" | sed -n 's/^Microsoft redirect URI: //p')" + if [[ "$actual_redirect" != "$FLECTAR_MICROSOFT_ANDROID_REDIRECT_URI" ]]; then + printf 'The Entra redirect does not match the APK signing certificate.\n' >&2 + printf 'Configured: %s\nActual: %s\n' \ + "$FLECTAR_MICROSOFT_ANDROID_REDIRECT_URI" "$actual_redirect" >&2 + exit 1 + fi fi ANDROID_HOME="$android_sdk" "$project_dir/scripts/verify-android-apk.sh" \ "$artifact_dir/flectar-mail.apk" production -printf 'Production artifacts:\n %s\n %s\n' \ +printf 'Signed release artifacts:\n %s\n %s\n' \ "$artifact_dir/flectar-mail.apk" "$artifact_dir/flectar-mail.aab" diff --git a/scripts/build-android-test-apk.sh b/scripts/build-android-test-apk.sh index b5c8ed09..4e36235e 100755 --- a/scripts/build-android-test-apk.sh +++ b/scripts/build-android-test-apk.sh @@ -6,10 +6,10 @@ target_dir="$project_dir/target" android_sdk="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}" android_ndk="${ANDROID_NDK_ROOT:-${ANDROID_NDK_HOME:-}}" -# CI must never fall back to a fresh per-run key, even when this script is -# invoked outside the native-platforms workflow. +# Keep this entry point for local development. CI always uses the same +# credentialed, pinned release builder as stable releases. if [[ "${CI:-}" == true || "${GITHUB_ACTIONS:-}" == true ]]; then - bash "$project_dir/scripts/verify-android-test-keystore.sh" >/dev/null + exec bash "$project_dir/scripts/build-android-release.sh" --preview fi # Missing registrations are supported: the app shows Sign-in settings instead @@ -50,8 +50,7 @@ export CXX_aarch64_linux_android="$toolchain/aarch64-linux-android26-clang++" export AR_aarch64_linux_android="$toolchain/llvm-ar" export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="$toolchain/aarch64-linux-android26-clang" -# Local development may generate its own key. CI has already required and -# verified the persistent alpha key above. +# Local development may generate its own key; these APKs are never published. data_home="${XDG_DATA_HOME:-$HOME/.local/share}" keystore_dir="$data_home/flectar-mail/android" keystore="${FLECTAR_ANDROID_TEST_KEYSTORE:-$keystore_dir/test-signing.keystore}" diff --git a/scripts/configure-android-signing.py b/scripts/configure-android-signing.py new file mode 100644 index 00000000..78837512 --- /dev/null +++ b/scripts/configure-android-signing.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +"""Preserve the app signing key, change its password, and optionally configure CI.""" + +import argparse +import base64 +import getpass +import json +import os +from pathlib import Path +import subprocess +import tempfile +from urllib.parse import quote + +SCRIPTS = Path(__file__).resolve().parent + + +def verify(keystore: Path, alias: str, store_password: str, key_password: str) -> dict[str, str]: + environment = { + **os.environ, + "FLECTAR_ANDROID_KEYSTORE": str(keystore), + "FLECTAR_ANDROID_KEY_ALIAS": alias, + "FLECTAR_ANDROID_KEYSTORE_PASSWORD": store_password, + "FLECTAR_ANDROID_KEY_PASSWORD": key_password, + } + subprocess.run( + ["bash", str(SCRIPTS / "verify-android-keystore.sh")], + env=environment, check=True, stdout=subprocess.DEVNULL, + ) + return environment + + +def reprotect(source: Path, destination: Path, alias: str, + store_password: str, key_password: str, new_password: str) -> dict[str, str]: + """Re-encrypt the existing private key and certificate without replacing either.""" + if len(new_password) < 16: + raise ValueError("Use a new keystore password of at least 16 characters.") + if destination.exists() or destination.is_symlink(): + raise ValueError("The destination already exists; refusing to overwrite a signing key.") + environment = verify(source, alias, store_password, key_password) + environment["FLECTAR_ANDROID_NEW_PASSWORD"] = new_password + destination.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix=".android-signing-", dir=destination.parent) as directory: + staged = Path(directory) / "app-signing.p12" + subprocess.run([ + "keytool", "-importkeystore", "-noprompt", + "-srckeystore", str(source), "-srcalias", alias, + "-srcstorepass:env", "FLECTAR_ANDROID_KEYSTORE_PASSWORD", + "-srckeypass:env", "FLECTAR_ANDROID_KEY_PASSWORD", + "-destkeystore", str(staged), "-deststoretype", "PKCS12", + "-destalias", alias, + "-deststorepass:env", "FLECTAR_ANDROID_NEW_PASSWORD", + "-destkeypass:env", "FLECTAR_ANDROID_NEW_PASSWORD", + ], env=environment, check=True, stdout=subprocess.DEVNULL) + verify(staged, alias, new_password, new_password) + staged.chmod(0o600) + # Publish the verified file without ever overwriting an existing path. + os.link(staged, destination) + return verify(destination, alias, new_password, new_password) + + +def github_json(arguments: list[str], body: dict | None = None): + command = ["gh", "api", *arguments] + if body is not None: + command += ["--input", "-"] + result = subprocess.run( + command, input=json.dumps(body) if body is not None else None, + text=True, check=True, capture_output=True, + ) + if "--paginate" not in arguments: + return json.loads(result.stdout) + # Older gh releases support --paginate but not --slurp. Each page is a + # separate JSON value and may span multiple lines, so decode the stream. + pages = [] + decoder = json.JSONDecoder() + remaining = result.stdout.lstrip() + while remaining: + page, end = decoder.raw_decode(remaining) + pages.append(page) + remaining = remaining[end:].lstrip() + return pages + + +def upload(environment: dict[str, str], repository: str, name: str) -> None: + # Creating an environment alone does not restrict which refs may use it. + # Start a new one closed to all refs, then allow main and release tags. + endpoint = f"repos/{repository}/environments/{quote(name, safe='')}" + pages = github_json([f"repos/{repository}/environments", "--paginate"]) + if not any(item["name"] == name for page in pages for item in page["environments"]): + github_json(["--method", "PUT", endpoint], { + "deployment_branch_policy": {"protected_branches": False, "custom_branch_policies": True}, + }) + branch = github_json([f"repos/{repository}"])["default_branch"] + for pattern, kind in ((branch, "branch"), ("v*", "tag")): + github_json(["--method", "POST", f"{endpoint}/deployment-branch-policies"], + {"name": pattern, "type": kind}) + else: + settings = github_json([endpoint]) + if not settings.get("deployment_branch_policy"): + raise ValueError(f"Restrict branches/tags for the {name} environment before uploading signing secrets.") + secrets = { + "FLECTAR_ANDROID_KEYSTORE_BASE64": base64.b64encode( + Path(environment["FLECTAR_ANDROID_KEYSTORE"]).read_bytes() + ).decode("ascii"), + **{key: environment[key] for key in ( + "FLECTAR_ANDROID_KEYSTORE_PASSWORD", "FLECTAR_ANDROID_KEY_ALIAS", "FLECTAR_ANDROID_KEY_PASSWORD", + )}, + } + for key, value in secrets.items(): + # gh encrypts locally. Values go over stdin, never argv or terminal output. + subprocess.run(["gh", "secret", "set", key, "--repo", repository, "--env", name], + input=value, text=True, check=True, stdout=subprocess.DEVNULL) + print(f"Configured the four signing secrets in {repository} / {name}.") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("keystore", type=Path, help="The existing keystore matching the committed certificate") + parser.add_argument("--output", type=Path, help="Write a copy encrypted with a new password") + parser.add_argument("--alias", default="androiddebugkey", help="Existing private-key alias") + parser.add_argument("--upload", action="store_true", help="Store the verified key and credentials in GitHub Actions") + parser.add_argument("--repo", default="flectar/mail") + parser.add_argument("--environment", default="android-release") + args = parser.parse_args() + source = args.keystore.expanduser().resolve(strict=True) + if source.is_relative_to(SCRIPTS.parent): + raise ValueError("Keep private signing keys outside the repository.") + if args.output: + destination = args.output.expanduser().absolute() + if destination.resolve().is_relative_to(SCRIPTS.parent): + raise ValueError("Keep the re-encrypted signing key outside the repository.") + if destination.exists() or destination.is_symlink(): + raise ValueError("The output already exists. To upload it, pass it as the keystore without --output.") + store_password = getpass.getpass("Current keystore password: ") + key_password = getpass.getpass("Current key password (Enter to use the keystore password): ") or store_password + if args.output: + new_password = getpass.getpass("New password (at least 16 characters; save it in your password manager): ") + if new_password != getpass.getpass("Confirm new password: "): + raise ValueError("The new passwords do not match.") + environment = reprotect(source, destination, args.alias, store_password, key_password, new_password) + print(f"Saved {destination}; the private key and certificate are unchanged.") + else: + environment = verify(source, args.alias, store_password, key_password) + if args.upload: + if min(len(environment[key]) for key in ( + "FLECTAR_ANDROID_KEYSTORE_PASSWORD", "FLECTAR_ANDROID_KEY_PASSWORD" + )) < 16: + raise ValueError("Use --output to protect the existing key with a password of at least 16 characters first.") + upload(environment, args.repo, args.environment) + print("Keep the keystore and its password in your secure backup. Do not generate a replacement key.") + + +if __name__ == "__main__": + try: + main() + except (ValueError, OSError, subprocess.CalledProcessError) as error: + raise SystemExit(str(error)) from error diff --git a/scripts/prepare-release.py b/scripts/prepare-release.py index b9a247e1..c355b753 100644 --- a/scripts/prepare-release.py +++ b/scripts/prepare-release.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Stage desktop downloads and the prerelease Android APK with checksums.""" +"""Stage desktop and Android downloads with checksums.""" import hashlib import os @@ -23,9 +23,8 @@ def prepare(source: Path, destination: Path, version: str) -> None: "flectar-mail-macos-arm64.dmg": f"flectar-mail-{version}-macos-arm64.dmg", "flectar-mail-macos-x64.zip": f"flectar-mail-{version}-macos-x64.zip", "flectar-mail-macos-x64.dmg": f"flectar-mail-{version}-macos-x64.dmg", + "flectar-mail.apk": f"flectar-mail-{version}-android-arm64.apk", } - if "-" in version: - packages["flectar-mail.apk"] = f"flectar-mail-{version}-android-arm64-test.apk" # Validate every input before copying anything; unrelated benchmark files # are intentionally excluded from the public downloads. selected = [] @@ -47,13 +46,12 @@ def prepare(source: Path, destination: Path, version: str) -> None: def release_notes(version: str) -> str: android = ( - "An experimental Android arm64 test APK (Android 8.0+) is included. " - "It is test-signed and is not a production or Play Store build. " - "Updates require the same signing key; builds without a persistent test " - "key may require uninstalling the previous app, which deletes local app data. " + "An Android arm64 APK (Android 8.0+) is included. " + "Starting with alpha 8, previews and stable releases share one signing " + "identity, so newer versions can update the app without uninstalling it. " + "Alpha 6 and alpha 7 used different keys and cannot update to this identity. " "Android OAuth requires separate registrations tied to the APK signing " "identity; desktop keys alone do not enable it.\n" - if "-" in version else "The Android test APK is available in prereleases only.\n" ) return ( "Desktop builds for Linux x64, Windows x64, and macOS Apple silicon and Intel. " diff --git a/scripts/restore-android-keystore.sh b/scripts/restore-android-keystore.sh new file mode 100644 index 00000000..43c5644d --- /dev/null +++ b/scripts/restore-android-keystore.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Materialize the shared release secret on an ephemeral Actions runner. +set -euo pipefail +project_dir="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" + +if [[ -z "${FLECTAR_ANDROID_KEYSTORE_BASE64:-}" ]]; then + printf 'FLECTAR_ANDROID_KEYSTORE_BASE64 is required in the android-release environment. Restore the original app signing key.\n' >&2 + exit 1 +fi +: "${RUNNER_TEMP:?RUNNER_TEMP is required}" +: "${GITHUB_ENV:?GITHUB_ENV is required}" +umask 077 +keystore="$(mktemp "$RUNNER_TEMP/flectar-app-signing.XXXXXX.keystore")" +trap 'rm -f "$keystore"' EXIT +printf '%s' "$FLECTAR_ANDROID_KEYSTORE_BASE64" | base64 --decode > "$keystore" +export FLECTAR_ANDROID_KEYSTORE="$keystore" +bash "$project_dir/scripts/verify-android-keystore.sh" +printf 'FLECTAR_ANDROID_KEYSTORE=%s\n' "$keystore" >> "$GITHUB_ENV" +trap - EXIT diff --git a/scripts/test-android-oauth.py b/scripts/test-android-oauth.py index 8b022dea..74f5cfaa 100644 --- a/scripts/test-android-oauth.py +++ b/scripts/test-android-oauth.py @@ -51,20 +51,20 @@ class AndroidOAuthTests(unittest.TestCase): self.assertIn(key, str(error.exception)) self.assertNotIn(value, str(error.exception)) - def run_build(self, script, configuration, ci_variable=None): + def run_build(self, script, configuration, ci_variable=None, preview=False): # Run the real shell entry points. Replace Cargo with a recorder that # stops before compilation; no SDK downloads, signing or network calls. with tempfile.TemporaryDirectory() as directory: root = Path(directory) (root / "scripts").mkdir() - for name in (script, "verify-android-oauth.py", "verify-android-test-keystore.sh"): + for name in (script, "verify-android-oauth.py", "verify-android-keystore.sh", "android-version-code.py"): shutil.copyfile(SCRIPTS / name, root / "scripts" / name) # Exercise the real CI signing guard with a fake public certificate, # without depending on release secrets or a JDK. certificate = b"OAuth test public certificate" android = root / "platform/android" android.mkdir(parents=True) - (android / "test-signing-certificate.sha256").write_text( + (android / "app-signing-certificate.sha256").write_text( hashlib.sha256(certificate).hexdigest() + "\n" ) sdk = root / "sdk" @@ -79,8 +79,9 @@ class AndroidOAuthTests(unittest.TestCase): keytool = binaries / "keytool" keytool.write_text( "#!/usr/bin/env python3\nimport pathlib, sys\n" - "assert sys.argv[1:3] == ['-exportcert', '-keystore']\n" - "assert sys.argv[4:] == ['-alias', 'androiddebugkey', '-storepass', 'android']\n" + "assert sys.argv[1] in ('-exportcert', '-certreq')\n" + "assert sys.argv[2] == '-keystore'\n" + "assert sys.argv[4:8] == ['-alias', 'fixture', '-storepass:env', 'FLECTAR_ANDROID_KEYSTORE_PASSWORD']\n" "assert pathlib.Path(sys.argv[3]).is_file()\n" f"sys.stdout.buffer.write({certificate!r})\n" ) @@ -107,19 +108,20 @@ class AndroidOAuthTests(unittest.TestCase): "ANDROID_NDK_ROOT": str(root / "ndk"), "GRADLE_CMD": str(binaries / "gradle"), "XDG_DATA_HOME": str(root / "data"), - "FLECTAR_ANDROID_TEST_KEYSTORE": str(keystore), "FLECTAR_ANDROID_KEYSTORE": str(keystore), "FLECTAR_ANDROID_KEYSTORE_PASSWORD": "fixture", "FLECTAR_ANDROID_KEY_ALIAS": "fixture", "FLECTAR_ANDROID_KEY_PASSWORD": "fixture", - "FLECTAR_ANDROID_VERSION_CODE": "1", - "FLECTAR_ANDROID_VERSION_NAME": "0.1.0", + "FLECTAR_ANDROID_VERSION_NAME": "0.1.0-alpha.8" if preview else "0.1.0", "OAUTH_CAPTURE": str(capture), **configuration, }) if ci_variable: env[ci_variable] = "true" - result = subprocess.run(["bash", str(root / "scripts" / script)], + command = ["bash", str(root / "scripts" / script)] + if preview: + command.append("--preview") + result = subprocess.run(command, env=env, capture_output=True, text=True, timeout=15) recorded = json.loads(capture.read_text()) if capture.exists() else None return result, recorded @@ -128,7 +130,7 @@ class AndroidOAuthTests(unittest.TestCase): for ci_variable in (None, "CI", "GITHUB_ACTIONS"): for values in ({}, {GOOGLE: "", MICROSOFT: "", REDIRECT: ""}, VALID): with self.subTest(ci_variable=ci_variable, configuration=values): - result, recorded = self.run_build("build-android-test-apk.sh", values, ci_variable) + result, recorded = self.run_build("build-android-release.sh", values, ci_variable, preview=True) self.assertEqual(result.returncode, 73, result.stdout + result.stderr) self.assertEqual(recorded, {key: values.get(key) for key in (GOOGLE, MICROSOFT, REDIRECT)}) @@ -136,7 +138,7 @@ class AndroidOAuthTests(unittest.TestCase): for ci_variable in (None, "CI", "GITHUB_ACTIONS"): for values in ({MICROSOFT: "00000000-0000-0000-0000-000000000000"}, {GOOGLE: "test.apps.googleusercontent.com"}): with self.subTest(ci_variable=ci_variable, configuration=values): - result, recorded = self.run_build("build-android-test-apk.sh", values, ci_variable) + result, recorded = self.run_build("build-android-release.sh", values, ci_variable, preview=True) self.assertEqual(result.returncode, 1, result.stdout + result.stderr) self.assertIsNone(recorded) self.assertIn("not a valid", result.stderr) @@ -151,6 +153,15 @@ class AndroidOAuthTests(unittest.TestCase): self.assertEqual(result.returncode, 1, result.stdout + result.stderr) self.assertIsNone(recorded) + def test_stable_cannot_bypass_provider_checks_or_override_version_order(self): + for values in ( + {"FLECTAR_ANDROID_VERSION_NAME": "0.1.0"}, + {"FLECTAR_ANDROID_VERSION_CODE": "1"}, + ): + result, recorded = self.run_build("build-android-release.sh", values, preview=True) + self.assertEqual(result.returncode, 1, result.stdout + result.stderr) + self.assertIsNone(recorded) + if __name__ == "__main__": unittest.main() diff --git a/scripts/test-android-signing.py b/scripts/test-android-signing.py new file mode 100644 index 00000000..02e8a382 --- /dev/null +++ b/scripts/test-android-signing.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +"""Exercise real keystore migration, private-key access, and secret delivery.""" + +import base64 +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +sys.dont_write_bytecode = True +SCRIPTS = Path(__file__).resolve().parent +spec = importlib.util.spec_from_file_location("configure_signing", SCRIPTS / "configure-android-signing.py") +configure = importlib.util.module_from_spec(spec) +spec.loader.exec_module(configure) + + +@unittest.skipUnless(shutil.which("keytool"), "A JDK is required for real signing tests") +class KeystoreTests(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + self.root = Path(self.directory.name) + scripts = self.root / "scripts" + scripts.mkdir() + shutil.copyfile(SCRIPTS / "verify-android-keystore.sh", scripts / "verify-android-keystore.sh") + patcher = patch.object(configure, "SCRIPTS", scripts) + patcher.start() + self.addCleanup(patcher.stop) + self.source = self.root / "original.p12" + self.alias = "original-key" + self.password = "original fixture password" + self.new_password = "new fixture password with spaces $()" + self.environment = {**os.environ, "FIXTURE_PASSWORD": self.password} + self.keytool( + "-genkeypair", "-noprompt", "-keystore", str(self.source), + "-storetype", "PKCS12", "-storepass:env", "FIXTURE_PASSWORD", + "-keypass:env", "FIXTURE_PASSWORD", "-alias", self.alias, + "-dname", "CN=Flectar Mail Test,O=Android,C=US", "-keyalg", "RSA", + "-keysize", "2048", "-validity", "10000", + ) + self.certificate = self.keytool( + "-exportcert", "-keystore", str(self.source), "-alias", self.alias, + "-storepass:env", "FIXTURE_PASSWORD", + ) + self.pin = self.root / "platform/android/app-signing-certificate.sha256" + self.pin.parent.mkdir(parents=True) + self.pin.write_text(hashlib.sha256(self.certificate).hexdigest() + "\n") + + def keytool(self, *arguments): + return subprocess.run( + ["keytool", *arguments], env=self.environment, + check=True, capture_output=True, + ).stdout + + def test_password_migration_preserves_certificate_and_private_key(self): + original_bytes = self.source.read_bytes() + destination = self.root / "protected.p12" + result = configure.reprotect(self.source, destination, self.alias, + self.password, self.password, self.new_password) + self.assertEqual(self.source.read_bytes(), original_bytes) + self.assertEqual(destination.stat().st_mode & 0o777, 0o600) + self.assertEqual(result["FLECTAR_ANDROID_KEYSTORE_PASSWORD"], self.new_password) + actual = subprocess.check_output([ + "keytool", "-exportcert", "-keystore", str(destination), "-alias", self.alias, + "-storepass:env", "FLECTAR_ANDROID_KEYSTORE_PASSWORD", + ], env=result) + self.assertEqual(actual, self.certificate) + # Verification includes signing a CSR, so a certificate-only copy or + # incorrect private-key password cannot masquerade as a working backup. + with self.assertRaises(subprocess.CalledProcessError): + configure.verify(destination, self.alias, self.password, self.password) + with self.assertRaises(subprocess.CalledProcessError): + configure.verify(destination, self.alias, self.new_password, "wrong private-key password") + with self.assertRaises(ValueError): + configure.reprotect(self.source, destination, self.alias, + self.password, self.password, self.new_password) + + def test_replacement_key_and_certificate_only_entries_are_rejected(self): + self.pin.write_text("00" * 32 + "\n") + with self.assertRaises(subprocess.CalledProcessError): + configure.verify(self.source, self.alias, self.password, self.password) + self.pin.write_text(hashlib.sha256(self.certificate).hexdigest() + "\n") + certificate = self.root / "public.der" + certificate.write_bytes(self.certificate) + public_store = self.root / "public-only.p12" + self.keytool( + "-importcert", "-noprompt", "-keystore", str(public_store), + "-storetype", "PKCS12", "-storepass:env", "FIXTURE_PASSWORD", + "-alias", self.alias, "-file", str(certificate), + ) + with self.assertRaises(subprocess.CalledProcessError): + configure.verify(public_store, self.alias, self.password, self.password) + + +class GitHubApiTests(unittest.TestCase): + def test_paginated_json_supports_older_github_cli(self): + pages = [ + {"environments": [{"name": "another-environment"}]}, + {"environments": [{"name": "android-release"}]}, + ] + for selected in (pages[:1], pages): + for separator in ("", "\n"): + with self.subTest(pages=len(selected), separator=separator): + output = "\n " + separator.join(json.dumps(page, indent=2) for page in selected) + "\n" + with patch.object(configure.subprocess, "run") as run: + run.return_value.stdout = output + actual = configure.github_json(["repos/owner/repo/environments", "--paginate"]) + self.assertEqual(actual, selected) + self.assertEqual(run.call_args.args[0], [ + "gh", "api", "repos/owner/repo/environments", "--paginate", + ]) + + def test_invalid_later_page_is_not_ignored(self): + with patch.object(configure.subprocess, "run") as run: + run.return_value.stdout = '{"environments": []}\n{"environments":' + with self.assertRaises(json.JSONDecodeError): + configure.github_json(["repos/owner/repo/environments", "--paginate"]) + + def test_nonpaginated_json_stays_an_object(self): + with patch.object(configure.subprocess, "run") as run: + run.return_value.stdout = '{"default_branch": "main"}' + self.assertEqual(configure.github_json(["repos/owner/repo"]), {"default_branch": "main"}) + + +class SecretUploadTests(unittest.TestCase): + def test_upload_scopes_all_secrets_and_uses_stdin(self): + with tempfile.TemporaryDirectory() as directory: + keystore = Path(directory) / "fixture.p12" + keystore.write_bytes(b"fixture private key") + environment = { + "FLECTAR_ANDROID_KEYSTORE": str(keystore), + "FLECTAR_ANDROID_KEYSTORE_PASSWORD": "secret store password", + "FLECTAR_ANDROID_KEY_ALIAS": "fixture-alias", + "FLECTAR_ANDROID_KEY_PASSWORD": "secret key password", + } + responses = [ + [{"environments": []}], {}, {"default_branch": "main"}, {}, {}, + ] + with patch.object(configure, "github_json", side_effect=responses) as github: + with patch.object(configure.subprocess, "run") as run: + configure.upload(environment, "owner/repo", "android-release") + self.assertEqual(github.call_args_list[0].args[0], [ + "repos/owner/repo/environments", "--paginate", + ]) + self.assertEqual(github.call_args_list[3].args[1], {"name": "main", "type": "branch"}) + self.assertEqual(github.call_args_list[4].args[1], {"name": "v*", "type": "tag"}) + self.assertEqual(run.call_count, 4) + values = {} + for call in run.call_args_list: + command = call.args[0] + self.assertEqual(command[:3], ["gh", "secret", "set"]) + self.assertEqual(command[4:], ["--repo", "owner/repo", "--env", "android-release"]) + self.assertNotIn(call.kwargs["input"], command) + values[command[3]] = call.kwargs["input"] + self.assertEqual(base64.b64decode(values["FLECTAR_ANDROID_KEYSTORE_BASE64"]), keystore.read_bytes()) + for key in ("FLECTAR_ANDROID_KEYSTORE_PASSWORD", "FLECTAR_ANDROID_KEY_ALIAS", "FLECTAR_ANDROID_KEY_PASSWORD"): + self.assertEqual(values[key], environment[key]) + + def test_upload_refuses_an_unrestricted_existing_environment(self): + with patch.object(configure, "github_json", side_effect=[ + [{"environments": [{"name": "android-release"}]}], {"deployment_branch_policy": None}, + ]): + with patch.object(configure.subprocess, "run") as run, self.assertRaises(ValueError): + configure.upload({}, "owner/repo", "android-release") + run.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test-release.py b/scripts/test-release.py index cc7d313d..f6b062f0 100644 --- a/scripts/test-release.py +++ b/scripts/test-release.py @@ -10,7 +10,6 @@ import shutil import subprocess import sys import tempfile -import textwrap import tomllib import unittest import xml.etree.ElementTree as ET @@ -33,6 +32,7 @@ release_assets = load_script("prepare-release") prepare = release_assets.prepare android_signing = load_script("verify-android-signing") verify_android_signing = android_signing.verify +android_version_code = load_script("android-version-code").version_code linux_metadata = load_script("stage-linux-metadata") stage_linux_metadata = linux_metadata.stage @@ -44,7 +44,8 @@ class ReleaseTests(unittest.TestCase): environment = { **os.environ, "CI": "", "GITHUB_ACTIONS": "", ci_variable: "true", - "FLECTAR_ANDROID_TEST_KEYSTORE": "", + "FLECTAR_ANDROID_KEYSTORE": "", + "FLECTAR_ANDROID_VERSION_NAME": "0.1.0-alpha.8", "XDG_DATA_HOME": directory, } result = subprocess.run( @@ -52,31 +53,36 @@ class ReleaseTests(unittest.TestCase): env=environment, capture_output=True, text=True, ) self.assertNotEqual(result.returncode, 0) - self.assertIn("CI requires an existing FLECTAR_ANDROID_TEST_KEYSTORE", result.stderr) + self.assertIn("requires FLECTAR_ANDROID_KEYSTORE", result.stderr) self.assertEqual(list(Path(directory).rglob("*")), []) def test_android_persistent_key_restore_and_pin(self): # Exercise the workflow's actual restore command and its verifier. # Stand in for keytool's public DER export; no private keys in fixtures. - workflow = (SCRIPTS.parent / ".github/workflows/native-platforms.yml").read_text() - restore = workflow.split("- name: Restore required persistent alpha signing key\n", 1)[1] - restore = restore.split(" run: |\n", 1)[1].split("\n - ", 1)[0] - restore = textwrap.dedent(restore) certificate = b"original public certificate" digest = hashlib.sha256(certificate).hexdigest() with tempfile.TemporaryDirectory() as directory: root = Path(directory) (root / "scripts").mkdir() (root / "platform/android").mkdir(parents=True) - pin = root / "platform/android/test-signing-certificate.sha256" + pin = root / "platform/android/app-signing-certificate.sha256" pin.write_text(digest + "\n") - shutil.copyfile(SCRIPTS / "verify-android-test-keystore.sh", root / "scripts/verify-android-test-keystore.sh") + for name in ("verify-android-keystore.sh", "restore-android-keystore.sh"): + shutil.copyfile(SCRIPTS / name, root / "scripts" / name) keytool = root / "keytool" keytool.write_text( "#!/usr/bin/env python3\n" - "import pathlib, sys\n" - "assert sys.argv[1:3] == ['-exportcert', '-keystore']\n" - "assert sys.argv[4:] == ['-alias', 'androiddebugkey', '-storepass', 'android']\n" + "import os, pathlib, sys\n" + "if sys.argv[1] == '-list':\n" + " print('Keystore type: JKS')\n" + " sys.exit(0)\n" + "assert sys.argv[1] in ('-exportcert', '-certreq')\n" + "assert sys.argv[2] == '-keystore'\n" + "assert sys.argv[4:8] == ['-alias', 'release-key', '-storepass:env', 'FLECTAR_ANDROID_KEYSTORE_PASSWORD']\n" + "if os.environ['FLECTAR_ANDROID_KEYSTORE_PASSWORD'] != 'fixture store password': sys.exit(1)\n" + "if sys.argv[1] == '-certreq':\n" + " assert sys.argv[8:] == ['-keypass:env', 'FLECTAR_ANDROID_KEY_PASSWORD']\n" + " sys.exit(0 if os.environ['FLECTAR_ANDROID_KEY_PASSWORD'] == 'fixture key password' else 1)\n" "data = pathlib.Path(sys.argv[3]).read_bytes()\n" "if data == b'unreadable keystore': sys.exit(1)\n" "sys.stdout.buffer.write(data)\n" @@ -86,9 +92,12 @@ class ReleaseTests(unittest.TestCase): environment = { **os.environ, "PATH": f"{root}{os.pathsep}{os.environ['PATH']}", "RUNNER_TEMP": str(root), "GITHUB_ENV": str(output), + "FLECTAR_ANDROID_KEY_ALIAS": "release-key", + "FLECTAR_ANDROID_KEYSTORE_PASSWORD": "fixture store password", + "FLECTAR_ANDROID_KEY_PASSWORD": "fixture key password", } for encoded, success, message in ( - ("", False, "FLECTAR_ANDROID_TEST_KEYSTORE_BASE64 is required"), + ("", False, "FLECTAR_ANDROID_KEYSTORE_BASE64 is required"), ("not base64!", False, ""), (base64.b64encode(b"unreadable keystore").decode(), False, ""), (base64.b64encode(b"replacement public certificate").decode(), False, "differs from the pinned"), @@ -97,26 +106,65 @@ class ReleaseTests(unittest.TestCase): with self.subTest(encoded=encoded): output.unlink(missing_ok=True) result = subprocess.run( - ["bash", "-euo", "pipefail", "-c", restore], cwd=root, - env={**environment, "TEST_KEYSTORE_BASE64": encoded}, + ["bash", str(root / "scripts/restore-android-keystore.sh")], cwd=root, + env={**environment, "FLECTAR_ANDROID_KEYSTORE_BASE64": encoded}, capture_output=True, text=True, ) self.assertEqual(result.returncode == 0, success, result.stderr) self.assertIn(message, result.stdout + result.stderr) if success: - self.assertIn("FLECTAR_ANDROID_TEST_KEYSTORE=", output.read_text()) - self.assertEqual((root / "flectar-android-test.keystore").stat().st_mode & 0o777, 0o600) + keystore = Path(output.read_text().strip().split("=", 1)[1]) + self.assertEqual(keystore.stat().st_mode & 0o777, 0o600) else: self.assertFalse(output.exists()) + self.assertFalse(list(root.glob("flectar-app-signing.*.keystore"))) + output.unlink() + result = subprocess.run( + ["bash", str(root / "scripts/restore-android-keystore.sh")], + env={**environment, "FLECTAR_ANDROID_KEYSTORE_BASE64": base64.b64encode(certificate).decode(), + "FLECTAR_ANDROID_KEY_PASSWORD": "wrong password"}, + capture_output=True, text=True, + ) + self.assertNotEqual(result.returncode, 0) + self.assertFalse(output.exists()) + self.assertEqual(list(root.glob("flectar-app-signing.*.keystore")), [keystore]) + for missing in ("FLECTAR_ANDROID_KEYSTORE_PASSWORD", "FLECTAR_ANDROID_KEY_ALIAS", "FLECTAR_ANDROID_KEY_PASSWORD"): + with self.subTest(missing=missing): + result = subprocess.run( + ["bash", str(root / "scripts/restore-android-keystore.sh")], + env={**environment, "FLECTAR_ANDROID_KEYSTORE_BASE64": base64.b64encode(certificate).decode(), + missing: ""}, + capture_output=True, text=True, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn(f"requires {missing}", result.stderr) + self.assertFalse(output.exists()) + self.assertEqual(list(root.glob("flectar-app-signing.*.keystore")), [keystore]) for value in ("", "not-a-digest"): pin.write_text(value) result = subprocess.run( - ["bash", str(root / "scripts/verify-android-test-keystore.sh")], - env={**environment, "FLECTAR_ANDROID_TEST_KEYSTORE": str(root / "flectar-android-test.keystore")}, + ["bash", str(root / "scripts/verify-android-keystore.sh")], + env={**environment, "FLECTAR_ANDROID_KEYSTORE": str(keystore)}, capture_output=True, text=True, ) self.assertNotEqual(result.returncode, 0) - self.assertIn("pinned Android test signing certificate SHA-256 is invalid", result.stderr) + self.assertIn("pinned Android app signing certificate SHA-256 is invalid", result.stderr) + + def test_android_version_codes_order_all_release_channels(self): + versions = ( + "0.1.0-alpha.8", "0.1.0-alpha.999", "0.1.0-beta.1", "0.1.0-beta.999", + "0.1.0-rc.1", "0.1.0-rc.999", "0.1.0", "0.1.1-alpha.1", "0.1.1", + "0.2.0-alpha.1", "0.99.99", "1.0.0-alpha.1", "20.99.99", + ) + codes = [android_version_code(version) for version in versions] + self.assertEqual(codes, sorted(set(codes))) + self.assertEqual(android_version_code("0.1.0-alpha.8"), 1_000_008) + self.assertEqual(android_version_code("0.1.0"), 1_009_999) + self.assertLessEqual(max(codes), 2_100_000_000) + for version in ("21.0.0", "0.100.0", "0.0.100", "0.1.0-alpha.1000", "0.1.0-alpha.0", + "01.1.0", "0.1.0-dev.1", "0.1.0+build", "0.1.0-alpha.01"): + with self.subTest(version=version), self.assertRaises(ValueError): + android_version_code(version) def test_flatpak_pdfium_archive_keeps_expected_layout(self): manifest = ( @@ -189,6 +237,7 @@ class ReleaseTests(unittest.TestCase): expected = "6f269ab047ca5d5eae7317758a265251e3d5645c5307633a5b92d192b8646280" for output in (signing, signing.replace("V2 Signer:", "Signer #1")): verify_android_signing(output, "test", expected) + verify_android_signing(output, "production", expected) with self.assertRaises(ValueError): verify_android_signing(output, "test", "00" * 32) with self.assertRaises(ValueError): @@ -224,21 +273,20 @@ class ReleaseTests(unittest.TestCase): f"Signer #1 certificate DN: {subject}\n" f"Signer #1 certificate SHA-256 digest: {digest}\n" ) - with self.subTest(subject=subject): - verify_android_signing(signing, "test", digest.upper()) - for expected in (None, "", "invalid", "cd" * 32): + for mode in ("test", "production"): + with self.subTest(subject=subject, mode=mode): + verify_android_signing(signing, mode, digest.upper()) + for expected in (None, "", "invalid", "cd" * 32): + with self.assertRaises(ValueError): + verify_android_signing(signing, mode, expected) with self.assertRaises(ValueError): - verify_android_signing(signing, "test", expected) - with self.assertRaises(ValueError): - verify_android_signing(signing, "production") - with self.assertRaises(ValueError): - verify_android_signing(signing.replace("signers: 1", "signers: 2"), "test", digest) - with self.assertRaises(ValueError): - verify_android_signing(signing.replace(": true", ": false"), "test", digest) - production = signing.replace("CN=Flectar Mail Test", "CN=Flectar") - verify_android_signing(production, "production") + verify_android_signing(signing.replace("signers: 1", "signers: 2"), mode, digest) + with self.assertRaises(ValueError): + verify_android_signing(signing.replace(": true", ": false"), mode, digest) + # A plausible release subject cannot make a replacement key trusted. + production = signing.replace("CN=Flectar Mail Test", "CN=Flectar").replace(digest, "cd" * 32) with self.assertRaises(ValueError): - verify_android_signing(production.replace("CN=Flectar", "CN=Android Debug"), "production") + verify_android_signing(production, "production", digest) def test_linux_metadata_versions(self): with tempfile.TemporaryDirectory() as directory: @@ -451,7 +499,7 @@ pathlib.Path(sys.argv[2]).write_text(json.dumps({ dist = root / "dist" prepare(source, dist, "0.1.0-beta.1") self.assertEqual(len(list(dist.iterdir())), 12) - self.assertTrue((dist / "flectar-mail-0.1.0-beta.1-android-arm64-test.apk").is_file()) + self.assertTrue((dist / "flectar-mail-0.1.0-beta.1-android-arm64.apk").is_file()) # GitHub must not rewrite a download name after we checksum it. deb = dist / "flectar-mail_0.1.0.beta.1_amd64.deb" self.assertEqual(deb.read_bytes(), (source / names[1]).read_bytes()) @@ -475,20 +523,23 @@ pathlib.Path(sys.argv[2]).write_text(json.dumps({ with self.assertRaises(ValueError): prepare(source, root / "duplicate", "0.1.0-beta.1") - # Stable releases keep the ten desktop downloads, even if the - # input folder happens to contain test APKs from another step. + # Stable releases also require exactly one signed Android APK. + (source / missing.name).unlink() (source / "linux/deb/flectar-mail_0.1.0~beta.1_amd64.deb").rename( source / "linux/deb/flectar-mail_0.1.0_amd64.deb" ) stable_dist = root / "stable" prepare(source, stable_dist, "0.1.0") - self.assertEqual(len(list(stable_dist.iterdir())), 11) + self.assertEqual(len(list(stable_dist.iterdir())), 12) self.assertTrue((stable_dist / "flectar-mail_0.1.0_amd64.deb").is_file()) - self.assertFalse(list(stable_dist.glob("*.apk"))) + self.assertTrue((stable_dist / "flectar-mail-0.1.0-android-arm64.apk").is_file()) + missing.unlink() + with self.assertRaises(ValueError): + prepare(source, root / "missing-stable", "0.1.0") def test_notes_distinguish_android_preview(self): - self.assertIn("test-signed", release_assets.release_notes("0.1.0-beta.1")) - self.assertIn("prereleases only", release_assets.release_notes("0.1.0")) + for version in ("0.1.0-beta.1", "0.1.0"): + self.assertIn("previews and stable releases share one signing", release_assets.release_notes(version)) self.assertIn("does not receive Flathub updates", release_assets.release_notes("0.1.0-beta.1")) self.assertIn("build-provenance attestations", release_assets.release_notes("0.1.0-beta.1")) self.assertIn("Apple silicon and Intel", release_assets.release_notes("0.1.0-beta.1")) diff --git a/scripts/verify-android-apk.sh b/scripts/verify-android-apk.sh index f326ec77..5c3bbc92 100755 --- a/scripts/verify-android-apk.sh +++ b/scripts/verify-android-apk.sh @@ -40,7 +40,10 @@ assert_contains "$badging" "package: name='com.flectar.mail'" 'package identity' if [[ -n "${FLECTAR_ANDROID_VERSION_NAME:-}" ]]; then assert_contains "$badging" "versionName='$FLECTAR_ANDROID_VERSION_NAME'" 'release version name' fi -if [[ -n "${FLECTAR_ANDROID_VERSION_CODE:-}" ]]; then +if [[ "$signing_mode" == production && -n "${FLECTAR_ANDROID_VERSION_NAME:-}" ]]; then + expected_code="$(python3 "$project_dir/scripts/android-version-code.py" "$FLECTAR_ANDROID_VERSION_NAME")" + assert_contains "$badging" "versionCode='$expected_code'" 'shared release version code' +elif [[ -n "${FLECTAR_ANDROID_VERSION_CODE:-}" ]]; then assert_contains "$badging" "versionCode='$FLECTAR_ANDROID_VERSION_CODE'" 'release version code' fi assert_contains "$badging" "sdkVersion:'26'" 'minimum SDK' @@ -76,23 +79,23 @@ python3 "$project_dir/scripts/verify-android-native.py" "$apk" signing="$($apksigner verify --verbose --print-certs "$apk")" printf '%s\n' "$signing" signing_args=("$signing_mode") -if [[ "$signing_mode" == test ]]; then - if [[ "${CI:-}" == true || "${GITHUB_ACTIONS:-}" == true ]]; then - # Compare the final APK against the committed identity, not merely whichever - # keystore happened to be supplied to this build. - expected_sha256="$(bash "$project_dir/scripts/verify-android-test-keystore.sh")" - else - data_home="${XDG_DATA_HOME:-$HOME/.local/share}" - keystore="${FLECTAR_ANDROID_TEST_KEYSTORE:-$data_home/flectar-mail/android/test-signing.keystore}" - if [[ ! -f "$keystore" ]]; then - printf 'Expected test keystore not found: %s\n' "$keystore" >&2 - exit 1 - fi - # Export only the public certificate. A subject string is not a signing - # identity, and SDK/JDK versions format that string differently. - expected_sha256="$(keytool -exportcert -keystore "$keystore" -alias androiddebugkey -storepass android | sha256sum)" +if [[ "$signing_mode" == production || "${CI:-}" == true || "${GITHUB_ACTIONS:-}" == true ]]; then + # Published previews and stable releases must match the same committed + # certificate. Verification needs no access to the private key or password. + expected_sha256="$(cat "$project_dir/platform/android/app-signing-certificate.sha256")" +elif [[ "$signing_mode" == test ]]; then + data_home="${XDG_DATA_HOME:-$HOME/.local/share}" + keystore="${FLECTAR_ANDROID_TEST_KEYSTORE:-$data_home/flectar-mail/android/test-signing.keystore}" + if [[ ! -f "$keystore" ]]; then + printf 'Expected test keystore not found: %s\n' "$keystore" >&2 + exit 1 fi - signing_args+=(--expected-sha256 "${expected_sha256%% *}") + # Local development keys are unrelated to the published app identity. + expected_sha256="$(keytool -exportcert -keystore "$keystore" -alias androiddebugkey -storepass android | sha256sum)" +else + printf 'Unknown Android signing mode: %s\n' "$signing_mode" >&2 + exit 1 fi +signing_args+=(--expected-sha256 "${expected_sha256%% *}") printf '%s\n' "$signing" | python3 "$project_dir/scripts/verify-android-signing.py" "${signing_args[@]}" printf 'Verified Android package metadata, arm64 ABI, TLS policy, and signing: %s\n' "$apk" diff --git a/scripts/verify-android-keystore.sh b/scripts/verify-android-keystore.sh new file mode 100644 index 00000000..d0b3a579 --- /dev/null +++ b/scripts/verify-android-keystore.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Check the single app signing identity used by every published Android APK. +set -euo pipefail + +project_dir="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +for name in FLECTAR_ANDROID_KEYSTORE FLECTAR_ANDROID_KEYSTORE_PASSWORD FLECTAR_ANDROID_KEY_ALIAS FLECTAR_ANDROID_KEY_PASSWORD; do + if [[ -z "${!name:-}" ]]; then + printf 'Android signing requires %s. Restore the original app signing key and its credentials; never generate a replacement release key.\n' "$name" >&2 + exit 1 + fi +done +if [[ ! -f "$FLECTAR_ANDROID_KEYSTORE" ]]; then + printf 'The configured Android app signing keystore does not exist.\n' >&2 + exit 1 +fi + +# keytool silently ignores a different -keypass for PKCS12, while Gradle's +# KeyStore.getKey does not. Reject inconsistent credentials before compilation. +if [[ "$FLECTAR_ANDROID_KEYSTORE_PASSWORD" != "$FLECTAR_ANDROID_KEY_PASSWORD" ]]; then + details="$(keytool -list -J-Duser.language=en -J-Duser.country=US \ + -keystore "$FLECTAR_ANDROID_KEYSTORE" \ + -storepass:env FLECTAR_ANDROID_KEYSTORE_PASSWORD)" + if [[ "$details" == *"Keystore type: PKCS12"* ]]; then + printf 'PKCS12 requires matching keystore and private-key passwords.\n' >&2 + exit 1 + fi +fi + +expected="$(cat "$project_dir/platform/android/app-signing-certificate.sha256")" +if [[ ! "$expected" =~ ^[0-9a-f]{64}$ ]]; then + printf 'The pinned Android app signing certificate SHA-256 is invalid.\n' >&2 + exit 1 +fi +actual="$(keytool -exportcert -keystore "$FLECTAR_ANDROID_KEYSTORE" \ + -alias "$FLECTAR_ANDROID_KEY_ALIAS" \ + -storepass:env FLECTAR_ANDROID_KEYSTORE_PASSWORD | sha256sum)" +if [[ "${actual%% *}" != "$expected" ]]; then + printf 'Android signing key differs from the pinned app identity. Alpha, beta, and stable must use the original key; do not replace the fingerprint.\n' >&2 + exit 1 +fi +# Exporting a certificate alone also succeeds for certificate-only entries. +# A discarded CSR proves that the private key and its password are usable. +keytool -certreq -keystore "$FLECTAR_ANDROID_KEYSTORE" \ + -alias "$FLECTAR_ANDROID_KEY_ALIAS" \ + -storepass:env FLECTAR_ANDROID_KEYSTORE_PASSWORD \ + -keypass:env FLECTAR_ANDROID_KEY_PASSWORD >/dev/null +printf '%s\n' "$expected" diff --git a/scripts/verify-android-signing.py b/scripts/verify-android-signing.py index c6b1dbd7..25bf985c 100644 --- a/scripts/verify-android-signing.py +++ b/scripts/verify-android-signing.py @@ -31,18 +31,12 @@ def verify(signing: str, mode: str, expected_sha256: str | None = None) -> None: raise ValueError("APK v2 signature is missing") if not re.search(r"^Number of signers: 1$", signing, re.MULTILINE): raise ValueError("expected exactly one APK signer") - if mode == "test": + if mode in ("test", "production"): actual = certificate_digest(signing, "SHA-256") if not expected_sha256 or not re.fullmatch(r"[0-9a-fA-F]{64}", expected_sha256): - raise ValueError("expected test certificate SHA-256 digest is missing or invalid") + raise ValueError("expected certificate SHA-256 digest is missing or invalid") if actual != expected_sha256.lower(): - raise ValueError("APK signer does not match the configured test keystore") - elif mode == "production": - # Subject formatting differs between SDK/JDK versions. Check the CN - # without depending on ordering or spaces between the subject fields. - subject = certificate_field(signing, "DN") - if re.search(r"(?:^|,)\s*CN\s*=\s*(?:Flectar Mail Test|Android Debug)\s*(?:,|$)", subject): - raise ValueError("a test/debug signing identity was used for production") + raise ValueError("APK signer does not match the expected signing certificate") else: raise ValueError(f"unknown signing mode: {mode}") diff --git a/scripts/verify-android-test-keystore.sh b/scripts/verify-android-test-keystore.sh deleted file mode 100644 index bdc90ea7..00000000 --- a/scripts/verify-android-test-keystore.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env bash -# Check the persistent alpha signing identity before building or publishing. -set -euo pipefail - -project_dir="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" -keystore="${FLECTAR_ANDROID_TEST_KEYSTORE:-}" -if [[ -z "$keystore" || ! -f "$keystore" ]]; then - printf 'CI requires an existing FLECTAR_ANDROID_TEST_KEYSTORE. Restore FLECTAR_ANDROID_TEST_KEYSTORE_BASE64; never generate a replacement release key.\n' >&2 - exit 1 -fi - -expected="$(cat "$project_dir/platform/android/test-signing-certificate.sha256")" -if [[ ! "$expected" =~ ^[0-9a-f]{64}$ ]]; then - printf 'The pinned Android test signing certificate SHA-256 is invalid.\n' >&2 - exit 1 -fi -actual="$(keytool -exportcert -keystore "$keystore" -alias androiddebugkey -storepass android | sha256sum)" -if [[ "${actual%% *}" != "$expected" ]]; then - printf 'Android test signing key differs from the pinned alpha 8 identity. Restore the original keystore to preserve updates; do not replace the fingerprint.\n' >&2 - exit 1 -fi -printf '%s\n' "$expected"