8 Commits
Author SHA1 Message Date
amir c3c61cd3fd release: v1.3 (versionCode 4); fix CI to sign from keystore.properties secrets
Release APK / build (push) Successful in 2m4s
2026-07-10 10:00:55 +00:00
amir 093ddb7fce 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
2026-07-10 09:31:13 +00:00
amir 336893ffa6 feat: login-context approval screen, one-tap Deny, stale-request expiry
- Approve now opens an in-app review screen showing app/user/IP/geo of the
  request (optional deep-link params: app, user, ip, geo, ts) before biometrics
- Add a Deny button on the approval screen (no biometric needed to reject)
- Reject requests older than 2 min via the ts param (replay safety net)
- README: context params table, new Features bullets, approve-context screenshot
- Bump to v1.2 (versionCode 3)
2026-06-21 02:44:27 +00:00
amir c40ffa7c62 docs: add 'login approved' screenshot to README 2026-06-19 13:26:00 +00:00
amir c4c6ddbb05 ci: add Gitea Actions release workflow (build, sign, attach APK, auto-notes) 2026-06-19 09:10:14 +00:00
amir 8a948936e5 docs: add README + setup screenshot; add .gitignore, untrack build/gradle artifacts 2026-06-19 09:08:35 +00:00
amir 466b337a9e v1.1: launcher icon, setup screen, biometric enrollment check 2026-06-04 13:03:12 +00:00
amir 9cc3c077f6 HomelabMFA v1.0 — biometric gate for ntfy Push MFA 2026-06-04 12:17:50 +00:00
5 changed files with 87 additions and 8 deletions
+23
View File
@@ -23,6 +23,29 @@ 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
+2
View File
@@ -11,3 +11,5 @@ captures/
.cxx/ .cxx/
*.keystore *.keystore
*.jks *.jks
keystore.properties
app/keystore.properties
+21 -7
View File
@@ -1,8 +1,16 @@
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
@@ -11,16 +19,19 @@ android {
applicationId = "me.khodak.mfa" applicationId = "me.khodak.mfa"
minSdk = 28 minSdk = 28
targetSdk = 34 targetSdk = 34
versionCode = 3 versionCode = 4
versionName = "1.2" versionName = "1.3"
} }
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")
}
} }
} }
@@ -29,7 +40,10 @@ 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")
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 { debug {
applicationIdSuffix = ".debug" applicationIdSuffix = ".debug"
Binary file not shown.
@@ -85,6 +85,14 @@ 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)
} }
@@ -225,6 +233,8 @@ 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...", "")
@@ -283,4 +293,34 @@ 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 }
} }