Skip to content

fix(#62): sign the Android installer with one permanent release key (0.4.2) - #71

Merged
dfattal merged 2 commits into
mainfrom
fix/android-installer-release-key
Oct 1, 2026
Merged

dfattal merged 2 commits into
mainfrom
fix/android-installer-release-key

Conversation

@dfattal

@dfattal dfattal commented Sep 30, 2026

Copy link
Copy Markdown
Contributor

Problem

Every published DisplayXR-Installer-<ver>.apk was signed with the CI runner's throwaway debug key, a different key on every run (v2.4.4 signer b7d95335…, v2.4.5 bec29699…). Android refuses to update an app across a key change, so a tablet could never upgrade the installer in place.

Fix: one release key, forever

Key RSA 4096, SHA256withRSA, CN=DisplayXR Installer, O=DisplayXR, valid 2026-09-30 → 2126-09-06
Cert SHA-256 (pin) 7df5e3233c76abf9544311268e76d57e1240c83805a5fd33ac91a4ab5371f8ce → android-installer/release-signing-cert.sha256
Custody environment android-installer-release (deployment branch: main only) — ANDROID_INSTALLER_KEYSTORE_B64, …_KEYSTORE_PASSWORD, …_KEY_ALIAS, …_KEY_PASSWORD; offline backup held by the repo owner
Scheme v2 only, the same shape as the runtime, demos and browser APKs
  • Gradle: a release signingConfig read from env vars (ANDROID_INSTALLER_KEYSTORE_FILE + passwords/alias). Without them, the release variant is signed with the debug key, so fork PRs and local builds still compile, test and install. Minification stays off; R8 can't be validated without a tablet run.
  • CI always builds assembleRelease. The key is decoded only in runs on main. The environment's branch policy also blocks a workflow edited on a branch.
  • Gate: scripts/verify-release-signature.sh <apk> passes only an APK whose single signer's cert equals the pin (it also checks for a v2/v3 signature and package com.displayxr.installer).
    • publish-bundle.yml requires it in the build job (require_release_key: true) and runs it again on the downloaded bytes right before the release is created.
    • build-android-installer.yml makes it required when attaching (release_tag) or when the key was present.
    • build-android-bundle.yml requires it when publish is on.
    • Every build also re-signs the APK with a throwaway key and asserts that the gate refuses it.
    • Debug-signed artifacts are named …-DEBUGKEY.apk.
  • Upgrade path from debug-signed 0.4.x: Android can't update across the key change, and the app can't fix that itself. The release notes, README and INSTALL.md now carry a one-time "uninstall the old DisplayXR Installer, then install this one; your DisplayXR apps are unaffected". The "signed with a different key for now" wording is removed.
  • installerVersionName 0.4.2 / installerVersionCode 7.
  • Rotation (if the key ever leaks) is documented: add a v3 lineage at rotation time and change the pin in the same PR.

Coordinates with #70: both touch .github/release-notes/bundle.md in different hunks, and git merge-tree shows no conflict.

Verification (local, no tablet)

  • :app:testDebugUnitTest: 94 tests, 0 failures (1 skipped, already skipped on main).
  • Built with the real key via env, then checked with apksigner verify --print-certs and aapt2 dump badging:
    • 0.4.2 (versionCode 7): v2 only, 1 signer, CN=DisplayXR Installer, O=DisplayXR, 7df5e323…71f8ce. Gate PASS.
    • 0.4.2-test (versionCode 8, built with -PinstallerVersionName/-Code): same cert 7df5e323…71f8ce. Gate PASS. Because the package and cert match and the versionCode is higher, Android will install it in place over the first one.
  • Negative controls (gate must fail):
    • Release variant built without the key (debug fallback, cert 91f2d749…): FAIL rc=1.
    • The published v2.4.5 asset DisplayXR-Installer-0.4.1.apk (debug cert bec29699…): FAIL rc=1.
    • The real APK checked against a wrong pin file: FAIL rc=1.
    • The real key re-signed as v2+v3 (the rotation-era shape): PASS, as intended.

Not verified

  • On-device in-place upgrade 0.4.2 → 0.4.2-test. The two APKs are built for the hub session to try on the tablet.
  • The signed path in CI on main (the environment is main-only). See the CI comment below for what was exercised.

🤖 Generated with Claude Code

dfattal and others added 2 commits September 30, 2026 09:18
…0.4.2)

Every published DisplayXR-Installer-<ver>.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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@dfattal

dfattal commented Sep 30, 2026

Copy link
Copy Markdown
Contributor Author

CI evidence for both signing paths

  • PR run (no key), run 36743796745: environment: '' runs without the environment and KS_B64 is empty. The release variant falls back to the debug key, and the pin check warns but does not fail because nothing requires it. The foreign-key negative control passes. The artifact is DisplayXR-Installer-0.4.2-DEBUGKEY.apk. All checks are green.
  • Signed path, pre-merge: the environment admits main only. To test it, I used a throwaway branch ci/relkey-signed-probe, which is this PR plus one line pointing the environment condition at that branch. I added it to the environment's branch policy for this one dispatch.
    • Run 36743294349 found a real bug. The runner's newer apksigner prints V2 Signer: certificate SHA-256 digest: rather than Signer #1 certificate …, so the gate counted 0 signers and refused a correctly signed APK. Fixed in 3906cbf, which accepts both formats. I tested it locally against build-tools 34 output and against a replay of the runner's output.
    • Run 36743813364 is green. The key is decoded and assembleRelease signs with it. The gate reports OK … CN=DisplayXR Installer, O=DisplayXR, 7df5e3233c76abf9544311268e76d57e1240c83805a5fd33ac91a4ab5371f8ce, and the foreign-key APK is refused. I downloaded the CI artifact and it verifies locally against the same pin.
    • Afterwards I deleted the probe branch and its branch-policy entry. The policy is back to ["main"].

Not exercised: publish-bundle.yml end to end. Its gate is the same script on the downloaded bytes, and it runs on the next bundle release, which must be dispatched from main.

@dfattal
dfattal merged commit bdad806 into main Oct 1, 2026
4 checks passed
@dfattal
dfattal deleted the fix/android-installer-release-key branch October 1, 2026 03:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant