# Faisanet MIB API Documentation Reverse-engineered from `mv.com.mib.faisamobilex` (React Native, Hermes bytecode v96). --- ## Base - **URL**: `https://faisanet.mib.com.mv/faisamobilex_smvc/` - **Method**: `POST /` - **Content-Type**: `application/x-www-form-urlencoded; charset=utf-8` - **User-Agent**: `android/1.0` - **Accept**: `application/json` All requests share the same form body structure: ``` sfunc=&data= ``` --- ## Encryption ### Algorithm - **Cipher**: Blowfish, ECB mode, PKCS5 padding - **Input**: raw UTF-8 bytes of JSON payload string - **Key**: raw UTF-8 bytes of key string - **Output**: base64-encoded ciphertext (URL-encoded when sent as form data) ### Python equivalent ```python from Crypto.Cipher import Blowfish from Crypto.Util.Padding import pad, unpad import base64 def encrypt(payload: dict, key: str) -> str: import json plaintext = json.dumps(payload).encode() key_bytes = key.encode('latin-1') cipher = Blowfish.new(key_bytes, Blowfish.MODE_ECB) ct = cipher.encrypt(pad(plaintext, Blowfish.block_size)) return base64.b64encode(ct).decode() def decrypt(ciphertext_b64: str, key: str) -> dict: import json key_bytes = key.encode('latin-1') ct = base64.b64decode(ciphertext_b64) cipher = Blowfish.new(key_bytes, Blowfish.MODE_ECB) plaintext = unpad(cipher.decrypt(ct), Blowfish.block_size) return json.loads(plaintext.decode()) ``` ### Key lifecycle | Phase | Key used | |---|---| | `sfunc=r` (key exchange) | `DEFAULT_KEY` (hardcoded in app) | | All subsequent requests | DH-derived session key | **DEFAULT_KEY** (hardcoded): ``` 8M3L9SBF1AC4FRE56788M3L9SBF1AC4FRE5678 ``` --- ## Diffie-Hellman Key Exchange The app uses a **custom Diffie-Hellman** scheme to derive a session key. ### Fixed parameters (hardcoded in app) > Note: the variable names in the app's source are swapped from their DH role. > `A_VALUE` in the source is the **exponent** (shorter number), `P_VALUE` is the **prime modulus** (longer number). ``` G (generator) = 2 A (client privkey) = 1563516802667282387226490351799736881442299778484610378722158765594241028592123324764949712696577 P (prime modulus) = 2410312426921032588552076022197566074856950548502459942654116941958108831682612228890093858261341614673227141477904012196503648957050582631942730706805009223062734745341073406696246014589361659774041027169249453200378729434170325843778659198143763193776859869524088940195577346119843545301547043747207749969763750084308926339295559968882457872412993810129130294592999947926365264059284647209730384947211681434464714438488520940127459844288859336526896320919633919 ``` > **Note**: `A` (client private key) is hardcoded in the app — this DH provides no real security. ### Session key derivation ```python import hashlib, base64 def derive_session_key(smod: int) -> str: A = 1563516802667282387226490351799736881442299778484610378722158765594241028592123324764949712696577 P = 2410312426921032588552076022197566074856950548502459942654116941958108831682612228890093858261341614673227141477904012196503648957050582631942730706805009223062734745341073406696246014589361659774041027169249453200378729434170325843778659198143763193776859869524088940195577346119843545301547043747207749969763750084308926339295559968882457872412993810129130294592999947926365264059284647209730384947211681434464714438488520940127459844288859336526896320919633919 shared_secret = pow(smod, A, P) sha256_hex = hashlib.sha256(str(shared_secret).encode()).hexdigest().upper() return base64.b64encode(bytes.fromhex(sha256_hex)).decode() ``` The resulting session key is always a **44-character base64 string** (32 bytes / 256-bit SHA-256 output), for example: ``` XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX= ``` It changes every session because `smod` is different each time. --- ## Endpoints (sfunc values) ### `r` — Key Exchange (initiate session) **Request payload** (encrypted with DEFAULT_KEY): ```json { "sfunc": "r", "data": { "cmod": "", "appId": "IOS17.2-", "routePath": "S40", "sodium": "", "xxid": "" } } ``` **Response payload** (encrypted with DEFAULT_KEY): ```json { "success": true, "responseCode": "1", "reasonCode": "201", "reasonText": "Key generated successfully.", "smod": "", "nonceGenerator": "", "xxid": "", "sodium": "", "encMethod": 1 } ``` After this call: - Compute `encryptionKey = derive_session_key(int(smod))` - Store `xxid` and `nonceGenerator` for subsequent calls --- ## Request envelope structure All requests after key exchange use this structure: ```json { "sfunc": "", "xxid": "", "data": { "nonce": "", "appId": "", "sodium": "", "routePath": "", "xxid": "", ...additional fields... } } ``` Encrypted with the DH-derived `encryptionKey`. --- ## Login Flows ### First-time device registration (no stored key1/key2) 1. `sfunc=r` → `S40` — DH key exchange with `DEFAULT_KEY` → receive `xxid`, `nonceGenerator`, `smod` → derive session key 2. `sfunc=n` → `A44` — get `userSalt` for username 3. `sfunc=n` → `C41` — submit `pgf03` (computed from password + userSalt + random clientSalt) 4. `sfunc=n` → `C42` — verify TOTP OTP → receive `key1` and `key2`; persist them 5. Continue with regular login below (using the just-received key1/key2) ### Regular login (stored key1/key2 present) 1. `sfunc=i` → `S40` — DH key exchange with `key1`, sending `key2` as extra form field → derive session key 2. `sfunc=n` → `A44` — get `userSalt` for username 3. `sfunc=n` → `A41` — submit `pgf03` → receive `operatingProfiles` list 4. For each profile: `sfunc=n` → `P47` — fetch `accountBalance` array > **No A42 step in regular login.** OTP is only verified once during first-time registration (C42). ### pgf03 formula ```python h1 = SHA256(password).hexdigest().upper() h2 = SHA256(h1 + userSalt).hexdigest().upper() pgf03 = SHA256(clientSalt + h2).hexdigest().upper() ``` `clientSalt` is a random 32-character alphanumeric string generated fresh each login. --- ## Known route paths | sfunc | routePath | Description | |---|---|---| | `r` | `S40` | DH key exchange (first-time registration) | | `i` | `S40` | DH key exchange (regular login, sends `key1`/`key2`) | | `n` | `A44` | Get auth type — returns `userSalt` for the given `uname` | | `n` | `C41` | Registration: submit credentials (`uname`, `pgf03`, `clientSalt`) | | `n` | `C42` | Registration: verify OTP (`otp`, `uname`, `otpType=3`) — returns `key1`/`key2` | | `n` | `A41` | Login: submit credentials (`uname`, `pgf03`, `clientSalt`, `pmodTime`, `requireBankData`) — returns `operatingProfiles` | | `n` | `P47` | Fetch account balances for a profile (`profileType`, `profileId`) — returns `accountBalance` array | | `n` | `P40` | Update profile image | | `n` | `P42` | Delete profile image | > Note: `A42` (login OTP verify) is **not sent** during regular login. It was present in an older flow but is no longer used. `C42` is only sent during first-time device registration. --- ## Nonce Computation Every request after key exchange includes a `nonce` field computed from the `nonceGenerator` string returned by the key exchange response. ### nonceGenerator format A string of 4 groups separated by `-`. Each group contains 8 space-separated tokens. Each token is a letter followed by a number (e.g. `M85`, `A37`, `C95`, `X2`). ``` M85 A87 A82 M82 M60 M31 A46 C95-M14 X83 A37 X2 C4 X22 X46 C95-M57 X29 C51 C34 S91 X60 S1 A15-M54 A89 S13 S18 C81 A70 X92 X59 ``` ### Nonce output format 4 groups separated by `-`. Each group: a zero-padded 5-digit number followed by 7 two-digit numbers separated by spaces. ``` 08160 19 73 45 17 89 07 10-00924 64 73 18 08 48 80 67-01026 20 17 13 26 26 43 24-00648 12 32 17 69 14 63 92 ``` ### Algorithm **Phase 1 — process first token of each group (produces seed values):** For each of the 4 groups (index `i`): 1. Take `token[0]` (e.g. `M85`). Extract the number: `N = parseInt(token.replace(/\D/g, ''))`. 2. Generate a random integer: `r = floor(random() * 99) + 1` (range 1–99 inclusive). 3. Compute `product = N * r`. Zero-pad to 5 digits: `padded = product.toString().padStart(5, '0')`. 4. Compute `digitSum[i]` = sum of all digits in `padded`. 5. Store `lastTwo[i]` = `parseInt(padded.slice(-2))` (last two digits as integer). 6. Accumulate `cumSum += digitSum[i]`. After all 4 groups: `cumSum` = sum of all four `digitSum` values. **Phase 2 — process tokens 1–7 of each group (produces nonce digits):** For each group (index `i`), process `token[1]` through `token[7]`: - Initialise `carry = lastTwo[i]`. - For each token at position `j` (1–7): - Extract letter `op` and number `num`. - Compute `val` based on `op`: | op | formula | |---|---| | `M` | `(carry % num) + digitSum[i] + cumSum` | | `A` | `carry + num + digitSum[i] + cumSum` | | `S` | `(carry * carry) + num + digitSum[i] + cumSum` | | `X` | `(carry * num) + digitSum[i] + cumSum` | | `C` | `(carry * carry * carry) + num + digitSum[i] + cumSum` | - Nonce digit = `parseInt(val.toString().slice(-2))` (last two digits as integer). - Update `carry = nonceDigit` for the next token. **Assembling the nonce string:** For each group `i`: ``` group_str = padded[i] + " " + nonceDigit[i][0].toString().padStart(2,'0') + " " + ... (7 digits) ``` Join 4 groups with `-`. ### Python implementation ```python import math, random def generate_nonce(nonce_generator: str) -> str: groups = nonce_generator.split('-') padded_list, last_two, digit_sum = [], [], [] cum_sum = 0 # Phase 1 for group in groups: tokens = group.split(' ') n = int(''.join(c for c in tokens[0] if c.isdigit())) r = math.floor(random.random() * 99) + 1 product = n * r padded = str(product).zfill(5) ds = sum(int(d) for d in padded) lt = int(padded[-2:]) padded_list.append(padded) last_two.append(lt) digit_sum.append(ds) cum_sum += ds # Phase 2 result_groups = [] for i, group in enumerate(groups): tokens = group.split(' ') carry = last_two[i] ds = digit_sum[i] nonce_digits = [] for token in tokens[1:]: op = ''.join(c for c in token if c.isalpha()) num = int(''.join(c for c in token if c.isdigit())) if op == 'M': val = (carry % num) + ds + cum_sum elif op == 'A': val = carry + num + ds + cum_sum elif op == 'S': val = (carry * carry) + num + ds + cum_sum elif op == 'X': val = (carry * num) + ds + cum_sum elif op == 'C': val = (carry * carry * carry) + num + ds + cum_sum else: val = 0 digit = int(str(val)[-2:]) nonce_digits.append(digit) carry = digit group_str = padded_list[i] + ' ' + ' '.join(str(d).zfill(2) for d in nonce_digits) result_groups.append(group_str) return '-'.join(result_groups) ``` ### Notes - `nonce` and `sodium` are **separate** request fields. `sodium` is an independent random integer (observed range ~1M–16M, approximately 23–24 bit). - The nonce string is the same value for both the `nonce` and ... actually they are different fields: `nonce` = the computed nonce string; `sodium` = a random integer sent as a plain string. - For `sfunc=i`, `key2` is sent as a **separate form field** (not inside the encrypted payload): `key2=&sfunc=i&data=`. The encrypted payload is the inner data object only, encrypted with `key1`. - For all `sfunc=n` requests (every request after key exchange), `xxid` is sent as a **separate unencrypted form field** as the FIRST field: `xxid=&sfunc=n&data=`. The `xxid` also appears inside the encrypted payload. Field order matters — `xxid` must come before `sfunc` and `data`.