346 lines
12 KiB
Markdown
346 lines
12 KiB
Markdown
# 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`.
|
||
|