From f439d5f5fc14b07357f24f4d0e4ddf8a7e4a9d9f Mon Sep 17 00:00:00 2001 From: Jason Monk Date: Fri, 17 Apr 2026 14:36:01 -0400 Subject: [PATCH] Fix #205: move release keystore passwords to env/.properties file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .gitignore | 6 ++-- .signing/release.properties.example | 10 ++++++ app/build.gradle.kts | 48 +++++++++++++++++++++++++++-- docs/self-hosting.md | 21 ++++++++++++- 4 files changed, 80 insertions(+), 5 deletions(-) create mode 100644 .signing/release.properties.example diff --git a/.gitignore b/.gitignore index a1cf45f0..3b1d1d2b 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/.signing/release.properties.example b/.signing/release.properties.example new file mode 100644 index 00000000..0b2ac6b2 --- /dev/null +++ b/.signing/release.properties.example @@ -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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c607ad55..edf6c497 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 diff --git a/docs/self-hosting.md b/docs/self-hosting.md index f0af9ef4..91c7d2c5 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -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.