neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
Document the release keystore and where it lives
c83c3d9 · Gabriel Morell · 2026-09-12 17:44
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