From 118566d96d3843683ed778780aa9e2cf2ca2a9c3 Mon Sep 17 00:00:00 2001 From: Michael Date: Sun, 4 Oct 2026 14:11:15 -0700 Subject: [PATCH] build: release signing key with v3.1 key rotation from the debug key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Releases through v2.4 were signed with this machine's Android debug key. Add a dedicated release key and sign releases with APK Signature Scheme v3.1 key rotation (old key vouches for the new one via a lineage file), so the next release still installs as an update over v2.4 — no uninstall, no lost progress. - scripts/sign-release.sh: signs an unsigned release APK with debug key -> release key rotation via apksigner; reads secrets from ~/.config/circuitqueest/ (passwords via env, not argv); no v4 sidecar. - Gradle unchanged: release builds stay unsigned (CI too). - Verified on a real Android 14 device: a rotated staging build installed as an update over the debug-signed staging app (firstInstallTime kept), and the package now records old + new signing certs. - DEPLOYMENT.md: real Code Signing section (keys, rotation, backups, signing and pre-publish update test). .gitignore: key material patterns. - RELEASE_NOTES: Unreleased entry. Key material is outside the repo, backed up to hamptonserver:~/backups/circuitqueest-signing/. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 8 ++++++ RELEASE_NOTES.md | 7 +++++ docs/DEPLOYMENT.md | 64 ++++++++++++++++++++++------------------- scripts/sign-release.sh | 51 ++++++++++++++++++++++++++++++++ 4 files changed, 100 insertions(+), 30 deletions(-) create mode 100755 scripts/sign-release.sh diff --git a/.gitignore b/.gitignore index 031b0ec..0395f84 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,11 @@ local.properties CircuitQueest.zip .kotlin + +# Signing material lives in ~/.config/circuitqueest/ — never commit it +*.jks +*.keystore +*.p12 +keystore.properties +*.lineage +*.idsig diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index fc25e10..cb4abe0 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,10 @@ +# Unreleased + +### Engineering +- **Release signing key.** Releases are now signed with a dedicated release key instead of the Android debug key, using APK Signature Scheme v3.1 key rotation so the next version still installs as an update over v2.4 and earlier (no uninstall, progress kept). See `docs/DEPLOYMENT.md` → Code Signing. + +--- + # CircuitQuEEst v2.4 — Fair Progression & Launch-Crash Fix **Release Date:** October 4, 2026 diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 07371c5..6e8cead 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -26,14 +26,14 @@ Before releasing to production: ### Release Build ```bash ./gradlew assembleRelease -# Output: app/build/outputs/apk/release/app-release.apk +# Output: app/build/outputs/apk/release/app-release-unsigned.apk — sign it (see Code Signing) ``` **Release builds include:** - ✅ ProGuard R8 code obfuscation - ✅ Resource shrinking - ✅ Minification (20-40% smaller APK) -- ✅ Release signing (configured locally) +- ✅ Release signing via `scripts/sign-release.sh` (key rotation; see Code Signing) ## Version Management @@ -55,39 +55,43 @@ defaultConfig { ## Code Signing -### Local Signing Setup +Gradle builds the release APK **unsigned**; `scripts/sign-release.sh` signs it. No signing +material is in the repo. + +### Keys and key rotation +- Releases up to **v2.4** were signed with the Android debug key (`~/.android/debug.keystore`, + cert SHA-256 `6554e4a2…d3b12b`). +- From **v2.5** on, the release key `~/.config/circuitqueest/release.jks` (alias `circuitqueest`, + cert SHA-256 `0fcd73f2…893bb7`, valid to 2054) takes over via **APK Signature Scheme v3.1 key + rotation**: `~/.config/circuitqueest/rotation.lineage` records the old key vouching for the new + one, so new releases install as updates over old ones and players keep their progress. + Android 13+ verifies against the new key; Android 8–12 against the old one. +- Every release therefore needs **all four files**: `release.jks`, `keystore.properties` + (passwords), `rotation.lineage`, and `~/.android/debug.keystore`. Backups: + `hamptonserver:~/backups/circuitqueest-signing/`. Losing them means future releases can't + update existing installs. + +### Signing a release ```bash -# Create keystore (one-time) -keytool -genkey -v -keystore ~/.android/keystore.jks \ - -keyalg RSA -keysize 2048 -validity 10000 -alias circuitqueest - -# Store credentials in local.properties -RELEASE_STORE_FILE=~/.android/keystore.jks -RELEASE_STORE_PASSWORD=your_password -RELEASE_KEY_ALIAS=circuitqueest -RELEASE_KEY_PASSWORD=your_key_password +./gradlew assembleRelease +scripts/sign-release.sh app/build/outputs/apk/release/app-release-unsigned.apk CircuitQueest-vX.Y.apk ``` +The script prints the verification: expect v3/v3.1 `true`, a `minSdkVersion=33` signer with the +release cert, and a `minSdkVersion=24` signer with the debug cert. -### Configure Gradle -```kotlin -// app/build.gradle.kts -android { - signingConfigs { - release { - storeFile = file(RELEASE_STORE_FILE) - storePassword = RELEASE_STORE_PASSWORD - keyAlias = RELEASE_KEY_ALIAS - keyPassword = RELEASE_KEY_PASSWORD - } - } - buildTypes { - release { - signingConfig = signingConfigs.release - } - } -} +### Testing an update before publishing +Re-sign a staging build and install it over the existing `CQ Staging` (separate package, so the +real app is untouched): +```bash +./gradlew assembleStaging +scripts/sign-release.sh app/build/outputs/apk/staging/app-staging.apk staging-signed.apk +adb install -r staging-signed.apk # must succeed as an update, not require an uninstall ``` +### Google Play +Play App Signing uses its own app-signing key; upload with the release key as the upload key. The +rotation lineage is for sideloaded APKs (GitHub releases). + ## Play Store Submission ### Prepare Store Listing diff --git a/scripts/sign-release.sh b/scripts/sign-release.sh new file mode 100755 index 0000000..7f6a4b4 --- /dev/null +++ b/scripts/sign-release.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Sign a release APK with key rotation (APK Signature Scheme v3.1). +# +# Releases up to v2.4 were signed with the Android debug key. From v2.5 on, the +# release key takes over; the rotation lineage (old key vouching for the new one) +# lets the new APK install as an update over old ones, keeping players' progress. +# +# Usage: scripts/sign-release.sh +# e.g. ./gradlew assembleRelease +# scripts/sign-release.sh app/build/outputs/apk/release/app-release-unsigned.apk CircuitQueest-v2.5.apk +# +# Needs (never committed; backed up on hamptonserver:~/backups/circuitqueest-signing/): +# ~/.config/circuitqueest/keystore.properties storeFile, storePassword, keyAlias, keyPassword +# ~/.config/circuitqueest/rotation.lineage debug key -> release key +# ~/.android/debug.keystore the original signer (still required) +# Override the directory with CQ_SIGNING_DIR, the SDK with ANDROID_HOME. +set -euo pipefail + +in_apk=${1:?usage: $0 } +out_apk=${2:?usage: $0 } + +signing_dir=${CQ_SIGNING_DIR:-$HOME/.config/circuitqueest} +props="$signing_dir/keystore.properties" +lineage="$signing_dir/rotation.lineage" +old_ks="$HOME/.android/debug.keystore" +sdk=${ANDROID_HOME:-$HOME/.local/android-sdk} +apksigner=$(ls -d "$sdk"/build-tools/*/apksigner | sort -V | tail -1) + +for f in "$in_apk" "$props" "$lineage" "$old_ks" "$apksigner"; do + [[ -e $f ]] || { echo "missing: $f" >&2; exit 1; } +done + +prop() { grep -E "^$1=" "$props" | head -1 | cut -d= -f2-; } +new_ks=$(prop storeFile) +new_alias=$(prop keyAlias) +export CQ_NEW_STORE_PW CQ_NEW_KEY_PW +CQ_NEW_STORE_PW=$(prop storePassword) +CQ_NEW_KEY_PW=$(prop keyPassword) + +"$apksigner" sign \ + --ks "$old_ks" --ks-key-alias androiddebugkey --ks-pass pass:android \ + --next-signer \ + --ks "$new_ks" --ks-key-alias "$new_alias" \ + --ks-pass env:CQ_NEW_STORE_PW --key-pass env:CQ_NEW_KEY_PW \ + --lineage "$lineage" \ + --v4-signing-enabled false \ + --out "$out_apk" "$in_apk" + +"$apksigner" verify --verbose --print-certs "$out_apk" | + grep -E "^Verified using|^Signer|rotation|lineage" || true +echo "signed: $out_apk"