Mobile Shell — Update Signing Certificate Runbook
Feature: 051-mobile-app-shell (T082) · Written: 2026-09-03
Covers: FR-032, FR-033, FR-034
Read the first section before you touch anything. The failure mode here is not "updates stop working until someone fixes it". It is "these installed binaries can never receive another update, by any means, for the rest of their lives."
Why this is not a normal certificate
EAS Update code signing embeds the public certificate in the binary at build time. The device checks every update payload against that embedded copy before running it. Nothing about that check can be changed over the air, because changing it would require running code the device has not yet verified — which is the exact thing the check exists to prevent.
Three consequences follow, and all three are counter-intuitive:
- An expired certificate permanently disables updates for that binary. Not until renewal — permanently. The device will reject every future payload, and the only remedy is a new binary from the App Store or Play Store.
- A compromised private key cannot be revoked over the air. There is no revocation list, and the embedded certificate is the only trust anchor. The response to compromise is a store release, on store review timelines.
- Losing the private key is unrecoverable in the same way. No further update can ever be published to binaries carrying the matching certificate.
This is why the certificate is generated with a 10-year validity (T047). The long life is not laziness; it is the only lever that makes the expiry cliff rare.
Custody
| Item | Where it lives | Who can reach it |
|---|---|---|
Private key (private-key.pem) | Doppler / GCP Secret Manager | Release engineers only |
Public certificate (certificate.pem) | Committed in apps/mobile/, embedded at build | Everyone — it is public by design |
| Expo publishing account | Fixx Expo organization | Release engineers |
FR-034 requires key access to be separate from publishing-account access. The reason is concrete: an attacker with only the publishing account can push an update that every device rejects — noisy, visible, harmless. An attacker with both can push one that every device accepts and runs. Keeping them apart turns a catastrophe into an incident.
Never commit the private key. Never paste it into a CI variable that is not a
secret store. It does not belong in eas.json.
Monitor expiry — nobody is going to warn you
No part of the toolchain warns before the certificate expires. There is no email, no build warning, no runtime notice: updates simply stop being accepted on the day it lapses, and the first symptom is that a fix you published did not reach anyone.
Check the expiry date and record the next check:
openssl x509 -in apps/mobile/certificate.pem -noout -subject -dates
Put a calendar reminder 12 months before notAfter. Twelve months is not
padding — see the timeline below, which needs a store release to reach every
device, and store releases reach stragglers slowly.
Rotation
Rotation is not a drop-in replacement. A new certificate means the currently installed binaries can no longer verify new updates, so rotation requires a new runtime version and a store build. Sequence it deliberately:
-
Generate the new key pair. Do not delete the old one yet.
cd apps/mobile
npx expo-updates codesigning:generate \
--key-output-directory keys \
--certificate-output-directory certs \
--certificate-validity-duration-years 10 \
--certificate-common-name "FlowPOS Mobile Shell" -
Store the new private key in Doppler / GCP Secret Manager. Keep the old one until step 6 — you still need it to publish to the old binaries.
-
Configure the new certificate into the app:
npx expo-updates codesigning:configure \
--certificate-input-directory certs \
--key-input-directory keys -
Build and release to the stores. The certificate change alters the native fingerprint, so
runtimeVersion: { policy: "fingerprint" }produces a new runtime version automatically — which is the safety property that policy was chosen for (T045). Old binaries and new binaries are now on different runtime versions and receive separate update streams. -
Keep publishing to both while adoption catches up. Updates for the old runtime version must still be signed with the old key. This is the step people skip, and skipping it strands every merchant who has not updated.
-
Retire the old key only once telemetry shows no meaningful population on the old runtime version. Sentry tags every event with
expo-runtime-version(T022), so that population is directly measurable — do not guess at it.
If the private key is compromised
- Rotate immediately, following the sequence above.
- Assume any update published with the old key may be hostile. Audit the update history for that channel — publishes you did not make, or made at times nobody was working.
- You cannot revoke the old certificate. Devices on the old binary will keep accepting payloads signed with the compromised key until they install the new binary from the store. Treat store adoption as the actual remediation timeline, and communicate it in those terms rather than as "fixed on rotation".
- Consider raising
minimumRuntimeVersionviaGET /api/v1/status/mobile-shell(T050) so affected devices showupdate_requiredand merchants are pushed to the store install, rather than waiting for an automatic update that would carry the same risk.
If the private key is lost
There is no recovery. Follow the rotation sequence from step 1, and accept that step 5 is impossible — no further update can be published to the old binaries. Every device must install from the store. Raise the version floor (T050) so those devices say so out loud instead of appearing healthy while silently frozen.
Related
- Mobile shell architecture — the pinned origin, the bridge, the release model
- Store submission — a new store binary is the only recovery from an expired or lost OTA certificate
- Manual verification procedures — MV-1…MV-5
specs/051-mobile-app-shell/tasks.md— T046 (plan tier), T047 (signing setup)