Document the release keystore and where it lives

c83c3d9 · Gabriel Morell · 2026-09-12 17:44

4 files +113 -12
Message
{commit_body(@commit)}

Files changed

modified android/README.md
+80 −4
@@ -179,10 +179,86 @@ Install from the repository by adding it in F-Droid:
179 179 curl -fsSL <forge>/pkg/_keys/fdroid/<owner> # prints the fingerprint
180 180 ```
181 181
182 −**Two different keys, deliberately.** The *APK* key lives in CI secrets
183 −and is what Android checks on every update — lose it and every install
184 −has to be removed and redone by hand. The *index* key lives in OpenBao
185 −and never comes near CI. See [docs/fdroid-registry.md](../docs/fdroid-registry.md).
182 +**Two different keys, deliberately.** The *APK* key is what Android
183 +checks on every update. The *index* key lives in OpenBao's transit
184 +engine and never comes near CI. See
185 +[docs/fdroid-registry.md](../docs/fdroid-registry.md) for why they are
186 +kept apart.
187 +
188 +## The release keystore
189 +
190 +Generate it once. Android verifies this certificate on every update, so
191 +**losing it means every install has to be uninstalled and redone by
192 +hand** — there is no recovery and no rotation.
193 +
194 +```sh
195 +keytool -genkeypair -v \
196 + -keystore release.jks \
197 + -storetype PKCS12 \
198 + -alias gitgud \
199 + -keyalg RSA -keysize 4096 \
200 + -validity 10000 \
201 + -dname "CN=GitGud, O=neiam, C=US"
202 +```
203 +
204 +It prompts for the password rather than taking `-storepass`, which
205 +keeps it out of shell history. PKCS12 uses one password for the store
206 +and the key, which is what `android/Dockerfile` assumes — `--ks-pass`
207 +and `--key-pass` read the same file.
208 +
209 +### Back it up before it exists anywhere else
210 +
211 +So there is never a window where your laptop holds the only copy:
212 +
213 +```sh
214 +export BAO_TOKEN=<root token>
215 +E() { kubectl -n openbao exec -i statefulset/openbao -- env BAO_TOKEN="$BAO_TOKEN" "$@"; }
216 +
217 +E bao secrets enable -path=kv kv-v2 # once
218 +E bao kv put kv/gitgud/android \
219 + keystore="$(base64 -w0 release.jks)" \
220 + password='<the password>'
221 +```
222 +
223 +`kv-v2` versions writes, so a later overwrite doesn't destroy this one.
224 +Recover with:
225 +
226 +```sh
227 +E bao kv get -field=keystore kv/gitgud/android | base64 -d > release.jks
228 +```
229 +
230 +### Hand it to CI
231 +
232 +On the repository: **Settings → Actions → Secrets**.
233 +
234 +| Secret | Value |
235 +|---|---|
236 +| `ANDROID_KEYSTORE` | `base64 -w0 release.jks` |
237 +| `ANDROID_KEYSTORE_PASS` | the password |
238 +
239 +The `android` job skips cleanly while these are unset, so nothing
240 +breaks before you get to it. It refuses to fall back to a debug-signed
241 +APK: that key is shared by everyone with an SDK, so publishing one
242 +would let anyone ship an "update" to it.
243 +
244 +### Why CI doesn't read this out of OpenBao
245 +
246 +The backup above is for a human with `kubectl`, not for the build.
247 +
248 +`apksigner` has no external-signer hook — no PKCS#11, no callback — so
249 +the APK key has to reach the build container whichever store it comes
250 +from. Bao would buy central custody, audit and revocation; it would not
251 +buy the "key never leaves" property the index key has, because that
252 +property is not available here at all.
253 +
254 +Against that, the runner is outside the cluster and OpenBao has no
255 +ingress on purpose. Reaching it would mean an ingress path and a second
256 +auth mechanism, for a secret whose exposure at the moment of use is
257 +identical either way. So: bao holds the durable copy, the Forgejo
258 +secret is the operational one.
259 +
260 +Worth revisiting if more secrets need centralising — one doesn't
261 +justify the ingress, several might.
186 262
187 263 ## What's not shipped yet
188 264
modified docs/fdroid-registry.md
+33 −8
@@ -417,14 +417,39 @@ Two details worth not rediscovering:
417 417 to record a key version, so rotation needs `key_version` threaded
418 418 through deliberately if it ever matters.
419 419
420 −### What this does *not* cover
421 −
422 −The **APK** signing key is a different key with a different home: CI
423 −secrets, used by `apksigner` at build time. OpenBao holds the *index*
424 −key. Keeping them apart is the point — an attacker with the index key
425 −can lie about which versions exist; they cannot forge an app, because
426 −Android verifies the APK signature at install against the certificate
427 −recorded in `signer`.
420 +### The APK key is a different key, and lives somewhere else
421 +
422 +| Key | Signs | Lives in | Property |
423 +|---|---|---|---|
424 +| **Index** | `entry.jar` | OpenBao transit, non-exportable | never leaves |
425 +| **APK** | the app | Forgejo secrets; backed up in OpenBao KV | reaches the build container |
426 +
427 +Keeping them apart is the point. An attacker with the index key can lie
428 +about which versions exist; they cannot forge an app, because Android
429 +verifies the APK signature at install against the certificate recorded
430 +in `signer`. The APK key is the one that actually matters, and it is
431 +also the one that cannot be protected as well — which is exactly why
432 +they should never be the same key.
433 +
434 +**Why the APK key isn't in transit.** `apksigner` has no external-signer
435 +hook — no PKCS#11, no callback — so the key must reach whatever process
436 +builds the APK. Transit's guarantee is unavailable here in principle,
437 +not by omission.
438 +
439 +**Why CI doesn't fetch it from OpenBao either.** It could: KV would give
440 +central custody, audit and revocation. But the runner is outside the
441 +cluster and OpenBao has no ingress by design, so reaching it would mean
442 +an ingress path plus a second auth mechanism — for a secret whose
443 +exposure *at the moment of use* is identical either way. OpenBao KV
444 +holds the durable backup, reached by a human with `kubectl`; the
445 +Forgejo secret is the operational copy. Revisit if more secrets need
446 +centralising; one doesn't justify the ingress.
447 +
448 +The recipe is in [`android/README.md`](../android/README.md#the-release-keystore).
449 +
450 +**There is no recovery.** Android checks this certificate on every
451 +update, so a lost APK key means every install has to be uninstalled and
452 +redone by hand. Back it up before it exists anywhere else.
428 453
429 454 [openbao]: https://openbao.org
430 455

Parents: d90390e