Fix #205: move release keystore passwords to env/.properties file

Release keystore passwords were hardcoded as string literals in
`app/build.gradle.kts`, public to anyone who forks or clones the repo.
Combined with a leaked keystore file (from a backup or CI compromise),
that collapses two factors into one and lets an attacker sign arbitrary
APKs as `com.rousecontext`.

Release passwords are now resolved from, in order of precedence:
  1. `ROUSE_RELEASE_STORE_PASSWORD` / `ROUSE_RELEASE_KEY_PASSWORD` env vars
  2. `storePassword` / `keyPassword` keys in `.signing/release.properties`

Both are absent from the tracked repo. A new
`.signing/release.properties.example` template is committed so new clones
see the expected format, and `.gitignore` now allows that one file while
still ignoring the real `release.properties`, keystores, and any other
signing artifacts.

Resolution is lazy: non-release tasks (lint, unit tests, debug builds)
do not require the env var or .properties file. A `taskGraph.whenReady`
check produces a clear error message when a release-signing task is
actually requested without credentials, instead of failing obscurely
inside the Android signing task.

Debug keystore password stays hardcoded on purpose — the debug key is
not a secret.

Docs updated in `docs/self-hosting.md`. Keystore files themselves are
untouched (regenerating would force a user uninstall and burn ACME cert
quota).
This commit is contained in:
Jason Monk 2026-04-17 14:36:01 -04:00
parent 05775a0d33
commit f439d5f5fc
4 changed files with 80 additions and 5 deletions

6
.gitignore vendored
View file

@ -11,5 +11,7 @@ local.properties
# google-services.json is checked in — API keys are not secret (embedded in APK)
relay/target/
.claude/worktrees/
.signing/
.signing
# Signing keystores and password files are private per clone.
# Only the *.example templates are tracked.
.signing/*
!.signing/release.properties.example

View file

@ -0,0 +1,10 @@
# Template for .signing/release.properties — copy this file to
# .signing/release.properties and fill in the real passwords locally.
#
# The real file is gitignored (see .gitignore). This .example file is
# tracked so new clones know the expected format.
#
# Alternative: set ROUSE_RELEASE_STORE_PASSWORD and ROUSE_RELEASE_KEY_PASSWORD
# environment variables instead. Env vars take precedence over this file.
storePassword=your-release-store-password
keyPassword=your-release-key-password

View file

@ -1,3 +1,5 @@
import java.util.Properties
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
@ -7,16 +9,36 @@ plugins {
alias(libs.plugins.google.services)
}
// Loads a value from `.signing/release.properties` if that file exists.
// The file is gitignored; see `.signing/release.properties.example` for format.
val releaseSigningProps: Properties? = run {
val f = rootProject.file(".signing/release.properties")
if (f.exists()) {
Properties().apply { f.inputStream().use { load(it) } }
} else {
null
}
}
fun loadFromSigningProperties(key: String): String? = releaseSigningProps?.getProperty(key)
android {
namespace = "com.rousecontext.app"
compileSdk = 36
// Release passwords are resolved lazily so non-release tasks (lint, tests,
// debug builds) don't require the env vars or .properties file.
val releaseStorePassword = System.getenv("ROUSE_RELEASE_STORE_PASSWORD")
?: loadFromSigningProperties("storePassword")
val releaseKeyPassword = System.getenv("ROUSE_RELEASE_KEY_PASSWORD")
?: loadFromSigningProperties("keyPassword")
signingConfigs {
create("release") {
storeFile = file("${rootProject.projectDir}/.signing/release.keystore")
storePassword = "rousecontext-release"
storePassword = releaseStorePassword
keyAlias = "release"
keyPassword = "rousecontext-release"
keyPassword = releaseKeyPassword
}
getByName("debug") {
storeFile = file("${rootProject.projectDir}/.signing/debug.keystore")
@ -26,6 +48,28 @@ android {
}
}
// Fail fast on release builds if passwords are missing, with a clear message.
gradle.taskGraph.whenReady {
val needsReleaseSigning = allTasks.any { task ->
if (task.project != project || !task.name.contains("Release")) return@any false
task.name.startsWith("package") ||
task.name.startsWith("assemble") ||
task.name.startsWith("bundle")
}
if (needsReleaseSigning) {
check(releaseStorePassword != null) {
"Release signing requires ROUSE_RELEASE_STORE_PASSWORD env var " +
"or storePassword in .signing/release.properties " +
"(see .signing/release.properties.example)."
}
check(releaseKeyPassword != null) {
"Release signing requires ROUSE_RELEASE_KEY_PASSWORD env var " +
"or keyPassword in .signing/release.properties " +
"(see .signing/release.properties.example)."
}
}
}
defaultConfig {
applicationId = "com.rousecontext"
minSdk = 24

View file

@ -63,7 +63,26 @@ keytool -genkeypair -v \
-dname "CN=Release, O=YourOrg, C=US"
```
Update `app/build.gradle.kts` signing config blocks to match your passwords, or (better) read them from environment variables.
Release passwords are read at build time from either environment variables or
a local properties file — they are **not** committed to the repo. Choose one:
Environment variables (preferred for CI):
```bash
export ROUSE_RELEASE_STORE_PASSWORD=YOUR_RELEASE_PASS
export ROUSE_RELEASE_KEY_PASSWORD=YOUR_RELEASE_PASS
```
Or a local properties file (preferred for interactive dev — survives shells):
```bash
cp .signing/release.properties.example .signing/release.properties
# edit .signing/release.properties and set storePassword / keyPassword
```
`.signing/release.properties` is gitignored. Env vars take precedence over the
file. Debug signing still uses a fixed password in `app/build.gradle.kts`
because the debug keystore is not a secret.
**Back these up off-machine.** Regenerating a keystore forces users to uninstall (app-data loss) and burns ACME cert quota on re-registration.