Release Keystore Migration — v1.0 Play Store

This document captures the one-time migration from Candela's checked-in debug keystore (stable since v0.4.15) to a Play-Store-grade release keystore for the v1.0 Play Store launch.

Audience. JP (the developer) and any future maintainer who needs to rebuild a release APK or recover from a keystore loss. Public-facing context for users lives in Privacy Policy and the install guide.


Why a new keystore

The repo today ships a storyvox-debug.keystore at app/storyvox-debug.keystore — generated by keytool with the default androiddebugkey alias and android password. Every Candela APK from v0.4.15 onward has been signed with this key.

Two reasons that key cannot ship to the Play Store:

  1. Google rejects debug keystores at upload time. The Play Console's automated keystore check flags the well-known CN=Android Debug, O=Android, C=US certificate and refuses the upload.
  2. The private key is in the public repo. Anyone with a checkout can sign an APK with applicationId = in.jphe.storyvox and Android will accept it as an upgrade. Fine for sideload-only distribution; unacceptable for a distribution channel that relies on signature continuity as a security guarantee.

The new keystore is a real, never-committed signing key. JP holds the private key + password in Vaultwarden; Google holds the app-signing key (via Play App Signing — see Recovery below).


Sideload-to-Play-Store continuity (known one-time disruption)

Heads-up for early sideload users. When Candela v1.0 lands on the Play Store, the APK will be signed with a different certificate than every v0.4.x / v0.5.x sideload build. Android's package manager refuses to install an APK signed by a different certificate over an existing install of the same applicationId (in.jphe.storyvox).

This means a v1.0 Play Store install over a sideloaded v0.5.x fails with:

INSTALL_FAILED_UPDATE_INCOMPATIBLE: Package in.jphe.storyvox signatures do not match …

The workaround for existing sideload users is uninstall first, then install from Play Store. Uninstalling wipes the local app data (library state, voice download cache, settings, encrypted WebView cookies). For users with sync enabled (InstantDB email sign-in), library + reading position + voice preferences sync back from the InstantDB record after re-sign-in; voice downloads + WebView cookies have to be re-acquired.

This is a one-time disruption at the v1.0 Play Store cutover. After v1.0 all upgrades flow through Play Store with the same signing key — Play App Signing guarantees Google re-signs every future upload with the canonical app signing key, even if JP rotates the upload key. See Recovery below.

The release-notes blurb for v1.0 must call this out prominently. Suggested copy:

Migrating from a sideloaded build? v1.0 is signed with a new Play-Store-grade certificate. Android can't replace a sideloaded install with a Play Store install signed by a different key, so please uninstall the sideloaded Candela first, then install from Google Play. If you have sync enabled, your library and reading positions will come back as soon as you sign back in. (Sorry — this is a one-time disruption at the v0.5→v1.0 cutover; every future Play Store update upgrades in place.)

The Slack release announcement template at docs/slack-release-template.md should carry the same blurb for v1.0.


Generate the keystore (JP's manual step)

JP runs these manually — Claude does not generate the keystore, because the password and the file itself must never touch the agent's context.

# 1. Pick a home OUTSIDE the repo. ~/ is fine; gitignored regardless.
mkdir -p ~/.storyvox-keystore
chmod 700 ~/.storyvox-keystore

# 2. Generate. RSA 2048+ (Play Console accepts 2048; 4096 is fine too),
#    validity ≥ 25 years past the planned launch (Play Console enforces
#    the cert validity > 22 October 2033 rule; setting 10,000 days
#    comfortably clears that and is the documented Google convention).
keytool -genkeypair \
  -alias storyvox \
  -keyalg RSA -keysize 4096 \
  -validity 10000 \
  -keystore ~/.storyvox-keystore/storyvox-release.keystore \
  -storetype JKS

# keytool will prompt for:
#   - Keystore password (use `bw generate -lus --length 32` and store in vault)
#   - Key password (Google's convention: same as keystore password)
#   - DN — Common name: storyvox; Organization: TechEMPOWER;
#          Org Unit: storyvox; City/State/Country: JP's call

# 3. Verify
keytool -list -v -keystore ~/.storyvox-keystore/storyvox-release.keystore

Then store the credentials in Vaultwarden as a single Secure Note. Suggested item name: storyvox-release-keystore (JP to confirm). The note holds:

Back up the keystore file itself somewhere safe (the Vaultwarden attachment slot for that same item, or an offline encrypted backup). If you lose the keystore AND the upload key, recovery requires Google support — see below.


Wire it into the build

The build-gradle plumbing is already in place — see app/build.gradle.kts:

  1. At configure time, the build reads local.properties and looks for these four keys:

    storyvox.releaseStoreFile=/home/jp/.storyvox-keystore/storyvox-release.keystore
    storyvox.releaseStorePassword=<keystore password from Vaultwarden>
    storyvox.releaseKeyAlias=storyvox
    storyvox.releaseKeyPassword=<key password from Vaultwarden>
    
  2. When all four are present AND the keystore file exists, the signingConfigs.release block is registered and buildTypes.release picks it up.
  3. When any are missing, the build silently falls back to the checked-in debug keystore (existing behavior). This keeps fresh checkouts, CI before the secret is wired in, and contributors who only do sideload builds working without surprise breakage.

To use it, add the four lines to local.properties (gitignored):

cat >> local.properties <<EOF

# Play Store release keystore (see docs/release-keystore.md). Never commit.
storyvox.releaseStoreFile=$HOME/.storyvox-keystore/storyvox-release.keystore
storyvox.releaseStorePassword=…paste from Vaultwarden…
storyvox.releaseKeyAlias=storyvox
storyvox.releaseKeyPassword=…paste from Vaultwarden…
EOF

Then build:

./gradlew :app:assembleRelease
# → app/build/outputs/apk/release/candela-v<versionName>.apk
#   signed with the new release keystore

Verify the APK signer:

$ANDROID_HOME/build-tools/35.0.0/apksigner verify --print-certs \
  app/build/outputs/apk/release/candela-v*.apk
# Should show:
#   Signer #1 certificate DN: CN=storyvox, OU=storyvox, O=TechEMPOWER, …
#   Signer #1 certificate SHA-256 digest: <matches Vaultwarden record>

CI wiring (GitHub-hosted runners, since 2026-09-06), and the upload key in CI (since 2026-10-23)

CI runs on GitHub-hosted ubuntu-latest (.github/workflows/android.yml). The build job makes only the two sideload APKs (:app:assembleRelease, :wear:assembleRelease), both debug-signed for sideload upgrade continuity (#952) and then re-signed with the sideload key (#1756). The Materialize local.properties step writes the file from Actions secrets: INSTANTDB_APP_ID (a public client id kept out of the tree) and, when present, the optional NOTION_OAUTH_* / GOOGLE_OAUTH_* client ids.

The Play AAB is never a GitHub release asset. An AAB cannot be installed by anyone (it is not sideloadable); its only consumer is Play Console, which re-signs it with the app signing key and serves users APKs.

The upload key is in CI, scoped to the play-upload environment (JP approved 2026-10-23, #1456). The Play upload (draft) job builds :app:bundleRelease on every v* tag, refuses any bundle whose signer is not the upload certificate (UPLOAD_CERT_SHA256), and uploads it to the closed-testing track alpha as a draft. A human still clicks Roll out in Play Console. Scope:

./gradlew :app:bundleRelease          # SEPARATE invocation from assembleRelease (#952)
# → app/build/outputs/bundle/release/app-release.aab  → upload in Play Console

The vault item storyvox-release-keystore stays the record of truth; the environment secrets are a copy. History: on 2026-09-06 the keystore was briefly copied into repo-level Actions secrets and CI attached AABs to releases v1.7.0–v1.14.1. Both were reverted the same day (secrets deleted, all 17 assets removed). The 2026-10-23 setup is the scoped replacement.

Rotating the upload key (or the service account key)

Set every secret through stdin, never on the command line, and never print it.

Upload key (planned rotation, or after a suspected leak):

  1. Generate a new upload keystore exactly as in Generate the keystore, and store it and its passwords in Vaultwarden (storyvox-release-keystore, with the old one kept as …-retired-<date> until Google accepts the new one).
  2. Export the certificate (keytool -export -rfc … -alias storyvox > upload-cert.pem). Then file Play Console → App integrity → Upload key reset with that certificate. Google takes 1–3 business days.
  3. Once Google accepts it, replace the three environment secrets:
    base64 -w0 new-upload.keystore | gh secret set PLAY_UPLOAD_KEYSTORE_B64 --env play-upload
    gh secret set PLAY_UPLOAD_KEYSTORE_PASSWORD --env play-upload   # paste at the prompt, or pipe from bw
    gh secret set PLAY_UPLOAD_KEY_PASSWORD --env play-upload
    
  4. Update UPLOAD_CERT_SHA256 in android.yml to the new certificate's SHA-256 (the job refuses a bundle signed with anything else), plus the storyvox.release* lines in local.properties on the build host.
  5. Cut the next tag and check that the job's "Refuse a bundle…" step passes.

Service account key (PLAY_SERVICE_ACCOUNT_JSON, planned rotation; for a suspected leak, revoke first as below): in Google Cloud → IAM → Service accounts, add a new key to the Play service account, set it with gh secret set PLAY_SERVICE_ACCOUNT_JSON --env play-upload < key.json, delete the local key.json, then delete the old key in Google Cloud.

Suspected leak: contain first, in this order.

  1. Stop publishing: disable the workflow (gh workflow disable android.yml) and cancel any running tag build (gh run cancel <id>).
  2. Revoke at Google: in Google Cloud → IAM → Service accounts, disable or delete the service-account key now. If unsure which key leaked, remove the account's access in Play Console → Users and permissions.
  3. Remove the GitHub copies: gh secret delete <name> --env play-upload for all four. This stops future runs only; it doesn't recall a secret already delivered to a run.
  4. Start the upload-key reset (steps 1–2 above) if the upload key may have leaked.
  5. Inspect Play: Play Console → Test and release, plus the Play Console activity log. Check every testing track for releases nobody made, and halt or replace any you find.
  6. Only then create new keys and re-enable the workflow.

Production users are not exposed by an upload-key or service-account leak: Google's app signing key, which only Google holds, signs what reaches devices, and the service account has no production permission. Testers on a testing track can be exposed if both leak together (see above).


Recovery — what if the keystore is lost

Candela uses Play App Signing (the default for any new Play Console app). This means:

If JP loses the upload key (file gone AND Vaultwarden record gone):

  1. Generate a new upload keystore with the same procedure above.
  2. Extract the public certificate: keytool -export -rfc -keystore … -alias storyvox > upload-cert.pem
  3. In Play Console → App integrity → Upload key, file an "Upload key reset" request and attach the new public cert.
  4. Google reviews (1–3 business days typically), then accepts the new upload key. User-facing experience is unaffected — Play App Signing means the re-signed APK that ships to users carries the same app signing certificate regardless of which upload key JP holds.

If the app signing key at Google ever leaks (extremely unlikely — Google holds it in their HSM), Google offers a Play Console flow to upgrade the signing key. Users get the new key on their next Play Store update; the transition is transparent to the app.

The recovery path is the whole reason JP can comfortably hold the upload keystore in Vaultwarden rather than a dedicated HSM: losing it is a 1–3 day inconvenience, not a project-ending event.


Migration checklist for v1.0


Why we keep the debug keystore alongside

The debug keystore stays in the repo and stays wired to the debug build type. Two reasons:

  1. AGP refuses to assemble the release variant without a signingConfig pointing at SOMETHING. The fallback in build.gradle.kts (use debug if release isn't wired) means a fresh checkout — or a contributor who doesn't have JP's Vaultwarden access — still gets a working assemble. The CI workflow file's setup also works unchanged on a clean clone.
  2. The :baselineprofile producer variant derives from release. It only needs A signing config to exist; we don't care which one when we're just generating profiles. Wiring the debug keystore there keeps profile generation unblocked on any machine.

Once the release keystore is in place on JP's laptop and on the CI runner, every shipped artifact is signed with it. The debug keystore is just there as a safety net for development environments.