working mib login and list accounts
This commit is contained in:
345
docs/mibapi/API.md
Normal file
345
docs/mibapi/API.md
Normal 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 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=<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`.
|
||||
|
||||
Reference in New Issue
Block a user