forked from shihaam/thijooree
security: encrypt credentials, caches, and harden lock screen
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user