diff --git a/.gitignore b/.gitignore index 43646e3..d359d9d 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ captures/ .cxx/ *.keystore *.jks +keystore.properties +app/keystore.properties diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 29a954d..28ba754 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,8 +1,16 @@ +import java.util.Properties + plugins { id("com.android.application") id("org.jetbrains.kotlin.android") } +// Signing secrets live in app/keystore.properties (gitignored), never in source control. +val keystorePropsFile = rootProject.file("app/keystore.properties") +val keystoreProps = Properties().apply { + if (keystorePropsFile.exists()) keystorePropsFile.inputStream().use { load(it) } +} + android { namespace = "me.khodak.mfa" compileSdk = 34 @@ -15,12 +23,15 @@ android { versionName = "1.2" } + val hasReleaseSigning = keystoreProps.getProperty("storePassword") != null signingConfigs { - create("release") { - storeFile = file("homelab-mfa-release.keystore") - storePassword = "HomelabMFA2026!" - keyAlias = "homelab-mfa" - keyPassword = "HomelabMFA2026!" + if (hasReleaseSigning) { + create("release") { + storeFile = file(keystoreProps.getProperty("storeFile", "homelab-mfa-release.keystore")) + storePassword = keystoreProps.getProperty("storePassword") + keyAlias = keystoreProps.getProperty("keyAlias") + keyPassword = keystoreProps.getProperty("keyPassword") + } } } @@ -29,7 +40,10 @@ android { isMinifyEnabled = true isShrinkResources = true proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") - signingConfig = signingConfigs.getByName("release") + // Only attach the release signing config when keystore.properties is present. + // Without it (e.g. a fresh clone), the release build stays unsigned rather than + // failing the whole configuration. + if (hasReleaseSigning) signingConfig = signingConfigs.getByName("release") } debug { applicationIdSuffix = ".debug" diff --git a/app/src/main/kotlin/me/khodak/mfa/MainActivity.kt b/app/src/main/kotlin/me/khodak/mfa/MainActivity.kt index eec291f..27a69fb 100644 --- a/app/src/main/kotlin/me/khodak/mfa/MainActivity.kt +++ b/app/src/main/kotlin/me/khodak/mfa/MainActivity.kt @@ -85,6 +85,14 @@ class MainActivity : AppCompatActivity() { return } + // Local single-use guard: the same approval token can't be replayed on this device. + // Defence-in-depth only — real single-use enforcement must live server-side in Authentik. + if (isTokenConsumed(token)) { + showResult(false, "Already handled", "This login request was already approved or denied. Sign in again for a fresh prompt.") + scheduleClose(3000) + return + } + // action == "approve" — show the request with its context and let the user decide. showApprovalRequest(uri, token) } @@ -225,6 +233,8 @@ class MainActivity : AppCompatActivity() { } private fun sendDecision(token: String, action: String) { + // Consume the token on any decision so a replayed deep link can't re-drive it. + markTokenConsumed(token) val topic = if (action == "approve") "mfa-approve" else "mfa-deny" val body = "$action:$token" showResult(null, "Sending...", "") @@ -283,4 +293,34 @@ class MainActivity : AppCompatActivity() { finish() } } + + // ── Local single-use token guard ───────────────────────────────────────────── + // Tokens are stored as SHA-256 hashes (never the raw secret) with a timestamp, and + // pruned after they'd have expired anyway. Every path fails OPEN: if the store is + // unreadable we allow the approval, because locking the user out of their own logins + // is worse than losing this defence-in-depth layer. + + private val consumedPrefs by lazy { getSharedPreferences("mfa_consumed_tokens", MODE_PRIVATE) } + + private fun isTokenConsumed(token: String): Boolean = + try { consumedPrefs.contains(hashToken(token)) } catch (_: Exception) { false } + + private fun markTokenConsumed(token: String) { + try { + val now = System.currentTimeMillis() + val editor = consumedPrefs.edit() + // Prune anything older than twice the max request age — it can't be replayed anyway. + for ((k, v) in consumedPrefs.all) { + if (now - ((v as? Long) ?: 0L) > maxRequestAgeMs * 2) editor.remove(k) + } + editor.putLong(hashToken(token), now).apply() + } catch (_: Exception) { /* fail open */ } + } + + private fun hashToken(token: String): String = + try { + java.security.MessageDigest.getInstance("SHA-256") + .digest(token.toByteArray()) + .joinToString("") { "%02x".format(it) } + } catch (_: Exception) { token } }