Implement bank transfer app flows

This commit is contained in:
2026-06-08 00:03:57 +05:00
parent ef877217ad
commit 9b281d48a7
127 changed files with 8008 additions and 90 deletions
@@ -0,0 +1,137 @@
import Foundation
// Mirrors the Kotlin MibContactsClient exactly.
// Endpoint: POST https://faisamobilex-wv.mib.com.mv/ajaxBeneficiary/main
// Auth: Cookie header with xxid, IBSID, mbnonce, mbmodel (same session established by MibLoginFlow).
actor MibContactsClient {
private let wvBase = "https://faisamobilex-wv.mib.com.mv"
private let urlSession: URLSession = {
let cfg = URLSessionConfiguration.ephemeral
cfg.timeoutIntervalForRequest = 30
cfg.timeoutIntervalForResource = 60
cfg.httpCookieAcceptPolicy = .never
cfg.httpShouldSetCookies = false
return URLSession(configuration: cfg)
}()
private let session: MibSession
init(session: MibSession) {
self.session = session
}
// MARK: - Public
func fetchContacts(loginTag: String) async throws -> [BankContact] {
var all: [BankContact] = []
var page = 1
let pageSize = 100
while true {
let start = (page - 1) * pageSize + 1
let end = page * pageSize
let formFields: [(String, String)] = [
("page", String(page)),
("search", ""),
("searchCategoryId", "0"),
("benefType", "A"),
("sortBenef", "name"),
("sortDir", "asc"),
("start", String(start)),
("end", String(end)),
("includeCount", "1"),
]
let (contacts, totalCount) = try await fetchPage(formFields: formFields, loginTag: loginTag)
all.append(contentsOf: contacts)
if all.count >= totalCount || contacts.isEmpty { break }
page += 1
}
return all
}
// MARK: - Private
private func fetchPage(formFields: [(String, String)], loginTag: String) async throws -> ([BankContact], Int) {
let url = URL(string: "\(wvBase)/ajaxBeneficiary/main")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
request.setValue(cookieHeader, forHTTPHeaderField: "Cookie")
request.setValue(
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148",
forHTTPHeaderField: "User-Agent"
)
request.setValue("XMLHttpRequest", forHTTPHeaderField: "X-Requested-With")
request.setValue("*/*", forHTTPHeaderField: "Accept")
request.setValue(wvBase, forHTTPHeaderField: "Origin")
request.setValue("\(wvBase)/beneficiary?dashurl=1", forHTTPHeaderField: "Referer")
var allowed = CharacterSet.alphanumerics
allowed.insert(charactersIn: "-._~")
let body = formFields.map { k, v in
"\(k.addingPercentEncoding(withAllowedCharacters: allowed) ?? k)=\(v.addingPercentEncoding(withAllowedCharacters: allowed) ?? v)"
}.joined(separator: "&")
request.httpBody = Data(body.utf8)
let (data, response) = try await urlSession.data(for: request)
let code = (response as? HTTPURLResponse)?.statusCode ?? 0
if code == 419 { throw MibError.sessionExpired }
if code >= 500 { throw MibError.networkError("HTTP \(code)") }
guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw MibError.invalidResponse
}
let totalCount = obj["total_count"] as? Int
?? Int(obj["total_count"] as? String ?? "") ?? 0
guard let arr = obj["data"] as? [[String: Any]] else {
return ([], totalCount)
}
let contacts = arr.compactMap { d -> BankContact? in
let account = d["benefAccount"] as? String ?? ""
guard !account.isEmpty else { return nil }
let benefNo = d["benefNo"] as? String ?? account
// Android: benefType "I"=Internal(MIB), "L"=Local(IPS), "S"=Swift
let rawType = d["benefType"] as? String ?? "I"
let benefType: String
switch rawType {
case "I": benefType = "MIB"
case "L": benefType = "LOCAL"
case "S": benefType = "SWIFT"
default: benefType = rawType
}
return BankContact(
id: "MIB_\(benefNo)_\(loginTag)",
benefNo: benefNo,
benefName: d["benefName"] as? String ?? "",
benefNickName: (d["benefNickName"] as? String).flatMap { $0.isEmpty ? nil : $0 },
benefAccount: account,
benefType: benefType,
bankColor: d["bankColor"] as? String,
benefBankName: d["benefBankName"] as? String,
bankCode: d["bankCode"] as? String,
benefStatus: d["benefStatus"] as? String ?? "Active",
transferCyDesc: d["transferCyDesc"] as? String ?? "MVR",
customerImgHash: (d["customerImgHash"] as? String).flatMap { $0.isEmpty || $0 == "null" ? nil : $0 },
benefCategoryId: d["benefCategoryID"] as? String,
profileId: nil,
source: "MIB"
)
}
return (contacts, totalCount)
}
private var cookieHeader: String {
"mbmodel=IOS-1.0; xxid=\(session.xxid); IBSID=\(session.xxid); mbnonce=\(session.nonceGenerator); time-tracker=597"
}
}
+289
View File
@@ -0,0 +1,289 @@
import CommonCrypto
import CryptoKit
import Foundation
// MARK: - BigUInt
struct BigUInt {
private(set) var words: [UInt32] // little-endian (index 0 = least significant)
static let zero = BigUInt(words: [0])
static let one = BigUInt(words: [1])
init(words: [UInt32]) {
self.words = words
normalize()
}
init(decimal string: String) {
var result = BigUInt.zero
let ten = BigUInt(words: [10])
for ch in string {
guard let d = ch.wholeNumberValue else { continue }
result = result.multiplied(by: ten)
result = result.adding(BigUInt(words: [UInt32(d)]))
}
self = result
}
private mutating func normalize() {
while words.count > 1 && words.last == 0 { words.removeLast() }
}
var isZero: Bool { words.count == 1 && words[0] == 0 }
var isOdd: Bool { words[0] & 1 == 1 }
var bitWidth: Int {
let n = words.count
guard n > 0 else { return 0 }
let top = words[n - 1]
if top == 0 { return max(0, (n - 1) * 32) }
return (n - 1) * 32 + (32 - top.leadingZeroBitCount)
}
func bit(_ index: Int) -> Bool {
let word = index / 32
let bit = index % 32
guard word < words.count else { return false }
return (words[word] >> bit) & 1 == 1
}
// MARK: Comparison
static func < (lhs: BigUInt, rhs: BigUInt) -> Bool {
if lhs.words.count != rhs.words.count {
return lhs.words.count < rhs.words.count
}
for i in stride(from: lhs.words.count - 1, through: 0, by: -1) {
if lhs.words[i] != rhs.words[i] { return lhs.words[i] < rhs.words[i] }
}
return false
}
static func == (lhs: BigUInt, rhs: BigUInt) -> Bool { lhs.words == rhs.words }
// MARK: Addition
func adding(_ other: BigUInt) -> BigUInt {
let maxLen = max(words.count, other.words.count)
var result = [UInt32](repeating: 0, count: maxLen + 1)
var carry: UInt64 = 0
for i in 0..<maxLen {
let a: UInt64 = i < words.count ? UInt64(words[i]) : 0
let b: UInt64 = i < other.words.count ? UInt64(other.words[i]) : 0
let sum = a + b + carry
result[i] = UInt32(sum & 0xFFFFFFFF)
carry = sum >> 32
}
result[maxLen] = UInt32(carry)
return BigUInt(words: result)
}
// MARK: Subtraction (self >= other assumed)
func subtracting(_ other: BigUInt) -> BigUInt {
var result = words
var borrow: Int64 = 0
for i in 0..<result.count {
let b: Int64 = i < other.words.count ? Int64(other.words[i]) : 0
let diff = Int64(result[i]) - b - borrow
if diff < 0 {
result[i] = UInt32(bitPattern: Int32(truncatingIfNeeded: diff &+ 0x1_0000_0000))
borrow = 1
} else {
result[i] = UInt32(diff)
borrow = 0
}
}
return BigUInt(words: result)
}
// MARK: Multiplication
func multiplied(by other: BigUInt) -> BigUInt {
let n = words.count
let m = other.words.count
var result = [UInt32](repeating: 0, count: n + m)
for i in 0..<n {
var carry: UInt64 = 0
for j in 0..<m {
let prod = UInt64(words[i]) * UInt64(other.words[j]) + UInt64(result[i + j]) + carry
result[i + j] = UInt32(prod & 0xFFFFFFFF)
carry = prod >> 32
}
result[i + m] += UInt32(carry)
}
return BigUInt(words: result)
}
// MARK: Modulo (binary shift-subtract)
func modulo(_ divisor: BigUInt) -> BigUInt {
if divisor.isZero { return .zero }
if self < divisor { return self }
var remainder = BigUInt.zero
let totalBits = bitWidth
for i in stride(from: totalBits - 1, through: 0, by: -1) {
remainder = remainder.shiftedLeft1()
if bit(i) {
remainder.words[0] |= 1
}
if !(remainder < divisor) {
remainder = remainder.subtracting(divisor)
}
}
return remainder
}
private func shiftedLeft1() -> BigUInt {
var result = [UInt32](repeating: 0, count: words.count + 1)
var carry: UInt32 = 0
for i in 0..<words.count {
let shifted = (UInt64(words[i]) << 1) | UInt64(carry)
result[i] = UInt32(shifted & 0xFFFFFFFF)
carry = UInt32(shifted >> 32)
}
result[words.count] = carry
return BigUInt(words: result)
}
// MARK: ModPow right-to-left binary
func modPow(exp: BigUInt, mod: BigUInt) -> BigUInt {
if mod == .one { return .zero }
var result = BigUInt.one
var base = modulo(mod)
var e = exp
while !e.isZero {
if e.isOdd {
result = result.multiplied(by: base).modulo(mod)
}
e = e.shiftedRight1()
base = base.multiplied(by: base).modulo(mod)
}
return result
}
private func shiftedRight1() -> BigUInt {
var result = words
var borrow: UInt32 = 0
for i in stride(from: result.count - 1, through: 0, by: -1) {
let new = (result[i] >> 1) | (borrow << 31)
borrow = result[i] & 1
result[i] = new
}
return BigUInt(words: result)
}
// MARK: Decimal String (O(n) per digit via UInt64 carry)
var decimalString: String {
if isZero { return "0" }
var digits: [Character] = []
var remaining = words
while !(remaining.count == 1 && remaining[0] == 0) {
var rem: UInt64 = 0
for i in stride(from: remaining.count - 1, through: 0, by: -1) {
let cur = (rem << 32) | UInt64(remaining[i])
remaining[i] = UInt32(cur / 10)
rem = cur % 10
}
digits.append(Character(String(rem)))
while remaining.count > 1 && remaining.last == 0 { remaining.removeLast() }
}
return String(digits.reversed())
}
}
// MARK: - MibCrypto
enum MibCrypto {
static let defaultKey = "8M3L9SBF1AC4FRE56788M3L9SBF1AC4FRE5678"
// DH exponent A
private static let dhA = BigUInt(decimal: "1563516802667282387226490351799736881442299778484610378722158765594241028592123324764949712696577")
// DH modulus P
private static let dhP = BigUInt(decimal: "2410312426921032588552076022197566074856950548502459942654116941958108831682612228890093858261341614673227141477904012196503648957050582631942730706805009223062734745341073406696246014589361659774041027169249453200378729434170325843778659198143763193776859869524088940195577346119843545301547043747207749969763750084308926339295559968882457872412993810129130294592999947926365264059284647209730384947211681434464714438488520940127459844288859336526896320919633919")
// Pre-computed: 2^A mod P (sent to server in every DH key exchange)
static let cmod = "2301533261465719294935473752300816828871347570489984010719302185643154930578030858802510266223676458248149736055131586081546441805822467879511077637292661430926459449504165353261883687521672133746481188567585259995381323120449147180627189549882525129015625540437713248150022924125681681822594907090716721855161928482983925605401801685462643917325004120294159320617793870605573541358655167992228254033496751384810855107557385690184061912872019448968632814193704749"
// Derives session key: Base64(SHA256(smod^A mod P as decimal string))
static func deriveSessionKey(_ smod: String) -> String {
let smodInt = BigUInt(decimal: smod)
let shared = smodInt.modPow(exp: dhA, mod: dhP)
let secretStr = shared.decimalString
let sha = SHA256.hash(data: Data(secretStr.utf8))
return Data(sha).base64EncodedString()
}
// MARK: Blowfish/ECB/PKCS7 Encrypt
static func encrypt(_ json: [String: Any], key: String) throws -> String {
guard let jsonData = try? JSONSerialization.data(withJSONObject: json) else {
throw MibError.encryptionFailed
}
let keyBytes = key.data(using: .isoLatin1) ?? Data(key.utf8)
let encrypted = try blowfishProcess(data: jsonData, key: keyBytes, operation: CCOperation(kCCEncrypt))
return encrypted.base64EncodedString()
}
static func decrypt(_ base64: String, key: String) throws -> [String: Any] {
guard let cipherData = Data(base64Encoded: base64, options: .ignoreUnknownCharacters) else {
throw MibError.decryptionFailed
}
let keyBytes = key.data(using: .isoLatin1) ?? Data(key.utf8)
let plainData = try blowfishProcess(data: cipherData, key: keyBytes, operation: CCOperation(kCCDecrypt))
guard let json = try? JSONSerialization.jsonObject(with: plainData) as? [String: Any] else {
throw MibError.decryptionFailed
}
return json
}
private static func blowfishProcess(data: Data, key: Data, operation: CCOperation) throws -> Data {
var outLen = 0
let bufLen = data.count + kCCBlockSizeBlowfish
var outBuf = [UInt8](repeating: 0, count: bufLen)
let status = data.withUnsafeBytes { dataPtr in
key.withUnsafeBytes { keyPtr in
CCCrypt(
operation,
CCAlgorithm(kCCAlgorithmBlowfish),
CCOptions(kCCOptionECBMode | kCCOptionPKCS7Padding),
keyPtr.baseAddress, key.count,
nil,
dataPtr.baseAddress, data.count,
&outBuf, bufLen,
&outLen
)
}
}
guard status == kCCSuccess else {
throw operation == CCOperation(kCCEncrypt) ? MibError.encryptionFailed : MibError.decryptionFailed
}
return Data(outBuf[0..<outLen])
}
// MARK: SHA-256 helpers
static func hashPassword(_ password: String) -> String {
sha256Upper(password)
}
static func sha256Upper(_ input: String) -> String {
let hash = SHA256.hash(data: Data(input.utf8))
return hash.map { String(format: "%02X", $0) }.joined()
}
// pgf03 = SHA256(clientSalt + SHA256(passwordHash + userSalt))
static func computePgf03(passwordHash: String, userSalt: String, clientSalt: String) -> String {
let inner = sha256Upper(passwordHash + userSalt)
return sha256Upper(clientSalt + inner)
}
}
@@ -0,0 +1,80 @@
import Foundation
// Mirrors Kotlin MibFinancingClient exactly.
// Fetches the /financing?dashurl=1 HTML page and parses finance-card-holder data attributes.
actor MibFinancingClient {
private let wvBase = "https://faisamobilex-wv.mib.com.mv"
private let session: MibSession
private let urlSession: URLSession = {
let cfg = URLSessionConfiguration.ephemeral
cfg.timeoutIntervalForRequest = 30
cfg.timeoutIntervalForResource = 60
return URLSession(configuration: cfg)
}()
init(session: MibSession) {
self.session = session
}
func fetchFinancing() async throws -> [MibFinanceDeal] {
var req = URLRequest(url: URL(string: "\(wvBase)/financing?dashurl=1")!)
req.setValue(cookieHeader, forHTTPHeaderField: "Cookie")
req.setValue(
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148",
forHTTPHeaderField: "User-Agent"
)
req.setValue("mv.com.mib.faisamobilex", forHTTPHeaderField: "X-Requested-With")
let (data, response) = try await urlSession.data(for: req)
let code = (response as? HTTPURLResponse)?.statusCode ?? 0
if code == 419 { throw MibError.sessionExpired }
if code >= 500 { throw MibError.networkError("HTTP \(code)") }
guard let html = String(data: data, encoding: .utf8) else { return [] }
return parseHtml(html)
}
// MARK: - HTML parsing (mirrors Kotlin regex approach)
private func parseHtml(_ html: String) -> [MibFinanceDeal] {
guard let cardRe = try? NSRegularExpression(pattern: #"finance-card-holder[^>]+>"#),
let attrRe = try? NSRegularExpression(pattern: #"data-(\w+)\s*=\s*"([^"]*)""#) else {
return []
}
let ns = html as NSString
return cardRe.matches(in: html, range: NSRange(location: 0, length: ns.length)).compactMap { m in
let cardStr = ns.substring(with: m.range)
let cardNS = cardStr as NSString
var attrs: [String: String] = [:]
attrRe.matches(in: cardStr, range: NSRange(location: 0, length: cardNS.length)).forEach { a in
let key = cardNS.substring(with: a.range(at: 1))
let val = cardNS.substring(with: a.range(at: 2))
attrs[key] = val
}
guard let dealNo = attrs["dealNo"] else { return nil }
return MibFinanceDeal(
dealNo: dealNo,
productDesc: attrs["productDesc"] ?? "",
dealStatus: attrs["dealStatus"] ?? "",
statusDesc: attrs["statusDesc"] ?? "",
dealAmount: Double(attrs["dealAmount"] ?? "") ?? 0,
paidAmount: Double(attrs["paidAmount"] ?? "") ?? 0,
outstandingAmount: Double(attrs["outstandingAmount"] ?? "") ?? 0,
dealDate: attrs["dealDate"] ?? "",
overdueAmount: Double(attrs["overdueAmount"] ?? "") ?? 0,
installmentAmount: Double(attrs["installmentAmount"] ?? "") ?? 0,
noOfInstallments: Int(attrs["noOfInstallments"] ?? "") ?? 0,
lastPaidDate: attrs["lastPaidDate"] ?? "",
lastPayAmount: Double(attrs["lastPayAmount"] ?? "") ?? 0,
currency: attrs["curCodeDesc"] ?? "MVR"
)
}
}
private var cookieHeader: String {
"mbmodel=IOS-1.0; xxid=\(session.xxid); IBSID=\(session.xxid); mbnonce=\(session.nonceGenerator); time-tracker=597"
}
}
+416
View File
@@ -0,0 +1,416 @@
import Foundation
private struct SessionExpiredError: Error {}
actor MibLoginFlow {
private let baseURL = URL(string: "https://faisanet.mib.com.mv/faisamobilex_smvc/")!
private(set) var lastSession: MibSession?
private(set) var lastProfiles: [MibProfile] = []
var onSessionRefreshed: (@Sendable (MibSession, [MibProfile]) -> Void)?
private var loginId: String = ""
private var storedUsername: String?
private var storedPasswordHash: String?
private var storedOtpSeed: String?
private var isRelogging = false
private let urlSession: URLSession = {
let cfg = URLSessionConfiguration.ephemeral
cfg.timeoutIntervalForRequest = 30
cfg.timeoutIntervalForResource = 60
cfg.httpCookieAcceptPolicy = .never
cfg.httpShouldSetCookies = false
return URLSession(configuration: cfg)
}()
// MARK: - Public entry point
func login(username: String, passwordHash: String, otpSeed: String) async throws -> [BankAccount] {
loginId = username
storedUsername = username
storedPasswordHash = passwordHash
storedOtpSeed = otpSeed
let appId = getOrCreateAppId(for: username)
let key1 = credentialString("mib_\(username)_enc_key1")
let key2 = credentialString("mib_\(username)_enc_key2")
if let k1 = key1, let k2 = key2 {
return try await regularLogin(username: username, passwordHash: passwordHash, appId: appId, key1: k1, key2: k2)
} else {
return try await firstTimeRegistration(username: username, passwordHash: passwordHash, otpSeed: otpSeed, appId: appId)
}
}
// MARK: - First-time registration
private func firstTimeRegistration(
username: String, passwordHash: String, otpSeed: String, appId: String
) async throws -> [BankAccount] {
let (session1, _) = try await initialKeyExchange(appId: appId, encKey: MibCrypto.defaultKey, sfunc: "r")
let userSalt = try await getAuthType(session: session1, username: username)
let clientSalt = MibNonce.randomAlpha(32)
let pgf03 = MibCrypto.computePgf03(passwordHash: passwordHash, userSalt: userSalt, clientSalt: clientSalt)
var regPayload = baseData(session: session1, routePath: "C41")
regPayload["uname"] = username
regPayload["pgf03"] = pgf03
regPayload["clientSalt"] = clientSalt
let regResp = try await doRequest(session: session1, data: regPayload, sfunc: "n")
guard regResp["success"] as? Bool == true else {
throw MibError.serverError(regResp["reasonText"] as? String ?? "Registration init failed")
}
let otp = Totp.generate(otpSeed)
var otpPayload = baseData(session: session1, routePath: "C42")
otpPayload["otp"] = otp
otpPayload["uname"] = username
otpPayload["otpType"] = "3"
let otpResp = try await doRequest(session: session1, data: otpPayload, sfunc: "n")
guard otpResp["success"] as? Bool == true else {
throw MibError.serverError(otpResp["reasonText"] as? String ?? "OTP verification failed")
}
guard let dataArr = otpResp["data"] as? [[String: Any]],
let keyData = dataArr.first,
let key1 = keyData["key1"] as? String,
let key2 = keyData["key2"] as? String else {
throw MibError.invalidResponse
}
try? CredentialStore.shared.save(key1, forKey: "mib_\(username)_enc_key1")
try? CredentialStore.shared.save(key2, forKey: "mib_\(username)_enc_key2")
return try await regularLogin(username: username, passwordHash: passwordHash, appId: appId, key1: key1, key2: key2)
}
// MARK: - Regular login
private func regularLogin(
username: String, passwordHash: String,
appId: String, key1: String, key2: String
) async throws -> [BankAccount] {
let (session2, _) = try await initialKeyExchange(appId: appId, encKey: key1, sfunc: "i", key2: key2)
let userSalt = try await getAuthType(session: session2, username: username)
let clientSalt = MibNonce.randomAlpha(32)
let pgf03 = MibCrypto.computePgf03(passwordHash: passwordHash, userSalt: userSalt, clientSalt: clientSalt)
var loginPayload = baseData(session: session2, routePath: "A41")
loginPayload["uname"] = username
loginPayload["pgf03"] = pgf03
loginPayload["clientSalt"] = clientSalt
loginPayload["pmodTime"] = 0
loginPayload["requireBankData"] = 1
let loginResp = try await doRequest(session: session2, data: loginPayload, sfunc: "n")
guard loginResp["success"] as? Bool == true else {
throw MibError.serverError(loginResp["reasonText"] as? String ?? "Login failed")
}
let profiles = parseProfiles(from: loginResp)
lastSession = session2
lastProfiles = profiles
// Single-profile fast path: server returned balances directly in A41
if loginResp["profileSelected"] as? Bool == true,
let balances = loginResp["accountBalance"] as? [[String: Any]], !balances.isEmpty {
let selectedId = loginResp["selectedProfileId"] as? String ?? ""
if let profile = profiles.first(where: { $0.profileId == selectedId }) ?? profiles.first {
return balances.map { makeAccount(from: $0, profile: profile, loginTag: "mib_\(username)") }
+ makeCards(from: loginResp, profile: profile, loginTag: "mib_\(username)")
}
}
let hidden = hiddenProfileIds(for: username)
let visible = hidden.isEmpty ? profiles : profiles.filter { !hidden.contains($0.profileId) }
return try await fetchAllProfiles(session: session2, profiles: visible, loginTag: "mib_\(username)")
}
// MARK: - Profile fetching
func fetchAllProfiles(session: MibSession, profiles: [MibProfile], loginTag: String) async throws -> [BankAccount] {
var allAccounts: [BankAccount] = []
for profile in profiles {
var payload = baseData(session: session, routePath: "P47")
payload["profileType"] = profile.profileType
payload["profileId"] = profile.profileId
let resp = try await doRequest(session: session, data: payload, sfunc: "n")
guard resp["success"] as? Bool == true,
let balances = resp["accountBalance"] as? [[String: Any]] else { continue }
allAccounts += balances.map { makeAccount(from: $0, profile: profile, loginTag: loginTag) }
allAccounts += makeCards(from: resp, profile: profile, loginTag: loginTag)
}
return allAccounts
}
func switchProfile(session: MibSession, profile: MibProfile) async throws {
var payload = baseData(session: session, routePath: "P47")
payload["profileType"] = profile.profileType
payload["profileId"] = profile.profileId
_ = try await doRequest(session: session, data: payload, sfunc: "n")
}
func fetchProfileImage(session: MibSession, imageHash: String) async throws -> String? {
var payload = baseData(session: session, routePath: "P41")
payload["imageHash"] = imageHash
let resp = try await doRequest(session: session, data: payload, sfunc: "n")
guard resp["success"] as? Bool == true else { return nil }
return (resp["profileImage"] as? String).flatMap { $0.isEmpty ? nil : $0 }
}
// MARK: - DH Key Exchange
private func initialKeyExchange(
appId: String, encKey: String, sfunc: String, key2: String? = nil
) async throws -> (MibSession, String) {
let innerPayload: [String: Any] = [
"cmod": MibCrypto.cmod,
"appId": appId,
"routePath": "S40",
"sodium": MibNonce.randomSodium(),
"xxid": MibNonce.randomXxid()
]
let encrypted = try MibCrypto.encrypt(innerPayload, key: encKey)
var formFields = [("sfunc", sfunc), ("data", encrypted)]
if let k2 = key2 { formFields.append(("key2", k2)) }
let responseStr = try await postForm(fields: formFields)
// Server may return a plain JSON error (not Blowfish-encrypted) for auth/config failures.
let trimmed = responseStr.trimmingCharacters(in: .whitespaces)
let respJson: [String: Any]
if trimmed.hasPrefix("{"),
let plainObj = try? JSONSerialization.jsonObject(with: Data(trimmed.utf8)) as? [String: Any] {
respJson = plainObj
} else {
respJson = try MibCrypto.decrypt(responseStr, key: encKey)
}
guard respJson["success"] as? Bool == true else {
throw MibError.serverError(respJson["reasonText"] as? String ?? "Key exchange failed")
}
let smod = respJson["smod"] as? String ?? ""
let xxid = respJson["xxid"] as? String ?? ""
let nonceGen = respJson["nonceGenerator"] as? String ?? ""
let sessionKey = MibCrypto.deriveSessionKey(smod)
return (MibSession(appId: appId, xxid: xxid, nonceGenerator: nonceGen, sessionKey: sessionKey), xxid)
}
private func getAuthType(session: MibSession, username: String) async throws -> String {
var payload = baseData(session: session, routePath: "A44")
payload["uname"] = username
let resp = try await doRequest(session: session, data: payload, sfunc: "n")
guard let arr = resp["data"] as? [[String: Any]],
let salt = arr.first?["userSalt"] as? String else { throw MibError.invalidResponse }
return salt
}
// MARK: - Request engine with session recovery
private func doRequest(session: MibSession, data: [String: Any], sfunc: String) async throws -> [String: Any] {
do {
return try await sendRequest(session: session, data: data, sfunc: sfunc)
} catch is SessionExpiredError {
guard !isRelogging else { throw MibError.sessionExpired }
isRelogging = true
defer { isRelogging = false }
guard let u = storedUsername, let ph = storedPasswordHash, let os = storedOtpSeed else {
throw MibError.sessionExpired
}
_ = try await login(username: u, passwordHash: ph, otpSeed: os)
guard let newSession = lastSession else { throw MibError.sessionExpired }
onSessionRefreshed?(newSession, lastProfiles)
var retryData = data
retryData["nonce"] = MibNonce.generate(newSession.nonceGenerator)
retryData["appId"] = newSession.appId
retryData["sodium"] = MibNonce.randomSodium()
retryData["xxid"] = newSession.xxid
return try await sendRequest(session: newSession, data: retryData, sfunc: sfunc)
}
}
private func sendRequest(session: MibSession, data: [String: Any], sfunc: String) async throws -> [String: Any] {
let encrypted = try MibCrypto.encrypt(data, key: session.sessionKey)
let formFields = [("xxid", session.xxid), ("sfunc", sfunc), ("data", encrypted)]
let responseStr = try await postForm(fields: formFields)
let trimmed = responseStr.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty { throw SessionExpiredError() }
if trimmed.hasPrefix("{"),
let obj = try? JSONSerialization.jsonObject(with: Data(trimmed.utf8)) as? [String: Any] {
if obj["reasonCode"] as? String == "505" { throw SessionExpiredError() }
return obj
}
return try MibCrypto.decrypt(responseStr, key: session.sessionKey)
}
// MARK: - HTTP
private func postForm(fields: [(String, String)]) async throws -> String {
var request = URLRequest(url: baseURL)
request.httpMethod = "POST"
request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
request.setValue("android/1.0", forHTTPHeaderField: "User-Agent")
request.setValue("application/json", forHTTPHeaderField: "Accept")
// RFC 3986 unreserved chars only ensures + in base64 values is encoded as %2B.
// .urlQueryAllowed leaves + unencoded; servers parsing application/x-www-form-urlencoded
// decode + as a space, silently corrupting any base64 ciphertext that contains it.
var formValueAllowed = CharacterSet.alphanumerics
formValueAllowed.insert(charactersIn: "-._~")
let body = fields.map { k, v in
let ek = k.addingPercentEncoding(withAllowedCharacters: formValueAllowed) ?? k
let ev = v.addingPercentEncoding(withAllowedCharacters: formValueAllowed) ?? v
return "\(ek)=\(ev)"
}.joined(separator: "&")
request.httpBody = Data(body.utf8)
let (data, response) = try await urlSession.data(for: request)
guard let http = response as? HTTPURLResponse else { throw MibError.networkError("No HTTP response") }
if http.statusCode == 419 { throw SessionExpiredError() }
if http.statusCode >= 500 { throw MibError.networkError("Server error HTTP \(http.statusCode)") }
return String(data: data, encoding: .utf8) ?? ""
}
// MARK: - Helpers
private func baseData(session: MibSession, routePath: String) -> [String: Any] {
[
"nonce": MibNonce.generate(session.nonceGenerator),
"appId": session.appId,
"sodium": MibNonce.randomSodium(),
"routePath": routePath,
"xxid": session.xxid
]
}
private func parseProfiles(from json: [String: Any]) -> [MibProfile] {
guard let arr = json["operatingProfiles"] as? [[String: Any]] else { return [] }
return arr.map { p in
MibProfile(
profileId: p["profileId"] as? String ?? "",
customerProfileId: p["customerProfileId"] as? String ?? "",
annexId: p["annexId"] as? String ?? "",
customerId: p["customerId"] as? String ?? "",
name: p["name"] as? String ?? "",
cifType: p["cifType"] as? String ?? "",
profileType: p["profileType"] as? String ?? "",
color: p["color"] as? String ?? "",
customerImage: (p["customerImage"] as? String).flatMap { $0.isEmpty ? nil : $0 }
)
}
}
private func makeAccount(from a: [String: Any], profile: MibProfile, loginTag: String) -> BankAccount {
let accNum = a["accountNumber"] as? String ?? ""
let rawBlocked = a["blockedAmount"] as? String ?? "0.00"
return BankAccount(
id: "MIB_\(accNum)_\(loginTag)",
bank: "MIB",
profileName: profile.name,
profileType: profile.profileType,
productCode: profile.cifType,
accountNumber: accNum,
accountBriefName: a["accountBriefName"] as? String ?? "",
currencyName: a["currencyName"] as? String ?? "MVR",
accountTypeName: a["accountTypeName"] as? String ?? "",
availableBalance: Double(a["availableBalance"] as? String ?? "") ?? 0,
currentBalance: Double(a["currentBalance"] as? String ?? "") ?? 0,
blockedAmount: abs(Double(rawBlocked) ?? 0),
mvrBalance: Double(a["mvrBalance"] as? String ?? ""),
statusDesc: a["statusDesc"] as? String ?? "",
profileImageHash: profile.customerImage,
loginTag: loginTag,
profileId: profile.profileId,
internalId: nil
)
}
private func makeCards(from response: [String: Any], profile: MibProfile, loginTag: String) -> [BankAccount] {
let keys = ["cards", "cardList", "cardDetails", "debitCards", "creditCards", "accountCards"]
let cardArrays = keys.compactMap { response[$0] as? [[String: Any]] }
return cardArrays.flatMap { $0 }.compactMap { makeCardAccount(from: $0, profile: profile, loginTag: loginTag) }
}
private func makeCardAccount(from card: [String: Any], profile: MibProfile, loginTag: String) -> BankAccount? {
let cardId = firstString(card, keys: ["cardId", "id", "cardNo", "cardNumber", "maskedCardNumber"])
let maskedNumber = firstString(card, keys: ["maskedCardNumber", "maskedCardNo", "cardNumber", "cardNo", "cardId"])
guard !cardId.isEmpty || !maskedNumber.isEmpty else { return nil }
let productCode = firstString(card, keys: ["cardType", "productCode", "cardProductCode"])
let typeName = firstString(card, keys: ["cardTypeDesc", "cardDescription", "productName"])
let holder = firstString(card, keys: ["cardHolderName", "holderName", "customerName"])
let status = firstString(card, keys: ["cardStatus", "statusDesc", "status"])
let identifier = cardId.isEmpty ? maskedNumber : cardId
return BankAccount(
id: "MIB_CARD_\(identifier)_\(loginTag)",
bank: "MIB",
profileName: holder.isEmpty ? profile.name : holder,
profileType: "MIB_CARD",
productCode: productCode,
accountNumber: maskedNumber.isEmpty ? identifier : maskedNumber,
accountBriefName: holder,
currencyName: firstString(card, keys: ["currencyName", "currency"]).isEmpty ? "MVR" : firstString(card, keys: ["currencyName", "currency"]),
accountTypeName: typeName.isEmpty ? "MIB Card" : typeName,
availableBalance: doubleValue(card, keys: ["availableBalance", "availableLimit", "balance"]),
currentBalance: doubleValue(card, keys: ["currentBalance", "balance"]),
blockedAmount: 0,
mvrBalance: nil,
statusDesc: status.isEmpty ? "Active" : status,
profileImageHash: profile.customerImage,
loginTag: loginTag,
profileId: profile.profileId,
internalId: cardId.isEmpty ? nil : cardId
)
}
private func firstString(_ dictionary: [String: Any], keys: [String]) -> String {
for key in keys {
if let value = dictionary[key] as? String, !value.isEmpty { return value }
if let value = dictionary[key] as? NSNumber { return value.stringValue }
}
return ""
}
private func doubleValue(_ dictionary: [String: Any], keys: [String]) -> Double {
for key in keys {
if let value = dictionary[key] as? Double { return value }
if let value = dictionary[key] as? NSNumber { return value.doubleValue }
if let value = dictionary[key] as? String, let parsed = Double(value.replacingOccurrences(of: ",", with: "")) { return parsed }
}
return 0
}
private func getOrCreateAppId(for username: String) -> String {
let key = "mib_\(username)_enc_app_id"
if let existing = credentialString(key) { return existing }
let newId = MibNonce.generateAppId()
try? CredentialStore.shared.save(newId, forKey: key)
return newId
}
private func credentialString(_ key: String) -> String? {
CredentialStore.shared.load(forKey: key)
}
private func hiddenProfileIds(for username: String) -> Set<String> {
let key = "mib_\(username)_hidden_profiles"
guard let str = CredentialStore.shared.load(forKey: key) else { return [] }
return Set(str.split(separator: ",").map(String.init))
}
}
+84
View File
@@ -0,0 +1,84 @@
import Foundation
struct MibSession: Codable {
let appId: String
let xxid: String
let nonceGenerator: String
let sessionKey: String
}
struct MibProfile: Codable, Identifiable {
let profileId: String
let customerProfileId: String
let annexId: String
let customerId: String
let name: String
let cifType: String
let profileType: String
let color: String
let customerImage: String?
var id: String { profileId }
}
struct MibCard: Codable, Identifiable {
let cardId: String
let maskedCardNumber: String
let cardStatus: String
let cardType: String
let cardTypeDesc: String
let customerId: String
let phoneNumber: String
let cardHolderName: String
let loginTag: String
var id: String { cardId }
}
struct MibFinanceDeal: Codable, Identifiable {
let dealNo: String
let productDesc: String
let dealStatus: String
let statusDesc: String
let dealAmount: Double
let paidAmount: Double
let outstandingAmount: Double
let dealDate: String
let overdueAmount: Double
let installmentAmount: Double
let noOfInstallments: Int
let lastPaidDate: String
let lastPayAmount: Double
let currency: String
var id: String { dealNo }
}
struct MibTransferResult {
let success: Bool
let trxId: String
let date: String
let errorMessage: String
}
enum MibError: LocalizedError {
case networkError(String)
case serverError(String)
case sessionExpired
case invalidCredentials
case decryptionFailed
case encryptionFailed
case invalidResponse
var errorDescription: String? {
switch self {
case .networkError(let msg): return "Network error: \(msg)"
case .serverError(let msg): return "Server error: \(msg)"
case .sessionExpired: return "Session expired. Please log in again."
case .invalidCredentials: return "Invalid credentials."
case .decryptionFailed: return "Failed to decrypt response."
case .encryptionFailed: return "Failed to encrypt request."
case .invalidResponse: return "Unexpected server response."
}
}
}
+93
View File
@@ -0,0 +1,93 @@
import Foundation
enum MibNonce {
/**
* Generates the nonce string from the nonceGenerator token returned by DH key exchange.
*
* Phase 1: for each dash-separated group, take the first token's digits * random(1-99),
* pad to 5 digits, compute digitSum and lastTwo.
* Phase 2: for each group tokens 1-7, apply the operation letter with carry chain.
* Operations: M=(carry%num)+ds+cumSum, A=carry+num+ds+cumSum,
* S=carry²+num+ds+cumSum, X=carry*num+ds+cumSum, C=carry³+num+ds+cumSum
*/
static func generate(_ nonceGenerator: String) -> String {
let groups = nonceGenerator.split(separator: "-", omittingEmptySubsequences: false).map(String.init)
var paddedList: [String] = []
var lastTwoList: [Int] = []
var digitSumList: [Int] = []
var cumSum = 0
// Phase 1
for group in groups {
let tokens = group.trimmingCharacters(in: .whitespaces).components(separatedBy: " ")
let nStr = tokens[0].filter { $0.isNumber }
let n = Int(nStr) ?? 1
let r = Int.random(in: 1...99)
let product = n * r
let padded = String(format: "%05d", product)
let ds = padded.compactMap { $0.wholeNumberValue }.reduce(0, +)
let lt = Int(padded.suffix(2)) ?? 0
paddedList.append(padded)
lastTwoList.append(lt)
digitSumList.append(ds)
cumSum += ds
}
// Phase 2
var resultGroups: [String] = []
for (i, group) in groups.enumerated() {
let tokens = group.trimmingCharacters(in: .whitespaces).components(separatedBy: " ")
var carry = lastTwoList[i]
let ds = digitSumList[i]
var nonceDigits: [Int] = []
for j in 1...7 {
guard j < tokens.count else { nonceDigits.append(0); continue }
let token = tokens[j]
let op = token.filter { $0.isLetter }
let num = Int(token.filter { $0.isNumber }) ?? 1
let value: Int64
switch op {
case "M": value = Int64(carry % num) + Int64(ds) + Int64(cumSum)
case "A": value = Int64(carry) + Int64(num) + Int64(ds) + Int64(cumSum)
case "S": value = Int64(carry) * Int64(carry) + Int64(num) + Int64(ds) + Int64(cumSum)
case "X": value = Int64(carry) * Int64(num) + Int64(ds) + Int64(cumSum)
case "C": value = Int64(carry) * Int64(carry) * Int64(carry) + Int64(num) + Int64(ds) + Int64(cumSum)
default: value = 0
}
let digit = Int(String(abs(value)).suffix(2)) ?? 0
nonceDigits.append(digit)
carry = digit
}
let digitStr = nonceDigits.map { String(format: "%02d", $0) }.joined(separator: " ")
resultGroups.append("\(paddedList[i]) \(digitStr)")
}
return resultGroups.joined(separator: "-")
}
// Random long in [1_000_000, 16_000_000)
static func randomSodium() -> String {
String(Int64.random(in: 1_000_000..<16_000_000))
}
// Random long in [0, 2^40)
static func randomXxid() -> String {
String(Int64.random(in: 0..<(1 << 40)))
}
// Generates persistent App ID in IOS format: "IOS17.2-{15 alphanumeric chars}"
static func generateAppId() -> String {
let chars = Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789")
let random = (0..<15).map { _ in chars.randomElement()! }
return "IOS17.2-\(String(random))"
}
// Random alphanumeric string of given length
static func randomAlpha(_ length: Int) -> String {
let chars = Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789")
return String((0..<length).map { _ in chars.randomElement()! })
}
}
@@ -0,0 +1,222 @@
import Foundation
struct MibAccountLookupResult: Sendable {
let accountNumber: String
let accountName: String
let bankName: String
let bankCode: String
let aliasId: String?
let network: TransferNetwork
}
// Handles MIB beneficiary lookups and transfer submission via the WebView API domain.
// Session identity requires the Cookie header (mbmodel, xxid, IBSID, mbnonce) on every request.
actor MibTransferClient {
private let wvBase = "https://faisamobilex-wv.mib.com.mv"
private let session: MibSession
private let urlSession: URLSession = {
let cfg = URLSessionConfiguration.ephemeral
cfg.timeoutIntervalForRequest = 30
cfg.timeoutIntervalForResource = 60
cfg.httpCookieAcceptPolicy = .never
cfg.httpShouldSetCookies = false
return URLSession(configuration: cfg)
}()
init(session: MibSession) {
self.session = session
}
// MARK: - Account lookup
func lookupAccount(_ query: String) async throws -> MibAccountLookupResult {
let trimmed = query.trimmingCharacters(in: .whitespaces)
// 17-digit starting with 9 MIB internal
if trimmed.count == 17 && trimmed.hasPrefix("9") {
return try await lookupMibInternal(trimmed)
}
// 13-digit starting with 7 IPS/local (BML etc.)
if trimmed.count == 13 && trimmed.hasPrefix("7") {
return try await lookupIPS(trimmed)
}
// Everything else Favara alias
return try await lookupFavara(trimmed)
}
private func lookupMibInternal(_ number: String) async throws -> MibAccountLookupResult {
let resp = try await postFormJSON(
path: "ajaxBeneficiary/getAccountName",
fields: [("accountNo", number)]
)
guard resp["success"] as? Bool == true else {
throw MibError.serverError(resp["reasonText"] as? String ?? "Account not found")
}
// Server returns accountName at root level or inside data dict/array
let name: String
if let n = resp["accountName"] as? String, !n.isEmpty {
name = n
} else if let dataDict = resp["data"] as? [String: Any],
let n = dataDict["accountName"] as? String {
name = n
} else if let dataArr = resp["data"] as? [[String: Any]],
let n = dataArr.first?["accountName"] as? String {
name = n
} else {
throw MibError.serverError("Account not found")
}
return MibAccountLookupResult(
accountNumber: number,
accountName: name,
bankName: "Maldives Islamic Bank",
bankCode: "2",
aliasId: nil,
network: .mibInternal
)
}
private func lookupIPS(_ number: String) async throws -> MibAccountLookupResult {
// Kotlin field name is benefAccount, not accountNo
let resp = try await postFormJSON(
path: "AjaxAlias/getIPSAccount",
fields: [("benefAccount", number)]
)
guard resp["success"] as? Bool == true else {
throw MibError.serverError(resp["reasonText"] as? String ?? "Account not found")
}
let name = resp["accountName"] as? String
?? (resp["data"] as? [[String: Any]])?.first?["accountName"] as? String
?? "Unknown"
let bankName = resp["bankName"] as? String ?? "Local Bank"
return MibAccountLookupResult(
accountNumber: number,
accountName: name,
bankName: bankName,
bankCode: "3",
aliasId: nil,
network: .local
)
}
private func lookupFavara(_ alias: String) async throws -> MibAccountLookupResult {
// Kotlin field name is aliasName, not alias
let resp = try await postFormJSON(
path: "AjaxAlias/getAlias",
fields: [("aliasName", alias)]
)
guard resp["success"] as? Bool == true else {
throw MibError.serverError(resp["reasonText"] as? String ?? "Alias not found")
}
// Response: { data: { BfyNm, CdtrAcct: { Acct, FinInstnId } } }
guard let dataDict = resp["data"] as? [String: Any] else {
throw MibError.serverError("Alias not found")
}
let name = dataDict["BfyNm"] as? String ?? "Unknown"
let cdtrAcct = dataDict["CdtrAcct"] as? [String: Any]
let accountNum = cdtrAcct?["Acct"] as? String ?? alias
let bic = cdtrAcct?["FinInstnId"] as? String ?? ""
// Determine network based on account number format
let network: TransferNetwork = accountNum.count == 17 && accountNum.hasPrefix("9")
? .mibInternal : .local
let bankCode = network == .mibInternal ? "2" : "3"
return MibAccountLookupResult(
accountNumber: accountNum,
accountName: name,
bankName: bic.isEmpty ? "Maldives Islamic Bank" : bic,
bankCode: bankCode,
aliasId: alias,
network: network
)
}
// MARK: - Transfer
func executeTransfer(
from fromAccount: BankAccount,
to lookup: MibAccountLookupResult,
amount: Double,
currency: String,
purpose: String,
otp: String // caller generates TOTP before entering actor context
) async throws -> MibTransferResult {
let endpoint = lookup.network == .mibInternal
? "ajaxTransfer/transferInternal"
: "ajaxTransfer/transferLocal"
// Kotlin maps currency name ISO 4217 numeric code
let currencyCode: String
switch currency.uppercased() {
case "MVR": currencyCode = "462"
case "USD": currencyCode = "840"
default: currencyCode = currency
}
let fields: [(String, String)] = [
("benefName", lookup.accountName.isEmpty ? "Recipient" : lookup.accountName),
("benefNo", "0"), // Kotlin always sends "0"
("fromAccountNo", fromAccount.accountNumber),
("benefAccountNo", lookup.accountNumber),
("transferCy", currencyCode),
("benefCurrencyCode", currencyCode),
("amount", String(format: "%.2f", amount)),
("bankNo", lookup.bankCode), // "2" MIB internal, "3" local
("purpose", purpose.isEmpty ? "-" : purpose),
("otp", otp),
("otpType", "3")
]
let resp = try await postFormJSON(path: endpoint, fields: fields)
guard resp["success"] as? Bool == true else {
throw MibError.serverError(resp["reasonText"] as? String ?? "Transfer failed")
}
// Response: { success: true, data: [{ trxId, date }] }
let dataArr = resp["data"] as? [[String: Any]]
let trxId = dataArr?.first?["trxId"] as? String
?? resp["trxId"] as? String ?? ""
let date = dataArr?.first?["date"] as? String
?? resp["transactionDate"] as? String ?? ""
return MibTransferResult(success: true, trxId: trxId, date: date, errorMessage: "")
}
// MARK: - HTTP
private var cookieHeader: String {
"mbmodel=IOS-1.0; xxid=\(session.xxid); IBSID=\(session.xxid); mbnonce=\(session.nonceGenerator); time-tracker=597"
}
private func postFormJSON(path: String, fields: [(String, String)]) async throws -> [String: Any] {
guard let url = URL(string: "\(wvBase)/\(path)") else {
throw MibError.networkError("Bad URL: \(path)")
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
request.setValue(
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148",
forHTTPHeaderField: "User-Agent"
)
request.setValue("*/*", forHTTPHeaderField: "Accept")
request.setValue("XMLHttpRequest", forHTTPHeaderField: "X-Requested-With")
request.setValue(cookieHeader, forHTTPHeaderField: "Cookie")
request.setValue(wvBase, forHTTPHeaderField: "Origin")
request.setValue("\(wvBase)/transfer/quick", forHTTPHeaderField: "Referer")
var allowed = CharacterSet.alphanumerics
allowed.insert(charactersIn: "-._~")
let body = fields.map { k, v in
"\(k.addingPercentEncoding(withAllowedCharacters: allowed) ?? k)=\(v.addingPercentEncoding(withAllowedCharacters: allowed) ?? v)"
}.joined(separator: "&")
request.httpBody = Data(body.utf8)
let (data, response) = try await urlSession.data(for: request)
let code = (response as? HTTPURLResponse)?.statusCode ?? 0
if code == 419 { throw MibError.sessionExpired }
if code >= 500 { throw MibError.networkError("HTTP \(code)") }
guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw MibError.invalidResponse
}
return obj
}
}