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
+172
View File
@@ -0,0 +1,172 @@
# MIB Faisanet — List Accounts & Balances
Account numbers and balances are returned by the **Select Profile** call (`routePath: P47`).
The login initialization call (`A41`) returns an empty `accountBalance` array until a profile is selected.
---
## Flow to Get Account Balances
```
[0] sfunc=i (key1) → DH key exchange → derive session_key
[1] sfunc=n A44 → get userSalt
[2] sfunc=n A41 → login with password → returns operatingProfiles (no balances yet)
[3] sfunc=n A42 → OTP verify
[4] sfunc=n P47 → select profile → returns accountBalance array
```
Steps 03 are the standard login flow (see `LOGIN_FLOW.md`). Step 4 is the new call.
---
## Step 1 — Get Profile List from A41 Response
The `A41` login initialization response includes `operatingProfiles` — the list of
profiles available to the user (personal, business, etc.).
**Relevant fields from A41 response:**
```json
{
"defaultProfile": "2",
"operatingProfiles": [
{
"profileId": "<profile ID>",
"customerProfileId": "<customer profile ID>",
"annexId": "<annex ID>",
"customerId": "<customer ID>",
"name": "<profile display name>",
"cifType": "Individual",
"customerImage": "<image hash>",
"profileType": "0",
"color": "<hex color>"
},
{
"profileId": "<profile ID>",
"customerProfileId": "<customer profile ID>",
"annexId": "<annex ID>",
"customerId": "<customer ID>",
"name": "<business name / owner name>",
"cifType": "Sole Propr",
"customerImage": "<image hash>",
"profileType": "1",
"color": "<hex color>"
}
],
"selectedProfileId": null,
"selectedProfileType": null,
"profileSelected": false
}
```
`profileType` values observed:
| Value | Meaning |
|---|---|
| `"0"` | Individual (personal) |
| `"1"` | Sole Proprietor (business) |
---
## Step 2 — Select Profile (`sfunc=n`, `routePath: P47`)
**Key**: session key (derived from `sfunc=i` response)
**Request:**
```json
{
"sfunc": "n",
"xxid": "<session xxid>",
"data": {
"profileType": "<profileType from operatingProfiles>",
"profileId": "<profileId from operatingProfiles>",
"nonce": "<computed nonce>",
"appId": "<appId>",
"sodium": "<random 20-bit int>",
"routePath": "P47",
"xxid": "<session xxid>"
}
}
```
**Response:**
```json
{
"success": true,
"reasonCode": "101",
"reasonText": "Profile Selected!",
"landingPage": "0",
"accountBalance": [ ... ],
"accessRights": { ... },
"services": []
}
```
---
## accountBalance Array
Each element in `accountBalance` represents one account:
```json
{
"cif": "<CIF number>",
"accountNumber": "<full account number>",
"accountBriefName": "<short label, e.g. 'SAR MVR - Savings'>",
"template": "<display template ID>",
"currencyCode": "<ISO 4217 numeric code>",
"currencyName": "<ISO 4217 alpha code>",
"accountTypeName": "<account type label>",
"transfer": "Y",
"branchName": "<branch name>",
"availableBalance": "<decimal string>",
"currentBalance": "<decimal string>",
"blockedAmount": "<decimal string, may be negative>",
"settlementBalance": "<decimal string>",
"mvrBalance": "<MVR equivalent as decimal string>",
"statusDesc": "Active"
}
```
### Field reference
| Field | Description |
|---|---|
| `accountNumber` | Full account number |
| `accountBriefName` | Human-readable account label |
| `currencyCode` | ISO 4217 numeric (e.g. `"462"` = MVR, `"840"` = USD) |
| `currencyName` | ISO 4217 alpha (e.g. `"MVR"`, `"USD"`) |
| `accountTypeName` | Account type (e.g. `"Saving Account"`) |
| `availableBalance` | Spendable balance |
| `currentBalance` | Ledger balance |
| `blockedAmount` | Held/blocked funds (negative means funds are held) |
| `settlementBalance` | Balance including pending settlements |
| `mvrBalance` | All balances converted to MVR for display |
| `transfer` | `"Y"` if account can be used as transfer source |
| `statusDesc` | Account status (e.g. `"Active"`) |
| `cif` | Customer Information File number |
| `template` | UI template ID (controls how card is rendered in-app) |
---
## accessRights
Also returned in the P47 response, describes what the selected profile can do:
```json
{
"numAccounts": "<number of accounts>",
"packageRights": "[1,2,3,4,6,7,8,9,10,11,12,...]",
"roleRights": "[]"
}
```
`packageRights` is a JSON array encoded as a string — parse it separately.
---
## Notes
- `accountBalance` in the `A41` response is always `[]`. Balances are only returned after `P47`.
- To switch between profiles (personal ↔ business), call `P47` again with the other profile's `profileId` and `profileType`.
- `mvrBalance` is always in MVR regardless of the account's native currency, useful for showing a unified total.
- All balance fields are **decimal strings**, not numbers — parse with `Decimal` for precision.
+345
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`.
+180
View File
@@ -0,0 +1,180 @@
# MIB Faisanet API — Encryption & Decryption
## Overview
All API traffic is encrypted using **Blowfish** in ECB mode with PKCS5 padding.
Every request and response body is a single base64-encoded Blowfish ciphertext.
There are two keys in play:
| Key | Used for |
|---|---|
| `DEFAULT_KEY` (hardcoded) | The initial key exchange request and response (`sfunc=r`) |
| Session key (DH-derived) | Every request and response after the key exchange |
---
## The DEFAULT_KEY
```
8M3L9SBF1AC4FRE56788M3L9SBF1AC4FRE5678
```
This key is hardcoded in the app's JavaScript bundle. It is only used for the
first call (`sfunc=r`) which establishes a session key via Diffie-Hellman.
---
## Session Key Derivation (Diffie-Hellman)
The app uses a custom DH key exchange to derive a per-session Blowfish key.
All three DH parameters are hardcoded in the app:
```
G = 2
P = 1563516802667282387226490351799736881442299778484610378722158765594241028592123324764949712696577
A = 2410312426921032588552076022197566074856950548502459942654116941958108831682612228890093858261341614673227141477904012196503648957050582631942730706805009223062734745341073406696246014589361659774041027169249453200378729434170325843778659198143763193776859869524088940195577346119843545301547043747207749969763750084308926339295559968882457872412993810129130294592999947926365264059284647209730384947211681434464714438488520940127459844288859336526896320919633919
```
`A` is the client's private key. Because it is hardcoded and never rotates,
anyone with the APK can derive the session key from a captured `smod`.
### Step-by-step
1. Client computes `cmod = G^A mod P` and sends it in the `sfunc=r` request.
2. Server computes its own keypair and responds with `smod` (its public key).
3. Client computes the shared secret: `shared = smod^A mod P`
4. Client SHA-256 hashes the decimal string of the shared secret (uppercased hex).
5. Client converts that hex string to raw bytes, then base64-encodes it.
6. The result is the Blowfish key for the rest of the session.
```python
import hashlib, base64
def derive_session_key(smod: int) -> str:
# A_VALUE in app = exponent (shorter), P_VALUE in app = modulus (longer)
A = 1563516802667282387226490351799736881442299778484610378722158765594241028592123324764949712696577
P = 2410312426921032588552076022197566074856950548502459942654116941958108831682612228890093858261341614673227141477904012196503648957050582631942730706805009223062734745341073406696246014589361659774041027169249453200378729434170325843778659198143763193776859869524088940195577346119843545301547043747207749969763750084308926339295559968882457872412993810129130294592999947926365264059284647209730384947211681434464714438488520940127459844288859336526896320919633919
shared = pow(smod, A, P)
sha256_hex = hashlib.sha256(str(shared).encode()).hexdigest().upper()
return base64.b64encode(bytes.fromhex(sha256_hex)).decode()
```
---
## Encrypting a Request
All request payloads follow this JSON structure before encryption:
```json
{
"sfunc": "<function code>",
"xxid": "<session token>",
"data": {
...
}
}
```
### Encryption steps
1. `JSON.stringify` the payload.
2. Use the raw UTF-8 bytes of the payload as plaintext.
3. Use the raw UTF-8 bytes of the key string as the Blowfish key.
4. Encrypt: Blowfish / ECB / PKCS5 padding.
5. Base64-encode the ciphertext.
6. URL-encode the base64 string.
7. Send as form field: `sfunc=<value>&data=<url-encoded-ciphertext>`
```python
import json, base64
from urllib.parse import quote
from Crypto.Cipher import Blowfish
from Crypto.Util.Padding import pad
def encrypt(payload: dict, key: str) -> str:
plaintext = json.dumps(payload, separators=(',', ':')).encode('utf-8')
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 build_request_body(payload: dict, key: str) -> str:
sfunc = payload.get('sfunc', '')
encrypted = encrypt(payload, key)
return f"sfunc={sfunc}&data={quote(encrypted)}"
```
---
## Decrypting a Response
The response body is a raw base64-encoded Blowfish ciphertext (no form encoding).
### Decryption steps
1. Base64-decode the response body to get the ciphertext bytes.
2. Decrypt with Blowfish / ECB / PKCS5 padding using the appropriate key.
3. Parse the result as JSON.
```python
import json, base64
from Crypto.Cipher import Blowfish
from Crypto.Util.Padding import unpad
def decrypt(ciphertext_b64: str, key: str) -> dict:
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('utf-8'))
```
---
## Full Session Example
### 1. Send key exchange (`sfunc=r`) — use DEFAULT_KEY
Request form body:
```
sfunc=r&data=<base64 of Blowfish(DEFAULT_KEY, {"sfunc":"r","data":{"cmod":"...","appId":"...","routePath":"S40","sodium":"...","xxid":"..."}})>
```
Response (decrypted with DEFAULT_KEY):
```json
{
"success": true,
"smod": "<large decimal integer — server DH public key>",
"nonceGenerator": "<instruction string, e.g. 'M26 C16 C4 C5 M64 ...'>",
"xxid": "<session token>",
"sodium": "<server random hex string>",
"encMethod": 2
}
```
### 2. Derive the session key from `smod`
```python
session_key = derive_session_key(int(response['smod']))
# → a 44-character base64 string, e.g. "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX="
# The key is 32 bytes (256-bit SHA-256 output) encoded as base64.
```
### 3. All subsequent requests — use session key
Encrypt with `session_key`, decrypt responses with `session_key`.
---
## Quick reference
| What | How |
|---|---|
| Cipher | Blowfish, ECB mode, PKCS5 padding |
| Key for `sfunc=r` | `8M3L9SBF1AC4FRE56788M3L9SBF1AC4FRE5678` |
| Key for everything else | `derive_session_key(smod)` |
| Request encoding | JSON → encrypt → base64 → URL-encode → form field `data=` |
| Response encoding | base64 → decrypt → JSON |
| Key input | raw UTF-8 bytes of key string |
| Plaintext input | raw UTF-8 bytes of JSON string |
+449
View File
@@ -0,0 +1,449 @@
# MIB Faisanet — Login Flow
Fully reverse engineered from a captured HAR trace of a first-time device
registration followed immediately by a regular login.
---
## Key Corrections
The DH parameter names in the app's source are **misleading**:
| App variable | DH role | Value |
|---|---|---|
| `A_VALUE` | Exponent / client private key | `15635168026...` (shorter) |
| `P_VALUE` | Prime modulus | `24103124269...` (longer) |
| `G_VALUE` | Generator | `2` |
The session key derivation is:
```python
shared = pow(smod, A_VALUE, P_VALUE) # NOT pow(smod, P_VALUE, A_VALUE)
sha256_hex = SHA256(str(shared)).upper()
session_key = base64(bytes.fromhex(sha256_hex))
```
---
## Overview
The full login sequence consists of two phases:
| Phase | Purpose | Key used |
|---|---|---|
| **Phase 1** — Device registration | First time this device+account pair is seen | DH session key from `sfunc=r` |
| **Phase 2** — Regular login | Every subsequent login | key1/key2 (from phase 1) → second DH → new session key |
---
## Full Flow Diagram
```
Client Server
| |
| [0] sfunc=r (DEFAULT_KEY) |
| { cmod, appId, routePath:S40, ... } |
|--------------------------------------------->|
| { smod, nonceGenerator, xxid, ... } |
|<---------------------------------------------|
| derive session_key_1 = DH(smod) |
| |
| [1] sfunc=n routePath:A44 (session_key_1)|
| { uname } |
|--------------------------------------------->|
| { loginType, userSalt } |
|<---------------------------------------------|
| |
| [2] sfunc=n routePath:C41 (session_key_1)| ← device registration init
| { uname, pgf03, clientSalt } |
|--------------------------------------------->|
| { key1, key2, otpTypes, fullName, ... } |
|<---------------------------------------------|
| |
| [3] sfunc=n routePath:C42 (session_key_1)| ← OTP verify (registration)
| { otp, uname, otpType } |
|--------------------------------------------->|
| { key1, key2, encryptionMethod:2, ... } |
|<---------------------------------------------|
| store key1, key2 on device |
| |
| [4] sfunc=i (key1) | ← second DH key exchange
| { cmod, appId, routePath:S40, key2 } |
|--------------------------------------------->|
| { smod, nonceGenerator, xxid, ... } |
|<---------------------------------------------|
| derive session_key_2 = DH(smod) |
| |
| [5] sfunc=n routePath:A44 (session_key_2)|
| { uname } |
|--------------------------------------------->|
| { loginType, userSalt } |
|<---------------------------------------------|
| |
| [6] sfunc=n routePath:A41 (session_key_2)| ← regular login init
| { uname, pgf03, clientSalt, requireBankData:1 }|
|--------------------------------------------->|
| { primaryOTPType, otpTypes, email, uuid, uuid2, ... }|
|<---------------------------------------------|
| |
| [7] sfunc=n routePath:P41 (session_key_2)| ← fetch profile image
| { imageHash } |
|--------------------------------------------->|
| { profileImage (base64 JPEG) } |
|<---------------------------------------------|
| |
| [8] sfunc=n routePath:A42 (session_key_2)| ← OTP verify (regular login)
| { otp, uname, otpType } |
|--------------------------------------------->|
| { ... session established ... } |
|<---------------------------------------------|
```
---
## Step-by-Step Reference
### [0] Initial Key Exchange — `sfunc=r`
**Key**: `DEFAULT_KEY = 8M3L9SBF1AC4FRE56788M3L9SBF1AC4FRE5678`
**Request body** (inner `data` field):
```json
{
"cmod": "<G^A_VALUE mod P_VALUE as decimal string>",
"appId": "IOS17.2-<15 random chars>",
"routePath": "S40",
"sodium": "<random 20-bit int>",
"xxid": "<random 40-bit int>"
}
```
**Full request** (outer wrapper, encrypted together):
```json
{ "sfunc": "r", "data": { ...above... } }
```
**Response** (decrypted with DEFAULT_KEY):
```json
{
"success": true,
"reasonCode": "201",
"reasonText": "Key generated successfully.",
"smod": "<server DH public key>",
"nonceGenerator": "<instruction string>",
"xxid": "<session token — carry for all subsequent calls>",
"sodium": "<server random>",
"encMethod": 2
}
```
After this step:
- Derive `session_key_1 = derive_session_key(smod)`
- Save `xxid` and `nonceGenerator`
---
### [1] Get Auth Type — `sfunc=n`, `routePath: A44`
**Key**: `session_key_1`
**Request** (encrypted):
```json
{
"sfunc": "n",
"xxid": "<session xxid>",
"data": {
"uname": "<username>",
"nonce": "<computed from nonceGenerator>",
"appId": "<appId>",
"sodium": "<random 20-bit int>",
"routePath": "A44",
"xxid": "<session xxid>"
}
}
```
**Response**:
```json
{
"success": true,
"reasonCode": "108",
"reasonText": "Auth type retrieved!",
"data": [
{
"loginType": "1",
"userSalt": "<server salt for password hashing>"
}
]
}
```
---
### [2] Device Registration Init — `sfunc=n`, `routePath: C41`
First-time only. Registers this device+account pair.
**Key**: `session_key_1`
**Request**:
```json
{
"sfunc": "n",
"xxid": "<session xxid>",
"data": {
"uname": "<username>",
"pgf03": "<salted password hash — see below>",
"clientSalt": "<random 32-char string>",
"nonce": "<nonce>",
"appId": "<appId>",
"sodium": "<random>",
"routePath": "C41",
"xxid": "<session xxid>"
}
}
```
**Response**:
```json
{
"success": true,
"reasonCode": "201",
"reasonText": "Registration Initialization Successfully.",
"primaryOTPType": "3",
"roleName": "Consumer Premium",
"otpTypes": [2, 3],
"fullName": "<user's full name>",
"lastLoginTime": "<datetime>",
"customerImgHash": "<hash>"
}
```
---
### [3] OTP Verification (Registration) — `sfunc=n`, `routePath: C42`
**Key**: `session_key_1`
**Request**:
```json
{
"sfunc": "n",
"xxid": "<session xxid>",
"data": {
"otp": "<6-digit OTP>",
"uname": "<username>",
"otpType": "3",
"nonce": "<nonce>",
"appId": "<appId>",
"sodium": "<random>",
"routePath": "C42",
"xxid": "<session xxid>"
}
}
```
**Response**:
```json
{
"success": true,
"reasonCode": "101",
"reasonText": "registration success",
"data": [
{
"appId": "<appId>",
"createdDate": "<datetime>",
"key1": "<device credential 1 — store securely>",
"key2": "<device credential 2 — store securely>",
"encryptionMethod": "2",
"appAgent": "android/1.0"
}
]
}
```
`key1` and `key2` are long-lived device credentials. `key1` is the Blowfish key
for the next `sfunc=i` call. `key2` is sent as plaintext in the outer wrapper of
that call.
---
### [4] Authenticated Key Exchange — `sfunc=i`
Second DH exchange, authenticated with the device credentials.
**Key**: `key1`
**Request** (outer wrapper includes `key2`):
```json
{
"sfunc": "i",
"key2": "<key2 from registration>",
"data": {
"cmod": "<G^A_VALUE mod P_VALUE>",
"appId": "<appId>",
"routePath": "S40",
"sodium": "<random 20-bit int>",
"xxid": "<random 40-bit int>"
}
}
```
**Response** (decrypted with `key1`):
```json
{
"success": true,
"reasonCode": "201",
"reasonText": "Key generated successfully.",
"smod": "<new server DH public key>",
"nonceGenerator": "<new instruction string>",
"xxid": "<new session token>",
"encMethod": 2
}
```
After this step:
- Derive `session_key_2 = derive_session_key(smod)`
- Replace `xxid` and `nonceGenerator` with new values
---
### [5] Get Auth Type — `sfunc=n`, `routePath: A44`
Same as step [1] but with `session_key_2`. Fetches `userSalt` for password hashing.
---
### [6] Regular Login Init — `sfunc=n`, `routePath: A41`
**Key**: `session_key_2`
**Request**:
```json
{
"sfunc": "n",
"xxid": "<session xxid>",
"data": {
"uname": "<username>",
"pgf03": "<salted password hash>",
"clientSalt": "<random 32-char string>",
"pmodTime": 0,
"requireBankData": 1,
"nonce": "<nonce>",
"appId": "<appId>",
"sodium": "<random>",
"routePath": "A41",
"xxid": "<session xxid>"
}
}
```
**Response**:
```json
{
"success": true,
"reasonCode": "104",
"reasonText": "Initialization Successful",
"primaryOTPType": "3",
"roleName": "Consumer Premium",
"otpTypes": [2, 3],
"email": "<masked email>",
"uuid": "<uuid1>",
"uuid2": "<uuid2>",
"xxid": "<xxid>"
}
```
---
### [7] Get Profile Image — `sfunc=n`, `routePath: P41`
**Key**: `session_key_2`
**Request**:
```json
{
"sfunc": "n",
"xxid": "<session xxid>",
"data": {
"imageHash": "<customerImgHash from step 2/6>",
"nonce": "<nonce>",
"appId": "<appId>",
"sodium": "<random>",
"routePath": "P41",
"xxid": "<session xxid>"
}
}
```
**Response**:
```json
{
"success": true,
"reasonCode": "201",
"reasonText": "Image Found",
"profileImage": "<base64 JPEG>"
}
```
---
### [8] OTP Verification (Login) — `sfunc=n`, `routePath: A42`
**Key**: `session_key_2`
**Request**:
```json
{
"sfunc": "n",
"xxid": "<session xxid>",
"data": {
"otp": "<6-digit OTP>",
"uname": "<username>",
"otpType": "3",
"nonce": "<nonce>",
"appId": "<appId>",
"sodium": "<random>",
"routePath": "A42",
"xxid": "<session xxid>"
}
}
```
---
## Password Hashing (`pgf03`)
The password is never sent in plaintext. The scheme prevents replay attacks by
mixing in a server-provided salt and a client-generated random salt.
```
pgf03 = SHA256( clientSalt + SHA256( userSalt + SHA256( password ) ) )
```
All SHA256 values are uppercase hex strings.
```python
import hashlib
def compute_pgf03(password: str, user_salt: str, client_salt: str) -> str:
h1 = hashlib.sha256(password.encode()).hexdigest().upper()
h2 = hashlib.sha256((user_salt + h1).encode()).hexdigest().upper()
h3 = hashlib.sha256((client_salt + h2).encode()).hexdigest().upper()
return h3
```
---
## Route Paths Summary
| routePath | sfunc | Description |
|---|---|---|
| `S40` | `r` or `i` | DH key exchange |
| `A44` | `n` | Get auth type / userSalt |
| `A41` | `n` | Regular login initialization |
| `A42` | `n` | OTP verification (regular login) |
| `C41` | `n` | Device registration initialization |
| `C42` | `n` | OTP verification (registration) |
| `P41` | `n` | Get profile image |
| `P40` | `n` | Update profile image |
| `P42` | `n` | Delete profile image |
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env python
"""
Faisanet MIB API decryption tool.
Usage:
./decrypt.py <encrypted_base64>
./decrypt.py <encrypted_base64> --key <blowfish_key>
./decrypt.py <encrypted_base64> --smod <server_dh_public_key>
"""
import sys
import json
import base64
import hashlib
import argparse
from urllib.parse import unquote
try:
from Crypto.Cipher import Blowfish
from Crypto.Util.Padding import unpad
except ImportError:
print("Missing dependency. Run: pip install pycryptodome", file=sys.stderr)
sys.exit(1)
DEFAULT_KEY = '8M3L9SBF1AC4FRE56788M3L9SBF1AC4FRE5678'
# Hardcoded DH parameters from app
# NOTE: the variable names in the app's source are misleading —
# A_VALUE is the exponent (client private key), the shorter number
# P_VALUE is the prime modulus, the longer number
A_VALUE = 1563516802667282387226490351799736881442299778484610378722158765594241028592123324764949712696577
P_VALUE = 2410312426921032588552076022197566074856950548502459942654116941958108831682612228890093858261341614673227141477904012196503648957050582631942730706805009223062734745341073406696246014589361659774041027169249453200378729434170325843778659198143763193776859869524088940195577346119843545301547043747207749969763750084308926339295559968882457872412993810129130294592999947926365264059284647209730384947211681434464714438488520940127459844288859336526896320919633919
def derive_session_key(smod: int) -> str:
shared_secret = pow(smod, A_VALUE, P_VALUE)
sha256_hex = hashlib.sha256(str(shared_secret).encode()).hexdigest().upper()
return base64.b64encode(bytes.fromhex(sha256_hex)).decode()
def decrypt(ciphertext: str, key: str) -> str:
# Strip URL encoding if present
ciphertext = unquote(ciphertext).strip()
key_bytes = key.encode('latin-1')
ct_bytes = base64.b64decode(ciphertext)
cipher = Blowfish.new(key_bytes, Blowfish.MODE_ECB)
plaintext = unpad(cipher.decrypt(ct_bytes), Blowfish.block_size)
return plaintext.decode('utf-8')
def main():
parser = argparse.ArgumentParser(description='Decrypt Faisanet MIB API payloads')
parser.add_argument('ciphertext', help='Base64 (or URL-encoded) encrypted payload')
parser.add_argument('--key', default=None, help='Blowfish key (default: hardcoded DEFAULT_KEY)')
parser.add_argument('--smod', default=None, help='Server DH public key (decimal) to derive session key')
args = parser.parse_args()
if args.smod:
key = derive_session_key(int(args.smod))
print(f"[derived session key] {key}", file=sys.stderr)
elif args.key:
key = args.key
else:
key = DEFAULT_KEY
print(f"[using DEFAULT_KEY]", file=sys.stderr)
try:
plaintext = decrypt(args.ciphertext, key)
except Exception as e:
print(f"Decryption failed: {e}", file=sys.stderr)
sys.exit(1)
# Pretty-print if JSON
try:
print(json.dumps(json.loads(plaintext), indent=2))
except Exception:
print(plaintext)
if __name__ == '__main__':
main()