Files
basedbank/docs/mibapi/API.md

12 KiB
Raw Blame History

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

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

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):

{
  "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):

{
  "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:

{
  "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=rS40 — DH key exchange with DEFAULT_KEY → receive xxid, nonceGenerator, smod → derive session key
  2. sfunc=nA44 — get userSalt for username
  3. sfunc=nC41 — submit pgf03 (computed from password + userSalt + random clientSalt)
  4. sfunc=nC42 — 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=iS40 — DH key exchange with key1, sending key2 as extra form field → derive session key
  2. sfunc=nA44 — get userSalt for username
  3. sfunc=nA41 — submit pgf03 → receive operatingProfiles list
  4. For each profile: sfunc=nP47 — fetch accountBalance array

No A42 step in regular login. OTP is only verified once during first-time registration (C42).

pgf03 formula

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

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.