security: encrypt credentials, caches, and harden lock screen
Auto Tag on Version Change / check-version (push) Successful in 6s
Auto Tag on Version Change / check-version (push) Successful in 6s
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user