working mib login and list accounts

This commit is contained in:
2026-05-14 05:24:11 +05:00
parent 31c8a5500d
commit 076a58359a
73 changed files with 3076 additions and 550 deletions
@@ -0,0 +1,50 @@
package sh.sar.basedbank.api.mib
import android.util.Base64
import org.json.JSONObject
import java.math.BigInteger
import java.security.MessageDigest
import javax.crypto.Cipher
import javax.crypto.spec.SecretKeySpec
object MibCrypto {
const val DEFAULT_KEY = "8M3L9SBF1AC4FRE56788M3L9SBF1AC4FRE5678"
private val A = BigInteger(
"1563516802667282387226490351799736881442299778484610378722158765594241028592123324764949712696577"
)
private val P = BigInteger(
"2410312426921032588552076022197566074856950548502459942654116941958108831682612228890093858261341614673227141477904012196503648957050582631942730706805009223062734745341073406696246014589361659774041027169249453200378729434170325843778659198143763193776859869524088940195577346119843545301547043747207749969763750084308926339295559968882457872412993810129130294592999947926365264059284647209730384947211681434464714438488520940127459844288859336526896320919633919"
)
// cmod = G^A mod P (sent in every DH key exchange request)
val CMOD: String = BigInteger.TWO.modPow(A, P).toString()
fun deriveSessionKey(smod: String): String {
val shared = BigInteger(smod).modPow(A, P)
val sha256hex = MessageDigest.getInstance("SHA-256")
.digest(shared.toString().toByteArray())
.joinToString("") { "%02X".format(it) }
val keyBytes = sha256hex.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
return Base64.encodeToString(keyBytes, Base64.NO_WRAP)
}
fun encrypt(json: JSONObject, key: String): String {
val plaintext = json.toString().toByteArray(Charsets.UTF_8)
val keyBytes = key.toByteArray(Charsets.ISO_8859_1)
val cipher = Cipher.getInstance("Blowfish/ECB/PKCS5Padding")
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(keyBytes, "Blowfish"))
val ct = cipher.doFinal(plaintext)
return Base64.encodeToString(ct, Base64.NO_WRAP)
}
fun decrypt(ciphertextB64: String, key: String): JSONObject {
val keyBytes = key.toByteArray(Charsets.ISO_8859_1)
val ct = Base64.decode(ciphertextB64, Base64.DEFAULT)
val cipher = Cipher.getInstance("Blowfish/ECB/PKCS5Padding")
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(keyBytes, "Blowfish"))
val plaintext = cipher.doFinal(ct)
return JSONObject(String(plaintext, Charsets.UTF_8))
}
}
@@ -0,0 +1,302 @@
package sh.sar.basedbank.api.mib
import android.util.Log
import okhttp3.FormBody
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 kotlin.random.Random
class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
private val TAG = "MibLoginFlow"
private val BASE_URL = "https://faisanet.mib.com.mv/faisamobilex_smvc/"
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.addInterceptor { chain ->
val req = chain.request().newBuilder()
.header("User-Agent", "android/1.0")
.header("Accept", "application/json")
.header("Content-Type", "application/x-www-form-urlencoded; charset=utf-8")
.build()
chain.proceed(req)
}
.build()
// ─── Public entry point ───────────────────────────────────────────────────
/**
* Full login flow. Automatically handles first-time device registration
* vs. subsequent logins using stored key1/key2.
*
* Returns list of accounts from all profiles on success.
*/
fun login(username: String, password: String, otpSeed: String): List<MibAccount> {
val appId = getOrCreateAppId()
Log.d(TAG, "login: appId=$appId")
val key1 = prefs.getString("mib_key1_$username", null)
val key2 = prefs.getString("mib_key2_$username", null)
Log.d(TAG, "login: stored keys present=${key1 != null && key2 != null}")
return if (key1 != null && key2 != null) {
Log.d(TAG, "login: taking regular login path")
regularLogin(username, password, appId, key1, key2)
} else {
Log.d(TAG, "login: taking first-time registration path")
firstTimeRegistration(username, password, otpSeed, appId)
}
}
// ─── First-time registration ──────────────────────────────────────────────
private fun firstTimeRegistration(
username: String, password: String, otpSeed: String, appId: String
): List<MibAccount> {
Log.d(TAG, "[reg] step 0: key exchange (sfunc=r)")
val (session1, _) = initialKeyExchange(appId, MibCrypto.DEFAULT_KEY, "r")
Log.d(TAG, "[reg] step 0 done: xxid=${session1.xxid}")
Log.d(TAG, "[reg] step 1: getAuthType (A44)")
val userSalt = getAuthType(session1, username)
Log.d(TAG, "[reg] step 1 done: userSalt length=${userSalt.length}")
Log.d(TAG, "[reg] step 2: registration init (C41)")
Log.d(TAG, "[reg] username='$username' password='$password' userSalt='$userSalt'")
val clientSalt = randomAlpha(32)
val pgf03 = computePgf03(password, userSalt, clientSalt)
Log.d(TAG, "[reg] pgf03=$pgf03")
val regInitPayload = baseData(session1, "C41").apply {
put("uname", username)
put("pgf03", pgf03)
put("clientSalt", clientSalt)
}
val regInitResp = doRequest(session1, regInitPayload, "n")
Log.d(TAG, "[reg] step 2 response: $regInitResp")
check(regInitResp.optBoolean("success", false)) {
regInitResp.optString("reasonText", "Registration init failed")
}
Log.d(TAG, "[reg] step 3: OTP verify (C42)")
val otp = generateOtp(otpSeed)
Log.d(TAG, "[reg] generated OTP=$otp")
val otpPayload = baseData(session1, "C42").apply {
put("otp", otp)
put("uname", username)
put("otpType", "3")
}
val otpResp = doRequest(session1, otpPayload, "n")
Log.d(TAG, "[reg] step 3 response: $otpResp")
check(otpResp.optBoolean("success", false)) {
otpResp.optString("reasonText", "OTP verification failed")
}
val keyData = otpResp.getJSONArray("data").getJSONObject(0)
val key1 = keyData.getString("key1")
val key2 = keyData.getString("key2")
Log.d(TAG, "[reg] stored key1/key2 for user=$username")
prefs.edit().putString("mib_key1_$username", key1).putString("mib_key2_$username", key2).apply()
return regularLogin(username, password, appId, key1, key2)
}
// ─── Regular login ────────────────────────────────────────────────────────
private fun regularLogin(
username: String, password: String,
appId: String, key1: String, key2: String
): List<MibAccount> {
Log.d(TAG, "[login] step 4: key exchange (sfunc=i)")
val (session2, _) = initialKeyExchange(appId, key1, "i", key2)
Log.d(TAG, "[login] step 4 done: xxid=${session2.xxid}")
Log.d(TAG, "[login] step 5: getAuthType (A44)")
val userSalt = getAuthType(session2, username)
Log.d(TAG, "[login] step 5 done: userSalt length=${userSalt.length}")
Log.d(TAG, "[login] step 6: login init (A41)")
val clientSalt = randomAlpha(32)
val pgf03 = computePgf03(password, userSalt, clientSalt)
Log.d(TAG, "[login] pgf03 length=${pgf03.length}")
val loginPayload = baseData(session2, "A41").apply {
put("uname", username)
put("pgf03", pgf03)
put("clientSalt", clientSalt)
put("pmodTime", 0)
put("requireBankData", 1)
}
val loginResp = doRequest(session2, loginPayload, "n")
Log.d(TAG, "[login] step 6 response: success=${loginResp.optBoolean("success")} reasonCode=${loginResp.optString("reasonCode")} reasonText=${loginResp.optString("reasonText")}")
check(loginResp.optBoolean("success", false)) {
loginResp.optString("reasonText", "Login init failed")
}
val profiles = parseProfiles(loginResp)
Log.d(TAG, "[login] parsed ${profiles.size} profiles")
Log.d(TAG, "[login] step 7: fetch all profiles")
return fetchAllProfiles(session2, profiles)
}
// ─── Helpers ─────────────────────────────────────────────────────────────
private fun initialKeyExchange(
appId: String, encKey: String, sfunc: String, key2: String? = null
): Pair<MibSession, String> {
val innerPayload = JSONObject().apply {
put("cmod", MibCrypto.CMOD)
put("appId", appId)
put("routePath", "S40")
put("sodium", MibNonce.randomSodium())
put("xxid", MibNonce.randomXxid())
}
val encrypted = MibCrypto.encrypt(innerPayload, encKey)
val formBody = FormBody.Builder()
.add("sfunc", sfunc)
.apply { if (key2 != null) add("key2", key2) }
.add("data", encrypted)
.build()
val response = post(formBody)
Log.d(TAG, "keyExchange($sfunc) raw response (first 80): ${response.take(80)}")
val respJson = MibCrypto.decrypt(response, encKey)
Log.d(TAG, "keyExchange($sfunc) decrypted: success=${respJson.optBoolean("success")} reasonText=${respJson.optString("reasonText")}")
check(respJson.optBoolean("success", false)) {
respJson.optString("reasonText", "Key exchange failed")
}
val smod = respJson.getString("smod")
val sessionKey = MibCrypto.deriveSessionKey(smod)
val xxid = respJson.getString("xxid")
val nonceGen = respJson.getString("nonceGenerator")
return Pair(MibSession(appId, xxid, nonceGen, sessionKey), xxid)
}
private fun getAuthType(session: MibSession, username: String): String {
val payload = baseData(session, "A44").apply {
put("uname", username)
}
val resp = doRequest(session, payload, "n")
return resp.getJSONArray("data").getJSONObject(0).getString("userSalt")
}
private fun doRequest(session: MibSession, data: JSONObject, sfunc: String): JSONObject {
val routePath = data.optString("routePath", "?")
Log.d(TAG, "doRequest: routePath=$routePath xxid=${session.xxid.take(16)}...")
val encrypted = MibCrypto.encrypt(data, session.sessionKey)
val formBody = FormBody.Builder()
.add("xxid", session.xxid)
.add("sfunc", sfunc)
.add("data", encrypted)
.build()
val response = post(formBody)
Log.d(TAG, "doRequest($routePath) raw response (first 80): ${response.take(80)}")
val result = MibCrypto.decrypt(response, session.sessionKey)
Log.d(TAG, "doRequest($routePath) decrypted: success=${result.optBoolean("success")} reasonText=${result.optString("reasonText")}")
return result
}
private fun baseData(session: MibSession, routePath: String): JSONObject = JSONObject().apply {
put("nonce", MibNonce.generate(session.nonceGenerator))
put("appId", session.appId)
put("sodium", MibNonce.randomSodium())
put("routePath", routePath)
put("xxid", session.xxid)
}
private fun fetchAllProfiles(session: MibSession, profiles: List<MibProfile>): List<MibAccount> {
val allAccounts = mutableListOf<MibAccount>()
for (profile in profiles) {
val payload = baseData(session, "P47").apply {
put("profileType", profile.profileType)
put("profileId", profile.profileId)
}
val resp = doRequest(session, payload, "n")
if (!resp.optBoolean("success", false)) {
Log.w(TAG, "P47 failed for profile ${profile.name}: ${resp.optString("reasonText")}")
continue
}
val accountBalances = resp.optJSONArray("accountBalance") ?: continue
for (i in 0 until accountBalances.length()) {
val a = accountBalances.getJSONObject(i)
allAccounts.add(
MibAccount(
profileName = profile.name,
profileType = profile.profileType,
accountNumber = a.optString("accountNumber"),
accountBriefName = a.optString("accountBriefName"),
currencyName = a.optString("currencyName"),
accountTypeName = a.optString("accountTypeName"),
availableBalance = a.optString("availableBalance"),
currentBalance = a.optString("currentBalance"),
blockedAmount = a.optString("blockedAmount"),
mvrBalance = a.optString("mvrBalance"),
statusDesc = a.optString("statusDesc")
)
)
}
}
return allAccounts
}
private fun parseProfiles(loginResp: JSONObject): List<MibProfile> {
val arr = loginResp.optJSONArray("operatingProfiles") ?: return emptyList()
return (0 until arr.length()).map { i ->
val p = arr.getJSONObject(i)
MibProfile(
profileId = p.optString("profileId"),
customerProfileId = p.optString("customerProfileId"),
annexId = p.optString("annexId"),
customerId = p.optString("customerId"),
name = p.optString("name"),
cifType = p.optString("cifType"),
profileType = p.optString("profileType"),
color = p.optString("color")
)
}
}
private fun post(body: FormBody): String {
val request = Request.Builder()
.url(BASE_URL)
.post(body)
.build()
val response = client.newCall(request).execute()
return response.body?.string() ?: throw IllegalStateException("Empty response body")
}
private fun computePgf03(password: String, userSalt: String, clientSalt: String): String {
fun sha256Upper(input: String) = MessageDigest.getInstance("SHA-256")
.digest(input.toByteArray())
.joinToString("") { "%02X".format(it) }
val h1 = sha256Upper(password)
val h2 = sha256Upper(h1 + userSalt)
return sha256Upper(clientSalt + h2)
}
private fun generateOtp(seed: String): String = Totp.generate(seed)
private fun getOrCreateAppId(): String {
var id = prefs.getString("mib_app_id", null)
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()
}
return id
}
private fun randomAlpha(length: Int): String {
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
return (1..length).map { chars[Random.nextInt(chars.length)] }.joinToString("")
}
}
@@ -0,0 +1,33 @@
package sh.sar.basedbank.api.mib
data class MibSession(
val appId: String,
val xxid: String,
val nonceGenerator: String,
val sessionKey: String
)
data class MibProfile(
val profileId: String,
val customerProfileId: String,
val annexId: String,
val customerId: String,
val name: String,
val cifType: String,
val profileType: String,
val color: String
)
data class MibAccount(
val profileName: String,
val profileType: String,
val accountNumber: String,
val accountBriefName: String,
val currencyName: String,
val accountTypeName: String,
val availableBalance: String,
val currentBalance: String,
val blockedAmount: String,
val mvrBalance: String,
val statusDesc: String
)
@@ -0,0 +1,79 @@
package sh.sar.basedbank.api.mib
import kotlin.math.floor
import kotlin.random.Random
object MibNonce {
/**
* Generate a nonce string from the nonceGenerator returned by the DH key exchange.
*
* Phase 1: for each group, take the first token's number * random(1-99), pad to 5 digits.
* Collect lastTwo (last 2 digits), digitSum (sum of all digits), cumSum.
* Phase 2: for each group, tokens 1-7 compute values using the operation letter.
* Each result's last 2 digits become the nonce digit and carry for next step.
*
* Operations: M=(carry%num)+ds+cs, A=carry+num+ds+cs, S=(carry^2)+num+ds+cs,
* X=(carry*num)+ds+cs, C=(carry^3)+num+ds+cs
*/
fun generate(nonceGenerator: String): String {
val groups = nonceGenerator.split("-")
val paddedList = mutableListOf<String>()
val lastTwoList = mutableListOf<Int>()
val digitSumList = mutableListOf<Int>()
var cumSum = 0
// Phase 1
for (group in groups) {
val tokens = group.trim().split(" ")
val n = tokens[0].filter { it.isDigit() }.toInt()
val r = floor(Random.nextDouble() * 99).toInt() + 1
val product = n * r
val padded = product.toString().padStart(5, '0')
val ds = padded.sumOf { it.digitToInt() }
val lt = padded.takeLast(2).toInt()
paddedList.add(padded)
lastTwoList.add(lt)
digitSumList.add(ds)
cumSum += ds
}
// Phase 2
val resultGroups = mutableListOf<String>()
for ((i, group) in groups.withIndex()) {
val tokens = group.trim().split(" ")
var carry = lastTwoList[i]
val ds = digitSumList[i]
val nonceDigits = mutableListOf<Int>()
for (j in 1..7) {
val token = tokens[j]
val op = token.filter { it.isLetter() }
val num = token.filter { it.isDigit() }.toInt()
val value: Long = when (op) {
"M" -> (carry % num).toLong() + ds + cumSum
"A" -> carry.toLong() + num + ds + cumSum
"S" -> (carry.toLong() * carry) + num + ds + cumSum
"X" -> carry.toLong() * num + ds + cumSum
"C" -> (carry.toLong() * carry * carry) + num + ds + cumSum
else -> 0L
}
val digit = value.toString().takeLast(2).toInt()
nonceDigits.add(digit)
carry = digit
}
val groupStr = paddedList[i] + " " + nonceDigits.joinToString(" ") {
it.toString().padStart(2, '0')
}
resultGroups.add(groupStr)
}
return resultGroups.joinToString("-")
}
fun randomSodium(): String = (Random.nextLong(1_000_000L, 16_000_000L)).toString()
fun randomXxid(): String = Random.nextLong(0L, (1L shl 40)).toString()
}