forked from shihaam/thijooree
working mib login and list accounts
This commit is contained in:
@@ -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 0–3 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.
|
||||
@@ -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`.
|
||||
|
||||
@@ -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 |
|
||||
@@ -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 |
|
||||
Executable
+80
@@ -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()
|
||||
Reference in New Issue
Block a user