forked from shihaam/thijooree
working mib login and list accounts
This commit is contained in:
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user