From 40dd5bb52cd4a25c7a47808aee359ecb2bf52785 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 6 Oct 2026 22:51:33 -0400 Subject: [PATCH] ci(android): pin the release certificate in the signer check (#5116) The check failed only on a debug signer, so an APK signed by any other wrong key (a regenerated keystore, a swapped secret) would publish and then reach no installed phone: Android updates in place only when the signer matches. The step now requires exactly one signer whose SHA-256 digest is the release certificate's (CN=Minstrel, O=FabledSword, read from run 8446), and names a debug key or the digest it got when it fails. Rotating the key on purpose changes the digest in the same commit. Co-Authored-By: Claude Opus 5.5 --- .gitea/workflows/release.yml | 34 +++++++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 58622d41..3988729e 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -523,23 +523,39 @@ jobs: -PMINSTREL_VERSION_NAME=${{ steps.ver.outputs.name }} \ -PMINSTREL_VERSION_CODE=${{ steps.ver.outputs.code }} - # The APK every phone updates from must carry the release key: Android - # updates an app in place only when the signer matches. Gradle signs - # with the release key or leaves the APK unsigned, so this catches a - # wrong key, an unsigned build and a debug signer before anything - # publishes (family idea #5103, practice 3). apksigner, not keytool: - # keytool prints nothing for a v2-only APK. + # The APK every phone updates from must carry THE release key: Android + # updates an app in place only when the signer matches, so an APK + # signed by any other key (debug, a regenerated keystore, a swapped + # secret) reaches no installed phone. The certificate's digest is + # pinned below; it is public, not a secret. Gradle signs with the + # release key or leaves the APK unsigned, and an unsigned build fails + # here too, as there is no app-release.apk to verify (family idea + # #5103, practice 3). apksigner, not keytool: keytool prints nothing + # for a v2-only APK. + # + # Rotating the key on purpose means every install must be removed and + # reinstalled; change the digest here in the same commit. - name: The APK carries the release key shell: bash + env: + # CN=Minstrel, O=FabledSword. Read from run 8446 (#5116). + RELEASE_CERT_SHA256: 43d183307bc46b821789d90444a960b137f78f2166ff431efb2406d0fceaf612 run: | set -euo pipefail sdk="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}" signer="$(ls "$sdk"/build-tools/*/apksigner 2>/dev/null | sort -V | tail -1 || true)" test -n "$signer" || { echo "::error::no apksigner under '$sdk/build-tools'"; exit 1; } certs="$("$signer" verify --print-certs app/build/outputs/apk/release/app-release.apk)" - printf '%s\n' "$certs" | grep -E 'Signer #1 certificate (DN|SHA-256 digest)' - if printf '%s' "$certs" | grep -q 'CN=Android Debug'; then - echo "::error::the release APK is signed with a debug key" + printf '%s\n' "$certs" | grep -E '^Signer #[0-9]+ certificate (DN|SHA-256 digest)' + # One signer, and it is ours. A second signer would be a lineage or + # a mistake; either way not something to ship unexamined. + digests="$(printf '%s\n' "$certs" | sed -n 's/^Signer #[0-9]* certificate SHA-256 digest: //p')" + if [ "$digests" != "$RELEASE_CERT_SHA256" ]; then + if printf '%s' "$certs" | grep -q 'CN=Android Debug'; then + echo "::error::the release APK is signed with a debug key" + else + echo "::error::the release APK is not signed by the release key: got '${digests//$'\n'/ }', want ${RELEASE_CERT_SHA256}" + fi exit 1 fi