This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
# Encryption & Anti-Replay
|
||||
|
||||
Every M-Faisa endpoint at `superapp.ooredoo.mv` mixes three things on the wire:
|
||||
|
||||
1. **Field-level RSA encryption** for the mobile number (`mdnId` / `mobileNumber` / `userName` / `initiatingMDN` / `identifier`) and the mPIN.
|
||||
2. An **anti-replay envelope** (`rndValue` + `csValue`) on every session-scoped form-encoded POST.
|
||||
3. A **Gson `htmlSafe` JSON quirk** that turns every literal `=` inside the JSON payload into `=`.
|
||||
|
||||
This document is the single source of truth for all three. The endpoint docs ([login](02-login.md), [history](03-history.md), [transfer](04-transfer.md)) just reference back here.
|
||||
|
||||
---
|
||||
|
||||
## RSA keys
|
||||
|
||||
Two distinct RSA public keys are used. Both live obfuscated inside `libnative-lib.so` (file offsets given for app version `10.3.1`, `versionCode = 101349`).
|
||||
|
||||
| Purpose | Bit length | JNI source | Cipher | Output format |
|
||||
|---|---|---|---|---|
|
||||
| Mobile / mdnId / mobileNumber / userName / initiatingMDN / identifier | 1024 | `getBCPublicKeyImpl()` (obfuscated, see below) | `RSA/ECB/OAEPWithSHA-256AndMGF1Padding` | base64 (`NO_WRAP`) — 172 chars + `=` padding |
|
||||
| mPin / rndValue / OTP code | 2048 | `getRSAModulusImpl()` (modulus hex) + `getRSAExponentImpl()` (`"10001"`) | `RSA/ECB/OAEPWithSHA-1AndMGF1Padding` | lowercase hex — 512 chars |
|
||||
|
||||
The native-function names are deliberately misleading — `getRSAModulusImpl` returns the modulus *as a hex string*, `getRSAExponentImpl` returns the exponent (also hex, always `"10001"`), and `getBCPublicKeyImpl` returns the mobile key in a custom obfuscated form (not a hex modulus).
|
||||
|
||||
### Mobile key (1024-bit)
|
||||
|
||||
```
|
||||
N = 12504370852445171564296397369840670875526950229356546060611893054268
|
||||
22759715800327041313624881501743511944071724521752756122840313665124
|
||||
84449720820820404229217064541745811143629538982383390723079478499614
|
||||
16062061691167925660329675284421662011306487434253185147285131906525
|
||||
8962732556596958868200227678294957694889
|
||||
E = 65537
|
||||
```
|
||||
|
||||
The plaintext is **always `"960" + msisdn`** (i.e. the local number prefixed with the Maldives country code, e.g. `9601234567`). The trailing `=` from base64 encoding survives the wire — the server's strict JSON parser actually relies on it.
|
||||
|
||||
### mPin key (2048-bit)
|
||||
|
||||
```
|
||||
N (hex, 512 chars) = f46921c7091b315f8b9b20ef548deac32ff5b519a2e9ace2f971cc82a341a90eca39…
|
||||
…d419274db7b
|
||||
E (hex) = 10001 (= 65537)
|
||||
```
|
||||
|
||||
The full hex modulus is the string returned by `SecurityConfig.m()` in the official app; see `MfaisaCrypto.kt` for the value as a decimal `BigInteger`.
|
||||
|
||||
The plaintext is `pin + <6-character random salt>`. The salt is drawn fresh on every encrypt from `[A-Za-z0-9]` (62-character alphabet) — e.g. `1234aB3xQz`. It exists only to keep the OAEP plaintext above a minimum length; the server discards it after decryption.
|
||||
|
||||
The same routine is reused for any short string that needs anti-replay (OTP codes, the `rndValue` nonce) — see below.
|
||||
|
||||
---
|
||||
|
||||
## Reference implementation (Kotlin)
|
||||
|
||||
```kotlin
|
||||
object MfaisaCrypto {
|
||||
private val MOBILE_N = BigInteger("125043708524451715642963973…7694889")
|
||||
private val MOBILE_E = BigInteger("65537")
|
||||
|
||||
private val PIN_N = BigInteger("30853988905151679601945771998…504123")
|
||||
private val PIN_E = BigInteger("65537")
|
||||
|
||||
private val mobileKey by lazy { rsaPublicKey(MOBILE_N, MOBILE_E) }
|
||||
private val pinKey by lazy { rsaPublicKey(PIN_N, PIN_E) }
|
||||
|
||||
private val random = SecureRandom()
|
||||
private const val SALT_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||
|
||||
/** RSA-OAEP-SHA256 of "960" + msisdn → base64 NO_WRAP. */
|
||||
fun encryptMobile(msisdn: String): String {
|
||||
val cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding")
|
||||
val params = OAEPParameterSpec("SHA-256", "MGF1", MGF1ParameterSpec.SHA256, PSource.PSpecified.DEFAULT)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, mobileKey, params)
|
||||
val ct = cipher.doFinal(("960" + msisdn).toByteArray(Charsets.UTF_8))
|
||||
return Base64.encodeToString(ct, Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
/** RSA-OAEP-SHA1 of `<value> + <6-char random salt>` → lowercase hex. */
|
||||
fun encryptPin(value: String): String {
|
||||
val salt = (1..6).map { SALT_ALPHABET[random.nextInt(SALT_ALPHABET.length)] }.joinToString("")
|
||||
val cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-1AndMGF1Padding")
|
||||
val params = OAEPParameterSpec("SHA-1", "MGF1", MGF1ParameterSpec.SHA1, PSource.PSpecified.DEFAULT)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, pinKey, params)
|
||||
val ct = cipher.doFinal((value + salt).toByteArray(Charsets.UTF_8))
|
||||
return ct.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
private fun rsaPublicKey(n: BigInteger, e: BigInteger): PublicKey =
|
||||
KeyFactory.getInstance("RSA").generatePublic(RSAPublicKeySpec(n, e))
|
||||
}
|
||||
```
|
||||
|
||||
The OAEP parameter spec **must be passed explicitly**. Android's `Cipher.init(mode, key)` without an `OAEPParameterSpec` defaults the MGF1 digest to SHA-1, even for `RSA/ECB/OAEPWithSHA-256AndMGF1Padding` — the server rejects this mismatch with HTTP 400.
|
||||
|
||||
---
|
||||
|
||||
## Reference implementation (Python)
|
||||
|
||||
```python
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa, padding
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
import base64, secrets, string
|
||||
|
||||
MOBILE_N = 12504370852445171564…7694889
|
||||
PIN_N = 30853988905151679601…504123
|
||||
E = 65537
|
||||
|
||||
def encrypt_mobile(msisdn: str) -> str:
|
||||
key = rsa.RSAPublicNumbers(E, MOBILE_N).public_key()
|
||||
ct = key.encrypt(
|
||||
("960" + msisdn).encode("utf-8"),
|
||||
padding.OAEP(mgf=padding.MGF1(hashes.SHA256()), algorithm=hashes.SHA256(), label=None),
|
||||
)
|
||||
return base64.b64encode(ct).decode("ascii")
|
||||
|
||||
def encrypt_pin(value: str) -> str:
|
||||
salt = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(6))
|
||||
key = rsa.RSAPublicNumbers(E, PIN_N).public_key()
|
||||
ct = key.encrypt(
|
||||
(value + salt).encode("utf-8"),
|
||||
padding.OAEP(mgf=padding.MGF1(hashes.SHA1()), algorithm=hashes.SHA1(), label=None),
|
||||
)
|
||||
return ct.hex()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Anti-replay envelope: `rndValue` + `csValue`
|
||||
|
||||
Every session-scoped form-encoded POST (history, transfer, recipient-lookup, …) carries two extra fields the server uses to detect replays:
|
||||
|
||||
```kotlin
|
||||
val offset = (Random.nextInt(5) + 10) xor 0xE // small noise: 0, 2, 3, 4, or 5
|
||||
val nonceStr = (System.currentTimeMillis() + offset).toString()
|
||||
val rndValue = MfaisaCrypto.encryptPin(nonceStr) // RSA-OAEP-SHA1 of nonceStr+salt, hex
|
||||
val csValue = Adler32().apply {
|
||||
update((formDataJson + nonceStr).toByteArray(Charsets.UTF_8))
|
||||
}.value.toString() // decimal string of the 32-bit Adler32 sum
|
||||
```
|
||||
|
||||
- **`rndValue`** is an `encryptPin(...)` of the timestamp string — the SAME 2048-bit RSA key + OAEP-SHA1 routine documented above. The 6-char salt added by `encryptPin` makes every encrypt non-deterministic.
|
||||
- **`csValue`** is `Adler32(formDataJson || nonceStr)` rendered in decimal. The server recomputes this; tampering with `formData` after generating `csValue` will cause rejection.
|
||||
- The offset (`(rand0-4 + 10) xor 14` → {0, 2, 3, 4, 5}) is a tiny bit of fixed noise on the timestamp. It exists in the official app's bytecode; the server tolerates any timestamp within a few seconds of `now` anyway.
|
||||
|
||||
`csValue` is computed from the **pre-html-escape** `formData` JSON (see next section) — i.e. the same string the server reads off the wire.
|
||||
|
||||
---
|
||||
|
||||
## HTML-safe Gson `=` escape
|
||||
|
||||
The official app serialises every JSON payload with Gson in `htmlSafe = true` mode. The relevant side effect for the wire format: any literal `=` character becomes the six-byte sequence `=`.
|
||||
|
||||
This matters because:
|
||||
- The base64 ciphertexts in `mdnId` / `mobileNumber` / `userName` / `initiatingMDN` / `identifier` always end with `=` (1024-bit ⇒ 128-byte output ⇒ 172 chars + 1 padding `=`).
|
||||
- The M-Faisa server's JSON parser is strict — sending the literal `=` instead of `=` returns HTTP 400 *even though both are valid JSON*.
|
||||
|
||||
Match the on-wire form with a simple string replace before sending:
|
||||
|
||||
```kotlin
|
||||
private fun String.matchGsonHtmlSafe(): String =
|
||||
replace("\\/", "/").replace("=", "\\u003d")
|
||||
```
|
||||
|
||||
The `\/` → `/` swap covers the corresponding `org.json` quirk that escapes forward slashes by default — also rejected by the server.
|
||||
|
||||
The same `csValue` / `rndValue` pair must be computed against the **escaped** string (i.e. exactly what's sent on the wire).
|
||||
|
||||
---
|
||||
|
||||
## The `getBCPublicKeyImpl` riddle
|
||||
|
||||
`SecurityConfig.d()` in the JVM bytecode returns a 231-character obfuscated string that, after a `replace("MeWtSVjV3Mj","").trim() + "="` cleanup and base64 decode, *should* yield an ASN.1 `SEQUENCE { INTEGER N, INTEGER E }`. **It does not** — the bytes start with `0x10` instead of the `0x30` ASN.1 SEQUENCE tag, and BouncyCastle's `ASN1Sequence.getInstance(bytes)` throws `unknown tag 16 encountered`.
|
||||
|
||||
The cleaned form the runtime actually feeds into `j()` starts `MIGJAoGB…AAE=` (i.e. a valid 1024-bit `RSAPublicKey` SEQUENCE). The transformation from the obfuscated `EAABMgAp…` to `MIGJAoGB…` is NOT what the visible Kotlin bytecode does — strongly suggesting Pairip-style VM protection (`com.pairip.licensecheck` is present in the APK) intercepts the call at runtime.
|
||||
|
||||
**Practical consequence:** the only reliable way to recover the BC public key is to hook the running app and dump `j()`'s input. A Frida script for that is checked in at `tmp/ooredoo_hook.js`. Once dumped, the key matches the `MOBILE_N` value above — so unless Ooredoo rotates keys, the values in `MfaisaCrypto.kt` are stable and the Frida step is one-off.
|
||||
|
||||
If the keys ever rotate, the **mPin key** (`SecurityConfig.m()` + `SecurityConfig.l()`) can be re-extracted purely statically — both `m()` and `l()` return plain hex strings of the modulus and exponent respectively. Only the mobile key needs Frida.
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
> **Next →** [Login](02-login.md) | **← Back to** [README](README.md)
|
||||
@@ -0,0 +1,220 @@
|
||||
# Login
|
||||
|
||||
Authenticate a user with their Ooredoo mobile number and 4-digit M-Faisa mPIN.
|
||||
|
||||
The flow is two requests:
|
||||
|
||||
1. `fetchSubscriberByMDN` — confirms the number has a registered, fully-KYC'd M-Faisa wallet before asking for the mPIN.
|
||||
2. `doMobileLogin` — submits the mPIN and (on success) returns the session + pocket details.
|
||||
|
||||
All RSA encryption used below is specified in detail in [01-encryption.md](01-encryption.md) — the mobile-key cipher is `RSA/ECB/OAEPWithSHA-256AndMGF1Padding` with the plaintext `"960" + msisdn`; the mPin cipher is `RSA/ECB/OAEPWithSHA-1AndMGF1Padding` with the plaintext `pin + <6-char salt>`.
|
||||
|
||||
---
|
||||
|
||||
## Step 1: `fetchSubscriberByMDN`
|
||||
|
||||
Confirms the number has a usable M-Faisa wallet before prompting for the mPIN.
|
||||
|
||||
### Endpoint
|
||||
|
||||
```
|
||||
POST https://superapp.ooredoo.mv/api/mfaisaa-bff/mfino/v1.1/web/fetchSubscriberByMDN
|
||||
```
|
||||
|
||||
### Request
|
||||
|
||||
**Content-Type:** `application/json; charset=UTF-8`
|
||||
|
||||
```json
|
||||
{ "mdnId": "<encryptMobile(msisdn), base64>" }
|
||||
```
|
||||
|
||||
### curl Example
|
||||
|
||||
```bash
|
||||
MDN_ENC=$(python tmp/mfaisa_encrypt.py mobile <msisdn>)
|
||||
curl --request POST \
|
||||
--url https://superapp.ooredoo.mv/api/mfaisaa-bff/mfino/v1.1/web/fetchSubscriberByMDN \
|
||||
--compressed \
|
||||
--header 'Content-Type: application/json; charset=UTF-8' \
|
||||
--header 'Host: superapp.ooredoo.mv' \
|
||||
--header 'Connection: Keep-Alive' \
|
||||
--header 'Accept-Encoding: gzip' \
|
||||
--data "{\"mdnId\":\"${MDN_ENC//=/\\u003d}\"}"
|
||||
```
|
||||
|
||||
> Note the `${MDN_ENC//=/=}` substitution — the server requires the [Gson html-safe `=` escape](01-encryption.md#html-safe-gson--escape).
|
||||
|
||||
### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Operation completed successfully.",
|
||||
"kycStatus": "Full KYC",
|
||||
"name": "<First Name>",
|
||||
"firstName": "<First Name>",
|
||||
"lastName": "<Last Name>",
|
||||
"language": "English",
|
||||
"activationPending": false,
|
||||
"passwordCreated": true,
|
||||
"subscriberRegistered": true,
|
||||
"userIdCreated": false
|
||||
}
|
||||
```
|
||||
|
||||
### Decision matrix
|
||||
|
||||
| Condition | Thijooree behaviour |
|
||||
|---|---|
|
||||
| `subscriberRegistered = false` | Show: *"User not registered. Please use the Ooredoo SuperApp to register your M-Faisa wallet and complete KYC, then come back to Thijooree."* |
|
||||
| `kycStatus != "Full KYC"` | Show: *"Your M-Faisa wallet needs Full KYC. Please complete KYC in the Ooredoo SuperApp, then come back to Thijooree."* |
|
||||
| `passwordCreated = false` | Show: *"Set your M-Faisa mPIN in the Ooredoo SuperApp first, then try again."* |
|
||||
| `activationPending = true` | Show: *"Your M-Faisa wallet activation is still pending. Complete it in the Ooredoo SuperApp first."* |
|
||||
| Otherwise | Proceed to `doMobileLogin` |
|
||||
|
||||
---
|
||||
|
||||
## Step 2: `doMobileLogin`
|
||||
|
||||
Submits the encrypted mPIN; the response contains the user's wallet pockets (E-Money MVR, optionally IMT MVR and PayPal USD).
|
||||
|
||||
### Endpoint
|
||||
|
||||
```
|
||||
POST https://superapp.ooredoo.mv/api/mfaisaa-bff/mfino/v1.1/web/doMobileLogin
|
||||
```
|
||||
|
||||
### Request
|
||||
|
||||
**Content-Type:** `application/x-www-form-urlencoded`
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| `channel` | `C03` (constant) |
|
||||
| `formData` | JSON object (see below), html-safe-escaped (`=` → `=`) |
|
||||
| `formDataCs` | `null` (literal string) |
|
||||
|
||||
`formData` JSON object:
|
||||
|
||||
```json
|
||||
{
|
||||
"deviceGeoInfo": {
|
||||
"appType": "CustomerAndroid",
|
||||
"appversion": "1.0",
|
||||
"deviceId": "<Settings.Secure.ANDROID_ID>",
|
||||
"deviceManufacturer": "<Build.MANUFACTURER>",
|
||||
"imieNumber": "<Settings.Secure.ANDROID_ID>",
|
||||
"ipaddress": "11.22.33.55",
|
||||
"latitude": "0.0",
|
||||
"longitude": "0.0",
|
||||
"simId": "<Settings.Secure.ANDROID_ID>"
|
||||
},
|
||||
"mPin": "<encryptPin(mpin), hex>",
|
||||
"mobileNumber": "<encryptMobile(msisdn), base64>",
|
||||
"role": "RETAIL_SUBSCRIBER",
|
||||
"tenantCode": "ooredoo",
|
||||
"userName": "<encryptMobile(msisdn), base64>"
|
||||
}
|
||||
```
|
||||
|
||||
Both `mobileNumber` and `userName` encrypt the same plaintext, but encrypt independently (so their ciphertexts differ — OAEP padding randomises the output).
|
||||
|
||||
`ipaddress` is the constant `"11.22.33.55"` in the official app — not the device's real IP.
|
||||
|
||||
### Response — success
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"loginExchangeKey": "<opaque hex token>",
|
||||
"mobileLoginSessionTimeout": "240",
|
||||
"kycStatus": "Full KYC",
|
||||
"suscriberId": "<12-digit subscriber id>",
|
||||
"pocketDetails": [
|
||||
{
|
||||
"name": "<Subscriber Name>",
|
||||
"eMailId": "<user@example.com>",
|
||||
"mdnId": "<msisdn>",
|
||||
"roleId": "<12-digit role id>",
|
||||
"walletId": "<11-digit wallet id>",
|
||||
"offerId": "<offer id>",
|
||||
"pocketSummaryDetailsArrayDTO": [
|
||||
{
|
||||
"pocketId": "<pocket id>",
|
||||
"pocketType": "INTERNAL",
|
||||
"pocketValueType": "EMONEY",
|
||||
"nickName": "E-Money",
|
||||
"balanceAmount": { "amount": 0.0, "currencyCode": "MVR" },
|
||||
"isDefaultPocket": true,
|
||||
"isSecondaryPocket": false,
|
||||
"statusType": "ACTIVE",
|
||||
"displayName": "E-Money"
|
||||
},
|
||||
{ "pocketValueType": "PAYPAL_USD", "...": "..." }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
> The typo `suscriberId` (missing `b`) is the **server's** spelling, not ours. The same value also appears as `pocketDetails[0].roleId`.
|
||||
|
||||
### Response — wrong PIN
|
||||
|
||||
The server returns a **JSON array** (not object) on failure:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"success": false,
|
||||
"message": "validation errors",
|
||||
"error": [
|
||||
{
|
||||
"objectName": "Credentials Criteria",
|
||||
"attributeName": "mPin",
|
||||
"attributeValue": "MPIN_NOT_VALID",
|
||||
"errorMessage": "Invalid mobile number/ Password. Please check and retry. If you have forgotten your PIN please go to FORGOT PIN to reset PIN."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
On the **second-to-last** attempt, the `errorMessage` changes to:
|
||||
|
||||
```
|
||||
Provided login details are not valid, One more wrong attempt will lock your account.
|
||||
```
|
||||
|
||||
Thijooree detects the warning by substring (`"one more"` / `"will lock"`, case-insensitive) and surfaces it as a stronger inline error.
|
||||
|
||||
### Distinguishing success from failure
|
||||
|
||||
The official app — and Thijooree — distinguish the two purely by the JSON shape:
|
||||
|
||||
```kotlin
|
||||
val trimmed = raw.trimStart()
|
||||
if (trimmed.startsWith("[")) {
|
||||
// wrong PIN path
|
||||
} else {
|
||||
// success path
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation notes
|
||||
|
||||
- **Plaintext is `"960" + msisdn`.** The country code is prepended *inside* `MfaisaCrypto.encryptMobile` rather than at the call site.
|
||||
- **`Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding")` is not enough on its own.** You also need the explicit `OAEPParameterSpec` — see [01-encryption.md](01-encryption.md#reference-implementation-kotlin).
|
||||
- **The mPIN salt must be exactly 6 alphanumeric chars.** Other lengths/charsets work for OAEP locally but were not seen in the official app and aren't worth deviating from.
|
||||
- **No User-Agent header**, as noted in the [README](README.md).
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
> **Next →** [Transaction History](03-history.md) | **← Back to** [README](README.md)
|
||||
@@ -0,0 +1,149 @@
|
||||
# Transaction History
|
||||
|
||||
Fetch a paginated list of transactions for the active subscriber session.
|
||||
|
||||
---
|
||||
|
||||
## Endpoint
|
||||
|
||||
```
|
||||
POST https://superapp.ooredoo.mv/api/mfaisaa-bff/mfino/v1.1/web/transactionInquiry/fetchSummary
|
||||
```
|
||||
|
||||
Requires an active session (i.e. a valid [`loginExchangeKey`](02-login.md#step-2-domobilelogin)) obtained from `doMobileLogin`. Sessions expire after `mobileLoginSessionTimeout` seconds (240s) — see [Session expiry](#session-expiry) below.
|
||||
|
||||
---
|
||||
|
||||
## Request
|
||||
|
||||
**Content-Type:** `application/x-www-form-urlencoded`
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| `role` | `RETAIL_SUBSCRIBER` (constant) |
|
||||
| `channel` | `SubscriberApp` — **not** `C03` as in login |
|
||||
| `loginExchangeKey` | From the login response |
|
||||
| `mdnId` | `<encryptMobile(msisdn), base64>` — same routine as `mdnId` in login |
|
||||
| `formData` | JSON object (see below), [html-safe-escaped](01-encryption.md#html-safe-gson--escape) |
|
||||
| `rndValue` | [Anti-replay nonce](01-encryption.md#anti-replay-envelope-rndvalue--csvalue) |
|
||||
| `csValue` | [Adler32 integrity check](01-encryption.md#anti-replay-envelope-rndvalue--csvalue) |
|
||||
|
||||
### `formData` JSON
|
||||
|
||||
```json
|
||||
{
|
||||
"actorRole": "RETAIL_SUBSCRIBER",
|
||||
"actorRoleId": "<suscriberId from login response>",
|
||||
"fromDate": "",
|
||||
"mdnId": "<encryptMobile(msisdn), base64>",
|
||||
"pageNo": "1",
|
||||
"recordSize": "70",
|
||||
"toDate": "",
|
||||
"transactionType": ""
|
||||
}
|
||||
```
|
||||
|
||||
- `actorRoleId` comes from `suscriberId` at the top level of `doMobileLogin`'s success body — see [02-login.md](02-login.md#step-2-domobilelogin). It also appears as `pocketDetails[0].roleId` in the same response.
|
||||
- The inner `mdnId` is independently encrypted from the outer `mdnId` — same plaintext, different ciphertext (OAEP random padding).
|
||||
- `fromDate` / `toDate` are empty strings in the official app — the server returns all available history.
|
||||
|
||||
---
|
||||
|
||||
## curl Example
|
||||
|
||||
```bash
|
||||
# Requires a valid loginExchangeKey + suscriberId from a fresh login call
|
||||
python tmp/mfaisa_history.py <msisdn> <mpin> 1
|
||||
```
|
||||
|
||||
See `tmp/mfaisa_history.py` for the full Python reference (it does the login, captures the session, then calls fetchSummary).
|
||||
|
||||
---
|
||||
|
||||
## Response
|
||||
|
||||
```json
|
||||
{
|
||||
"transactionInquiryDTOList": [
|
||||
{
|
||||
"requestId": "<request id>",
|
||||
"referenceId": "<reference id>",
|
||||
"sourceMDN": "<Subscriber Name>-DT Pocket-<msisdn>",
|
||||
"sourcePocketId":"<pocket id>",
|
||||
"actorRoleType": "RETAIL_SUBSCRIBER",
|
||||
"actorRoleId": "<subscriber id>",
|
||||
"commodityType": "WALLET",
|
||||
"channel": "SubscriberApp",
|
||||
"transactionAmount": { "amount": 1, "currencyCode": "MVR" },
|
||||
"userStatus": "CONFIRMED",
|
||||
"trnStage": "AUTO_REVERSED",
|
||||
"trnType": "CASH_IN",
|
||||
"status": "FAILED",
|
||||
"trnDate": "2026-06-13 13:04:19",
|
||||
"narrationString": "Load Money",
|
||||
"typeSummaryString": "[{\"Transaction Type\":\"Load Money\",\"Deposit Pocket\":\"DT Pocket\",\"Reference\":\"<ref>\"}]",
|
||||
"errorCode": "INT_002",
|
||||
"errorDesc": "QR_CODE_GENERATED",
|
||||
"resolutionDetails": "Please reconcile with the payment gateway..."
|
||||
},
|
||||
"..."
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Key fields used by Thijooree:
|
||||
|
||||
| Field | Maps to `BankTransaction` |
|
||||
|---|---|
|
||||
| `trnDate` | `date` (already in `YYYY-MM-DD HH:mm:ss` form) |
|
||||
| `narrationString` | `description` (suffixed with `· Failed` when `status == "FAILED"`) |
|
||||
| `transactionAmount.amount` | `amount` (signed — see direction rule below) |
|
||||
| `transactionAmount.currencyCode` | `currency` |
|
||||
| `referenceId` (fallback `requestId`) | `id` + `reference` |
|
||||
| `typeSummaryString` → `Merchant Name` / `Receiver Name` / `Sender Name` | `counterpartyName` |
|
||||
| `sourceMDN` (e.g. `"<Name>-DT Pocket-<msisdn>"`) | `counterpartyName` fallback (first segment before `-`) |
|
||||
|
||||
### Debit / credit direction
|
||||
|
||||
The response does not include a signed amount or direction flag. Direction is inferred from `trnType`:
|
||||
|
||||
| `trnType` | Direction |
|
||||
|---|---|
|
||||
| `CASH_IN`, `RECEIVE_MONEY`, `*_IN` | credit (positive amount) |
|
||||
| everything else (`PURCHASE`, `TRANSFER`, …) | debit (negative amount) |
|
||||
|
||||
### Pagination
|
||||
|
||||
The server does not return a `total` field. Thijooree treats "received a full `recordSize` (= 70) records" as the only signal that further pages may exist; the next call uses `pageNo = pageNo + 1`. Once a page comes back with fewer than 70 records, no more pages are fetched.
|
||||
|
||||
### Session expiry
|
||||
|
||||
When the 240-second session lapses, the server still returns HTTP 200 but the body is its standard error envelope:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"success": false,
|
||||
"message": "validation errors",
|
||||
"error": [
|
||||
{
|
||||
"objectName": "LoginLog",
|
||||
"attributeName": "LoginLog",
|
||||
"attributeValue": "SESSION_EXPIRED",
|
||||
"errorCode": "SESSION_EXPIRED",
|
||||
"errorMessage": "SESSION_EXPIRED"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
`MfaisaHistoryClient` parses this into `MfaisaSessionExpiredException`. Callers (`HistoryFetcher`, `TransferHistoryFragment`) catch it, call `BasedBankApp.refreshMfaisaSession(loginId)` to re-login transparently, and retry the same page once.
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
> **Next →** [Transfer Money](04-transfer.md) | **← Back to** [Login](02-login.md) | [README](README.md)
|
||||
@@ -0,0 +1,282 @@
|
||||
# Transfer Money (Wallet-to-Wallet)
|
||||
|
||||
Send MVR from the user's M-Faisa pocket to another M-Faisa subscriber, identified by phone number.
|
||||
|
||||
There is no "account number" concept on M-Faisa — recipients are addressed by mobile number, and the server resolves the destination pocket itself. The flow is three calls, ending with an OTP delivered by SMS to the sender.
|
||||
|
||||
> **Currency / pocket constraints:** Thijooree only sends from the user's MVR (EMONEY) pocket to the recipient's MVR pocket. PayPal-USD pockets are out of scope (the HAR captures don't cover them and we have no Frida-extracted recipe).
|
||||
|
||||
---
|
||||
|
||||
## Flow
|
||||
|
||||
```
|
||||
Client Server
|
||||
| |
|
||||
| POST /Pocket/basicBeneDetails | ← user typed a phone and tapped 🔍
|
||||
| formData = { beneficaryDetails, initiator } |
|
||||
|--------------------------------------------->|
|
||||
| [{ success, response:[[pocket, pocket,…]]}] |
|
||||
|<---------------------------------------------|
|
||||
| |
|
||||
| (show recipient name, accept amount + remarks)
|
||||
| |
|
||||
| POST /initiateFTRequest |
|
||||
| formData = { sourceDetails, recipient, |
|
||||
| transactionAmount, … } |
|
||||
|--------------------------------------------->|
|
||||
| { 2FARequired:"OTP", response:[{ |
|
||||
| responseObject:{ referenceId, |
|
||||
| chargeDetails, … } }] } |
|
||||
|<---------------------------------------------|
|
||||
| |
|
||||
| (server sends SMS OTP to sender's phone)
|
||||
| (user types OTP) |
|
||||
| |
|
||||
| POST /confirmFTRequest |
|
||||
| formData = { referenceId } |
|
||||
| transactionAuthDetails = { OTP encrypted } |
|
||||
|--------------------------------------------->|
|
||||
| { success:true, |
|
||||
| message:"Transfer Completed Successfully" }
|
||||
|<---------------------------------------------|
|
||||
```
|
||||
|
||||
All three endpoints carry the same anti-replay pair (`rndValue` + `csValue`) derived from the request's `formData` JSON — see [01-encryption.md → rndValue / csValue](01-encryption.md#anti-replay-envelope-rndvalue--csvalue).
|
||||
|
||||
---
|
||||
|
||||
## Step 1: `Pocket/basicBeneDetails` — recipient lookup
|
||||
|
||||
### Endpoint
|
||||
|
||||
```
|
||||
POST https://superapp.ooredoo.mv/api/mfaisaa-bff/mfino/v1.1/web/Pocket/basicBeneDetails
|
||||
```
|
||||
|
||||
### Request
|
||||
|
||||
**Content-Type:** `application/x-www-form-urlencoded`
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| `role` | `RETAIL_SUBSCRIBER` |
|
||||
| `channel` | `SubscriberApp` |
|
||||
| `loginExchangeKey` | From login |
|
||||
| `rndValue` / `csValue` | [Standard anti-replay](01-encryption.md#anti-replay-envelope-rndvalue--csvalue) |
|
||||
| `formData` | JSON below ([html-safe `=` escaping](01-encryption.md#html-safe-gson--escape)) |
|
||||
|
||||
```json
|
||||
{
|
||||
"beneficaryDetails": {
|
||||
"MDNId": "<encryptMobile(recipientMsisdn), base64>",
|
||||
"actorRoleType": "RETAIL_SUBSCRIBER"
|
||||
},
|
||||
"initiatorDetailsDTO": {
|
||||
"initiatingMDN": "<encryptMobile(myMsisdn), base64>",
|
||||
"initiatingRoleId": "<my suscriberId>",
|
||||
"initiatorRole": "RETAIL_SUBSCRIBER"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> `suscriberId` (note the server's typo) comes from the top level of the `doMobileLogin` response — see [02-login.md](02-login.md#step-2-domobilelogin).
|
||||
|
||||
### Response — happy path
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"success": true,
|
||||
"message": "Operation Completed Successfully",
|
||||
"response": [
|
||||
[
|
||||
{ "pocketId": "<paypal pocket id>", "pocketCurrency": "USD",
|
||||
"pocketValueType": "PAYPAL_USD", "name": "<Recipient Name>", "MDNId": "<recipient msisdn>",
|
||||
"walletId": "<recipient wallet id>", "actorId": "<recipient actor id>", "...": "..." },
|
||||
{ "pocketId": "<mvr pocket id>", "pocketCurrency": "MVR",
|
||||
"pocketValueType": "EMONEY", "name": "<Recipient Name>", "MDNId": "<recipient msisdn>",
|
||||
"walletId": "<recipient wallet id>", "actorId": "<recipient actor id>", "...": "..." }
|
||||
]
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Note the nesting — `response` is an array (one element only seen in practice) of arrays of pocket objects (one per pocket the recipient owns).
|
||||
|
||||
### Response — recipient not found
|
||||
|
||||
```json
|
||||
[{ "success": false, "message": "Pocket details not found." }]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 2: `initiateFTRequest` — initiate, server SMSes OTP
|
||||
|
||||
### Endpoint
|
||||
|
||||
```
|
||||
POST https://superapp.ooredoo.mv/api/mfaisaa-bff/mfino/v1.1/web/initiateFTRequest
|
||||
```
|
||||
|
||||
### Request
|
||||
|
||||
**Content-Type:** `application/x-www-form-urlencoded`
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| `identifier` | `<encryptMobile(recipientMsisdn), base64>` — independent encryption from `formData.MDNId` |
|
||||
| `role` | `RETAIL_SUBSCRIBER` |
|
||||
| `transferMode` | `MOBILE` |
|
||||
| `channel` | **`C03`** (the top-level value differs from the inner `formData.channel`, which is `SubscriberApp`) |
|
||||
| `tPin` | empty string `""` (a relic — the OTP step authenticates) |
|
||||
| `loginExchangeKey` | From login |
|
||||
| `rndValue` / `csValue` | Standard anti-replay (derived from the `formData` below) |
|
||||
| `formData` | JSON below |
|
||||
|
||||
```json
|
||||
{
|
||||
"MDNId": "960<recipientMsisdn>", /* PLAINTEXT — '960' + recipient phone */
|
||||
"beneDetails": {
|
||||
"miscDetails": "<remarks>",
|
||||
"transferMode":"MOBILE"
|
||||
},
|
||||
"channel": "SubscriberApp",
|
||||
"commodityType": "WALLET",
|
||||
"description": "<remarks>",
|
||||
"inputDetailsDTO": { "deviceId": "…", "simId": "…" },
|
||||
"mfs-transactionType": "send-money-to-mobile",
|
||||
"pocketId": "",
|
||||
"sourceDetails": {
|
||||
"MDNId": "960<myMsisdn>", /* PLAINTEXT — '960' + my phone */
|
||||
"actorRoleType":"RETAIL_SUBSCRIBER",
|
||||
"pocketId": "<my source pocket id>" /* from login.pocketDetails[0].pocketSummaryDetailsArrayDTO */
|
||||
},
|
||||
"transactionAmount": "<amount>", /* string, MVR */
|
||||
"transactionCurrency":"MVR",
|
||||
"transferMode": "MOBILE"
|
||||
}
|
||||
```
|
||||
|
||||
`deviceId` and `simId` are both `Settings.Secure.ANDROID_ID` in Thijooree's implementation — matching the device-info pattern from login.
|
||||
|
||||
### Response — happy path
|
||||
|
||||
```json
|
||||
{
|
||||
"2FARequired": "OTP",
|
||||
"authenticationType": "OTP",
|
||||
"success": true,
|
||||
"message": "Operation Completed Successfully",
|
||||
"response": [
|
||||
{
|
||||
"requestObject": { "...": "..." },
|
||||
"responseObject": {
|
||||
"referenceId": "<reference id>",
|
||||
"transactionAmount": { "amount": 1.0, "currencyCode": "MVR" },
|
||||
"netAmount": { "amount": 1.0, "currencyCode": "MVR" },
|
||||
"chargeDetailsDTO": { "totalFeesInTenantCurrency": { "amount": 0.0, "...": "..." }, "...": "..." },
|
||||
"isCompleted": false,
|
||||
"...": "..."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The server SMSes a 6-digit OTP to the sender's phone immediately. Cache `referenceId` for step 3.
|
||||
|
||||
---
|
||||
|
||||
## Step 3: `confirmFTRequest` — submit OTP
|
||||
|
||||
### Endpoint
|
||||
|
||||
```
|
||||
POST https://superapp.ooredoo.mv/api/mfaisaa-bff/mfino/v1.1/web/confirmFTRequest
|
||||
```
|
||||
|
||||
### Request
|
||||
|
||||
**Content-Type:** `application/x-www-form-urlencoded`
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| `role` | `RETAIL_SUBSCRIBER` |
|
||||
| `channel` | `C03` |
|
||||
| `loginExchangeKey` | From login |
|
||||
| `rndValue` / `csValue` | Anti-replay derived from `formData` below |
|
||||
| `formData` | `{"referenceId": "<from step 2>"}` |
|
||||
| `transactionAuthDetails` | JSON below |
|
||||
|
||||
```json
|
||||
{
|
||||
"authenticationType": "OTP",
|
||||
"authenticationValue": "<encryptPin(otpCode), hex>",
|
||||
"otpTransactionType": "TRANSACTION",
|
||||
"referenceId": "<from step 2>"
|
||||
}
|
||||
```
|
||||
|
||||
The OTP code is encrypted with the same `encryptPin` routine used for the mPIN — i.e. RSA-OAEP-SHA1 against the 2048-bit mPin key, with a fresh 6-character salt. See [01-encryption.md](01-encryption.md#mpin-key-2048-bit).
|
||||
|
||||
### Response — happy path
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Transfer Completed Successfully.",
|
||||
"response": [
|
||||
{
|
||||
"responseObject": {
|
||||
"isCompleted": true,
|
||||
"balanceInquiryDTO": {
|
||||
"currencyCode": "MVR",
|
||||
"pocketAmount": 0.45,
|
||||
"pocketId": "<source pocket id>",
|
||||
"pocketBalanceMap": { "...": "..." }
|
||||
},
|
||||
"status": { "replyCode": 0.0, "replyText": "Success" },
|
||||
"...": "..."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Response — wrong OTP
|
||||
|
||||
The server returns its standard error envelope as a JSON array:
|
||||
|
||||
```json
|
||||
[{
|
||||
"success": false, "message": "validation errors",
|
||||
"error": [{ "attributeName":"OTP", "errorMessage":"<details>" }]
|
||||
}]
|
||||
```
|
||||
|
||||
`MfaisaTransferClient` parses this into [`MfaisaInvalidOtpException`](../../app/src/main/java/sh/sar/basedbank/api/mfaisa/MfaisaModels.kt) so the caller can re-prompt without losing the `referenceId`.
|
||||
|
||||
### Session expiry
|
||||
|
||||
Same envelope as elsewhere — `attributeValue: "SESSION_EXPIRED"` with HTTP 200; the client throws `MfaisaSessionExpiredException`. See [03-history.md → Session expiry](03-history.md#session-expiry).
|
||||
|
||||
---
|
||||
|
||||
## curl Reference
|
||||
|
||||
```bash
|
||||
# Step 1 (search a recipient)
|
||||
python tmp/mfaisa_transfer.py <myMsisdn> <myMpin> <recipientMsisdn>
|
||||
# Steps 2 + 3 require a live phone OTP and are documented in tmp/mfaisa_transfer.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
> **← Back to** [Transaction History](03-history.md) | [README](README.md)
|
||||
@@ -0,0 +1,98 @@
|
||||
# Ooredoo M-Faisa API Documentation
|
||||
|
||||
Reverse-engineered from traffic captures and live Frida hooks of the official Ooredoo SuperApp (`com.mventus.ooredoomaldives`).
|
||||
|
||||
[Play Store](https://play.google.com/store/apps/details?id=com.mventus.ooredoomaldives)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
M-Faisa is Ooredoo Maldives' mobile wallet, exposed via a JSON/form-encoded REST API on `superapp.ooredoo.mv`. The wire format is unusual in three ways:
|
||||
|
||||
1. **Field-level RSA encryption.** The MSISDN (`mdnId`, `mobileNumber`, `userName`, `initiatingMDN`, `identifier`) and the mPIN (`mPin`) are each encrypted with a different RSA public key before being placed in the request body. See [01-encryption.md](01-encryption.md).
|
||||
2. **Anti-replay envelope.** Every session-scoped form-encoded POST carries an `rndValue` (RSA-encrypted timestamp) and a `csValue` (Adler32 of `formDataJson + nonceStr`). See [01-encryption.md → `rndValue` / `csValue`](01-encryption.md#anti-replay-envelope-rndvalue--csvalue).
|
||||
3. **Cloudflare-fingerprinted header order.** A `User-Agent` header sent explicitly (instead of letting OkHttp add it last) returns HTTP 400.
|
||||
|
||||
---
|
||||
|
||||
## Base URL
|
||||
|
||||
```
|
||||
https://superapp.ooredoo.mv
|
||||
```
|
||||
|
||||
All M-Faisa endpoints are mounted at `/api/mfaisaa-bff/mfino/v1.1/web/...`.
|
||||
|
||||
---
|
||||
|
||||
## Authentication Model
|
||||
|
||||
| Value | How obtained | How used |
|
||||
|---|---|---|
|
||||
| `loginExchangeKey` | Returned by `doMobileLogin` on success | Held in memory only; identifies the session |
|
||||
| Session timeout | `mobileLoginSessionTimeout` field, default **240 seconds** | After expiry the user must re-login (no refresh-token flow) |
|
||||
|
||||
Because there is no refresh, Thijooree re-runs `fetchSubscriberByMDN` + `doMobileLogin` on every cold-start refresh, using the saved msisdn + mPIN from `CredentialStore`. The `SESSION_EXPIRED` error envelope is also caught at runtime and the session is silently re-established before retrying the failed request.
|
||||
|
||||
---
|
||||
|
||||
## Common Request Headers
|
||||
|
||||
```
|
||||
Content-Type: application/json; charset=UTF-8 (fetchSubscriberByMDN)
|
||||
Content-Type: application/x-www-form-urlencoded (every other endpoint)
|
||||
Host: superapp.ooredoo.mv
|
||||
Connection: Keep-Alive
|
||||
Accept-Encoding: gzip
|
||||
User-Agent: okhttp/4.12.0
|
||||
```
|
||||
|
||||
> **Do NOT set User-Agent in code.** Cloudflare fingerprints the header order; an explicit `User-Agent` header is pushed to the front of the request and the request is rejected with HTTP 400. Let OkHttp's `BridgeInterceptor` add the default `okhttp/4.12.0` at the end.
|
||||
|
||||
---
|
||||
|
||||
## Login Flow
|
||||
|
||||
```
|
||||
Client Server
|
||||
| |
|
||||
| POST /fetchSubscriberByMDN |
|
||||
| { mdnId: encryptMobile(msisdn) } |
|
||||
|---------------------------------------------------->|
|
||||
| { success, subscriberRegistered, kycStatus, ... } |
|
||||
|<----------------------------------------------------|
|
||||
| |
|
||||
| (abort if subscriberRegistered=false |
|
||||
| or kycStatus != "Full KYC") |
|
||||
| |
|
||||
| POST /doMobileLogin |
|
||||
| channel=C03 |
|
||||
| formData={ deviceGeoInfo, mPin: encryptPin(mpin), |
|
||||
| mobileNumber: ..., userName: ..., |
|
||||
| role:"RETAIL_SUBSCRIBER", |
|
||||
| tenantCode:"ooredoo" } |
|
||||
| formDataCs=null |
|
||||
|---------------------------------------------------->|
|
||||
| { success, loginExchangeKey, pocketDetails: [...]} |
|
||||
|<----------------------------------------------------|
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Documents
|
||||
|
||||
| # | File | Description |
|
||||
|---|---|---|
|
||||
| 1 | [Encryption & Anti-Replay](01-encryption.md) | Mobile / mPin RSA, the `rndValue` + `csValue` envelope, the Gson `=` quirk, key-extraction story |
|
||||
| 2 | [Login](02-login.md) | Subscriber lookup + mPIN login |
|
||||
| 3 | [Transaction History](03-history.md) | Paginated history per session |
|
||||
| 4 | [Transfer Money](04-transfer.md) | Three-step wallet-to-wallet send: recipient lookup → initiate (server SMSes OTP) → confirm |
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
> **Next →** [Encryption & Anti-Replay](01-encryption.md)
|
||||
Reference in New Issue
Block a user