417 lines
19 KiB
Swift
417 lines
19 KiB
Swift
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))
|
|
}
|
|
}
|