working mib login and list accounts

This commit is contained in:
2026-05-12 04:19:52 +05:00
parent 31c8a5500d
commit 076a58359a
73 changed files with 3076 additions and 550 deletions

345
docs/mibapi/API.md Normal file
View File

@@ -0,0 +1,345 @@
# 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=<function_code>&data=<urlencode(blowfish_ecb_base64_ciphertext)>
```
---
## 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": "<G^A mod P as decimal string>",
"appId": "IOS17.2-<random 15-char string>",
"routePath": "S40",
"sodium": "<random 20-bit integer as string>",
"xxid": "<random 40-bit integer as string>"
}
}
```
**Response payload** (encrypted with DEFAULT_KEY):
```json
{
"success": true,
"responseCode": "1",
"reasonCode": "201",
"reasonText": "Key generated successfully.",
"smod": "<server DH public key as decimal string>",
"nonceGenerator": "<instruction string for nonce computation>",
"xxid": "<session token>",
"sodium": "<server random>",
"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": "<function_code>",
"xxid": "<session xxid>",
"data": {
"nonce": "<computed from nonceGenerator>",
"appId": "<same appId>",
"sodium": "<random 20-bit>",
"routePath": "<route constant>",
"xxid": "<session 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 199 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 17 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` (17):
- 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 ~1M16M, approximately 2324 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=<key2>&sfunc=i&data=<encrypted>`. 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=<session_xxid>&sfunc=n&data=<encrypted>`. The `xxid` also appears inside the encrypted
payload. Field order matters — `xxid` must come before `sfunc` and `data`.