From 43f3cca2aa5952914450debe84593331b6611176 Mon Sep 17 00:00:00 2001 From: Shihaam Abdul Rahman Date: Sat, 27 Jun 2026 14:25:46 +0500 Subject: [PATCH] ooredoo mfaisa --- docs/mfaisaapi/01-encryption.md | 186 +++++++++++++++++++++ docs/mfaisaapi/02-login.md | 220 +++++++++++++++++++++++++ docs/mfaisaapi/03-history.md | 149 +++++++++++++++++ docs/mfaisaapi/04-transfer.md | 282 ++++++++++++++++++++++++++++++++ docs/mfaisaapi/README.md | 98 +++++++++++ 5 files changed, 935 insertions(+) create mode 100644 docs/mfaisaapi/01-encryption.md create mode 100644 docs/mfaisaapi/02-login.md create mode 100644 docs/mfaisaapi/03-history.md create mode 100644 docs/mfaisaapi/04-transfer.md create mode 100644 docs/mfaisaapi/README.md diff --git a/docs/mfaisaapi/01-encryption.md b/docs/mfaisaapi/01-encryption.md new file mode 100644 index 0000000..d3f5393 --- /dev/null +++ b/docs/mfaisaapi/01-encryption.md @@ -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 ` + <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) diff --git a/docs/mfaisaapi/02-login.md b/docs/mfaisaapi/02-login.md new file mode 100644 index 0000000..c15c354 --- /dev/null +++ b/docs/mfaisaapi/02-login.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": "" } +``` + +### curl Example + +```bash +MDN_ENC=$(python tmp/mfaisa_encrypt.py mobile ) +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": "", + "firstName": "", + "lastName": "", + "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": "", + "deviceManufacturer": "", + "imieNumber": "", + "ipaddress": "11.22.33.55", + "latitude": "0.0", + "longitude": "0.0", + "simId": "" + }, + "mPin": "", + "mobileNumber": "", + "role": "RETAIL_SUBSCRIBER", + "tenantCode": "ooredoo", + "userName": "" +} +``` + +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": "", + "mobileLoginSessionTimeout": "240", + "kycStatus": "Full KYC", + "suscriberId": "<12-digit subscriber id>", + "pocketDetails": [ + { + "name": "", + "eMailId": "", + "mdnId": "", + "roleId": "<12-digit role id>", + "walletId": "<11-digit wallet id>", + "offerId": "", + "pocketSummaryDetailsArrayDTO": [ + { + "pocketId": "", + "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) diff --git a/docs/mfaisaapi/03-history.md b/docs/mfaisaapi/03-history.md new file mode 100644 index 0000000..0f2b63e --- /dev/null +++ b/docs/mfaisaapi/03-history.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` | `` — 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": "", + "fromDate": "", + "mdnId": "", + "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 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": "", + "referenceId": "", + "sourceMDN": "-DT Pocket-", + "sourcePocketId":"", + "actorRoleType": "RETAIL_SUBSCRIBER", + "actorRoleId": "", + "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\":\"\"}]", + "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. `"-DT Pocket-"`) | `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) diff --git a/docs/mfaisaapi/04-transfer.md b/docs/mfaisaapi/04-transfer.md new file mode 100644 index 0000000..65d1659 --- /dev/null +++ b/docs/mfaisaapi/04-transfer.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": "", + "actorRoleType": "RETAIL_SUBSCRIBER" + }, + "initiatorDetailsDTO": { + "initiatingMDN": "", + "initiatingRoleId": "", + "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": "", "pocketCurrency": "USD", + "pocketValueType": "PAYPAL_USD", "name": "", "MDNId": "", + "walletId": "", "actorId": "", "...": "..." }, + { "pocketId": "", "pocketCurrency": "MVR", + "pocketValueType": "EMONEY", "name": "", "MDNId": "", + "walletId": "", "actorId": "", "...": "..." } + ] + ] + } +] +``` + +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` | `` — 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", /* PLAINTEXT — '960' + recipient phone */ + "beneDetails": { + "miscDetails": "", + "transferMode":"MOBILE" + }, + "channel": "SubscriberApp", + "commodityType": "WALLET", + "description": "", + "inputDetailsDTO": { "deviceId": "…", "simId": "…" }, + "mfs-transactionType": "send-money-to-mobile", + "pocketId": "", + "sourceDetails": { + "MDNId": "960", /* PLAINTEXT — '960' + my phone */ + "actorRoleType":"RETAIL_SUBSCRIBER", + "pocketId": "" /* from login.pocketDetails[0].pocketSummaryDetailsArrayDTO */ + }, + "transactionAmount": "", /* 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": "", + "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": ""}` | +| `transactionAuthDetails` | JSON below | + +```json +{ + "authenticationType": "OTP", + "authenticationValue": "", + "otpTransactionType": "TRANSACTION", + "referenceId": "" +} +``` + +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": "", + "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":"
" }] +}] +``` + +`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 +# 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) diff --git a/docs/mfaisaapi/README.md b/docs/mfaisaapi/README.md new file mode 100644 index 0000000..e673ec7 --- /dev/null +++ b/docs/mfaisaapi/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)