Implement bank transfer app flows

This commit is contained in:
2026-06-08 00:03:57 +05:00
parent ef877217ad
commit 9b281d48a7
127 changed files with 8008 additions and 90 deletions
@@ -0,0 +1,235 @@
import Foundation
private let BML_API_BASE = "https://www.bankofmaldives.com.mv/internetbanking"
private let BML_API_UA = "bml-mobile-banking/348 (Apple; iOS 17.0; iPhone)"
private let BML_API_VERSION = "2.1.44.348"
final class BmlAccountClient {
private let session: BmlSession
private let urlSession: URLSession = {
let cfg = URLSessionConfiguration.default
cfg.timeoutIntervalForRequest = 30
return URLSession(configuration: cfg)
}()
init(session: BmlSession) {
self.session = session
}
// MARK: - Dashboard / Accounts
func fetchAccounts(
loginTag: String,
profileName: String = "Personal",
profileId: String = ""
) async throws -> [BankAccount] {
let data = try await apiGet("/api/mobile/dashboard")
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
root["success"] as? Bool == true,
let payload = root["payload"] as? [String: Any],
let dashboard = payload["dashboard"] as? [[String: Any]] else {
return []
}
return parseDashboard(dashboard, loginTag: loginTag, profileName: profileName, profileId: profileId)
}
// MARK: - Profile check (lightweight ping to verify session)
func checkProfile() async throws {
let data = try await apiGet("/api/mobile/profile")
if let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
root["success"] as? Bool == false {
throw BmlError.sessionExpired
}
}
// MARK: - User info
func fetchUserInfo() async throws -> BmlUserInfo? {
let data = try await apiGet("/api/mobile/userinfo")
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
root["success"] as? Bool == true,
let payload = root["payload"] as? [String: Any],
let user = payload["user"] as? [String: Any] else { return nil }
return BmlUserInfo(
fullName: (user["fullname"] as? String ?? "").trimmingCharacters(in: .whitespaces),
email: (user["email"] as? String ?? "").trimmingCharacters(in: .whitespaces),
mobile: (user["mobile_phone"] as? String ?? "").trimmingCharacters(in: .whitespaces),
customerId: (user["customer_number"] as? String ?? "").trimmingCharacters(in: .whitespaces),
idCard: (user["idcard"] as? String ?? "").trimmingCharacters(in: .whitespaces),
birthdate: (user["birthdate"] as? String ?? "").trimmingCharacters(in: .whitespaces)
)
}
// MARK: - Loan detail
func fetchLoanDetail(internalId: String) async throws -> BmlLoanDetail? {
let data = try await apiGet("/api/mobile/account/\(internalId)")
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
root["success"] as? Bool == true,
let p = root["payload"] as? [String: Any] else { return nil }
return BmlLoanDetail(
loanAmount: p["loanAmount"] as? Double ?? 0,
outstandingAmt: p["outstandingAmt"] as? Double ?? 0,
repayAmount: p["repayAmount"] as? Double ?? 0,
intRate: p["intRate"] as? Double ?? 0,
loanStatus: p["loanStatus"] as? String ?? "",
startDate: p["startDate"] as? String ?? "",
endDate: p["endDate"] as? String ?? "",
noOfRepayOverdue: p["noOfRepayOverdue"] as? Int ?? 0,
overdueAmount: p["overdueAmount"] as? Double ?? 0
)
}
// MARK: - Transfer OTP channels
func fetchTransferChannels() async throws -> [BmlOtpChannel] {
let data = try await apiGet("/api/mobile/transfer")
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
root["success"] as? Bool == true,
let arr = (root["payload"] as? [String: Any])?["transfer"] as? [String: Any],
let channels = arr["otpChannel"] as? [[String: Any]] else { return [] }
return channels.map {
BmlOtpChannel(
channel: $0["channel"] as? String ?? "",
description: $0["description"] as? String ?? "",
masked: $0["masked"] as? String ?? ""
)
}
}
// MARK: - Dashboard parsing
private func parseDashboard(
_ dashboard: [[String: Any]],
loginTag: String,
profileName: String,
profileId: String
) -> [BankAccount] {
var casaAccounts: [BankAccount] = []
var cardAccounts: [BankAccount] = []
var loanAccounts: [BankAccount] = []
for item in dashboard {
let currency = item["currency"] as? String ?? "MVR"
let accountType = item["account_type"] as? String ?? "CASA"
let product = item["product"] as? String ?? ""
let accountNum = Self.stringValue(item["account"])
let status = item["account_status"] as? String ?? "Active"
let internalId = Self.stringValue(item["id"])
switch accountType {
case "CASA":
let avail = item["availableBalance"] as? Double ?? 0
casaAccounts.append(BankAccount(
id: "BML_\(accountNum)_\(loginTag)",
bank: "BML",
profileName: profileName,
profileType: "BML",
productCode: item["product_code"] as? String ?? "",
accountNumber: accountNum,
accountBriefName: item["alias"] as? String ?? "",
currencyName: currency,
accountTypeName: product,
availableBalance: avail,
currentBalance: item["ledgerBalance"] as? Double ?? 0,
blockedAmount: item["lockedAmount"] as? Double ?? 0,
mvrBalance: currency == "MVR" ? avail : nil,
statusDesc: status,
profileImageHash: nil,
loginTag: loginTag,
profileId: profileId,
internalId: internalId
))
case "Loan":
let outstanding = abs(item["availableBalance"] as? Double ?? 0)
loanAccounts.append(BankAccount(
id: "BML_LOAN_\(accountNum)_\(loginTag)",
bank: "BML",
profileName: profileName,
profileType: "BML_LOAN",
productCode: item["product_code"] as? String ?? "",
accountNumber: accountNum,
accountBriefName: item["alias"] as? String ?? "",
currencyName: currency,
accountTypeName: product,
availableBalance: outstanding,
currentBalance: outstanding,
blockedAmount: 0,
mvrBalance: nil,
statusDesc: status,
profileImageHash: nil,
loginTag: loginTag,
profileId: profileId,
internalId: internalId
))
case "Card":
let isPrepaid = item["prepaid_card"] as? Bool ?? false
let isVisible = item["account_visible"] as? Bool ?? false
let cardBalance = item["cardBalance"] as? [String: Any]
let avail = cardBalance?["AvailableLimit"] as? Double ?? 0
let current = cardBalance?["CurrentBalance"] as? Double ?? 0
let cardType: String = isPrepaid ? "BML_PREPAID" : isVisible ? "BML_CREDIT" : "BML_DEBIT"
let alias = (item["alias"] as? String ?? "").isEmpty ? product : (item["alias"] as? String ?? product)
cardAccounts.append(BankAccount(
id: "BML_CARD_\(accountNum)_\(loginTag)",
bank: "BML",
profileName: profileName,
profileType: cardType,
productCode: item["product_code"] as? String ?? "",
accountNumber: accountNum,
accountBriefName: alias,
currencyName: currency,
accountTypeName: product,
availableBalance: avail,
currentBalance: current,
blockedAmount: 0,
mvrBalance: currency == "MVR" ? avail : nil,
statusDesc: status,
profileImageHash: nil,
loginTag: loginTag,
profileId: profileId,
internalId: internalId
))
default:
break
}
}
return casaAccounts + cardAccounts + loanAccounts
}
private static func stringValue(_ value: Any?) -> String {
switch value {
case let string as String:
return string
case let int as Int:
return String(int)
case let int64 as Int64:
return String(int64)
case let double as Double:
return double.rounded() == double ? String(Int64(double)) : String(double)
default:
return ""
}
}
// MARK: - HTTP
private func apiGet(_ path: String) async throws -> Data {
var req = URLRequest(url: URL(string: "\(BML_API_BASE)\(path)")!)
req.setValue("Bearer \(session.accessToken)", forHTTPHeaderField: "Authorization")
req.setValue(BML_API_UA, forHTTPHeaderField: "User-Agent")
req.setValue(BML_API_VERSION, forHTTPHeaderField: "x-app-version")
let (data, response) = try await urlSession.data(for: req)
let code = (response as? HTTPURLResponse)?.statusCode ?? 0
if code == 401 || code == 419 { throw BmlError.sessionExpired }
if code >= 500 { throw BmlError.networkError("Server error HTTP \(code)") }
return data
}
}
@@ -0,0 +1,68 @@
import Foundation
// Mirrors Kotlin BmlContactsClient exactly.
// Endpoint: GET /api/mobile/contacts
// Response: { success: true, payload: [ { id, name, alias, account, status, currency }, ... ] }
final class BmlContactsClient {
private let base = "https://www.bankofmaldives.com.mv/internetbanking"
private let ua = "bml-mobile-banking/348 (Apple; iOS 17.0; iPhone)"
private let ver = "2.1.44.348"
private let bmlSession: BmlSession
private let urlSession: URLSession
init(bmlSession: BmlSession) {
self.bmlSession = bmlSession
let cfg = URLSessionConfiguration.ephemeral
cfg.timeoutIntervalForRequest = 30
self.urlSession = URLSession(configuration: cfg)
}
func fetchContacts(loginTag: String) async throws -> [BankContact] {
var req = URLRequest(url: URL(string: "\(base)/api/mobile/contacts")!)
req.setValue("Bearer \(bmlSession.accessToken)", forHTTPHeaderField: "Authorization")
req.setValue(ua, forHTTPHeaderField: "User-Agent")
req.setValue(ver, forHTTPHeaderField: "x-app-version")
let (data, response) = try await urlSession.data(for: req)
let code = (response as? HTTPURLResponse)?.statusCode ?? 0
if code == 401 || code == 419 { throw BmlError.sessionExpired }
if code >= 500 { throw BmlError.networkError("HTTP \(code)") }
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
root["success"] as? Bool == true,
let payload = root["payload"] as? [[String: Any]] else {
return []
}
return payload.compactMap { item -> BankContact? in
let account = item["account"] as? String ?? ""
guard !account.isEmpty else { return nil }
let id = item["id"] as? Int ?? 0
let name = item["name"] as? String ?? ""
let alias = (item["alias"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? name
let status = item["status"] as? String ?? "S"
let currency = item["currency"] as? String ?? "MVR"
return BankContact(
id: "BML_bml_\(id)_\(loginTag)",
benefNo: "bml_\(id)",
benefName: name,
benefNickName: alias == name ? nil : alias,
benefAccount: account,
benefType: "I",
bankColor: "#0066A1",
benefBankName: "Bank of Maldives",
bankCode: "",
benefStatus: status,
transferCyDesc: currency,
customerImgHash: nil,
benefCategoryId: "BML",
profileId: loginTag,
source: "BML"
)
}
}
}
@@ -0,0 +1,64 @@
import Foundation
// Mirrors Kotlin BmlForeignLimitsClient exactly.
// Endpoint: GET https://app.bankofmaldives.com.mv/api/v2/foreign-limits
final class BmlForeignLimitsClient {
private let base = "https://app.bankofmaldives.com.mv/api/v2"
private let ua = "bml-mobile-banking/348 (Apple; iOS 17.0; iPhone)"
private let ver = "2.1.44.348"
private let urlSession: URLSession = {
let cfg = URLSessionConfiguration.ephemeral
cfg.timeoutIntervalForRequest = 30
return URLSession(configuration: cfg)
}()
func fetchForeignLimits(session: BmlSession) async throws -> [BmlForeignLimit] {
var req = URLRequest(url: URL(string: "\(base)/foreign-limits")!)
req.setValue("Bearer \(session.accessToken)", forHTTPHeaderField: "Authorization")
req.setValue(ua, "User-Agent")
req.setValue(ver, "x-app-version")
req.setValue("application/json","Accept")
let (data, response) = try await urlSession.data(for: req)
let code = (response as? HTTPURLResponse)?.statusCode ?? 0
if code == 401 || code == 419 { throw BmlError.sessionExpired }
if code >= 500 { throw BmlError.networkError("HTTP \(code)") }
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
root["success"] as? Bool == true,
let payload = root["payload"] as? [[String: Any]] else {
return []
}
return payload.map { item in
let usage = item["usageByCategory"] as? [String: Any] ?? [:]
let atm = usage["ATM"] as? [String: Any] ?? [:]
let ecom = usage["ECOM"] as? [String: Any] ?? [:]
let pos = usage["POS"] as? [String: Any] ?? [:]
return BmlForeignLimit(
type: item["type"] as? String ?? "Debit",
used: item["used"] as? Double ?? 0,
totalLimit: item["totalLimit"] as? Double ?? 0,
generalCap: item["generalCap"] as? Double ?? 0,
generalRemaining: item["generalRemaining"] as? Double ?? 0,
medicalRemaining: item["medicalRemaining"] as? Double ?? 0,
isAtmEnabled: item["isAtmEnabled"] as? Bool ?? false,
isPosEnabled: item["isPosEnabled"] as? Bool ?? false,
atmRemaining: atm["remaining"] as? Double ?? 0,
atmLimit: atm["limit"] as? Double ?? 0,
ecomRemaining: ecom["remaining"] as? Double ?? 0,
ecomLimit: ecom["limit"] as? Double ?? 0,
posRemaining: pos["remaining"] as? Double ?? 0,
posLimit: pos["limit"] as? Double ?? 0
)
}
}
}
private extension URLRequest {
mutating func setValue(_ value: String, _ field: String) {
setValue(value, forHTTPHeaderField: field)
}
}
+504
View File
@@ -0,0 +1,504 @@
import CryptoKit
import Foundation
private let BML_BASE_URL = "https://www.bankofmaldives.com.mv/internetbanking"
private let BML_CLIENT_ID = "98C83590-513F-4716-B02B-EC68B7D9E7E7"
private let BML_REDIRECT = "https://app.bankofmaldives.com.mv/oauth/mobile-callback"
private let BML_APP_UA = "bml-mobile-banking/348 (Apple; iOS 17.0; iPhone)"
private let BML_APP_VERSION = "2.1.44.348"
private let BML_WEB_UA = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"
// Prevents URLSession from following redirects and captures Set-Cookie headers from
// redirect responses. The 302 response is only fully accessible here, before completionHandler(nil)
// is called by the time data(for:delegate:) returns, the redirect response headers may be gone.
//
// Also stores lastRedirectLocation so callers can reliably read the Location header even when
// the async data(for:) return value loses it after redirect cancellation.
private final class NoRedirectDelegate: NSObject, URLSessionTaskDelegate {
var onRedirectResponse: (@Sendable (HTTPURLResponse) -> Void)?
// Written synchronously inside willPerformHTTPRedirection before completionHandler(nil);
// read by BmlLoginFlow after data(for:) returns sequential by design, no data race.
var lastRedirectLocation = ""
func urlSession(
_ session: URLSession,
task: URLSessionTask,
willPerformHTTPRedirection response: HTTPURLResponse,
newRequest request: URLRequest,
completionHandler: @escaping @Sendable (URLRequest?) -> Void
) {
lastRedirectLocation = response.value(forHTTPHeaderField: "Location") ?? ""
onRedirectResponse?(response)
completionHandler(nil)
}
}
final class BmlLoginFlow {
private let noRedirect = NoRedirectDelegate()
// Ephemeral session with automatic cookie handling.
// noRedirect.onRedirectResponse feeds Set-Cookie headers from every 302 response
// into forceCookies() the only reliable place to see redirect response headers,
// since data(for:delegate:) receives a stripped response after the redirect is cancelled.
// Declared as `let` (not lazy) to avoid @MainActor inference on the accessing methods.
private let webSession: URLSession
init() {
let cfg = URLSessionConfiguration.ephemeral
cfg.timeoutIntervalForRequest = 30
webSession = URLSession(configuration: cfg, delegate: noRedirect, delegateQueue: nil)
noRedirect.onRedirectResponse = { [weak self] redirectResp in
self?.forceCookies(from: redirectResp)
self?.captureRedirectCookies(from: redirectResp)
}
}
// Extra store for cookies captured from redirect responses.
// Keyed by name; injected explicitly into the authorize request header.
private var redirectCookieStore: [String: String] = [:]
private var codeVerifier = ""
private var codeChallenge = ""
private var deviceId = ""
private(set) var lastProfiles: [BmlProfile] = []
// MARK: - Login
func login(username: String, password: String, otpSeed: String) async throws -> [BmlProfile] {
codeVerifier = generateCodeVerifier()
codeChallenge = generateCodeChallenge(codeVerifier)
deviceId = generateDeviceId()
// Step 1: GET /web/login seeds XSRF-TOKEN + blaze_session cookies
try await webGet("\(BML_BASE_URL)/web/login")
guard let xsrf = xsrfToken() else { throw BmlError.loginFailed("Could not fetch login page") }
// Step 2: POST credentials
let credBody = try jsonBody(["username": username, "password": password, "code": ""])
let credResp = try await webPost("\(BML_BASE_URL)/web/login", body: credBody, xsrf: xsrf)
guard credResp.status == 302 else {
throw BmlError.loginFailed("Login failed — check your username/password")
}
// Step 3: GET 2FA page follow any canonical redirect so we land on the actual page,
// which sets a fresh XSRF-TOKEN cookie needed for the OTP POST.
let twoFaGetResp = try await webGet("\(BML_BASE_URL)/web/login/2fa")
if twoFaGetResp.status == 302 {
let twoFaLoc = twoFaGetResp.location.isEmpty ? noRedirect.lastRedirectLocation : twoFaGetResp.location
if !twoFaLoc.isEmpty {
let twoFaUrl = twoFaLoc.hasPrefix("http") ? twoFaLoc : "https://www.bankofmaldives.com.mv\(twoFaLoc)"
try await webGet(twoFaUrl)
}
}
let xsrf2 = xsrfToken() ?? xsrf
// Step 4: POST TOTP try current window, then ±1 windows to handle device clock skew.
// A successful 2FA POST redirects somewhere other than /web/login (e.g. /web/profile).
// A failed OTP redirects back to /web/login or /web/login/2fa.
// Re-fetch XSRF before each attempt in case the server rotates it on a failed try.
var twoFaSucceeded = false
var lastOtp = ""
var lastRedirectLoc = ""
for timeStep in [0, -1, 1] {
// Refresh XSRF before each attempt server may invalidate it after a failed POST.
let freshXsrf = xsrfToken() ?? xsrf2
let otp = Totp.generate(otpSeed, timeStep: timeStep)
lastOtp = otp
let body = try jsonBody(["code": otp, "channel": "authenticator"])
let resp = try await webPost("\(BML_BASE_URL)/web/login/2fa", body: body, xsrf: freshXsrf)
let loc = resp.location.isEmpty ? noRedirect.lastRedirectLocation : resp.location
lastRedirectLoc = loc
if resp.status == 302 && !loc.contains("/web/login") {
twoFaSucceeded = true
break
}
}
guard twoFaSucceeded else {
throw BmlError.otpFailed("OTP verification failed (code tried: \(lastOtp), server redirected to: \(lastRedirectLoc)). If the code matches your authenticator, the XSRF-TOKEN or session cookie may be invalid.")
}
// Step 5: GET /web/profile 302 = single profile, 200 = picker.
// For 302, follow the redirect (e.g. to /web/redirect) so the server-side OAuth
// session state is established before we call /oauth/authorize.
// NOTE: The async data(for:delegate:) API may not return the Location header on the
// response object after redirect cancellation. We fall back to noRedirect.lastRedirectLocation
// which is written synchronously inside willPerformHTTPRedirection before the task completes.
let profileResp = try await webGet("\(BML_BASE_URL)/web/profile")
if profileResp.status == 302 {
let loc = profileResp.location.isEmpty ? noRedirect.lastRedirectLocation : profileResp.location
if !loc.isEmpty {
let redirectUrl = loc.hasPrefix("http") ? loc : "https://www.bankofmaldives.com.mv\(loc)"
try await webGet(redirectUrl)
}
lastProfiles = [BmlProfile(profileId: username, name: "Personal", type: "Profile", profileType: "default", autoActivated: true)]
} else {
lastProfiles = parseProfiles(profileResp.body)
}
return lastProfiles
}
// MARK: - Profile activation
func activateProfile(_ profile: BmlProfile, loginTag: String) async throws -> BmlActivationResult {
if profile.autoActivated {
let (session, accounts) = try await doOAuthAndFetchAccounts(loginTag: loginTag, profileName: profile.name, profileId: profile.profileId)
return .success(session, accounts)
}
var req = makeWebRequest(url: "\(BML_BASE_URL)/web/profile/\(profile.profileId)")
if let xsrf = xsrfToken() { req.setValue(xsrf, forHTTPHeaderField: "X-XSRF-TOKEN") }
let (_, resp) = try await webSession.data(for: req, delegate: noRedirect)
let http = resp as! HTTPURLResponse
forceCookies(from: http)
let loc = http.value(forHTTPHeaderField: "Location") ?? ""
let needsBusinessOtp = http.statusCode == 302 && loc.contains("/web/profile/2fa/business")
if needsBusinessOtp {
let channels = try await fetchBusinessOtpChannels()
return .needsBusinessOtp(channels)
} else if http.statusCode == 409 || http.statusCode == 302 {
// Follow the redirect to establish server-side OAuth session state
if !loc.isEmpty {
let redirectUrl = loc.hasPrefix("http") ? loc : "https://www.bankofmaldives.com.mv\(loc)"
try await webGet(redirectUrl)
}
let (session, accounts) = try await doOAuthAndFetchAccounts(loginTag: loginTag, profileName: profile.name, profileId: profile.profileId)
return .success(session, accounts)
} else {
throw BmlError.profileActivationFailed("HTTP \(http.statusCode)")
}
}
func requestBusinessOtp(channel: String) async throws {
guard let xsrf = xsrfToken() else { throw BmlError.sessionExpired }
let body = try jsonBody(["code": "", "channel": channel])
let resp = try await webPost("\(BML_BASE_URL)/web/profile/2fa/business", body: body, xsrf: xsrf)
guard resp.status == 302 else { throw BmlError.loginFailed("Failed to request OTP (HTTP \(resp.status))") }
}
func submitBusinessOtp(channel: String, code: String, profile: BmlProfile, loginTag: String) async throws -> (BmlSession, [BankAccount]) {
try await webGet("\(BML_BASE_URL)/web/profile/2fa/business")
guard let xsrf = xsrfToken() else { throw BmlError.sessionExpired }
let body = try jsonBody(["code": code, "channel": channel])
let resp = try await webPost("\(BML_BASE_URL)/web/profile/2fa/business", body: body, xsrf: xsrf)
let loc = resp.location
if resp.status == 409 || (resp.status == 302 && loc.contains("/web/redirect")) {
return try await doOAuthAndFetchAccounts(loginTag: loginTag, profileName: profile.name, profileId: profile.profileId)
} else if resp.status == 302 {
throw BmlError.otpFailed("Invalid OTP — please try again")
} else {
throw BmlError.otpFailed("Business OTP failed (HTTP \(resp.status))")
}
}
// MARK: - Token refresh
func refreshSession(_ session: BmlSession) async throws -> BmlSession {
var req = URLRequest(url: URL(string: "\(BML_BASE_URL)/oauth/token")!)
req.httpMethod = "POST"
req.setValue(BML_WEB_UA, forHTTPHeaderField: "User-Agent")
let fields = [
("grant_type", "refresh_token"),
("refresh_token", session.refreshToken),
("client_id", BML_CLIENT_ID),
("Device-ID", session.deviceId),
("User-Agent", BML_APP_UA),
("x-app-version", BML_APP_VERSION)
]
req.httpBody = Data(fields.map { "\($0.0)=\($0.1)" }.joined(separator: "&").utf8)
req.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
let (data, _) = try await makeApiSession().data(for: req)
guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let newAccess = (obj["access_token"] as? String).flatMap({ $0.isEmpty ? nil : $0 }) else {
throw BmlError.sessionExpired
}
let newRefresh = (obj["refresh_token"] as? String ?? "").isEmpty ? session.refreshToken : obj["refresh_token"] as! String
let expiresIn = obj["expires_in"] as? Int64 ?? 0
let expiresAt = expiresIn > 0 ? Int64(Date().timeIntervalSince1970 * 1000) + expiresIn * 1000 : 0
return BmlSession(accessToken: newAccess, deviceId: session.deviceId, refreshToken: newRefresh, expiresAt: expiresAt)
}
// MARK: - OAuth + account fetch
private func doOAuthAndFetchAccounts(
loginTag: String, profileName: String, profileId: String
) async throws -> (BmlSession, [BankAccount]) {
// Device-ID, User-Agent (APP UA), x-app-version are query params same as Android.
var comps = URLComponents(string: "\(BML_BASE_URL)/oauth/authorize")!
comps.queryItems = [
.init(name: "redirect_uri", value: BML_REDIRECT),
.init(name: "client_id", value: BML_CLIENT_ID),
.init(name: "response_type", value: "code"),
.init(name: "state", value: randomUrlSafe(16)),
.init(name: "nonce", value: randomUrlSafe(12)),
.init(name: "code_challenge", value: codeChallenge),
.init(name: "code_challenge_method", value: "S256"),
.init(name: "Device-ID", value: deviceId),
.init(name: "User-Agent", value: BML_APP_UA),
.init(name: "x-app-version", value: BML_APP_VERSION),
]
// Sync any cookies captured from redirect responses into the session cookie storage.
// This covers blaze_identity and any other cookies that forceCookies() may have missed
// due to redirect responses being cancelled before URLSession auto-processes them.
if let storage = webSession.configuration.httpCookieStorage {
for (name, value) in redirectCookieStore where !name.hasPrefix("__REDIRECT__") {
if let cookie = HTTPCookie(properties: [
.name: name,
.value: value,
.domain: "www.bankofmaldives.com.mv",
.path: "/",
.secure: "TRUE"
]) {
storage.setCookie(cookie)
}
}
}
// Build authorize request with all session cookies (now including redirect-captured ones).
var authorizeReq = makeWebRequest(url: comps.url!.absoluteString)
let allCookies = webSession.configuration.httpCookieStorage?.cookies ?? []
let cookieDict: [String: String] = Dictionary(
uniqueKeysWithValues: allCookies.map { ($0.name, $0.value) }
)
if !cookieDict.isEmpty {
authorizeReq.setValue(
cookieDict.map { "\($0.key)=\($0.value)" }.joined(separator: "; "),
forHTTPHeaderField: "Cookie"
)
authorizeReq.httpShouldHandleCookies = false
}
let sentCookieNames = cookieDict.keys.joined(separator: ",")
let redirectMarkers = redirectCookieStore.keys.filter { $0.hasPrefix("__REDIRECT__") }.sorted()
.compactMap { redirectCookieStore[$0] }.joined(separator: "")
let (_, authorizeRaw) = try await webSession.data(for: authorizeReq, delegate: noRedirect)
let authorizeHttp = authorizeRaw as! HTTPURLResponse
forceCookies(from: authorizeHttp)
let loc = authorizeHttp.value(forHTTPHeaderField: "Location") ?? ""
guard !loc.isEmpty,
let urlComps = URLComponents(string: loc),
let code = urlComps.queryItems?.first(where: { $0.name == "code" })?.value else {
let rStore = redirectCookieStore.keys.filter { !$0.hasPrefix("__REDIRECT__") }.sorted().joined(separator: ",")
throw BmlError.oauthFailed("OAuth authorize failed (HTTP \(authorizeHttp.statusCode), location: \(loc), cookies: [\(sentCookieNames)], redirectPaths: [\(redirectMarkers)], redirectStore: [\(rStore)])")
}
let tokenFields = [
("Device-ID", deviceId),
("code", code),
("grant_type", "authorization_code"),
("User-Agent", BML_APP_UA),
("redirect_uri", BML_REDIRECT),
("code_verifier", codeVerifier),
("client_id", BML_CLIENT_ID),
("x-app-version", BML_APP_VERSION)
]
var tokenReq = URLRequest(url: URL(string: "\(BML_BASE_URL)/oauth/token")!)
tokenReq.httpMethod = "POST"
tokenReq.setValue(BML_WEB_UA, forHTTPHeaderField: "User-Agent")
tokenReq.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
tokenReq.httpBody = Data(tokenFields.map { "\($0.0)=\($0.1)" }.joined(separator: "&").utf8)
let (tokenData, _) = try await webSession.data(for: tokenReq, delegate: noRedirect)
guard let tokenObj = try? JSONSerialization.jsonObject(with: tokenData) as? [String: Any],
let accessToken = (tokenObj["access_token"] as? String).flatMap({ $0.isEmpty ? nil : $0 }) else {
throw BmlError.oauthFailed("Token exchange failed")
}
let refreshToken = tokenObj["refresh_token"] as? String ?? ""
let expiresIn = tokenObj["expires_in"] as? Int64 ?? 0
let expiresAt = expiresIn > 0 ? Int64(Date().timeIntervalSince1970 * 1000) + expiresIn * 1000 : 0
let session = BmlSession(accessToken: accessToken, deviceId: deviceId, refreshToken: refreshToken, expiresAt: expiresAt)
let client = BmlAccountClient(session: session)
let accounts = try await client.fetchAccounts(loginTag: loginTag, profileName: profileName, profileId: profileId)
return (session, accounts)
}
// MARK: - Business OTP channels
private func fetchBusinessOtpChannels() async throws -> [BmlOtpChannel] {
let resp = try await webGet("\(BML_BASE_URL)/web/profile/2fa/business")
return parseBusinessOtpChannels(resp.body)
}
// MARK: - Inertia.js parsing
private func extractInertiaJson(_ html: String) -> [String: Any]? {
guard let range = html.range(of: #"data-page="([^"]+)""#, options: .regularExpression) else { return nil }
var escaped = String(html[range])
escaped = escaped.replacingOccurrences(of: #"data-page=""#, with: "").dropLast().description
let unescaped = escaped
.replacingOccurrences(of: """, with: "\"")
.replacingOccurrences(of: "&", with: "&")
.replacingOccurrences(of: "'", with: "'")
.replacingOccurrences(of: "&lt;", with: "<")
.replacingOccurrences(of: "&gt;", with: ">")
return try? JSONSerialization.jsonObject(with: Data(unescaped.utf8)) as? [String: Any]
}
private func parseProfiles(_ html: String) -> [BmlProfile] {
guard let root = extractInertiaJson(html),
let props = root["props"] as? [String: Any],
let profiles = props["profiles"] as? [[String: Any]] else { return [] }
return profiles.compactMap { p in
guard let profileObj = p["profile"] as? [String: Any] else { return nil }
return BmlProfile(
profileId: p["profile_id"] as? String ?? "",
name: p["name"] as? String ?? "",
type: p["type"] as? String ?? "",
profileType: profileObj["profile_type"] as? String ?? "default",
autoActivated: false
)
}
}
private func parseBusinessOtpChannels(_ html: String) -> [BmlOtpChannel] {
guard let root = extractInertiaJson(html),
let props = root["props"] as? [String: Any],
let channels = props["channels"] as? [[String: Any]] else { return [] }
return channels.map { c in
BmlOtpChannel(
channel: c["channel"] as? String ?? "",
description: c["description"] as? String ?? "",
masked: c["masked"] as? String ?? ""
)
}
}
// MARK: - Cookie helpers
// Stores raw name=value pairs from redirect response cookies into redirectCookieStore.
// Uses direct string parsing of allHeaderFields to bypass HTTPCookie parsing quirks.
// Also records a __REDIRECT__{n} marker so we know if this was ever called at all.
private func captureRedirectCookies(from response: HTTPURLResponse) {
let path = response.url?.path ?? "nil"
let location = response.value(forHTTPHeaderField: "Location") ?? "?"
let markerKey = "__REDIRECT__\(redirectCookieStore.keys.filter { $0.hasPrefix("__REDIRECT__") }.count)"
redirectCookieStore[markerKey] = "\(path)\(location)" // source path redirect destination
for (key, value) in response.allHeaderFields {
guard let k = key.base as? String,
k.lowercased() == "set-cookie",
let v = value as? String else { continue }
// URLSession may combine multiple Set-Cookie headers with "\n"
for cookieLine in v.components(separatedBy: "\n") {
guard let nameValuePart = cookieLine.split(separator: ";").first else { continue }
let parts = nameValuePart.split(separator: "=", maxSplits: 1)
guard parts.count == 2 else { continue }
let name = parts[0].trimmingCharacters(in: .whitespaces)
let val = String(parts[1]).trimmingCharacters(in: .whitespaces)
redirectCookieStore[name] = val
}
}
}
// After every response, force-write Set-Cookie headers into the session's ephemeral
// cookie storage. URLSession may skip automatic Set-Cookie processing for redirect
// responses when the redirect is cancelled via NoRedirectDelegate.completionHandler(nil).
//
// IMPORTANT: allHeaderFields is a [AnyHashable: Any] dict if the server sends multiple
// Set-Cookie headers, URLSession joins them with "\n" into a single entry. Building a
// plain [String: String] and passing it to HTTPCookie.cookies() would only parse the
// first cookie. We split on "\n" and call HTTPCookie.cookies() once per line instead.
private func forceCookies(from response: HTTPURLResponse) {
guard let storage = webSession.configuration.httpCookieStorage,
let url = response.url else { return }
for (key, value) in response.allHeaderFields {
guard let k = key.base as? String,
k.caseInsensitiveCompare("set-cookie") == .orderedSame,
let v = value as? String else { continue }
for line in v.components(separatedBy: "\n") {
let trimmed = line.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty else { continue }
let cookies = HTTPCookie.cookies(
withResponseHeaderFields: ["Set-Cookie": trimmed],
for: url
)
cookies.forEach { storage.setCookie($0) }
}
}
}
private func xsrfToken() -> String? {
guard let url = URL(string: BML_BASE_URL) else { return nil }
return webSession.configuration.httpCookieStorage?
.cookies(for: url)?.first { $0.name == "XSRF-TOKEN" }?.value
}
// MARK: - HTTP helpers
@discardableResult
private func webGet(_ urlString: String) async throws -> (status: Int, body: String, location: String) {
let req = makeWebRequest(url: urlString)
let (data, resp) = try await webSession.data(for: req, delegate: noRedirect)
let http = resp as! HTTPURLResponse
forceCookies(from: http)
return (http.statusCode, String(data: data, encoding: .utf8) ?? "", http.value(forHTTPHeaderField: "Location") ?? "")
}
private func webPost(_ urlString: String, body: Data, xsrf: String) async throws -> (status: Int, body: String, location: String) {
var req = makeWebRequest(url: urlString)
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.setValue(xsrf, forHTTPHeaderField: "X-XSRF-TOKEN")
req.httpBody = body
let (data, resp) = try await webSession.data(for: req, delegate: noRedirect)
let http = resp as! HTTPURLResponse
forceCookies(from: http)
return (http.statusCode, String(data: data, encoding: .utf8) ?? "", http.value(forHTTPHeaderField: "Location") ?? "")
}
private func makeWebRequest(url: String) -> URLRequest {
var req = URLRequest(url: URL(string: url)!)
req.setValue(BML_WEB_UA, forHTTPHeaderField: "User-Agent")
return req
}
private func jsonBody(_ dict: [String: String]) throws -> Data {
try JSONSerialization.data(withJSONObject: dict)
}
private func makeApiSession() -> URLSession {
let cfg = URLSessionConfiguration.default
cfg.timeoutIntervalForRequest = 30
return URLSession(configuration: cfg)
}
// MARK: - PKCE / crypto
private func generateCodeVerifier() -> String {
var bytes = [UInt8](repeating: 0, count: 72)
_ = SecRandomCopyBytes(kSecRandomDefault, 72, &bytes)
return Data(bytes).base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
private func generateCodeChallenge(_ verifier: String) -> String {
let hash = SHA256.hash(data: Data(verifier.utf8))
return Data(hash).base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
private func randomUrlSafe(_ byteCount: Int) -> String {
var bytes = [UInt8](repeating: 0, count: byteCount)
_ = SecRandomCopyBytes(kSecRandomDefault, byteCount, &bytes)
return Data(bytes).base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
private func generateDeviceId() -> String {
var bytes = [UInt8](repeating: 0, count: 8)
_ = SecRandomCopyBytes(kSecRandomDefault, 8, &bytes)
return bytes.map { String(format: "%02x", $0) }.joined()
}
}
+106
View File
@@ -0,0 +1,106 @@
import Foundation
struct BmlSession: Codable {
let accessToken: String
let deviceId: String
let refreshToken: String
let expiresAt: Int64 // Unix millis; 0 = unknown
var isExpired: Bool {
expiresAt > 0 && Int64(Date().timeIntervalSince1970 * 1000) >= expiresAt
}
}
struct BmlProfile: Codable, Identifiable {
let profileId: String
let name: String
let type: String // "Profile" or "Business"
let profileType: String // "default" or "business"
let autoActivated: Bool
var id: String { profileId }
}
struct BmlOtpChannel: Codable {
let channel: String
let description: String
let masked: String
}
enum BmlActivationResult {
case success(BmlSession, [BankAccount])
case needsBusinessOtp([BmlOtpChannel])
}
struct BmlUserInfo {
let fullName: String
let email: String
let mobile: String
let customerId: String
let idCard: String
let birthdate: String
}
struct BmlLoanDetail {
let loanAmount: Double
let outstandingAmt: Double
let repayAmount: Double
let intRate: Double
let loanStatus: String
let startDate: String
let endDate: String
let noOfRepayOverdue: Int
let overdueAmount: Double
}
struct BmlWalletToken {
let token: String
let expiry: String
let appCode: String
let serviceCode: String
let data: String
let validUntil: String
}
struct BmlForeignLimit: Codable {
let type: String
let used: Double
let totalLimit: Double
let generalCap: Double
let generalRemaining: Double
let medicalRemaining: Double
let isAtmEnabled: Bool
let isPosEnabled: Bool
let atmRemaining: Double
let atmLimit: Double
let ecomRemaining: Double
let ecomLimit: Double
let posRemaining: Double
let posLimit: Double
}
enum BmlError: LocalizedError {
case networkError(String)
case loginFailed(String)
case otpFailed(String)
case serverError(String)
case sessionExpired
case profileActivationFailed(String)
case oauthFailed(String)
case invalidResponse
case endpointNotFound
var errorDescription: String? {
switch self {
case .networkError(let msg): return "Network error: \(msg)"
case .loginFailed(let msg): return "Login failed: \(msg)"
case .otpFailed(let msg): return "OTP failed: \(msg)"
case .serverError(let msg): return msg
case .sessionExpired: return "Session expired."
case .profileActivationFailed(let msg): return "Profile activation failed: \(msg)"
case .oauthFailed(let msg): return "OAuth failed: \(msg)"
case .invalidResponse: return "Unexpected server response."
case .endpointNotFound: return "Endpoint not found."
}
}
}
@@ -0,0 +1,238 @@
import Foundation
struct BmlAccountLookupResult: Sendable {
let accountNumber: String
let accountName: String
let bankName: String
let trnType: String // "IAT" BML-to-BML, "QTR" Favara, "DOT" other bank
let bank: String?
}
struct BmlTransferInit: Sendable {
let code: Int // 22 = OTP required
}
struct BmlTransferReceipt: Sendable {
let reference: String
let timestamp: String
let message: String
}
// BML 2-step transfer:
// Step 1 POST /api/mobile/transfer with transfer details (no otp).
// Server responds {"success":true,"code":22} when OTP is required.
// Step 2 POST same endpoint adding otp field.
// Server responds {"success":true,"payload":{"reference":...,"timestamp":...}}.
//
// Field names from Kotlin: debitAccount, creditAccount, debitAmount, transfertype, currency, channel, otp, remarks
final class BmlTransferClient {
private let bmlSession: BmlSession
private let urlSession: URLSession
private let base = "https://www.bankofmaldives.com.mv/internetbanking"
private let ua = "bml-mobile-banking/348 (Apple; iOS 17.0; iPhone)"
private let ver = "2.1.44.348"
init(bmlSession: BmlSession) {
self.bmlSession = bmlSession
let cfg = URLSessionConfiguration.default
cfg.timeoutIntervalForRequest = 30
self.urlSession = URLSession(configuration: cfg)
}
// MARK: - Account lookup
func lookupAccount(_ input: String) async throws -> BmlAccountLookupResult {
let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
if Self.isMibAccount(trimmed) {
return try await lookupMibAccount(trimmed)
}
// Kotlin uses /api/mobile/validate/account/<input> for BML/Favara validation.
let data = try await apiGet("/api/mobile/validate/account/\(Self.pathComponent(trimmed))")
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
root["success"] as? Bool == true,
let payload = root["payload"] as? [String: Any] else {
throw BmlError.serverError("Account not found")
}
return Self.lookupResult(from: payload, fallbackAccount: trimmed, defaultType: "IAT", defaultBank: nil)
}
private func lookupMibAccount(_ account: String) async throws -> BmlAccountLookupResult {
// Kotlin BmlValidateClient verifies MIB accounts through the Favara account-verification endpoint.
let data = try await apiGet("/api/mobile/favara/account-verification/\(Self.pathComponent(account))/MIB")
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
root["success"] as? Bool == true else {
throw BmlError.serverError("Account not found")
}
let payload = root["payload"] as? [String: Any] ?? root
return Self.lookupResult(from: payload, fallbackAccount: account, defaultType: "DOT", defaultBank: "MIB")
}
private static func lookupResult(
from payload: [String: Any],
fallbackAccount: String,
defaultType: String,
defaultBank: String?
) -> BmlAccountLookupResult {
let trnType = payload["trnType"] as? String
?? payload["transferType"] as? String
?? defaultType
let name = payload["name"] as? String
?? payload["account_name"] as? String
?? payload["accountName"] as? String
?? payload["contact_name"] as? String
?? payload["BfyNm"] as? String
?? ""
let cdtr = payload["CdtrAcct"] as? [String: Any]
let account = cdtr?["Acct"] as? String
?? payload["account"] as? String
?? payload["accountNumber"] as? String
?? payload["benefAccount"] as? String
?? fallbackAccount
let bank = payload["bank"] as? String ?? defaultBank
let bankName = bank == "MIB" ? "Maldives Islamic Bank" : "Bank of Maldives"
return BmlAccountLookupResult(
accountNumber: account,
accountName: name,
bankName: bankName,
trnType: trnType,
bank: bank
)
}
private static func isMibAccount(_ value: String) -> Bool {
value.count == 17 && value.hasPrefix("9") && value.allSatisfy(\.isNumber)
}
private static func pathComponent(_ value: String) -> String {
value.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? value
}
// MARK: - Step 1: Initiate (no OTP)
func initiateTransfer(
fromAccount: BankAccount,
toAccount: String,
amount: Double,
currency: String,
trnType: String,
bank: String?
) async throws -> BmlTransferInit {
let debitAccount = try debitAccountCode(fromAccount)
var body: [String: Any] = [
"debitAccount": debitAccount,
"creditAccount": toAccount,
"debitAmount": amount,
"transfertype": trnType,
"currency": currency,
"channel": "token"
]
if let bank, !bank.isEmpty {
body["bank"] = bank
}
let data = try await apiPost("/api/mobile/transfer", body: body)
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw BmlError.invalidResponse
}
guard root["success"] as? Bool == true else {
let msg = root["message"] as? String ?? ""
let code = root["code"] as? Int
let extra = (root["payload"] as? [String: Any])?["message"] as? String ?? ""
let full = [msg, extra].filter { !$0.isEmpty }.joined(separator: "")
let suffix = code.map { " (code \($0))" } ?? ""
throw BmlError.serverError(full.isEmpty ? "Transfer failed\(suffix)" : "\(full)\(suffix)")
}
return BmlTransferInit(code: root["code"] as? Int ?? 0)
}
// MARK: - Step 2: Confirm with OTP
func confirmTransfer(
fromAccount: BankAccount,
toAccount: String,
amount: Double,
currency: String,
trnType: String,
remarks: String,
otp: String,
bank: String?
) async throws -> BmlTransferReceipt {
let debitAccount = try debitAccountCode(fromAccount)
var body: [String: Any] = [
"debitAccount": debitAccount,
"creditAccount": toAccount,
"debitAmount": amount,
"transfertype": trnType,
"currency": currency,
"channel": "token",
"otp": otp
]
if !remarks.isEmpty {
body["remarks"] = remarks
}
if let bank, !bank.isEmpty {
body["bank"] = bank
}
let data = try await apiPost("/api/mobile/transfer", body: body)
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw BmlError.invalidResponse
}
guard root["success"] as? Bool == true else {
let msg = root["message"] as? String ?? ""
let code = root["code"] as? Int
let extra = (root["payload"] as? [String: Any])?["message"] as? String ?? ""
let full = [msg, extra].filter { !$0.isEmpty }.joined(separator: "")
let suffix = code.map { " (code \($0))" } ?? ""
throw BmlError.serverError(full.isEmpty ? "Transfer confirmation failed\(suffix)" : "\(full)\(suffix)")
}
let payload = root["payload"] as? [String: Any] ?? [:]
return BmlTransferReceipt(
reference: payload["reference"] as? String ?? "",
timestamp: payload["timestamp"] as? String ?? "",
message: root["message"] as? String ?? "Transfer successful"
)
}
private func debitAccountCode(_ account: BankAccount) throws -> String {
let code = (account.internalId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
guard !code.isEmpty else {
throw BmlError.serverError("BML debit account id missing. Refresh dashboard and select the account again.")
}
return code
}
// MARK: - HTTP
private func apiGet(_ path: String) async throws -> Data {
var req = URLRequest(url: URL(string: "\(base)\(path)")!)
req.setValue("Bearer \(bmlSession.accessToken)", forHTTPHeaderField: "Authorization")
req.setValue(ua, forHTTPHeaderField: "User-Agent")
req.setValue(ver, forHTTPHeaderField: "x-app-version")
let (data, resp) = try await urlSession.data(for: req)
let code = (resp as? HTTPURLResponse)?.statusCode ?? 0
if code == 401 || code == 419 { throw BmlError.sessionExpired }
if code >= 500 { throw BmlError.networkError("HTTP \(code)") }
return data
}
private func apiPost(_ path: String, body: [String: Any]) async throws -> Data {
var req = URLRequest(url: URL(string: "\(base)\(path)")!)
req.httpMethod = "POST"
req.setValue("Bearer \(bmlSession.accessToken)", forHTTPHeaderField: "Authorization")
req.setValue(ua, forHTTPHeaderField: "User-Agent")
req.setValue(ver, forHTTPHeaderField: "x-app-version")
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try? JSONSerialization.data(withJSONObject: body)
let (data, resp) = try await urlSession.data(for: req)
let code = (resp as? HTTPURLResponse)?.statusCode ?? 0
if code == 401 || code == 419 { throw BmlError.sessionExpired }
if code >= 500 { throw BmlError.networkError("HTTP \(code)") }
return data
}
}
@@ -0,0 +1,173 @@
import Foundation
final class FahipayLoginFlow {
private let baseURL = "https://fahipay.mv"
private let webUA = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"
private let cookieStorage = HTTPCookieStorage()
private lazy var urlSession: URLSession = {
let cfg = URLSessionConfiguration.default
cfg.httpCookieStorage = cookieStorage
cfg.timeoutIntervalForRequest = 30
cfg.httpShouldSetCookies = true
return URLSession(configuration: cfg)
}()
// MARK: - Session cookie management
func setSessionCookie(_ value: String) {
guard let url = URL(string: baseURL),
let cookie = HTTPCookie(properties: [
.name: "__Secure-sess",
.value: value,
.domain: "fahipay.mv",
.path: "/",
.secure: "TRUE"
]) else { return }
cookieStorage.setCookie(cookie)
}
func getSessionCookieValue() -> String? {
cookieStorage.cookies?.first { $0.name == "__Secure-sess" }?.value
}
// MARK: - Login
/**
* Step 1: POST /api/app/login/
* Returns FahipayLoginStep:
* twoFactorRequired = false + authId set login complete
* twoFactorRequired = true + authId nil call verifyTotp() next
*/
func login(idCard: String, password: String, deviceUuid: String) async throws -> FahipayLoginStep {
try await initSession()
let parts: [(String, String)] = [
("email", idCard),
("password", password),
("grant_type", "auth_id"),
("lang", "en"),
("version", "2.0.0"),
("platform", "BasedBank")
] + deviceParts(deviceUuid)
var req = URLRequest(url: URL(string: "\(baseURL)/api/app/login/")!)
req.httpMethod = "POST"
req.setValue(webUA, forHTTPHeaderField: "User-Agent")
req.setValue("application/json", forHTTPHeaderField: "accept")
let (contentType, body) = buildMultipartBody(parts: parts)
req.setValue(contentType, forHTTPHeaderField: "Content-Type")
req.httpBody = body
let (data, _) = try await urlSession.data(for: req)
guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw FahipayError.invalidResponse
}
guard obj["type"] as? String == "success" else {
throw FahipayError.loginFailed(obj["msg"] as? String ?? "Login failed — check your ID card and password")
}
let authId = (obj["authID"] as? String).flatMap { $0.isEmpty ? nil : $0 }
let twoFa = obj["two_factor_required"] as? Bool ?? false
return FahipayLoginStep(twoFactorRequired: twoFa, authId: authId)
}
// MARK: - TOTP verification
/**
* Step 2 (if 2FA required): POST /api/app/otp/
* Returns authId on success.
*/
func verifyTotp(code: String, deviceUuid: String) async throws -> String {
let parts: [(String, String)] = [
("code", code),
("channel", "totp"),
("action", "login"),
("grant_type", "auth_id"),
("lang", "en"),
("version", "2.0.0"),
("platform", "BasedBank")
] + deviceParts(deviceUuid)
var req = URLRequest(url: URL(string: "\(baseURL)/api/app/otp/")!)
req.httpMethod = "POST"
req.setValue(webUA, forHTTPHeaderField: "User-Agent")
req.setValue("application/json", forHTTPHeaderField: "accept")
let (contentType, body) = buildMultipartBody(parts: parts)
req.setValue(contentType, forHTTPHeaderField: "Content-Type")
req.httpBody = body
let (data, _) = try await urlSession.data(for: req)
guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw FahipayError.invalidResponse
}
guard obj["type"] as? String == "success" else {
throw FahipayError.otpFailed(obj["msg"] as? String ?? "OTP verification failed")
}
guard let authId = (obj["authID"] as? String).flatMap({ $0.isEmpty ? nil : $0 }) else {
throw FahipayError.otpFailed("No authID in OTP response")
}
return authId
}
// MARK: - Session init
// Establishes the __Secure-sess cookie required for the login + OTP flow
private func initSession() async throws {
var req = URLRequest(url: URL(string: "\(baseURL)/api/app/lang/data/")!)
req.setValue(webUA, forHTTPHeaderField: "User-Agent")
_ = try await urlSession.data(for: req)
}
// MARK: - Device info
private func deviceParts(_ uuid: String) -> [(String, String)] {
[
("device[available]", "true"),
("device[platform]", "iOS"),
("device[uuid]", uuid),
("device[model]", "iPhone"),
("device[manufacturer]", "Apple"),
("device[isVirtual]", "false"),
("device[serial]", "unknown")
]
}
// MARK: - Multipart body with lowercase content-disposition headers (Fahipay requirement)
private func buildMultipartBody(parts: [(String, String)]) -> (contentType: String, body: Data) {
let boundary = UUID().uuidString
var data = Data()
for (name, value) in parts {
let valueBytes = Data(value.utf8)
data += "--\(boundary)\r\n".utf8Data
// Fahipay requires lowercase "content-disposition", not standard "Content-Disposition"
data += "content-disposition: form-data; name=\"\(name)\"\r\n".utf8Data
data += "Content-Length: \(valueBytes.count)\r\n".utf8Data
data += "\r\n".utf8Data
data += valueBytes
data += "\r\n".utf8Data
}
data += "--\(boundary)--\r\n".utf8Data
return ("multipart/form-data; boundary=\(boundary)", data)
}
// MARK: - Device UUID generation
static func generateDeviceUuid() -> String {
var bytes = [UInt8](repeating: 0, count: 8)
_ = SecRandomCopyBytes(kSecRandomDefault, 8, &bytes)
return bytes.map { String(format: "%02x", $0) }.joined()
}
}
private extension String {
var utf8Data: Data { Data(utf8) }
}
private extension Data {
static func += (lhs: inout Data, rhs: Data) { lhs.append(rhs) }
}
@@ -0,0 +1,35 @@
import Foundation
struct FahipaySession: Codable {
let authId: String
let sessionCookie: String
}
struct FahipayLoginStep {
let twoFactorRequired: Bool
let authId: String?
}
struct FahipayContactGroup: Codable, Identifiable {
let id: String
let name: String
let contacts: [BankContact]
}
enum FahipayError: LocalizedError {
case networkError(String)
case loginFailed(String)
case otpFailed(String)
case sessionExpired
case invalidResponse
var errorDescription: String? {
switch self {
case .networkError(let msg): return "Network error: \(msg)"
case .loginFailed(let msg): return "Login failed: \(msg)"
case .otpFailed(let msg): return "OTP verification failed: \(msg)"
case .sessionExpired: return "Session expired."
case .invalidResponse: return "Unexpected server response."
}
}
}
@@ -0,0 +1,137 @@
import Foundation
// Mirrors the Kotlin MibContactsClient exactly.
// Endpoint: POST https://faisamobilex-wv.mib.com.mv/ajaxBeneficiary/main
// Auth: Cookie header with xxid, IBSID, mbnonce, mbmodel (same session established by MibLoginFlow).
actor MibContactsClient {
private let wvBase = "https://faisamobilex-wv.mib.com.mv"
private let urlSession: URLSession = {
let cfg = URLSessionConfiguration.ephemeral
cfg.timeoutIntervalForRequest = 30
cfg.timeoutIntervalForResource = 60
cfg.httpCookieAcceptPolicy = .never
cfg.httpShouldSetCookies = false
return URLSession(configuration: cfg)
}()
private let session: MibSession
init(session: MibSession) {
self.session = session
}
// MARK: - Public
func fetchContacts(loginTag: String) async throws -> [BankContact] {
var all: [BankContact] = []
var page = 1
let pageSize = 100
while true {
let start = (page - 1) * pageSize + 1
let end = page * pageSize
let formFields: [(String, String)] = [
("page", String(page)),
("search", ""),
("searchCategoryId", "0"),
("benefType", "A"),
("sortBenef", "name"),
("sortDir", "asc"),
("start", String(start)),
("end", String(end)),
("includeCount", "1"),
]
let (contacts, totalCount) = try await fetchPage(formFields: formFields, loginTag: loginTag)
all.append(contentsOf: contacts)
if all.count >= totalCount || contacts.isEmpty { break }
page += 1
}
return all
}
// MARK: - Private
private func fetchPage(formFields: [(String, String)], loginTag: String) async throws -> ([BankContact], Int) {
let url = URL(string: "\(wvBase)/ajaxBeneficiary/main")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
request.setValue(cookieHeader, forHTTPHeaderField: "Cookie")
request.setValue(
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148",
forHTTPHeaderField: "User-Agent"
)
request.setValue("XMLHttpRequest", forHTTPHeaderField: "X-Requested-With")
request.setValue("*/*", forHTTPHeaderField: "Accept")
request.setValue(wvBase, forHTTPHeaderField: "Origin")
request.setValue("\(wvBase)/beneficiary?dashurl=1", forHTTPHeaderField: "Referer")
var allowed = CharacterSet.alphanumerics
allowed.insert(charactersIn: "-._~")
let body = formFields.map { k, v in
"\(k.addingPercentEncoding(withAllowedCharacters: allowed) ?? k)=\(v.addingPercentEncoding(withAllowedCharacters: allowed) ?? v)"
}.joined(separator: "&")
request.httpBody = Data(body.utf8)
let (data, response) = try await urlSession.data(for: request)
let code = (response as? HTTPURLResponse)?.statusCode ?? 0
if code == 419 { throw MibError.sessionExpired }
if code >= 500 { throw MibError.networkError("HTTP \(code)") }
guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw MibError.invalidResponse
}
let totalCount = obj["total_count"] as? Int
?? Int(obj["total_count"] as? String ?? "") ?? 0
guard let arr = obj["data"] as? [[String: Any]] else {
return ([], totalCount)
}
let contacts = arr.compactMap { d -> BankContact? in
let account = d["benefAccount"] as? String ?? ""
guard !account.isEmpty else { return nil }
let benefNo = d["benefNo"] as? String ?? account
// Android: benefType "I"=Internal(MIB), "L"=Local(IPS), "S"=Swift
let rawType = d["benefType"] as? String ?? "I"
let benefType: String
switch rawType {
case "I": benefType = "MIB"
case "L": benefType = "LOCAL"
case "S": benefType = "SWIFT"
default: benefType = rawType
}
return BankContact(
id: "MIB_\(benefNo)_\(loginTag)",
benefNo: benefNo,
benefName: d["benefName"] as? String ?? "",
benefNickName: (d["benefNickName"] as? String).flatMap { $0.isEmpty ? nil : $0 },
benefAccount: account,
benefType: benefType,
bankColor: d["bankColor"] as? String,
benefBankName: d["benefBankName"] as? String,
bankCode: d["bankCode"] as? String,
benefStatus: d["benefStatus"] as? String ?? "Active",
transferCyDesc: d["transferCyDesc"] as? String ?? "MVR",
customerImgHash: (d["customerImgHash"] as? String).flatMap { $0.isEmpty || $0 == "null" ? nil : $0 },
benefCategoryId: d["benefCategoryID"] as? String,
profileId: nil,
source: "MIB"
)
}
return (contacts, totalCount)
}
private var cookieHeader: String {
"mbmodel=IOS-1.0; xxid=\(session.xxid); IBSID=\(session.xxid); mbnonce=\(session.nonceGenerator); time-tracker=597"
}
}
+289
View File
@@ -0,0 +1,289 @@
import CommonCrypto
import CryptoKit
import Foundation
// MARK: - BigUInt
struct BigUInt {
private(set) var words: [UInt32] // little-endian (index 0 = least significant)
static let zero = BigUInt(words: [0])
static let one = BigUInt(words: [1])
init(words: [UInt32]) {
self.words = words
normalize()
}
init(decimal string: String) {
var result = BigUInt.zero
let ten = BigUInt(words: [10])
for ch in string {
guard let d = ch.wholeNumberValue else { continue }
result = result.multiplied(by: ten)
result = result.adding(BigUInt(words: [UInt32(d)]))
}
self = result
}
private mutating func normalize() {
while words.count > 1 && words.last == 0 { words.removeLast() }
}
var isZero: Bool { words.count == 1 && words[0] == 0 }
var isOdd: Bool { words[0] & 1 == 1 }
var bitWidth: Int {
let n = words.count
guard n > 0 else { return 0 }
let top = words[n - 1]
if top == 0 { return max(0, (n - 1) * 32) }
return (n - 1) * 32 + (32 - top.leadingZeroBitCount)
}
func bit(_ index: Int) -> Bool {
let word = index / 32
let bit = index % 32
guard word < words.count else { return false }
return (words[word] >> bit) & 1 == 1
}
// MARK: Comparison
static func < (lhs: BigUInt, rhs: BigUInt) -> Bool {
if lhs.words.count != rhs.words.count {
return lhs.words.count < rhs.words.count
}
for i in stride(from: lhs.words.count - 1, through: 0, by: -1) {
if lhs.words[i] != rhs.words[i] { return lhs.words[i] < rhs.words[i] }
}
return false
}
static func == (lhs: BigUInt, rhs: BigUInt) -> Bool { lhs.words == rhs.words }
// MARK: Addition
func adding(_ other: BigUInt) -> BigUInt {
let maxLen = max(words.count, other.words.count)
var result = [UInt32](repeating: 0, count: maxLen + 1)
var carry: UInt64 = 0
for i in 0..<maxLen {
let a: UInt64 = i < words.count ? UInt64(words[i]) : 0
let b: UInt64 = i < other.words.count ? UInt64(other.words[i]) : 0
let sum = a + b + carry
result[i] = UInt32(sum & 0xFFFFFFFF)
carry = sum >> 32
}
result[maxLen] = UInt32(carry)
return BigUInt(words: result)
}
// MARK: Subtraction (self >= other assumed)
func subtracting(_ other: BigUInt) -> BigUInt {
var result = words
var borrow: Int64 = 0
for i in 0..<result.count {
let b: Int64 = i < other.words.count ? Int64(other.words[i]) : 0
let diff = Int64(result[i]) - b - borrow
if diff < 0 {
result[i] = UInt32(bitPattern: Int32(truncatingIfNeeded: diff &+ 0x1_0000_0000))
borrow = 1
} else {
result[i] = UInt32(diff)
borrow = 0
}
}
return BigUInt(words: result)
}
// MARK: Multiplication
func multiplied(by other: BigUInt) -> BigUInt {
let n = words.count
let m = other.words.count
var result = [UInt32](repeating: 0, count: n + m)
for i in 0..<n {
var carry: UInt64 = 0
for j in 0..<m {
let prod = UInt64(words[i]) * UInt64(other.words[j]) + UInt64(result[i + j]) + carry
result[i + j] = UInt32(prod & 0xFFFFFFFF)
carry = prod >> 32
}
result[i + m] += UInt32(carry)
}
return BigUInt(words: result)
}
// MARK: Modulo (binary shift-subtract)
func modulo(_ divisor: BigUInt) -> BigUInt {
if divisor.isZero { return .zero }
if self < divisor { return self }
var remainder = BigUInt.zero
let totalBits = bitWidth
for i in stride(from: totalBits - 1, through: 0, by: -1) {
remainder = remainder.shiftedLeft1()
if bit(i) {
remainder.words[0] |= 1
}
if !(remainder < divisor) {
remainder = remainder.subtracting(divisor)
}
}
return remainder
}
private func shiftedLeft1() -> BigUInt {
var result = [UInt32](repeating: 0, count: words.count + 1)
var carry: UInt32 = 0
for i in 0..<words.count {
let shifted = (UInt64(words[i]) << 1) | UInt64(carry)
result[i] = UInt32(shifted & 0xFFFFFFFF)
carry = UInt32(shifted >> 32)
}
result[words.count] = carry
return BigUInt(words: result)
}
// MARK: ModPow right-to-left binary
func modPow(exp: BigUInt, mod: BigUInt) -> BigUInt {
if mod == .one { return .zero }
var result = BigUInt.one
var base = modulo(mod)
var e = exp
while !e.isZero {
if e.isOdd {
result = result.multiplied(by: base).modulo(mod)
}
e = e.shiftedRight1()
base = base.multiplied(by: base).modulo(mod)
}
return result
}
private func shiftedRight1() -> BigUInt {
var result = words
var borrow: UInt32 = 0
for i in stride(from: result.count - 1, through: 0, by: -1) {
let new = (result[i] >> 1) | (borrow << 31)
borrow = result[i] & 1
result[i] = new
}
return BigUInt(words: result)
}
// MARK: Decimal String (O(n) per digit via UInt64 carry)
var decimalString: String {
if isZero { return "0" }
var digits: [Character] = []
var remaining = words
while !(remaining.count == 1 && remaining[0] == 0) {
var rem: UInt64 = 0
for i in stride(from: remaining.count - 1, through: 0, by: -1) {
let cur = (rem << 32) | UInt64(remaining[i])
remaining[i] = UInt32(cur / 10)
rem = cur % 10
}
digits.append(Character(String(rem)))
while remaining.count > 1 && remaining.last == 0 { remaining.removeLast() }
}
return String(digits.reversed())
}
}
// MARK: - MibCrypto
enum MibCrypto {
static let defaultKey = "8M3L9SBF1AC4FRE56788M3L9SBF1AC4FRE5678"
// DH exponent A
private static let dhA = BigUInt(decimal: "1563516802667282387226490351799736881442299778484610378722158765594241028592123324764949712696577")
// DH modulus P
private static let dhP = BigUInt(decimal: "2410312426921032588552076022197566074856950548502459942654116941958108831682612228890093858261341614673227141477904012196503648957050582631942730706805009223062734745341073406696246014589361659774041027169249453200378729434170325843778659198143763193776859869524088940195577346119843545301547043747207749969763750084308926339295559968882457872412993810129130294592999947926365264059284647209730384947211681434464714438488520940127459844288859336526896320919633919")
// Pre-computed: 2^A mod P (sent to server in every DH key exchange)
static let cmod = "2301533261465719294935473752300816828871347570489984010719302185643154930578030858802510266223676458248149736055131586081546441805822467879511077637292661430926459449504165353261883687521672133746481188567585259995381323120449147180627189549882525129015625540437713248150022924125681681822594907090716721855161928482983925605401801685462643917325004120294159320617793870605573541358655167992228254033496751384810855107557385690184061912872019448968632814193704749"
// Derives session key: Base64(SHA256(smod^A mod P as decimal string))
static func deriveSessionKey(_ smod: String) -> String {
let smodInt = BigUInt(decimal: smod)
let shared = smodInt.modPow(exp: dhA, mod: dhP)
let secretStr = shared.decimalString
let sha = SHA256.hash(data: Data(secretStr.utf8))
return Data(sha).base64EncodedString()
}
// MARK: Blowfish/ECB/PKCS7 Encrypt
static func encrypt(_ json: [String: Any], key: String) throws -> String {
guard let jsonData = try? JSONSerialization.data(withJSONObject: json) else {
throw MibError.encryptionFailed
}
let keyBytes = key.data(using: .isoLatin1) ?? Data(key.utf8)
let encrypted = try blowfishProcess(data: jsonData, key: keyBytes, operation: CCOperation(kCCEncrypt))
return encrypted.base64EncodedString()
}
static func decrypt(_ base64: String, key: String) throws -> [String: Any] {
guard let cipherData = Data(base64Encoded: base64, options: .ignoreUnknownCharacters) else {
throw MibError.decryptionFailed
}
let keyBytes = key.data(using: .isoLatin1) ?? Data(key.utf8)
let plainData = try blowfishProcess(data: cipherData, key: keyBytes, operation: CCOperation(kCCDecrypt))
guard let json = try? JSONSerialization.jsonObject(with: plainData) as? [String: Any] else {
throw MibError.decryptionFailed
}
return json
}
private static func blowfishProcess(data: Data, key: Data, operation: CCOperation) throws -> Data {
var outLen = 0
let bufLen = data.count + kCCBlockSizeBlowfish
var outBuf = [UInt8](repeating: 0, count: bufLen)
let status = data.withUnsafeBytes { dataPtr in
key.withUnsafeBytes { keyPtr in
CCCrypt(
operation,
CCAlgorithm(kCCAlgorithmBlowfish),
CCOptions(kCCOptionECBMode | kCCOptionPKCS7Padding),
keyPtr.baseAddress, key.count,
nil,
dataPtr.baseAddress, data.count,
&outBuf, bufLen,
&outLen
)
}
}
guard status == kCCSuccess else {
throw operation == CCOperation(kCCEncrypt) ? MibError.encryptionFailed : MibError.decryptionFailed
}
return Data(outBuf[0..<outLen])
}
// MARK: SHA-256 helpers
static func hashPassword(_ password: String) -> String {
sha256Upper(password)
}
static func sha256Upper(_ input: String) -> String {
let hash = SHA256.hash(data: Data(input.utf8))
return hash.map { String(format: "%02X", $0) }.joined()
}
// pgf03 = SHA256(clientSalt + SHA256(passwordHash + userSalt))
static func computePgf03(passwordHash: String, userSalt: String, clientSalt: String) -> String {
let inner = sha256Upper(passwordHash + userSalt)
return sha256Upper(clientSalt + inner)
}
}
@@ -0,0 +1,80 @@
import Foundation
// Mirrors Kotlin MibFinancingClient exactly.
// Fetches the /financing?dashurl=1 HTML page and parses finance-card-holder data attributes.
actor MibFinancingClient {
private let wvBase = "https://faisamobilex-wv.mib.com.mv"
private let session: MibSession
private let urlSession: URLSession = {
let cfg = URLSessionConfiguration.ephemeral
cfg.timeoutIntervalForRequest = 30
cfg.timeoutIntervalForResource = 60
return URLSession(configuration: cfg)
}()
init(session: MibSession) {
self.session = session
}
func fetchFinancing() async throws -> [MibFinanceDeal] {
var req = URLRequest(url: URL(string: "\(wvBase)/financing?dashurl=1")!)
req.setValue(cookieHeader, forHTTPHeaderField: "Cookie")
req.setValue(
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148",
forHTTPHeaderField: "User-Agent"
)
req.setValue("mv.com.mib.faisamobilex", forHTTPHeaderField: "X-Requested-With")
let (data, response) = try await urlSession.data(for: req)
let code = (response as? HTTPURLResponse)?.statusCode ?? 0
if code == 419 { throw MibError.sessionExpired }
if code >= 500 { throw MibError.networkError("HTTP \(code)") }
guard let html = String(data: data, encoding: .utf8) else { return [] }
return parseHtml(html)
}
// MARK: - HTML parsing (mirrors Kotlin regex approach)
private func parseHtml(_ html: String) -> [MibFinanceDeal] {
guard let cardRe = try? NSRegularExpression(pattern: #"finance-card-holder[^>]+>"#),
let attrRe = try? NSRegularExpression(pattern: #"data-(\w+)\s*=\s*"([^"]*)""#) else {
return []
}
let ns = html as NSString
return cardRe.matches(in: html, range: NSRange(location: 0, length: ns.length)).compactMap { m in
let cardStr = ns.substring(with: m.range)
let cardNS = cardStr as NSString
var attrs: [String: String] = [:]
attrRe.matches(in: cardStr, range: NSRange(location: 0, length: cardNS.length)).forEach { a in
let key = cardNS.substring(with: a.range(at: 1))
let val = cardNS.substring(with: a.range(at: 2))
attrs[key] = val
}
guard let dealNo = attrs["dealNo"] else { return nil }
return MibFinanceDeal(
dealNo: dealNo,
productDesc: attrs["productDesc"] ?? "",
dealStatus: attrs["dealStatus"] ?? "",
statusDesc: attrs["statusDesc"] ?? "",
dealAmount: Double(attrs["dealAmount"] ?? "") ?? 0,
paidAmount: Double(attrs["paidAmount"] ?? "") ?? 0,
outstandingAmount: Double(attrs["outstandingAmount"] ?? "") ?? 0,
dealDate: attrs["dealDate"] ?? "",
overdueAmount: Double(attrs["overdueAmount"] ?? "") ?? 0,
installmentAmount: Double(attrs["installmentAmount"] ?? "") ?? 0,
noOfInstallments: Int(attrs["noOfInstallments"] ?? "") ?? 0,
lastPaidDate: attrs["lastPaidDate"] ?? "",
lastPayAmount: Double(attrs["lastPayAmount"] ?? "") ?? 0,
currency: attrs["curCodeDesc"] ?? "MVR"
)
}
}
private var cookieHeader: String {
"mbmodel=IOS-1.0; xxid=\(session.xxid); IBSID=\(session.xxid); mbnonce=\(session.nonceGenerator); time-tracker=597"
}
}
+416
View File
@@ -0,0 +1,416 @@
import Foundation
private struct SessionExpiredError: Error {}
actor MibLoginFlow {
private let baseURL = URL(string: "https://faisanet.mib.com.mv/faisamobilex_smvc/")!
private(set) var lastSession: MibSession?
private(set) var lastProfiles: [MibProfile] = []
var onSessionRefreshed: (@Sendable (MibSession, [MibProfile]) -> Void)?
private var loginId: String = ""
private var storedUsername: String?
private var storedPasswordHash: String?
private var storedOtpSeed: String?
private var isRelogging = false
private let urlSession: URLSession = {
let cfg = URLSessionConfiguration.ephemeral
cfg.timeoutIntervalForRequest = 30
cfg.timeoutIntervalForResource = 60
cfg.httpCookieAcceptPolicy = .never
cfg.httpShouldSetCookies = false
return URLSession(configuration: cfg)
}()
// MARK: - Public entry point
func login(username: String, passwordHash: String, otpSeed: String) async throws -> [BankAccount] {
loginId = username
storedUsername = username
storedPasswordHash = passwordHash
storedOtpSeed = otpSeed
let appId = getOrCreateAppId(for: username)
let key1 = credentialString("mib_\(username)_enc_key1")
let key2 = credentialString("mib_\(username)_enc_key2")
if let k1 = key1, let k2 = key2 {
return try await regularLogin(username: username, passwordHash: passwordHash, appId: appId, key1: k1, key2: k2)
} else {
return try await firstTimeRegistration(username: username, passwordHash: passwordHash, otpSeed: otpSeed, appId: appId)
}
}
// MARK: - First-time registration
private func firstTimeRegistration(
username: String, passwordHash: String, otpSeed: String, appId: String
) async throws -> [BankAccount] {
let (session1, _) = try await initialKeyExchange(appId: appId, encKey: MibCrypto.defaultKey, sfunc: "r")
let userSalt = try await getAuthType(session: session1, username: username)
let clientSalt = MibNonce.randomAlpha(32)
let pgf03 = MibCrypto.computePgf03(passwordHash: passwordHash, userSalt: userSalt, clientSalt: clientSalt)
var regPayload = baseData(session: session1, routePath: "C41")
regPayload["uname"] = username
regPayload["pgf03"] = pgf03
regPayload["clientSalt"] = clientSalt
let regResp = try await doRequest(session: session1, data: regPayload, sfunc: "n")
guard regResp["success"] as? Bool == true else {
throw MibError.serverError(regResp["reasonText"] as? String ?? "Registration init failed")
}
let otp = Totp.generate(otpSeed)
var otpPayload = baseData(session: session1, routePath: "C42")
otpPayload["otp"] = otp
otpPayload["uname"] = username
otpPayload["otpType"] = "3"
let otpResp = try await doRequest(session: session1, data: otpPayload, sfunc: "n")
guard otpResp["success"] as? Bool == true else {
throw MibError.serverError(otpResp["reasonText"] as? String ?? "OTP verification failed")
}
guard let dataArr = otpResp["data"] as? [[String: Any]],
let keyData = dataArr.first,
let key1 = keyData["key1"] as? String,
let key2 = keyData["key2"] as? String else {
throw MibError.invalidResponse
}
try? CredentialStore.shared.save(key1, forKey: "mib_\(username)_enc_key1")
try? CredentialStore.shared.save(key2, forKey: "mib_\(username)_enc_key2")
return try await regularLogin(username: username, passwordHash: passwordHash, appId: appId, key1: key1, key2: key2)
}
// MARK: - Regular login
private func regularLogin(
username: String, passwordHash: String,
appId: String, key1: String, key2: String
) async throws -> [BankAccount] {
let (session2, _) = try await initialKeyExchange(appId: appId, encKey: key1, sfunc: "i", key2: key2)
let userSalt = try await getAuthType(session: session2, username: username)
let clientSalt = MibNonce.randomAlpha(32)
let pgf03 = MibCrypto.computePgf03(passwordHash: passwordHash, userSalt: userSalt, clientSalt: clientSalt)
var loginPayload = baseData(session: session2, routePath: "A41")
loginPayload["uname"] = username
loginPayload["pgf03"] = pgf03
loginPayload["clientSalt"] = clientSalt
loginPayload["pmodTime"] = 0
loginPayload["requireBankData"] = 1
let loginResp = try await doRequest(session: session2, data: loginPayload, sfunc: "n")
guard loginResp["success"] as? Bool == true else {
throw MibError.serverError(loginResp["reasonText"] as? String ?? "Login failed")
}
let profiles = parseProfiles(from: loginResp)
lastSession = session2
lastProfiles = profiles
// Single-profile fast path: server returned balances directly in A41
if loginResp["profileSelected"] as? Bool == true,
let balances = loginResp["accountBalance"] as? [[String: Any]], !balances.isEmpty {
let selectedId = loginResp["selectedProfileId"] as? String ?? ""
if let profile = profiles.first(where: { $0.profileId == selectedId }) ?? profiles.first {
return balances.map { makeAccount(from: $0, profile: profile, loginTag: "mib_\(username)") }
+ makeCards(from: loginResp, profile: profile, loginTag: "mib_\(username)")
}
}
let hidden = hiddenProfileIds(for: username)
let visible = hidden.isEmpty ? profiles : profiles.filter { !hidden.contains($0.profileId) }
return try await fetchAllProfiles(session: session2, profiles: visible, loginTag: "mib_\(username)")
}
// MARK: - Profile fetching
func fetchAllProfiles(session: MibSession, profiles: [MibProfile], loginTag: String) async throws -> [BankAccount] {
var allAccounts: [BankAccount] = []
for profile in profiles {
var payload = baseData(session: session, routePath: "P47")
payload["profileType"] = profile.profileType
payload["profileId"] = profile.profileId
let resp = try await doRequest(session: session, data: payload, sfunc: "n")
guard resp["success"] as? Bool == true,
let balances = resp["accountBalance"] as? [[String: Any]] else { continue }
allAccounts += balances.map { makeAccount(from: $0, profile: profile, loginTag: loginTag) }
allAccounts += makeCards(from: resp, profile: profile, loginTag: loginTag)
}
return allAccounts
}
func switchProfile(session: MibSession, profile: MibProfile) async throws {
var payload = baseData(session: session, routePath: "P47")
payload["profileType"] = profile.profileType
payload["profileId"] = profile.profileId
_ = try await doRequest(session: session, data: payload, sfunc: "n")
}
func fetchProfileImage(session: MibSession, imageHash: String) async throws -> String? {
var payload = baseData(session: session, routePath: "P41")
payload["imageHash"] = imageHash
let resp = try await doRequest(session: session, data: payload, sfunc: "n")
guard resp["success"] as? Bool == true else { return nil }
return (resp["profileImage"] as? String).flatMap { $0.isEmpty ? nil : $0 }
}
// MARK: - DH Key Exchange
private func initialKeyExchange(
appId: String, encKey: String, sfunc: String, key2: String? = nil
) async throws -> (MibSession, String) {
let innerPayload: [String: Any] = [
"cmod": MibCrypto.cmod,
"appId": appId,
"routePath": "S40",
"sodium": MibNonce.randomSodium(),
"xxid": MibNonce.randomXxid()
]
let encrypted = try MibCrypto.encrypt(innerPayload, key: encKey)
var formFields = [("sfunc", sfunc), ("data", encrypted)]
if let k2 = key2 { formFields.append(("key2", k2)) }
let responseStr = try await postForm(fields: formFields)
// Server may return a plain JSON error (not Blowfish-encrypted) for auth/config failures.
let trimmed = responseStr.trimmingCharacters(in: .whitespaces)
let respJson: [String: Any]
if trimmed.hasPrefix("{"),
let plainObj = try? JSONSerialization.jsonObject(with: Data(trimmed.utf8)) as? [String: Any] {
respJson = plainObj
} else {
respJson = try MibCrypto.decrypt(responseStr, key: encKey)
}
guard respJson["success"] as? Bool == true else {
throw MibError.serverError(respJson["reasonText"] as? String ?? "Key exchange failed")
}
let smod = respJson["smod"] as? String ?? ""
let xxid = respJson["xxid"] as? String ?? ""
let nonceGen = respJson["nonceGenerator"] as? String ?? ""
let sessionKey = MibCrypto.deriveSessionKey(smod)
return (MibSession(appId: appId, xxid: xxid, nonceGenerator: nonceGen, sessionKey: sessionKey), xxid)
}
private func getAuthType(session: MibSession, username: String) async throws -> String {
var payload = baseData(session: session, routePath: "A44")
payload["uname"] = username
let resp = try await doRequest(session: session, data: payload, sfunc: "n")
guard let arr = resp["data"] as? [[String: Any]],
let salt = arr.first?["userSalt"] as? String else { throw MibError.invalidResponse }
return salt
}
// MARK: - Request engine with session recovery
private func doRequest(session: MibSession, data: [String: Any], sfunc: String) async throws -> [String: Any] {
do {
return try await sendRequest(session: session, data: data, sfunc: sfunc)
} catch is SessionExpiredError {
guard !isRelogging else { throw MibError.sessionExpired }
isRelogging = true
defer { isRelogging = false }
guard let u = storedUsername, let ph = storedPasswordHash, let os = storedOtpSeed else {
throw MibError.sessionExpired
}
_ = try await login(username: u, passwordHash: ph, otpSeed: os)
guard let newSession = lastSession else { throw MibError.sessionExpired }
onSessionRefreshed?(newSession, lastProfiles)
var retryData = data
retryData["nonce"] = MibNonce.generate(newSession.nonceGenerator)
retryData["appId"] = newSession.appId
retryData["sodium"] = MibNonce.randomSodium()
retryData["xxid"] = newSession.xxid
return try await sendRequest(session: newSession, data: retryData, sfunc: sfunc)
}
}
private func sendRequest(session: MibSession, data: [String: Any], sfunc: String) async throws -> [String: Any] {
let encrypted = try MibCrypto.encrypt(data, key: session.sessionKey)
let formFields = [("xxid", session.xxid), ("sfunc", sfunc), ("data", encrypted)]
let responseStr = try await postForm(fields: formFields)
let trimmed = responseStr.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty { throw SessionExpiredError() }
if trimmed.hasPrefix("{"),
let obj = try? JSONSerialization.jsonObject(with: Data(trimmed.utf8)) as? [String: Any] {
if obj["reasonCode"] as? String == "505" { throw SessionExpiredError() }
return obj
}
return try MibCrypto.decrypt(responseStr, key: session.sessionKey)
}
// MARK: - HTTP
private func postForm(fields: [(String, String)]) async throws -> String {
var request = URLRequest(url: baseURL)
request.httpMethod = "POST"
request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
request.setValue("android/1.0", forHTTPHeaderField: "User-Agent")
request.setValue("application/json", forHTTPHeaderField: "Accept")
// RFC 3986 unreserved chars only ensures + in base64 values is encoded as %2B.
// .urlQueryAllowed leaves + unencoded; servers parsing application/x-www-form-urlencoded
// decode + as a space, silently corrupting any base64 ciphertext that contains it.
var formValueAllowed = CharacterSet.alphanumerics
formValueAllowed.insert(charactersIn: "-._~")
let body = fields.map { k, v in
let ek = k.addingPercentEncoding(withAllowedCharacters: formValueAllowed) ?? k
let ev = v.addingPercentEncoding(withAllowedCharacters: formValueAllowed) ?? v
return "\(ek)=\(ev)"
}.joined(separator: "&")
request.httpBody = Data(body.utf8)
let (data, response) = try await urlSession.data(for: request)
guard let http = response as? HTTPURLResponse else { throw MibError.networkError("No HTTP response") }
if http.statusCode == 419 { throw SessionExpiredError() }
if http.statusCode >= 500 { throw MibError.networkError("Server error HTTP \(http.statusCode)") }
return String(data: data, encoding: .utf8) ?? ""
}
// MARK: - Helpers
private func baseData(session: MibSession, routePath: String) -> [String: Any] {
[
"nonce": MibNonce.generate(session.nonceGenerator),
"appId": session.appId,
"sodium": MibNonce.randomSodium(),
"routePath": routePath,
"xxid": session.xxid
]
}
private func parseProfiles(from json: [String: Any]) -> [MibProfile] {
guard let arr = json["operatingProfiles"] as? [[String: Any]] else { return [] }
return arr.map { p in
MibProfile(
profileId: p["profileId"] as? String ?? "",
customerProfileId: p["customerProfileId"] as? String ?? "",
annexId: p["annexId"] as? String ?? "",
customerId: p["customerId"] as? String ?? "",
name: p["name"] as? String ?? "",
cifType: p["cifType"] as? String ?? "",
profileType: p["profileType"] as? String ?? "",
color: p["color"] as? String ?? "",
customerImage: (p["customerImage"] as? String).flatMap { $0.isEmpty ? nil : $0 }
)
}
}
private func makeAccount(from a: [String: Any], profile: MibProfile, loginTag: String) -> BankAccount {
let accNum = a["accountNumber"] as? String ?? ""
let rawBlocked = a["blockedAmount"] as? String ?? "0.00"
return BankAccount(
id: "MIB_\(accNum)_\(loginTag)",
bank: "MIB",
profileName: profile.name,
profileType: profile.profileType,
productCode: profile.cifType,
accountNumber: accNum,
accountBriefName: a["accountBriefName"] as? String ?? "",
currencyName: a["currencyName"] as? String ?? "MVR",
accountTypeName: a["accountTypeName"] as? String ?? "",
availableBalance: Double(a["availableBalance"] as? String ?? "") ?? 0,
currentBalance: Double(a["currentBalance"] as? String ?? "") ?? 0,
blockedAmount: abs(Double(rawBlocked) ?? 0),
mvrBalance: Double(a["mvrBalance"] as? String ?? ""),
statusDesc: a["statusDesc"] as? String ?? "",
profileImageHash: profile.customerImage,
loginTag: loginTag,
profileId: profile.profileId,
internalId: nil
)
}
private func makeCards(from response: [String: Any], profile: MibProfile, loginTag: String) -> [BankAccount] {
let keys = ["cards", "cardList", "cardDetails", "debitCards", "creditCards", "accountCards"]
let cardArrays = keys.compactMap { response[$0] as? [[String: Any]] }
return cardArrays.flatMap { $0 }.compactMap { makeCardAccount(from: $0, profile: profile, loginTag: loginTag) }
}
private func makeCardAccount(from card: [String: Any], profile: MibProfile, loginTag: String) -> BankAccount? {
let cardId = firstString(card, keys: ["cardId", "id", "cardNo", "cardNumber", "maskedCardNumber"])
let maskedNumber = firstString(card, keys: ["maskedCardNumber", "maskedCardNo", "cardNumber", "cardNo", "cardId"])
guard !cardId.isEmpty || !maskedNumber.isEmpty else { return nil }
let productCode = firstString(card, keys: ["cardType", "productCode", "cardProductCode"])
let typeName = firstString(card, keys: ["cardTypeDesc", "cardDescription", "productName"])
let holder = firstString(card, keys: ["cardHolderName", "holderName", "customerName"])
let status = firstString(card, keys: ["cardStatus", "statusDesc", "status"])
let identifier = cardId.isEmpty ? maskedNumber : cardId
return BankAccount(
id: "MIB_CARD_\(identifier)_\(loginTag)",
bank: "MIB",
profileName: holder.isEmpty ? profile.name : holder,
profileType: "MIB_CARD",
productCode: productCode,
accountNumber: maskedNumber.isEmpty ? identifier : maskedNumber,
accountBriefName: holder,
currencyName: firstString(card, keys: ["currencyName", "currency"]).isEmpty ? "MVR" : firstString(card, keys: ["currencyName", "currency"]),
accountTypeName: typeName.isEmpty ? "MIB Card" : typeName,
availableBalance: doubleValue(card, keys: ["availableBalance", "availableLimit", "balance"]),
currentBalance: doubleValue(card, keys: ["currentBalance", "balance"]),
blockedAmount: 0,
mvrBalance: nil,
statusDesc: status.isEmpty ? "Active" : status,
profileImageHash: profile.customerImage,
loginTag: loginTag,
profileId: profile.profileId,
internalId: cardId.isEmpty ? nil : cardId
)
}
private func firstString(_ dictionary: [String: Any], keys: [String]) -> String {
for key in keys {
if let value = dictionary[key] as? String, !value.isEmpty { return value }
if let value = dictionary[key] as? NSNumber { return value.stringValue }
}
return ""
}
private func doubleValue(_ dictionary: [String: Any], keys: [String]) -> Double {
for key in keys {
if let value = dictionary[key] as? Double { return value }
if let value = dictionary[key] as? NSNumber { return value.doubleValue }
if let value = dictionary[key] as? String, let parsed = Double(value.replacingOccurrences(of: ",", with: "")) { return parsed }
}
return 0
}
private func getOrCreateAppId(for username: String) -> String {
let key = "mib_\(username)_enc_app_id"
if let existing = credentialString(key) { return existing }
let newId = MibNonce.generateAppId()
try? CredentialStore.shared.save(newId, forKey: key)
return newId
}
private func credentialString(_ key: String) -> String? {
CredentialStore.shared.load(forKey: key)
}
private func hiddenProfileIds(for username: String) -> Set<String> {
let key = "mib_\(username)_hidden_profiles"
guard let str = CredentialStore.shared.load(forKey: key) else { return [] }
return Set(str.split(separator: ",").map(String.init))
}
}
+84
View File
@@ -0,0 +1,84 @@
import Foundation
struct MibSession: Codable {
let appId: String
let xxid: String
let nonceGenerator: String
let sessionKey: String
}
struct MibProfile: Codable, Identifiable {
let profileId: String
let customerProfileId: String
let annexId: String
let customerId: String
let name: String
let cifType: String
let profileType: String
let color: String
let customerImage: String?
var id: String { profileId }
}
struct MibCard: Codable, Identifiable {
let cardId: String
let maskedCardNumber: String
let cardStatus: String
let cardType: String
let cardTypeDesc: String
let customerId: String
let phoneNumber: String
let cardHolderName: String
let loginTag: String
var id: String { cardId }
}
struct MibFinanceDeal: Codable, Identifiable {
let dealNo: String
let productDesc: String
let dealStatus: String
let statusDesc: String
let dealAmount: Double
let paidAmount: Double
let outstandingAmount: Double
let dealDate: String
let overdueAmount: Double
let installmentAmount: Double
let noOfInstallments: Int
let lastPaidDate: String
let lastPayAmount: Double
let currency: String
var id: String { dealNo }
}
struct MibTransferResult {
let success: Bool
let trxId: String
let date: String
let errorMessage: String
}
enum MibError: LocalizedError {
case networkError(String)
case serverError(String)
case sessionExpired
case invalidCredentials
case decryptionFailed
case encryptionFailed
case invalidResponse
var errorDescription: String? {
switch self {
case .networkError(let msg): return "Network error: \(msg)"
case .serverError(let msg): return "Server error: \(msg)"
case .sessionExpired: return "Session expired. Please log in again."
case .invalidCredentials: return "Invalid credentials."
case .decryptionFailed: return "Failed to decrypt response."
case .encryptionFailed: return "Failed to encrypt request."
case .invalidResponse: return "Unexpected server response."
}
}
}
+93
View File
@@ -0,0 +1,93 @@
import Foundation
enum MibNonce {
/**
* Generates the nonce string from the nonceGenerator token returned by DH key exchange.
*
* Phase 1: for each dash-separated group, take the first token's digits * random(1-99),
* pad to 5 digits, compute digitSum and lastTwo.
* Phase 2: for each group tokens 1-7, apply the operation letter with carry chain.
* Operations: M=(carry%num)+ds+cumSum, A=carry+num+ds+cumSum,
* S=carry²+num+ds+cumSum, X=carry*num+ds+cumSum, C=carry³+num+ds+cumSum
*/
static func generate(_ nonceGenerator: String) -> String {
let groups = nonceGenerator.split(separator: "-", omittingEmptySubsequences: false).map(String.init)
var paddedList: [String] = []
var lastTwoList: [Int] = []
var digitSumList: [Int] = []
var cumSum = 0
// Phase 1
for group in groups {
let tokens = group.trimmingCharacters(in: .whitespaces).components(separatedBy: " ")
let nStr = tokens[0].filter { $0.isNumber }
let n = Int(nStr) ?? 1
let r = Int.random(in: 1...99)
let product = n * r
let padded = String(format: "%05d", product)
let ds = padded.compactMap { $0.wholeNumberValue }.reduce(0, +)
let lt = Int(padded.suffix(2)) ?? 0
paddedList.append(padded)
lastTwoList.append(lt)
digitSumList.append(ds)
cumSum += ds
}
// Phase 2
var resultGroups: [String] = []
for (i, group) in groups.enumerated() {
let tokens = group.trimmingCharacters(in: .whitespaces).components(separatedBy: " ")
var carry = lastTwoList[i]
let ds = digitSumList[i]
var nonceDigits: [Int] = []
for j in 1...7 {
guard j < tokens.count else { nonceDigits.append(0); continue }
let token = tokens[j]
let op = token.filter { $0.isLetter }
let num = Int(token.filter { $0.isNumber }) ?? 1
let value: Int64
switch op {
case "M": value = Int64(carry % num) + Int64(ds) + Int64(cumSum)
case "A": value = Int64(carry) + Int64(num) + Int64(ds) + Int64(cumSum)
case "S": value = Int64(carry) * Int64(carry) + Int64(num) + Int64(ds) + Int64(cumSum)
case "X": value = Int64(carry) * Int64(num) + Int64(ds) + Int64(cumSum)
case "C": value = Int64(carry) * Int64(carry) * Int64(carry) + Int64(num) + Int64(ds) + Int64(cumSum)
default: value = 0
}
let digit = Int(String(abs(value)).suffix(2)) ?? 0
nonceDigits.append(digit)
carry = digit
}
let digitStr = nonceDigits.map { String(format: "%02d", $0) }.joined(separator: " ")
resultGroups.append("\(paddedList[i]) \(digitStr)")
}
return resultGroups.joined(separator: "-")
}
// Random long in [1_000_000, 16_000_000)
static func randomSodium() -> String {
String(Int64.random(in: 1_000_000..<16_000_000))
}
// Random long in [0, 2^40)
static func randomXxid() -> String {
String(Int64.random(in: 0..<(1 << 40)))
}
// Generates persistent App ID in IOS format: "IOS17.2-{15 alphanumeric chars}"
static func generateAppId() -> String {
let chars = Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789")
let random = (0..<15).map { _ in chars.randomElement()! }
return "IOS17.2-\(String(random))"
}
// Random alphanumeric string of given length
static func randomAlpha(_ length: Int) -> String {
let chars = Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789")
return String((0..<length).map { _ in chars.randomElement()! })
}
}
@@ -0,0 +1,222 @@
import Foundation
struct MibAccountLookupResult: Sendable {
let accountNumber: String
let accountName: String
let bankName: String
let bankCode: String
let aliasId: String?
let network: TransferNetwork
}
// Handles MIB beneficiary lookups and transfer submission via the WebView API domain.
// Session identity requires the Cookie header (mbmodel, xxid, IBSID, mbnonce) on every request.
actor MibTransferClient {
private let wvBase = "https://faisamobilex-wv.mib.com.mv"
private let session: MibSession
private let urlSession: URLSession = {
let cfg = URLSessionConfiguration.ephemeral
cfg.timeoutIntervalForRequest = 30
cfg.timeoutIntervalForResource = 60
cfg.httpCookieAcceptPolicy = .never
cfg.httpShouldSetCookies = false
return URLSession(configuration: cfg)
}()
init(session: MibSession) {
self.session = session
}
// MARK: - Account lookup
func lookupAccount(_ query: String) async throws -> MibAccountLookupResult {
let trimmed = query.trimmingCharacters(in: .whitespaces)
// 17-digit starting with 9 MIB internal
if trimmed.count == 17 && trimmed.hasPrefix("9") {
return try await lookupMibInternal(trimmed)
}
// 13-digit starting with 7 IPS/local (BML etc.)
if trimmed.count == 13 && trimmed.hasPrefix("7") {
return try await lookupIPS(trimmed)
}
// Everything else Favara alias
return try await lookupFavara(trimmed)
}
private func lookupMibInternal(_ number: String) async throws -> MibAccountLookupResult {
let resp = try await postFormJSON(
path: "ajaxBeneficiary/getAccountName",
fields: [("accountNo", number)]
)
guard resp["success"] as? Bool == true else {
throw MibError.serverError(resp["reasonText"] as? String ?? "Account not found")
}
// Server returns accountName at root level or inside data dict/array
let name: String
if let n = resp["accountName"] as? String, !n.isEmpty {
name = n
} else if let dataDict = resp["data"] as? [String: Any],
let n = dataDict["accountName"] as? String {
name = n
} else if let dataArr = resp["data"] as? [[String: Any]],
let n = dataArr.first?["accountName"] as? String {
name = n
} else {
throw MibError.serverError("Account not found")
}
return MibAccountLookupResult(
accountNumber: number,
accountName: name,
bankName: "Maldives Islamic Bank",
bankCode: "2",
aliasId: nil,
network: .mibInternal
)
}
private func lookupIPS(_ number: String) async throws -> MibAccountLookupResult {
// Kotlin field name is benefAccount, not accountNo
let resp = try await postFormJSON(
path: "AjaxAlias/getIPSAccount",
fields: [("benefAccount", number)]
)
guard resp["success"] as? Bool == true else {
throw MibError.serverError(resp["reasonText"] as? String ?? "Account not found")
}
let name = resp["accountName"] as? String
?? (resp["data"] as? [[String: Any]])?.first?["accountName"] as? String
?? "Unknown"
let bankName = resp["bankName"] as? String ?? "Local Bank"
return MibAccountLookupResult(
accountNumber: number,
accountName: name,
bankName: bankName,
bankCode: "3",
aliasId: nil,
network: .local
)
}
private func lookupFavara(_ alias: String) async throws -> MibAccountLookupResult {
// Kotlin field name is aliasName, not alias
let resp = try await postFormJSON(
path: "AjaxAlias/getAlias",
fields: [("aliasName", alias)]
)
guard resp["success"] as? Bool == true else {
throw MibError.serverError(resp["reasonText"] as? String ?? "Alias not found")
}
// Response: { data: { BfyNm, CdtrAcct: { Acct, FinInstnId } } }
guard let dataDict = resp["data"] as? [String: Any] else {
throw MibError.serverError("Alias not found")
}
let name = dataDict["BfyNm"] as? String ?? "Unknown"
let cdtrAcct = dataDict["CdtrAcct"] as? [String: Any]
let accountNum = cdtrAcct?["Acct"] as? String ?? alias
let bic = cdtrAcct?["FinInstnId"] as? String ?? ""
// Determine network based on account number format
let network: TransferNetwork = accountNum.count == 17 && accountNum.hasPrefix("9")
? .mibInternal : .local
let bankCode = network == .mibInternal ? "2" : "3"
return MibAccountLookupResult(
accountNumber: accountNum,
accountName: name,
bankName: bic.isEmpty ? "Maldives Islamic Bank" : bic,
bankCode: bankCode,
aliasId: alias,
network: network
)
}
// MARK: - Transfer
func executeTransfer(
from fromAccount: BankAccount,
to lookup: MibAccountLookupResult,
amount: Double,
currency: String,
purpose: String,
otp: String // caller generates TOTP before entering actor context
) async throws -> MibTransferResult {
let endpoint = lookup.network == .mibInternal
? "ajaxTransfer/transferInternal"
: "ajaxTransfer/transferLocal"
// Kotlin maps currency name ISO 4217 numeric code
let currencyCode: String
switch currency.uppercased() {
case "MVR": currencyCode = "462"
case "USD": currencyCode = "840"
default: currencyCode = currency
}
let fields: [(String, String)] = [
("benefName", lookup.accountName.isEmpty ? "Recipient" : lookup.accountName),
("benefNo", "0"), // Kotlin always sends "0"
("fromAccountNo", fromAccount.accountNumber),
("benefAccountNo", lookup.accountNumber),
("transferCy", currencyCode),
("benefCurrencyCode", currencyCode),
("amount", String(format: "%.2f", amount)),
("bankNo", lookup.bankCode), // "2" MIB internal, "3" local
("purpose", purpose.isEmpty ? "-" : purpose),
("otp", otp),
("otpType", "3")
]
let resp = try await postFormJSON(path: endpoint, fields: fields)
guard resp["success"] as? Bool == true else {
throw MibError.serverError(resp["reasonText"] as? String ?? "Transfer failed")
}
// Response: { success: true, data: [{ trxId, date }] }
let dataArr = resp["data"] as? [[String: Any]]
let trxId = dataArr?.first?["trxId"] as? String
?? resp["trxId"] as? String ?? ""
let date = dataArr?.first?["date"] as? String
?? resp["transactionDate"] as? String ?? ""
return MibTransferResult(success: true, trxId: trxId, date: date, errorMessage: "")
}
// MARK: - HTTP
private var cookieHeader: String {
"mbmodel=IOS-1.0; xxid=\(session.xxid); IBSID=\(session.xxid); mbnonce=\(session.nonceGenerator); time-tracker=597"
}
private func postFormJSON(path: String, fields: [(String, String)]) async throws -> [String: Any] {
guard let url = URL(string: "\(wvBase)/\(path)") else {
throw MibError.networkError("Bad URL: \(path)")
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
request.setValue(
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148",
forHTTPHeaderField: "User-Agent"
)
request.setValue("*/*", forHTTPHeaderField: "Accept")
request.setValue("XMLHttpRequest", forHTTPHeaderField: "X-Requested-With")
request.setValue(cookieHeader, forHTTPHeaderField: "Cookie")
request.setValue(wvBase, forHTTPHeaderField: "Origin")
request.setValue("\(wvBase)/transfer/quick", forHTTPHeaderField: "Referer")
var allowed = CharacterSet.alphanumerics
allowed.insert(charactersIn: "-._~")
let body = fields.map { k, v in
"\(k.addingPercentEncoding(withAllowedCharacters: allowed) ?? k)=\(v.addingPercentEncoding(withAllowedCharacters: allowed) ?? v)"
}.joined(separator: "&")
request.httpBody = Data(body.utf8)
let (data, response) = try await urlSession.data(for: request)
let code = (response as? HTTPURLResponse)?.statusCode ?? 0
if code == 419 { throw MibError.sessionExpired }
if code >= 500 { throw MibError.networkError("HTTP \(code)") }
guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw MibError.invalidResponse
}
return obj
}
}
+151
View File
@@ -0,0 +1,151 @@
import Foundation
// MARK: - Unified bank data models
// These aggregate MIB, BML, and Fahipay responses into a common schema.
// All types are Codable for cache serialisation and Hashable for use in SwiftUI ForEach.
struct BankAccount: Identifiable, Codable, Hashable {
let id: String // Synthetic: "\(bank)_\(accountNumber)_\(loginTag)"
let bank: String // "MIB" | "BML" | "FAHIPAY"
let profileName: String
let profileType: String
let productCode: String
let accountNumber: String
let accountBriefName: String
let currencyName: String // "MVR" | "USD" | ...
let accountTypeName: String
let availableBalance: Double
let currentBalance: Double
let blockedAmount: Double
let mvrBalance: Double? // Converted balance when currency != MVR
let statusDesc: String
let profileImageHash: String?
let loginTag: String // loginId that owns this account
let profileId: String?
let internalId: String?
var isActive: Bool {
statusDesc.lowercased().contains("active")
}
var formattedAvailableBalance: String {
"\(currencyName) \(Self.balanceFormatter.string(from: NSNumber(value: availableBalance)) ?? "0.00")"
}
private static let balanceFormatter: NumberFormatter = {
let f = NumberFormatter()
f.numberStyle = .decimal
f.minimumFractionDigits = 2
f.maximumFractionDigits = 2
return f
}()
}
struct BankContact: Identifiable, Codable, Hashable {
let id: String // Unique composite across banks
let benefNo: String
let benefName: String
let benefNickName: String?
let benefAccount: String
let benefType: String // "MIB" | "LOCAL" | "SWIFT" | "BML" | "FAHIPAY"
let bankColor: String?
let benefBankName: String?
let bankCode: String?
let benefStatus: String
let transferCyDesc: String?
let customerImgHash: String?
let benefCategoryId: String?
let profileId: String?
let source: String // "MIB" | "BML" | "FAHIPAY"
var displayName: String {
if let nick = benefNickName, !nick.isEmpty { return nick }
return benefName
}
}
struct BankTransaction: Identifiable, Codable, Hashable {
let id: String
let date: Date
let description: String
let amount: Double // Positive = credit, negative = debit
let currency: String
let counterpartyName: String?
let reference: String?
let accountNumber: String
let accountDisplayName: String
let source: String // "MIB" | "BML" | "BML_CARD" | "FAHIPAY"
let iconUrl: String?
var isCredit: Bool { amount >= 0 }
var formattedAmount: String {
let sign = isCredit ? "+" : "-"
let abs = Self.amountFormatter.string(from: NSNumber(value: Swift.abs(amount))) ?? "0.00"
return "\(sign) \(currency) \(abs)"
}
private static let amountFormatter: NumberFormatter = {
let f = NumberFormatter()
f.numberStyle = .decimal
f.minimumFractionDigits = 2
f.maximumFractionDigits = 2
return f
}()
}
struct BankContactCategory: Identifiable, Codable, Hashable {
let id: String
let categoryName: String
let numBenef: Int
let source: String // "MIB" | "FAHIPAY"
}
struct BankNotification: Identifiable, Codable, Hashable {
let id: String
let title: String
let body: String
let date: Date
var isRead: Bool
let source: String
}
// MARK: - Transfer
struct TransferResult: Codable {
let success: Bool
let transactionId: String?
let reference: String?
let date: Date?
let errorMessage: String?
}
struct TransferReceiptData: Sendable, Equatable {
let fromAccountNumber: String
let fromBankName: String
let toAccountNumber: String
let toAccountName: String
let amount: Double
let currency: String
let reference: String
let date: String
let message: String
}
struct RecentPick: Identifiable, Codable, Hashable {
let id: String
let benefName: String
let benefAccount: String
let transferNetwork: String // TransferNetwork.rawValue
let bankName: String?
let lastUsed: Date
}
// MARK: - Connectivity
enum ConnectivityError: String, CaseIterable, Codable {
case noInternet = "NO_INTERNET"
case mib = "MIB"
case bml = "BML"
case fahipay = "FAHIPAY"
}
@@ -0,0 +1,33 @@
import Foundation
// Mirrors Android's TransferNetwork enum exactly (same raw values).
enum TransferNetwork: String, Codable, CaseIterable, Equatable {
case mibInternal = "MIB"
case local = "LOCAL"
case swift = "SWIFT"
case bml = "BML"
case fahipay = "FAHIPAY"
var displayName: String {
switch self {
case .mibInternal: return "MIB Internal"
case .local: return "Local (IPS)"
case .swift: return "International (SWIFT)"
case .bml: return "BML"
case .fahipay: return "Fahipay"
}
}
var bankFullName: String {
switch self {
case .mibInternal: return "Maldives Islamic Bank"
case .local: return "Local Bank Transfer"
case .swift: return "International Wire Transfer"
case .bml: return "Bank of Maldives"
case .fahipay: return "Fahipay"
}
}
var isInternational: Bool { self == .swift }
var requiresSwiftCode: Bool { self == .swift }
}
@@ -0,0 +1,4 @@
{
"images": [{"filename": "amex_credit_gold.png", "idiom": "universal", "scale": "1x"}],
"info": {"author": "xcode", "version": 1}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

@@ -0,0 +1,4 @@
{
"images": [{"filename": "amex_credit_green.png", "idiom": "universal", "scale": "1x"}],
"info": {"author": "xcode", "version": 1}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

@@ -0,0 +1,4 @@
{
"images": [{"filename": "amex_debit_gold.png", "idiom": "universal", "scale": "1x"}],
"info": {"author": "xcode", "version": 1}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

@@ -0,0 +1,4 @@
{
"images": [{"filename": "amex_debit_green.png", "idiom": "universal", "scale": "1x"}],
"info": {"author": "xcode", "version": 1}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

@@ -0,0 +1,4 @@
{
"images": [{"filename": "amex_platinum.png", "idiom": "universal", "scale": "1x"}],
"info": {"author": "xcode", "version": 1}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

@@ -0,0 +1,4 @@
{
"images": [{"filename": "defaultcard.png", "idiom": "universal", "scale": "1x"}],
"info": {"author": "xcode", "version": 1}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

@@ -0,0 +1,4 @@
{
"images": [{"filename": "master.png", "idiom": "universal", "scale": "1x"}],
"info": {"author": "xcode", "version": 1}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

@@ -0,0 +1,4 @@
{
"images": [{"filename": "master_business_debit.png", "idiom": "universal", "scale": "1x"}],
"info": {"author": "xcode", "version": 1}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

@@ -0,0 +1,4 @@
{
"images": [{"filename": "master_gold.png", "idiom": "universal", "scale": "1x"}],
"info": {"author": "xcode", "version": 1}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

@@ -0,0 +1,4 @@
{
"images": [{"filename": "master_islamic.png", "idiom": "universal", "scale": "1x"}],
"info": {"author": "xcode", "version": 1}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

@@ -0,0 +1,4 @@
{
"images": [{"filename": "master_masveriyaa.png", "idiom": "universal", "scale": "1x"}],
"info": {"author": "xcode", "version": 1}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

@@ -0,0 +1,4 @@
{
"images": [{"filename": "master_odiveriyaa.png", "idiom": "universal", "scale": "1x"}],
"info": {"author": "xcode", "version": 1}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

@@ -0,0 +1,4 @@
{
"images": [{"filename": "master_passport.png", "idiom": "universal", "scale": "1x"}],
"info": {"author": "xcode", "version": 1}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

@@ -0,0 +1,4 @@
{
"images": [{"filename": "master_platinum.png", "idiom": "universal", "scale": "1x"}],
"info": {"author": "xcode", "version": 1}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

@@ -0,0 +1,4 @@
{
"images": [{"filename": "master_prepaid.png", "idiom": "universal", "scale": "1x"}],
"info": {"author": "xcode", "version": 1}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

@@ -0,0 +1,4 @@
{
"images": [{"filename": "master_prepaid_business.png", "idiom": "universal", "scale": "1x"}],
"info": {"author": "xcode", "version": 1}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

@@ -0,0 +1,4 @@
{
"images": [{"filename": "master_prepaid_travel.png", "idiom": "universal", "scale": "1x"}],
"info": {"author": "xcode", "version": 1}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

@@ -0,0 +1,4 @@
{
"images": [{"filename": "master_world.png", "idiom": "universal", "scale": "1x"}],
"info": {"author": "xcode", "version": 1}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

@@ -0,0 +1,4 @@
{
"images": [{"filename": "visa_corporate.png", "idiom": "universal", "scale": "1x"}],
"info": {"author": "xcode", "version": 1}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

@@ -0,0 +1,4 @@
{
"images": [{"filename": "visa_credit.png", "idiom": "universal", "scale": "1x"}],
"info": {"author": "xcode", "version": 1}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

@@ -0,0 +1,4 @@
{
"images": [{"filename": "visa_debit.png", "idiom": "universal", "scale": "1x"}],
"info": {"author": "xcode", "version": 1}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

@@ -0,0 +1,4 @@
{
"images": [{"filename": "visa_debit_generic.png", "idiom": "universal", "scale": "1x"}],
"info": {"author": "xcode", "version": 1}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

@@ -0,0 +1,4 @@
{
"images": [{"filename": "visa_debit_islamic.png", "idiom": "universal", "scale": "1x"}],
"info": {"author": "xcode", "version": 1}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

@@ -0,0 +1,4 @@
{
"images": [{"filename": "visa_debit_platinum.png", "idiom": "universal", "scale": "1x"}],
"info": {"author": "xcode", "version": 1}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

@@ -0,0 +1,4 @@
{
"images": [{"filename": "visa_gold.png", "idiom": "universal", "scale": "1x"}],
"info": {"author": "xcode", "version": 1}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

@@ -0,0 +1,4 @@
{
"images": [{"filename": "visa_infinite.png", "idiom": "universal", "scale": "1x"}],
"info": {"author": "xcode", "version": 1}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

@@ -0,0 +1,4 @@
{
"images": [{"filename": "visa_platinum.png", "idiom": "universal", "scale": "1x"}],
"info": {"author": "xcode", "version": 1}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

@@ -0,0 +1,4 @@
{
"images": [{"filename": "visa_student_black.png", "idiom": "universal", "scale": "1x"}],
"info": {"author": "xcode", "version": 1}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

@@ -0,0 +1,4 @@
{
"images": [{"filename": "visa_student_blue.png", "idiom": "universal", "scale": "1x"}],
"info": {"author": "xcode", "version": 1}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

@@ -0,0 +1,4 @@
{
"images": [{"filename": "fais_wear_smart_sticker.png", "idiom": "universal", "scale": "1x"}],
"info": {"author": "xcode", "version": 1}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

@@ -0,0 +1,4 @@
{
"images": [{"filename": "faisa_card.png", "idiom": "universal", "scale": "1x"}],
"info": {"author": "xcode", "version": 1}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

@@ -0,0 +1,4 @@
{
"images": [{"filename": "faisa_wear_ring_black.png", "idiom": "universal", "scale": "1x"}],
"info": {"author": "xcode", "version": 1}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

@@ -0,0 +1,4 @@
{
"images": [{"filename": "faisa_wear_ring_floral.png", "idiom": "universal", "scale": "1x"}],
"info": {"author": "xcode", "version": 1}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 267 KiB

@@ -0,0 +1,4 @@
{
"images": [{"filename": "visa_bingaa.png", "idiom": "universal", "scale": "1x"}],
"info": {"author": "xcode", "version": 1}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

@@ -0,0 +1,4 @@
{
"images": [{"filename": "visa_bingaa_mvr.png", "idiom": "universal", "scale": "1x"}],
"info": {"author": "xcode", "version": 1}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

@@ -0,0 +1,4 @@
{
"images": [{"filename": "visa_bingaa_usd.png", "idiom": "universal", "scale": "1x"}],
"info": {"author": "xcode", "version": 1}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

@@ -0,0 +1,4 @@
{
"images": [{"filename": "visa_black_platinum.png", "idiom": "universal", "scale": "1x"}],
"info": {"author": "xcode", "version": 1}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

@@ -0,0 +1,4 @@
{
"images": [{"filename": "visa_blue_everyday.png", "idiom": "universal", "scale": "1x"}],
"info": {"author": "xcode", "version": 1}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

@@ -0,0 +1,4 @@
{
"images": [{"filename": "visa_business.png", "idiom": "universal", "scale": "1x"}],
"info": {"author": "xcode", "version": 1}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

+16 -50
View File
@@ -1,61 +1,27 @@
//
// ContentView.swift
// Thijooree iOS
//
// Created by Mohamed Azim on 6/6/26.
//
import SwiftUI
import SwiftData
struct ContentView: View {
@Environment(\.modelContext) private var modelContext
@Query private var items: [Item]
// Root navigation controller. Switches between top-level app states driven by AppViewModel.route.
struct AppRouter: View {
@Environment(AppViewModel.self) private var appViewModel
var body: some View {
NavigationSplitView {
List {
ForEach(items) { item in
NavigationLink {
Text("Item at \(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))")
} label: {
Text(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))
}
}
.onDelete(perform: deleteItems)
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
EditButton()
}
ToolbarItem {
Button(action: addItem) {
Label("Add Item", systemImage: "plus")
}
}
}
} detail: {
Text("Select an item")
}
}
private func addItem() {
withAnimation {
let newItem = Item(timestamp: Date())
modelContext.insert(newItem)
}
}
private func deleteItems(offsets: IndexSet) {
withAnimation {
for index in offsets {
modelContext.delete(items[index])
Group {
switch appViewModel.route {
case .onboarding:
OnboardingView()
case .login:
BankSelectionView()
case .lock:
LockScreenView { appViewModel.unlock() }
case .home:
HomeView()
}
}
.animation(.easeInOut(duration: 0.25), value: appViewModel.route)
}
}
#Preview {
ContentView()
.modelContainer(for: Item.self, inMemory: true)
AppRouter()
.environment(AppViewModel())
}
-18
View File
@@ -1,18 +0,0 @@
//
// Item.swift
// Thijooree iOS
//
// Created by Mohamed Azim on 6/6/26.
//
import Foundation
import SwiftData
@Model
final class Item {
var timestamp: Date
init(timestamp: Date) {
self.timestamp = timestamp
}
}
+38
View File
@@ -0,0 +1,38 @@
import CommonCrypto
import Foundation
enum PinHash {
private static let iterations: UInt32 = 100_000
private static let keyLength = 32 // 256 bits
static func generateSalt() -> Data {
var salt = Data(count: 16)
_ = salt.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) }
return salt
}
static func hash(_ input: String, salt: Data) -> String {
var derivedKey = Data(count: keyLength)
let inputData = Data(input.utf8)
_ = derivedKey.withUnsafeMutableBytes { derivedPtr in
inputData.withUnsafeBytes { inputPtr in
salt.withUnsafeBytes { saltPtr in
CCKeyDerivationPBKDF(
CCPBKDFAlgorithm(kCCPBKDF2),
inputPtr.baseAddress, inputData.count,
saltPtr.baseAddress, salt.count,
CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256),
iterations,
derivedPtr.baseAddress, keyLength
)
}
}
}
return derivedKey.base64EncodedString()
}
static func verify(_ input: String, against storedHash: String, salt: Data) -> Bool {
hash(input, salt: salt) == storedHash
}
}
+51
View File
@@ -0,0 +1,51 @@
import CryptoKit
import Foundation
enum Totp {
// timeStep offsets the counter by N periods use -1/+1 to handle clock skew at window boundaries.
static func generate(_ base32Secret: String, digits: Int = 6, period: Int = 30, timeStep: Int = 0) -> String {
let counter = Int64(Date().timeIntervalSince1970) / Int64(period) + Int64(timeStep)
guard let keyBytes = base32Decode(base32Secret.uppercased()) else { return String(repeating: "0", count: digits) }
let otp = hotp(key: keyBytes, counter: counter, digits: digits)
return String(format: "%0\(digits)d", otp)
}
private static func hotp(key: [UInt8], counter: Int64, digits: Int) -> Int {
var counterBigEndian = counter.bigEndian
let counterData = withUnsafeBytes(of: &counterBigEndian) { Data($0) }
let hmac = HMAC<Insecure.SHA1>.authenticationCode(
for: counterData,
using: SymmetricKey(data: Data(key))
)
let hash = Array(hmac)
let offset = Int(hash[hash.count - 1] & 0x0F)
let truncated = (Int(hash[offset]) & 0x7F) << 24
| Int(hash[offset + 1]) << 16
| Int(hash[offset + 2]) << 8
| Int(hash[offset + 3])
let modulus = Int(pow(10.0, Double(digits)))
return truncated % modulus
}
private static func base32Decode(_ input: String) -> [UInt8]? {
let alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
var bits = 0
var bitCount = 0
var result: [UInt8] = []
for char in input where char != "=" {
guard let idx = alphabet.firstIndex(of: char) else { return nil }
let value = alphabet.distance(from: alphabet.startIndex, to: idx)
bits = (bits << 5) | value
bitCount += 5
if bitCount >= 8 {
bitCount -= 8
result.append(UInt8((bits >> bitCount) & 0xFF))
}
}
return result
}
}
+27
View File
@@ -0,0 +1,27 @@
import Foundation
// Encrypted account cache backed by UserDefaults (AES-256-GCM via CacheEncryption).
// Used by HomeViewModel to display cached data instantly on launch while a fresh
// network fetch runs in the background.
struct AccountCache {
static let shared = AccountCache()
private let udKey = "thijooree_accounts_v1"
private init() {}
func save(_ accounts: [BankAccount]) {
guard let json = try? JSONEncoder().encode(accounts),
let encrypted = try? CacheEncryption.shared.encryptData(json) else { return }
UserDefaults.standard.set(encrypted, forKey: udKey)
}
func load() -> [BankAccount] {
guard let encrypted = UserDefaults.standard.string(forKey: udKey),
let json = try? CacheEncryption.shared.decryptData(encrypted),
let accounts = try? JSONDecoder().decode([BankAccount].self, from: json) else { return [] }
return accounts
}
func clear() {
UserDefaults.standard.removeObject(forKey: udKey)
}
}

Some files were not shown because too many files have changed in this diff Show More