# 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 ` + <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)