187 lines
9.8 KiB
Markdown
187 lines
9.8 KiB
Markdown
# Encryption & Anti-Replay
|
|
|
|
Every M-Faisa endpoint at `superapp.ooredoo.mv` mixes three things on the wire:
|
|
|
|
1. **Field-level RSA encryption** for the mobile number (`mdnId` / `mobileNumber` / `userName` / `initiatingMDN` / `identifier`) and the mPIN.
|
|
2. An **anti-replay envelope** (`rndValue` + `csValue`) on every session-scoped form-encoded POST.
|
|
3. A **Gson `htmlSafe` JSON quirk** that turns every literal `=` inside the JSON payload into `=`.
|
|
|
|
This document is the single source of truth for all three. The endpoint docs ([login](02-login.md), [history](03-history.md), [transfer](04-transfer.md)) just reference back here.
|
|
|
|
---
|
|
|
|
## RSA keys
|
|
|
|
Two distinct RSA public keys are used. Both live obfuscated inside `libnative-lib.so` (file offsets given for app version `10.3.1`, `versionCode = 101349`).
|
|
|
|
| Purpose | Bit length | JNI source | Cipher | Output format |
|
|
|---|---|---|---|---|
|
|
| Mobile / mdnId / mobileNumber / userName / initiatingMDN / identifier | 1024 | `getBCPublicKeyImpl()` (obfuscated, see below) | `RSA/ECB/OAEPWithSHA-256AndMGF1Padding` | base64 (`NO_WRAP`) — 172 chars + `=` padding |
|
|
| mPin / rndValue / OTP code | 2048 | `getRSAModulusImpl()` (modulus hex) + `getRSAExponentImpl()` (`"10001"`) | `RSA/ECB/OAEPWithSHA-1AndMGF1Padding` | lowercase hex — 512 chars |
|
|
|
|
The native-function names are deliberately misleading — `getRSAModulusImpl` returns the modulus *as a hex string*, `getRSAExponentImpl` returns the exponent (also hex, always `"10001"`), and `getBCPublicKeyImpl` returns the mobile key in a custom obfuscated form (not a hex modulus).
|
|
|
|
### Mobile key (1024-bit)
|
|
|
|
```
|
|
N = 12504370852445171564296397369840670875526950229356546060611893054268
|
|
22759715800327041313624881501743511944071724521752756122840313665124
|
|
84449720820820404229217064541745811143629538982383390723079478499614
|
|
16062061691167925660329675284421662011306487434253185147285131906525
|
|
8962732556596958868200227678294957694889
|
|
E = 65537
|
|
```
|
|
|
|
The plaintext is **always `"960" + msisdn`** (i.e. the local number prefixed with the Maldives country code, e.g. `9601234567`). The trailing `=` from base64 encoding survives the wire — the server's strict JSON parser actually relies on it.
|
|
|
|
### mPin key (2048-bit)
|
|
|
|
```
|
|
N (hex, 512 chars) = f46921c7091b315f8b9b20ef548deac32ff5b519a2e9ace2f971cc82a341a90eca39…
|
|
…d419274db7b
|
|
E (hex) = 10001 (= 65537)
|
|
```
|
|
|
|
The full hex modulus is the string returned by `SecurityConfig.m()` in the official app; see `MfaisaCrypto.kt` for the value as a decimal `BigInteger`.
|
|
|
|
The plaintext is `pin + <6-character random salt>`. The salt is drawn fresh on every encrypt from `[A-Za-z0-9]` (62-character alphabet) — e.g. `1234aB3xQz`. It exists only to keep the OAEP plaintext above a minimum length; the server discards it after decryption.
|
|
|
|
The same routine is reused for any short string that needs anti-replay (OTP codes, the `rndValue` nonce) — see below.
|
|
|
|
---
|
|
|
|
## Reference implementation (Kotlin)
|
|
|
|
```kotlin
|
|
object MfaisaCrypto {
|
|
private val MOBILE_N = BigInteger("125043708524451715642963973…7694889")
|
|
private val MOBILE_E = BigInteger("65537")
|
|
|
|
private val PIN_N = BigInteger("30853988905151679601945771998…504123")
|
|
private val PIN_E = BigInteger("65537")
|
|
|
|
private val mobileKey by lazy { rsaPublicKey(MOBILE_N, MOBILE_E) }
|
|
private val pinKey by lazy { rsaPublicKey(PIN_N, PIN_E) }
|
|
|
|
private val random = SecureRandom()
|
|
private const val SALT_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
|
|
|
/** RSA-OAEP-SHA256 of "960" + msisdn → base64 NO_WRAP. */
|
|
fun encryptMobile(msisdn: String): String {
|
|
val cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding")
|
|
val params = OAEPParameterSpec("SHA-256", "MGF1", MGF1ParameterSpec.SHA256, PSource.PSpecified.DEFAULT)
|
|
cipher.init(Cipher.ENCRYPT_MODE, mobileKey, params)
|
|
val ct = cipher.doFinal(("960" + msisdn).toByteArray(Charsets.UTF_8))
|
|
return Base64.encodeToString(ct, Base64.NO_WRAP)
|
|
}
|
|
|
|
/** RSA-OAEP-SHA1 of `<value> + <6-char random salt>` → lowercase hex. */
|
|
fun encryptPin(value: String): String {
|
|
val salt = (1..6).map { SALT_ALPHABET[random.nextInt(SALT_ALPHABET.length)] }.joinToString("")
|
|
val cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-1AndMGF1Padding")
|
|
val params = OAEPParameterSpec("SHA-1", "MGF1", MGF1ParameterSpec.SHA1, PSource.PSpecified.DEFAULT)
|
|
cipher.init(Cipher.ENCRYPT_MODE, pinKey, params)
|
|
val ct = cipher.doFinal((value + salt).toByteArray(Charsets.UTF_8))
|
|
return ct.joinToString("") { "%02x".format(it) }
|
|
}
|
|
|
|
private fun rsaPublicKey(n: BigInteger, e: BigInteger): PublicKey =
|
|
KeyFactory.getInstance("RSA").generatePublic(RSAPublicKeySpec(n, e))
|
|
}
|
|
```
|
|
|
|
The OAEP parameter spec **must be passed explicitly**. Android's `Cipher.init(mode, key)` without an `OAEPParameterSpec` defaults the MGF1 digest to SHA-1, even for `RSA/ECB/OAEPWithSHA-256AndMGF1Padding` — the server rejects this mismatch with HTTP 400.
|
|
|
|
---
|
|
|
|
## Reference implementation (Python)
|
|
|
|
```python
|
|
from cryptography.hazmat.primitives.asymmetric import rsa, padding
|
|
from cryptography.hazmat.primitives import hashes
|
|
import base64, secrets, string
|
|
|
|
MOBILE_N = 12504370852445171564…7694889
|
|
PIN_N = 30853988905151679601…504123
|
|
E = 65537
|
|
|
|
def encrypt_mobile(msisdn: str) -> str:
|
|
key = rsa.RSAPublicNumbers(E, MOBILE_N).public_key()
|
|
ct = key.encrypt(
|
|
("960" + msisdn).encode("utf-8"),
|
|
padding.OAEP(mgf=padding.MGF1(hashes.SHA256()), algorithm=hashes.SHA256(), label=None),
|
|
)
|
|
return base64.b64encode(ct).decode("ascii")
|
|
|
|
def encrypt_pin(value: str) -> str:
|
|
salt = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(6))
|
|
key = rsa.RSAPublicNumbers(E, PIN_N).public_key()
|
|
ct = key.encrypt(
|
|
(value + salt).encode("utf-8"),
|
|
padding.OAEP(mgf=padding.MGF1(hashes.SHA1()), algorithm=hashes.SHA1(), label=None),
|
|
)
|
|
return ct.hex()
|
|
```
|
|
|
|
---
|
|
|
|
## Anti-replay envelope: `rndValue` + `csValue`
|
|
|
|
Every session-scoped form-encoded POST (history, transfer, recipient-lookup, …) carries two extra fields the server uses to detect replays:
|
|
|
|
```kotlin
|
|
val offset = (Random.nextInt(5) + 10) xor 0xE // small noise: 0, 2, 3, 4, or 5
|
|
val nonceStr = (System.currentTimeMillis() + offset).toString()
|
|
val rndValue = MfaisaCrypto.encryptPin(nonceStr) // RSA-OAEP-SHA1 of nonceStr+salt, hex
|
|
val csValue = Adler32().apply {
|
|
update((formDataJson + nonceStr).toByteArray(Charsets.UTF_8))
|
|
}.value.toString() // decimal string of the 32-bit Adler32 sum
|
|
```
|
|
|
|
- **`rndValue`** is an `encryptPin(...)` of the timestamp string — the SAME 2048-bit RSA key + OAEP-SHA1 routine documented above. The 6-char salt added by `encryptPin` makes every encrypt non-deterministic.
|
|
- **`csValue`** is `Adler32(formDataJson || nonceStr)` rendered in decimal. The server recomputes this; tampering with `formData` after generating `csValue` will cause rejection.
|
|
- The offset (`(rand0-4 + 10) xor 14` → {0, 2, 3, 4, 5}) is a tiny bit of fixed noise on the timestamp. It exists in the official app's bytecode; the server tolerates any timestamp within a few seconds of `now` anyway.
|
|
|
|
`csValue` is computed from the **pre-html-escape** `formData` JSON (see next section) — i.e. the same string the server reads off the wire.
|
|
|
|
---
|
|
|
|
## HTML-safe Gson `=` escape
|
|
|
|
The official app serialises every JSON payload with Gson in `htmlSafe = true` mode. The relevant side effect for the wire format: any literal `=` character becomes the six-byte sequence `=`.
|
|
|
|
This matters because:
|
|
- The base64 ciphertexts in `mdnId` / `mobileNumber` / `userName` / `initiatingMDN` / `identifier` always end with `=` (1024-bit ⇒ 128-byte output ⇒ 172 chars + 1 padding `=`).
|
|
- The M-Faisa server's JSON parser is strict — sending the literal `=` instead of `=` returns HTTP 400 *even though both are valid JSON*.
|
|
|
|
Match the on-wire form with a simple string replace before sending:
|
|
|
|
```kotlin
|
|
private fun String.matchGsonHtmlSafe(): String =
|
|
replace("\\/", "/").replace("=", "\\u003d")
|
|
```
|
|
|
|
The `\/` → `/` swap covers the corresponding `org.json` quirk that escapes forward slashes by default — also rejected by the server.
|
|
|
|
The same `csValue` / `rndValue` pair must be computed against the **escaped** string (i.e. exactly what's sent on the wire).
|
|
|
|
---
|
|
|
|
## The `getBCPublicKeyImpl` riddle
|
|
|
|
`SecurityConfig.d()` in the JVM bytecode returns a 231-character obfuscated string that, after a `replace("MeWtSVjV3Mj","").trim() + "="` cleanup and base64 decode, *should* yield an ASN.1 `SEQUENCE { INTEGER N, INTEGER E }`. **It does not** — the bytes start with `0x10` instead of the `0x30` ASN.1 SEQUENCE tag, and BouncyCastle's `ASN1Sequence.getInstance(bytes)` throws `unknown tag 16 encountered`.
|
|
|
|
The cleaned form the runtime actually feeds into `j()` starts `MIGJAoGB…AAE=` (i.e. a valid 1024-bit `RSAPublicKey` SEQUENCE). The transformation from the obfuscated `EAABMgAp…` to `MIGJAoGB…` is NOT what the visible Kotlin bytecode does — strongly suggesting Pairip-style VM protection (`com.pairip.licensecheck` is present in the APK) intercepts the call at runtime.
|
|
|
|
**Practical consequence:** the only reliable way to recover the BC public key is to hook the running app and dump `j()`'s input. A Frida script for that is checked in at `tmp/ooredoo_hook.js`. Once dumped, the key matches the `MOBILE_N` value above — so unless Ooredoo rotates keys, the values in `MfaisaCrypto.kt` are stable and the Frida step is one-off.
|
|
|
|
If the keys ever rotate, the **mPin key** (`SecurityConfig.m()` + `SecurityConfig.l()`) can be re-extracted purely statically — both `m()` and `l()` return plain hex strings of the modulus and exponent respectively. Only the mobile key needs Frida.
|
|
|
|
---
|
|
|
|
|
|
|
|
---
|
|
|
|
> **Next →** [Login](02-login.md) | **← Back to** [README](README.md)
|