From 211f25ed0a4d88f925afa8af975eb2ed7e492b80 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 30 Sep 2026 09:18:18 -0700 Subject: [PATCH 1/2] fix(#62): sign the Android installer with one permanent release key (0.4.2) Every published DisplayXR-Installer-.apk was signed with the CI runner's throwaway debug key, a different key per run, so a tablet could never update the installer in place (INSTALL_FAILED_UPDATE_INCOMPATIBLE). - One RSA-4096 release key (CN=DisplayXR Installer, O=DisplayXR, valid to 2126), held in the android-installer-release environment (deployable from main only). Its certificate SHA-256 is pinned in android-installer/release-signing-cert.sha256. - app/build.gradle.kts: a v2-only `release` signingConfig from env vars; without them the release variant falls back to the debug key, so forks/PRs still build. - CI builds assembleRelease. scripts/verify-release-signature.sh passes only an APK whose sole signer is the pinned cert. It is required by publish-bundle.yml (build job + again on the downloaded bytes before the release is created) and by build-android-bundle.yml when publishing; every build also proves it refuses a throwaway-key APK. Debug-signed CI artifacts are named ...-DEBUGKEY.apk. - Release notes, README, INSTALL.md: one-time "uninstall the old installer" when coming from 0.4.1 or older; the "different key for now" wording is gone. - installerVersionName 0.4.2 / versionCode 7. Co-Authored-By: Claude Opus 5.5 --- .github/release-notes/bundle.md | 2 +- .github/workflows/build-android-bundle.yml | 31 ++++- .github/workflows/build-android-installer.yml | 117 ++++++++++++++++-- .github/workflows/publish-bundle.yml | 20 +++ .gitignore | 5 +- CLAUDE.md | 1 + android-bundle/INSTALL.md | 5 +- android-installer/README.md | 88 ++++++++----- android-installer/app/build.gradle.kts | 47 ++++++- android-installer/gradle.properties | 7 +- android-installer/release-signing-cert.sha256 | 10 ++ .../scripts/verify-release-signature.sh | 54 ++++++++ 12 files changed, 334 insertions(+), 53 deletions(-) create mode 100644 android-installer/release-signing-cert.sha256 create mode 100755 android-installer/scripts/verify-release-signature.sh diff --git a/.github/release-notes/bundle.md b/.github/release-notes/bundle.md index 390b3cc..12d942f 100644 --- a/.github/release-notes/bundle.md +++ b/.github/release-notes/bundle.md @@ -15,6 +15,6 @@ You need only the tablet and Wi-Fi — no computer. If the installer shows a red **"Display services update needed"** card, it could not download the display services — check the Wi-Fi and tap **Check again**. Until they are updated, **3D will not work correctly** (content stays 2D, parallax is wrong, apps can freeze). -Upgrading the installer itself from an older build fails with *"App not installed"* (each build is signed with a different key for now) — uninstall the old **DisplayXR Installer** first; your DisplayXR apps are not affected. +**Already have DisplayXR Installer 0.4.1 or older? One time only:** uninstall the old **DisplayXR Installer** (*Settings → Apps → DisplayXR Installer → Uninstall*), then install this one — otherwise Android says *"App not installed"*. Your DisplayXR apps are not affected. Those older builds were signed with a temporary key; from 0.4.2 on every installer is signed with the same permanent key and updates in place. --- diff --git a/.github/workflows/build-android-bundle.yml b/.github/workflows/build-android-bundle.yml index 133e676..dd5eeb7 100644 --- a/.github/workflows/build-android-bundle.yml +++ b/.github/workflows/build-android-bundle.yml @@ -45,6 +45,10 @@ permissions: jobs: bundle: runs-on: ubuntu-latest + # The installer app's signing key (build-android-installer.yml, "SIGNING"): reachable + # from main only. From any other branch the installer is debug-signed, and publish=true + # then fails at the installer step instead of shipping an APK tablets cannot update to. + environment: ${{ github.ref == 'refs/heads/main' && 'android-installer-release' || '' }} steps: - uses: actions/checkout@v5 @@ -270,14 +274,35 @@ jobs: packages: '' - name: Build the installer app + env: + KS_B64: ${{ secrets.ANDROID_INSTALLER_KEYSTORE_B64 }} + ANDROID_INSTALLER_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_INSTALLER_KEYSTORE_PASSWORD }} + ANDROID_INSTALLER_KEY_ALIAS: ${{ secrets.ANDROID_INSTALLER_KEY_ALIAS }} + ANDROID_INSTALLER_KEY_PASSWORD: ${{ secrets.ANDROID_INSTALLER_KEY_PASSWORD }} + PUBLISH: ${{ inputs.publish }} run: | set -euo pipefail V=$(grep -E '^installerVersionName=' android-installer/gradle.properties | cut -d= -f2 | tr -d '[:space:]') [ -n "$V" ] || { echo "::error::installerVersionName missing from android-installer/gradle.properties"; exit 1; } - OUT="DisplayXR-Installer-$V.apk" b="_bundle/$BUNDLE" - ( cd android-installer && ./gradlew --no-daemon :app:testDebugUnitTest :app:assembleDebug ) - A=android-installer/app/build/outputs/apk/debug/app-debug.apk + # Same signing as build-android-installer.yml: the release key when this run has + # it (main), else the debug key. The keystore is a temp file, removed right after. + if [ -n "${KS_B64:-}" ]; then + export ANDROID_INSTALLER_KEYSTORE_FILE="$RUNNER_TEMP/dxr-installer-release.p12" + ( umask 077; printf '%s' "$KS_B64" | base64 -d > "$ANDROID_INSTALLER_KEYSTORE_FILE" ) + fi + ( cd android-installer && ./gradlew --no-daemon :app:testDebugUnitTest :app:assembleRelease ) + rm -f "$RUNNER_TEMP/dxr-installer-release.p12" + A=android-installer/app/build/outputs/apk/release/app-release.apk + if android-installer/scripts/verify-release-signature.sh "$A"; then + OUT="DisplayXR-Installer-$V.apk" + elif [ "$PUBLISH" = true ] || [ -n "${KS_B64:-}" ]; then + echo "::error::the installer APK is not signed with the pinned release key — refusing to put it in a published bundle (dispatch from main)" + exit 1 + else + echo "::warning::installer APK is DEBUG-signed (not main): fine for a CI-only bundle, never for a published one." + OUT="DisplayXR-Installer-$V-DEBUGKEY.apk" + fi # Same rule as the public release: it must carry nothing embedded. if unzip -Z1 "$A" | grep -E '^assets/cnsdk/|\.apk$'; then echo "::error::the installer APK embeds files it must not"; exit 1 diff --git a/.github/workflows/build-android-installer.yml b/.github/workflows/build-android-installer.yml index 1ec7a7a..0e1d771 100644 --- a/.github/workflows/build-android-installer.yml +++ b/.github/workflows/build-android-installer.yml @@ -24,6 +24,16 @@ name: build-android-installer # # publish-bundle.yml calls this via workflow_call, so every public bundle release # carries DisplayXR-Installer-.apk built from the same commit. +# +# SIGNING (README "Signing"). Every published installer is signed with ONE release key, +# because Android updates an installed app only from an APK signed by the same key. The +# key lives in the `android-installer-release` environment, whose deployment-branch policy +# admits `main` only — so a PR (fork or not), or a dispatch from any other branch, never +# sees it, and even a workflow edited on a branch cannot read it. Those runs build the +# release variant signed with the DEBUG key: it compiles, tests and installs, and it is +# unpublishable, because scripts/verify-release-signature.sh (run here, and again in +# publish-bundle.yml right before the release is created) refuses any signer but the one +# pinned in android-installer/release-signing-cert.sha256. on: pull_request: @@ -43,17 +53,25 @@ on: release_tag: type: string default: "" + # publish-bundle.yml passes true: fail HERE, before anything else of the release + # is built on top, if the APK is not signed with the pinned release key. + require_release_key: + type: boolean + default: false outputs: version: description: "installerVersionName the APK was built with" value: ${{ jobs.build.outputs.version }} + release_signed: + description: "'true' when the APK's signer is the pinned release key" + value: ${{ jobs.build.outputs.release_signed }} artifact: description: "Name of the uploaded artifact holding the installer APK + .sha256" value: ${{ jobs.build.outputs.artifact }} workflow_dispatch: inputs: release_tag: - description: "Attach the APK to this existing release (e.g. android-bundle-2026-09-22). Empty = CI artifact only." + description: "Attach the APK to this existing release (e.g. android-bundle-2026-09-22). Empty = CI artifact only. Requires the release key, so dispatch from main." type: string default: "" @@ -63,9 +81,15 @@ permissions: jobs: build: runs-on: ubuntu-latest + # The signing key's environment, on main only (its branch policy would refuse any other + # ref anyway, and a refused environment FAILS the job rather than skipping the key — so + # the condition keeps PR and branch runs building, debug-signed). For a workflow_call, + # github.* is the caller's: publish-bundle dispatched from main gets the key. + environment: ${{ (github.event_name != 'pull_request' && github.ref == 'refs/heads/main') && 'android-installer-release' || '' }} outputs: version: ${{ steps.ver.outputs.version }} artifact: DisplayXR-Installer-${{ steps.ver.outputs.version }} + release_signed: ${{ steps.pin.outputs.release_signed }} defaults: run: working-directory: android-installer @@ -111,14 +135,72 @@ jobs: - name: The services publishing script parses run: bash -n scripts/make-services-manifest.sh - # Debug, not release, and deliberately: an unsigned release APK cannot be - # installed at all, and this repo signs nothing (see CLAUDE.md). The cost - # is stated in the README: each run signs with that runner's throwaway - # debug key, so upgrading the installer itself in place fails with - # INSTALL_FAILED_UPDATE_INCOMPATIBLE — uninstall the old one first. It - # does not affect anything the installer installs, only the installer. + # The keystore goes to a file outside the checkout (RUNNER_TEMP) and is deleted at the + # end of the job; only its PATH reaches Gradle. Absent secret = no file = the release + # variant falls back to the debug key (app/build.gradle.kts). + - name: Decode the release key (main only) + env: + KS_B64: ${{ secrets.ANDROID_INSTALLER_KEYSTORE_B64 }} + run: | + set -euo pipefail + if [ -z "${KS_B64:-}" ]; then + echo "No release key in this run (PR, fork, or not main) — the APK will be DEBUG-signed and unpublishable." + exit 0 + fi + ks="$RUNNER_TEMP/dxr-installer-release.p12" + ( umask 077; printf '%s' "$KS_B64" | base64 -d > "$ks" ) + echo "ANDROID_INSTALLER_KEYSTORE_FILE=$ks" >> "$GITHUB_ENV" + echo "Release key decoded ($(wc -c < "$ks") bytes)." + + # The release variant, always: signed with the release key when the step above found + # one, with the debug key otherwise. One task and one output path for both cases, so + # what a PR builds is what a release builds, minus the key. - name: Assemble - run: ./gradlew --no-daemon :app:assembleDebug + env: + ANDROID_INSTALLER_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_INSTALLER_KEYSTORE_PASSWORD }} + ANDROID_INSTALLER_KEY_ALIAS: ${{ secrets.ANDROID_INSTALLER_KEY_ALIAS }} + ANDROID_INSTALLER_KEY_PASSWORD: ${{ secrets.ANDROID_INSTALLER_KEY_PASSWORD }} + run: ./gradlew --no-daemon :app:assembleRelease + + # Is it signed with THE key? Judged from the APK's bytes against the committed pin. + # Required (and so a failure) when a release depends on it or when the key was + # present — a key that is present but produces the wrong signer is a broken secret, + # not a PR. Otherwise it is reported and the run stays green. + - name: Release-key pin check + id: pin + env: + REQUIRE: ${{ inputs.require_release_key == true || inputs.release_tag != '' || env.ANDROID_INSTALLER_KEYSTORE_FILE != '' }} + run: | + set -euo pipefail + A=app/build/outputs/apk/release/app-release.apk + if scripts/verify-release-signature.sh "$A"; then + echo "release_signed=true" >> "$GITHUB_OUTPUT" + else + echo "release_signed=false" >> "$GITHUB_OUTPUT" + if [ "$REQUIRE" = true ]; then + echo "::error::this APK must be signed with the pinned release key and is not — see above" + exit 1 + fi + echo "::warning::DEBUG-signed APK (no release key in this run): fine for testing, refused by every publish path." + fi + + # Proves the check above can fail: the same bytes re-signed with a key made up on the + # spot must be REFUSED. Without this, a verifier that always passed would look exactly + # like a working one on main. + - name: The pin check refuses a foreign key + run: | + set -euo pipefail + BT=$(find "$ANDROID_HOME/build-tools" -mindepth 1 -maxdepth 1 -type d | sort -V | tail -1) + t="$RUNNER_TEMP/negative"; mkdir -p "$t" + keytool -genkeypair -keystore "$t/foreign.p12" -storetype PKCS12 -alias x -keyalg EC -groupname secp256r1 \ + -validity 1 -dname "CN=Not DisplayXR" -storepass throwaway -keypass throwaway >/dev/null 2>&1 + "$BT/apksigner" sign --ks "$t/foreign.p12" --ks-pass pass:throwaway --ks-key-alias x \ + --out "$t/foreign.apk" app/build/outputs/apk/release/app-release.apk + if scripts/verify-release-signature.sh "$t/foreign.apk"; then + echo "::error::verify-release-signature.sh ACCEPTED an APK signed with a throwaway key — the release gate is broken" + exit 1 + fi + echo "OK: a foreign-key APK is refused." # The APK goes on PUBLIC releases, so prove from its bytes that it # carries nothing embedded: no assets/cnsdk/ tree and no nested APK of any @@ -127,7 +209,7 @@ jobs: - name: The APK carries no vendor bytes run: | set -euo pipefail - A=app/build/outputs/apk/debug/app-debug.apk + A=app/build/outputs/apk/release/app-release.apk if unzip -Z1 "$A" | grep -E '^assets/cnsdk/|\.apk$'; then echo "::error::the installer APK embeds files it must not — it is published on public releases" exit 1 @@ -138,8 +220,12 @@ jobs: id: apk run: | set -euo pipefail - SRC=app/build/outputs/apk/debug/app-debug.apk - OUT="DisplayXR-Installer-${{ steps.ver.outputs.version }}.apk" + SRC=app/build/outputs/apk/release/app-release.apk + # A debug-signed build says so in its file name, so a PR artifact passed around + # by hand cannot be mistaken for a release (it would not update a tablet's copy). + SUFFIX="" + [ "${{ steps.pin.outputs.release_signed }}" = true ] || SUFFIX="-DEBUGKEY" + OUT="DisplayXR-Installer-${{ steps.ver.outputs.version }}$SUFFIX.apk" mkdir -p _out cp "$SRC" "_out/$OUT" ( cd _out && sha256sum "$OUT" > "$OUT.sha256" ) @@ -154,9 +240,10 @@ jobs: # Opt-in, and only onto a release that already exists — the bundle release # is cut by build-android-bundle.yml, and this attaches to the same tag - # rather than creating a second one. + # rather than creating a second one. Only a release-signed APK gets here: a + # release_tag makes the pin check above required. - name: Attach to the release - if: inputs.release_tag != '' + if: inputs.release_tag != '' && steps.pin.outputs.release_signed == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | @@ -165,3 +252,7 @@ jobs: "_out/${{ steps.apk.outputs.name }}" \ "_out/${{ steps.apk.outputs.name }}.sha256" \ --repo "$GITHUB_REPOSITORY" --clobber + + - name: Remove the decoded key + if: always() + run: rm -f "$RUNNER_TEMP/dxr-installer-release.p12" diff --git a/.github/workflows/publish-bundle.yml b/.github/workflows/publish-bundle.yml index 8d8ab6d..0ca86aa 100644 --- a/.github/workflows/publish-bundle.yml +++ b/.github/workflows/publish-bundle.yml @@ -267,9 +267,17 @@ jobs: # The old with-services (cnsdk) build that embedded them is retired, but the rule # it forced stays: an installer APK attached to a public release must carry nothing # embedded, and the release job below re-proves that from the bytes. + # + # It must be signed with THE release key (android-installer/release-signing-cert.sha256): + # one key forever, or tablets cannot update the installer in place. The key is only + # reachable from `main` (environment android-installer-release), so dispatch this + # workflow from main; require_release_key fails the build job otherwise, and the + # release job re-checks the downloaded bytes before anything is published. build-android-installer: needs: assert-versions-in-sync uses: ./.github/workflows/build-android-installer.yml + with: + require_release_key: true release: needs: [build-macos, build-windows, build-linux, build-android-installer] @@ -305,6 +313,18 @@ jobs: ( cd _out && sha256sum -c "$(basename "$a").sha256" ) echo "OK: $(basename "$a") is the public flavor." + # The other rule this release must never break: the installer is signed with THE + # release key, the one every copy already on a tablet was signed with. An APK signed + # with anything else (a debug key, a new key) cannot update those copies in place, so + # it fails the release here — never on a tablet. Judged from the downloaded bytes, + # independently of the build job's own check. + - name: The Android installer is signed with the release key + run: | + set -euo pipefail + shopt -s nullglob + apks=(_out/DisplayXR-Installer-*.apk) + android-installer/scripts/verify-release-signature.sh "${apks[0]}" + - name: Create release uses: softprops/action-gh-release@v3 with: diff --git a/.gitignore b/.gitignore index 80056ef..552f7db 100644 --- a/.gitignore +++ b/.gitignore @@ -5,8 +5,11 @@ _out/ *.exe !installer/windows/*.exe.placeholder -# Local credentials +# Local credentials — incl. the Android installer's release keystore, which is never committed .secrets/ +*.jks +*.p12 +*.keystore .env .env.local diff --git a/CLAUDE.md b/CLAUDE.md index 24ff0bb..bfabdf9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -113,3 +113,4 @@ Full design rationale: - **Don't strip ad-hoc signatures** from macOS dylibs when re-wrapping the runtime `.pkg`. `pkgutil --expand` / `pkgutil --flatten` preserve signatures by default; do NOT add `install_name_tool` or `codesign --remove-signature` to the build flow. Regression of `DisplayXR/displayxr-runtime#279` would SIGKILL the installed runtime at `dlopen`. The build script verifies the runtime payload's presence after re-wrap; extend that check if you change the rewrap flow. - **Don't auto-fire `publish-bundle.yml`.** It's `workflow_dispatch` for a reason (compat-test gate). The `assert-versions-in-sync` job exists to *fail-fast* on drift, not to repair pins. - **Don't sign anything.** v1 ships unsigned (#280 / #281 in runtime repo are parallel work). When those land, signing gets a small CI block; until then, no `codesign` / `signtool` calls. +- **Exception: the Android installer APK is release-signed, with ONE key, forever.** `DisplayXR-Installer-.apk` is signed in CI with the key in the GitHub environment `android-installer-release` (main-only), and every publish path refuses an APK whose signer is not the pin in `android-installer/release-signing-cert.sha256` (`android-installer/scripts/verify-release-signature.sh`). Never "fix" a failing release by editing the pin or falling back to the debug key: a different key cannot update the copies already on tablets. Design, rotation and backup: `android-installer/README.md` § *Signing*. diff --git a/android-bundle/INSTALL.md b/android-bundle/INSTALL.md index dcf7a67..d3a2209 100644 --- a/android-bundle/INSTALL.md +++ b/android-bundle/INSTALL.md @@ -135,8 +135,9 @@ screen before doing so — launching an app behind the lockscreen crashes some d The vendor licence notices (the same files as `licenses/`) are published beside the services and readable in-app: *Licences for the display services*, at the bottom of the screen. -The installer is currently signed with a per-build debug key, so a newer installer cannot update an -older one in place: uninstall the old *installer app* first (this does not touch anything it +From 0.4.2 the installer is signed with one permanent release key, so a newer installer updates an +older one in place. Installers 0.4.1 and older were signed with a per-build debug key: replacing one +of those is a one-time uninstall of the old *installer app* first (this does not touch anything it installed). The retired `…-with-cnsdk.apk` build is a different app id (`com.displayxr.installer.cnsdk`); uninstall it too, so there is one installer on the tablet. diff --git a/android-installer/README.md b/android-installer/README.md index 3638342..84ed9ab 100644 --- a/android-installer/README.md +++ b/android-installer/README.md @@ -23,9 +23,10 @@ You need only the tablet and Wi-Fi — no computer. If a red **"Display services update needed"** card appears, the installer could not download the display services: check the Wi-Fi and tap **Check again**. Until they are updated **3D will not work -correctly** (content stays 2D, parallax is wrong, apps can freeze). Upgrading the installer itself -from an older build fails with *"App not installed"* — uninstall the old *DisplayXR Installer* first -(see *Signing*); the DisplayXR apps are not affected. +correctly** (content stays 2D, parallax is wrong, apps can freeze). Coming from installer **0.4.1 or +older** (debug-signed): uninstall the old *DisplayXR Installer* first, once — Android says *"App not +installed"* otherwise (see *Signing*); the DisplayXR apps are not affected. From 0.4.2 on, the +installer updates in place. The same instructions head every bundle release's notes (`.github/release-notes/bundle.md`). @@ -329,8 +330,8 @@ Local, from a checkout (needs a JDK 17+ and an Android SDK; `ANDROID_HOME` must ```bash cd android-installer -./gradlew :app:testDebugUnitTest :app:assembleDebug -# -> app/build/outputs/apk/debug/app-debug.apk +./gradlew :app:testDebugUnitTest :app:assembleRelease +# -> app/build/outputs/apk/release/app-release.apk (debug-key signed unless the release key is set; see Signing) ``` Lay out the services for the host from a local copy of the vendor release (what the publishing @@ -345,7 +346,8 @@ scripts/make-services-manifest.sh --tag v0.10.69 \ CI: - `.github/workflows/build-android-installer.yml` runs on every PR that touches this directory: the - unit tests (incl. the certificate-pin agreement), the APK, and the no-vendor-bytes check. It names + unit tests (incl. the certificate-pin agreement), the release APK, the release-key pin check (see + *Signing*), and the no-vendor-bytes check. It names the artifact `DisplayXR-Installer-.apk` from `gradle.properties` — one property drives both the version inside the APK and the file name outside it. Dispatch it with a `release_tag` to attach the APK to an existing release. @@ -358,28 +360,56 @@ CI: ## Signing -**Debug-signed today, and the consequence is real.** This repo signs nothing, and an *unsigned* -release APK cannot be installed at all, so CI ships the debug variant. Each CI run signs with that -runner's **throwaway** debug key, so **upgrading the installer app itself in place fails** with -`INSTALL_FAILED_UPDATE_INCOMPATIBLE` — uninstall the old installer first. This affects only the -installer; everything it installs is release-signed by its own repo and upgrades normally. - -It matters more now that the installer is on every public release: it is meant to be kept and -re-run, and it also asks Android for `USER_ACTION_NOT_REQUIRED`, which is honoured only for -packages it installed itself — a reinstalled installer loses that. - -Proposed path (not implemented; needs the repo owner to create a key and secrets): - -1. Generate one long-lived upload key for `com.displayxr.installer`: `keytool -genkeypair -v -keystore dxr-installer.jks -alias dxr-installer -keyalg RSA - -keysize 4096 -validity 36500`. Back it up offline — **losing it strands every installed copy**, - exactly the failure above, permanently. -2. Store it as repo secrets: `ANDROID_INSTALLER_KEYSTORE_B64`, `ANDROID_INSTALLER_KEYSTORE_PASSWORD`, - `ANDROID_INSTALLER_KEY_ALIAS`, `ANDROID_INSTALLER_KEY_PASSWORD`. -3. Add a `release` `signingConfig` in `app/build.gradle.kts` that reads those from the environment - and only exists when they are set, so forks and PRs from forks still build debug. -4. In CI, build `assembleRelease` when the secrets are present, - debug otherwise, and verify the signer with `apksigner verify --print-certs` against a pinned - certificate digest before publishing — a key swap should fail the release, not the tablet. +**One release key, forever.** Android updates an installed app only from an APK signed with the same +key, so every published `DisplayXR-Installer-.apk` from **0.4.2** on is signed with the one +DisplayXR Installer key: + +| | | +|---|---| +| Certificate | `CN=DisplayXR Installer, O=DisplayXR`, RSA 4096, SHA256withRSA, valid 2026-09-30 → 2126-09-06 | +| Certificate SHA-256 (the pin) | `7df5e3233c76abf9544311268e76d57e1240c83805a5fd33ac91a4ab5371f8ce` — [`release-signing-cert.sha256`](release-signing-cert.sha256) | +| Scheme | APK Signature Scheme v2 only, the shape every DisplayXR APK ships | +| Where the key is | GitHub environment **`android-installer-release`** on this repo (deployment branch: `main` only): `ANDROID_INSTALLER_KEYSTORE_B64`, `…_KEYSTORE_PASSWORD`, `…_KEY_ALIAS`, `…_KEY_PASSWORD`. An offline backup is held by the repo owner (outside any repo). | + +How it is enforced: + +- `app/build.gradle.kts` signs the **release** variant with the key when `ANDROID_INSTALLER_KEYSTORE_FILE` + (+ the password/alias variables) is set, and with the **debug** key otherwise — so a fork's PR or a + local build still compiles, tests and installs. +- CI always builds `assembleRelease`. The key is decoded only in runs on `main` (the environment's + branch policy refuses every other ref, even from a workflow edited on a branch). +- [`scripts/verify-release-signature.sh`](scripts/verify-release-signature.sh) `` passes only an + APK whose single signer's certificate equals the pin (plus a v2/v3 signature and the right package). + `build-android-installer.yml` runs it on every build — required when a release depends on it — and + proves on every run that it **refuses** the same APK re-signed with a throwaway key. + `publish-bundle.yml` requires it in the build job **and** re-runs it on the downloaded bytes right + before the release is created; `build-android-bundle.yml` requires it when `publish` is on. A + debug-signed APK therefore fails the release, never a tablet. Debug-signed CI artifacts are named + `…-DEBUGKEY.apk`. + +**Crossing from 0.4.1 or older is the one exception.** Those builds were signed with each CI runner's +throwaway debug key; Android cannot update across a key change and no app can fix that for itself. +The upgrade is one-time: uninstall the old *DisplayXR Installer*, install 0.4.2. The apps it installed +are not affected (they are release-signed by their own repos). The release notes say so. + +**Local release-signed build** (needs the keystore, which is never committed — `*.p12`/`*.jks` are +gitignored): + +```bash +export ANDROID_INSTALLER_KEYSTORE_FILE=/path/to/displayxr-installer-release.p12 +export ANDROID_INSTALLER_KEYSTORE_PASSWORD=… ANDROID_INSTALLER_KEY_PASSWORD=… ANDROID_INSTALLER_KEY_ALIAS=dxr-installer +./gradlew :app:assembleRelease +scripts/verify-release-signature.sh app/build/outputs/apk/release/app-release.apk +``` + +**If the key is lost**, every installed copy is stranded: each later installer is a different app to +the tablet. Keep the backup. **If it leaks**, do not simply swap keys (that strands every copy the same +way): rotate with an APK Signature Scheme v3 lineage (`apksigner rotate`, then sign with `--lineage` +and v3 on). Tablets (API ≥ 28; all supported ones are 31+) accept the new key as an update of a copy +signed with the old one, even though that copy was v2-only. Then change the pin to the new certificate +in the same PR. + +Minification stays off (see `app/build.gradle.kts`): R8 cannot be validated without a tablet run. Never commit a keystore, not even a "debug" one: an installer holding `REQUEST_INSTALL_PACKAGES` signed with a public key can be updated by anyone to install anything. @@ -397,6 +427,8 @@ android-installer/ ├── gradle.properties installerVersionName / installerVersionCode ├── scripts/make-services-manifest.sh lays out + verifies the display services for the host ├── scripts/service-signers.tsv the vendor certificate pin (== DisplayServices.PINNED_SIGNERS) +├── scripts/verify-release-signature.sh the release gate: APK signer == release-signing-cert.sha256 +├── release-signing-cert.sha256 the installer's own release-certificate pin └── app/src/main/java/com/displayxr/installer/ ├── Catalog.kt the component table — mirrors install-android-bundle.sh ├── UiModel.kt row/phase model + plannedStatus / servicePlannedStatus (pure, JVM-tested) diff --git a/android-installer/app/build.gradle.kts b/android-installer/app/build.gradle.kts index 92a1753..2e8dd83 100644 --- a/android-installer/app/build.gradle.kts +++ b/android-installer/app/build.gradle.kts @@ -35,12 +35,53 @@ android { * build-android-installer.yml and publish-bundle.yml both check from the file. */ + // THE release key (README "Signing"). One key for every published installer, because + // Android updates an installed app only from an APK signed by the same key. The keystore + // never enters the repo: CI decodes it from the `android-installer-release` environment + // secrets (deployable from `main` only) and points these variables at the file. + // + // Without them — a fork's PR, a local build — the release variant is signed with the + // DEBUG key instead, so it still builds and installs for testing, and it can never be + // published: every workflow that attaches the APK to a release runs + // scripts/verify-release-signature.sh, which refuses any signer but the pinned one. + val releaseKeystore = System.getenv("ANDROID_INSTALLER_KEYSTORE_FILE")?.takeIf { it.isNotBlank() } + signingConfigs { + if (releaseKeystore != null) { + create("release") { + val ks = file(releaseKeystore) + require(ks.isFile) { "ANDROID_INSTALLER_KEYSTORE_FILE=$releaseKeystore is not a file" } + fun env(name: String) = System.getenv(name)?.takeIf { it.isNotEmpty() } + ?: throw GradleException("ANDROID_INSTALLER_KEYSTORE_FILE is set but $name is not") + storeFile = ks + storePassword = env("ANDROID_INSTALLER_KEYSTORE_PASSWORD") + keyAlias = env("ANDROID_INSTALLER_KEY_ALIAS") + keyPassword = env("ANDROID_INSTALLER_KEY_PASSWORD") + // v2 only, stated rather than left to AGP's defaults: the shape every other + // DisplayXR APK ships (runtime, demos, browser), and the one the release gate + // asserts. v3 is not needed to rotate later — a v3 lineage added AT rotation + // time is accepted by devices whose installed copy was v2-signed with the old + // key (API 28+; every supported tablet is 31+). + enableV1Signing = false + enableV2Signing = true + enableV3Signing = false + enableV4Signing = false + } + } + } + buildTypes { release { - // No minification: the release variant is only ever built to be - // sideloaded, and an obfuscated stack trace from a tester's tablet - // is worth less than the ~200 KB saved. + // No minification: the release variant is sideloaded, and R8 cannot be + // validated from a build alone — a stripped ViewModel or ViewBinding shows up + // only at run time, on the tablet. The ~200 KB is not worth that risk. isMinifyEnabled = false + signingConfig = if (releaseKeystore != null) { + signingConfigs.getByName("release") + } else { + logger.warn("android-installer: ANDROID_INSTALLER_KEYSTORE_FILE unset — release variant " + + "signed with the DEBUG key (not publishable).") + signingConfigs.getByName("debug") + } } } diff --git a/android-installer/gradle.properties b/android-installer/gradle.properties index 0739131..5270000 100644 --- a/android-installer/gradle.properties +++ b/android-installer/gradle.properties @@ -22,8 +22,11 @@ # the APK Signing Block (v2/v3/v3.1). 0.4.0 relied on getPackageArchiveInfo(GET_SIGNING_ # CERTIFICATES), which the initial Android 13 framework (NP02J/K68) answers with a null # signingInfo — so it refused the tablet's own v2-signed vendor update. -installerVersionName=0.4.1 -installerVersionCode=6 +# 0.4.2: signed with THE DisplayXR Installer release key (release-signing-cert.sha256) instead of +# each CI runner's throwaway debug key, so from here on the installer updates itself in place. +# Crossing from a debug-signed 0.4.x is the one exception: uninstall the old installer first. +installerVersionName=0.4.2 +installerVersionCode=7 org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 org.gradle.parallel=true diff --git a/android-installer/release-signing-cert.sha256 b/android-installer/release-signing-cert.sha256 new file mode 100644 index 0000000..e7bb22b --- /dev/null +++ b/android-installer/release-signing-cert.sha256 @@ -0,0 +1,10 @@ +# The ONE key that signs every published DisplayXR-Installer-.apk (com.displayxr.installer). +# SHA-256 of its X.509 certificate (lowercase hex, no colons) — public, not a secret. +# +# CN=DisplayXR Installer, O=DisplayXR · RSA 4096 · SHA256withRSA · valid 2026-09-30 .. 2126-09-06 +# +# scripts/verify-release-signature.sh refuses any APK whose signer is not this certificate, and +# every workflow that attaches the APK to a release runs it first. Changing this line changes the +# key every tablet will accept updates from: do it only as part of a v3 key rotation (README, +# "Signing"), never to make a failing release pass. +7df5e3233c76abf9544311268e76d57e1240c83805a5fd33ac91a4ab5371f8ce diff --git a/android-installer/scripts/verify-release-signature.sh b/android-installer/scripts/verify-release-signature.sh new file mode 100755 index 0000000..64e379e --- /dev/null +++ b/android-installer/scripts/verify-release-signature.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# verify-release-signature.sh — is this the DisplayXR Installer, signed with THE release key? +# +# Exit 0 only when ALL of these hold, judged from the APK's bytes by apksigner: +# - the signature verifies; +# - exactly one signer, and its certificate SHA-256 equals the pin in +# android-installer/release-signing-cert.sha256; +# - that signer is verified from the APK Signing Block (v2, or v3 after a key rotation), +# not from a JAR (v1) signature alone. apksigner checks only the schemes the APK's minSdk +# range needs (31+: v2/v3), so "v1: false" in its output is not a statement about v1 and +# is not asserted here; +# - the package is com.displayxr.installer (when aapt2 is available). +# Exit 1 otherwise, with the reason. A debug-signed or foreign-key APK FAILS: this is +# the gate that keeps an installer tablets cannot update in place off a release. +# +# apksigner / aapt2: $APKSIGNER / $AAPT2, else the newest under +# $ANDROID_HOME (or $ANDROID_SDK_ROOT, or ~/Library/Android/sdk) /build-tools. +set -euo pipefail + +apk=${1:?usage: verify-release-signature.sh } +here=$(cd "$(dirname "$0")/.." && pwd) +pin_file=${RELEASE_CERT_PIN_FILE:-$here/release-signing-cert.sha256} + +die() { echo "::error::verify-release-signature: $*" >&2; exit 1; } + +[ -f "$apk" ] || die "no such file: $apk" +pin=$(grep -v '^[[:space:]]*#' "$pin_file" | tr -d '[:space:]' | tr 'A-F' 'a-f') +[[ "$pin" =~ ^[0-9a-f]{64}$ ]] || die "$pin_file does not hold exactly one SHA-256 (got '$pin')" + +sdk=${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$HOME/Library/Android/sdk}} +bt=$(ls -d "$sdk"/build-tools/* 2>/dev/null | sort -V | tail -1 || true) +apksigner=${APKSIGNER:-$bt/apksigner} +aapt2=${AAPT2:-$bt/aapt2} +[ -x "$apksigner" ] || die "apksigner not found (looked at '$apksigner'); set APKSIGNER or ANDROID_HOME" + +out=$("$apksigner" verify --verbose --print-certs "$apk" 2>&1) || { echo "$out" >&2; die "$(basename "$apk"): apksigner verify FAILED"; } + +signers=$(grep -cE '^Signer #[0-9]+ certificate SHA-256 digest:' <<<"$out" || true) +[ "$signers" = 1 ] || { echo "$out" >&2; die "$(basename "$apk"): expected exactly 1 signer, found $signers"; } +got=$(sed -nE 's/^Signer #1 certificate SHA-256 digest: *([0-9a-fA-F]+).*/\1/p' <<<"$out" | tr 'A-F' 'a-f') +dn=$(sed -nE 's/^Signer #1 certificate DN: *//p' <<<"$out") +if [ "$got" != "$pin" ]; then + die "$(basename "$apk") is signed by '$dn' ($got), NOT the DisplayXR Installer release key ($pin). Tablets could not update to it in place. Refusing." +fi + +grep -qE '^Verified using v(2|3|3\.1) scheme \(APK Signature Scheme v[0-9.]+\): true' <<<"$out" \ + || { echo "$out" >&2; die "$(basename "$apk"): no v2/v3 (APK Signing Block) signature"; } + +if [ -x "$aapt2" ]; then + pkg=$("$aapt2" dump badging "$apk" 2>/dev/null | sed -nE "s/^package: name='([^']+)'.*/\1/p") + [ "$pkg" = com.displayxr.installer ] || die "$(basename "$apk"): package is '$pkg', not com.displayxr.installer" + ver=$("$aapt2" dump badging "$apk" 2>/dev/null | sed -nE "s/^package: .*versionCode='([0-9]+)'.*versionName='([^']*)'.*/\2 (versionCode \1)/p") +fi +echo "OK: $(basename "$apk") ${ver:+$ver }is signed by the DisplayXR Installer release key ($dn, $got)." From 3906cbf897ae7e5fbac55015c60f02b97015cc9a Mon Sep 17 00:00:00 2001 From: David Date: Wed, 30 Sep 2026 09:23:00 -0700 Subject: [PATCH 2/2] fix(#62): read the signer from both apksigner output formats build-tools <= 34 print 'Signer #1 certificate SHA-256 digest:'; newer ones (the ubuntu-latest runner's) print 'V2 Signer: certificate SHA-256 digest:'. The gate saw 0 signers on the runner and refused a correctly signed APK. Count from 'Number of signers' and require every signer-certificate digest, in either format, to be the pin. Co-Authored-By: Claude Opus 5.5 --- .../scripts/verify-release-signature.sh | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/android-installer/scripts/verify-release-signature.sh b/android-installer/scripts/verify-release-signature.sh index 64e379e..d0e8e56 100755 --- a/android-installer/scripts/verify-release-signature.sh +++ b/android-installer/scripts/verify-release-signature.sh @@ -35,10 +35,18 @@ aapt2=${AAPT2:-$bt/aapt2} out=$("$apksigner" verify --verbose --print-certs "$apk" 2>&1) || { echo "$out" >&2; die "$(basename "$apk"): apksigner verify FAILED"; } -signers=$(grep -cE '^Signer #[0-9]+ certificate SHA-256 digest:' <<<"$out" || true) -[ "$signers" = 1 ] || { echo "$out" >&2; die "$(basename "$apk"): expected exactly 1 signer, found $signers"; } -got=$(sed -nE 's/^Signer #1 certificate SHA-256 digest: *([0-9a-fA-F]+).*/\1/p' <<<"$out" | tr 'A-F' 'a-f') -dn=$(sed -nE 's/^Signer #1 certificate DN: *//p' <<<"$out") +# Two output formats exist: build-tools <= 34 print "Signer #1 certificate SHA-256 digest: …", +# newer ones print one block per scheme, "V2 Signer: certificate SHA-256 digest: …". Count +# signers from "Number of signers", and collect every signer-certificate digest in either +# format (not "public key" digests, not a Source Stamp's) — all of them must be the pin. +signers=$(sed -nE 's/^Number of signers: *([0-9]+).*/\1/p' <<<"$out" | head -1) +[ "$signers" = 1 ] || { echo "$out" >&2; die "$(basename "$apk"): expected exactly 1 signer, found '${signers:-none}'"; } +digests=$(grep -v '^Source Stamp' <<<"$out" \ + | sed -nE 's/^(Signer #[0-9]+|V[0-9.]+ Signer( #[0-9]+)?):? certificate SHA-256 digest: *([0-9a-fA-F]{64}).*/\3/p' \ + | tr 'A-F' 'a-f' | sort -u) +[ -n "$digests" ] || { echo "$out" >&2; die "$(basename "$apk"): apksigner printed no signer certificate digest (unknown output format?)"; } +dn=$(grep -v '^Source Stamp' <<<"$out" | sed -nE 's/^(Signer #[0-9]+|V[0-9.]+ Signer( #[0-9]+)?):? certificate DN: *//p' | head -1) +got=$(tr '\n' ' ' <<<"$digests" | sed 's/ $//') if [ "$got" != "$pin" ]; then die "$(basename "$apk") is signed by '$dn' ($got), NOT the DisplayXR Installer release key ($pin). Tablets could not update to it in place. Refusing." fi