security: move signing key out of source (rotated), add local replay guard
- Remove committed release keystore + hardcoded signing password - Load signing config from gitignored app/keystore.properties - Add single-use token guard (fail-open) to block on-device approval replay
This commit is contained in:
+20
-6
@@ -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"
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user