Local-only Android release signing, env-var driven. The maintainer keeps the .jks on their machine; CI age-encrypted-in-repo variant deferred to a follow-up. Adds signingConfigs.release reading 4 env vars (v1+v2+v3 enabled), scripts/build-apk-local.sh --release with .env auto-sourcing and --env-file secret handling, .env.sample template, root .gitignore for *.jks/*.keystore, peer-promoted Signed release APK section in docs/android-apk.md, and new docs/android-signing.md with the keystore ceremony, threat model, backup checklist, and rotation procedure. Closes #165.
This commit is contained in:
parent
e3f630a913
commit
b2dec037a8
6 changed files with 582 additions and 46 deletions
26
.env.sample
Normal file
26
.env.sample
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# MetaScrub local environment overrides.
|
||||
#
|
||||
# Copy to `.env` and edit. `.env` is gitignored; never commit it.
|
||||
# cp .env.sample .env
|
||||
#
|
||||
# Sourced automatically by scripts/build-apk-local.sh when `--release` is used.
|
||||
# For day-to-day debug builds, no .env is required.
|
||||
#
|
||||
# See docs/android-signing.md for the keystore generation ceremony, backup
|
||||
# checklist, and rotation procedure.
|
||||
|
||||
# --- Android release signing -------------------------------------------------
|
||||
# Absolute path to the .jks keystore on the host. The script bind-mounts this
|
||||
# file read-only into the build container; it never enters the repo working
|
||||
# tree. Keep the keystore (and its backups) outside the repo.
|
||||
METASCRUB_KEYSTORE_FILE=/absolute/path/to/metascrub-release.jks
|
||||
|
||||
# Key alias used inside the keystore (the -alias passed to keytool -genkey).
|
||||
METASCRUB_KEYSTORE_ALIAS=metascrub
|
||||
|
||||
# Store password and key password. Often identical; keep them distinct for
|
||||
# defence in depth. Use a password manager — never paste these into chat,
|
||||
# commit messages, or shell history (`HISTCONTROL=ignorespace` + leading space
|
||||
# avoids the latter for one-off invocations).
|
||||
METASCRUB_KEYSTORE_PASSWORD=change-me
|
||||
METASCRUB_KEY_PASSWORD=change-me
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -319,3 +319,10 @@ test-results/
|
|||
|
||||
# Local Docker build caches (see scripts/build-apk-local.sh)
|
||||
.docker-cache/
|
||||
|
||||
# Android signing keystores — never committed at any path. android/.gitignore
|
||||
# already has `*.jks` (matching any .jks under android/); this root-level rule
|
||||
# catches a keystore generated outside android/ — e.g. from the repo root
|
||||
# during the Phase 0 ceremony in docs/android-signing.md.
|
||||
*.jks
|
||||
*.keystore
|
||||
|
|
|
|||
|
|
@ -16,10 +16,47 @@ android {
|
|||
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||
}
|
||||
}
|
||||
// Release signing is driven by four environment variables — the keystore
|
||||
// itself is never committed. See docs/android-signing.md for the keytool
|
||||
// ceremony, backup expectations, and rotation procedure.
|
||||
//
|
||||
// METASCRUB_KEYSTORE_FILE — absolute path to the .jks file
|
||||
// METASCRUB_KEYSTORE_ALIAS — key alias inside the keystore
|
||||
// METASCRUB_KEYSTORE_PASSWORD — store password
|
||||
// METASCRUB_KEY_PASSWORD — key password (often same as store password)
|
||||
//
|
||||
// When METASCRUB_KEYSTORE_FILE is absent, `assembleRelease` deliberately
|
||||
// produces `app-release-unsigned.apk` — the artifact F-Droid's build server
|
||||
// expects. Local maintainers wanting a self-signed APK set the env vars
|
||||
// (via `.env` + `scripts/build-apk-local.sh --release`).
|
||||
def releaseKeystoreFile = System.getenv("METASCRUB_KEYSTORE_FILE")
|
||||
signingConfigs {
|
||||
release {
|
||||
if (releaseKeystoreFile) {
|
||||
storeFile file(releaseKeystoreFile)
|
||||
storePassword System.getenv("METASCRUB_KEYSTORE_PASSWORD")
|
||||
keyAlias System.getenv("METASCRUB_KEYSTORE_ALIAS")
|
||||
keyPassword System.getenv("METASCRUB_KEY_PASSWORD")
|
||||
// v1 (JAR) + v2 (APK Signature Scheme v2) are AGP defaults and
|
||||
// are required for our minSdk 23 (Android 6.0) install path:
|
||||
// Android 6 only understands v1, Android 7+ uses v2. v3 enables
|
||||
// the signing-key rotation lineage scheme — required by the
|
||||
// rotation procedure in docs/android-signing.md and by the
|
||||
// issue #165 acceptance criteria. v4 is for Play Store
|
||||
// streaming installs (out of scope; not enabled).
|
||||
enableV1Signing true
|
||||
enableV2Signing true
|
||||
enableV3Signing true
|
||||
}
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
if (releaseKeystoreFile) {
|
||||
signingConfig signingConfigs.release
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -132,6 +132,49 @@ The Dockerfile is in [`docker/android-builder/`](../docker/android-builder/) —
|
|||
|
||||
---
|
||||
|
||||
## Signed release APK
|
||||
|
||||
The default `./scripts/build-apk-local.sh` invocation produces a **debug** APK signed with Android's auto-generated per-developer debug keystore. That's the right artifact for personal sideloading and forensic verification, but it cannot install over a release APK signed by anyone else — and Play Protect surfaces a warning on a downloaded debug APK that does not appear on a release APK.
|
||||
|
||||
For distribution beyond personal use (a download page, F-Droid, multi-dev rebuilds that should install over each other), switch to a signed release build:
|
||||
|
||||
```bash
|
||||
./scripts/build-apk-local.sh --release
|
||||
# Output: android/app/build/outputs/apk/release/app-release.apk
|
||||
```
|
||||
|
||||
The flag is enabled by populating `.env` at the repo root (gitignored) from the committed template:
|
||||
|
||||
```bash
|
||||
cp .env.sample .env
|
||||
$EDITOR .env # set the four METASCRUB_* variables
|
||||
```
|
||||
|
||||
Four environment variables drive the signing config in `android/app/build.gradle`:
|
||||
|
||||
| Variable | Meaning |
|
||||
| --- | --- |
|
||||
| `METASCRUB_KEYSTORE_FILE` | Absolute path to the `.jks` keystore on the host. Bind-mounted read-only into the build container; never enters the repo working tree. |
|
||||
| `METASCRUB_KEYSTORE_ALIAS` | The key alias inside the keystore (the `-alias` passed to `keytool -genkey`). |
|
||||
| `METASCRUB_KEYSTORE_PASSWORD` | Store password. |
|
||||
| `METASCRUB_KEY_PASSWORD` | Key password. Often identical to the store password; keep them distinct for defence in depth. |
|
||||
|
||||
If any variable is missing or the keystore file is unreadable, the script refuses to build — silent fallback to an unsigned APK would let you ship an artifact that fails update-install checks on user devices without ever noticing during the build.
|
||||
|
||||
When all four env vars are absent, `./gradlew assembleRelease` (run directly, without the script) deliberately produces `app-release-unsigned.apk` — the artifact F-Droid's build server expects. The script's `--release` path always signs; the unsigned-release artifact is for F-Droid submission only.
|
||||
|
||||
> **Generating the keystore is a one-time ceremony with permanent consequences.** The SHA-256 fingerprint of this keystore becomes the app's permanent identity. A lost keystore means no future APK can install as an update on top of an existing one — there is no recovery mechanism. A leaked keystore lets an attacker produce APKs that Android treats as legitimate updates to MetaScrub, inheriting all granted permissions. Read **[`docs/android-signing.md`](android-signing.md)** before running `keytool -genkey`; it covers the recommended parameters, the offline-backup checklist, and the rotation procedure.
|
||||
|
||||
Verify the signature before distributing the APK:
|
||||
|
||||
```bash
|
||||
apksigner verify --print-certs android/app/build/outputs/apk/release/app-release.apk
|
||||
```
|
||||
|
||||
The certificate fingerprint must match the one published in `docs/android-signing.md` § Fingerprint.
|
||||
|
||||
---
|
||||
|
||||
## One-time project setup
|
||||
|
||||
These steps add Capacitor to the repo. Run them once, commit the result, then `cap sync` is all you do on subsequent builds.
|
||||
|
|
@ -223,24 +266,9 @@ The debug APK is ~5–10 MB depending on how the WebView strips assets. That's t
|
|||
2. On the device, allow "Install from unknown sources" for the file manager being used (Android 8+ scopes this per-app rather than system-wide).
|
||||
3. Tap the APK. Android shows an install dialog. After install, MetaScrub appears in the launcher like any other app.
|
||||
|
||||
### Signed release APK
|
||||
### Release signing
|
||||
|
||||
For distribution beyond personal use (e.g. F-Droid, a download link), switch to a signed release build:
|
||||
|
||||
```bash
|
||||
# Generate a project-owned signing key (once; store securely — never commit it)
|
||||
keytool -genkey -v -keystore metascrub-release.jks \
|
||||
-alias metascrub -keyalg RSA -keysize 2048 -validity 10000
|
||||
|
||||
# Configure signing in android/app/build.gradle
|
||||
# signingConfigs { release { storeFile file("...") storePassword "..." ... } }
|
||||
# buildTypes { release { signingConfig signingConfigs.release } }
|
||||
|
||||
cd android && ./gradlew assembleRelease
|
||||
# Output: android/app/build/outputs/apk/release/app-release.apk
|
||||
```
|
||||
|
||||
Store the keystore and its passwords as CI secrets if CI APK builds are added later.
|
||||
For distribution beyond personal use, see [§ Signed release APK](#signed-release-apk) above and [`docs/android-signing.md`](android-signing.md) for the keystore ceremony, backup expectations, and rotation procedure.
|
||||
|
||||
### F-Droid
|
||||
|
||||
|
|
@ -340,7 +368,7 @@ The fallback path additionally runs `setup-java@v4` + `android-actions/setup-and
|
|||
|
||||
**Why not on push/PR:** Android SDK install + Gradle build add ~5–10 min per run on a cold cache, with no upside for the normal CI path (web build, lint, typecheck, unit tests, e2e are all platform-agnostic). The APK only needs to be rebuilt when there is something to distribute.
|
||||
|
||||
**Signing:** Debug APK only — sufficient for personal sideloading and forensic verification. Release signing is out of scope for this iteration (it would require a keystore committed as a CI secret); see the "Signed release APK" section above for the manual path.
|
||||
**Signing:** CI builds the debug APK only. Release signing is local-only today — run `./scripts/build-apk-local.sh --release` from a machine with the keystore mounted; see [§ Signed release APK](#signed-release-apk) above. Wiring CI release signing (age-encrypted-in-repo keystore + Forgejo Secrets) is tracked as a follow-up in [`docs/android-signing.md`](android-signing.md) § Future work.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
286
docs/android-signing.md
Normal file
286
docs/android-signing.md
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
# Android release signing
|
||||
|
||||
> **One-time ceremony.** Do this once per project. The signing key generated
|
||||
> here becomes the app's permanent identity — re-generating it is operationally
|
||||
> impossible without breaking every existing install. Read the whole document
|
||||
> before running any commands.
|
||||
|
||||
## Why a release key
|
||||
|
||||
Every APK installed on Android is signed; the signing certificate's SHA-256
|
||||
fingerprint is how Android decides whether one APK is an *update* of another
|
||||
already on disk:
|
||||
|
||||
- Same package name (`com.metascrub.app`) + same signature → install replaces
|
||||
the existing app, **keeps user data and permissions**.
|
||||
- Same package name + **different** signature → install fails with
|
||||
`INSTALL_FAILED_UPDATE_INCOMPATIBLE`; the user must uninstall first.
|
||||
|
||||
The debug keystore that `./gradlew assembleDebug` uses is generated per
|
||||
developer machine and works only for sideloading by that one developer. A
|
||||
single project-owned **release** keystore means every release ever produced —
|
||||
on any dev machine, on any CI runner — signs with the same key, so devices
|
||||
treat every release as a smooth update of the same app.
|
||||
|
||||
## Threat model — what a leak or loss means
|
||||
|
||||
| Failure mode | Consequence | Recovery |
|
||||
| --- | --- | --- |
|
||||
| **Leak** (attacker has it, you also have it) | Attacker can build APKs that Android trusts as legitimate updates to MetaScrub. A modified APK distributed through a typo-squatted domain or fake mirror installs over the legitimate one with no warning, inheriting all granted permissions. | APK Signature Scheme v3+ key rotation: generate a new key signed by the old key as proof of authority, ship new releases using both. Old key is deprecated. **Requires the old key to be available.** |
|
||||
| **Loss** (nobody has it) | Rotation is impossible — rotation requires signing with the old key. No future release can install as an update on any existing device. | Users must uninstall the old version (losing data) and reinstall fresh under a different signature. |
|
||||
|
||||
For a privacy tool whose value proposition is "no telemetry, no network
|
||||
calls," a leak is unusually damaging: the audience trusts the signature as
|
||||
evidence of provenance.
|
||||
|
||||
## Decisions before generating
|
||||
|
||||
### Validity period
|
||||
|
||||
`keytool -validity` defaults to **90 days** — useless for an app you intend
|
||||
to support for years. Recommended: `-validity 36500` (100 years).
|
||||
F-Droid recommends 30+ years; Play Store wants 25+; 100 is the "good enough
|
||||
forever" choice.
|
||||
|
||||
### Key algorithm and size
|
||||
|
||||
RSA 4096. The default is 2048, which is fine cryptographically but costs you
|
||||
nothing at sign/verify time to upgrade — and you're stuck with this key for
|
||||
decades. `-keyalg RSA -keysize 4096`.
|
||||
|
||||
### Passwords
|
||||
|
||||
Two **separate** strong passwords for the store and the key, generated by a
|
||||
password manager (24+ characters, no dictionary words). Store them:
|
||||
|
||||
- In your password manager (1Password, Bitwarden, etc. — fine for *passwords*;
|
||||
the privacy invariants forbid cloud-syncing the keystore, not its
|
||||
passwords).
|
||||
- In Forgejo Secrets (only when CI release signing is wired up — currently
|
||||
deferred; see [Future work](#future-work)).
|
||||
|
||||
Never paste passwords into:
|
||||
|
||||
- Shell history (`HISTCONTROL=ignorespace` + leading space avoids this for
|
||||
one-off invocations).
|
||||
- Commit messages or PR descriptions.
|
||||
- Chat transcripts (including conversations with AI assistants).
|
||||
- The `.env` file you commit. `.env` is gitignored — but **verify** with
|
||||
`git check-ignore .env` before saving real passwords there.
|
||||
|
||||
### Distinguished name (`-dname`)
|
||||
|
||||
Populated explicitly so the cert subject isn't blank. Do **not** put a real
|
||||
mailing address, phone number, or personal email in here — it ends up
|
||||
embedded in every APK forever, visible to anyone running
|
||||
`apksigner verify --print-certs`.
|
||||
|
||||
Recommended skeleton:
|
||||
|
||||
```
|
||||
CN=MetaScrub Release, O=MetaScrub, C=XX
|
||||
```
|
||||
|
||||
## Storage strategy (currently: maintainer-only)
|
||||
|
||||
The `.jks` file lives on the maintainer's machine. CI does **not** release-sign
|
||||
today; only local builds via `./scripts/build-apk-local.sh --release` produce
|
||||
signed APKs.
|
||||
|
||||
Trade-offs of this choice:
|
||||
|
||||
- ✅ Strictest privacy — the keystore never enters the Forgejo trust boundary,
|
||||
never sits encrypted-in-repo.
|
||||
- ✅ No additional tooling (age, sops, git-crypt) to install.
|
||||
- ⚠️ **Bus factor of 1.** Without a robust offline backup, a laptop loss is a
|
||||
permanent identity loss. Backups are non-negotiable; see below.
|
||||
- ⚠️ No CI release path. Every signed APK requires the maintainer to be
|
||||
online with the keystore mounted.
|
||||
|
||||
Future work tracks the age-encrypted-in-repo option for CI signing; see below.
|
||||
|
||||
## Backup checklist
|
||||
|
||||
The keystore lives in **three** offline places, geographically separated.
|
||||
Privacy invariants §5 (no telemetry, no cloud sync) rules out 1Password
|
||||
cloud sync, iCloud Keychain, Google Drive, or any service that transmits the
|
||||
keystore over the network. The keystore's *passwords* may live in a
|
||||
password manager; the keystore file itself must not.
|
||||
|
||||
- [ ] **Encrypted USB stick** in a fireproof safe — full `.jks` file, sealed
|
||||
envelope with both passwords on paper.
|
||||
- [ ] **Paper backup** of `base64(.jks)` + passwords in a different fireproof
|
||||
container (geographic redundancy). Use `paperkey`-style QR codes or a
|
||||
print-friendly base64 dump.
|
||||
- [ ] **Secondary offline drive** — duplicate of (1), in a different physical
|
||||
location.
|
||||
|
||||
Verify each backup at least once a year by decoding it and running
|
||||
`keytool -list -alias metascrub -keystore <restored.jks>` to confirm the
|
||||
SHA-256 fingerprint matches `docs/android-signing.md` § Fingerprint.
|
||||
|
||||
## Generation ceremony
|
||||
|
||||
Run from a trusted machine. The host should be one you control — not a
|
||||
shared workstation, not a borrowed laptop. Do this **after** the backup
|
||||
destinations are physically ready (USB sticks formatted and encrypted,
|
||||
fireproof container at hand, paper printer ready).
|
||||
|
||||
```bash
|
||||
# 0. Work outside the repo. Generating the keystore in $REPO_ROOT risks an
|
||||
# accidental `git add -A` — root .gitignore covers *.jks but defence in
|
||||
# depth costs nothing here.
|
||||
mkdir -p ~/.metascrub && cd ~/.metascrub
|
||||
|
||||
# 1. Generate the keystore. keytool prompts interactively for both passwords
|
||||
# — do not pass them on the command line (they would leak into shell
|
||||
# history and ps output).
|
||||
keytool -genkey -v \
|
||||
-keystore metascrub-release.jks \
|
||||
-alias metascrub \
|
||||
-keyalg RSA -keysize 4096 \
|
||||
-validity 36500 \
|
||||
-dname "CN=MetaScrub Release, O=MetaScrub, C=XX"
|
||||
|
||||
chmod 600 metascrub-release.jks # the script later warns if this drifts
|
||||
|
||||
# 2. Print the SHA-256 fingerprint. Copy it into this file under § Fingerprint
|
||||
# and into any release notes — users can verify it with
|
||||
# `apksigner verify --print-certs app-release.apk`.
|
||||
keytool -list -v -alias metascrub -keystore metascrub-release.jks \
|
||||
| grep -E 'SHA-?256'
|
||||
|
||||
# 3. Back up. Do all three before relying on the working copy. The keystore
|
||||
# file itself is what's being copied — pipe-to-printer / pipe-to-qrencode
|
||||
# avoids ever writing a plaintext base64 sidecar to disk.
|
||||
|
||||
# a) Encrypted USB #1 (the "primary" offline copy, kept in a safe):
|
||||
cp ~/.metascrub/metascrub-release.jks /media/<usb-1>/metascrub/
|
||||
|
||||
# b) Paper backup. Pipe straight to the printer or to a QR encoder so no
|
||||
# plaintext base64 file is left on disk. Pick whichever your printer
|
||||
# and storage container support:
|
||||
base64 ~/.metascrub/metascrub-release.jks | lp # printed pages
|
||||
base64 ~/.metascrub/metascrub-release.jks | qrencode -o qr.png -t PNG -l H # then save qr.png to encrypted media
|
||||
|
||||
# c) Encrypted USB #2 (secondary copy in a different physical location):
|
||||
cp ~/.metascrub/metascrub-release.jks /media/<usb-2>/metascrub/
|
||||
|
||||
# d) Verify each backup by listing the cert in the restored copy — the
|
||||
# fingerprint must match step 2.
|
||||
keytool -list -v -alias metascrub -keystore /media/<usb-1>/metascrub/metascrub-release.jks \
|
||||
| grep -E 'SHA-?256'
|
||||
|
||||
# 4. Configure .env at the repo root. The working keystore at
|
||||
# ~/.metascrub/metascrub-release.jks is kept — the .env points at it.
|
||||
cd <path-to-exifcleaner-repo>
|
||||
cp .env.sample .env
|
||||
$EDITOR .env # set the four METASCRUB_* variables
|
||||
# METASCRUB_KEYSTORE_FILE=$HOME/.metascrub/metascrub-release.jks
|
||||
```
|
||||
|
||||
## Building a signed release APK
|
||||
|
||||
```bash
|
||||
./scripts/build-apk-local.sh --release
|
||||
```
|
||||
|
||||
The script:
|
||||
|
||||
1. Sources `.env` from the repo root if present.
|
||||
2. Validates all four `METASCRUB_*` env vars are set and the keystore file
|
||||
exists. **Refuses** to fall back to an unsigned build — silent fallback
|
||||
would let you ship an APK that fails update-install checks on user
|
||||
devices without ever noticing during the build.
|
||||
3. Bind-mounts the keystore read-only into the build container at
|
||||
`/keystore/release.jks` (a path outside `/workspace`, so it cannot leak
|
||||
into git status, gradle output, or `cap sync`).
|
||||
4. Runs `./gradlew assembleRelease`.
|
||||
5. Reports the output path and the `apksigner verify` command.
|
||||
|
||||
Output: `android/app/build/outputs/apk/release/app-release.apk`.
|
||||
|
||||
Verify the signature before distribution:
|
||||
|
||||
```bash
|
||||
apksigner verify --print-certs android/app/build/outputs/apk/release/app-release.apk
|
||||
```
|
||||
|
||||
The certificate fingerprint must match the one in § Fingerprint below.
|
||||
|
||||
## Fingerprint
|
||||
|
||||
> _Fill in after running the ceremony._
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| Alias | `metascrub` |
|
||||
| Algorithm | `RSA 4096` |
|
||||
| Validity | 100 years (`-validity 36500`) |
|
||||
| SHA-256 fingerprint | `XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX` |
|
||||
| Generated on | `YYYY-MM-DD` |
|
||||
| Generated from commit | `<short SHA of the commit at which the keystore was generated>` |
|
||||
|
||||
Publishing the SHA-256 fingerprint lets users running
|
||||
`apksigner verify --print-certs` independently verify they're installing an
|
||||
APK built from this keystore.
|
||||
|
||||
## Rotation procedure
|
||||
|
||||
> Document now, even if you never use it. By the time you need to rotate,
|
||||
> the old key is either lost (rotation impossible) or compromised (rotation
|
||||
> is the only remaining option) — neither state leaves room for figuring
|
||||
> out the procedure from scratch.
|
||||
|
||||
APK Signature Scheme v3+ rotation, in outline:
|
||||
|
||||
1. Generate the new keystore by the same ceremony as above (same backup
|
||||
discipline, same `-keyalg` and `-keysize`). Different `-alias` is fine.
|
||||
2. Configure `signingConfigs.release` to sign with the new key *and* include
|
||||
a proof-of-authority lineage signed by the old key:
|
||||
|
||||
```groovy
|
||||
signingConfigs {
|
||||
release {
|
||||
storeFile file(System.getenv("METASCRUB_KEYSTORE_FILE_NEW"))
|
||||
storePassword System.getenv("METASCRUB_KEYSTORE_PASSWORD_NEW")
|
||||
keyAlias System.getenv("METASCRUB_KEYSTORE_ALIAS_NEW")
|
||||
keyPassword System.getenv("METASCRUB_KEY_PASSWORD_NEW")
|
||||
// The lineage file is produced by apksigner rotate; see below.
|
||||
// The old key signs the lineage as proof of authority.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. Build the lineage file once with `apksigner rotate`:
|
||||
|
||||
```bash
|
||||
apksigner rotate \
|
||||
--in /dev/null \
|
||||
--out metascrub-lineage.bin \
|
||||
--old-signer --ks metascrub-release.jks --ks-key-alias metascrub \
|
||||
--new-signer --ks metascrub-release-new.jks --ks-key-alias metascrub-new
|
||||
```
|
||||
|
||||
Commit `metascrub-lineage.bin` to the repo — it's not secret. It just
|
||||
proves the new key was authorized by the old one.
|
||||
|
||||
4. Ship a release built with the new signing config. Users who update
|
||||
through the project's distribution channel get the lineage transparently;
|
||||
devices verify the new signature against the old via the lineage.
|
||||
|
||||
5. Update § Fingerprint above with the new SHA-256 and the rotation date.
|
||||
Keep the old fingerprint in a "Retired keys" section so users running
|
||||
`apksigner verify --print-certs` on old APKs can still cross-check.
|
||||
|
||||
## Future work
|
||||
|
||||
- **CI release signing via age-encrypted-in-repo keystore.** Commit an
|
||||
`android/metascrub-release.jks.age` to the repo; keep the age decryption
|
||||
key in Forgejo Secrets (for CI) and on offline media (for the maintainer).
|
||||
Build script gains `--age-key <path>` that decrypts to `/keystore/`,
|
||||
builds, and shreds. Tracked in a follow-up issue.
|
||||
- **F-Droid metadata.** F-Droid signs APKs with their own key on their build
|
||||
server, so the project keystore is not involved in the F-Droid
|
||||
distribution path. Submit `.fdroid.yml` once the APK distribution is
|
||||
stable.
|
||||
|
|
@ -1,20 +1,30 @@
|
|||
#!/usr/bin/env bash
|
||||
# Build the MetaScrub Android debug APK locally using Docker only — no
|
||||
# Android SDK, JDK, or Node required on the host.
|
||||
# Build the MetaScrub Android APK locally using Docker only — no Android SDK,
|
||||
# JDK, or Node required on the host.
|
||||
#
|
||||
# Mirrors the steps in .github/workflows/build-android.yml. The Dockerfile
|
||||
# at docker/android-builder/Dockerfile is the local-dev counterpart to
|
||||
# forgejo-stack/job-android:latest — same JDK 21 + Android SDK 35
|
||||
# toolchain, but based on the public node:22-bookworm-slim image so it
|
||||
# works on any host with Docker.
|
||||
# Mirrors the steps in .github/workflows/build-android.yml. The Dockerfile at
|
||||
# docker/android-builder/Dockerfile is the local-dev counterpart to
|
||||
# forgejo-stack/job-android:latest — same JDK 21 + Android SDK 35 toolchain,
|
||||
# but based on the public node:22-bookworm-slim image so it works on any host
|
||||
# with Docker.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/build-apk-local.sh # build APK
|
||||
# ./scripts/build-apk-local.sh # debug APK (default)
|
||||
# ./scripts/build-apk-local.sh --release # signed release APK
|
||||
# ./scripts/build-apk-local.sh --rebuild # force image rebuild
|
||||
# ./scripts/build-apk-local.sh --clean # remove cache dirs and exit
|
||||
#
|
||||
# Output:
|
||||
# android/app/build/outputs/apk/debug/app-debug.apk
|
||||
# debug: android/app/build/outputs/apk/debug/app-debug.apk
|
||||
# release: android/app/build/outputs/apk/release/app-release.apk
|
||||
#
|
||||
# Release signing reads four env vars from the shell (or .env at repo root):
|
||||
# METASCRUB_KEYSTORE_FILE — absolute path to the .jks file
|
||||
# METASCRUB_KEYSTORE_ALIAS — key alias inside the keystore
|
||||
# METASCRUB_KEYSTORE_PASSWORD — store password
|
||||
# METASCRUB_KEY_PASSWORD — key password
|
||||
# See docs/android-signing.md for the keystore ceremony. `cp .env.sample .env`
|
||||
# is the maintainer's starting point.
|
||||
#
|
||||
# Caches (gitignored, repo-local):
|
||||
# .docker-cache/gradle Gradle dep cache (~/.gradle inside container)
|
||||
|
|
@ -25,6 +35,10 @@ set -euo pipefail
|
|||
IMAGE_TAG="metascrub-android-builder:local"
|
||||
DOCKERFILE_DIR="docker/android-builder"
|
||||
|
||||
# Fixed in-container keystore mount point. Deliberately outside /workspace so
|
||||
# it cannot leak into git status, the gradle build output, or `cap sync`.
|
||||
CONTAINER_KEYSTORE_PATH="/keystore/release.jks"
|
||||
|
||||
# Resolve repo root from the script location so the script works from any cwd.
|
||||
REPO_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
|
@ -66,7 +80,82 @@ cmd_build_image() {
|
|||
docker build --network host -t "$IMAGE_TAG" "$DOCKERFILE_DIR"
|
||||
}
|
||||
|
||||
# Source .env at the repo root if it exists. Only used by --release because
|
||||
# debug builds need no secrets; loading .env unconditionally would surprise
|
||||
# users who keep unrelated overrides there.
|
||||
cmd_load_env() {
|
||||
local env_file="$REPO_ROOT/.env"
|
||||
if [[ -f "$env_file" ]]; then
|
||||
log "Loading $env_file"
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
source "$env_file"
|
||||
set +a
|
||||
fi
|
||||
}
|
||||
|
||||
# Validate the four release-signing env vars are set and the keystore exists.
|
||||
# Refuses to fall back to an unsigned build — silent fallback would let users
|
||||
# ship an APK that fails Play Protect or signature-update checks without ever
|
||||
# noticing during the build.
|
||||
cmd_check_release_env() {
|
||||
local missing=()
|
||||
for var in METASCRUB_KEYSTORE_FILE METASCRUB_KEYSTORE_ALIAS \
|
||||
METASCRUB_KEYSTORE_PASSWORD METASCRUB_KEY_PASSWORD; do
|
||||
if [[ -z "${!var:-}" ]]; then
|
||||
missing+=("$var")
|
||||
fi
|
||||
done
|
||||
if [[ "${#missing[@]}" -gt 0 ]]; then
|
||||
err "Missing required env vars for --release:"
|
||||
for var in "${missing[@]}"; do
|
||||
err " - $var"
|
||||
done
|
||||
err "Set them in your shell or copy .env.sample to .env and edit."
|
||||
err "See docs/android-signing.md for the keystore ceremony."
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -f "$METASCRUB_KEYSTORE_FILE" ]]; then
|
||||
err "METASCRUB_KEYSTORE_FILE does not exist: $METASCRUB_KEYSTORE_FILE"
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -r "$METASCRUB_KEYSTORE_FILE" ]]; then
|
||||
err "METASCRUB_KEYSTORE_FILE is not readable: $METASCRUB_KEYSTORE_FILE"
|
||||
exit 1
|
||||
fi
|
||||
# Docker's --env-file parser is line-delimited; a newline embedded in any
|
||||
# value (alias or password) would split the line and silently pass wrong
|
||||
# credentials to Gradle. Password managers don't produce newlines, but a
|
||||
# .env file mis-edit could — catch it here with an actionable message
|
||||
# instead of a cryptic Gradle signing failure later.
|
||||
for var in METASCRUB_KEYSTORE_ALIAS METASCRUB_KEYSTORE_PASSWORD METASCRUB_KEY_PASSWORD; do
|
||||
if [[ "${!var}" == *$'\n'* ]]; then
|
||||
err "$var contains a newline character — this would corrupt the docker --env-file."
|
||||
err "Re-generate the value without embedded newlines, or re-save .env in an editor that doesn't wrap long lines."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
# Docker bind-mount syntax uses ':' as the separator. A keystore path
|
||||
# containing ':' (legal on Linux but rare) would parse wrong and either
|
||||
# fail or — worse — silently mount something unexpected.
|
||||
if [[ "$METASCRUB_KEYSTORE_FILE" == *:* ]]; then
|
||||
err "METASCRUB_KEYSTORE_FILE must not contain ':' (breaks the docker bind mount)."
|
||||
err "Move the keystore to a path without ':'."
|
||||
exit 1
|
||||
fi
|
||||
# Warn (don't block) if the keystore is more permissive than 0600 — on a
|
||||
# shared machine, world-readable signing material is a privacy hazard.
|
||||
# Phase 0 ceremony in docs/android-signing.md chmods 600; this catches drift.
|
||||
local mode
|
||||
mode="$(stat -c %a "$METASCRUB_KEYSTORE_FILE" 2>/dev/null || stat -f %Lp "$METASCRUB_KEYSTORE_FILE" 2>/dev/null || echo "")"
|
||||
if [[ -n "$mode" && "$mode" != "600" && "$mode" != "400" ]]; then
|
||||
err "warn: $METASCRUB_KEYSTORE_FILE has mode $mode; expected 0600. Run: chmod 600 \"\$METASCRUB_KEYSTORE_FILE\""
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_run_build() {
|
||||
local build_type="${1:-debug}"
|
||||
|
||||
mkdir -p "$REPO_ROOT/.docker-cache/gradle" \
|
||||
"$REPO_ROOT/.docker-cache/yarn" \
|
||||
"$REPO_ROOT/.docker-cache/home"
|
||||
|
|
@ -75,7 +164,59 @@ cmd_run_build() {
|
|||
host_uid="$(id -u)"
|
||||
host_gid="$(id -g)"
|
||||
|
||||
log "Running build container (uid=$host_uid gid=$host_gid)..."
|
||||
# Assemble docker run args incrementally so the release path can layer in
|
||||
# the keystore mount + env vars without duplicating the rest.
|
||||
local -a docker_args=(
|
||||
--rm
|
||||
--network host
|
||||
--user "$host_uid:$host_gid"
|
||||
-v "$REPO_ROOT:/workspace"
|
||||
-v "$REPO_ROOT/.docker-cache/gradle:/cache/gradle"
|
||||
-v "$REPO_ROOT/.docker-cache/yarn:/cache/yarn"
|
||||
-v "$REPO_ROOT/.docker-cache/home:/home/builder"
|
||||
-e HOME=/home/builder
|
||||
-e GRADLE_USER_HOME=/cache/gradle
|
||||
-e YARN_CACHE_FOLDER=/cache/yarn
|
||||
-w /workspace
|
||||
)
|
||||
|
||||
local gradle_task env_file=""
|
||||
if [[ "$build_type" == "release" ]]; then
|
||||
gradle_task="assembleRelease"
|
||||
# Secrets go via --env-file, not -e: `ps auxe` and `docker inspect`
|
||||
# both surface `-e VAR=value` for the lifetime of the container; an
|
||||
# env-file path is opaque. The file is mode 0600 (mktemp default) and
|
||||
# shredded on exit by the trap below. INT/TERM are listed alongside
|
||||
# EXIT because modern bash does fire EXIT on Ctrl+C in practice, but
|
||||
# explicit signal traps make the cleanup contract independent of
|
||||
# shell-version behaviour for this secrets-bearing file.
|
||||
env_file="$(mktemp -t metascrub-env.XXXXXX)"
|
||||
chmod 600 "$env_file"
|
||||
# shellcheck disable=SC2064
|
||||
trap "shred -u '$env_file' 2>/dev/null || rm -f '$env_file'" EXIT INT TERM
|
||||
# `cat <<EOF` writes the values literally — no shell expansion of the
|
||||
# values themselves; docker's --env-file parser treats everything after
|
||||
# the first '=' as the value (no quote stripping). Passwords containing
|
||||
# '$', '`', or quotes survive intact.
|
||||
{
|
||||
printf 'METASCRUB_KEYSTORE_ALIAS=%s\n' "$METASCRUB_KEYSTORE_ALIAS"
|
||||
printf 'METASCRUB_KEYSTORE_PASSWORD=%s\n' "$METASCRUB_KEYSTORE_PASSWORD"
|
||||
printf 'METASCRUB_KEY_PASSWORD=%s\n' "$METASCRUB_KEY_PASSWORD"
|
||||
} > "$env_file"
|
||||
# Keystore mounted read-only at a stable in-container path; the
|
||||
# in-container env var the gradle file reads is overridden to that
|
||||
# path so the host path never leaks into the build. The keystore
|
||||
# path is not secret (just a file location), so -e is fine here.
|
||||
docker_args+=(
|
||||
-v "$METASCRUB_KEYSTORE_FILE:$CONTAINER_KEYSTORE_PATH:ro"
|
||||
-e "METASCRUB_KEYSTORE_FILE=$CONTAINER_KEYSTORE_PATH"
|
||||
--env-file "$env_file"
|
||||
)
|
||||
else
|
||||
gradle_task="assembleDebug"
|
||||
fi
|
||||
|
||||
log "Running build container (uid=$host_uid gid=$host_gid, task=$gradle_task)..."
|
||||
|
||||
# Container shell script: mirror the workflow steps verbatim.
|
||||
#
|
||||
|
|
@ -84,18 +225,11 @@ cmd_run_build() {
|
|||
# --stacktrace: surfaces Gradle internal errors on failure. Matches the workflow.
|
||||
# --network host: same rationale as in cmd_build_image — Gradle and yarn
|
||||
# both pull large dependencies (~150 MB) over HTTPS during the build.
|
||||
docker run --rm \
|
||||
--network host \
|
||||
--user "$host_uid:$host_gid" \
|
||||
-v "$REPO_ROOT:/workspace" \
|
||||
-v "$REPO_ROOT/.docker-cache/gradle:/cache/gradle" \
|
||||
-v "$REPO_ROOT/.docker-cache/yarn:/cache/yarn" \
|
||||
-v "$REPO_ROOT/.docker-cache/home:/home/builder" \
|
||||
-e HOME=/home/builder \
|
||||
-e GRADLE_USER_HOME=/cache/gradle \
|
||||
-e YARN_CACHE_FOLDER=/cache/yarn \
|
||||
-w /workspace \
|
||||
"$IMAGE_TAG" \
|
||||
#
|
||||
# Heredoc is single-quoted (no host-side expansion). $gradle_task is
|
||||
# passed as $1 to the inner bash so the script body stays literal —
|
||||
# anything added later cannot accidentally get host-expanded.
|
||||
docker run "${docker_args[@]}" "$IMAGE_TAG" \
|
||||
bash -eu -o pipefail -c '
|
||||
echo "==> yarn install"
|
||||
yarn install --frozen-lockfile
|
||||
|
|
@ -106,17 +240,26 @@ cmd_run_build() {
|
|||
echo "==> npx cap sync android"
|
||||
npx cap sync android
|
||||
|
||||
echo "==> ./gradlew assembleDebug"
|
||||
echo "==> ./gradlew $1"
|
||||
cd android
|
||||
./gradlew assembleDebug --no-daemon --stacktrace
|
||||
'
|
||||
./gradlew "$1" --no-daemon --stacktrace
|
||||
' build-apk-local "$gradle_task"
|
||||
}
|
||||
|
||||
cmd_report() {
|
||||
local apk="android/app/build/outputs/apk/debug/app-debug.apk"
|
||||
local build_type="${1:-debug}"
|
||||
local apk
|
||||
if [[ "$build_type" == "release" ]]; then
|
||||
apk="android/app/build/outputs/apk/release/app-release.apk"
|
||||
else
|
||||
apk="android/app/build/outputs/apk/debug/app-debug.apk"
|
||||
fi
|
||||
if [[ -f "$apk" ]]; then
|
||||
log "APK built: $apk ($(du -h "$apk" | cut -f1))"
|
||||
file "$apk" 2>/dev/null || true
|
||||
if [[ "$build_type" == "release" ]]; then
|
||||
log "Verify signature with: apksigner verify --print-certs $apk"
|
||||
fi
|
||||
else
|
||||
err "Expected APK not found at $apk"
|
||||
exit 1
|
||||
|
|
@ -125,6 +268,7 @@ cmd_report() {
|
|||
|
||||
main() {
|
||||
local rebuild=0
|
||||
local build_type="debug"
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--clean)
|
||||
|
|
@ -134,6 +278,9 @@ main() {
|
|||
--rebuild)
|
||||
rebuild=1
|
||||
;;
|
||||
--release)
|
||||
build_type="release"
|
||||
;;
|
||||
-h | --help)
|
||||
# Print the leading comment block (drops the shebang, stops at
|
||||
# the first non-comment line). Sentinel-driven so growing the
|
||||
|
|
@ -148,14 +295,19 @@ main() {
|
|||
esac
|
||||
done
|
||||
|
||||
if [[ "$build_type" == "release" ]]; then
|
||||
cmd_load_env
|
||||
cmd_check_release_env
|
||||
fi
|
||||
|
||||
cmd_check_docker
|
||||
if [[ "$rebuild" -eq 1 ]]; then
|
||||
cmd_build_image --force
|
||||
else
|
||||
cmd_build_image
|
||||
fi
|
||||
cmd_run_build
|
||||
cmd_report
|
||||
cmd_run_build "$build_type"
|
||||
cmd_report "$build_type"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue