security: encrypt credentials, caches, and harden lock screen
All checks were successful
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

View File

@@ -9,6 +9,7 @@ import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.api.mib.MibLoginFlow
import sh.sar.basedbank.api.mib.MibProfile
import sh.sar.basedbank.api.mib.MibSession
import sh.sar.basedbank.util.CredentialStore
class BasedBankApp : Application() {
@@ -24,7 +25,7 @@ class BasedBankApp : Application() {
val mibMutex = Mutex()
val mibLoginFlow by lazy {
MibLoginFlow(getSharedPreferences("mib_prefs", MODE_PRIVATE)).also { flow ->
MibLoginFlow(CredentialStore(this)).also { flow ->
flow.onSessionRefreshed = { session, profiles ->
mibSession = session
mibProfiles = profiles

View File

@@ -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)
}
}

View File

@@ -69,8 +69,11 @@ class BmlLoginFlow {
val xsrf = xsrfToken() ?: throw Exception("Could not fetch login page")
// Step 2: POST credentials
val loginBody = """{"username":${quote(username)},"password":${quote(password)},"code":""}"""
.toRequestBody("application/json".toMediaType())
val loginBody = JSONObject().apply {
put("username", username)
put("password", password)
put("code", "")
}.toString().toRequestBody("application/json".toMediaType())
val loginResp = client.newCall(
Request.Builder().url("$BASE_URL/web/login").post(loginBody)
.header("X-XSRF-TOKEN", xsrf)
@@ -89,8 +92,10 @@ class BmlLoginFlow {
// Step 4: POST OTP
val otp = Totp.generate(otpSeed)
val twoFaBody = """{"code":${quote(otp)},"channel":"authenticator"}"""
.toRequestBody("application/json".toMediaType())
val twoFaBody = JSONObject().apply {
put("code", otp)
put("channel", "authenticator")
}.toString().toRequestBody("application/json".toMediaType())
val twoFaResp = client.newCall(
Request.Builder().url("$BASE_URL/web/login/2fa").post(twoFaBody)
.header("X-XSRF-TOKEN", xsrf2)
@@ -740,6 +745,4 @@ class BmlLoginFlow {
SecureRandom().nextBytes(bytes)
return bytes.joinToString("") { "%02x".format(it) }
}
private fun quote(s: String) = "\"${s.replace("\\", "\\\\").replace("\"", "\\\"")}\""
}

View File

@@ -1,19 +1,20 @@
package sh.sar.basedbank.api.mib
import okhttp3.FormBody
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.Totp
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import java.security.MessageDigest
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.random.Random
class SessionExpiredException : Exception("MIB session expired")
class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
class MibLoginFlow(private val credentialStore: CredentialStore) {
private val TAG = "MibLoginFlow"
private val BASE_URL = "https://faisanet.mib.com.mv/faisamobilex_smvc/"
/** The active session after a successful login, usable for subsequent WebView requests. */
@@ -31,7 +32,7 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
@Volatile private var storedUsername: String? = null
@Volatile private var storedPasswordHash: String? = null
@Volatile private var storedOtpSeed: String? = null
private var inRelogin = false
private val inRelogin = AtomicBoolean(false)
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
@@ -61,11 +62,10 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
storedPasswordHash = passwordHash
storedOtpSeed = otpSeed
val appId = getOrCreateAppId()
val key1 = prefs.getString("mib_key1_$username", null)
val key2 = prefs.getString("mib_key2_$username", null)
val keys = credentialStore.loadMibKeys()
return if (key1 != null && key2 != null) {
regularLogin(username, passwordHash, appId, key1, key2)
return if (keys != null) {
regularLogin(username, passwordHash, appId, keys.first, keys.second)
} else {
firstTimeRegistration(username, passwordHash, otpSeed, appId)
}
@@ -106,7 +106,7 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
val keyData = otpResp.getJSONArray("data").getJSONObject(0)
val key1 = keyData.getString("key1")
val key2 = keyData.getString("key2")
prefs.edit().putString("mib_key1_$username", key1).putString("mib_key2_$username", key2).apply()
credentialStore.saveMibKeys(key1, key2)
return regularLogin(username, passwordHash, appId, key1, key2)
}
@@ -189,7 +189,7 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
val response = try {
sendRequest(session, data, sfunc)
} catch (e: SessionExpiredException) {
if (inRelogin) throw e
if (inRelogin.get()) throw e
"" // fall through to recovery below
}
@@ -198,22 +198,27 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
(response.trimStart().startsWith("{") &&
JSONObject(response).optString("reasonCode") == "505")
if (isExpired && !inRelogin) {
val u = storedUsername ?: throw SessionExpiredException()
val ph = storedPasswordHash ?: throw SessionExpiredException()
val os = storedOtpSeed ?: throw SessionExpiredException()
inRelogin = true
try { login(u, ph, os) } finally { inRelogin = false }
val newSession = lastSession ?: throw SessionExpiredException()
onSessionRefreshed?.invoke(newSession, lastProfiles)
// Refresh nonce/xxid in the payload for the retry
data.put("nonce", MibNonce.generate(newSession.nonceGenerator))
data.put("appId", newSession.appId)
data.put("sodium", MibNonce.randomSodium())
data.put("xxid", newSession.xxid)
val retryResponse = sendRequest(newSession, data, sfunc)
if (retryResponse.trimStart().startsWith("{")) return JSONObject(retryResponse)
return MibCrypto.decrypt(retryResponse, newSession.sessionKey)
if (isExpired && inRelogin.compareAndSet(false, true)) {
try {
val u = storedUsername ?: throw SessionExpiredException()
val ph = storedPasswordHash ?: throw SessionExpiredException()
val os = storedOtpSeed ?: throw SessionExpiredException()
login(u, ph, os)
val newSession = lastSession ?: throw SessionExpiredException()
onSessionRefreshed?.invoke(newSession, lastProfiles)
// Refresh nonce/xxid in the payload for the retry
data.put("nonce", MibNonce.generate(newSession.nonceGenerator))
data.put("appId", newSession.appId)
data.put("sodium", MibNonce.randomSodium())
data.put("xxid", newSession.xxid)
val retryResponse = sendRequest(newSession, data, sfunc)
if (retryResponse.trimStart().startsWith("{")) return JSONObject(retryResponse)
return MibCrypto.decrypt(retryResponse, newSession.sessionKey)
} finally {
inRelogin.set(false)
}
} else if (isExpired) {
throw SessionExpiredException()
}
if (response.trimStart().startsWith("{")) return JSONObject(response)
@@ -384,11 +389,11 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
private fun generateOtp(seed: String): String = Totp.generate(seed)
private fun getOrCreateAppId(): String {
var id = prefs.getString("mib_app_id", null)
var id = credentialStore.loadMibAppId()
if (id == null) {
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
id = "IOS17.2-" + (1..15).map { chars[Random.nextInt(chars.length)] }.joinToString("")
prefs.edit().putString("mib_app_id", id).apply()
credentialStore.saveMibAppId(id)
}
return id
}

View File

@@ -3,6 +3,7 @@ package sh.sar.basedbank.api.mib
import android.content.Context
import org.json.JSONArray
import org.json.JSONObject
import sh.sar.basedbank.util.CacheEncryption
import java.io.File
object TransactionCache {
@@ -22,7 +23,7 @@ object TransactionCache {
put("accountDisplayName", t.accountDisplayName)
put("source", t.source)
})
File(context.cacheDir, "tx_$key.json").writeText(arr.toString())
File(context.cacheDir, "tx_$key.json").writeText(CacheEncryption.encrypt(arr.toString()))
} catch (_: Exception) {}
}
@@ -32,7 +33,7 @@ object TransactionCache {
}
fun load(context: Context, key: String): List<Transaction> = try {
val arr = JSONArray(File(context.cacheDir, "tx_$key.json").readText())
val arr = JSONArray(CacheEncryption.decrypt(File(context.cacheDir, "tx_$key.json").readText()))
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
Transaction(

View File

@@ -56,6 +56,7 @@ class HomeActivity : AppCompatActivity() {
private val autolockHandler = Handler(Looper.getMainLooper())
private var warningDialog: AlertDialog? = null
private var countdownTimer: CountDownTimer? = null
private var pauseTime = 0L
private val warningRunnable = Runnable { showAutolockWarning() }
@@ -194,11 +195,24 @@ class HomeActivity : AppCompatActivity() {
override fun onResume() {
super.onResume()
// If we were away long enough to have hit the autolock timeout (e.g. while
// QrScannerActivity was in the foreground), lock immediately.
if (pauseTime > 0L) {
val elapsed = System.currentTimeMillis() - pauseTime
val timeout = getSharedPreferences("prefs", MODE_PRIVATE).getLong("autolock_timeout", 60_000L)
val securitySet = getSharedPreferences("prefs", MODE_PRIVATE).getString("security_method", null) != null
if (timeout > 0L && elapsed >= timeout && securitySet) {
startActivity(Intent(this, sh.sar.basedbank.LockActivity::class.java))
finish()
return
}
}
resetAutolockTimer()
}
override fun onPause() {
super.onPause()
pauseTime = System.currentTimeMillis()
autolockHandler.removeCallbacks(autolockRunnable)
autolockHandler.removeCallbacks(warningRunnable)
countdownTimer?.cancel(); countdownTimer = null
@@ -300,8 +314,7 @@ class HomeActivity : AppCompatActivity() {
val mibJob = mibCreds?.let {
async(Dispatchers.IO) {
try {
val prefs = getSharedPreferences("mib_prefs", MODE_PRIVATE)
val flow = MibLoginFlow(prefs)
val flow = MibLoginFlow(CredentialStore(this@HomeActivity))
val accounts = flow.login(it.username, it.passwordHash, it.otpSeed)
val app = application as BasedBankApp
app.accounts = accounts
@@ -411,8 +424,7 @@ class HomeActivity : AppCompatActivity() {
private fun refreshContacts(session: MibSession?, profiles: List<MibProfile>) {
if (session == null || profiles.isEmpty()) return
val prefs = getSharedPreferences("mib_prefs", MODE_PRIVATE)
val flow = MibLoginFlow(prefs)
val flow = MibLoginFlow(CredentialStore(this))
val contactsClient = MibContactsClient()
lifecycleScope.launch {
try {
@@ -465,8 +477,7 @@ class HomeActivity : AppCompatActivity() {
val fresh = withContext(Dispatchers.IO) {
val sess = app.mibSession ?: return@withContext null
val profile = app.mibProfiles.firstOrNull { it.profileId == src.profileId } ?: return@withContext null
val prefs = getSharedPreferences("mib_prefs", MODE_PRIVATE)
try { MibLoginFlow(prefs).fetchAllProfiles(sess, listOf(profile), src.loginTag) }
try { MibLoginFlow(CredentialStore(this@HomeActivity)).fetchAllProfiles(sess, listOf(profile), src.loginTag) }
catch (_: Exception) { null }
} ?: return@launch
// Replace accounts from this profile only, keep everything else
@@ -480,8 +491,7 @@ class HomeActivity : AppCompatActivity() {
private fun refreshFinancing(session: MibSession?, profiles: List<MibProfile>) {
if (session == null || profiles.isEmpty()) return
val prefs = getSharedPreferences("mib_prefs", MODE_PRIVATE)
val flow = MibLoginFlow(prefs)
val flow = MibLoginFlow(CredentialStore(this))
val client = MibFinancingClient()
lifecycleScope.launch {
try {

View File

@@ -111,8 +111,7 @@ class CredentialsFragment : Fragment() {
binding.btnLogin.isEnabled = false
val passwordHash = MibLoginFlow.hashPassword(password)
val prefs = requireContext().getSharedPreferences("mib_prefs", android.content.Context.MODE_PRIVATE)
val flow = MibLoginFlow(prefs)
val flow = MibLoginFlow(CredentialStore(requireContext()))
viewLifecycleOwner.lifecycleScope.launch {
try {

View File

@@ -12,8 +12,10 @@ import androidx.fragment.app.Fragment
import com.google.android.material.button.MaterialButton
import sh.sar.basedbank.R
import sh.sar.basedbank.databinding.FragmentSecuritySetupBinding
import java.security.MessageDigest
import sh.sar.basedbank.util.CredentialStore
import java.security.SecureRandom
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.PBEKeySpec
class SecuritySetupFragment : Fragment() {
@@ -231,16 +233,25 @@ class SecuritySetupFragment : Fragment() {
private fun saveCredential(method: String, input: String) {
val salt = ByteArray(16).also { SecureRandom().nextBytes(it) }
val saltB64 = Base64.encodeToString(salt, Base64.NO_WRAP)
val hash = sha256(saltB64 + input)
val hash = pbkdf2(input, salt)
requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE).edit()
.putString("security_method", method)
.putString("security_salt", saltB64)
.putString("security_hash", hash)
// Remove legacy plaintext fields if they exist from an old install
.remove("security_salt")
.remove("security_hash")
.apply()
CredentialStore(requireContext()).saveSecurityHash(saltB64, hash)
}
private fun sha256(input: String) = MessageDigest.getInstance("SHA-256")
.digest(input.toByteArray()).joinToString("") { "%02x".format(it) }
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()
}
}
private fun finishSetup() {
val cb = activity as? Callback

View File

@@ -32,7 +32,7 @@ object AccountCache {
})
}
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit().putString(KEY_MIB, arr.toString()).apply()
.edit().putString(KEY_MIB, CacheEncryption.encrypt(arr.toString())).apply()
}
fun saveBml(context: Context, accounts: List<MibAccount>) {
@@ -55,13 +55,14 @@ object AccountCache {
})
}
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit().putString(KEY_BML, arr.toString()).apply()
.edit().putString(KEY_BML, CacheEncryption.encrypt(arr.toString())).apply()
}
fun loadBml(context: Context): List<MibAccount> {
val json = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY_BML, null) ?: return emptyList()
return try {
val json = CacheEncryption.decrypt(raw)
val arr = JSONArray(json)
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
@@ -90,9 +91,10 @@ object AccountCache {
}
fun load(context: Context): List<MibAccount> {
val json = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY_MIB, null) ?: return emptyList()
return try {
val json = CacheEncryption.decrypt(raw)
val arr = JSONArray(json)
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)

View File

@@ -0,0 +1,55 @@
package sh.sar.basedbank.util
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
/**
* Shared AES-256/GCM encryption for cache SharedPreferences and files.
* Uses the same AndroidKeyStore key as CredentialStore so no extra key material is created.
*/
internal object CacheEncryption {
private const val KEY_ALIAS = "basedbank_credential_key"
private const val TRANSFORMATION = "AES/GCM/NoPadding"
fun encrypt(plaintext: String): String {
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey())
val iv = cipher.iv
val ct = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
return Base64.encodeToString(iv, Base64.NO_WRAP) + ":" + Base64.encodeToString(ct, Base64.NO_WRAP)
}
fun decrypt(encoded: String): String {
val colon = encoded.indexOf(':')
val iv = Base64.decode(encoded.substring(0, colon), Base64.NO_WRAP)
val ct = Base64.decode(encoded.substring(colon + 1), Base64.NO_WRAP)
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), GCMParameterSpec(128, iv))
return String(cipher.doFinal(ct), Charsets.UTF_8)
}
private fun getOrCreateKey(): SecretKey {
val ks = KeyStore.getInstance("AndroidKeyStore").also { it.load(null) }
ks.getKey(KEY_ALIAS, null)?.let { return it as SecretKey }
val spec = KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
.build()
return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
.also { it.init(spec) }
.generateKey()
}
}

View File

@@ -37,7 +37,7 @@ object ContactsCache {
put("profileId", c.profileId)
})
}
prefs.putString(KEY_CONTACTS, contactsArr.toString())
prefs.putString(KEY_CONTACTS, CacheEncryption.encrypt(contactsArr.toString()))
val catArr = JSONArray()
for (cat in categories) {
@@ -47,7 +47,7 @@ object ContactsCache {
put("numBenef", cat.numBenef)
})
}
prefs.putString(KEY_CATEGORIES, catArr.toString())
prefs.putString(KEY_CATEGORIES, CacheEncryption.encrypt(catArr.toString()))
prefs.apply()
}
@@ -56,9 +56,10 @@ object ContactsCache {
}
fun loadContacts(context: Context): List<MibBeneficiary> {
val json = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY_CONTACTS, null) ?: return emptyList()
return try {
val json = CacheEncryption.decrypt(raw)
val arr = JSONArray(json)
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
@@ -101,13 +102,14 @@ object ContactsCache {
})
}
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit().putString("bml_contacts", arr.toString()).apply()
.edit().putString("bml_contacts", CacheEncryption.encrypt(arr.toString())).apply()
}
fun loadBml(context: Context): List<MibBeneficiary> {
val json = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString("bml_contacts", null) ?: return emptyList()
return try {
val json = CacheEncryption.decrypt(raw)
val arr = JSONArray(json)
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
@@ -130,9 +132,10 @@ object ContactsCache {
}
fun loadCategories(context: Context): List<MibBeneficiaryCategory> {
val json = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY_CATEGORIES, null) ?: return emptyList()
return try {
val json = CacheEncryption.decrypt(raw)
val arr = JSONArray(json)
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)

View File

@@ -4,6 +4,7 @@ import android.content.Context
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import org.json.JSONObject
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
@@ -19,6 +20,8 @@ class CredentialStore(context: Context) {
data class MibCredentials(val username: String, val passwordHash: String, val otpSeed: String)
data class BmlCredentials(val username: String, val password: String, val otpSeed: String)
// ── MIB login credentials ─────────────────────────────────────────────────
fun hasMibCredentials(): Boolean = prefs.contains("mib_enc_username")
fun hasBmlCredentials(): Boolean = prefs.contains("bml_enc_username")
@@ -42,9 +45,7 @@ class CredentialStore(context: Context) {
decrypt(encHash, key),
decrypt(encSeed, key)
)
} catch (e: Exception) {
null
}
} catch (_: Exception) { null }
}
fun clearMibCredentials() {
@@ -52,9 +53,44 @@ class CredentialStore(context: Context) {
.remove("mib_enc_username")
.remove("mib_enc_password_hash")
.remove("mib_enc_otp_seed")
.remove("mib_enc_key1")
.remove("mib_enc_key2")
.remove("mib_enc_app_id")
.apply()
}
// ── MIB session keys (key1/key2) and app ID ───────────────────────────────
fun saveMibKeys(key1: String, key2: String) {
val key = getOrCreateKey()
prefs.edit()
.putString("mib_enc_key1", encrypt(key1, key))
.putString("mib_enc_key2", encrypt(key2, key))
.apply()
}
fun loadMibKeys(): Pair<String, String>? {
val key = getOrCreateKey()
val encKey1 = prefs.getString("mib_enc_key1", null) ?: return null
val encKey2 = prefs.getString("mib_enc_key2", null) ?: return null
return try {
Pair(decrypt(encKey1, key), decrypt(encKey2, key))
} catch (_: Exception) { null }
}
fun saveMibAppId(id: String) {
val key = getOrCreateKey()
prefs.edit().putString("mib_enc_app_id", encrypt(id, key)).apply()
}
fun loadMibAppId(): String? {
val key = getOrCreateKey()
val enc = prefs.getString("mib_enc_app_id", null) ?: return null
return try { decrypt(enc, key) } catch (_: Exception) { null }
}
// ── BML login credentials ─────────────────────────────────────────────────
fun saveBmlCredentials(username: String, password: String, otpSeed: String) {
val key = getOrCreateKey()
prefs.edit()
@@ -71,7 +107,7 @@ class CredentialStore(context: Context) {
val encSeed = prefs.getString("bml_enc_otp_seed", null) ?: return null
return try {
BmlCredentials(decrypt(encUsername, key), decrypt(encPassword, key), decrypt(encSeed, key))
} catch (e: Exception) { null }
} catch (_: Exception) { null }
}
fun clearBmlCredentials() {
@@ -82,6 +118,8 @@ class CredentialStore(context: Context) {
.apply()
}
// ── BML session token ─────────────────────────────────────────────────────
fun saveBmlSession(accessToken: String, deviceId: String) {
val key = getOrCreateKey()
prefs.edit()
@@ -106,6 +144,39 @@ class CredentialStore(context: Context) {
.apply()
}
// ── Security credential (PIN / pattern hash) ──────────────────────────────
/**
* Stores the PBKDF2-derived hash and its salt, encrypted with the AndroidKeyStore key.
* The lock method ("pin"/"pattern") remains in the app's "prefs" SharedPreferences and
* is not stored here.
*/
fun saveSecurityHash(salt: String, hash: String) {
val key = getOrCreateKey()
prefs.edit()
.putString("security_enc_salt", encrypt(salt, key))
.putString("security_enc_hash", encrypt(hash, key))
.apply()
}
fun loadSecurityHash(): Pair<String, String>? {
val key = getOrCreateKey()
val encSalt = prefs.getString("security_enc_salt", null) ?: return null
val encHash = prefs.getString("security_enc_hash", null) ?: return null
return try {
Pair(decrypt(encSalt, key), decrypt(encHash, key))
} catch (_: Exception) { null }
}
fun clearSecurityHash() {
prefs.edit()
.remove("security_enc_salt")
.remove("security_enc_hash")
.apply()
}
// ── User profile (PII) ────────────────────────────────────────────────────
data class MibUserProfile(
val fullName: String,
val username: String,
@@ -123,54 +194,89 @@ class CredentialStore(context: Context) {
val birthdate: String
)
fun saveMibFullName(name: String) = prefs.edit().putString("mib_full_name", name).apply()
fun loadMibFullName(): String? = prefs.getString("mib_full_name", null)
fun saveMibFullName(name: String) {
val key = getOrCreateKey()
prefs.edit().putString("mib_enc_full_name", encrypt(name, key)).apply()
}
fun saveBmlFullName(name: String) = prefs.edit().putString("bml_full_name", name).apply()
fun loadBmlFullName(): String? = prefs.getString("bml_full_name", null)
fun loadMibFullName(): String? {
val key = getOrCreateKey()
val enc = prefs.getString("mib_enc_full_name", null) ?: return null
return try { decrypt(enc, key) } catch (_: Exception) { null }
}
fun saveBmlFullName(name: String) {
val key = getOrCreateKey()
prefs.edit().putString("bml_enc_full_name", encrypt(name, key)).apply()
}
fun loadBmlFullName(): String? {
val key = getOrCreateKey()
val enc = prefs.getString("bml_enc_full_name", null) ?: return null
return try { decrypt(enc, key) } catch (_: Exception) { null }
}
fun saveMibUserProfile(p: MibUserProfile) {
prefs.edit().putString("mib_full_name", p.fullName)
.putString("mib_profile_username", p.username)
.putString("mib_profile_email", p.email)
.putString("mib_profile_mobile", p.mobile)
.putString("mib_profile_enrolled", p.enrolled)
.apply()
val json = JSONObject().apply {
put("fullName", p.fullName)
put("username", p.username)
put("email", p.email)
put("mobile", p.mobile)
put("enrolled", p.enrolled)
}.toString()
val key = getOrCreateKey()
prefs.edit().putString("mib_enc_profile", encrypt(json, key)).apply()
// Keep the name in sync with the fast-path field
prefs.edit().putString("mib_enc_full_name", encrypt(p.fullName, key)).apply()
}
fun loadMibUserProfile(): MibUserProfile? {
val name = prefs.getString("mib_full_name", null) ?: return null
return MibUserProfile(
fullName = name,
username = prefs.getString("mib_profile_username", "") ?: "",
email = prefs.getString("mib_profile_email", "") ?: "",
mobile = prefs.getString("mib_profile_mobile", "") ?: "",
enrolled = prefs.getString("mib_profile_enrolled", "") ?: ""
)
val key = getOrCreateKey()
val enc = prefs.getString("mib_enc_profile", null) ?: return null
return try {
val o = JSONObject(decrypt(enc, key))
MibUserProfile(
fullName = o.optString("fullName"),
username = o.optString("username"),
email = o.optString("email"),
mobile = o.optString("mobile"),
enrolled = o.optString("enrolled")
)
} catch (_: Exception) { null }
}
fun saveBmlUserProfile(p: BmlUserProfile) {
prefs.edit().putString("bml_full_name", p.fullName)
.putString("bml_profile_email", p.email)
.putString("bml_profile_mobile", p.mobile)
.putString("bml_profile_customer_id", p.customerId)
.putString("bml_profile_idcard", p.idCard)
.putString("bml_profile_birthdate", p.birthdate)
.apply()
val json = JSONObject().apply {
put("fullName", p.fullName)
put("email", p.email)
put("mobile", p.mobile)
put("customerId", p.customerId)
put("idCard", p.idCard)
put("birthdate", p.birthdate)
}.toString()
val key = getOrCreateKey()
prefs.edit().putString("bml_enc_profile", encrypt(json, key)).apply()
prefs.edit().putString("bml_enc_full_name", encrypt(p.fullName, key)).apply()
}
fun loadBmlUserProfile(): BmlUserProfile? {
val name = prefs.getString("bml_full_name", null) ?: return null
return BmlUserProfile(
fullName = name,
email = prefs.getString("bml_profile_email", "") ?: "",
mobile = prefs.getString("bml_profile_mobile", "") ?: "",
customerId = prefs.getString("bml_profile_customer_id", "") ?: "",
idCard = prefs.getString("bml_profile_idcard", "") ?: "",
birthdate = prefs.getString("bml_profile_birthdate", "") ?: ""
)
val key = getOrCreateKey()
val enc = prefs.getString("bml_enc_profile", null) ?: return null
return try {
val o = JSONObject(decrypt(enc, key))
BmlUserProfile(
fullName = o.optString("fullName"),
email = o.optString("email"),
mobile = o.optString("mobile"),
customerId = o.optString("customerId"),
idCard = o.optString("idCard"),
birthdate = o.optString("birthdate")
)
} catch (_: Exception) { null }
}
// ── Crypto primitives ─────────────────────────────────────────────────────
private fun getOrCreateKey(): SecretKey {
val ks = KeyStore.getInstance("AndroidKeyStore").also { it.load(null) }
ks.getKey(keyAlias, null)?.let { return it as SecretKey }
@@ -193,16 +299,16 @@ class CredentialStore(context: Context) {
val cipher = Cipher.getInstance(transformation)
cipher.init(Cipher.ENCRYPT_MODE, key)
val iv = cipher.iv
val ct = cipher.doFinal(plaintext.toByteArray())
val ct = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
return Base64.encodeToString(iv, Base64.NO_WRAP) + ":" + Base64.encodeToString(ct, Base64.NO_WRAP)
}
private fun decrypt(encoded: String, key: SecretKey): String {
val parts = encoded.split(":")
val iv = Base64.decode(parts[0], Base64.NO_WRAP)
val ct = Base64.decode(parts[1], Base64.NO_WRAP)
val colon = encoded.indexOf(':')
val iv = Base64.decode(encoded.substring(0, colon), Base64.NO_WRAP)
val ct = Base64.decode(encoded.substring(colon + 1), Base64.NO_WRAP)
val cipher = Cipher.getInstance(transformation)
cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(128, iv))
return String(cipher.doFinal(ct))
return String(cipher.doFinal(ct), Charsets.UTF_8)
}
}

View File

@@ -31,7 +31,7 @@ object FinancingCache {
})
}
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit().putString(KEY_MIB, arr.toString()).apply()
.edit().putString(KEY_MIB, CacheEncryption.encrypt(arr.toString())).apply()
}
fun clear(context: Context) {
@@ -39,9 +39,10 @@ object FinancingCache {
}
fun load(context: Context): List<MibFinanceDeal> {
val json = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY_MIB, null) ?: return emptyList()
return try {
val json = CacheEncryption.decrypt(raw)
val arr = JSONArray(json)
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)

View File

@@ -39,7 +39,7 @@ object ForeignLimitsCache {
})
}
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit().putString(KEY, arr.toString()).apply()
.edit().putString(KEY, CacheEncryption.encrypt(arr.toString())).apply()
}
fun clear(context: Context) {
@@ -47,9 +47,10 @@ object ForeignLimitsCache {
}
fun load(context: Context): List<HomeViewModel.BmlLimitsData> {
val json = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY, null) ?: return emptyList()
return try {
val json = CacheEncryption.decrypt(raw)
val arr = JSONArray(json)
(0 until arr.length()).map { i ->
val entry = arr.getJSONObject(i)

View File

@@ -37,7 +37,7 @@ object RecentsCache {
})
}
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit().putString(KEY, arr.toString()).apply()
.edit().putString(KEY, CacheEncryption.encrypt(arr.toString())).apply()
}
fun remove(context: Context, accountNumber: String) {
@@ -54,7 +54,7 @@ object RecentsCache {
})
}
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit().putString(KEY, arr.toString()).apply()
.edit().putString(KEY, CacheEncryption.encrypt(arr.toString())).apply()
}
fun clear(context: Context) {
@@ -62,9 +62,10 @@ object RecentsCache {
}
fun load(context: Context): List<RecentPick> {
val json = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY, null) ?: return emptyList()
return try {
val json = CacheEncryption.decrypt(raw)
val arr = JSONArray(json)
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)

View File

@@ -36,6 +36,8 @@
<string name="biometric_prompt_subtitle">ފިންގަޕްރިންޓް ނުވަތަ މޫނު ބޭނުން ކޮށްގެން ހުޅުވާ</string>
<string name="biometric_negative_btn">PIN / ޕެޓަން ބޭނުން ކުރޭ</string>
<string name="unlock_failed">ދިމައެއް ނުވި — އަލުން ކަނޑޭ</string>
<string name="unlock_attempts_remaining">ދިމައެއް ނުވި — %d ފަހަރު ބާކީ</string>
<string name="unlock_locked_out">ވަރަށް ގިނަ ފަހަރު ނުކުރިހުރި. %d ސިކުންތު ފަހުން ލޮކް ހިލޭ.</string>
<!-- Security setup -->
<string name="security_setup">އެޕް ރައްކާތެރި ކުރޭ</string>

View File

@@ -36,6 +36,8 @@
<string name="biometric_prompt_subtitle">Use your fingerprint or face to unlock</string>
<string name="biometric_negative_btn">Use PIN / Pattern</string>
<string name="unlock_failed">Incorrect — try again</string>
<string name="unlock_attempts_remaining">Incorrect — %d attempts remaining</string>
<string name="unlock_locked_out">Too many attempts. Try again in %d seconds.</string>
<!-- Security setup -->
<string name="security_setup">Secure Your App</string>

View File

@@ -6,8 +6,14 @@
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
<exclude domain="sharedpref" path="credential_store.xml"/>
<exclude domain="sharedpref" path="mib_prefs.xml"/>
<exclude domain="sharedpref" path="prefs.xml"/>
<exclude domain="sharedpref" path="account_cache.xml"/>
<exclude domain="sharedpref" path="contacts_cache.xml"/>
<exclude domain="sharedpref" path="financing_cache.xml"/>
<exclude domain="sharedpref" path="foreign_limits_cache.xml"/>
<exclude domain="sharedpref" path="recents_cache.xml"/>
<exclude domain="sharedpref" path="lock_attempts.xml"/>
<exclude domain="cache" path="."/>
</full-backup-content>

View File

@@ -5,15 +5,27 @@
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
<exclude domain="sharedpref" path="credential_store.xml"/>
<exclude domain="sharedpref" path="mib_prefs.xml"/>
<exclude domain="sharedpref" path="prefs.xml"/>
<exclude domain="sharedpref" path="account_cache.xml"/>
<exclude domain="sharedpref" path="contacts_cache.xml"/>
<exclude domain="sharedpref" path="financing_cache.xml"/>
<exclude domain="sharedpref" path="foreign_limits_cache.xml"/>
<exclude domain="sharedpref" path="recents_cache.xml"/>
<exclude domain="sharedpref" path="lock_attempts.xml"/>
<exclude domain="cache" path="."/>
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
<exclude domain="sharedpref" path="credential_store.xml"/>
<exclude domain="sharedpref" path="mib_prefs.xml"/>
<exclude domain="sharedpref" path="prefs.xml"/>
<exclude domain="sharedpref" path="account_cache.xml"/>
<exclude domain="sharedpref" path="contacts_cache.xml"/>
<exclude domain="sharedpref" path="financing_cache.xml"/>
<exclude domain="sharedpref" path="foreign_limits_cache.xml"/>
<exclude domain="sharedpref" path="recents_cache.xml"/>
<exclude domain="sharedpref" path="lock_attempts.xml"/>
<exclude domain="cache" path="."/>
</device-transfer>
-->
</data-extraction-rules>

View File

@@ -1,6 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">faisanet.mib.com.mv</domain>
</domain-config>
</network-security-config>
<network-security-config/>