diff --git a/docs/AI_SECURITY_CHECK.md b/docs/AI_SECURITY_CHECK.md index 8a0bfec..e44f109 100644 --- a/docs/AI_SECURITY_CHECK.md +++ b/docs/AI_SECURITY_CHECK.md @@ -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. | diff --git a/docs/bmlapi/03-oauth-token.md b/docs/bmlapi/03-oauth-token.md index 4f2a3d2..b19f26e 100644 --- a/docs/bmlapi/03-oauth-token.md +++ b/docs/bmlapi/03-oauth-token.md @@ -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=&nonce=&code_challenge=&code_challenge_method=S256&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=&nonce=&code_challenge=&code_challenge_method=S256&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=; blaze_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=' \ --data '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=' \ - --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' ``` diff --git a/docs/bmlapi/04-dashboard.md b/docs/bmlapi/04-dashboard.md index 40917fa..cefbb10 100644 --- a/docs/bmlapi/04-dashboard.md +++ b/docs/bmlapi/04-dashboard.md @@ -25,14 +25,14 @@ GET https://www.bankofmaldives.com.mv/internetbanking/api/mobile/dashboard | Header | Value | |---|---| | `Authorization` | `Bearer ` | -| `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 ' \ - --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' ``` diff --git a/docs/bmlapi/05-userinfo.md b/docs/bmlapi/05-userinfo.md index 67bfd2f..c7dea49 100644 --- a/docs/bmlapi/05-userinfo.md +++ b/docs/bmlapi/05-userinfo.md @@ -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 ' \ - --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 ' \ - --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 ' \ - --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' ``` diff --git a/docs/bmlapi/06-account-history.md b/docs/bmlapi/06-account-history.md index 28c92c4..7fdc076 100644 --- a/docs/bmlapi/06-account-history.md +++ b/docs/bmlapi/06-account-history.md @@ -31,14 +31,14 @@ GET https://www.bankofmaldives.com.mv/internetbanking/api/mobile/account/{accoun | Header | Value | |---|---| | `Authorization` | `Bearer ` | -| `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 ' \ - --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' ``` diff --git a/docs/bmlapi/07-card-statement.md b/docs/bmlapi/07-card-statement.md index 37aa63e..809961f 100644 --- a/docs/bmlapi/07-card-statement.md +++ b/docs/bmlapi/07-card-statement.md @@ -42,14 +42,14 @@ POST https://www.bankofmaldives.com.mv/internetbanking/api/mobile/card/statement | Header | Value | |---|---| | `Authorization` | `Bearer ` | -| `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 ' \ - --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"}' diff --git a/docs/bmlapi/08-transfer.md b/docs/bmlapi/08-transfer.md index 02d36bc..5b9a6d3 100644 --- a/docs/bmlapi/08-transfer.md +++ b/docs/bmlapi/08-transfer.md @@ -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 ' \ - --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 ` | -| `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 ' \ - --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 ' \ - --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' \ diff --git a/docs/bmlapi/09-contacts.md b/docs/bmlapi/09-contacts.md index 14d6f2b..fe83f88 100644 --- a/docs/bmlapi/09-contacts.md +++ b/docs/bmlapi/09-contacts.md @@ -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 ' \ - --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 ' \ - --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 ' \ - --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' \ diff --git a/docs/bmlapi/10-validate.md b/docs/bmlapi/10-validate.md index 9cd5820..d31db30 100644 --- a/docs/bmlapi/10-validate.md +++ b/docs/bmlapi/10-validate.md @@ -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 ' \ - --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 ' \ - --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 ' \ - --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' ``` diff --git a/docs/bmlapi/11-foreign-limits.md b/docs/bmlapi/11-foreign-limits.md index abe3b3f..d907641 100644 --- a/docs/bmlapi/11-foreign-limits.md +++ b/docs/bmlapi/11-foreign-limits.md @@ -27,7 +27,7 @@ GET https://app.bankofmaldives.com.mv/api/v2/foreign-limits | Header | Value | |---|---| | `Authorization` | `Bearer ` | -| `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 ' \ - --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' ``` diff --git a/docs/bmlapi/README.md b/docs/bmlapi/README.md index d3da7fc..abaf659 100644 --- a/docs/bmlapi/README.md +++ b/docs/bmlapi/README.md @@ -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 (; Android ; < Example app UA: ``` -bml-mobile-banking/348 (Xiaomi; Android 14; 22101320I) +bml-mobile-banking/348 ({manufacturer}; Android {version}; {model}) ``` --- diff --git a/docs/fahipayapi/01-login.md b/docs/fahipayapi/01-login.md index 2634d6e..77ddec3 100644 --- a/docs/fahipayapi/01-login.md +++ b/docs/fahipayapi/01-login.md @@ -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' ``` diff --git a/docs/fahipayapi/02-otp.md b/docs/fahipayapi/02-otp.md index 035758e..5a8a02e 100644 --- a/docs/fahipayapi/02-otp.md +++ b/docs/fahipayapi/02-otp.md @@ -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' ``` diff --git a/docs/fahipayapi/README.md b/docs/fahipayapi/README.md index 7658bb5..3cc12b7 100644 --- a/docs/fahipayapi/README.md +++ b/docs/fahipayapi/README.md @@ -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= 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` | diff --git a/docs/mibapi/01-encryption.md b/docs/mibapi/01-encryption.md new file mode 100644 index 0000000..f4200d0 --- /dev/null +++ b/docs/mibapi/01-encryption.md @@ -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=&data= +``` + +For `sfunc=n` calls, `xxid` must be the **first** field: +``` +xxid=&sfunc=n&data= +``` + +For `sfunc=i` calls, `key2` is a separate unencrypted field: +``` +key2=&sfunc=i&data= +``` + +**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) diff --git a/docs/mibapi/02-login.md b/docs/mibapi/02-login.md new file mode 100644 index 0000000..3e02ffe --- /dev/null +++ b/docs/mibapi/02-login.md @@ -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": "", + "appId": "IOS17.2-<15 random alphanumeric chars>", + "routePath": "S40", + "sodium": "", + "xxid": "" + } +} +``` + +**Response** (decrypted with `DEFAULT_KEY`): +```json +{ + "success": true, + "reasonCode": "201", + "reasonText": "Key generated successfully.", + "smod": "", + "nonceGenerator": "", + "xxid": "", + "sodium": "", + "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": "", + "data": { + "uname": "", + "nonce": "", + "appId": "", + "sodium": "", + "routePath": "A44", + "xxid": "" + } +} +``` + +**Response**: +```json +{ + "success": true, + "data": [{ "loginType": "1", "userSalt": "" }] +} +``` + +Use `userSalt` in `pgf03` computation. + +--- + +### [2a] Device Registration Init — `sfunc=n`, `routePath: C41` + +_First-time only._ + +**Request**: +```json +{ + "sfunc": "n", + "xxid": "", + "data": { + "uname": "", + "pgf03": "", + "clientSalt": "", + "nonce": "", + "appId": "", + "sodium": "", + "routePath": "C41", + "xxid": "" + } +} +``` + +**Response**: +```json +{ + "success": true, + "reasonCode": "201", + "primaryOTPType": "3", + "otpTypes": [2, 3], + "fullName": "", + "customerImgHash": "" +} +``` + +--- + +### [3a] OTP Verification (Registration) — `sfunc=n`, `routePath: C42` + +_First-time only. Receive and persist `key1`/`key2`._ + +**Request**: +```json +{ + "sfunc": "n", + "xxid": "", + "data": { + "otp": "<6-digit OTP>", + "uname": "", + "otpType": "3", + "nonce": "", + "appId": "", + "sodium": "", + "routePath": "C42", + "xxid": "" + } +} +``` + +**Response**: +```json +{ + "success": true, + "reasonCode": "101", + "data": [{ + "key1": "", + "key2": "", + "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=&sfunc=i&data=` + +**Encrypted payload**: +```json +{ + "sfunc": "i", + "key2": "", + "data": { + "cmod": "", + "appId": "", + "routePath": "S40", + "sodium": "", + "xxid": "" + } +} +``` + +**Response** (decrypted with `key1`): +```json +{ + "success": true, + "smod": "", + "nonceGenerator": "", + "xxid": "", + "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": "", + "data": { + "uname": "", + "pgf03": "", + "clientSalt": "", + "pmodTime": 0, + "requireBankData": 1, + "nonce": "", + "appId": "", + "sodium": "", + "routePath": "A41", + "xxid": "" + } +} +``` + +**Response**: +```json +{ + "success": true, + "reasonCode": "104", + "primaryOTPType": "3", + "otpTypes": [2, 3], + "email": "", + "uuid": "", + "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": "", + "data": { + "imageHash": "", + "nonce": "", + "appId": "", + "sodium": "", + "routePath": "P41", + "xxid": "" + } +} +``` + +**Response**: +```json +{ + "success": true, + "reasonCode": "201", + "profileImage": "" +} +``` + +--- + +### [4b] OTP Verification (Login) — `sfunc=n`, `routePath: A42` + +**Request**: +```json +{ + "sfunc": "n", + "xxid": "", + "data": { + "otp": "<6-digit OTP>", + "uname": "", + "otpType": "3", + "nonce": "", + "appId": "", + "sodium": "", + "routePath": "A42", + "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) diff --git a/docs/mibapi/03-accounts.md b/docs/mibapi/03-accounts.md new file mode 100644 index 0000000..c57f9c7 --- /dev/null +++ b/docs/mibapi/03-accounts.md @@ -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": "", + "data": { + "profileType": "", + "profileId": "", + "nonce": "", + "appId": "", + "sodium": "", + "routePath": "P47", + "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": "", + "customerProfileId": "", + "annexId": "", + "customerId": "", + "name": "", + "cifType": "Individual", + "customerImage": "", + "profileType": "0", + "color": "" + } + ] +} +``` + +| `profileType` | Meaning | +|---|---| +| `"0"` | Individual (personal) | +| `"1"` | Sole Proprietor (business) | + +--- + +## `accountBalance` Array + +Each element represents one account: + +```json +{ + "cif": "", + "accountNumber": "", + "accountBriefName": "", + "template": "", + "currencyCode": "", + "currencyName": "", + "accountTypeName": "", + "transfer": "Y", + "branchName": "", + "availableBalance": "", + "currentBalance": "", + "blockedAmount": "", + "settlementBalance": "", + "mvrBalance": "", + "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": "", + "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) diff --git a/docs/mibapi/04-history.md b/docs/mibapi/04-history.md new file mode 100644 index 0000000..86e5ef2 --- /dev/null +++ b/docs/mibapi/04-history.md @@ -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= +``` + +--- + +## 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) diff --git a/docs/mibapi/05-cards.md b/docs/mibapi/05-cards.md new file mode 100644 index 0000000..184c554 --- /dev/null +++ b/docs/mibapi/05-cards.md @@ -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) diff --git a/docs/mibapi/06-financing.md b/docs/mibapi/06-financing.md new file mode 100644 index 0000000..9f5fe5a --- /dev/null +++ b/docs/mibapi/06-financing.md @@ -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 `
` with class `finance-card-holder` and all fields as `data-*` attributes: + +```html +
+``` + +### 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) diff --git a/docs/mibapi/07-profile.md b/docs/mibapi/07-profile.md new file mode 100644 index 0000000..de63a11 --- /dev/null +++ b/docs/mibapi/07-profile.md @@ -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=; IBSID=; mbnonce=; time-tracker=597 +``` + +--- + +## Response + +**Content-Type:** `text/html; charset=UTF-8` + +The page contains an `
` with the user's full name and `` elements with labelled fields. + +### Parsing Strategy + +**Full name** — extracted from: +```html +
Mohamed Ali
+``` + +Regex: +```kotlin +Regex("""
\s*([^<]+)\s*
""") +``` + +**Labelled fields** — each follows this pattern: +```html +Username:...myusername +``` + +Regex (used for each label): +```kotlin +Regex( + """]*>\s*]*>\s*$label\s*]*>.*?]*>([^<]+)""", + 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 `
`: + +```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) diff --git a/docs/mibapi/08-transfer.md b/docs/mibapi/08-transfer.md new file mode 100644 index 0000000..39cda82 --- /dev/null +++ b/docs/mibapi/08-transfer.md @@ -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=` + +**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) diff --git a/docs/mibapi/09-contacts.md b/docs/mibapi/09-contacts.md new file mode 100644 index 0000000..854ac0d --- /dev/null +++ b/docs/mibapi/09-contacts.md @@ -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=` + +### Response + +```json +{ + "success": true, + "profileImage": "" +} +``` + +`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=` + +### 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) diff --git a/docs/mibapi/ACCOUNTS.md b/docs/mibapi/ACCOUNTS.md deleted file mode 100644 index 4827b64..0000000 --- a/docs/mibapi/ACCOUNTS.md +++ /dev/null @@ -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": "", - "customerProfileId": "", - "annexId": "", - "customerId": "", - "name": "", - "cifType": "Individual", - "customerImage": "", - "profileType": "0", - "color": "" - }, - { - "profileId": "", - "customerProfileId": "", - "annexId": "", - "customerId": "", - "name": "", - "cifType": "Sole Propr", - "customerImage": "", - "profileType": "1", - "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": "", - "data": { - "profileType": "", - "profileId": "", - "nonce": "", - "appId": "", - "sodium": "", - "routePath": "P47", - "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": "", - "accountNumber": "", - "accountBriefName": "", - "template": "", - "currencyCode": "", - "currencyName": "", - "accountTypeName": "", - "transfer": "Y", - "branchName": "", - "availableBalance": "", - "currentBalance": "", - "blockedAmount": "", - "settlementBalance": "", - "mvrBalance": "", - "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": "", - "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. diff --git a/docs/mibapi/API.md b/docs/mibapi/API.md deleted file mode 100644 index ca8cfcf..0000000 --- a/docs/mibapi/API.md +++ /dev/null @@ -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=&data= -``` - ---- - -## 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": "", - "appId": "IOS17.2-", - "routePath": "S40", - "sodium": "", - "xxid": "" - } -} -``` - -**Response payload** (encrypted with DEFAULT_KEY): -```json -{ - "success": true, - "responseCode": "1", - "reasonCode": "201", - "reasonText": "Key generated successfully.", - "smod": "", - "nonceGenerator": "", - "xxid": "", - "sodium": "", - "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": "", - "xxid": "", - "data": { - "nonce": "", - "appId": "", - "sodium": "", - "routePath": "", - "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=&sfunc=i&data=`. 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=&sfunc=n&data=`. The `xxid` also appears inside the encrypted - payload. Field order matters — `xxid` must come before `sfunc` and `data`. - diff --git a/docs/mibapi/ENCRYPTION.md b/docs/mibapi/ENCRYPTION.md deleted file mode 100644 index bbb59c2..0000000 --- a/docs/mibapi/ENCRYPTION.md +++ /dev/null @@ -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": "", - "xxid": "", - "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=&data=` - -```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= -``` - -Response (decrypted with DEFAULT_KEY): -```json -{ - "success": true, - "smod": "", - "nonceGenerator": "", - "xxid": "", - "sodium": "", - "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 | diff --git a/docs/mibapi/LOGIN_FLOW.md b/docs/mibapi/LOGIN_FLOW.md deleted file mode 100644 index e17e678..0000000 --- a/docs/mibapi/LOGIN_FLOW.md +++ /dev/null @@ -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": "", - "appId": "IOS17.2-<15 random chars>", - "routePath": "S40", - "sodium": "", - "xxid": "" -} -``` - -**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": "", - "nonceGenerator": "", - "xxid": "", - "sodium": "", - "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": "", - "data": { - "uname": "", - "nonce": "", - "appId": "", - "sodium": "", - "routePath": "A44", - "xxid": "" - } -} -``` - -**Response**: -```json -{ - "success": true, - "reasonCode": "108", - "reasonText": "Auth type retrieved!", - "data": [ - { - "loginType": "1", - "userSalt": "" - } - ] -} -``` - ---- - -### [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": "", - "data": { - "uname": "", - "pgf03": "", - "clientSalt": "", - "nonce": "", - "appId": "", - "sodium": "", - "routePath": "C41", - "xxid": "" - } -} -``` - -**Response**: -```json -{ - "success": true, - "reasonCode": "201", - "reasonText": "Registration Initialization Successfully.", - "primaryOTPType": "3", - "roleName": "Consumer Premium", - "otpTypes": [2, 3], - "fullName": "", - "lastLoginTime": "", - "customerImgHash": "" -} -``` - ---- - -### [3] OTP Verification (Registration) — `sfunc=n`, `routePath: C42` - -**Key**: `session_key_1` - -**Request**: -```json -{ - "sfunc": "n", - "xxid": "", - "data": { - "otp": "<6-digit OTP>", - "uname": "", - "otpType": "3", - "nonce": "", - "appId": "", - "sodium": "", - "routePath": "C42", - "xxid": "" - } -} -``` - -**Response**: -```json -{ - "success": true, - "reasonCode": "101", - "reasonText": "registration success", - "data": [ - { - "appId": "", - "createdDate": "", - "key1": "", - "key2": "", - "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": "", - "data": { - "cmod": "", - "appId": "", - "routePath": "S40", - "sodium": "", - "xxid": "" - } -} -``` - -**Response** (decrypted with `key1`): -```json -{ - "success": true, - "reasonCode": "201", - "reasonText": "Key generated successfully.", - "smod": "", - "nonceGenerator": "", - "xxid": "", - "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": "", - "data": { - "uname": "", - "pgf03": "", - "clientSalt": "", - "pmodTime": 0, - "requireBankData": 1, - "nonce": "", - "appId": "", - "sodium": "", - "routePath": "A41", - "xxid": "" - } -} -``` - -**Response**: -```json -{ - "success": true, - "reasonCode": "104", - "reasonText": "Initialization Successful", - "primaryOTPType": "3", - "roleName": "Consumer Premium", - "otpTypes": [2, 3], - "email": "", - "uuid": "", - "uuid2": "", - "xxid": "" -} -``` - ---- - -### [7] Get Profile Image — `sfunc=n`, `routePath: P41` - -**Key**: `session_key_2` - -**Request**: -```json -{ - "sfunc": "n", - "xxid": "", - "data": { - "imageHash": "", - "nonce": "", - "appId": "", - "sodium": "", - "routePath": "P41", - "xxid": "" - } -} -``` - -**Response**: -```json -{ - "success": true, - "reasonCode": "201", - "reasonText": "Image Found", - "profileImage": "" -} -``` - ---- - -### [8] OTP Verification (Login) — `sfunc=n`, `routePath: A42` - -**Key**: `session_key_2` - -**Request**: -```json -{ - "sfunc": "n", - "xxid": "", - "data": { - "otp": "<6-digit OTP>", - "uname": "", - "otpType": "3", - "nonce": "", - "appId": "", - "sodium": "", - "routePath": "A42", - "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 | diff --git a/docs/mibapi/README.md b/docs/mibapi/README.md new file mode 100644 index 0000000..0548adb --- /dev/null +++ b/docs/mibapi/README.md @@ -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=&data= +``` + +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=; IBSID=; mbnonce=; 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) diff --git a/docs/mibapi/accountlookup.md b/docs/mibapi/accountlookup.md deleted file mode 100644 index 8b0fdad..0000000 --- a/docs/mibapi/accountlookup.md +++ /dev/null @@ -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=` - -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. diff --git a/docs/mibapi/contacts.md b/docs/mibapi/contacts.md deleted file mode 100644 index 8f49374..0000000 --- a/docs/mibapi/contacts.md +++ /dev/null @@ -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=; IBSID=; mbnonce=; 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": "" -} -``` - -- `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`. diff --git a/docs/mibapi/financing.md b/docs/mibapi/financing.md deleted file mode 100644 index ce35ae5..0000000 --- a/docs/mibapi/financing.md +++ /dev/null @@ -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 `
` with the class `finance-card-holder` and all deal fields embedded as `data-*` attributes: - -```html -
-``` - -### 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. diff --git a/docs/mibapi/transfer.md b/docs/mibapi/transfer.md deleted file mode 100644 index 583bd53..0000000 --- a/docs/mibapi/transfer.md +++ /dev/null @@ -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=; IBSID=; mbnonce=; 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.