security: encrypt credentials, caches, and harden lock screen
Auto Tag on Version Change / check-version (push) Successful in 6s

This commit is contained in:
2026-05-15 18:35:14 +05:00
parent 106004421e
commit fc031f1f2a
20 changed files with 506 additions and 149 deletions
@@ -5,14 +5,23 @@ import android.os.Bundle
import android.util.Base64
import android.view.View
import android.widget.LinearLayout
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AppCompatActivity
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import com.google.android.material.button.MaterialButton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import sh.sar.basedbank.databinding.ActivityLockBinding
import sh.sar.basedbank.ui.home.HomeActivity
import sh.sar.basedbank.util.CredentialStore
import java.security.MessageDigest
import java.security.SecureRandom
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.PBEKeySpec
class LockActivity : AppCompatActivity() {
@@ -22,6 +31,15 @@ class LockActivity : AppCompatActivity() {
private lateinit var salt: String
private lateinit var storedHash: String
private var biometricsEnabled = false
private var isLegacyFormat = false
private var isVerifying = false
private val lockPrefs get() = getSharedPreferences("lock_attempts", MODE_PRIVATE)
companion object {
private const val MAX_ATTEMPTS = 5
private const val LOCKOUT_MS = 30_000L
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -30,10 +48,20 @@ class LockActivity : AppCompatActivity() {
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
method = prefs.getString("security_method", "pin") ?: "pin"
salt = prefs.getString("security_salt", "") ?: ""
storedHash = prefs.getString("security_hash", "") ?: ""
biometricsEnabled = prefs.getBoolean("biometrics_enabled", false)
// Try new encrypted format first; fall back to legacy SHA-256
val stored = CredentialStore(this).loadSecurityHash()
if (stored != null) {
salt = stored.first
storedHash = stored.second
isLegacyFormat = false
} else {
salt = prefs.getString("security_salt", "") ?: ""
storedHash = prefs.getString("security_hash", "") ?: ""
isLegacyFormat = true
}
if (method == "pin") {
binding.viewPin.visibility = View.VISIBLE
buildNumpad()
@@ -48,6 +76,12 @@ class LockActivity : AppCompatActivity() {
}
if (biometricsEnabled) triggerBiometric()
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
moveTaskToBack(true)
}
})
}
private fun buildNumpad() {
@@ -92,6 +126,7 @@ class LockActivity : AppCompatActivity() {
}
private fun handleKey(key: String) {
if (isVerifying) return
when (key) {
"" -> if (pinDigits.isNotEmpty()) { pinDigits.removeLast(); updateDots() }
"" -> if (pinDigits.size >= 4) verifyPin()
@@ -105,13 +140,21 @@ class LockActivity : AppCompatActivity() {
}
private fun verifyPin() {
if (checkAndShowLockout()) return
val entered = pinDigits.joinToString("")
if (verify(entered)) {
proceed()
} else {
binding.tvLockPinDots.text = getString(R.string.unlock_failed)
pinDigits.clear()
binding.root.postDelayed({ updateDots() }, 1200)
pinDigits.clear()
updateDots()
isVerifying = true
lifecycleScope.launch {
val ok = withContext(Dispatchers.Default) { verify(entered) }
isVerifying = false
if (ok) {
migrateIfNeeded(entered)
resetFailures()
proceed()
} else {
showFailure()
}
}
}
@@ -121,18 +164,82 @@ class LockActivity : AppCompatActivity() {
binding.tvPatternHint.text = getString(R.string.pattern_min_dots)
return
}
if (verify(pattern.joinToString(""))) {
proceed()
} else {
if (checkAndShowLockout()) {
binding.lockPatternView.showError()
binding.tvPatternHint.text = getString(R.string.unlock_failed)
binding.root.postDelayed({ binding.tvPatternHint.text = "" }, 1500)
return
}
val entered = pattern.joinToString("")
isVerifying = true
lifecycleScope.launch {
val ok = withContext(Dispatchers.Default) { verify(entered) }
isVerifying = false
if (ok) {
migrateIfNeeded(entered)
resetFailures()
proceed()
} else {
binding.lockPatternView.showError()
val msg = failureMessage()
binding.tvPatternHint.text = msg
binding.root.postDelayed({ binding.tvPatternHint.text = "" }, 1500)
}
}
}
/** Returns true and shows the lockout message if currently locked out. */
private fun checkAndShowLockout(): Boolean {
val remaining = lockoutRemainingMs()
if (remaining <= 0) return false
val secs = ((remaining + 999L) / 1000L).toInt()
val msg = getString(R.string.unlock_locked_out, secs)
binding.tvLockPinDots.text = msg
binding.root.postDelayed({ updateDots() }, remaining)
return true
}
private fun showFailure() {
val msg = failureMessage()
binding.tvLockPinDots.text = msg
binding.root.postDelayed({ updateDots() }, 1200)
}
private fun failureMessage(): String {
val fails = incrementFailures()
val left = MAX_ATTEMPTS - fails
return if (left <= 0) {
val secs = (LOCKOUT_MS / 1000L).toInt()
getString(R.string.unlock_locked_out, secs)
} else {
getString(R.string.unlock_attempts_remaining, left)
}
}
private fun verify(input: String): Boolean {
val hash = sha256(salt + input)
return hash == storedHash
if (storedHash.isBlank()) return false
return if (isLegacyFormat) {
sha256Legacy(salt + input) == storedHash
} else {
val saltBytes = Base64.decode(salt, Base64.NO_WRAP)
pbkdf2(input, saltBytes) == storedHash
}
}
/**
* On the first successful unlock after legacy SHA-256 format is detected,
* transparently migrate to PBKDF2 + CredentialStore.
*/
private fun migrateIfNeeded(input: String) {
if (!isLegacyFormat) return
try {
val newSalt = ByteArray(16).also { SecureRandom().nextBytes(it) }
val newHash = pbkdf2(input, newSalt)
val saltB64 = Base64.encodeToString(newSalt, Base64.NO_WRAP)
CredentialStore(this).saveSecurityHash(saltB64, newHash)
// Remove legacy plaintext fields
getSharedPreferences("prefs", MODE_PRIVATE).edit()
.remove("security_salt").remove("security_hash").apply()
isLegacyFormat = false
} catch (_: Exception) { /* migration will retry next unlock */ }
}
private fun triggerBiometric() {
@@ -145,6 +252,7 @@ class LockActivity : AppCompatActivity() {
ContextCompat.getMainExecutor(this),
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
resetFailures()
proceed()
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
@@ -166,11 +274,43 @@ class LockActivity : AppCompatActivity() {
finish()
}
private fun sha256(input: String) = MessageDigest.getInstance("SHA-256")
// ── Brute-force tracking ──────────────────────────────────────────────────
private fun incrementFailures(): Int {
val fails = lockPrefs.getInt("fail_count", 0) + 1
lockPrefs.edit()
.putInt("fail_count", fails)
.putLong("last_fail_time", System.currentTimeMillis())
.apply()
return fails
}
private fun resetFailures() {
lockPrefs.edit().remove("fail_count").remove("last_fail_time").apply()
}
private fun lockoutRemainingMs(): Long {
val fails = lockPrefs.getInt("fail_count", 0)
if (fails < MAX_ATTEMPTS) return 0L
val lastFail = lockPrefs.getLong("last_fail_time", 0L)
return maxOf(0L, LOCKOUT_MS - (System.currentTimeMillis() - lastFail))
}
// ── Crypto ────────────────────────────────────────────────────────────────
private fun pbkdf2(input: String, salt: ByteArray): String {
val spec = PBEKeySpec(input.toCharArray(), salt, 100_000, 256)
return try {
val hash = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(spec).encoded
Base64.encodeToString(hash, Base64.NO_WRAP)
} finally {
spec.clearPassword()
}
}
/** Legacy: raw SHA-256(salt + input) — only used for migration path. */
private fun sha256Legacy(input: String) = MessageDigest.getInstance("SHA-256")
.digest(input.toByteArray()).joinToString("") { "%02x".format(it) }
override fun onBackPressed() {
// Lock screen cannot be dismissed
moveTaskToBack(true)
}
}