security: encrypt credentials, caches, and harden lock screen

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
@@ -9,6 +9,7 @@ import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.api.mib.MibLoginFlow import sh.sar.basedbank.api.mib.MibLoginFlow
import sh.sar.basedbank.api.mib.MibProfile import sh.sar.basedbank.api.mib.MibProfile
import sh.sar.basedbank.api.mib.MibSession import sh.sar.basedbank.api.mib.MibSession
import sh.sar.basedbank.util.CredentialStore
class BasedBankApp : Application() { class BasedBankApp : Application() {
@@ -24,7 +25,7 @@ class BasedBankApp : Application() {
val mibMutex = Mutex() val mibMutex = Mutex()
val mibLoginFlow by lazy { val mibLoginFlow by lazy {
MibLoginFlow(getSharedPreferences("mib_prefs", MODE_PRIVATE)).also { flow -> MibLoginFlow(CredentialStore(this)).also { flow ->
flow.onSessionRefreshed = { session, profiles -> flow.onSessionRefreshed = { session, profiles ->
mibSession = session mibSession = session
mibProfiles = profiles mibProfiles = profiles
@@ -5,14 +5,23 @@ import android.os.Bundle
import android.util.Base64 import android.util.Base64
import android.view.View import android.view.View
import android.widget.LinearLayout import android.widget.LinearLayout
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.biometric.BiometricManager import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import com.google.android.material.button.MaterialButton 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.databinding.ActivityLockBinding
import sh.sar.basedbank.ui.home.HomeActivity import sh.sar.basedbank.ui.home.HomeActivity
import sh.sar.basedbank.util.CredentialStore
import java.security.MessageDigest import java.security.MessageDigest
import java.security.SecureRandom
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.PBEKeySpec
class LockActivity : AppCompatActivity() { class LockActivity : AppCompatActivity() {
@@ -22,6 +31,15 @@ class LockActivity : AppCompatActivity() {
private lateinit var salt: String private lateinit var salt: String
private lateinit var storedHash: String private lateinit var storedHash: String
private var biometricsEnabled = false 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -30,10 +48,20 @@ class LockActivity : AppCompatActivity() {
val prefs = getSharedPreferences("prefs", MODE_PRIVATE) val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
method = prefs.getString("security_method", "pin") ?: "pin" method = prefs.getString("security_method", "pin") ?: "pin"
salt = prefs.getString("security_salt", "") ?: ""
storedHash = prefs.getString("security_hash", "") ?: ""
biometricsEnabled = prefs.getBoolean("biometrics_enabled", false) 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") { if (method == "pin") {
binding.viewPin.visibility = View.VISIBLE binding.viewPin.visibility = View.VISIBLE
buildNumpad() buildNumpad()
@@ -48,6 +76,12 @@ class LockActivity : AppCompatActivity() {
} }
if (biometricsEnabled) triggerBiometric() if (biometricsEnabled) triggerBiometric()
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
moveTaskToBack(true)
}
})
} }
private fun buildNumpad() { private fun buildNumpad() {
@@ -92,6 +126,7 @@ class LockActivity : AppCompatActivity() {
} }
private fun handleKey(key: String) { private fun handleKey(key: String) {
if (isVerifying) return
when (key) { when (key) {
"" -> if (pinDigits.isNotEmpty()) { pinDigits.removeLast(); updateDots() } "" -> if (pinDigits.isNotEmpty()) { pinDigits.removeLast(); updateDots() }
"" -> if (pinDigits.size >= 4) verifyPin() "" -> if (pinDigits.size >= 4) verifyPin()
@@ -105,13 +140,21 @@ class LockActivity : AppCompatActivity() {
} }
private fun verifyPin() { private fun verifyPin() {
if (checkAndShowLockout()) return
val entered = pinDigits.joinToString("") val entered = pinDigits.joinToString("")
if (verify(entered)) { pinDigits.clear()
proceed() updateDots()
} else { isVerifying = true
binding.tvLockPinDots.text = getString(R.string.unlock_failed) lifecycleScope.launch {
pinDigits.clear() val ok = withContext(Dispatchers.Default) { verify(entered) }
binding.root.postDelayed({ updateDots() }, 1200) 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) binding.tvPatternHint.text = getString(R.string.pattern_min_dots)
return return
} }
if (verify(pattern.joinToString(""))) { if (checkAndShowLockout()) {
proceed()
} else {
binding.lockPatternView.showError() binding.lockPatternView.showError()
binding.tvPatternHint.text = getString(R.string.unlock_failed) return
binding.root.postDelayed({ binding.tvPatternHint.text = "" }, 1500) }
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 { private fun verify(input: String): Boolean {
val hash = sha256(salt + input) if (storedHash.isBlank()) return false
return hash == storedHash 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() { private fun triggerBiometric() {
@@ -145,6 +252,7 @@ class LockActivity : AppCompatActivity() {
ContextCompat.getMainExecutor(this), ContextCompat.getMainExecutor(this),
object : BiometricPrompt.AuthenticationCallback() { object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
resetFailures()
proceed() proceed()
} }
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
@@ -166,11 +274,43 @@ class LockActivity : AppCompatActivity() {
finish() 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) } .digest(input.toByteArray()).joinToString("") { "%02x".format(it) }
override fun onBackPressed() {
// Lock screen cannot be dismissed
moveTaskToBack(true)
}
} }
@@ -69,8 +69,11 @@ class BmlLoginFlow {
val xsrf = xsrfToken() ?: throw Exception("Could not fetch login page") val xsrf = xsrfToken() ?: throw Exception("Could not fetch login page")
// Step 2: POST credentials // Step 2: POST credentials
val loginBody = """{"username":${quote(username)},"password":${quote(password)},"code":""}""" val loginBody = JSONObject().apply {
.toRequestBody("application/json".toMediaType()) put("username", username)
put("password", password)
put("code", "")
}.toString().toRequestBody("application/json".toMediaType())
val loginResp = client.newCall( val loginResp = client.newCall(
Request.Builder().url("$BASE_URL/web/login").post(loginBody) Request.Builder().url("$BASE_URL/web/login").post(loginBody)
.header("X-XSRF-TOKEN", xsrf) .header("X-XSRF-TOKEN", xsrf)
@@ -89,8 +92,10 @@ class BmlLoginFlow {
// Step 4: POST OTP // Step 4: POST OTP
val otp = Totp.generate(otpSeed) val otp = Totp.generate(otpSeed)
val twoFaBody = """{"code":${quote(otp)},"channel":"authenticator"}""" val twoFaBody = JSONObject().apply {
.toRequestBody("application/json".toMediaType()) put("code", otp)
put("channel", "authenticator")
}.toString().toRequestBody("application/json".toMediaType())
val twoFaResp = client.newCall( val twoFaResp = client.newCall(
Request.Builder().url("$BASE_URL/web/login/2fa").post(twoFaBody) Request.Builder().url("$BASE_URL/web/login/2fa").post(twoFaBody)
.header("X-XSRF-TOKEN", xsrf2) .header("X-XSRF-TOKEN", xsrf2)
@@ -740,6 +745,4 @@ class BmlLoginFlow {
SecureRandom().nextBytes(bytes) SecureRandom().nextBytes(bytes)
return bytes.joinToString("") { "%02x".format(it) } return bytes.joinToString("") { "%02x".format(it) }
} }
private fun quote(s: String) = "\"${s.replace("\\", "\\\\").replace("\"", "\\\"")}\""
} }
@@ -1,19 +1,20 @@
package sh.sar.basedbank.api.mib package sh.sar.basedbank.api.mib
import okhttp3.FormBody import okhttp3.FormBody
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.Totp import sh.sar.basedbank.util.Totp
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import org.json.JSONObject import org.json.JSONObject
import java.security.MessageDigest import java.security.MessageDigest
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.random.Random import kotlin.random.Random
class SessionExpiredException : Exception("MIB session expired") 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/" private val BASE_URL = "https://faisanet.mib.com.mv/faisamobilex_smvc/"
/** The active session after a successful login, usable for subsequent WebView requests. */ /** 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 storedUsername: String? = null
@Volatile private var storedPasswordHash: String? = null @Volatile private var storedPasswordHash: String? = null
@Volatile private var storedOtpSeed: String? = null @Volatile private var storedOtpSeed: String? = null
private var inRelogin = false private val inRelogin = AtomicBoolean(false)
private val client = OkHttpClient.Builder() private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS) .connectTimeout(30, TimeUnit.SECONDS)
@@ -61,11 +62,10 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
storedPasswordHash = passwordHash storedPasswordHash = passwordHash
storedOtpSeed = otpSeed storedOtpSeed = otpSeed
val appId = getOrCreateAppId() val appId = getOrCreateAppId()
val key1 = prefs.getString("mib_key1_$username", null) val keys = credentialStore.loadMibKeys()
val key2 = prefs.getString("mib_key2_$username", null)
return if (key1 != null && key2 != null) { return if (keys != null) {
regularLogin(username, passwordHash, appId, key1, key2) regularLogin(username, passwordHash, appId, keys.first, keys.second)
} else { } else {
firstTimeRegistration(username, passwordHash, otpSeed, appId) 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 keyData = otpResp.getJSONArray("data").getJSONObject(0)
val key1 = keyData.getString("key1") val key1 = keyData.getString("key1")
val key2 = keyData.getString("key2") 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) return regularLogin(username, passwordHash, appId, key1, key2)
} }
@@ -189,7 +189,7 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
val response = try { val response = try {
sendRequest(session, data, sfunc) sendRequest(session, data, sfunc)
} catch (e: SessionExpiredException) { } catch (e: SessionExpiredException) {
if (inRelogin) throw e if (inRelogin.get()) throw e
"" // fall through to recovery below "" // fall through to recovery below
} }
@@ -198,22 +198,27 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
(response.trimStart().startsWith("{") && (response.trimStart().startsWith("{") &&
JSONObject(response).optString("reasonCode") == "505") JSONObject(response).optString("reasonCode") == "505")
if (isExpired && !inRelogin) { if (isExpired && inRelogin.compareAndSet(false, true)) {
val u = storedUsername ?: throw SessionExpiredException() try {
val ph = storedPasswordHash ?: throw SessionExpiredException() val u = storedUsername ?: throw SessionExpiredException()
val os = storedOtpSeed ?: throw SessionExpiredException() val ph = storedPasswordHash ?: throw SessionExpiredException()
inRelogin = true val os = storedOtpSeed ?: throw SessionExpiredException()
try { login(u, ph, os) } finally { inRelogin = false } login(u, ph, os)
val newSession = lastSession ?: throw SessionExpiredException() val newSession = lastSession ?: throw SessionExpiredException()
onSessionRefreshed?.invoke(newSession, lastProfiles) onSessionRefreshed?.invoke(newSession, lastProfiles)
// Refresh nonce/xxid in the payload for the retry // Refresh nonce/xxid in the payload for the retry
data.put("nonce", MibNonce.generate(newSession.nonceGenerator)) data.put("nonce", MibNonce.generate(newSession.nonceGenerator))
data.put("appId", newSession.appId) data.put("appId", newSession.appId)
data.put("sodium", MibNonce.randomSodium()) data.put("sodium", MibNonce.randomSodium())
data.put("xxid", newSession.xxid) data.put("xxid", newSession.xxid)
val retryResponse = sendRequest(newSession, data, sfunc) val retryResponse = sendRequest(newSession, data, sfunc)
if (retryResponse.trimStart().startsWith("{")) return JSONObject(retryResponse) if (retryResponse.trimStart().startsWith("{")) return JSONObject(retryResponse)
return MibCrypto.decrypt(retryResponse, newSession.sessionKey) return MibCrypto.decrypt(retryResponse, newSession.sessionKey)
} finally {
inRelogin.set(false)
}
} else if (isExpired) {
throw SessionExpiredException()
} }
if (response.trimStart().startsWith("{")) return JSONObject(response) 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 generateOtp(seed: String): String = Totp.generate(seed)
private fun getOrCreateAppId(): String { private fun getOrCreateAppId(): String {
var id = prefs.getString("mib_app_id", null) var id = credentialStore.loadMibAppId()
if (id == null) { if (id == null) {
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
id = "IOS17.2-" + (1..15).map { chars[Random.nextInt(chars.length)] }.joinToString("") 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 return id
} }
@@ -3,6 +3,7 @@ package sh.sar.basedbank.api.mib
import android.content.Context import android.content.Context
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import sh.sar.basedbank.util.CacheEncryption
import java.io.File import java.io.File
object TransactionCache { object TransactionCache {
@@ -22,7 +23,7 @@ object TransactionCache {
put("accountDisplayName", t.accountDisplayName) put("accountDisplayName", t.accountDisplayName)
put("source", t.source) 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) {} } catch (_: Exception) {}
} }
@@ -32,7 +33,7 @@ object TransactionCache {
} }
fun load(context: Context, key: String): List<Transaction> = try { 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 -> (0 until arr.length()).map { i ->
val o = arr.getJSONObject(i) val o = arr.getJSONObject(i)
Transaction( Transaction(
@@ -56,6 +56,7 @@ class HomeActivity : AppCompatActivity() {
private val autolockHandler = Handler(Looper.getMainLooper()) private val autolockHandler = Handler(Looper.getMainLooper())
private var warningDialog: AlertDialog? = null private var warningDialog: AlertDialog? = null
private var countdownTimer: CountDownTimer? = null private var countdownTimer: CountDownTimer? = null
private var pauseTime = 0L
private val warningRunnable = Runnable { showAutolockWarning() } private val warningRunnable = Runnable { showAutolockWarning() }
@@ -194,11 +195,24 @@ class HomeActivity : AppCompatActivity() {
override fun onResume() { override fun onResume() {
super.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() resetAutolockTimer()
} }
override fun onPause() { override fun onPause() {
super.onPause() super.onPause()
pauseTime = System.currentTimeMillis()
autolockHandler.removeCallbacks(autolockRunnable) autolockHandler.removeCallbacks(autolockRunnable)
autolockHandler.removeCallbacks(warningRunnable) autolockHandler.removeCallbacks(warningRunnable)
countdownTimer?.cancel(); countdownTimer = null countdownTimer?.cancel(); countdownTimer = null
@@ -300,8 +314,7 @@ class HomeActivity : AppCompatActivity() {
val mibJob = mibCreds?.let { val mibJob = mibCreds?.let {
async(Dispatchers.IO) { async(Dispatchers.IO) {
try { try {
val prefs = getSharedPreferences("mib_prefs", MODE_PRIVATE) val flow = MibLoginFlow(CredentialStore(this@HomeActivity))
val flow = MibLoginFlow(prefs)
val accounts = flow.login(it.username, it.passwordHash, it.otpSeed) val accounts = flow.login(it.username, it.passwordHash, it.otpSeed)
val app = application as BasedBankApp val app = application as BasedBankApp
app.accounts = accounts app.accounts = accounts
@@ -411,8 +424,7 @@ class HomeActivity : AppCompatActivity() {
private fun refreshContacts(session: MibSession?, profiles: List<MibProfile>) { private fun refreshContacts(session: MibSession?, profiles: List<MibProfile>) {
if (session == null || profiles.isEmpty()) return if (session == null || profiles.isEmpty()) return
val prefs = getSharedPreferences("mib_prefs", MODE_PRIVATE) val flow = MibLoginFlow(CredentialStore(this))
val flow = MibLoginFlow(prefs)
val contactsClient = MibContactsClient() val contactsClient = MibContactsClient()
lifecycleScope.launch { lifecycleScope.launch {
try { try {
@@ -465,8 +477,7 @@ class HomeActivity : AppCompatActivity() {
val fresh = withContext(Dispatchers.IO) { val fresh = withContext(Dispatchers.IO) {
val sess = app.mibSession ?: return@withContext null val sess = app.mibSession ?: return@withContext null
val profile = app.mibProfiles.firstOrNull { it.profileId == src.profileId } ?: return@withContext null val profile = app.mibProfiles.firstOrNull { it.profileId == src.profileId } ?: return@withContext null
val prefs = getSharedPreferences("mib_prefs", MODE_PRIVATE) try { MibLoginFlow(CredentialStore(this@HomeActivity)).fetchAllProfiles(sess, listOf(profile), src.loginTag) }
try { MibLoginFlow(prefs).fetchAllProfiles(sess, listOf(profile), src.loginTag) }
catch (_: Exception) { null } catch (_: Exception) { null }
} ?: return@launch } ?: return@launch
// Replace accounts from this profile only, keep everything else // Replace accounts from this profile only, keep everything else
@@ -480,8 +491,7 @@ class HomeActivity : AppCompatActivity() {
private fun refreshFinancing(session: MibSession?, profiles: List<MibProfile>) { private fun refreshFinancing(session: MibSession?, profiles: List<MibProfile>) {
if (session == null || profiles.isEmpty()) return if (session == null || profiles.isEmpty()) return
val prefs = getSharedPreferences("mib_prefs", MODE_PRIVATE) val flow = MibLoginFlow(CredentialStore(this))
val flow = MibLoginFlow(prefs)
val client = MibFinancingClient() val client = MibFinancingClient()
lifecycleScope.launch { lifecycleScope.launch {
try { try {
@@ -111,8 +111,7 @@ class CredentialsFragment : Fragment() {
binding.btnLogin.isEnabled = false binding.btnLogin.isEnabled = false
val passwordHash = MibLoginFlow.hashPassword(password) val passwordHash = MibLoginFlow.hashPassword(password)
val prefs = requireContext().getSharedPreferences("mib_prefs", android.content.Context.MODE_PRIVATE) val flow = MibLoginFlow(CredentialStore(requireContext()))
val flow = MibLoginFlow(prefs)
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
try { try {
@@ -12,8 +12,10 @@ import androidx.fragment.app.Fragment
import com.google.android.material.button.MaterialButton import com.google.android.material.button.MaterialButton
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.databinding.FragmentSecuritySetupBinding import sh.sar.basedbank.databinding.FragmentSecuritySetupBinding
import java.security.MessageDigest import sh.sar.basedbank.util.CredentialStore
import java.security.SecureRandom import java.security.SecureRandom
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.PBEKeySpec
class SecuritySetupFragment : Fragment() { class SecuritySetupFragment : Fragment() {
@@ -231,16 +233,25 @@ class SecuritySetupFragment : Fragment() {
private fun saveCredential(method: String, input: String) { private fun saveCredential(method: String, input: String) {
val salt = ByteArray(16).also { SecureRandom().nextBytes(it) } val salt = ByteArray(16).also { SecureRandom().nextBytes(it) }
val saltB64 = Base64.encodeToString(salt, Base64.NO_WRAP) 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() requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE).edit()
.putString("security_method", method) .putString("security_method", method)
.putString("security_salt", saltB64) // Remove legacy plaintext fields if they exist from an old install
.putString("security_hash", hash) .remove("security_salt")
.remove("security_hash")
.apply() .apply()
CredentialStore(requireContext()).saveSecurityHash(saltB64, hash)
} }
private fun sha256(input: String) = MessageDigest.getInstance("SHA-256") private fun pbkdf2(input: String, salt: ByteArray): String {
.digest(input.toByteArray()).joinToString("") { "%02x".format(it) } 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() { private fun finishSetup() {
val cb = activity as? Callback val cb = activity as? Callback
@@ -32,7 +32,7 @@ object AccountCache {
}) })
} }
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) 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>) { fun saveBml(context: Context, accounts: List<MibAccount>) {
@@ -55,13 +55,14 @@ object AccountCache {
}) })
} }
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) 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> { 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() .getString(KEY_BML, null) ?: return emptyList()
return try { return try {
val json = CacheEncryption.decrypt(raw)
val arr = JSONArray(json) val arr = JSONArray(json)
(0 until arr.length()).map { i -> (0 until arr.length()).map { i ->
val o = arr.getJSONObject(i) val o = arr.getJSONObject(i)
@@ -90,9 +91,10 @@ object AccountCache {
} }
fun load(context: Context): List<MibAccount> { 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() .getString(KEY_MIB, null) ?: return emptyList()
return try { return try {
val json = CacheEncryption.decrypt(raw)
val arr = JSONArray(json) val arr = JSONArray(json)
(0 until arr.length()).map { i -> (0 until arr.length()).map { i ->
val o = arr.getJSONObject(i) val o = arr.getJSONObject(i)
@@ -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()
}
}
@@ -37,7 +37,7 @@ object ContactsCache {
put("profileId", c.profileId) put("profileId", c.profileId)
}) })
} }
prefs.putString(KEY_CONTACTS, contactsArr.toString()) prefs.putString(KEY_CONTACTS, CacheEncryption.encrypt(contactsArr.toString()))
val catArr = JSONArray() val catArr = JSONArray()
for (cat in categories) { for (cat in categories) {
@@ -47,7 +47,7 @@ object ContactsCache {
put("numBenef", cat.numBenef) put("numBenef", cat.numBenef)
}) })
} }
prefs.putString(KEY_CATEGORIES, catArr.toString()) prefs.putString(KEY_CATEGORIES, CacheEncryption.encrypt(catArr.toString()))
prefs.apply() prefs.apply()
} }
@@ -56,9 +56,10 @@ object ContactsCache {
} }
fun loadContacts(context: Context): List<MibBeneficiary> { 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() .getString(KEY_CONTACTS, null) ?: return emptyList()
return try { return try {
val json = CacheEncryption.decrypt(raw)
val arr = JSONArray(json) val arr = JSONArray(json)
(0 until arr.length()).map { i -> (0 until arr.length()).map { i ->
val o = arr.getJSONObject(i) val o = arr.getJSONObject(i)
@@ -101,13 +102,14 @@ object ContactsCache {
}) })
} }
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) 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> { 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() .getString("bml_contacts", null) ?: return emptyList()
return try { return try {
val json = CacheEncryption.decrypt(raw)
val arr = JSONArray(json) val arr = JSONArray(json)
(0 until arr.length()).map { i -> (0 until arr.length()).map { i ->
val o = arr.getJSONObject(i) val o = arr.getJSONObject(i)
@@ -130,9 +132,10 @@ object ContactsCache {
} }
fun loadCategories(context: Context): List<MibBeneficiaryCategory> { 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() .getString(KEY_CATEGORIES, null) ?: return emptyList()
return try { return try {
val json = CacheEncryption.decrypt(raw)
val arr = JSONArray(json) val arr = JSONArray(json)
(0 until arr.length()).map { i -> (0 until arr.length()).map { i ->
val o = arr.getJSONObject(i) val o = arr.getJSONObject(i)
@@ -4,6 +4,7 @@ import android.content.Context
import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties import android.security.keystore.KeyProperties
import android.util.Base64 import android.util.Base64
import org.json.JSONObject
import java.security.KeyStore import java.security.KeyStore
import javax.crypto.Cipher import javax.crypto.Cipher
import javax.crypto.KeyGenerator 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 MibCredentials(val username: String, val passwordHash: String, val otpSeed: String)
data class BmlCredentials(val username: String, val password: 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 hasMibCredentials(): Boolean = prefs.contains("mib_enc_username")
fun hasBmlCredentials(): Boolean = prefs.contains("bml_enc_username") fun hasBmlCredentials(): Boolean = prefs.contains("bml_enc_username")
@@ -42,9 +45,7 @@ class CredentialStore(context: Context) {
decrypt(encHash, key), decrypt(encHash, key),
decrypt(encSeed, key) decrypt(encSeed, key)
) )
} catch (e: Exception) { } catch (_: Exception) { null }
null
}
} }
fun clearMibCredentials() { fun clearMibCredentials() {
@@ -52,9 +53,44 @@ class CredentialStore(context: Context) {
.remove("mib_enc_username") .remove("mib_enc_username")
.remove("mib_enc_password_hash") .remove("mib_enc_password_hash")
.remove("mib_enc_otp_seed") .remove("mib_enc_otp_seed")
.remove("mib_enc_key1")
.remove("mib_enc_key2")
.remove("mib_enc_app_id")
.apply() .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) { fun saveBmlCredentials(username: String, password: String, otpSeed: String) {
val key = getOrCreateKey() val key = getOrCreateKey()
prefs.edit() prefs.edit()
@@ -71,7 +107,7 @@ class CredentialStore(context: Context) {
val encSeed = prefs.getString("bml_enc_otp_seed", null) ?: return null val encSeed = prefs.getString("bml_enc_otp_seed", null) ?: return null
return try { return try {
BmlCredentials(decrypt(encUsername, key), decrypt(encPassword, key), decrypt(encSeed, key)) BmlCredentials(decrypt(encUsername, key), decrypt(encPassword, key), decrypt(encSeed, key))
} catch (e: Exception) { null } } catch (_: Exception) { null }
} }
fun clearBmlCredentials() { fun clearBmlCredentials() {
@@ -82,6 +118,8 @@ class CredentialStore(context: Context) {
.apply() .apply()
} }
// ── BML session token ─────────────────────────────────────────────────────
fun saveBmlSession(accessToken: String, deviceId: String) { fun saveBmlSession(accessToken: String, deviceId: String) {
val key = getOrCreateKey() val key = getOrCreateKey()
prefs.edit() prefs.edit()
@@ -106,6 +144,39 @@ class CredentialStore(context: Context) {
.apply() .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( data class MibUserProfile(
val fullName: String, val fullName: String,
val username: String, val username: String,
@@ -123,54 +194,89 @@ class CredentialStore(context: Context) {
val birthdate: String val birthdate: String
) )
fun saveMibFullName(name: String) = prefs.edit().putString("mib_full_name", name).apply() fun saveMibFullName(name: String) {
fun loadMibFullName(): String? = prefs.getString("mib_full_name", null) 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 loadMibFullName(): String? {
fun loadBmlFullName(): String? = prefs.getString("bml_full_name", null) 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) { fun saveMibUserProfile(p: MibUserProfile) {
prefs.edit().putString("mib_full_name", p.fullName) val json = JSONObject().apply {
.putString("mib_profile_username", p.username) put("fullName", p.fullName)
.putString("mib_profile_email", p.email) put("username", p.username)
.putString("mib_profile_mobile", p.mobile) put("email", p.email)
.putString("mib_profile_enrolled", p.enrolled) put("mobile", p.mobile)
.apply() 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? { fun loadMibUserProfile(): MibUserProfile? {
val name = prefs.getString("mib_full_name", null) ?: return null val key = getOrCreateKey()
return MibUserProfile( val enc = prefs.getString("mib_enc_profile", null) ?: return null
fullName = name, return try {
username = prefs.getString("mib_profile_username", "") ?: "", val o = JSONObject(decrypt(enc, key))
email = prefs.getString("mib_profile_email", "") ?: "", MibUserProfile(
mobile = prefs.getString("mib_profile_mobile", "") ?: "", fullName = o.optString("fullName"),
enrolled = prefs.getString("mib_profile_enrolled", "") ?: "" username = o.optString("username"),
) email = o.optString("email"),
mobile = o.optString("mobile"),
enrolled = o.optString("enrolled")
)
} catch (_: Exception) { null }
} }
fun saveBmlUserProfile(p: BmlUserProfile) { fun saveBmlUserProfile(p: BmlUserProfile) {
prefs.edit().putString("bml_full_name", p.fullName) val json = JSONObject().apply {
.putString("bml_profile_email", p.email) put("fullName", p.fullName)
.putString("bml_profile_mobile", p.mobile) put("email", p.email)
.putString("bml_profile_customer_id", p.customerId) put("mobile", p.mobile)
.putString("bml_profile_idcard", p.idCard) put("customerId", p.customerId)
.putString("bml_profile_birthdate", p.birthdate) put("idCard", p.idCard)
.apply() 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? { fun loadBmlUserProfile(): BmlUserProfile? {
val name = prefs.getString("bml_full_name", null) ?: return null val key = getOrCreateKey()
return BmlUserProfile( val enc = prefs.getString("bml_enc_profile", null) ?: return null
fullName = name, return try {
email = prefs.getString("bml_profile_email", "") ?: "", val o = JSONObject(decrypt(enc, key))
mobile = prefs.getString("bml_profile_mobile", "") ?: "", BmlUserProfile(
customerId = prefs.getString("bml_profile_customer_id", "") ?: "", fullName = o.optString("fullName"),
idCard = prefs.getString("bml_profile_idcard", "") ?: "", email = o.optString("email"),
birthdate = prefs.getString("bml_profile_birthdate", "") ?: "" 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 { private fun getOrCreateKey(): SecretKey {
val ks = KeyStore.getInstance("AndroidKeyStore").also { it.load(null) } val ks = KeyStore.getInstance("AndroidKeyStore").also { it.load(null) }
ks.getKey(keyAlias, null)?.let { return it as SecretKey } ks.getKey(keyAlias, null)?.let { return it as SecretKey }
@@ -193,16 +299,16 @@ class CredentialStore(context: Context) {
val cipher = Cipher.getInstance(transformation) val cipher = Cipher.getInstance(transformation)
cipher.init(Cipher.ENCRYPT_MODE, key) cipher.init(Cipher.ENCRYPT_MODE, key)
val iv = cipher.iv 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) return Base64.encodeToString(iv, Base64.NO_WRAP) + ":" + Base64.encodeToString(ct, Base64.NO_WRAP)
} }
private fun decrypt(encoded: String, key: SecretKey): String { private fun decrypt(encoded: String, key: SecretKey): String {
val parts = encoded.split(":") val colon = encoded.indexOf(':')
val iv = Base64.decode(parts[0], Base64.NO_WRAP) val iv = Base64.decode(encoded.substring(0, colon), Base64.NO_WRAP)
val ct = Base64.decode(parts[1], Base64.NO_WRAP) val ct = Base64.decode(encoded.substring(colon + 1), Base64.NO_WRAP)
val cipher = Cipher.getInstance(transformation) val cipher = Cipher.getInstance(transformation)
cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(128, iv)) cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(128, iv))
return String(cipher.doFinal(ct)) return String(cipher.doFinal(ct), Charsets.UTF_8)
} }
} }
@@ -31,7 +31,7 @@ object FinancingCache {
}) })
} }
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) 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) { fun clear(context: Context) {
@@ -39,9 +39,10 @@ object FinancingCache {
} }
fun load(context: Context): List<MibFinanceDeal> { 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() .getString(KEY_MIB, null) ?: return emptyList()
return try { return try {
val json = CacheEncryption.decrypt(raw)
val arr = JSONArray(json) val arr = JSONArray(json)
(0 until arr.length()).map { i -> (0 until arr.length()).map { i ->
val o = arr.getJSONObject(i) val o = arr.getJSONObject(i)
@@ -39,7 +39,7 @@ object ForeignLimitsCache {
}) })
} }
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit().putString(KEY, arr.toString()).apply() .edit().putString(KEY, CacheEncryption.encrypt(arr.toString())).apply()
} }
fun clear(context: Context) { fun clear(context: Context) {
@@ -47,9 +47,10 @@ object ForeignLimitsCache {
} }
fun load(context: Context): List<HomeViewModel.BmlLimitsData> { 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() .getString(KEY, null) ?: return emptyList()
return try { return try {
val json = CacheEncryption.decrypt(raw)
val arr = JSONArray(json) val arr = JSONArray(json)
(0 until arr.length()).map { i -> (0 until arr.length()).map { i ->
val entry = arr.getJSONObject(i) val entry = arr.getJSONObject(i)
@@ -37,7 +37,7 @@ object RecentsCache {
}) })
} }
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) 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) { fun remove(context: Context, accountNumber: String) {
@@ -54,7 +54,7 @@ object RecentsCache {
}) })
} }
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit().putString(KEY, arr.toString()).apply() .edit().putString(KEY, CacheEncryption.encrypt(arr.toString())).apply()
} }
fun clear(context: Context) { fun clear(context: Context) {
@@ -62,9 +62,10 @@ object RecentsCache {
} }
fun load(context: Context): List<RecentPick> { 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() .getString(KEY, null) ?: return emptyList()
return try { return try {
val json = CacheEncryption.decrypt(raw)
val arr = JSONArray(json) val arr = JSONArray(json)
(0 until arr.length()).map { i -> (0 until arr.length()).map { i ->
val o = arr.getJSONObject(i) val o = arr.getJSONObject(i)
+2
View File
@@ -36,6 +36,8 @@
<string name="biometric_prompt_subtitle">ފިންގަޕްރިންޓް ނުވަތަ މޫނު ބޭނުން ކޮށްގެން ހުޅުވާ</string> <string name="biometric_prompt_subtitle">ފިންގަޕްރިންޓް ނުވަތަ މޫނު ބޭނުން ކޮށްގެން ހުޅުވާ</string>
<string name="biometric_negative_btn">PIN / ޕެޓަން ބޭނުން ކުރޭ</string> <string name="biometric_negative_btn">PIN / ޕެޓަން ބޭނުން ކުރޭ</string>
<string name="unlock_failed">ދިމައެއް ނުވި — އަލުން ކަނޑޭ</string> <string name="unlock_failed">ދިމައެއް ނުވި — އަލުން ކަނޑޭ</string>
<string name="unlock_attempts_remaining">ދިމައެއް ނުވި — %d ފަހަރު ބާކީ</string>
<string name="unlock_locked_out">ވަރަށް ގިނަ ފަހަރު ނުކުރިހުރި. %d ސިކުންތު ފަހުން ލޮކް ހިލޭ.</string>
<!-- Security setup --> <!-- Security setup -->
<string name="security_setup">އެޕް ރައްކާތެރި ކުރޭ</string> <string name="security_setup">އެޕް ރައްކާތެރި ކުރޭ</string>
+2
View File
@@ -36,6 +36,8 @@
<string name="biometric_prompt_subtitle">Use your fingerprint or face to unlock</string> <string name="biometric_prompt_subtitle">Use your fingerprint or face to unlock</string>
<string name="biometric_negative_btn">Use PIN / Pattern</string> <string name="biometric_negative_btn">Use PIN / Pattern</string>
<string name="unlock_failed">Incorrect — try again</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 --> <!-- Security setup -->
<string name="security_setup">Secure Your App</string> <string name="security_setup">Secure Your App</string>
+10 -4
View File
@@ -6,8 +6,14 @@
See https://developer.android.com/about/versions/12/backup-restore See https://developer.android.com/about/versions/12/backup-restore
--> -->
<full-backup-content> <full-backup-content>
<!-- <exclude domain="sharedpref" path="credential_store.xml"/>
<include domain="sharedpref" path="."/> <exclude domain="sharedpref" path="mib_prefs.xml"/>
<exclude domain="sharedpref" path="device.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> </full-backup-content>
+20 -8
View File
@@ -5,15 +5,27 @@
--> -->
<data-extraction-rules> <data-extraction-rules>
<cloud-backup> <cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up. <exclude domain="sharedpref" path="credential_store.xml"/>
<include .../> <exclude domain="sharedpref" path="mib_prefs.xml"/>
<exclude .../> <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> </cloud-backup>
<!--
<device-transfer> <device-transfer>
<include .../> <exclude domain="sharedpref" path="credential_store.xml"/>
<exclude .../> <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> </device-transfer>
-->
</data-extraction-rules> </data-extraction-rules>
@@ -1,6 +1,2 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<network-security-config> <network-security-config/>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">faisanet.mib.com.mv</domain>
</domain-config>
</network-security-config>