diff --git a/app/src/main/java/sh/sar/basedbank/BasedBankApp.kt b/app/src/main/java/sh/sar/basedbank/BasedBankApp.kt index 59e3766..7f2cf5c 100644 --- a/app/src/main/java/sh/sar/basedbank/BasedBankApp.kt +++ b/app/src/main/java/sh/sar/basedbank/BasedBankApp.kt @@ -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 diff --git a/app/src/main/java/sh/sar/basedbank/LockActivity.kt b/app/src/main/java/sh/sar/basedbank/LockActivity.kt index 247ba5e..837caad 100644 --- a/app/src/main/java/sh/sar/basedbank/LockActivity.kt +++ b/app/src/main/java/sh/sar/basedbank/LockActivity.kt @@ -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) - } + } diff --git a/app/src/main/java/sh/sar/basedbank/api/bml/BmlLoginFlow.kt b/app/src/main/java/sh/sar/basedbank/api/bml/BmlLoginFlow.kt index 3d451e5..ff262e2 100644 --- a/app/src/main/java/sh/sar/basedbank/api/bml/BmlLoginFlow.kt +++ b/app/src/main/java/sh/sar/basedbank/api/bml/BmlLoginFlow.kt @@ -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("\"", "\\\"")}\"" } diff --git a/app/src/main/java/sh/sar/basedbank/api/mib/MibLoginFlow.kt b/app/src/main/java/sh/sar/basedbank/api/mib/MibLoginFlow.kt index 03141aa..7789ea2 100644 --- a/app/src/main/java/sh/sar/basedbank/api/mib/MibLoginFlow.kt +++ b/app/src/main/java/sh/sar/basedbank/api/mib/MibLoginFlow.kt @@ -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 } diff --git a/app/src/main/java/sh/sar/basedbank/api/mib/TransactionCache.kt b/app/src/main/java/sh/sar/basedbank/api/mib/TransactionCache.kt index 7b5a1b3..4b3d8ba 100644 --- a/app/src/main/java/sh/sar/basedbank/api/mib/TransactionCache.kt +++ b/app/src/main/java/sh/sar/basedbank/api/mib/TransactionCache.kt @@ -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 = 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( diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt b/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt index 5150822..3a266be 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt @@ -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) { 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) { 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 { diff --git a/app/src/main/java/sh/sar/basedbank/ui/login/CredentialsFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/login/CredentialsFragment.kt index ea5d371..060a3f3 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/login/CredentialsFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/login/CredentialsFragment.kt @@ -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 { diff --git a/app/src/main/java/sh/sar/basedbank/ui/onboarding/SecuritySetupFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/onboarding/SecuritySetupFragment.kt index 21406e8..aeebe70 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/onboarding/SecuritySetupFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/onboarding/SecuritySetupFragment.kt @@ -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 diff --git a/app/src/main/java/sh/sar/basedbank/util/AccountCache.kt b/app/src/main/java/sh/sar/basedbank/util/AccountCache.kt index 59726c9..42350a2 100644 --- a/app/src/main/java/sh/sar/basedbank/util/AccountCache.kt +++ b/app/src/main/java/sh/sar/basedbank/util/AccountCache.kt @@ -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) { @@ -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 { - 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 { - 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) diff --git a/app/src/main/java/sh/sar/basedbank/util/CacheEncryption.kt b/app/src/main/java/sh/sar/basedbank/util/CacheEncryption.kt new file mode 100644 index 0000000..7fe34c3 --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/util/CacheEncryption.kt @@ -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() + } +} diff --git a/app/src/main/java/sh/sar/basedbank/util/ContactsCache.kt b/app/src/main/java/sh/sar/basedbank/util/ContactsCache.kt index 8dfa7ce..8a186ac 100644 --- a/app/src/main/java/sh/sar/basedbank/util/ContactsCache.kt +++ b/app/src/main/java/sh/sar/basedbank/util/ContactsCache.kt @@ -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 { - 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 { - 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 { - 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) diff --git a/app/src/main/java/sh/sar/basedbank/util/CredentialStore.kt b/app/src/main/java/sh/sar/basedbank/util/CredentialStore.kt index a78c81e..7b048d2 100644 --- a/app/src/main/java/sh/sar/basedbank/util/CredentialStore.kt +++ b/app/src/main/java/sh/sar/basedbank/util/CredentialStore.kt @@ -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? { + 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? { + 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) } } diff --git a/app/src/main/java/sh/sar/basedbank/util/FinancingCache.kt b/app/src/main/java/sh/sar/basedbank/util/FinancingCache.kt index 162a11c..8d01ac5 100644 --- a/app/src/main/java/sh/sar/basedbank/util/FinancingCache.kt +++ b/app/src/main/java/sh/sar/basedbank/util/FinancingCache.kt @@ -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 { - 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) diff --git a/app/src/main/java/sh/sar/basedbank/util/ForeignLimitsCache.kt b/app/src/main/java/sh/sar/basedbank/util/ForeignLimitsCache.kt index 7aca12e..d81a588 100644 --- a/app/src/main/java/sh/sar/basedbank/util/ForeignLimitsCache.kt +++ b/app/src/main/java/sh/sar/basedbank/util/ForeignLimitsCache.kt @@ -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 { - 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) diff --git a/app/src/main/java/sh/sar/basedbank/util/RecentsCache.kt b/app/src/main/java/sh/sar/basedbank/util/RecentsCache.kt index 64c6d71..c6a1665 100644 --- a/app/src/main/java/sh/sar/basedbank/util/RecentsCache.kt +++ b/app/src/main/java/sh/sar/basedbank/util/RecentsCache.kt @@ -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 { - 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) diff --git a/app/src/main/res/values-b+dv/strings.xml b/app/src/main/res/values-b+dv/strings.xml index 3bc2537..3976d6a 100644 --- a/app/src/main/res/values-b+dv/strings.xml +++ b/app/src/main/res/values-b+dv/strings.xml @@ -36,6 +36,8 @@ ފިންގަޕްރިންޓް ނުވަތަ މޫނު ބޭނުން ކޮށްގެން ހުޅުވާ PIN / ޕެޓަން ބޭނުން ކުރޭ ދިމައެއް ނުވި — އަލުން ކަނޑޭ + ދިމައެއް ނުވި — %d ފަހަރު ބާކީ + ވަރަށް ގިނަ ފަހަރު ނުކުރިހުރި. %d ސިކުންތު ފަހުން ލޮކް ހިލޭ. އެޕް ރައްކާތެރި ކުރޭ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e37f886..a02a1b2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -36,6 +36,8 @@ Use your fingerprint or face to unlock Use PIN / Pattern Incorrect — try again + Incorrect — %d attempts remaining + Too many attempts. Try again in %d seconds. Secure Your App diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml index 4df9255..f162531 100644 --- a/app/src/main/res/xml/backup_rules.xml +++ b/app/src/main/res/xml/backup_rules.xml @@ -6,8 +6,14 @@ See https://developer.android.com/about/versions/12/backup-restore --> - + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml index 9ee9997..845ec30 100644 --- a/app/src/main/res/xml/data_extraction_rules.xml +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -5,15 +5,27 @@ --> - + + + + + + + + + + - \ No newline at end of file diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml index ebc833f..5904dd6 100644 --- a/app/src/main/res/xml/network_security_config.xml +++ b/app/src/main/res/xml/network_security_config.xml @@ -1,6 +1,2 @@ - - - faisanet.mib.com.mv - - +