update docs
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 4s
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 4s
This commit is contained in:
@@ -187,15 +187,15 @@ The app presents different identities to different backends, which is intentiona
|
||||
| Backend | User-Agent sent |
|
||||
|---|---|
|
||||
| MIB API | `android/1.0` |
|
||||
| MIB WebView | `Mozilla/5.0 (Linux; Android 14; wv) AppleWebKit/537.36 ... Mobile Safari/537.36` |
|
||||
| MIB WebView | `Mozilla/5.0 (Linux; Android {version}; wv) AppleWebKit/537.36 ... Mobile Safari/537.36` |
|
||||
| BML web steps | `Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0` |
|
||||
| BML API calls | `bml-mobile-banking/345 (POCO; Android 14; 22101320I)` |
|
||||
| BML API calls | `bml-mobile-banking/345 (POCO; Android {version}; {model})` |
|
||||
| Fahipay login/OTP | WebView UA with actual `Build.MODEL` |
|
||||
| Fahipay API calls | `okhttp/4.12.0` |
|
||||
| Ooredoo | No custom UA |
|
||||
| Dhiraagu | `Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0` |
|
||||
|
||||
The BML API User-Agent hardcodes a specific device model (`POCO; Android 14; 22101320I`) to mimic the official BML mobile app. This is required for the API to accept requests.
|
||||
The BML API User-Agent hardcodes a specific device model (`POCO; Android {version}; {model}`) to mimic the official BML mobile app. This is required for the API to accept requests.
|
||||
|
||||
The Fahipay login includes `Build.MODEL` and `Build.MANUFACTURER` from the actual device, sent as `device[model]` and `device[manufacturer]` form fields. This is a **device fingerprint** sent to Fahipay on every login.
|
||||
|
||||
@@ -327,7 +327,7 @@ All hardcoded values in this codebase are protocol constants extracted from reve
|
||||
| `P = BigInteger("2410312426921...")` | `MibCrypto.kt:17` | MIB DH prime modulus. Same value as in the official app. | Public parameter — no risk. |
|
||||
| `CLIENT_ID = "98C83590-513F-4716-B02B-EC68B7D9E7E7"` | `BmlLoginFlow.kt:30` | BML OAuth client ID, extracted from the official BML mobile app APK. | Not a personal secret — it is the same value for all BML mobile clients. |
|
||||
| `REDIRECT_URI = "https://app.bankofmaldives.com.mv/oauth/mobile-callback"` | `BmlLoginFlow.kt:31` | BML OAuth redirect URI, must match what BML's server expects. | Fixed protocol value. |
|
||||
| `APP_USER_AGENT = "bml-mobile-banking/345 (POCO; Android 14; 22101320I)"` | `BmlLoginFlow.kt:32` | Impersonates the official BML app and a specific POCO device model. | Intentional compatibility measure; no personal data. |
|
||||
| `APP_USER_AGENT = "bml-mobile-banking/345 (POCO; Android {version}; {model})"` | `BmlLoginFlow.kt:32` | Impersonates the official BML app and a specific POCO device model. | Intentional compatibility measure; no personal data. |
|
||||
| `APP_VERSION = "2.1.43.345"` | `BmlLoginFlow.kt:33` | BML app version string being impersonated. | Fixed protocol value. |
|
||||
| `website_id = "CA2BB809-3A22-485B-A518-DA6B6DE653A5"` | `DhiraaguClient.kt:45` | Dhiraagu SDK identifier embedded in the lookup URL. Extracted from Dhiraagu's public Easy Pay page. | Public value embedded in their web page; not a secret. |
|
||||
| `MIB_SWIFT_ON_BML = "F4E79935-3E73-E611-80DD-00155D020F0A"` | `AddContactSheetFragment.kt:457` | BML's internal bank code (SWIFT/FinInstnId) used to identify MIB as the counterpart bank during BML transfers. | Protocol constant, not a secret. |
|
||||
|
||||
@@ -50,8 +50,8 @@ GET https://www.bankofmaldives.com.mv/internetbanking/oauth/authorize
|
||||
|
||||
```bash
|
||||
curl --request GET \
|
||||
--url 'https://www.bankofmaldives.com.mv/internetbanking/oauth/authorize?redirect_uri=https%3A%2F%2Fapp.bankofmaldives.com.mv%2Foauth%2Fmobile-callback&client_id=98C83590-513F-4716-B02B-EC68B7D9E7E7&response_type=code&state=<state>&nonce=<nonce>&code_challenge=<code_challenge>&code_challenge_method=S256&Device-ID=<device_id>&User-Agent=bml-mobile-banking%2F348+%28Xiaomi%3B+Android+14%3B+22101320I%29&x-app-version=2.1.44.348' \
|
||||
--header 'User-Agent: Mozilla/5.0 (Android 14; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0' \
|
||||
--url 'https://www.bankofmaldives.com.mv/internetbanking/oauth/authorize?redirect_uri=https%3A%2F%2Fapp.bankofmaldives.com.mv%2Foauth%2Fmobile-callback&client_id=98C83590-513F-4716-B02B-EC68B7D9E7E7&response_type=code&state=<state>&nonce=<nonce>&code_challenge=<code_challenge>&code_challenge_method=S256&Device-ID=<device_id>&User-Agent=bml-mobile-banking%2F348+%28{manufacturer}%3B+Android+14%3B+{model}%29&x-app-version=2.1.44.348' \
|
||||
--header 'User-Agent: Mozilla/5.0 (Android {version}; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0' \
|
||||
--cookie 'blaze_session=<session>; blaze_identity=<identity>'
|
||||
```
|
||||
|
||||
@@ -90,14 +90,14 @@ POST https://www.bankofmaldives.com.mv/internetbanking/oauth/token
|
||||
```bash
|
||||
curl --request POST \
|
||||
--url 'https://www.bankofmaldives.com.mv/internetbanking/oauth/token' \
|
||||
--header 'User-Agent: Mozilla/5.0 (Android 14; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0' \
|
||||
--header 'User-Agent: Mozilla/5.0 (Android {version}; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0' \
|
||||
--data 'grant_type=authorization_code' \
|
||||
--data 'code=<auth_code>' \
|
||||
--data 'code_verifier=<code_verifier>' \
|
||||
--data 'client_id=98C83590-513F-4716-B02B-EC68B7D9E7E7' \
|
||||
--data 'redirect_uri=https%3A%2F%2Fapp.bankofmaldives.com.mv%2Foauth%2Fmobile-callback' \
|
||||
--data 'Device-ID=<device_id>' \
|
||||
--data 'User-Agent=bml-mobile-banking%2F348+%28Xiaomi%3B+Android+14%3B+22101320I%29' \
|
||||
--data 'User-Agent=bml-mobile-banking%2F348+%28{manufacturer}%3B+Android+14%3B+{model}%29' \
|
||||
--data 'x-app-version=2.1.44.348'
|
||||
```
|
||||
|
||||
@@ -147,12 +147,12 @@ POST https://www.bankofmaldives.com.mv/internetbanking/oauth/token
|
||||
```bash
|
||||
curl --request POST \
|
||||
--url 'https://www.bankofmaldives.com.mv/internetbanking/oauth/token' \
|
||||
--header 'User-Agent: bml-mobile-banking/348 (Xiaomi; Android 14; 22101320I)' \
|
||||
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
|
||||
--data 'grant_type=refresh_token' \
|
||||
--data 'refresh_token=def50200aabbcc...' \
|
||||
--data 'client_id=98C83590-513F-4716-B02B-EC68B7D9E7E7' \
|
||||
--data 'Device-ID=a1b2c3d4e5f60718' \
|
||||
--data 'User-Agent=bml-mobile-banking%2F348+%28Xiaomi%3B+Android+14%3B+22101320I%29' \
|
||||
--data 'User-Agent=bml-mobile-banking%2F348+%28{manufacturer}%3B+Android+14%3B+{model}%29' \
|
||||
--data 'x-app-version=2.1.44.348'
|
||||
```
|
||||
|
||||
|
||||
@@ -25,14 +25,14 @@ GET https://www.bankofmaldives.com.mv/internetbanking/api/mobile/dashboard
|
||||
| Header | Value |
|
||||
|---|---|
|
||||
| `Authorization` | `Bearer <access_token>` |
|
||||
| `User-Agent` | `bml-mobile-banking/348 (Xiaomi; Android 14; 22101320I)` |
|
||||
| `User-Agent` | `bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})` |
|
||||
| `x-app-version` | `2.1.44.348` |
|
||||
|
||||
```bash
|
||||
curl --request GET \
|
||||
--url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/dashboard' \
|
||||
--header 'Authorization: Bearer <access_token>' \
|
||||
--header 'User-Agent: bml-mobile-banking/348 (Xiaomi; Android 14; 22101320I)' \
|
||||
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
|
||||
--header 'x-app-version: 2.1.44.348'
|
||||
```
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ GET https://www.bankofmaldives.com.mv/internetbanking/api/mobile/profile
|
||||
curl --request GET \
|
||||
--url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/profile' \
|
||||
--header 'Authorization: Bearer <access_token>' \
|
||||
--header 'User-Agent: bml-mobile-banking/348 (Xiaomi; Android 14; 22101320I)' \
|
||||
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
|
||||
--header 'x-app-version: 2.1.44.348'
|
||||
```
|
||||
|
||||
@@ -40,7 +40,7 @@ GET https://www.bankofmaldives.com.mv/internetbanking/api/mobile/userinfo
|
||||
curl --request GET \
|
||||
--url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/userinfo' \
|
||||
--header 'Authorization: Bearer <access_token>' \
|
||||
--header 'User-Agent: bml-mobile-banking/348 (Xiaomi; Android 14; 22101320I)' \
|
||||
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
|
||||
--header 'x-app-version: 2.1.44.348'
|
||||
```
|
||||
|
||||
@@ -95,7 +95,7 @@ GET https://www.bankofmaldives.com.mv/internetbanking/api/mobile/account/{id}
|
||||
curl --request GET \
|
||||
--url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/account/loan001' \
|
||||
--header 'Authorization: Bearer <access_token>' \
|
||||
--header 'User-Agent: bml-mobile-banking/348 (Xiaomi; Android 14; 22101320I)' \
|
||||
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
|
||||
--header 'x-app-version: 2.1.44.348'
|
||||
```
|
||||
|
||||
|
||||
@@ -31,14 +31,14 @@ GET https://www.bankofmaldives.com.mv/internetbanking/api/mobile/account/{accoun
|
||||
| Header | Value |
|
||||
|---|---|
|
||||
| `Authorization` | `Bearer <access_token>` |
|
||||
| `User-Agent` | `bml-mobile-banking/348 (Xiaomi; Android 14; 22101320I)` |
|
||||
| `User-Agent` | `bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})` |
|
||||
| `x-app-version` | `2.1.44.348` |
|
||||
|
||||
```bash
|
||||
curl --request GET \
|
||||
--url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/account/abc123def456/history/1' \
|
||||
--header 'Authorization: Bearer <access_token>' \
|
||||
--header 'User-Agent: bml-mobile-banking/348 (Xiaomi; Android 14; 22101320I)' \
|
||||
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
|
||||
--header 'x-app-version: 2.1.44.348'
|
||||
```
|
||||
|
||||
|
||||
@@ -42,14 +42,14 @@ POST https://www.bankofmaldives.com.mv/internetbanking/api/mobile/card/statement
|
||||
| Header | Value |
|
||||
|---|---|
|
||||
| `Authorization` | `Bearer <access_token>` |
|
||||
| `User-Agent` | `bml-mobile-banking/348 (Xiaomi; Android 14; 22101320I)` |
|
||||
| `User-Agent` | `bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})` |
|
||||
| `x-app-version` | `2.1.44.348` |
|
||||
|
||||
```bash
|
||||
curl --request POST \
|
||||
--url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/card/statement' \
|
||||
--header 'Authorization: Bearer <access_token>' \
|
||||
--header 'User-Agent: bml-mobile-banking/348 (Xiaomi; Android 14; 22101320I)' \
|
||||
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
|
||||
--header 'x-app-version: 2.1.44.348' \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data '{"card":"card001","month":"2026-05"}'
|
||||
|
||||
@@ -25,7 +25,7 @@ GET https://www.bankofmaldives.com.mv/internetbanking/api/mobile/transfer
|
||||
curl --request GET \
|
||||
--url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/transfer' \
|
||||
--header 'Authorization: Bearer <access_token>' \
|
||||
--header 'User-Agent: bml-mobile-banking/348 (Xiaomi; Android 14; 22101320I)' \
|
||||
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
|
||||
--header 'x-app-version: 2.1.44.348'
|
||||
```
|
||||
|
||||
@@ -121,7 +121,7 @@ For `DOT` (outside bank) transfers, add `"bank"`:
|
||||
| Header | Value |
|
||||
|---|---|
|
||||
| `Authorization` | `Bearer <access_token>` |
|
||||
| `User-Agent` | `bml-mobile-banking/348 (Xiaomi; Android 14; 22101320I)` |
|
||||
| `User-Agent` | `bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})` |
|
||||
| `x-app-version` | `2.1.44.348` |
|
||||
| `accept` | `application/json` |
|
||||
|
||||
@@ -129,7 +129,7 @@ For `DOT` (outside bank) transfers, add `"bank"`:
|
||||
curl --request POST \
|
||||
--url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/transfer' \
|
||||
--header 'Authorization: Bearer <access_token>' \
|
||||
--header 'User-Agent: bml-mobile-banking/348 (Xiaomi; Android 14; 22101320I)' \
|
||||
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
|
||||
--header 'x-app-version: 2.1.44.348' \
|
||||
--header 'accept: application/json' \
|
||||
--header 'Content-Type: application/json' \
|
||||
@@ -184,7 +184,7 @@ Repeat the exact same body as Step 1, adding the `otp` field (and optionally `re
|
||||
curl --request POST \
|
||||
--url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/transfer' \
|
||||
--header 'Authorization: Bearer <access_token>' \
|
||||
--header 'User-Agent: bml-mobile-banking/348 (Xiaomi; Android 14; 22101320I)' \
|
||||
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
|
||||
--header 'x-app-version: 2.1.44.348' \
|
||||
--header 'accept: application/json' \
|
||||
--header 'Content-Type: application/json' \
|
||||
|
||||
@@ -16,7 +16,7 @@ GET https://www.bankofmaldives.com.mv/internetbanking/api/mobile/contacts
|
||||
curl --request GET \
|
||||
--url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/contacts' \
|
||||
--header 'Authorization: Bearer <access_token>' \
|
||||
--header 'User-Agent: bml-mobile-banking/348 (Xiaomi; Android 14; 22101320I)' \
|
||||
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
|
||||
--header 'x-app-version: 2.1.44.348'
|
||||
```
|
||||
|
||||
@@ -92,7 +92,7 @@ POST https://www.bankofmaldives.com.mv/internetbanking/api/mobile/contacts
|
||||
curl --request POST \
|
||||
--url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/contacts' \
|
||||
--header 'Authorization: Bearer <access_token>' \
|
||||
--header 'User-Agent: bml-mobile-banking/348 (Xiaomi; Android 14; 22101320I)' \
|
||||
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
|
||||
--header 'x-app-version: 2.1.44.348' \
|
||||
--header 'Accept: application/json' \
|
||||
--header 'Content-Type: application/json' \
|
||||
@@ -133,7 +133,7 @@ BML does not support `DELETE` directly — the delete is sent as a POST with a `
|
||||
curl --request POST \
|
||||
--url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/contacts/1' \
|
||||
--header 'Authorization: Bearer <access_token>' \
|
||||
--header 'User-Agent: bml-mobile-banking/348 (Xiaomi; Android 14; 22101320I)' \
|
||||
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
|
||||
--header 'x-app-version: 2.1.44.348' \
|
||||
--header 'accept: application/json' \
|
||||
--header 'Content-Type: application/json' \
|
||||
|
||||
@@ -22,7 +22,7 @@ GET https://www.bankofmaldives.com.mv/internetbanking/api/mobile/validate/accoun
|
||||
curl --request GET \
|
||||
--url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/validate/account/7730000000001' \
|
||||
--header 'Authorization: Bearer <access_token>' \
|
||||
--header 'User-Agent: bml-mobile-banking/348 (Xiaomi; Android 14; 22101320I)' \
|
||||
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
|
||||
--header 'x-app-version: 2.1.44.348' \
|
||||
--header 'Accept: application/json'
|
||||
|
||||
@@ -30,7 +30,7 @@ curl --request GET \
|
||||
curl --request GET \
|
||||
--url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/validate/account/MALI' \
|
||||
--header 'Authorization: Bearer <access_token>' \
|
||||
--header 'User-Agent: bml-mobile-banking/348 (Xiaomi; Android 14; 22101320I)' \
|
||||
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
|
||||
--header 'x-app-version: 2.1.44.348' \
|
||||
--header 'Accept: application/json'
|
||||
```
|
||||
@@ -125,7 +125,7 @@ Verifies a Maldives Islamic Bank (MIB) account number via BML's Favara interbank
|
||||
curl --request GET \
|
||||
--url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/favara/account-verification/90101000000001000/MIB' \
|
||||
--header 'Authorization: Bearer <access_token>' \
|
||||
--header 'User-Agent: bml-mobile-banking/348 (Xiaomi; Android 14; 22101320I)' \
|
||||
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
|
||||
--header 'x-app-version: 2.1.44.348' \
|
||||
--header 'Accept: application/json'
|
||||
```
|
||||
|
||||
@@ -27,7 +27,7 @@ GET https://app.bankofmaldives.com.mv/api/v2/foreign-limits
|
||||
| Header | Value |
|
||||
|---|---|
|
||||
| `Authorization` | `Bearer <access_token>` |
|
||||
| `User-Agent` | `bml-mobile-banking/348 (Xiaomi; Android 14; 22101320I)` |
|
||||
| `User-Agent` | `bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})` |
|
||||
| `x-app-version` | `2.1.44.348` |
|
||||
| `Accept` | `application/json` |
|
||||
|
||||
@@ -35,7 +35,7 @@ GET https://app.bankofmaldives.com.mv/api/v2/foreign-limits
|
||||
curl --request GET \
|
||||
--url 'https://app.bankofmaldives.com.mv/api/v2/foreign-limits' \
|
||||
--header 'Authorization: Bearer <access_token>' \
|
||||
--header 'User-Agent: bml-mobile-banking/348 (Xiaomi; Android 14; 22101320I)' \
|
||||
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
|
||||
--header 'x-app-version: 2.1.44.348' \
|
||||
--header 'Accept: application/json'
|
||||
```
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
Reverse-engineered from traffic captures of the BML Mobile Banking Android app (`mv.com.bml.mib`).
|
||||
|
||||
[Play Store](https://play.google.com/store/apps/details?id=mv.com.bml.mib)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
@@ -77,7 +79,7 @@ bml-mobile-banking/348 (<Build.MANUFACTURER>; Android <Build.VERSION.RELEASE>; <
|
||||
|
||||
Example app UA:
|
||||
```
|
||||
bml-mobile-banking/348 (Xiaomi; Android 14; 22101320I)
|
||||
bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -29,8 +29,8 @@ POST https://fahipay.mv/api/app/login/
|
||||
| `device[available]` | `true` | See [common device fields](README.md#common-form-fields-device-info) |
|
||||
| `device[platform]` | `Android` | |
|
||||
| `device[uuid]` | `a1b2c3d4e5f60718` | Persistent 16-char hex UUID, generated once per install |
|
||||
| `device[model]` | `22101320I` | `Build.MODEL` |
|
||||
| `device[manufacturer]` | `Xiaomi` | `Build.MANUFACTURER` |
|
||||
| `device[model]` | `{model}` | `Build.MODEL` |
|
||||
| `device[manufacturer]` | `{manufacturer}` | `Build.MANUFACTURER` |
|
||||
| `device[isVirtual]` | `false` | |
|
||||
| `device[serial]` | `unknown` | |
|
||||
|
||||
@@ -47,7 +47,7 @@ curl --request POST \
|
||||
--header 'accept: application/json' \
|
||||
--header 'accept-encoding: gzip, deflate, br' \
|
||||
--header 'connection: keep-alive' \
|
||||
--header 'user-agent: Mozilla/5.0 (Linux; Android 14; 22101320I Build/AP2A.240905.003; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36' \
|
||||
--header 'user-agent: Mozilla/5.0 (Linux; Android {version}; {model} Build/AP2A.240905.003; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36' \
|
||||
--form 'email=A123456' \
|
||||
--form 'password=your_password' \
|
||||
--form 'grant_type=auth_id' \
|
||||
@@ -57,8 +57,8 @@ curl --request POST \
|
||||
--form 'device[available]=true' \
|
||||
--form 'device[platform]=Android' \
|
||||
--form 'device[uuid]=a1b2c3d4e5f60718' \
|
||||
--form 'device[model]=22101320I' \
|
||||
--form 'device[manufacturer]=Xiaomi' \
|
||||
--form 'device[model]={model}' \
|
||||
--form 'device[manufacturer]={manufacturer}' \
|
||||
--form 'device[isVirtual]=false' \
|
||||
--form 'device[serial]=unknown'
|
||||
```
|
||||
|
||||
@@ -38,8 +38,8 @@ POST https://fahipay.mv/api/app/otp/
|
||||
| `device[available]` | `true` | Same device fields as login — must match |
|
||||
| `device[platform]` | `Android` | |
|
||||
| `device[uuid]` | `a1b2c3d4e5f60718` | Must be the **same UUID** used in the login request |
|
||||
| `device[model]` | `22101320I` | |
|
||||
| `device[manufacturer]` | `Xiaomi` | |
|
||||
| `device[model]` | `{model}` | |
|
||||
| `device[manufacturer]` | `{manufacturer}` | |
|
||||
| `device[isVirtual]` | `false` | |
|
||||
| `device[serial]` | `unknown` | |
|
||||
|
||||
@@ -57,7 +57,7 @@ curl --request POST \
|
||||
--header 'accept: application/json' \
|
||||
--header 'accept-encoding: gzip, deflate, br' \
|
||||
--header 'connection: keep-alive' \
|
||||
--header 'user-agent: Mozilla/5.0 (Linux; Android 14; 22101320I Build/AP2A.240905.003; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36' \
|
||||
--header 'user-agent: Mozilla/5.0 (Linux; Android {version}; {model} Build/AP2A.240905.003; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36' \
|
||||
--form 'code=123456' \
|
||||
--form 'channel=totp' \
|
||||
--form 'action=login' \
|
||||
@@ -68,8 +68,8 @@ curl --request POST \
|
||||
--form 'device[available]=true' \
|
||||
--form 'device[platform]=Android' \
|
||||
--form 'device[uuid]=a1b2c3d4e5f60718' \
|
||||
--form 'device[model]=22101320I' \
|
||||
--form 'device[manufacturer]=Xiaomi' \
|
||||
--form 'device[model]={model}' \
|
||||
--form 'device[manufacturer]={manufacturer}' \
|
||||
--form 'device[isVirtual]=false' \
|
||||
--form 'device[serial]=unknown'
|
||||
```
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Fahipay API Documentation
|
||||
|
||||
Reverse-engineered from traffic captures of the Fahipay Android WebView app (`fahipay.mv`).
|
||||
Reverse-engineered from traffic captures of the Fahipay Android WebView app (`mv.fahipay`).
|
||||
|
||||
[Play Store](https://play.google.com/store/apps/details?id=mv.fahipay)
|
||||
|
||||
---
|
||||
|
||||
@@ -41,7 +43,7 @@ Content-Type: multipart/form-data; boundary=<boundary>
|
||||
accept: application/json
|
||||
accept-encoding: gzip, deflate, br
|
||||
connection: keep-alive
|
||||
user-agent: Mozilla/5.0 (Linux; Android 14; 22101320I Build/AP2A.240905.003; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36
|
||||
user-agent: Mozilla/5.0 (Linux; Android {version}; {model} Build/AP2A.240905.003; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36
|
||||
```
|
||||
|
||||
### Authenticated data endpoints
|
||||
@@ -64,8 +66,8 @@ All login and OTP requests include a standard set of device fields:
|
||||
| `device[available]` | `true` | Always `true` |
|
||||
| `device[platform]` | `Android` | Always `Android` |
|
||||
| `device[uuid]` | `a1b2c3d4e5f60718` | 16 hex chars, generated once per install, persisted |
|
||||
| `device[model]` | `22101320I` | Device model string |
|
||||
| `device[manufacturer]` | `Xiaomi` | Device manufacturer |
|
||||
| `device[model]` | `{model}` | Device model string |
|
||||
| `device[manufacturer]` | `{manufacturer}` | Device manufacturer |
|
||||
| `device[isVirtual]` | `false` | Always `false` |
|
||||
| `device[serial]` | `unknown` | Always `unknown` |
|
||||
|
||||
|
||||
234
docs/mibapi/01-encryption.md
Normal file
234
docs/mibapi/01-encryption.md
Normal file
@@ -0,0 +1,234 @@
|
||||
# Encryption, Key Exchange & Nonce
|
||||
|
||||
All traffic to the encrypted API (`faisanet.mib.com.mv`) uses Blowfish encryption. This document covers the cipher, the DH key exchange that derives the session key, and the nonce algorithm required by every request.
|
||||
|
||||
---
|
||||
|
||||
## Cipher
|
||||
|
||||
- **Algorithm**: Blowfish, ECB mode, PKCS5 padding
|
||||
- **Input**: raw UTF-8 bytes of the JSON payload string
|
||||
- **Key**: raw UTF-8 bytes of the key string
|
||||
- **Output**: base64-encoded ciphertext (URL-encoded when sent as form data)
|
||||
|
||||
```python
|
||||
from Crypto.Cipher import Blowfish
|
||||
from Crypto.Util.Padding import pad, unpad
|
||||
import base64, json
|
||||
from urllib.parse import quote
|
||||
|
||||
def encrypt(payload: dict, key: str) -> str:
|
||||
plaintext = json.dumps(payload, separators=(',', ':')).encode('utf-8')
|
||||
key_bytes = key.encode('latin-1')
|
||||
cipher = Blowfish.new(key_bytes, Blowfish.MODE_ECB)
|
||||
ct = cipher.encrypt(pad(plaintext, Blowfish.block_size))
|
||||
return base64.b64encode(ct).decode()
|
||||
|
||||
def decrypt(ciphertext_b64: str, key: str) -> dict:
|
||||
key_bytes = key.encode('latin-1')
|
||||
ct = base64.b64decode(ciphertext_b64)
|
||||
cipher = Blowfish.new(key_bytes, Blowfish.MODE_ECB)
|
||||
plaintext = unpad(cipher.decrypt(ct), Blowfish.block_size)
|
||||
return json.loads(plaintext.decode('utf-8'))
|
||||
|
||||
def build_request_body(payload: dict, key: str, extra_fields: dict = {}) -> str:
|
||||
sfunc = payload.get('sfunc', '')
|
||||
encrypted = encrypt(payload, key)
|
||||
body = '&'.join(f"{k}={v}" for k, v in extra_fields.items())
|
||||
if body:
|
||||
return f"{body}&sfunc={sfunc}&data={quote(encrypted)}"
|
||||
return f"sfunc={sfunc}&data={quote(encrypted)}"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Request / Response Transport
|
||||
|
||||
**Request** — form field `data`:
|
||||
```
|
||||
sfunc=<value>&data=<url-encoded base64 Blowfish ciphertext>
|
||||
```
|
||||
|
||||
For `sfunc=n` calls, `xxid` must be the **first** field:
|
||||
```
|
||||
xxid=<session_xxid>&sfunc=n&data=<encrypted>
|
||||
```
|
||||
|
||||
For `sfunc=i` calls, `key2` is a separate unencrypted field:
|
||||
```
|
||||
key2=<key2>&sfunc=i&data=<encrypted>
|
||||
```
|
||||
|
||||
**Response** — body is raw base64 Blowfish ciphertext (no form encoding); base64-decode then decrypt directly.
|
||||
|
||||
---
|
||||
|
||||
## Keys
|
||||
|
||||
| Key | Value | Used for |
|
||||
|---|---|---|
|
||||
| `DEFAULT_KEY` | `8M3L9SBF1AC4FRE56788M3L9SBF1AC4FRE5678` | `sfunc=r` key exchange request/response |
|
||||
| Session key | DH-derived (44-char base64, 32 bytes) | All subsequent requests |
|
||||
|
||||
---
|
||||
|
||||
## Diffie-Hellman Session Key Derivation
|
||||
|
||||
The session key is derived via a custom DH exchange. All three parameters are hardcoded in the app — this provides no real security since the private key `A` never rotates.
|
||||
|
||||
> **Note**: The variable names in the app source are **swapped** from their DH role. `A_VALUE` in source is the exponent (shorter number); `P_VALUE` is the prime modulus (longer number).
|
||||
|
||||
```
|
||||
G (generator) = 2
|
||||
A (client privkey) = 1563516802667282387226490351799736881442299778484610378722158765594241028592123324764949712696577
|
||||
P (prime modulus) = 2410312426921032588552076022197566074856950548502459942654116941958108831682612228890093858261341614673227141477904012196503648957050582631942730706805009223062734745341073406696246014589361659774041027169249453200378729434170325843778659198143763193776859869524088940195577346119843545301547043747207749969763750084308926339295559968882457872412993810129130294592999947926365264059284647209730384947211681434464714438488520940127459844288859336526896320919633919
|
||||
```
|
||||
|
||||
### Steps
|
||||
|
||||
1. Client sends `cmod = G^A mod P` (as decimal string) in the `sfunc=r` request
|
||||
2. Server responds with `smod` (its DH public key, as decimal string)
|
||||
3. Client computes shared secret: `shared = smod^A mod P`
|
||||
4. Client SHA-256 hashes `str(shared)` → uppercase hex
|
||||
5. Client converts the hex string to raw bytes, then base64-encodes → session key
|
||||
|
||||
```python
|
||||
import hashlib, base64
|
||||
|
||||
def derive_session_key(smod: int) -> str:
|
||||
A = 1563516802667282387226490351799736881442299778484610378722158765594241028592123324764949712696577
|
||||
P = 2410312426921032588552076022197566074856950548502459942654116941958108831682612228890093858261341614673227141477904012196503648957050582631942730706805009223062734745341073406696246014589361659774041027169249453200378729434170325843778659198143763193776859869524088940195577346119843545301547043747207749969763750084308926339295559968882457872412993810129130294592999947926365264059284647209730384947211681434464714438488520940127459844288859336526896320919633919
|
||||
shared = pow(smod, A, P)
|
||||
sha256_hex = hashlib.sha256(str(shared).encode()).hexdigest().upper()
|
||||
return base64.b64encode(bytes.fromhex(sha256_hex)).decode()
|
||||
```
|
||||
|
||||
The result is always a **44-character base64 string** (32 bytes). It changes every session.
|
||||
|
||||
---
|
||||
|
||||
## Nonce Computation
|
||||
|
||||
Every request after key exchange includes a `nonce` field. It is computed from the `nonceGenerator` string returned by the key exchange response.
|
||||
|
||||
### `nonceGenerator` format
|
||||
|
||||
A string of 4 groups separated by `-`. Each group contains 8 space-separated tokens. Each token is a letter followed by a number (e.g. `M85`, `A37`, `C95`, `X2`).
|
||||
|
||||
```
|
||||
M85 A87 A82 M82 M60 M31 A46 C95-M14 X83 A37 X2 C4 X22 X46 C95-M57 X29 C51 C34 S91 X60 S1 A15-M54 A89 S13 S18 C81 A70 X92 X59
|
||||
```
|
||||
|
||||
### Nonce output format
|
||||
|
||||
4 groups separated by `-`. Each group: a zero-padded 5-digit seed followed by 7 two-digit numbers separated by spaces.
|
||||
|
||||
```
|
||||
08160 19 73 45 17 89 07 10-00924 64 73 18 08 48 80 67-01026 20 17 13 26 26 43 24-00648 12 32 17 69 14 63 92
|
||||
```
|
||||
|
||||
### Algorithm
|
||||
|
||||
**Phase 1 — seed values (one per group):**
|
||||
|
||||
For each of the 4 groups:
|
||||
1. Extract the number from `token[0]` (e.g. `M85` → `85`)
|
||||
2. Generate random `r = floor(random() * 99) + 1` (range 1–99 inclusive)
|
||||
3. `product = N * r` → zero-pad to 5 digits
|
||||
4. `digitSum = sum of digits of padded`
|
||||
5. `lastTwo = int(padded[-2:])` (last two digits)
|
||||
6. Accumulate `cumSum += digitSum`
|
||||
|
||||
**Phase 2 — nonce digits (tokens 1–7 of each group):**
|
||||
|
||||
For each group, start with `carry = lastTwo[i]`:
|
||||
|
||||
| op letter | Formula |
|
||||
|---|---|
|
||||
| `M` | `(carry % num) + digitSum + cumSum` |
|
||||
| `A` | `carry + num + digitSum + cumSum` |
|
||||
| `S` | `(carry * carry) + num + digitSum + cumSum` |
|
||||
| `X` | `(carry * num) + digitSum + cumSum` |
|
||||
| `C` | `(carry * carry * carry) + num + digitSum + cumSum` |
|
||||
|
||||
Nonce digit = last two digits of the result. Update `carry = nonceDigit` for the next token.
|
||||
|
||||
**Output**: join padded seed + 7 two-digit nonce digits per group, join 4 groups with `-`.
|
||||
|
||||
### Python implementation
|
||||
|
||||
```python
|
||||
import math, random
|
||||
|
||||
def generate_nonce(nonce_generator: str) -> str:
|
||||
groups = nonce_generator.split('-')
|
||||
padded_list, last_two, digit_sum = [], [], []
|
||||
cum_sum = 0
|
||||
|
||||
for group in groups:
|
||||
tokens = group.split(' ')
|
||||
n = int(''.join(c for c in tokens[0] if c.isdigit()))
|
||||
r = math.floor(random.random() * 99) + 1
|
||||
product = n * r
|
||||
padded = str(product).zfill(5)
|
||||
ds = sum(int(d) for d in padded)
|
||||
lt = int(padded[-2:])
|
||||
padded_list.append(padded)
|
||||
last_two.append(lt)
|
||||
digit_sum.append(ds)
|
||||
cum_sum += ds
|
||||
|
||||
result_groups = []
|
||||
for i, group in enumerate(groups):
|
||||
tokens = group.split(' ')
|
||||
carry = last_two[i]
|
||||
ds = digit_sum[i]
|
||||
nonce_digits = []
|
||||
for token in tokens[1:]:
|
||||
op = ''.join(c for c in token if c.isalpha())
|
||||
num = int(''.join(c for c in token if c.isdigit()))
|
||||
if op == 'M': val = (carry % num) + ds + cum_sum
|
||||
elif op == 'A': val = carry + num + ds + cum_sum
|
||||
elif op == 'S': val = (carry * carry) + num + ds + cum_sum
|
||||
elif op == 'X': val = (carry * num) + ds + cum_sum
|
||||
elif op == 'C': val = (carry * carry * carry) + num + ds + cum_sum
|
||||
else: val = 0
|
||||
digit = int(str(val)[-2:])
|
||||
nonce_digits.append(digit)
|
||||
carry = digit
|
||||
group_str = padded_list[i] + ' ' + ' '.join(str(d).zfill(2) for d in nonce_digits)
|
||||
result_groups.append(group_str)
|
||||
|
||||
return '-'.join(result_groups)
|
||||
```
|
||||
|
||||
### Notes
|
||||
|
||||
- `nonce` and `sodium` are separate fields. `sodium` is an independent random integer (~23–24 bit range).
|
||||
- The `nonceGenerator` is returned once by the key exchange response and reused for the entire session.
|
||||
|
||||
---
|
||||
|
||||
## Known `sfunc` / `routePath` Values
|
||||
|
||||
| `sfunc` | `routePath` | Description |
|
||||
|---|---|---|
|
||||
| `r` | `S40` | Initial DH key exchange (DEFAULT_KEY) |
|
||||
| `i` | `S40` | Authenticated DH key exchange (key1/key2) |
|
||||
| `n` | `A44` | Get auth type / `userSalt` |
|
||||
| `n` | `A41` | Regular login initialization |
|
||||
| `n` | `A42` | OTP verification (regular login) |
|
||||
| `n` | `C41` | Device registration initialization |
|
||||
| `n` | `C42` | OTP verification (registration) |
|
||||
| `n` | `P41` | Get profile image (by hash) |
|
||||
| `n` | `P40` | Update profile image |
|
||||
| `n` | `P42` | Delete profile image |
|
||||
| `n` | `P47` | Select profile / fetch account balances |
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
**Next →** [Login Flow](02-login.md)
|
||||
346
docs/mibapi/02-login.md
Normal file
346
docs/mibapi/02-login.md
Normal file
@@ -0,0 +1,346 @@
|
||||
# Login Flow
|
||||
|
||||
MIB uses a two-phase authentication model:
|
||||
|
||||
| Phase | Trigger | Key used |
|
||||
|---|---|---|
|
||||
| **Device Registration** | First time this device+account pair is seen | `DEFAULT_KEY` → DH session key |
|
||||
| **Regular Login** | Every subsequent login (stored `key1`/`key2`) | `key1` → DH session key |
|
||||
|
||||
---
|
||||
|
||||
## Password Hashing (`pgf03`)
|
||||
|
||||
The password is never sent in plaintext. Required by both `C41` (registration) and `A41` (login).
|
||||
|
||||
```
|
||||
pgf03 = SHA256( clientSalt + SHA256( userSalt + SHA256( password ) ) )
|
||||
```
|
||||
|
||||
All SHA-256 values are uppercase hex strings. `clientSalt` is a fresh random 32-character alphanumeric string each time.
|
||||
|
||||
```python
|
||||
import hashlib
|
||||
|
||||
def compute_pgf03(password: str, user_salt: str, client_salt: str) -> str:
|
||||
h1 = hashlib.sha256(password.encode()).hexdigest().upper()
|
||||
h2 = hashlib.sha256((user_salt + h1).encode()).hexdigest().upper()
|
||||
return hashlib.sha256((client_salt + h2).encode()).hexdigest().upper()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Device Registration Flow (first time only)
|
||||
|
||||
```
|
||||
[0] sfunc=r DEFAULT_KEY → DH exchange → derive session_key_1, get xxid + nonceGenerator
|
||||
[1] sfunc=n A44 → get userSalt
|
||||
[2] sfunc=n C41 → submit credentials → returns key1, key2 (persist!)
|
||||
[3] sfunc=n C42 → verify OTP
|
||||
[4–8] regular login (below) using the key1/key2 just received
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Regular Login Flow
|
||||
|
||||
```
|
||||
[0] sfunc=i key1 → DH exchange → derive session_key_2, get xxid + nonceGenerator
|
||||
[1] sfunc=n A44 → get userSalt
|
||||
[2] sfunc=n A41 → submit credentials → returns otpTypes, email, uuid
|
||||
[3] sfunc=n P41 → fetch profile image (optional)
|
||||
[4] sfunc=n A42 → verify OTP → session established
|
||||
[5] sfunc=n P47 → select profile → returns accountBalance array
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step-by-Step Reference
|
||||
|
||||
### [0] Initial Key Exchange — `sfunc=r`
|
||||
|
||||
**Key**: `DEFAULT_KEY = 8M3L9SBF1AC4FRE56788M3L9SBF1AC4FRE5678`
|
||||
|
||||
**Request** (outer + inner are encrypted together):
|
||||
```json
|
||||
{
|
||||
"sfunc": "r",
|
||||
"data": {
|
||||
"cmod": "<G^A mod P as decimal string>",
|
||||
"appId": "IOS17.2-<15 random alphanumeric chars>",
|
||||
"routePath": "S40",
|
||||
"sodium": "<random 20-bit int as string>",
|
||||
"xxid": "<random 40-bit int as string>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response** (decrypted with `DEFAULT_KEY`):
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"reasonCode": "201",
|
||||
"reasonText": "Key generated successfully.",
|
||||
"smod": "<server DH public key as decimal string>",
|
||||
"nonceGenerator": "<instruction string>",
|
||||
"xxid": "<session token — use for all subsequent calls>",
|
||||
"sodium": "<server random>",
|
||||
"encMethod": 2
|
||||
}
|
||||
```
|
||||
|
||||
After: `session_key = derive_session_key(int(smod))`. Save `xxid` and `nonceGenerator`.
|
||||
|
||||
---
|
||||
|
||||
### [1] Get Auth Type — `sfunc=n`, `routePath: A44`
|
||||
|
||||
**Key**: session key
|
||||
|
||||
**Request**:
|
||||
```json
|
||||
{
|
||||
"sfunc": "n",
|
||||
"xxid": "<session xxid>",
|
||||
"data": {
|
||||
"uname": "<username>",
|
||||
"nonce": "<computed nonce>",
|
||||
"appId": "<appId>",
|
||||
"sodium": "<random>",
|
||||
"routePath": "A44",
|
||||
"xxid": "<session xxid>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [{ "loginType": "1", "userSalt": "<server salt>" }]
|
||||
}
|
||||
```
|
||||
|
||||
Use `userSalt` in `pgf03` computation.
|
||||
|
||||
---
|
||||
|
||||
### [2a] Device Registration Init — `sfunc=n`, `routePath: C41`
|
||||
|
||||
_First-time only._
|
||||
|
||||
**Request**:
|
||||
```json
|
||||
{
|
||||
"sfunc": "n",
|
||||
"xxid": "<session xxid>",
|
||||
"data": {
|
||||
"uname": "<username>",
|
||||
"pgf03": "<computed>",
|
||||
"clientSalt": "<random 32-char string>",
|
||||
"nonce": "<nonce>",
|
||||
"appId": "<appId>",
|
||||
"sodium": "<random>",
|
||||
"routePath": "C41",
|
||||
"xxid": "<session xxid>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"reasonCode": "201",
|
||||
"primaryOTPType": "3",
|
||||
"otpTypes": [2, 3],
|
||||
"fullName": "<user full name>",
|
||||
"customerImgHash": "<hash>"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### [3a] OTP Verification (Registration) — `sfunc=n`, `routePath: C42`
|
||||
|
||||
_First-time only. Receive and persist `key1`/`key2`._
|
||||
|
||||
**Request**:
|
||||
```json
|
||||
{
|
||||
"sfunc": "n",
|
||||
"xxid": "<session xxid>",
|
||||
"data": {
|
||||
"otp": "<6-digit OTP>",
|
||||
"uname": "<username>",
|
||||
"otpType": "3",
|
||||
"nonce": "<nonce>",
|
||||
"appId": "<appId>",
|
||||
"sodium": "<random>",
|
||||
"routePath": "C42",
|
||||
"xxid": "<session xxid>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"reasonCode": "101",
|
||||
"data": [{
|
||||
"key1": "<store securely — Blowfish key for next sfunc=i>",
|
||||
"key2": "<store securely — sent plaintext in sfunc=i wrapper>",
|
||||
"appId": "<appId>",
|
||||
"encryptionMethod": "2"
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
`key1` and `key2` are long-lived device credentials. Store them securely on the device.
|
||||
|
||||
---
|
||||
|
||||
### [0b] Authenticated Key Exchange — `sfunc=i`
|
||||
|
||||
_Regular login. `key2` is a separate unencrypted outer field._
|
||||
|
||||
**Key**: `key1`
|
||||
|
||||
Form body: `key2=<key2>&sfunc=i&data=<encrypted payload>`
|
||||
|
||||
**Encrypted payload**:
|
||||
```json
|
||||
{
|
||||
"sfunc": "i",
|
||||
"key2": "<key2>",
|
||||
"data": {
|
||||
"cmod": "<G^A mod P>",
|
||||
"appId": "<appId>",
|
||||
"routePath": "S40",
|
||||
"sodium": "<random 20-bit int>",
|
||||
"xxid": "<random 40-bit int>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response** (decrypted with `key1`):
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"smod": "<new server DH public key>",
|
||||
"nonceGenerator": "<new instruction string>",
|
||||
"xxid": "<new session token>",
|
||||
"encMethod": 2
|
||||
}
|
||||
```
|
||||
|
||||
After: derive new session key, replace `xxid` and `nonceGenerator`.
|
||||
|
||||
---
|
||||
|
||||
### [2b] Regular Login Init — `sfunc=n`, `routePath: A41`
|
||||
|
||||
**Key**: session key
|
||||
|
||||
**Request**:
|
||||
```json
|
||||
{
|
||||
"sfunc": "n",
|
||||
"xxid": "<session xxid>",
|
||||
"data": {
|
||||
"uname": "<username>",
|
||||
"pgf03": "<computed>",
|
||||
"clientSalt": "<random 32-char>",
|
||||
"pmodTime": 0,
|
||||
"requireBankData": 1,
|
||||
"nonce": "<nonce>",
|
||||
"appId": "<appId>",
|
||||
"sodium": "<random>",
|
||||
"routePath": "A41",
|
||||
"xxid": "<session xxid>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"reasonCode": "104",
|
||||
"primaryOTPType": "3",
|
||||
"otpTypes": [2, 3],
|
||||
"email": "<masked email>",
|
||||
"uuid": "<uuid1>",
|
||||
"uuid2": "<uuid2>"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### [3b] Get Profile Image — `sfunc=n`, `routePath: P41`
|
||||
|
||||
Optional. Fetch the user's avatar to display on the OTP screen.
|
||||
|
||||
**Request**:
|
||||
```json
|
||||
{
|
||||
"sfunc": "n",
|
||||
"xxid": "<session xxid>",
|
||||
"data": {
|
||||
"imageHash": "<customerImgHash from C41/A41>",
|
||||
"nonce": "<nonce>",
|
||||
"appId": "<appId>",
|
||||
"sodium": "<random>",
|
||||
"routePath": "P41",
|
||||
"xxid": "<session xxid>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"reasonCode": "201",
|
||||
"profileImage": "<base64-encoded JPEG>"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### [4b] OTP Verification (Login) — `sfunc=n`, `routePath: A42`
|
||||
|
||||
**Request**:
|
||||
```json
|
||||
{
|
||||
"sfunc": "n",
|
||||
"xxid": "<session xxid>",
|
||||
"data": {
|
||||
"otp": "<6-digit OTP>",
|
||||
"uname": "<username>",
|
||||
"otpType": "3",
|
||||
"nonce": "<nonce>",
|
||||
"appId": "<appId>",
|
||||
"sodium": "<random>",
|
||||
"routePath": "A42",
|
||||
"xxid": "<session xxid>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
After successful `A42`, the `xxid` and `nonceGenerator` from the `sfunc=i` response become the WebView session cookies. See [README](README.md) for the cookie format.
|
||||
|
||||
---
|
||||
|
||||
### [5] Select Profile — `sfunc=n`, `routePath: P47`
|
||||
|
||||
See [03-accounts.md](03-accounts.md) for the full P47 reference.
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
[← Encryption](01-encryption.md) **Next →** [Accounts](03-accounts.md)
|
||||
137
docs/mibapi/03-accounts.md
Normal file
137
docs/mibapi/03-accounts.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# Accounts & Balances
|
||||
|
||||
Account numbers and balances are returned by the **Select Profile** call (`routePath: P47`). The login init call (`A41`) returns an empty `accountBalance` array — balances are only available after `P47`.
|
||||
|
||||
---
|
||||
|
||||
## Select Profile — `sfunc=n`, `routePath: P47`
|
||||
|
||||
**Key**: session key (from `sfunc=i` response)
|
||||
|
||||
**Request**:
|
||||
```json
|
||||
{
|
||||
"sfunc": "n",
|
||||
"xxid": "<session xxid>",
|
||||
"data": {
|
||||
"profileType": "<profileType from A41 operatingProfiles>",
|
||||
"profileId": "<profileId from A41 operatingProfiles>",
|
||||
"nonce": "<computed nonce>",
|
||||
"appId": "<appId>",
|
||||
"sodium": "<random 20-bit int>",
|
||||
"routePath": "P47",
|
||||
"xxid": "<session xxid>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"reasonCode": "101",
|
||||
"reasonText": "Profile Selected!",
|
||||
"landingPage": "0",
|
||||
"accountBalance": [ ... ],
|
||||
"accessRights": { ... },
|
||||
"services": []
|
||||
}
|
||||
```
|
||||
|
||||
To switch between profiles (personal ↔ business), call `P47` again with the other profile's `profileId` and `profileType`.
|
||||
|
||||
---
|
||||
|
||||
## Profiles (from `A41` response)
|
||||
|
||||
The `A41` login init response includes `operatingProfiles`:
|
||||
|
||||
```json
|
||||
{
|
||||
"operatingProfiles": [
|
||||
{
|
||||
"profileId": "<profile ID>",
|
||||
"customerProfileId": "<customer profile ID>",
|
||||
"annexId": "<annex ID>",
|
||||
"customerId": "<customer ID>",
|
||||
"name": "<display name>",
|
||||
"cifType": "Individual",
|
||||
"customerImage": "<image hash>",
|
||||
"profileType": "0",
|
||||
"color": "<hex color>"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| `profileType` | Meaning |
|
||||
|---|---|
|
||||
| `"0"` | Individual (personal) |
|
||||
| `"1"` | Sole Proprietor (business) |
|
||||
|
||||
---
|
||||
|
||||
## `accountBalance` Array
|
||||
|
||||
Each element represents one account:
|
||||
|
||||
```json
|
||||
{
|
||||
"cif": "<CIF number>",
|
||||
"accountNumber": "<full account number>",
|
||||
"accountBriefName": "<short label, e.g. 'SAR MVR - Savings'>",
|
||||
"template": "<display template ID>",
|
||||
"currencyCode": "<ISO 4217 numeric>",
|
||||
"currencyName": "<ISO 4217 alpha>",
|
||||
"accountTypeName": "<account type label>",
|
||||
"transfer": "Y",
|
||||
"branchName": "<branch name>",
|
||||
"availableBalance": "<decimal string>",
|
||||
"currentBalance": "<decimal string>",
|
||||
"blockedAmount": "<decimal string, may be negative>",
|
||||
"settlementBalance": "<decimal string>",
|
||||
"mvrBalance": "<MVR equivalent>",
|
||||
"statusDesc": "Active"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| `accountNumber` | Full account number |
|
||||
| `accountBriefName` | Human-readable account label |
|
||||
| `currencyCode` | ISO 4217 numeric (e.g. `"462"` = MVR, `"840"` = USD) |
|
||||
| `currencyName` | ISO 4217 alpha (e.g. `"MVR"`, `"USD"`) |
|
||||
| `accountTypeName` | Account type (e.g. `"Saving Account"`) |
|
||||
| `availableBalance` | Spendable balance (decimal string) |
|
||||
| `currentBalance` | Ledger balance (decimal string) |
|
||||
| `blockedAmount` | Held/blocked funds — negative means funds are held |
|
||||
| `settlementBalance` | Balance including pending settlements |
|
||||
| `mvrBalance` | All balances converted to MVR for unified display |
|
||||
| `transfer` | `"Y"` if usable as transfer source |
|
||||
| `statusDesc` | Account status (e.g. `"Active"`) |
|
||||
| `cif` | Customer Information File number |
|
||||
| `template` | UI template ID |
|
||||
|
||||
> All balance fields are **decimal strings**, not numbers — parse with `Decimal` for precision.
|
||||
|
||||
---
|
||||
|
||||
## `accessRights`
|
||||
|
||||
```json
|
||||
{
|
||||
"numAccounts": "<number of accounts>",
|
||||
"packageRights": "[1,2,3,4,6,7,8,9,10,11,12]",
|
||||
"roleRights": "[]"
|
||||
}
|
||||
```
|
||||
|
||||
`packageRights` is a JSON array encoded as a string — parse it separately.
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
[← Login Flow](02-login.md) **Next →** [Transaction History](04-history.md)
|
||||
107
docs/mibapi/04-history.md
Normal file
107
docs/mibapi/04-history.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# Transaction History
|
||||
|
||||
Fetch paginated transaction history for a single MIB account. Served from the WebView subdomain.
|
||||
|
||||
---
|
||||
|
||||
## Endpoint
|
||||
|
||||
```
|
||||
POST https://faisamobilex-wv.mib.com.mv/ajaxAccounts/trxHistory
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
See [README](README.md) for cookie and AJAX header format.
|
||||
|
||||
```
|
||||
Referer: https://faisamobilex-wv.mib.com.mv//accountDetails?trxh=1&dashurl=1&accountNo=<accountNo>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Request Body (form-urlencoded)
|
||||
|
||||
| Field | Example | Description |
|
||||
|---|---|---|
|
||||
| `accountNo` | `90101000000001000` | Account number to fetch history for |
|
||||
| `trxNo` | `` | Transaction number filter (empty = all) |
|
||||
| `trxType` | `0` | Transaction type filter (`0` = all) |
|
||||
| `sortTrx` | `date` | Sort field |
|
||||
| `sortDir` | `desc` | Sort direction |
|
||||
| `fromDate` | `` | From date filter (empty = no filter) |
|
||||
| `toDate` | `` | To date filter (empty = no filter) |
|
||||
| `start` | `1` | Start record index (1-based) |
|
||||
| `end` | `20` | End record index (`start + pageSize - 1`) |
|
||||
| `includeCount` | `1` | Include `total_count` in response |
|
||||
|
||||
### Pagination
|
||||
|
||||
Page size is 20. Compute `start`/`end` per page:
|
||||
|
||||
| Page | `start` | `end` |
|
||||
|---|---|---|
|
||||
| 1 | `1` | `20` |
|
||||
| 2 | `21` | `40` |
|
||||
| N | `(N-1)*20 + 1` | `N*20` |
|
||||
|
||||
Stop when total fetched equals `total_count` or `data` is empty.
|
||||
|
||||
---
|
||||
|
||||
## Response
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"total_count": "87",
|
||||
"data": [
|
||||
{
|
||||
"trxNumber": "TXN20260516001",
|
||||
"trxDate": "2026-05-16",
|
||||
"descr1": "Transfer Debit",
|
||||
"baseAmount": "-500.00",
|
||||
"curCodeDesc": "MVR",
|
||||
"benefName": "Mohamed Ali",
|
||||
"trxNumber2": "FT20260516001"
|
||||
},
|
||||
{
|
||||
"trxNumber": "TXN20260515001",
|
||||
"trxDate": "2026-05-15",
|
||||
"descr1": "Transfer Credit",
|
||||
"baseAmount": "1000.00",
|
||||
"curCodeDesc": "MVR",
|
||||
"benefName": "",
|
||||
"trxNumber2": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `success` | `bool` | `true` on success |
|
||||
| `total_count` | `string` | Total transaction count — parse to `int` |
|
||||
| `data` | `array` | Transactions for this page |
|
||||
|
||||
### Transaction Object
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `trxNumber` | `string` | Unique transaction ID |
|
||||
| `trxDate` | `string` | Transaction date (`YYYY-MM-DD`) |
|
||||
| `descr1` | `string` | Transaction description — trim whitespace |
|
||||
| `baseAmount` | `string` | Decimal string — **negative = debit, positive = credit** |
|
||||
| `curCodeDesc` | `string` | Currency code (e.g. `"MVR"`, `"USD"`) |
|
||||
| `benefName` | `string` | Counterparty name — blank or literal `"null"` means none |
|
||||
| `trxNumber2` | `string` | Secondary reference; may be blank |
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
[← Accounts](03-accounts.md) **Next →** [Cards](05-cards.md)
|
||||
86
docs/mibapi/05-cards.md
Normal file
86
docs/mibapi/05-cards.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# Cards
|
||||
|
||||
Fetch debit card information for the authenticated session.
|
||||
|
||||
---
|
||||
|
||||
## Endpoint
|
||||
|
||||
```
|
||||
POST https://faisamobilex-wv.mib.com.mv/ajaxDebitCard/fetchCardInfos
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
See [README](README.md) for cookie and AJAX header format.
|
||||
|
||||
```
|
||||
Referer: https://faisamobilex-wv.mib.com.mv//debitCards?dashurl=1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Request Body (form-urlencoded)
|
||||
|
||||
| Field | Value | Description |
|
||||
|---|---|---|
|
||||
| `name` | `` | Card name filter (empty = all) |
|
||||
| `start` | `1` | Start index (1-based) |
|
||||
| `end` | `50` | End index |
|
||||
| `includeCount` | `1` | Include total count |
|
||||
|
||||
---
|
||||
|
||||
## Response
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"cardId": "CARD001",
|
||||
"maskedCardNumber": "4111 **** **** 1234",
|
||||
"cardStatus": "A",
|
||||
"cardType": "D",
|
||||
"cardTypeDesc": "Debit Card",
|
||||
"customerId": "C000001",
|
||||
"phoneNumber": "9600000001",
|
||||
"cardHolderName": "MOHAMED ALI"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `success` | `bool` | `true` on success |
|
||||
| `data` | `array` | List of card objects |
|
||||
|
||||
### Card Object
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `cardId` | `string` | Internal card identifier |
|
||||
| `maskedCardNumber` | `string` | Partially masked card number for display |
|
||||
| `cardStatus` | `string` | Card status (`A` = Active) |
|
||||
| `cardType` | `string` | Card type code (e.g. `D` = Debit) |
|
||||
| `cardTypeDesc` | `string` | Human-readable card type (e.g. `"Debit Card"`) |
|
||||
| `customerId` | `string` | Customer ID |
|
||||
| `phoneNumber` | `string` | Registered phone number |
|
||||
| `cardHolderName` | `string` | Name on card |
|
||||
|
||||
### Failure
|
||||
|
||||
```json
|
||||
{ "success": false }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
[← Transaction History](04-history.md) **Next →** [Financing](06-financing.md)
|
||||
106
docs/mibapi/06-financing.md
Normal file
106
docs/mibapi/06-financing.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# Financing
|
||||
|
||||
Fetch active financing deals. Unlike other data endpoints, this returns an HTML page — deal data is embedded in `data-*` attributes on card elements.
|
||||
|
||||
---
|
||||
|
||||
## Endpoint
|
||||
|
||||
```
|
||||
GET https://faisamobilex-wv.mib.com.mv/financing?dashurl=1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
See [README](README.md) for cookie format.
|
||||
|
||||
### Additional Header
|
||||
|
||||
| Header | Value |
|
||||
|---|---|
|
||||
| `X-Requested-With` | `mv.com.mib.faisamobilex` |
|
||||
|
||||
Note: this endpoint uses the app package name as `X-Requested-With`, not `XMLHttpRequest`.
|
||||
|
||||
---
|
||||
|
||||
## Response
|
||||
|
||||
**Content-Type:** `text/html; charset=UTF-8`
|
||||
|
||||
Each financing deal is a `<div>` with class `finance-card-holder` and all fields as `data-*` attributes:
|
||||
|
||||
```html
|
||||
<div class="card border finance-card-holder"
|
||||
data-productDesc = "Product Name"
|
||||
data-dealStatus = "P"
|
||||
data-statusDesc = "Approved"
|
||||
data-dealAmount = "10000.00"
|
||||
data-dealNo = "12345"
|
||||
data-paidAmount = "2500.00"
|
||||
data-outstandingAmount = "7500.00"
|
||||
data-dealDate = "2024-01-15 00:00:00"
|
||||
data-overdueAmount = "0"
|
||||
data-installmentAmount = "500.00"
|
||||
data-noOfInstallments = "24"
|
||||
data-lastPaidDate = "2026-05-01 00:00:00"
|
||||
data-lastPayAmount = "500.00"
|
||||
data-financeCurrency = "462"
|
||||
data-curCodeDesc = "MVR">
|
||||
```
|
||||
|
||||
### Data Fields
|
||||
|
||||
| Attribute | Type | Description |
|
||||
|---|---|---|
|
||||
| `productDesc` | String | Product name (e.g. `"Ujalaa CG Finance"`) |
|
||||
| `dealStatus` | String | Status code (`P` = Active/Pending) |
|
||||
| `statusDesc` | String | Human-readable status (e.g. `"Approved"`) |
|
||||
| `dealAmount` | Decimal | Total financing amount |
|
||||
| `dealNo` | Integer | Unique deal/contract number |
|
||||
| `paidAmount` | Decimal | Amount paid to date |
|
||||
| `outstandingAmount` | Decimal | Remaining unpaid balance |
|
||||
| `dealDate` | String | Contract start date (`yyyy-MM-dd HH:mm:ss`) |
|
||||
| `overdueAmount` | Decimal | Amount currently overdue (`0` if none) |
|
||||
| `installmentAmount` | Decimal | Monthly installment amount |
|
||||
| `noOfInstallments` | Integer | Total number of installments |
|
||||
| `lastPaidDate` | String | Date of most recent payment (`yyyy-MM-dd HH:mm:ss`) |
|
||||
| `lastPayAmount` | Decimal | Amount of most recent payment |
|
||||
| `financeCurrency` | Integer | Currency numeric code (`462` = MVR) |
|
||||
| `curCodeDesc` | String | Currency abbreviation (e.g. `"MVR"`) |
|
||||
|
||||
### Parsing
|
||||
|
||||
```kotlin
|
||||
val cardPattern = Regex("""finance-card-holder[^>]+>""")
|
||||
val attrPattern = Regex("""data-(\w+)\s*=\s*"([^"]*)"""")
|
||||
```
|
||||
|
||||
Find all `finance-card-holder` elements, then extract `data-*` key/value pairs from each match.
|
||||
|
||||
---
|
||||
|
||||
## Completion Date Estimation
|
||||
|
||||
```
|
||||
remainingInstallments = ceil(outstandingAmount / installmentAmount)
|
||||
completionDate = today + remainingInstallments months
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- No encryption — session maintained purely via cookies.
|
||||
- The response is gzip/brotli compressed; OkHttp handles decompression automatically.
|
||||
- `time-tracker=597` appears static — omitting it may affect behavior.
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
[← Cards](05-cards.md) **Next →** [Personal Profile](07-profile.md)
|
||||
92
docs/mibapi/07-profile.md
Normal file
92
docs/mibapi/07-profile.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# Personal Profile
|
||||
|
||||
Fetch the user's personal profile details. This endpoint returns an HTML page; data is extracted via HTML scraping.
|
||||
|
||||
---
|
||||
|
||||
## Endpoint
|
||||
|
||||
```
|
||||
GET https://faisamobilex-wv.mib.com.mv/personalProfile
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
Session cookies only — no additional AJAX headers required.
|
||||
|
||||
```
|
||||
Cookie: mbmodel=IOS-1.0; xxid=<session_xxid>; IBSID=<session_xxid>; mbnonce=<nonceGenerator>; time-tracker=597
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Response
|
||||
|
||||
**Content-Type:** `text/html; charset=UTF-8`
|
||||
|
||||
The page contains an `<h5>` with the user's full name and `<span>` elements with labelled fields.
|
||||
|
||||
### Parsing Strategy
|
||||
|
||||
**Full name** — extracted from:
|
||||
```html
|
||||
<h5 class="mb-1 text-dark fw-semibold">Mohamed Ali</h5>
|
||||
```
|
||||
|
||||
Regex:
|
||||
```kotlin
|
||||
Regex("""<h5 class="mb-1 text-dark fw-semibold">\s*([^<]+)\s*</h5>""")
|
||||
```
|
||||
|
||||
**Labelled fields** — each follows this pattern:
|
||||
```html
|
||||
<span ...><b ...>Username:</b ...>...<span ...>myusername</span>
|
||||
```
|
||||
|
||||
Regex (used for each label):
|
||||
```kotlin
|
||||
Regex(
|
||||
"""<span[^>]*>\s*<b[^>]*>\s*$label\s*</b[^>]*>.*?<span[^>]*>([^<]+)</span>""",
|
||||
setOf(RegexOption.DOT_MATCHES_ALL, RegexOption.IGNORE_CASE)
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Extracted Fields
|
||||
|
||||
| Label in HTML | Field | Description |
|
||||
|---|---|---|
|
||||
| `Username:` | `username` | Login username |
|
||||
| `Email:` | `email` | Registered email address |
|
||||
| `Mobile no:` | `mobile` | Registered mobile number |
|
||||
| `Enrolled:` | `enrolled` | Enrollment date or status |
|
||||
|
||||
Combined with the `fullName` from the `<h5>`:
|
||||
|
||||
```kotlin
|
||||
data class MibPersonalProfile(
|
||||
val fullName: String,
|
||||
val username: String,
|
||||
val email: String,
|
||||
val mobile: String,
|
||||
val enrolled: String
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Returns `null` if the response cannot be parsed (network error or unexpected HTML structure).
|
||||
- This endpoint does not have a JSON equivalent — scraping is the only method.
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
[← Financing](06-financing.md) **Next →** [Transfer](08-transfer.md)
|
||||
208
docs/mibapi/08-transfer.md
Normal file
208
docs/mibapi/08-transfer.md
Normal file
@@ -0,0 +1,208 @@
|
||||
# Account Lookup & Transfer
|
||||
|
||||
Two-step process: look up the recipient to validate the account and get the holder name, then execute the transfer.
|
||||
|
||||
All endpoints are on the WebView subdomain. See [README](README.md) for cookie and AJAX header format.
|
||||
|
||||
```
|
||||
Referer: https://faisamobilex-wv.mib.com.mv/transfer/quick
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Account Lookup
|
||||
|
||||
The lookup endpoint depends on the format of the input:
|
||||
|
||||
| Input format | Endpoint | Body field |
|
||||
|---|---|---|
|
||||
| Starts with `7`, exactly 13 digits | `AjaxAlias/getIPSAccount` | `benefAccount` |
|
||||
| Starts with `9`, exactly 17 digits | `ajaxBeneficiary/getAccountName` | `accountNo` |
|
||||
| Starts with `7` or `9`, exactly 7 digits | `AjaxAlias/getAlias` | `aliasName` |
|
||||
| Starts with `A` followed by 6 digits | `AjaxAlias/getAlias` | `aliasName` |
|
||||
| Contains `@` (email) | `AjaxAlias/getAlias` | `aliasName` |
|
||||
|
||||
---
|
||||
|
||||
### 1a. IPS Account — BML / local bank (13 digits, starts with `7`)
|
||||
|
||||
```
|
||||
POST https://faisamobilex-wv.mib.com.mv/AjaxAlias/getIPSAccount
|
||||
```
|
||||
|
||||
Body: `benefAccount=7700000000000`
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"responseCode": "2",
|
||||
"accountName": "ACCOUNT HOLDER NAME",
|
||||
"bankBic": "MALBMVMV"
|
||||
}
|
||||
```
|
||||
|
||||
- `accountName` — account holder name
|
||||
- `bankBic` — bank BIC (e.g. `MALBMVMV` for BML)
|
||||
- Account number is the input itself — not returned in response
|
||||
|
||||
Use `bankNo=3` and `transferLocal` for the transfer.
|
||||
|
||||
---
|
||||
|
||||
### 1b. MIB Internal Account (17 digits, starts with `9`)
|
||||
|
||||
```
|
||||
POST https://faisamobilex-wv.mib.com.mv/ajaxBeneficiary/getAccountName
|
||||
```
|
||||
|
||||
Body: `accountNo=90100000000000000`
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"accountName": "ACCOUNT HOLDER NAME"
|
||||
}
|
||||
```
|
||||
|
||||
- `accountName` may be at root level or inside a `data` object — check both
|
||||
- Bank is always MIB (`MADVMVMV`)
|
||||
|
||||
Use `bankNo=2` and `transferInternal` for the transfer.
|
||||
|
||||
---
|
||||
|
||||
### 1c. Favara Alias — shortcodes, A-IDs, emails
|
||||
|
||||
```
|
||||
POST https://faisamobilex-wv.mib.com.mv/AjaxAlias/getAlias
|
||||
```
|
||||
|
||||
Body: `aliasName=<alias>`
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"responseCode": "2",
|
||||
"data": {
|
||||
"BfyNm": "Account Holder Name",
|
||||
"CdtrAcct": {
|
||||
"Acct": "90100000000000000",
|
||||
"FinInstnId": "MADVMVMV"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `BfyNm` — beneficiary name (trim whitespace)
|
||||
- `CdtrAcct.Acct` — resolved account number to use for the transfer
|
||||
- `CdtrAcct.FinInstnId` — bank BIC (`MADVMVMV` = MIB, `MALBMVMV` = BML)
|
||||
|
||||
Use `bankNo=2` (MIB) or `3` (BML/local) depending on `FinInstnId`, and the matching transfer endpoint.
|
||||
|
||||
---
|
||||
|
||||
### Lookup Errors
|
||||
|
||||
All three lookup endpoints return `success: false` with a human-readable `reasonText` on failure:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"reasonText": "Account not found"
|
||||
}
|
||||
```
|
||||
|
||||
Always show `reasonText` directly to the user.
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — Execute Transfer
|
||||
|
||||
Two endpoints depending on the destination bank:
|
||||
|
||||
| `bankNo` | Endpoint | Destination |
|
||||
|---|---|---|
|
||||
| `2` | `ajaxTransfer/transferInternal` | MIB internal account |
|
||||
| `3` | `ajaxTransfer/transferLocal` | BML or other local bank |
|
||||
|
||||
```
|
||||
POST https://faisamobilex-wv.mib.com.mv/ajaxTransfer/transferInternal
|
||||
POST https://faisamobilex-wv.mib.com.mv/ajaxTransfer/transferLocal
|
||||
```
|
||||
|
||||
### Request Body (form-urlencoded)
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| `benefName` | Recipient name (from lookup) |
|
||||
| `benefNo` | `0` (not a saved contact) |
|
||||
| `fromAccountNo` | Source account number |
|
||||
| `benefAccountNo` | Destination account number |
|
||||
| `transferCy` | Currency numeric code (`"462"` = MVR, `"840"` = USD) |
|
||||
| `benefCurrencyCode` | Same as `transferCy` |
|
||||
| `amount` | Amount as string (e.g. `"100.00"`) |
|
||||
| `bankNo` | `2` = MIB internal, `3` = local/BML |
|
||||
| `purpose` | Transfer purpose; send `"-"` if blank |
|
||||
| `otp` | OTP from the user |
|
||||
| `otpType` | `"3"` (SMS/authenticator OTP) |
|
||||
|
||||
### Response — Success
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"trxId": "TRX20260516001",
|
||||
"date": "2026-05-16 15:10:25"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| `trxId` | Transaction ID |
|
||||
| `date` | Completion timestamp |
|
||||
|
||||
### Response — Failure
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"reasonText": "Insufficient balance"
|
||||
}
|
||||
```
|
||||
|
||||
`reasonText` contains the error reason. On HTTP `419`, the session has expired — re-login required.
|
||||
|
||||
---
|
||||
|
||||
## Transfer Type Summary
|
||||
|
||||
| Recipient | Lookup endpoint | `bankNo` | Transfer endpoint |
|
||||
|---|---|---|---|
|
||||
| MIB (17-digit `9…`) | `getAccountName` | `2` | `transferInternal` |
|
||||
| BML (13-digit `7…`) | `getIPSAccount` | `3` | `transferLocal` |
|
||||
| Favara alias → MIB | `getAlias` | `2` | `transferInternal` |
|
||||
| Favara alias → BML | `getAlias` | `3` | `transferLocal` |
|
||||
|
||||
---
|
||||
|
||||
## Currency Codes
|
||||
|
||||
| `transferCy` | Currency |
|
||||
|---|---|
|
||||
| `"462"` | MVR (Maldivian Rufiyaa) |
|
||||
| `"840"` | USD |
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
[← Personal Profile](07-profile.md) **Next →** [Contacts](09-contacts.md)
|
||||
229
docs/mibapi/09-contacts.md
Normal file
229
docs/mibapi/09-contacts.md
Normal file
@@ -0,0 +1,229 @@
|
||||
# Contacts (Beneficiaries)
|
||||
|
||||
Manage the user's saved beneficiary list. All endpoints use WebView session auth — see [README](README.md).
|
||||
|
||||
```
|
||||
Referer: https://faisamobilex-wv.mib.com.mv/beneficiary?dashurl=1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## List Categories
|
||||
|
||||
```
|
||||
POST https://faisamobilex-wv.mib.com.mv/ajaxBeneficiary/getCategories
|
||||
```
|
||||
|
||||
Empty POST body.
|
||||
|
||||
### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{ "id": "100001", "categoryName": "Myself", "numBenef": "2" },
|
||||
{ "id": "100002", "categoryName": "Friends", "numBenef": "10" },
|
||||
{ "id": "100003", "categoryName": "Business", "numBenef": "8" },
|
||||
{ "id": "100004", "categoryName": "Family", "numBenef": "5" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| `id` | Category ID — use as `searchCategoryId` when filtering contacts |
|
||||
| `categoryName` | Display name |
|
||||
| `numBenef` | Number of beneficiaries in this category (string) |
|
||||
|
||||
---
|
||||
|
||||
## List Contacts
|
||||
|
||||
```
|
||||
POST https://faisamobilex-wv.mib.com.mv/ajaxBeneficiary/main
|
||||
```
|
||||
|
||||
### Request Body (form-urlencoded)
|
||||
|
||||
| Field | Example | Description |
|
||||
|---|---|---|
|
||||
| `page` | `1` | Page number (1-based) |
|
||||
| `search` | `` | Search query (empty = all) |
|
||||
| `searchCategoryId` | `0` | Category filter (`0` = all) |
|
||||
| `benefType` | `A` | `A`=All, `L`=Local, `I`=Internal, `S`=Swift |
|
||||
| `sortBenef` | `name` | Sort field |
|
||||
| `sortDir` | `asc` | Sort direction |
|
||||
| `start` | `1` | Start record index (1-based) |
|
||||
| `end` | `100` | End record index |
|
||||
| `includeCount` | `1` | Include `total_count` |
|
||||
|
||||
### Beneficiary Types
|
||||
|
||||
| `benefType` | Meaning |
|
||||
|---|---|
|
||||
| `I` | Internal (MIB to MIB) |
|
||||
| `L` | Local (other Maldivian banks, e.g. BML) |
|
||||
| `S` | Swift (international) |
|
||||
|
||||
### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"total_count": "48",
|
||||
"data": [
|
||||
{
|
||||
"benefNo": "100001",
|
||||
"benefName": "Person Name",
|
||||
"benefNickName": "Nickname",
|
||||
"benefAccount": "7700000000001",
|
||||
"benefType": "L",
|
||||
"bankColor": "#AC0000",
|
||||
"benefBankName": "Bank of Maldives PLC",
|
||||
"bankCode": "BML",
|
||||
"benefSwiftCode": "MALBMVMV",
|
||||
"benefStatus": "A",
|
||||
"benefBankId": "3",
|
||||
"transferCy": "462",
|
||||
"transferCyDesc": "MVR",
|
||||
"customerImgHash": "abcd1234hash",
|
||||
"benefCategoryID": "100002"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| `benefNo` | Unique beneficiary ID — use for delete |
|
||||
| `benefNickName` | User-assigned nickname (prefer over `benefName` for display) |
|
||||
| `benefType` | `L`, `I`, or `S` |
|
||||
| `bankColor` | Hex color for placeholder avatar background |
|
||||
| `customerImgHash` | Hash for fetching profile photo (`null` if no photo) |
|
||||
| `benefCategoryID` | Category ID — `"0"` means uncategorized |
|
||||
| `transferCyDesc` | Currency (e.g. `"MVR"`, `"USD"`) |
|
||||
|
||||
---
|
||||
|
||||
## Get Profile Image
|
||||
|
||||
```
|
||||
POST https://faisamobilex-wv.mib.com.mv/ajaxBeneficiary/getProfileImage
|
||||
```
|
||||
|
||||
Body: `imageHash=<customerImgHash>`
|
||||
|
||||
### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"profileImage": "<base64-encoded JPEG>"
|
||||
}
|
||||
```
|
||||
|
||||
`profileImage` is raw base64 JPEG with no data URI prefix. Decode with `Base64.decode(value, Base64.DEFAULT)` then `BitmapFactory.decodeByteArray(...)`. Cache decoded bitmaps — the same hash may appear across multiple contacts.
|
||||
|
||||
---
|
||||
|
||||
## Create Contact
|
||||
|
||||
```
|
||||
POST https://faisamobilex-wv.mib.com.mv/ajaxBeneficiary/createLocalBeneficiary
|
||||
```
|
||||
|
||||
```
|
||||
Referer: https://faisamobilex-wv.mib.com.mv/beneficiary/createNew
|
||||
```
|
||||
|
||||
### Request Body (form-urlencoded)
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| `benefType` | `"I"` = MIB internal, `"L"` = local/BML |
|
||||
| `benefAccount` | Beneficiary account number |
|
||||
| `benefName` | Full name |
|
||||
| `nickName` | Display nickname |
|
||||
| `bankNo` | `2` = MIB, `3` = BML/local |
|
||||
| `transferCy` | Currency numeric code (`"462"` = MVR) |
|
||||
| `categoryId` | Category ID (`"0"` = uncategorized) |
|
||||
| `imageSet` | `"1"` if image provided, `"0"` otherwise |
|
||||
| `image` | Base64-encoded image (empty string if none) |
|
||||
| `benefIban` | Leave blank |
|
||||
| `benefAddress` | Leave blank |
|
||||
| `benefCity` | Leave blank |
|
||||
| `benefCountry` | `"4"` |
|
||||
| `benefBankSwift` | Leave blank |
|
||||
| `benefBankName` | Leave blank |
|
||||
| `benefBankBranch` | Leave blank |
|
||||
| `benefBankAddress` | Leave blank |
|
||||
| `benefBankCity` | Leave blank |
|
||||
| `benefBankCountry` | `"4"` |
|
||||
| `intBankSwift` | Leave blank |
|
||||
| `intBankName` | Leave blank |
|
||||
| `intBankAddress` | Leave blank |
|
||||
| `intBankBranch` | Leave blank |
|
||||
| `intBankCity` | Leave blank |
|
||||
| `intBankCountry` | `"4"` |
|
||||
| `transferCySwift` | `"840"` (USD numeric — static) |
|
||||
| `email` | Leave blank |
|
||||
| `contactNumber` | Leave blank |
|
||||
| `website` | Leave blank |
|
||||
|
||||
### Response
|
||||
|
||||
```json
|
||||
{ "success": true }
|
||||
```
|
||||
|
||||
`success: true` confirms the contact was saved.
|
||||
|
||||
---
|
||||
|
||||
## Delete Contact
|
||||
|
||||
```
|
||||
POST https://faisamobilex-wv.mib.com.mv/ajaxBeneficiary/deleteBeneficiary
|
||||
```
|
||||
|
||||
Body: `benefNo=<benefNo>`
|
||||
|
||||
### Response
|
||||
|
||||
```json
|
||||
{ "success": true }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Contact Stats
|
||||
|
||||
```
|
||||
POST https://faisamobilex-wv.mib.com.mv/ajaxBeneficiary/getStats
|
||||
```
|
||||
|
||||
Empty POST body.
|
||||
|
||||
### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{ "type": "L", "count": "30" },
|
||||
{ "type": "I", "count": "10" },
|
||||
{ "type": "S", "count": "2" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Gives counts per beneficiary type. Useful for showing tab badges.
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
[← Transfer](08-transfer.md)
|
||||
@@ -1,172 +0,0 @@
|
||||
# MIB Faisanet — List Accounts & Balances
|
||||
|
||||
Account numbers and balances are returned by the **Select Profile** call (`routePath: P47`).
|
||||
The login initialization call (`A41`) returns an empty `accountBalance` array until a profile is selected.
|
||||
|
||||
---
|
||||
|
||||
## Flow to Get Account Balances
|
||||
|
||||
```
|
||||
[0] sfunc=i (key1) → DH key exchange → derive session_key
|
||||
[1] sfunc=n A44 → get userSalt
|
||||
[2] sfunc=n A41 → login with password → returns operatingProfiles (no balances yet)
|
||||
[3] sfunc=n A42 → OTP verify
|
||||
[4] sfunc=n P47 → select profile → returns accountBalance array
|
||||
```
|
||||
|
||||
Steps 0–3 are the standard login flow (see `LOGIN_FLOW.md`). Step 4 is the new call.
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Get Profile List from A41 Response
|
||||
|
||||
The `A41` login initialization response includes `operatingProfiles` — the list of
|
||||
profiles available to the user (personal, business, etc.).
|
||||
|
||||
**Relevant fields from A41 response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"defaultProfile": "2",
|
||||
"operatingProfiles": [
|
||||
{
|
||||
"profileId": "<profile ID>",
|
||||
"customerProfileId": "<customer profile ID>",
|
||||
"annexId": "<annex ID>",
|
||||
"customerId": "<customer ID>",
|
||||
"name": "<profile display name>",
|
||||
"cifType": "Individual",
|
||||
"customerImage": "<image hash>",
|
||||
"profileType": "0",
|
||||
"color": "<hex color>"
|
||||
},
|
||||
{
|
||||
"profileId": "<profile ID>",
|
||||
"customerProfileId": "<customer profile ID>",
|
||||
"annexId": "<annex ID>",
|
||||
"customerId": "<customer ID>",
|
||||
"name": "<business name / owner name>",
|
||||
"cifType": "Sole Propr",
|
||||
"customerImage": "<image hash>",
|
||||
"profileType": "1",
|
||||
"color": "<hex color>"
|
||||
}
|
||||
],
|
||||
"selectedProfileId": null,
|
||||
"selectedProfileType": null,
|
||||
"profileSelected": false
|
||||
}
|
||||
```
|
||||
|
||||
`profileType` values observed:
|
||||
| Value | Meaning |
|
||||
|---|---|
|
||||
| `"0"` | Individual (personal) |
|
||||
| `"1"` | Sole Proprietor (business) |
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — Select Profile (`sfunc=n`, `routePath: P47`)
|
||||
|
||||
**Key**: session key (derived from `sfunc=i` response)
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"sfunc": "n",
|
||||
"xxid": "<session xxid>",
|
||||
"data": {
|
||||
"profileType": "<profileType from operatingProfiles>",
|
||||
"profileId": "<profileId from operatingProfiles>",
|
||||
"nonce": "<computed nonce>",
|
||||
"appId": "<appId>",
|
||||
"sodium": "<random 20-bit int>",
|
||||
"routePath": "P47",
|
||||
"xxid": "<session xxid>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"reasonCode": "101",
|
||||
"reasonText": "Profile Selected!",
|
||||
"landingPage": "0",
|
||||
"accountBalance": [ ... ],
|
||||
"accessRights": { ... },
|
||||
"services": []
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## accountBalance Array
|
||||
|
||||
Each element in `accountBalance` represents one account:
|
||||
|
||||
```json
|
||||
{
|
||||
"cif": "<CIF number>",
|
||||
"accountNumber": "<full account number>",
|
||||
"accountBriefName": "<short label, e.g. 'SAR MVR - Savings'>",
|
||||
"template": "<display template ID>",
|
||||
"currencyCode": "<ISO 4217 numeric code>",
|
||||
"currencyName": "<ISO 4217 alpha code>",
|
||||
"accountTypeName": "<account type label>",
|
||||
"transfer": "Y",
|
||||
"branchName": "<branch name>",
|
||||
"availableBalance": "<decimal string>",
|
||||
"currentBalance": "<decimal string>",
|
||||
"blockedAmount": "<decimal string, may be negative>",
|
||||
"settlementBalance": "<decimal string>",
|
||||
"mvrBalance": "<MVR equivalent as decimal string>",
|
||||
"statusDesc": "Active"
|
||||
}
|
||||
```
|
||||
|
||||
### Field reference
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| `accountNumber` | Full account number |
|
||||
| `accountBriefName` | Human-readable account label |
|
||||
| `currencyCode` | ISO 4217 numeric (e.g. `"462"` = MVR, `"840"` = USD) |
|
||||
| `currencyName` | ISO 4217 alpha (e.g. `"MVR"`, `"USD"`) |
|
||||
| `accountTypeName` | Account type (e.g. `"Saving Account"`) |
|
||||
| `availableBalance` | Spendable balance |
|
||||
| `currentBalance` | Ledger balance |
|
||||
| `blockedAmount` | Held/blocked funds (negative means funds are held) |
|
||||
| `settlementBalance` | Balance including pending settlements |
|
||||
| `mvrBalance` | All balances converted to MVR for display |
|
||||
| `transfer` | `"Y"` if account can be used as transfer source |
|
||||
| `statusDesc` | Account status (e.g. `"Active"`) |
|
||||
| `cif` | Customer Information File number |
|
||||
| `template` | UI template ID (controls how card is rendered in-app) |
|
||||
|
||||
---
|
||||
|
||||
## accessRights
|
||||
|
||||
Also returned in the P47 response, describes what the selected profile can do:
|
||||
|
||||
```json
|
||||
{
|
||||
"numAccounts": "<number of accounts>",
|
||||
"packageRights": "[1,2,3,4,6,7,8,9,10,11,12,...]",
|
||||
"roleRights": "[]"
|
||||
}
|
||||
```
|
||||
|
||||
`packageRights` is a JSON array encoded as a string — parse it separately.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- `accountBalance` in the `A41` response is always `[]`. Balances are only returned after `P47`.
|
||||
- To switch between profiles (personal ↔ business), call `P47` again with the other profile's `profileId` and `profileType`.
|
||||
- `mvrBalance` is always in MVR regardless of the account's native currency, useful for showing a unified total.
|
||||
- All balance fields are **decimal strings**, not numbers — parse with `Decimal` for precision.
|
||||
@@ -1,345 +0,0 @@
|
||||
# Faisanet MIB API Documentation
|
||||
|
||||
Reverse-engineered from `mv.com.mib.faisamobilex` (React Native, Hermes bytecode v96).
|
||||
|
||||
---
|
||||
|
||||
## Base
|
||||
|
||||
- **URL**: `https://faisanet.mib.com.mv/faisamobilex_smvc/`
|
||||
- **Method**: `POST /`
|
||||
- **Content-Type**: `application/x-www-form-urlencoded; charset=utf-8`
|
||||
- **User-Agent**: `android/1.0`
|
||||
- **Accept**: `application/json`
|
||||
|
||||
All requests share the same form body structure:
|
||||
```
|
||||
sfunc=<function_code>&data=<urlencode(blowfish_ecb_base64_ciphertext)>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Encryption
|
||||
|
||||
### Algorithm
|
||||
- **Cipher**: Blowfish, ECB mode, PKCS5 padding
|
||||
- **Input**: raw UTF-8 bytes of JSON payload string
|
||||
- **Key**: raw UTF-8 bytes of key string
|
||||
- **Output**: base64-encoded ciphertext (URL-encoded when sent as form data)
|
||||
|
||||
### Python equivalent
|
||||
```python
|
||||
from Crypto.Cipher import Blowfish
|
||||
from Crypto.Util.Padding import pad, unpad
|
||||
import base64
|
||||
|
||||
def encrypt(payload: dict, key: str) -> str:
|
||||
import json
|
||||
plaintext = json.dumps(payload).encode()
|
||||
key_bytes = key.encode('latin-1')
|
||||
cipher = Blowfish.new(key_bytes, Blowfish.MODE_ECB)
|
||||
ct = cipher.encrypt(pad(plaintext, Blowfish.block_size))
|
||||
return base64.b64encode(ct).decode()
|
||||
|
||||
def decrypt(ciphertext_b64: str, key: str) -> dict:
|
||||
import json
|
||||
key_bytes = key.encode('latin-1')
|
||||
ct = base64.b64decode(ciphertext_b64)
|
||||
cipher = Blowfish.new(key_bytes, Blowfish.MODE_ECB)
|
||||
plaintext = unpad(cipher.decrypt(ct), Blowfish.block_size)
|
||||
return json.loads(plaintext.decode())
|
||||
```
|
||||
|
||||
### Key lifecycle
|
||||
|
||||
| Phase | Key used |
|
||||
|---|---|
|
||||
| `sfunc=r` (key exchange) | `DEFAULT_KEY` (hardcoded in app) |
|
||||
| All subsequent requests | DH-derived session key |
|
||||
|
||||
**DEFAULT_KEY** (hardcoded):
|
||||
```
|
||||
8M3L9SBF1AC4FRE56788M3L9SBF1AC4FRE5678
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Diffie-Hellman Key Exchange
|
||||
|
||||
The app uses a **custom Diffie-Hellman** scheme to derive a session key.
|
||||
|
||||
### Fixed parameters (hardcoded in app)
|
||||
|
||||
> Note: the variable names in the app's source are swapped from their DH role.
|
||||
> `A_VALUE` in the source is the **exponent** (shorter number), `P_VALUE` is the **prime modulus** (longer number).
|
||||
|
||||
```
|
||||
G (generator) = 2
|
||||
A (client privkey) = 1563516802667282387226490351799736881442299778484610378722158765594241028592123324764949712696577
|
||||
P (prime modulus) = 2410312426921032588552076022197566074856950548502459942654116941958108831682612228890093858261341614673227141477904012196503648957050582631942730706805009223062734745341073406696246014589361659774041027169249453200378729434170325843778659198143763193776859869524088940195577346119843545301547043747207749969763750084308926339295559968882457872412993810129130294592999947926365264059284647209730384947211681434464714438488520940127459844288859336526896320919633919
|
||||
```
|
||||
|
||||
> **Note**: `A` (client private key) is hardcoded in the app — this DH provides no real security.
|
||||
|
||||
### Session key derivation
|
||||
```python
|
||||
import hashlib, base64
|
||||
|
||||
def derive_session_key(smod: int) -> str:
|
||||
A = 1563516802667282387226490351799736881442299778484610378722158765594241028592123324764949712696577
|
||||
P = 2410312426921032588552076022197566074856950548502459942654116941958108831682612228890093858261341614673227141477904012196503648957050582631942730706805009223062734745341073406696246014589361659774041027169249453200378729434170325843778659198143763193776859869524088940195577346119843545301547043747207749969763750084308926339295559968882457872412993810129130294592999947926365264059284647209730384947211681434464714438488520940127459844288859336526896320919633919
|
||||
shared_secret = pow(smod, A, P)
|
||||
sha256_hex = hashlib.sha256(str(shared_secret).encode()).hexdigest().upper()
|
||||
return base64.b64encode(bytes.fromhex(sha256_hex)).decode()
|
||||
```
|
||||
|
||||
The resulting session key is always a **44-character base64 string** (32 bytes / 256-bit SHA-256 output), for example:
|
||||
```
|
||||
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX=
|
||||
```
|
||||
It changes every session because `smod` is different each time.
|
||||
|
||||
---
|
||||
|
||||
## Endpoints (sfunc values)
|
||||
|
||||
### `r` — Key Exchange (initiate session)
|
||||
|
||||
**Request payload** (encrypted with DEFAULT_KEY):
|
||||
```json
|
||||
{
|
||||
"sfunc": "r",
|
||||
"data": {
|
||||
"cmod": "<G^A mod P as decimal string>",
|
||||
"appId": "IOS17.2-<random 15-char string>",
|
||||
"routePath": "S40",
|
||||
"sodium": "<random 20-bit integer as string>",
|
||||
"xxid": "<random 40-bit integer as string>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response payload** (encrypted with DEFAULT_KEY):
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"responseCode": "1",
|
||||
"reasonCode": "201",
|
||||
"reasonText": "Key generated successfully.",
|
||||
"smod": "<server DH public key as decimal string>",
|
||||
"nonceGenerator": "<instruction string for nonce computation>",
|
||||
"xxid": "<session token>",
|
||||
"sodium": "<server random>",
|
||||
"encMethod": 1
|
||||
}
|
||||
```
|
||||
|
||||
After this call:
|
||||
- Compute `encryptionKey = derive_session_key(int(smod))`
|
||||
- Store `xxid` and `nonceGenerator` for subsequent calls
|
||||
|
||||
---
|
||||
|
||||
## Request envelope structure
|
||||
|
||||
All requests after key exchange use this structure:
|
||||
```json
|
||||
{
|
||||
"sfunc": "<function_code>",
|
||||
"xxid": "<session xxid>",
|
||||
"data": {
|
||||
"nonce": "<computed from nonceGenerator>",
|
||||
"appId": "<same appId>",
|
||||
"sodium": "<random 20-bit>",
|
||||
"routePath": "<route constant>",
|
||||
"xxid": "<session xxid>",
|
||||
...additional fields...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Encrypted with the DH-derived `encryptionKey`.
|
||||
|
||||
---
|
||||
|
||||
## Login Flows
|
||||
|
||||
### First-time device registration (no stored key1/key2)
|
||||
|
||||
1. `sfunc=r` → `S40` — DH key exchange with `DEFAULT_KEY` → receive `xxid`, `nonceGenerator`, `smod` → derive session key
|
||||
2. `sfunc=n` → `A44` — get `userSalt` for username
|
||||
3. `sfunc=n` → `C41` — submit `pgf03` (computed from password + userSalt + random clientSalt)
|
||||
4. `sfunc=n` → `C42` — verify TOTP OTP → receive `key1` and `key2`; persist them
|
||||
5. Continue with regular login below (using the just-received key1/key2)
|
||||
|
||||
### Regular login (stored key1/key2 present)
|
||||
|
||||
1. `sfunc=i` → `S40` — DH key exchange with `key1`, sending `key2` as extra form field → derive session key
|
||||
2. `sfunc=n` → `A44` — get `userSalt` for username
|
||||
3. `sfunc=n` → `A41` — submit `pgf03` → receive `operatingProfiles` list
|
||||
4. For each profile: `sfunc=n` → `P47` — fetch `accountBalance` array
|
||||
|
||||
> **No A42 step in regular login.** OTP is only verified once during first-time registration (C42).
|
||||
|
||||
### pgf03 formula
|
||||
|
||||
```python
|
||||
h1 = SHA256(password).hexdigest().upper()
|
||||
h2 = SHA256(h1 + userSalt).hexdigest().upper()
|
||||
pgf03 = SHA256(clientSalt + h2).hexdigest().upper()
|
||||
```
|
||||
|
||||
`clientSalt` is a random 32-character alphanumeric string generated fresh each login.
|
||||
|
||||
---
|
||||
|
||||
## Known route paths
|
||||
|
||||
| sfunc | routePath | Description |
|
||||
|---|---|---|
|
||||
| `r` | `S40` | DH key exchange (first-time registration) |
|
||||
| `i` | `S40` | DH key exchange (regular login, sends `key1`/`key2`) |
|
||||
| `n` | `A44` | Get auth type — returns `userSalt` for the given `uname` |
|
||||
| `n` | `C41` | Registration: submit credentials (`uname`, `pgf03`, `clientSalt`) |
|
||||
| `n` | `C42` | Registration: verify OTP (`otp`, `uname`, `otpType=3`) — returns `key1`/`key2` |
|
||||
| `n` | `A41` | Login: submit credentials (`uname`, `pgf03`, `clientSalt`, `pmodTime`, `requireBankData`) — returns `operatingProfiles` |
|
||||
| `n` | `P47` | Fetch account balances for a profile (`profileType`, `profileId`) — returns `accountBalance` array |
|
||||
| `n` | `P40` | Update profile image |
|
||||
| `n` | `P42` | Delete profile image |
|
||||
|
||||
> Note: `A42` (login OTP verify) is **not sent** during regular login. It was present in an older flow but is no longer used. `C42` is only sent during first-time device registration.
|
||||
|
||||
---
|
||||
|
||||
## Nonce Computation
|
||||
|
||||
Every request after key exchange includes a `nonce` field computed from the `nonceGenerator`
|
||||
string returned by the key exchange response.
|
||||
|
||||
### nonceGenerator format
|
||||
|
||||
A string of 4 groups separated by `-`. Each group contains 8 space-separated tokens.
|
||||
Each token is a letter followed by a number (e.g. `M85`, `A37`, `C95`, `X2`).
|
||||
|
||||
```
|
||||
M85 A87 A82 M82 M60 M31 A46 C95-M14 X83 A37 X2 C4 X22 X46 C95-M57 X29 C51 C34 S91 X60 S1 A15-M54 A89 S13 S18 C81 A70 X92 X59
|
||||
```
|
||||
|
||||
### Nonce output format
|
||||
|
||||
4 groups separated by `-`. Each group: a zero-padded 5-digit number followed by 7 two-digit
|
||||
numbers separated by spaces.
|
||||
|
||||
```
|
||||
08160 19 73 45 17 89 07 10-00924 64 73 18 08 48 80 67-01026 20 17 13 26 26 43 24-00648 12 32 17 69 14 63 92
|
||||
```
|
||||
|
||||
### Algorithm
|
||||
|
||||
**Phase 1 — process first token of each group (produces seed values):**
|
||||
|
||||
For each of the 4 groups (index `i`):
|
||||
1. Take `token[0]` (e.g. `M85`). Extract the number: `N = parseInt(token.replace(/\D/g, ''))`.
|
||||
2. Generate a random integer: `r = floor(random() * 99) + 1` (range 1–99 inclusive).
|
||||
3. Compute `product = N * r`. Zero-pad to 5 digits: `padded = product.toString().padStart(5, '0')`.
|
||||
4. Compute `digitSum[i]` = sum of all digits in `padded`.
|
||||
5. Store `lastTwo[i]` = `parseInt(padded.slice(-2))` (last two digits as integer).
|
||||
6. Accumulate `cumSum += digitSum[i]`.
|
||||
|
||||
After all 4 groups: `cumSum` = sum of all four `digitSum` values.
|
||||
|
||||
**Phase 2 — process tokens 1–7 of each group (produces nonce digits):**
|
||||
|
||||
For each group (index `i`), process `token[1]` through `token[7]`:
|
||||
- Initialise `carry = lastTwo[i]`.
|
||||
- For each token at position `j` (1–7):
|
||||
- Extract letter `op` and number `num`.
|
||||
- Compute `val` based on `op`:
|
||||
| op | formula |
|
||||
|---|---|
|
||||
| `M` | `(carry % num) + digitSum[i] + cumSum` |
|
||||
| `A` | `carry + num + digitSum[i] + cumSum` |
|
||||
| `S` | `(carry * carry) + num + digitSum[i] + cumSum` |
|
||||
| `X` | `(carry * num) + digitSum[i] + cumSum` |
|
||||
| `C` | `(carry * carry * carry) + num + digitSum[i] + cumSum` |
|
||||
- Nonce digit = `parseInt(val.toString().slice(-2))` (last two digits as integer).
|
||||
- Update `carry = nonceDigit` for the next token.
|
||||
|
||||
**Assembling the nonce string:**
|
||||
|
||||
For each group `i`:
|
||||
```
|
||||
group_str = padded[i] + " " + nonceDigit[i][0].toString().padStart(2,'0') + " " + ... (7 digits)
|
||||
```
|
||||
Join 4 groups with `-`.
|
||||
|
||||
### Python implementation
|
||||
|
||||
```python
|
||||
import math, random
|
||||
|
||||
def generate_nonce(nonce_generator: str) -> str:
|
||||
groups = nonce_generator.split('-')
|
||||
|
||||
padded_list, last_two, digit_sum = [], [], []
|
||||
cum_sum = 0
|
||||
|
||||
# Phase 1
|
||||
for group in groups:
|
||||
tokens = group.split(' ')
|
||||
n = int(''.join(c for c in tokens[0] if c.isdigit()))
|
||||
r = math.floor(random.random() * 99) + 1
|
||||
product = n * r
|
||||
padded = str(product).zfill(5)
|
||||
ds = sum(int(d) for d in padded)
|
||||
lt = int(padded[-2:])
|
||||
padded_list.append(padded)
|
||||
last_two.append(lt)
|
||||
digit_sum.append(ds)
|
||||
cum_sum += ds
|
||||
|
||||
# Phase 2
|
||||
result_groups = []
|
||||
for i, group in enumerate(groups):
|
||||
tokens = group.split(' ')
|
||||
carry = last_two[i]
|
||||
ds = digit_sum[i]
|
||||
nonce_digits = []
|
||||
for token in tokens[1:]:
|
||||
op = ''.join(c for c in token if c.isalpha())
|
||||
num = int(''.join(c for c in token if c.isdigit()))
|
||||
if op == 'M':
|
||||
val = (carry % num) + ds + cum_sum
|
||||
elif op == 'A':
|
||||
val = carry + num + ds + cum_sum
|
||||
elif op == 'S':
|
||||
val = (carry * carry) + num + ds + cum_sum
|
||||
elif op == 'X':
|
||||
val = (carry * num) + ds + cum_sum
|
||||
elif op == 'C':
|
||||
val = (carry * carry * carry) + num + ds + cum_sum
|
||||
else:
|
||||
val = 0
|
||||
digit = int(str(val)[-2:])
|
||||
nonce_digits.append(digit)
|
||||
carry = digit
|
||||
group_str = padded_list[i] + ' ' + ' '.join(str(d).zfill(2) for d in nonce_digits)
|
||||
result_groups.append(group_str)
|
||||
|
||||
return '-'.join(result_groups)
|
||||
```
|
||||
|
||||
### Notes
|
||||
|
||||
- `nonce` and `sodium` are **separate** request fields. `sodium` is an independent random integer
|
||||
(observed range ~1M–16M, approximately 23–24 bit).
|
||||
- The nonce string is the same value for both the `nonce` and ... actually they are different fields:
|
||||
`nonce` = the computed nonce string; `sodium` = a random integer sent as a plain string.
|
||||
- For `sfunc=i`, `key2` is sent as a **separate form field** (not inside the encrypted payload):
|
||||
`key2=<key2>&sfunc=i&data=<encrypted>`. The encrypted payload is the inner data object only,
|
||||
encrypted with `key1`.
|
||||
- For all `sfunc=n` requests (every request after key exchange), `xxid` is sent as a **separate
|
||||
unencrypted form field** as the FIRST field:
|
||||
`xxid=<session_xxid>&sfunc=n&data=<encrypted>`. The `xxid` also appears inside the encrypted
|
||||
payload. Field order matters — `xxid` must come before `sfunc` and `data`.
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
# MIB Faisanet API — Encryption & Decryption
|
||||
|
||||
## Overview
|
||||
|
||||
All API traffic is encrypted using **Blowfish** in ECB mode with PKCS5 padding.
|
||||
Every request and response body is a single base64-encoded Blowfish ciphertext.
|
||||
|
||||
There are two keys in play:
|
||||
|
||||
| Key | Used for |
|
||||
|---|---|
|
||||
| `DEFAULT_KEY` (hardcoded) | The initial key exchange request and response (`sfunc=r`) |
|
||||
| Session key (DH-derived) | Every request and response after the key exchange |
|
||||
|
||||
---
|
||||
|
||||
## The DEFAULT_KEY
|
||||
|
||||
```
|
||||
8M3L9SBF1AC4FRE56788M3L9SBF1AC4FRE5678
|
||||
```
|
||||
|
||||
This key is hardcoded in the app's JavaScript bundle. It is only used for the
|
||||
first call (`sfunc=r`) which establishes a session key via Diffie-Hellman.
|
||||
|
||||
---
|
||||
|
||||
## Session Key Derivation (Diffie-Hellman)
|
||||
|
||||
The app uses a custom DH key exchange to derive a per-session Blowfish key.
|
||||
All three DH parameters are hardcoded in the app:
|
||||
|
||||
```
|
||||
G = 2
|
||||
P = 1563516802667282387226490351799736881442299778484610378722158765594241028592123324764949712696577
|
||||
A = 2410312426921032588552076022197566074856950548502459942654116941958108831682612228890093858261341614673227141477904012196503648957050582631942730706805009223062734745341073406696246014589361659774041027169249453200378729434170325843778659198143763193776859869524088940195577346119843545301547043747207749969763750084308926339295559968882457872412993810129130294592999947926365264059284647209730384947211681434464714438488520940127459844288859336526896320919633919
|
||||
```
|
||||
|
||||
`A` is the client's private key. Because it is hardcoded and never rotates,
|
||||
anyone with the APK can derive the session key from a captured `smod`.
|
||||
|
||||
### Step-by-step
|
||||
|
||||
1. Client computes `cmod = G^A mod P` and sends it in the `sfunc=r` request.
|
||||
2. Server computes its own keypair and responds with `smod` (its public key).
|
||||
3. Client computes the shared secret: `shared = smod^A mod P`
|
||||
4. Client SHA-256 hashes the decimal string of the shared secret (uppercased hex).
|
||||
5. Client converts that hex string to raw bytes, then base64-encodes it.
|
||||
6. The result is the Blowfish key for the rest of the session.
|
||||
|
||||
```python
|
||||
import hashlib, base64
|
||||
|
||||
def derive_session_key(smod: int) -> str:
|
||||
# A_VALUE in app = exponent (shorter), P_VALUE in app = modulus (longer)
|
||||
A = 1563516802667282387226490351799736881442299778484610378722158765594241028592123324764949712696577
|
||||
P = 2410312426921032588552076022197566074856950548502459942654116941958108831682612228890093858261341614673227141477904012196503648957050582631942730706805009223062734745341073406696246014589361659774041027169249453200378729434170325843778659198143763193776859869524088940195577346119843545301547043747207749969763750084308926339295559968882457872412993810129130294592999947926365264059284647209730384947211681434464714438488520940127459844288859336526896320919633919
|
||||
shared = pow(smod, A, P)
|
||||
sha256_hex = hashlib.sha256(str(shared).encode()).hexdigest().upper()
|
||||
return base64.b64encode(bytes.fromhex(sha256_hex)).decode()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Encrypting a Request
|
||||
|
||||
All request payloads follow this JSON structure before encryption:
|
||||
|
||||
```json
|
||||
{
|
||||
"sfunc": "<function code>",
|
||||
"xxid": "<session token>",
|
||||
"data": {
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Encryption steps
|
||||
|
||||
1. `JSON.stringify` the payload.
|
||||
2. Use the raw UTF-8 bytes of the payload as plaintext.
|
||||
3. Use the raw UTF-8 bytes of the key string as the Blowfish key.
|
||||
4. Encrypt: Blowfish / ECB / PKCS5 padding.
|
||||
5. Base64-encode the ciphertext.
|
||||
6. URL-encode the base64 string.
|
||||
7. Send as form field: `sfunc=<value>&data=<url-encoded-ciphertext>`
|
||||
|
||||
```python
|
||||
import json, base64
|
||||
from urllib.parse import quote
|
||||
from Crypto.Cipher import Blowfish
|
||||
from Crypto.Util.Padding import pad
|
||||
|
||||
def encrypt(payload: dict, key: str) -> str:
|
||||
plaintext = json.dumps(payload, separators=(',', ':')).encode('utf-8')
|
||||
key_bytes = key.encode('latin-1')
|
||||
cipher = Blowfish.new(key_bytes, Blowfish.MODE_ECB)
|
||||
ct = cipher.encrypt(pad(plaintext, Blowfish.block_size))
|
||||
return base64.b64encode(ct).decode()
|
||||
|
||||
def build_request_body(payload: dict, key: str) -> str:
|
||||
sfunc = payload.get('sfunc', '')
|
||||
encrypted = encrypt(payload, key)
|
||||
return f"sfunc={sfunc}&data={quote(encrypted)}"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Decrypting a Response
|
||||
|
||||
The response body is a raw base64-encoded Blowfish ciphertext (no form encoding).
|
||||
|
||||
### Decryption steps
|
||||
|
||||
1. Base64-decode the response body to get the ciphertext bytes.
|
||||
2. Decrypt with Blowfish / ECB / PKCS5 padding using the appropriate key.
|
||||
3. Parse the result as JSON.
|
||||
|
||||
```python
|
||||
import json, base64
|
||||
from Crypto.Cipher import Blowfish
|
||||
from Crypto.Util.Padding import unpad
|
||||
|
||||
def decrypt(ciphertext_b64: str, key: str) -> dict:
|
||||
key_bytes = key.encode('latin-1')
|
||||
ct = base64.b64decode(ciphertext_b64)
|
||||
cipher = Blowfish.new(key_bytes, Blowfish.MODE_ECB)
|
||||
plaintext = unpad(cipher.decrypt(ct), Blowfish.block_size)
|
||||
return json.loads(plaintext.decode('utf-8'))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Full Session Example
|
||||
|
||||
### 1. Send key exchange (`sfunc=r`) — use DEFAULT_KEY
|
||||
|
||||
Request form body:
|
||||
```
|
||||
sfunc=r&data=<base64 of Blowfish(DEFAULT_KEY, {"sfunc":"r","data":{"cmod":"...","appId":"...","routePath":"S40","sodium":"...","xxid":"..."}})>
|
||||
```
|
||||
|
||||
Response (decrypted with DEFAULT_KEY):
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"smod": "<large decimal integer — server DH public key>",
|
||||
"nonceGenerator": "<instruction string, e.g. 'M26 C16 C4 C5 M64 ...'>",
|
||||
"xxid": "<session token>",
|
||||
"sodium": "<server random hex string>",
|
||||
"encMethod": 2
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Derive the session key from `smod`
|
||||
|
||||
```python
|
||||
session_key = derive_session_key(int(response['smod']))
|
||||
# → a 44-character base64 string, e.g. "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX="
|
||||
# The key is 32 bytes (256-bit SHA-256 output) encoded as base64.
|
||||
```
|
||||
|
||||
### 3. All subsequent requests — use session key
|
||||
|
||||
Encrypt with `session_key`, decrypt responses with `session_key`.
|
||||
|
||||
---
|
||||
|
||||
## Quick reference
|
||||
|
||||
| What | How |
|
||||
|---|---|
|
||||
| Cipher | Blowfish, ECB mode, PKCS5 padding |
|
||||
| Key for `sfunc=r` | `8M3L9SBF1AC4FRE56788M3L9SBF1AC4FRE5678` |
|
||||
| Key for everything else | `derive_session_key(smod)` |
|
||||
| Request encoding | JSON → encrypt → base64 → URL-encode → form field `data=` |
|
||||
| Response encoding | base64 → decrypt → JSON |
|
||||
| Key input | raw UTF-8 bytes of key string |
|
||||
| Plaintext input | raw UTF-8 bytes of JSON string |
|
||||
@@ -1,449 +0,0 @@
|
||||
# MIB Faisanet — Login Flow
|
||||
|
||||
Fully reverse engineered from a captured HAR trace of a first-time device
|
||||
registration followed immediately by a regular login.
|
||||
|
||||
---
|
||||
|
||||
## Key Corrections
|
||||
|
||||
The DH parameter names in the app's source are **misleading**:
|
||||
|
||||
| App variable | DH role | Value |
|
||||
|---|---|---|
|
||||
| `A_VALUE` | Exponent / client private key | `15635168026...` (shorter) |
|
||||
| `P_VALUE` | Prime modulus | `24103124269...` (longer) |
|
||||
| `G_VALUE` | Generator | `2` |
|
||||
|
||||
The session key derivation is:
|
||||
```python
|
||||
shared = pow(smod, A_VALUE, P_VALUE) # NOT pow(smod, P_VALUE, A_VALUE)
|
||||
sha256_hex = SHA256(str(shared)).upper()
|
||||
session_key = base64(bytes.fromhex(sha256_hex))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The full login sequence consists of two phases:
|
||||
|
||||
| Phase | Purpose | Key used |
|
||||
|---|---|---|
|
||||
| **Phase 1** — Device registration | First time this device+account pair is seen | DH session key from `sfunc=r` |
|
||||
| **Phase 2** — Regular login | Every subsequent login | key1/key2 (from phase 1) → second DH → new session key |
|
||||
|
||||
---
|
||||
|
||||
## Full Flow Diagram
|
||||
|
||||
```
|
||||
Client Server
|
||||
| |
|
||||
| [0] sfunc=r (DEFAULT_KEY) |
|
||||
| { cmod, appId, routePath:S40, ... } |
|
||||
|--------------------------------------------->|
|
||||
| { smod, nonceGenerator, xxid, ... } |
|
||||
|<---------------------------------------------|
|
||||
| derive session_key_1 = DH(smod) |
|
||||
| |
|
||||
| [1] sfunc=n routePath:A44 (session_key_1)|
|
||||
| { uname } |
|
||||
|--------------------------------------------->|
|
||||
| { loginType, userSalt } |
|
||||
|<---------------------------------------------|
|
||||
| |
|
||||
| [2] sfunc=n routePath:C41 (session_key_1)| ← device registration init
|
||||
| { uname, pgf03, clientSalt } |
|
||||
|--------------------------------------------->|
|
||||
| { key1, key2, otpTypes, fullName, ... } |
|
||||
|<---------------------------------------------|
|
||||
| |
|
||||
| [3] sfunc=n routePath:C42 (session_key_1)| ← OTP verify (registration)
|
||||
| { otp, uname, otpType } |
|
||||
|--------------------------------------------->|
|
||||
| { key1, key2, encryptionMethod:2, ... } |
|
||||
|<---------------------------------------------|
|
||||
| store key1, key2 on device |
|
||||
| |
|
||||
| [4] sfunc=i (key1) | ← second DH key exchange
|
||||
| { cmod, appId, routePath:S40, key2 } |
|
||||
|--------------------------------------------->|
|
||||
| { smod, nonceGenerator, xxid, ... } |
|
||||
|<---------------------------------------------|
|
||||
| derive session_key_2 = DH(smod) |
|
||||
| |
|
||||
| [5] sfunc=n routePath:A44 (session_key_2)|
|
||||
| { uname } |
|
||||
|--------------------------------------------->|
|
||||
| { loginType, userSalt } |
|
||||
|<---------------------------------------------|
|
||||
| |
|
||||
| [6] sfunc=n routePath:A41 (session_key_2)| ← regular login init
|
||||
| { uname, pgf03, clientSalt, requireBankData:1 }|
|
||||
|--------------------------------------------->|
|
||||
| { primaryOTPType, otpTypes, email, uuid, uuid2, ... }|
|
||||
|<---------------------------------------------|
|
||||
| |
|
||||
| [7] sfunc=n routePath:P41 (session_key_2)| ← fetch profile image
|
||||
| { imageHash } |
|
||||
|--------------------------------------------->|
|
||||
| { profileImage (base64 JPEG) } |
|
||||
|<---------------------------------------------|
|
||||
| |
|
||||
| [8] sfunc=n routePath:A42 (session_key_2)| ← OTP verify (regular login)
|
||||
| { otp, uname, otpType } |
|
||||
|--------------------------------------------->|
|
||||
| { ... session established ... } |
|
||||
|<---------------------------------------------|
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step-by-Step Reference
|
||||
|
||||
### [0] Initial Key Exchange — `sfunc=r`
|
||||
|
||||
**Key**: `DEFAULT_KEY = 8M3L9SBF1AC4FRE56788M3L9SBF1AC4FRE5678`
|
||||
|
||||
**Request body** (inner `data` field):
|
||||
```json
|
||||
{
|
||||
"cmod": "<G^A_VALUE mod P_VALUE as decimal string>",
|
||||
"appId": "IOS17.2-<15 random chars>",
|
||||
"routePath": "S40",
|
||||
"sodium": "<random 20-bit int>",
|
||||
"xxid": "<random 40-bit int>"
|
||||
}
|
||||
```
|
||||
|
||||
**Full request** (outer wrapper, encrypted together):
|
||||
```json
|
||||
{ "sfunc": "r", "data": { ...above... } }
|
||||
```
|
||||
|
||||
**Response** (decrypted with DEFAULT_KEY):
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"reasonCode": "201",
|
||||
"reasonText": "Key generated successfully.",
|
||||
"smod": "<server DH public key>",
|
||||
"nonceGenerator": "<instruction string>",
|
||||
"xxid": "<session token — carry for all subsequent calls>",
|
||||
"sodium": "<server random>",
|
||||
"encMethod": 2
|
||||
}
|
||||
```
|
||||
|
||||
After this step:
|
||||
- Derive `session_key_1 = derive_session_key(smod)`
|
||||
- Save `xxid` and `nonceGenerator`
|
||||
|
||||
---
|
||||
|
||||
### [1] Get Auth Type — `sfunc=n`, `routePath: A44`
|
||||
|
||||
**Key**: `session_key_1`
|
||||
|
||||
**Request** (encrypted):
|
||||
```json
|
||||
{
|
||||
"sfunc": "n",
|
||||
"xxid": "<session xxid>",
|
||||
"data": {
|
||||
"uname": "<username>",
|
||||
"nonce": "<computed from nonceGenerator>",
|
||||
"appId": "<appId>",
|
||||
"sodium": "<random 20-bit int>",
|
||||
"routePath": "A44",
|
||||
"xxid": "<session xxid>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"reasonCode": "108",
|
||||
"reasonText": "Auth type retrieved!",
|
||||
"data": [
|
||||
{
|
||||
"loginType": "1",
|
||||
"userSalt": "<server salt for password hashing>"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### [2] Device Registration Init — `sfunc=n`, `routePath: C41`
|
||||
|
||||
First-time only. Registers this device+account pair.
|
||||
|
||||
**Key**: `session_key_1`
|
||||
|
||||
**Request**:
|
||||
```json
|
||||
{
|
||||
"sfunc": "n",
|
||||
"xxid": "<session xxid>",
|
||||
"data": {
|
||||
"uname": "<username>",
|
||||
"pgf03": "<salted password hash — see below>",
|
||||
"clientSalt": "<random 32-char string>",
|
||||
"nonce": "<nonce>",
|
||||
"appId": "<appId>",
|
||||
"sodium": "<random>",
|
||||
"routePath": "C41",
|
||||
"xxid": "<session xxid>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"reasonCode": "201",
|
||||
"reasonText": "Registration Initialization Successfully.",
|
||||
"primaryOTPType": "3",
|
||||
"roleName": "Consumer Premium",
|
||||
"otpTypes": [2, 3],
|
||||
"fullName": "<user's full name>",
|
||||
"lastLoginTime": "<datetime>",
|
||||
"customerImgHash": "<hash>"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### [3] OTP Verification (Registration) — `sfunc=n`, `routePath: C42`
|
||||
|
||||
**Key**: `session_key_1`
|
||||
|
||||
**Request**:
|
||||
```json
|
||||
{
|
||||
"sfunc": "n",
|
||||
"xxid": "<session xxid>",
|
||||
"data": {
|
||||
"otp": "<6-digit OTP>",
|
||||
"uname": "<username>",
|
||||
"otpType": "3",
|
||||
"nonce": "<nonce>",
|
||||
"appId": "<appId>",
|
||||
"sodium": "<random>",
|
||||
"routePath": "C42",
|
||||
"xxid": "<session xxid>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"reasonCode": "101",
|
||||
"reasonText": "registration success",
|
||||
"data": [
|
||||
{
|
||||
"appId": "<appId>",
|
||||
"createdDate": "<datetime>",
|
||||
"key1": "<device credential 1 — store securely>",
|
||||
"key2": "<device credential 2 — store securely>",
|
||||
"encryptionMethod": "2",
|
||||
"appAgent": "android/1.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`key1` and `key2` are long-lived device credentials. `key1` is the Blowfish key
|
||||
for the next `sfunc=i` call. `key2` is sent as plaintext in the outer wrapper of
|
||||
that call.
|
||||
|
||||
---
|
||||
|
||||
### [4] Authenticated Key Exchange — `sfunc=i`
|
||||
|
||||
Second DH exchange, authenticated with the device credentials.
|
||||
|
||||
**Key**: `key1`
|
||||
|
||||
**Request** (outer wrapper includes `key2`):
|
||||
```json
|
||||
{
|
||||
"sfunc": "i",
|
||||
"key2": "<key2 from registration>",
|
||||
"data": {
|
||||
"cmod": "<G^A_VALUE mod P_VALUE>",
|
||||
"appId": "<appId>",
|
||||
"routePath": "S40",
|
||||
"sodium": "<random 20-bit int>",
|
||||
"xxid": "<random 40-bit int>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response** (decrypted with `key1`):
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"reasonCode": "201",
|
||||
"reasonText": "Key generated successfully.",
|
||||
"smod": "<new server DH public key>",
|
||||
"nonceGenerator": "<new instruction string>",
|
||||
"xxid": "<new session token>",
|
||||
"encMethod": 2
|
||||
}
|
||||
```
|
||||
|
||||
After this step:
|
||||
- Derive `session_key_2 = derive_session_key(smod)`
|
||||
- Replace `xxid` and `nonceGenerator` with new values
|
||||
|
||||
---
|
||||
|
||||
### [5] Get Auth Type — `sfunc=n`, `routePath: A44`
|
||||
|
||||
Same as step [1] but with `session_key_2`. Fetches `userSalt` for password hashing.
|
||||
|
||||
---
|
||||
|
||||
### [6] Regular Login Init — `sfunc=n`, `routePath: A41`
|
||||
|
||||
**Key**: `session_key_2`
|
||||
|
||||
**Request**:
|
||||
```json
|
||||
{
|
||||
"sfunc": "n",
|
||||
"xxid": "<session xxid>",
|
||||
"data": {
|
||||
"uname": "<username>",
|
||||
"pgf03": "<salted password hash>",
|
||||
"clientSalt": "<random 32-char string>",
|
||||
"pmodTime": 0,
|
||||
"requireBankData": 1,
|
||||
"nonce": "<nonce>",
|
||||
"appId": "<appId>",
|
||||
"sodium": "<random>",
|
||||
"routePath": "A41",
|
||||
"xxid": "<session xxid>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"reasonCode": "104",
|
||||
"reasonText": "Initialization Successful",
|
||||
"primaryOTPType": "3",
|
||||
"roleName": "Consumer Premium",
|
||||
"otpTypes": [2, 3],
|
||||
"email": "<masked email>",
|
||||
"uuid": "<uuid1>",
|
||||
"uuid2": "<uuid2>",
|
||||
"xxid": "<xxid>"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### [7] Get Profile Image — `sfunc=n`, `routePath: P41`
|
||||
|
||||
**Key**: `session_key_2`
|
||||
|
||||
**Request**:
|
||||
```json
|
||||
{
|
||||
"sfunc": "n",
|
||||
"xxid": "<session xxid>",
|
||||
"data": {
|
||||
"imageHash": "<customerImgHash from step 2/6>",
|
||||
"nonce": "<nonce>",
|
||||
"appId": "<appId>",
|
||||
"sodium": "<random>",
|
||||
"routePath": "P41",
|
||||
"xxid": "<session xxid>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"reasonCode": "201",
|
||||
"reasonText": "Image Found",
|
||||
"profileImage": "<base64 JPEG>"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### [8] OTP Verification (Login) — `sfunc=n`, `routePath: A42`
|
||||
|
||||
**Key**: `session_key_2`
|
||||
|
||||
**Request**:
|
||||
```json
|
||||
{
|
||||
"sfunc": "n",
|
||||
"xxid": "<session xxid>",
|
||||
"data": {
|
||||
"otp": "<6-digit OTP>",
|
||||
"uname": "<username>",
|
||||
"otpType": "3",
|
||||
"nonce": "<nonce>",
|
||||
"appId": "<appId>",
|
||||
"sodium": "<random>",
|
||||
"routePath": "A42",
|
||||
"xxid": "<session xxid>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Password Hashing (`pgf03`)
|
||||
|
||||
The password is never sent in plaintext. The scheme prevents replay attacks by
|
||||
mixing in a server-provided salt and a client-generated random salt.
|
||||
|
||||
```
|
||||
pgf03 = SHA256( clientSalt + SHA256( userSalt + SHA256( password ) ) )
|
||||
```
|
||||
|
||||
All SHA256 values are uppercase hex strings.
|
||||
|
||||
```python
|
||||
import hashlib
|
||||
|
||||
def compute_pgf03(password: str, user_salt: str, client_salt: str) -> str:
|
||||
h1 = hashlib.sha256(password.encode()).hexdigest().upper()
|
||||
h2 = hashlib.sha256((user_salt + h1).encode()).hexdigest().upper()
|
||||
h3 = hashlib.sha256((client_salt + h2).encode()).hexdigest().upper()
|
||||
return h3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Route Paths Summary
|
||||
|
||||
| routePath | sfunc | Description |
|
||||
|---|---|---|
|
||||
| `S40` | `r` or `i` | DH key exchange |
|
||||
| `A44` | `n` | Get auth type / userSalt |
|
||||
| `A41` | `n` | Regular login initialization |
|
||||
| `A42` | `n` | OTP verification (regular login) |
|
||||
| `C41` | `n` | Device registration initialization |
|
||||
| `C42` | `n` | OTP verification (registration) |
|
||||
| `P41` | `n` | Get profile image |
|
||||
| `P40` | `n` | Update profile image |
|
||||
| `P42` | `n` | Delete profile image |
|
||||
88
docs/mibapi/README.md
Normal file
88
docs/mibapi/README.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# MIB Faisanet API
|
||||
|
||||
Reverse-engineered from `mv.com.mib.faisamobilex` (Faisanet Mobile Banking, React Native / Hermes bytecode v96).
|
||||
|
||||
[Play Store](https://play.google.com/store/apps/details?id=mv.com.mib.faisamobilex)
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
MIB uses **two completely separate backends**:
|
||||
|
||||
| Backend | Base URL | Auth | Used for |
|
||||
|---|---|---|---|
|
||||
| Encrypted API | `https://faisanet.mib.com.mv/faisamobilex_smvc/` | Blowfish + DH session key | Login, key exchange |
|
||||
| WebView host | `https://faisamobilex-wv.mib.com.mv` | Session cookies | Accounts, history, transfers, contacts, cards, financing |
|
||||
|
||||
---
|
||||
|
||||
## Encrypted API
|
||||
|
||||
All calls to the encrypted API are `POST /` with `Content-Type: application/x-www-form-urlencoded; charset=utf-8` and form body:
|
||||
|
||||
```
|
||||
sfunc=<function_code>&data=<url_encoded_base64_blowfish_ciphertext>
|
||||
```
|
||||
|
||||
The request JSON is encrypted with Blowfish (ECB, PKCS5) before sending. The response body is also base64-encoded Blowfish ciphertext.
|
||||
|
||||
Two keys are used:
|
||||
|
||||
| Phase | Key |
|
||||
|---|---|
|
||||
| `sfunc=r` (initial key exchange) | `DEFAULT_KEY` (hardcoded in app) |
|
||||
| All subsequent requests | DH-derived session key |
|
||||
|
||||
See [01-encryption.md](01-encryption.md) for full details.
|
||||
|
||||
---
|
||||
|
||||
## WebView Session Auth
|
||||
|
||||
After login, all data endpoints use cookie-based auth on `faisamobilex-wv.mib.com.mv`:
|
||||
|
||||
```
|
||||
Cookie: mbmodel=IOS-1.0; xxid=<session_xxid>; IBSID=<session_xxid>; mbnonce=<nonceGenerator>; time-tracker=597
|
||||
```
|
||||
|
||||
These values come from the login flow — `xxid` and `nonceGenerator` from the DH key exchange response.
|
||||
|
||||
### WebView AJAX Headers
|
||||
|
||||
All AJAX `POST` calls also require:
|
||||
|
||||
```
|
||||
X-Requested-With: XMLHttpRequest
|
||||
Accept: */*
|
||||
Origin: https://faisamobilex-wv.mib.com.mv
|
||||
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
|
||||
```
|
||||
|
||||
The `Referer` value varies per endpoint (documented per endpoint).
|
||||
|
||||
### WebView User-Agent
|
||||
|
||||
```
|
||||
Mozilla/5.0 (Linux; Android {version}; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Documents
|
||||
|
||||
| # | File | Description |
|
||||
|---|---|---|
|
||||
| 1 | [01-encryption.md](01-encryption.md) | Blowfish encryption, DH key exchange, nonce computation |
|
||||
| 2 | [02-login.md](02-login.md) | Device registration and regular login flows |
|
||||
| 3 | [03-accounts.md](03-accounts.md) | Select profile, account balances |
|
||||
| 4 | [04-history.md](04-history.md) | Transaction history |
|
||||
| 5 | [05-cards.md](05-cards.md) | Debit card list |
|
||||
| 6 | [06-financing.md](06-financing.md) | Financing deals |
|
||||
| 7 | [07-profile.md](07-profile.md) | Personal profile (HTML scrape) |
|
||||
| 8 | [08-transfer.md](08-transfer.md) | Account lookup and fund transfer |
|
||||
| 9 | [09-contacts.md](09-contacts.md) | Beneficiary management |
|
||||
|
||||
---
|
||||
|
||||
**Start here →** [01-encryption.md](01-encryption.md)
|
||||
@@ -1,123 +0,0 @@
|
||||
# MIB Account Lookup Routing
|
||||
|
||||
Before initiating a transfer, the recipient input must be resolved to a verified account name and
|
||||
account number. Three different endpoints are used depending on the format of the input.
|
||||
|
||||
## Input Format Routing
|
||||
|
||||
| Input format | Endpoint | Body field |
|
||||
|-------------------------------------------|---------------------------------------|-----------------|
|
||||
| Starts with `7`, exactly 13 digits | `AjaxAlias/getIPSAccount` | `benefAccount` |
|
||||
| Starts with `9`, exactly 17 digits | `ajaxBeneficiary/getAccountName` | `accountNo` |
|
||||
| Starts with `7` or `9`, exactly 7 digits | `AjaxAlias/getAlias` | `aliasName` |
|
||||
| Starts with `A` followed by 6 digits | `AjaxAlias/getAlias` | `aliasName` |
|
||||
| Email address (contains `@`) | `AjaxAlias/getAlias` | `aliasName` |
|
||||
|
||||
All endpoints share the same WebView session auth (see `contacts.md` for cookie format) and use
|
||||
`Referer: https://faisamobilex-wv.mib.com.mv/transfer/quick`.
|
||||
|
||||
---
|
||||
|
||||
## Endpoint Details
|
||||
|
||||
### 1. IPS Account Lookup — Local / BML accounts (13 digits, starts with 7)
|
||||
|
||||
**POST** `https://faisamobilex-wv.mib.com.mv/AjaxAlias/getIPSAccount`
|
||||
|
||||
Body: `benefAccount=7700000000000` (13 digits)
|
||||
|
||||
**Success response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"responseCode": "2",
|
||||
"reasonCode": "201",
|
||||
"reasonText": "Request Successful. Account Found",
|
||||
"accountName": "ACCOUNT HOLDER NAME",
|
||||
"bankBic": "MALBMVMV"
|
||||
}
|
||||
```
|
||||
|
||||
Fields used:
|
||||
- `accountName` — account holder name
|
||||
- `bankBic` — bank SWIFT/BIC code
|
||||
|
||||
The account number is already known from the input; it is not returned in the response.
|
||||
|
||||
---
|
||||
|
||||
### 2. MIB Internal Account Name Lookup — MIB accounts (17 digits, starts with 9)
|
||||
|
||||
**POST** `https://faisamobilex-wv.mib.com.mv/ajaxBeneficiary/getAccountName`
|
||||
|
||||
Body: `accountNo=90100000000000000` (17 digits)
|
||||
|
||||
**Success response** (exact structure to be confirmed):
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"responseCode": "1",
|
||||
"reasonText": "Account found",
|
||||
"accountName": "ACCOUNT HOLDER NAME"
|
||||
}
|
||||
```
|
||||
|
||||
Fields used:
|
||||
- `accountName` — account holder name (check at root level or inside `data` object)
|
||||
|
||||
The account number is already known from the input; bank is always MIB (`MADVMVMV`).
|
||||
|
||||
---
|
||||
|
||||
### 3. Favara Alias Lookup — Shortcodes, A-IDs, emails
|
||||
|
||||
**POST** `https://faisamobilex-wv.mib.com.mv/AjaxAlias/getAlias`
|
||||
|
||||
Body: `aliasName=<alias>`
|
||||
|
||||
Accepted alias formats:
|
||||
- `7` or `9` followed by 6 digits → e.g. `7012345`, `9198026`
|
||||
- `A` followed by 6 digits → e.g. `A123456`
|
||||
- Email address → e.g. `user@example.com`
|
||||
|
||||
**Success response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"responseCode": "2",
|
||||
"reasonCode": "203",
|
||||
"reasonText": " Favara ID found",
|
||||
"data": {
|
||||
"TxId": "BANK00001",
|
||||
"CdtrAcct": {
|
||||
"Acct": "90100000000000000",
|
||||
"FinInstnId": "MADVMVMV"
|
||||
},
|
||||
"BfyNm": "Account Holder Name",
|
||||
"RegDtTm": "2023-01-01T00:00:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Fields used from `data`:
|
||||
- `BfyNm` — beneficiary name (trim whitespace)
|
||||
- `CdtrAcct.Acct` — resolved account number to use for the transfer
|
||||
- `CdtrAcct.FinInstnId` — bank institution ID
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
All three endpoints return `"success": false` on failure with a human-readable `reasonText`:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"responseCode": "0",
|
||||
"reasonText": "Account not found"
|
||||
}
|
||||
```
|
||||
|
||||
- Always show `reasonText` directly to the user as the error message.
|
||||
- For non-200 HTTP responses, also attempt to parse `reasonText` from the body before falling back to a generic error.
|
||||
- If the input does not match any known format, reject it client-side before making any request.
|
||||
@@ -1,249 +0,0 @@
|
||||
# MIB Contacts (Beneficiary) API
|
||||
|
||||
The contacts/beneficiary system is served from the MIB WebView subdomain. All endpoints use
|
||||
session-cookie authentication (same cookies as the financing WebView).
|
||||
|
||||
## Authentication
|
||||
|
||||
All requests use the same session cookies:
|
||||
|
||||
```
|
||||
Cookie: mbmodel=IOS-1.0; xxid=<session_xxid>; IBSID=<session_xxid>; mbnonce=<nonceGenerator>; time-tracker=597
|
||||
```
|
||||
|
||||
All AJAX POST requests also require:
|
||||
|
||||
```
|
||||
X-Requested-With: XMLHttpRequest
|
||||
Origin: https://faisamobilex-wv.mib.com.mv
|
||||
Referer: https://faisamobilex-wv.mib.com.mv/beneficiary?dashurl=1
|
||||
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Endpoints
|
||||
|
||||
### 1. Get Categories
|
||||
|
||||
**POST** `https://faisamobilex-wv.mib.com.mv/ajaxBeneficiary/getCategories`
|
||||
|
||||
No request body required (empty POST).
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"responseCode": "1",
|
||||
"reasonText": "Category retrieval success",
|
||||
"reasonCode": "105",
|
||||
"data": [
|
||||
{
|
||||
"id": "100001",
|
||||
"categoryName": "Myself",
|
||||
"icon": "f091",
|
||||
"createdDate": "2023-01-01 00:00:00",
|
||||
"modifiedDate": null,
|
||||
"numBenef": "2"
|
||||
},
|
||||
{
|
||||
"id": "100002",
|
||||
"categoryName": "Friends",
|
||||
"icon": "f095",
|
||||
"createdDate": "2023-01-01 00:00:00",
|
||||
"modifiedDate": "2023-01-02 00:00:00",
|
||||
"numBenef": "10"
|
||||
},
|
||||
{
|
||||
"id": "100003",
|
||||
"categoryName": "Business",
|
||||
"icon": "f097",
|
||||
"createdDate": "2023-01-01 00:00:00",
|
||||
"modifiedDate": "2023-01-02 00:00:00",
|
||||
"numBenef": "8"
|
||||
},
|
||||
{
|
||||
"id": "100004",
|
||||
"categoryName": "Family",
|
||||
"icon": "f090",
|
||||
"createdDate": "2023-01-01 00:00:00",
|
||||
"modifiedDate": "2023-01-02 00:00:00",
|
||||
"numBenef": "5"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Fields:
|
||||
- `id` — category ID (used as `searchCategoryId` when filtering contacts)
|
||||
- `categoryName` — display name
|
||||
- `icon` — font-awesome icon code (used in web UI, ignore in native app)
|
||||
- `numBenef` — number of beneficiaries in this category (string)
|
||||
|
||||
---
|
||||
|
||||
### 2. Get Contacts (Paginated)
|
||||
|
||||
**POST** `https://faisamobilex-wv.mib.com.mv/ajaxBeneficiary/main`
|
||||
|
||||
**Request body (form-urlencoded):**
|
||||
|
||||
| Field | Example | Description |
|
||||
|--------------------|----------|-----------------------------------------------------------|
|
||||
| `page` | `1` | Page number (1-based) |
|
||||
| `search` | `` | Search query (empty = all) |
|
||||
| `searchCategoryId` | `0` | Category filter (`0` = all categories) |
|
||||
| `benefType` | `A` | Beneficiary type: `A`=All, `L`=Local, `I`=Internal, `S`=Swift |
|
||||
| `sortBenef` | `name` | Sort field |
|
||||
| `sortDir` | `asc` | Sort direction |
|
||||
| `start` | `1` | Record start index (1-based) |
|
||||
| `end` | `100` | Record end index |
|
||||
| `includeCount` | `1` | Include `total_count` in response |
|
||||
|
||||
**Beneficiary types:**
|
||||
- `L` — Local (other Maldivian banks, e.g. BML)
|
||||
- `I` — Internal (MIB to MIB transfers)
|
||||
- `S` — Swift (international transfers)
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"responseCode": "1",
|
||||
"reasonText": "beneficiary retrieval success",
|
||||
"reasonCode": "103",
|
||||
"data": [
|
||||
{
|
||||
"benefNo": "100001",
|
||||
"benefName": "Person Name",
|
||||
"benefNickName": "Nickname",
|
||||
"benefAccount": "7700000000001",
|
||||
"benefType": "L",
|
||||
"bankColor": "#AC0000",
|
||||
"benefBankName": "Bank of Maldives PLC",
|
||||
"bankCode": "BML",
|
||||
"benefBankBranch": null,
|
||||
"benefAddress1": null,
|
||||
"benefAddress2": null,
|
||||
"benefAddress3": null,
|
||||
"benefCity": null,
|
||||
"benefRegion": null,
|
||||
"benefCountry": null,
|
||||
"benefStatus": "A",
|
||||
"benefBankId": "3",
|
||||
"benefSwiftCode": "MALBMVMV",
|
||||
"transferCy": "462",
|
||||
"transferCyDesc": "MVR",
|
||||
"bicCode": null,
|
||||
"intermBankCode": "0",
|
||||
"customerImgHash": "abcd1234hash...",
|
||||
"benefImgHash": "abcd1234hash...",
|
||||
"benefCategoryID": "100002",
|
||||
"BENEF_CIF_NO": null,
|
||||
"rnum": "1",
|
||||
"last": "0"
|
||||
},
|
||||
{
|
||||
"benefNo": "100002",
|
||||
"benefName": "Another Person",
|
||||
"benefNickName": "MIB Contact",
|
||||
"benefAccount": "90103100000001000",
|
||||
"benefType": "I",
|
||||
"bankColor": "#FE860E",
|
||||
"benefBankName": "MIB",
|
||||
"bankCode": "MIB",
|
||||
"benefBankBranch": null,
|
||||
"benefStatus": "A",
|
||||
"benefBankId": "2",
|
||||
"benefSwiftCode": "SWIFTCODE",
|
||||
"transferCy": "462",
|
||||
"transferCyDesc": "MVR",
|
||||
"customerImgHash": null,
|
||||
"benefImgHash": null,
|
||||
"benefCategoryID": "0",
|
||||
"rnum": "2",
|
||||
"last": "1"
|
||||
}
|
||||
],
|
||||
"total_count": "48",
|
||||
"pos": "1"
|
||||
}
|
||||
```
|
||||
|
||||
Key fields:
|
||||
- `benefNo` — unique beneficiary ID
|
||||
- `benefNickName` — user-assigned nickname (prefer over `benefName` for display)
|
||||
- `benefType` — `L`, `I`, or `S`
|
||||
- `bankColor` — hex color representing the bank (use for placeholder avatar background)
|
||||
- `customerImgHash` — hash used to fetch profile photo (null if no photo set)
|
||||
- `benefCategoryID` — category ID, `"0"` means uncategorized
|
||||
- `transferCyDesc` — currency (MVR, USD)
|
||||
- `rnum` — row number (1-based position in full sorted list)
|
||||
- `last` — `"1"` if this is the last record on the page
|
||||
|
||||
Pagination: use `start`/`end` to page through results. `total_count` gives the total number of records.
|
||||
|
||||
---
|
||||
|
||||
### 3. Get Profile Image
|
||||
|
||||
**POST** `https://faisamobilex-wv.mib.com.mv/ajaxBeneficiary/getProfileImage`
|
||||
|
||||
**Request body (form-urlencoded):**
|
||||
|
||||
| Field | Description |
|
||||
|-------------|------------------------------------|
|
||||
| `imageHash` | The `customerImgHash` from contact |
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"responseCode": "1",
|
||||
"reasonCode": "1",
|
||||
"reasonText": "image found",
|
||||
"profileImage": "<base64-encoded JPEG>"
|
||||
}
|
||||
```
|
||||
|
||||
- `profileImage` — raw base64-encoded JPEG (no data URI prefix)
|
||||
- Decode with `Base64.decode(value, Base64.DEFAULT)` then `BitmapFactory.decodeByteArray(...)`
|
||||
- The same hash may be reused across multiple contacts (deduplication recommended)
|
||||
|
||||
---
|
||||
|
||||
### 4. Get Stats (optional)
|
||||
|
||||
**POST** `https://faisamobilex-wv.mib.com.mv/ajaxBeneficiary/getStats`
|
||||
|
||||
No request body required.
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"responseCode": "1",
|
||||
"reasonText": "Beneficiary Stats Retrieved",
|
||||
"reasonCode": "109",
|
||||
"data": [
|
||||
{ "type": "L", "count": "30" },
|
||||
{ "type": "I", "count": "10" },
|
||||
{ "type": "S", "count": "2" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Gives counts per beneficiary type. Useful for showing tab badges.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Profile images are fetched on-demand per contact. Cache decoded bitmaps in memory to avoid re-fetching.
|
||||
- Contacts with `customerImgHash == null` have no profile photo; show initials + bank color as placeholder.
|
||||
- The `benefCategoryID` of `"0"` means uncategorized (not in any category group).
|
||||
- Pagination: use `start=1&end=100` for the first 100 records. Increment accordingly using `total_count`.
|
||||
@@ -1,109 +0,0 @@
|
||||
# MIB Financing API
|
||||
|
||||
## Overview
|
||||
|
||||
Financing data is fetched from the MIB **WebView** host (`faisamobilex-wv.mib.com.mv`), which is separate from the API host (`faisanet.mib.com.mv`). The response is an HTML page; financing deal data is embedded in `data-*` attributes on card elements.
|
||||
|
||||
---
|
||||
|
||||
## Endpoint
|
||||
|
||||
```
|
||||
GET https://faisamobilex-wv.mib.com.mv/financing?dashurl=1
|
||||
```
|
||||
|
||||
### Authentication
|
||||
|
||||
Session cookies from the login flow must be sent with the request:
|
||||
|
||||
| Cookie | Value |
|
||||
|----------------|---------------------------------------------|
|
||||
| `mbmodel` | `IOS-1.0` (literal string) |
|
||||
| `xxid` | Session ID from login (`MibSession.xxid`) |
|
||||
| `IBSID` | Same as `xxid` |
|
||||
| `mbnonce` | `nonceGenerator` string from login response |
|
||||
| `time-tracker` | `597` (literal string) |
|
||||
|
||||
### Request Headers
|
||||
|
||||
| Header | Value |
|
||||
|--------------------|------------------------------------|
|
||||
| `User-Agent` | Standard Android WebView UA string |
|
||||
| `X-Requested-With` | `mv.com.mib.faisamobilex` |
|
||||
|
||||
---
|
||||
|
||||
## Response
|
||||
|
||||
**Content-Type:** `text/html; charset=UTF-8`
|
||||
|
||||
The response is a full HTML page. Each financing deal is represented as a `<div>` with the class `finance-card-holder` and all deal fields embedded as `data-*` attributes:
|
||||
|
||||
```html
|
||||
<div class="card border finance-card-holder"
|
||||
data-productDesc = "Product Name"
|
||||
data-dealStatus = "P"
|
||||
data-statusDesc = "Approved"
|
||||
data-dealAmount = "10000.00"
|
||||
data-dealNo = "12345"
|
||||
data-paidAmount = "2500.00"
|
||||
data-outstandingAmount = "7500.00"
|
||||
data-dealDate = "2024-01-15 00:00:00"
|
||||
data-overdueAmount = "0"
|
||||
data-installmentAmount = "500.00"
|
||||
data-noOfInstallments = "24"
|
||||
data-lastPaidDate = "2026-05-01 00:00:00"
|
||||
data-lastPayAmount = "500.00"
|
||||
data-financeCurrency = "462"
|
||||
data-curCodeDesc = "MVR">
|
||||
```
|
||||
|
||||
### Data Fields
|
||||
|
||||
| Field | Type | Description |
|
||||
|-----------------------|---------|------------------------------------------------------|
|
||||
| `productDesc` | String | Product name (e.g. "Ujalaa CG Finance") |
|
||||
| `dealStatus` | String | Status code: `P` = Active/Pending |
|
||||
| `statusDesc` | String | Human-readable status (e.g. "Approved") |
|
||||
| `dealAmount` | Decimal | Total financing amount |
|
||||
| `dealNo` | Integer | Unique deal/contract number |
|
||||
| `paidAmount` | Decimal | Amount paid to date |
|
||||
| `outstandingAmount` | Decimal | Remaining unpaid balance |
|
||||
| `dealDate` | String | Contract start date (`yyyy-MM-dd HH:mm:ss`) |
|
||||
| `overdueAmount` | Decimal | Amount currently overdue (0 if none) |
|
||||
| `installmentAmount` | Decimal | Monthly installment amount |
|
||||
| `noOfInstallments` | Integer | Total number of installments |
|
||||
| `lastPaidDate` | String | Date of most recent payment (`yyyy-MM-dd HH:mm:ss`) |
|
||||
| `lastPayAmount` | Decimal | Amount of most recent payment |
|
||||
| `financeCurrency` | Integer | Currency code (462 = MVR) |
|
||||
| `curCodeDesc` | String | Currency abbreviation (e.g. "MVR") |
|
||||
|
||||
### Parsing Strategy
|
||||
|
||||
Use a regex to find all elements with class `finance-card-holder`, then extract all `data-*` attribute key/value pairs from each match:
|
||||
|
||||
```kotlin
|
||||
val cardPattern = Regex("""finance-card-holder[^>]+>""")
|
||||
val attrPattern = Regex("""data-(\w+)\s*=\s*"([^"]*)"""")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Completion Date Estimation
|
||||
|
||||
Remaining installments can be estimated from outstanding and installment amounts:
|
||||
|
||||
```
|
||||
remainingInstallments = ceil(outstandingAmount / installmentAmount)
|
||||
completionDate = today + remainingInstallments months
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- The WebView endpoint uses a different subdomain (`faisamobilex-wv`) from the encrypted API (`faisanet`).
|
||||
- No encryption is used; the session is maintained purely via cookies.
|
||||
- The HTML is served gzip/brotli compressed; OkHttp handles decompression automatically.
|
||||
- The `time-tracker` cookie value appears to be static at `597` — its purpose is unclear, but omitting it may affect behavior.
|
||||
- Known product names include consumer goods finance and cash financing variants.
|
||||
@@ -1,81 +0,0 @@
|
||||
# MIB Transfer API
|
||||
|
||||
Transfer endpoints are served from the MIB WebView subdomain, using the same session-cookie auth as
|
||||
financing and contacts.
|
||||
|
||||
## Authentication
|
||||
|
||||
```
|
||||
Cookie: mbmodel=IOS-1.0; xxid=<session_xxid>; IBSID=<session_xxid>; mbnonce=<nonceGenerator>; time-tracker=597
|
||||
```
|
||||
|
||||
All AJAX POST requests also require:
|
||||
|
||||
```
|
||||
X-Requested-With: XMLHttpRequest
|
||||
Origin: https://faisamobilex-wv.mib.com.mv
|
||||
Referer: https://faisamobilex-wv.mib.com.mv/transfer/quick
|
||||
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Endpoints
|
||||
|
||||
### 1. Look Up Recipient by Favara Alias
|
||||
|
||||
Resolves a Favara ID (alias) to the account holder name and account number before initiating a transfer.
|
||||
|
||||
**POST** `https://faisamobilex-wv.mib.com.mv/AjaxAlias/getAlias`
|
||||
|
||||
**Request body (form-urlencoded):**
|
||||
|
||||
| Field | Description |
|
||||
|-------------|------------------------------------------|
|
||||
| `aliasName` | The recipient's Favara ID / alias number |
|
||||
|
||||
**Success response (`responseCode: "2"`):**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"responseCode": "2",
|
||||
"reasonCode": "203",
|
||||
"reasonText": " Favara ID found",
|
||||
"data": {
|
||||
"TxId": "BANK00001",
|
||||
"CreDtTm": "...",
|
||||
"Resp": {
|
||||
"Rslt": true,
|
||||
"RsltDtls": null
|
||||
},
|
||||
"CdtrAcct": {
|
||||
"Acct": "90100000000000000",
|
||||
"FinInstnId": "MADVMVMV"
|
||||
},
|
||||
"BfyNm": "Account Holder Name",
|
||||
"RegDtTm": "2023-01-01T00:00:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Not found / error response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"responseCode": "0",
|
||||
"reasonCode": "400",
|
||||
"reasonText": "Alias not found"
|
||||
}
|
||||
```
|
||||
|
||||
Key fields from `data`:
|
||||
- `BfyNm` — beneficiary full name (trim whitespace)
|
||||
- `CdtrAcct.Acct` — resolved account number to use for the transfer
|
||||
- `CdtrAcct.FinInstnId` — bank institution ID (e.g. `MADVMVMV`, `MALBMVMV`)
|
||||
|
||||
**Notes:**
|
||||
- Use `success` (not `responseCode`) to determine if the lookup succeeded.
|
||||
- Show `BfyNm` + `CdtrAcct.Acct` to the user as confirmation before proceeding.
|
||||
- The `reasonText` from error responses should be shown directly to the user.
|
||||
Reference in New Issue
Block a user