Implement bank transfer app flows
This commit is contained in:
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user