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 { let key = "mib_\(username)_hidden_profiles" guard let str = CredentialStore.shared.load(forKey: key) else { return [] } return Set(str.split(separator: ",").map(String.init)) } }