Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fa7144a7b | ||
|
|
b5286ca6db | ||
|
|
ad32e731fb | ||
|
|
70ac1cd90e | ||
|
|
62d7c929fe | ||
|
|
9f76a0a4ee |
@@ -23,29 +23,6 @@ jobs:
|
|||||||
- name: Set up Android SDK
|
- name: Set up Android SDK
|
||||||
uses: android-actions/setup-android@v3
|
uses: android-actions/setup-android@v3
|
||||||
|
|
||||||
# Signing material is injected at build time from Gitea Actions secrets — never
|
|
||||||
# committed. build.gradle.kts reads app/keystore.properties, so recreate it here.
|
|
||||||
- name: Decode signing keystore
|
|
||||||
run: |
|
|
||||||
if [ -z "${{ secrets.KEYSTORE_BASE64 }}" ]; then
|
|
||||||
echo "::error::KEYSTORE_BASE64 secret is not set — cannot build a signed release."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > app/homelab-mfa-release.keystore
|
|
||||||
|
|
||||||
- name: Write signing credentials
|
|
||||||
run: |
|
|
||||||
if [ -z "${{ secrets.KEYSTORE_PASSWORD }}" ] || [ -z "${{ secrets.KEY_PASSWORD }}" ]; then
|
|
||||||
echo "::error::KEYSTORE_PASSWORD / KEY_PASSWORD secrets not set — cannot sign release."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
{
|
|
||||||
echo "storeFile=homelab-mfa-release.keystore"
|
|
||||||
echo "storePassword=${{ secrets.KEYSTORE_PASSWORD }}"
|
|
||||||
echo "keyPassword=${{ secrets.KEY_PASSWORD }}"
|
|
||||||
echo "keyAlias=${{ secrets.KEY_ALIAS || 'homelab-mfa' }}"
|
|
||||||
} > app/keystore.properties
|
|
||||||
|
|
||||||
- name: Build release APK
|
- name: Build release APK
|
||||||
run: |
|
run: |
|
||||||
chmod +x ./gradlew
|
chmod +x ./gradlew
|
||||||
|
|||||||
@@ -11,5 +11,3 @@ captures/
|
|||||||
.cxx/
|
.cxx/
|
||||||
*.keystore
|
*.keystore
|
||||||
*.jks
|
*.jks
|
||||||
keystore.properties
|
|
||||||
app/keystore.properties
|
|
||||||
|
|||||||
+8
-22
@@ -1,16 +1,8 @@
|
|||||||
import java.util.Properties
|
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("org.jetbrains.kotlin.android")
|
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 {
|
android {
|
||||||
namespace = "me.khodak.mfa"
|
namespace = "me.khodak.mfa"
|
||||||
compileSdk = 34
|
compileSdk = 34
|
||||||
@@ -19,19 +11,16 @@ android {
|
|||||||
applicationId = "me.khodak.mfa"
|
applicationId = "me.khodak.mfa"
|
||||||
minSdk = 28
|
minSdk = 28
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 4
|
versionCode = 3
|
||||||
versionName = "1.3"
|
versionName = "1.2"
|
||||||
}
|
}
|
||||||
|
|
||||||
val hasReleaseSigning = keystoreProps.getProperty("storePassword") != null
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
if (hasReleaseSigning) {
|
create("release") {
|
||||||
create("release") {
|
storeFile = file("homelab-mfa-release.keystore")
|
||||||
storeFile = file(keystoreProps.getProperty("storeFile", "homelab-mfa-release.keystore"))
|
storePassword = "HomelabMFA2026!"
|
||||||
storePassword = keystoreProps.getProperty("storePassword")
|
keyAlias = "homelab-mfa"
|
||||||
keyAlias = keystoreProps.getProperty("keyAlias")
|
keyPassword = "HomelabMFA2026!"
|
||||||
keyPassword = keystoreProps.getProperty("keyPassword")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,10 +29,7 @@ android {
|
|||||||
isMinifyEnabled = true
|
isMinifyEnabled = true
|
||||||
isShrinkResources = true
|
isShrinkResources = true
|
||||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||||
// Only attach the release signing config when keystore.properties is present.
|
signingConfig = signingConfigs.getByName("release")
|
||||||
// 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 {
|
debug {
|
||||||
applicationIdSuffix = ".debug"
|
applicationIdSuffix = ".debug"
|
||||||
|
|||||||
Binary file not shown.
@@ -85,14 +85,6 @@ class MainActivity : AppCompatActivity() {
|
|||||||
return
|
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.
|
// action == "approve" — show the request with its context and let the user decide.
|
||||||
showApprovalRequest(uri, token)
|
showApprovalRequest(uri, token)
|
||||||
}
|
}
|
||||||
@@ -233,8 +225,6 @@ class MainActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun sendDecision(token: String, action: String) {
|
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 topic = if (action == "approve") "mfa-approve" else "mfa-deny"
|
||||||
val body = "$action:$token"
|
val body = "$action:$token"
|
||||||
showResult(null, "Sending...", "")
|
showResult(null, "Sending...", "")
|
||||||
@@ -293,34 +283,4 @@ class MainActivity : AppCompatActivity() {
|
|||||||
finish()
|
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