Files
basedbank/docs/mibapi/API.md

346 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`.