ooredoo mfaisa
Auto Tag on Version Change / check-version (push) Failing after 13m41s

This commit is contained in:
2026-06-27 14:25:46 +05:00
parent 0e226d17ae
commit 43f3cca2aa
5 changed files with 935 additions and 0 deletions
+186
View File
@@ -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 = 125043708524451715647694889
PIN_N = 30853988905151679601504123
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.
---
&nbsp;
---
> **Next →** [Login](02-login.md) | **← Back to** [README](README.md)
+220
View File
@@ -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).
---
&nbsp;
---
> **Next →** [Transaction History](03-history.md) | **← Back to** [README](README.md)
+149
View File
@@ -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.
---
&nbsp;
---
> **Next →** [Transfer Money](04-transfer.md) | **← Back to** [Login](02-login.md) | [README](README.md)
+282
View File
@@ -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
```
---
&nbsp;
---
> **← Back to** [Transaction History](03-history.md) | [README](README.md)
+98
View File
@@ -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 |
---
&nbsp;
---
> **Next →** [Encryption & Anti-Replay](01-encryption.md)