diff --git a/.gitea/workflows/build-and-test.yml b/.gitea/workflows/build-and-test.yml index 5031deb..8d1f8c2 100644 --- a/.gitea/workflows/build-and-test.yml +++ b/.gitea/workflows/build-and-test.yml @@ -295,20 +295,45 @@ jobs: - name: Package the APK env: CARGO_TARGET_DIR: target-android + # Absent secrets mean a debug signature, which is what a fork or a + # branch build should get. Set all three (see docs/android-signing.md) + # and the same job produces a release-signed APK instead. + ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + KEYSTORE_PASS: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + KEY_PASS: ${{ secrets.ANDROID_KEY_PASSWORD }} + KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} run: | set -e KEYDIR="$(mktemp -d)" + chmod 700 "$KEYDIR" trap 'rm -rf "$KEYDIR"' EXIT - REPO="$PWD" \ - TARGET_DIR="$PWD/target-android" \ - KEYSTORE="$KEYDIR/debug.keystore" \ + + if [ -n "$ANDROID_KEYSTORE_BASE64" ]; then + # The keystore reaches the runner base64-encoded because a secret + # is a string. It is written under a 0700 mktemp directory, never + # into the workspace: `target-android` is what actions/cache saves, + # and the upload step globs the workspace. + printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > "$KEYDIR/release.keystore" + export KEYSTORE="$KEYDIR/release.keystore" + else + # Not an error. Unset the rest so assemble-apk.sh takes its debug + # path cleanly rather than seeing a half-configured release one. + export KEYSTORE="$KEYDIR/debug.keystore" + unset KEYSTORE_PASS KEY_PASS KEY_ALIAS + fi + + REPO="$PWD" TARGET_DIR="$PWD/target-android" \ bash docker/android/assemble-apk.sh + # v3, not v4. v4 is untested against this Gitea and its runner; v3 is + # what JellyTau uploads its APK with on this same runner, so it is the + # version known to work here rather than the version that ought to. + # # `if-no-files-found: error` because the failure this guards against is # a green run with an empty artefact list, which reads as success until # somebody goes looking for the file. - name: Upload the APK - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v3 with: name: darkroom-arm64-v8a-apk path: target-android/apk/darkroom.apk diff --git a/docker/android/assemble-apk.sh b/docker/android/assemble-apk.sh index ae238ea..2deb050 100755 --- a/docker/android/assemble-apk.sh +++ b/docker/android/assemble-apk.sh @@ -19,6 +19,19 @@ # KEYSTORE signing keystore (default: $TARGET_DIR/debug.keystore) # ABI Android ABI (default: arm64-v8a) # RUST_TARGET Rust target triple (default: aarch64-linux-android) +# +# Signing. With none of these set the APK is debug-signed with a generated +# throwaway key, which is what a test device wants. Set all three for a real +# signature: +# +# KEYSTORE_PASS keystore password — presence of this is what selects +# release signing +# KEY_PASS key password (default: same as KEYSTORE_PASS) +# KEY_ALIAS key alias within the store +# +# The passwords are read from the environment and handed to apksigner as +# `env:`, never `pass:`. `pass:` puts the password in the process table, where +# every other process on the machine can read it out of `ps`. set -euo pipefail HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -30,6 +43,20 @@ TARGET_DIR="$(cd "${TARGET_DIR}" && pwd)" JNILIBS="${JNILIBS:-${TARGET_DIR}/jniLibs}" OUT="${OUT:-${TARGET_DIR}/apk}" KEYSTORE="${KEYSTORE:-${TARGET_DIR}/debug.keystore}" + +# Release signing is selected by supplying a password, not by a flag, so there +# is no way to ask for a release build and silently get a debug one. +if [[ -n "${KEYSTORE_PASS:-}" ]]; then + SIGNING=release + KEY_ALIAS="${KEY_ALIAS:?KEY_ALIAS is required when KEYSTORE_PASS is set}" + export DR_KS_PASS="${KEYSTORE_PASS}" + export DR_KEY_PASS="${KEY_PASS:-${KEYSTORE_PASS}}" +else + SIGNING=debug + KEY_ALIAS="androiddebugkey" + export DR_KS_PASS=android + export DR_KEY_PASS=android +fi ABI="${ABI:-arm64-v8a}" RUST_TARGET="${RUST_TARGET:-aarch64-linux-android}" @@ -84,11 +111,20 @@ echo " dex: ${DEX}" # Debug-signed only. This gets the app onto a test device; it is not a release # signature, and the store password is the Android convention rather than a # secret worth protecting. -if [[ ! -f "${KEYSTORE}" ]]; then +if [[ "${SIGNING}" == "release" ]]; then + # Never generated on demand. A release key is created once, by hand, and + # kept; conjuring one here would mean every build signed by a different + # identity, which is indistinguishable from having no signing story at all. + [[ -f "${KEYSTORE}" ]] || { + echo "error: KEYSTORE_PASS is set but ${KEYSTORE} does not exist" >&2 + exit 1 + } + echo " signing with the release key (alias ${KEY_ALIAS})" +elif [[ ! -f "${KEYSTORE}" ]]; then echo " generating debug keystore" mkdir -p "$(dirname "${KEYSTORE}")" - keytool -genkeypair -keystore "${KEYSTORE}" -alias androiddebugkey \ - -storepass android -keypass android \ + keytool -genkeypair -keystore "${KEYSTORE}" -alias "${KEY_ALIAS}" \ + -storepass:env DR_KS_PASS -keypass:env DR_KEY_PASS \ -keyalg RSA -keysize 2048 -validity 10950 \ -dname "CN=Android Debug,O=Android,C=US" >/dev/null 2>&1 fi @@ -129,10 +165,12 @@ zip -q -X "${OUT}/unaligned.apk" classes.dex # invalidates the signature. "${BT}/zipalign" -p -f 4 "${OUT}/unaligned.apk" "${OUT}/darkroom.apk" "${BT}/apksigner" sign \ - --ks "${KEYSTORE}" --ks-pass pass:android --key-pass pass:android \ + --ks "${KEYSTORE}" --ks-key-alias "${KEY_ALIAS}" \ + --ks-pass env:DR_KS_PASS --key-pass env:DR_KEY_PASS \ --min-sdk-version "${MIN_API}" \ "${OUT}/darkroom.apk" "${BT}/apksigner" verify --print-certs "${OUT}/darkroom.apk" | head -2 +echo " signing: ${SIGNING}" # The intermediates are not the artefact, and leaving them beside it invites # the wrong file being picked up by a glob. diff --git a/docs/android-signing.md b/docs/android-signing.md new file mode 100644 index 0000000..2bffe84 --- /dev/null +++ b/docs/android-signing.md @@ -0,0 +1,70 @@ +# Signing the Android build + +Every build produces an APK. Which key signs it depends entirely on whether +four secrets are present: + +| secret | what it is | +|---|---| +| `ANDROID_KEYSTORE_BASE64` | the keystore file, base64-encoded | +| `ANDROID_KEYSTORE_PASSWORD` | the store password | +| `ANDROID_KEY_ALIAS` | the alias of the key inside the store | +| `ANDROID_KEY_PASSWORD` | the key password | + +With none of them set, `docker/android/assemble-apk.sh` generates a throwaway +debug key and signs with that. That is the right answer for a branch build or +a fork: the APK installs on a test device and nothing pretends it is a +release. With all of them set, the same script signs with the real key. + +The names match JellyTau's deliberately. One convention across both Android +projects is one thing to remember instead of two. + +## Making the key + +Once, and then never again — keep it forever. Android identifies an app by +its signature, so an app signed with a new key is a *different* app to every +device that has the old one installed. There is no recovery from losing it +beyond telling everybody to uninstall and reinstall. + + keytool -genkeypair -v \ + -keystore darkroom-release.jks \ + -alias darkroom \ + -keyalg RSA -keysize 4096 -validity 10000 \ + -dname "CN=Duncan Tourolle, O=tourolle.paris, C=FR" + +`keytool` prompts for the passwords rather than taking them on the command +line, which keeps them out of shell history. Back the `.jks` up somewhere that +is not this repository and not the machine that builds it. + +## Loading the secrets + + base64 -w0 darkroom-release.jks > /tmp/ks.b64 + tea api --method PUT /repos/dtourolle/DarkRoom/actions/secrets/ANDROID_KEYSTORE_BASE64 \ + -f data=@/tmp/ks.b64 + shred -u /tmp/ks.b64 + + # and the three strings, read rather than typed so they miss the history + read -rs PW && tea api --method PUT \ + /repos/dtourolle/DarkRoom/actions/secrets/ANDROID_KEYSTORE_PASSWORD -f data="$PW" + +...and the same for `ANDROID_KEY_PASSWORD` and `ANDROID_KEY_ALIAS`. Or paste +them into Settings → Actions → Secrets in the web UI, which is less fiddly and +just as good. + +## Checking which key signed a build + +The packaging step prints it, and the APK carries it: + + apksigner verify --print-certs darkroom.apk + +A debug build says `CN=Android Debug`. Anything else is the real key. + +## Signing locally + +`docker/android/package.sh` takes the same environment variables, so a local +release-signed build is: + + KEYSTORE=$PWD/darkroom-release.jks KEY_ALIAS=darkroom \ + KEYSTORE_PASS=... KEY_PASS=... ./docker/android/package.sh + +Without them it debug-signs, and keeps one debug keystore in the build cache +so repeat installs to a device do not need an uninstall first.