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