All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 4s
235 lines
8.7 KiB
Markdown
235 lines
8.7 KiB
Markdown
# Encryption, Key Exchange & Nonce
|
||
|
||
All traffic to the encrypted API (`faisanet.mib.com.mv`) uses Blowfish encryption. This document covers the cipher, the DH key exchange that derives the session key, and the nonce algorithm required by every request.
|
||
|
||
---
|
||
|
||
## Cipher
|
||
|
||
- **Algorithm**: Blowfish, ECB mode, PKCS5 padding
|
||
- **Input**: raw UTF-8 bytes of the JSON payload string
|
||
- **Key**: raw UTF-8 bytes of the key string
|
||
- **Output**: base64-encoded ciphertext (URL-encoded when sent as form data)
|
||
|
||
```python
|
||
from Crypto.Cipher import Blowfish
|
||
from Crypto.Util.Padding import pad, unpad
|
||
import base64, json
|
||
from urllib.parse import quote
|
||
|
||
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 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'))
|
||
|
||
def build_request_body(payload: dict, key: str, extra_fields: dict = {}) -> str:
|
||
sfunc = payload.get('sfunc', '')
|
||
encrypted = encrypt(payload, key)
|
||
body = '&'.join(f"{k}={v}" for k, v in extra_fields.items())
|
||
if body:
|
||
return f"{body}&sfunc={sfunc}&data={quote(encrypted)}"
|
||
return f"sfunc={sfunc}&data={quote(encrypted)}"
|
||
```
|
||
|
||
---
|
||
|
||
## Request / Response Transport
|
||
|
||
**Request** — form field `data`:
|
||
```
|
||
sfunc=<value>&data=<url-encoded base64 Blowfish ciphertext>
|
||
```
|
||
|
||
For `sfunc=n` calls, `xxid` must be the **first** field:
|
||
```
|
||
xxid=<session_xxid>&sfunc=n&data=<encrypted>
|
||
```
|
||
|
||
For `sfunc=i` calls, `key2` is a separate unencrypted field:
|
||
```
|
||
key2=<key2>&sfunc=i&data=<encrypted>
|
||
```
|
||
|
||
**Response** — body is raw base64 Blowfish ciphertext (no form encoding); base64-decode then decrypt directly.
|
||
|
||
---
|
||
|
||
## Keys
|
||
|
||
| Key | Value | Used for |
|
||
|---|---|---|
|
||
| `DEFAULT_KEY` | `8M3L9SBF1AC4FRE56788M3L9SBF1AC4FRE5678` | `sfunc=r` key exchange request/response |
|
||
| Session key | DH-derived (44-char base64, 32 bytes) | All subsequent requests |
|
||
|
||
---
|
||
|
||
## Diffie-Hellman Session Key Derivation
|
||
|
||
The session key is derived via a custom DH exchange. All three parameters are hardcoded in the app — this provides no real security since the private key `A` never rotates.
|
||
|
||
> **Note**: The variable names in the app source are **swapped** from their DH role. `A_VALUE` in 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
|
||
```
|
||
|
||
### Steps
|
||
|
||
1. Client sends `cmod = G^A mod P` (as decimal string) in the `sfunc=r` request
|
||
2. Server responds with `smod` (its DH public key, as decimal string)
|
||
3. Client computes shared secret: `shared = smod^A mod P`
|
||
4. Client SHA-256 hashes `str(shared)` → uppercase hex
|
||
5. Client converts the hex string to raw bytes, then base64-encodes → session key
|
||
|
||
```python
|
||
import hashlib, base64
|
||
|
||
def derive_session_key(smod: int) -> str:
|
||
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()
|
||
```
|
||
|
||
The result is always a **44-character base64 string** (32 bytes). It changes every session.
|
||
|
||
---
|
||
|
||
## Nonce Computation
|
||
|
||
Every request after key exchange includes a `nonce` field. It is 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 seed 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 — seed values (one per group):**
|
||
|
||
For each of the 4 groups:
|
||
1. Extract the number from `token[0]` (e.g. `M85` → `85`)
|
||
2. Generate random `r = floor(random() * 99) + 1` (range 1–99 inclusive)
|
||
3. `product = N * r` → zero-pad to 5 digits
|
||
4. `digitSum = sum of digits of padded`
|
||
5. `lastTwo = int(padded[-2:])` (last two digits)
|
||
6. Accumulate `cumSum += digitSum`
|
||
|
||
**Phase 2 — nonce digits (tokens 1–7 of each group):**
|
||
|
||
For each group, start with `carry = lastTwo[i]`:
|
||
|
||
| op letter | Formula |
|
||
|---|---|
|
||
| `M` | `(carry % num) + digitSum + cumSum` |
|
||
| `A` | `carry + num + digitSum + cumSum` |
|
||
| `S` | `(carry * carry) + num + digitSum + cumSum` |
|
||
| `X` | `(carry * num) + digitSum + cumSum` |
|
||
| `C` | `(carry * carry * carry) + num + digitSum + cumSum` |
|
||
|
||
Nonce digit = last two digits of the result. Update `carry = nonceDigit` for the next token.
|
||
|
||
**Output**: join padded seed + 7 two-digit nonce digits per group, 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
|
||
|
||
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
|
||
|
||
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 fields. `sodium` is an independent random integer (~23–24 bit range).
|
||
- The `nonceGenerator` is returned once by the key exchange response and reused for the entire session.
|
||
|
||
---
|
||
|
||
## Known `sfunc` / `routePath` Values
|
||
|
||
| `sfunc` | `routePath` | Description |
|
||
|---|---|---|
|
||
| `r` | `S40` | Initial DH key exchange (DEFAULT_KEY) |
|
||
| `i` | `S40` | Authenticated DH key exchange (key1/key2) |
|
||
| `n` | `A44` | Get auth type / `userSalt` |
|
||
| `n` | `A41` | Regular login initialization |
|
||
| `n` | `A42` | OTP verification (regular login) |
|
||
| `n` | `C41` | Device registration initialization |
|
||
| `n` | `C42` | OTP verification (registration) |
|
||
| `n` | `P41` | Get profile image (by hash) |
|
||
| `n` | `P40` | Update profile image |
|
||
| `n` | `P42` | Delete profile image |
|
||
| `n` | `P47` | Select profile / fetch account balances |
|
||
|
||
---
|
||
|
||
|
||
|
||
---
|
||
|
||
**Next →** [Login Flow](02-login.md)
|