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