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
+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)
}
}
+149
View File
@@ -0,0 +1,149 @@
import Foundation
import CryptoKit
import Security
enum CacheEncryptionError: Error, LocalizedError {
case keyStorageFailed(OSStatus)
case encryptionFailed
case decryptionFailed
case invalidData
var errorDescription: String? {
switch self {
case .keyStorageFailed(let s): return "Keychain key storage failed (OSStatus \(s))"
case .encryptionFailed: return "AES-GCM encryption failed"
case .decryptionFailed: return "AES-GCM decryption failed"
case .invalidData: return "Data is not valid Base64 or UTF-8"
}
}
}
// AES-256-GCM encryption for all local caches.
// Mirrors Android's CacheEncryption (AES-256-GCM, AndroidKeyStore-backed key).
// The symmetric key is generated once and stored in Keychain, inaccessible when device is locked.
final class CacheEncryption {
static let shared = CacheEncryption()
private let keychainService = "sh.sar.thijooree.cachekey"
private let keychainAccount = "aes256_master_key"
private let lock = NSLock()
private var cachedKey: SymmetricKey?
private init() {}
// MARK: - String convenience
func encrypt(_ string: String) throws -> String {
guard let data = string.data(using: .utf8) else {
throw CacheEncryptionError.invalidData
}
return try encryptData(data)
}
func decryptString(_ base64: String) throws -> String {
let data = try decryptData(base64)
guard let string = String(data: data, encoding: .utf8) else {
throw CacheEncryptionError.invalidData
}
return string
}
// MARK: - Data operations
// Returns a Base64 string containing the AES-GCM sealed box (nonce + ciphertext + tag).
func encryptData(_ plaintext: Data) throws -> String {
let key = try loadOrCreateKey()
guard let combined = (try? AES.GCM.seal(plaintext, using: key))?.combined else {
throw CacheEncryptionError.encryptionFailed
}
return combined.base64EncodedString()
}
// Decrypts a Base64 AES-GCM sealed box back to the original data.
func decryptData(_ base64: String) throws -> Data {
guard let combined = Data(base64Encoded: base64) else {
throw CacheEncryptionError.invalidData
}
let key = try loadOrCreateKey()
do {
return try AES.GCM.open(AES.GCM.SealedBox(combined: combined), using: key)
} catch {
throw CacheEncryptionError.decryptionFailed
}
}
// MARK: - Key lifecycle
// Clears the cached key and removes it from Keychain.
// Call on full logout so cached data becomes unreadable.
func purge() {
lock.lock()
defer { lock.unlock() }
cachedKey = nil
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainService,
kSecAttrAccount as String: keychainAccount
]
SecItemDelete(query as CFDictionary)
}
// MARK: - Private
private func loadOrCreateKey() throws -> SymmetricKey {
lock.lock()
defer { lock.unlock() }
if let key = cachedKey { return key }
if let key = loadKeyFromKeychain() {
cachedKey = key
return key
}
let newKey = SymmetricKey(size: .bits256)
try saveKeyToKeychain(newKey)
cachedKey = newKey
return newKey
}
private func loadKeyFromKeychain() -> SymmetricKey? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainService,
kSecAttrAccount as String: keychainAccount,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
let data = result as? Data, data.count == 32 else { return nil }
return SymmetricKey(data: data)
}
private func saveKeyToKeychain(_ key: SymmetricKey) throws {
let keyData = key.withUnsafeBytes { Data($0) }
let searchQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainService,
kSecAttrAccount as String: keychainAccount
]
let attributes: [String: Any] = [
kSecValueData as String: keyData,
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
]
let addQuery = searchQuery.merging(attributes) { _, new in new }
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
if addStatus == errSecDuplicateItem {
let updateStatus = SecItemUpdate(searchQuery as CFDictionary, attributes as CFDictionary)
guard updateStatus == errSecSuccess else {
throw CacheEncryptionError.keyStorageFailed(updateStatus)
}
} else if addStatus != errSecSuccess {
throw CacheEncryptionError.keyStorageFailed(addStatus)
}
}
}
+25
View File
@@ -0,0 +1,25 @@
import Foundation
// Encrypted contacts cache backed by UserDefaults (same pattern as AccountCache).
struct ContactsCache {
static let shared = ContactsCache()
private let udKey = "thijooree_contacts_v1"
private init() {}
func save(_ contacts: [BankContact]) {
guard let json = try? JSONEncoder().encode(contacts),
let encrypted = try? CacheEncryption.shared.encryptData(json) else { return }
UserDefaults.standard.set(encrypted, forKey: udKey)
}
func load() -> [BankContact] {
guard let encrypted = UserDefaults.standard.string(forKey: udKey),
let json = try? CacheEncryption.shared.decryptData(encrypted),
let contacts = try? JSONDecoder().decode([BankContact].self, from: json) else { return [] }
return contacts
}
func clear() {
UserDefaults.standard.removeObject(forKey: udKey)
}
}
+217
View File
@@ -0,0 +1,217 @@
import Foundation
import Security
enum CredentialStoreError: Error, LocalizedError {
case encodingFailed
case saveFailed(OSStatus)
var errorDescription: String? {
switch self {
case .encodingFailed: return "Failed to encode value for Keychain storage"
case .saveFailed(let s): return "Keychain write failed (OSStatus \(s))"
}
}
}
// Keychain-backed credential storage for all three banks.
// Key naming mirrors Android's CredentialStore exactly so the logic maps 1-to-1.
// Sensitive string values are pre-encrypted by callers via CacheEncryption before storing here.
// Non-sensitive state (security_method, onboarding_done) lives in UserDefaults, not here.
final class CredentialStore {
static let shared = CredentialStore()
private let service = "sh.sar.thijooree"
private init() {}
// MARK: - Core Keychain CRUD
func save(_ string: String, forKey key: String) throws {
guard let data = string.data(using: .utf8) else {
throw CredentialStoreError.encodingFailed
}
try save(data, forKey: key)
}
func save(_ data: Data, forKey key: String) throws {
let search = baseQuery(for: key)
let attributes: [String: Any] = [
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
]
let addQuery = search.merging(attributes) { _, new in new }
let status = SecItemAdd(addQuery as CFDictionary, nil)
if status == errSecDuplicateItem {
let updateStatus = SecItemUpdate(
search as CFDictionary,
[kSecValueData as String: data] as CFDictionary
)
guard updateStatus == errSecSuccess else {
throw CredentialStoreError.saveFailed(updateStatus)
}
} else if status != errSecSuccess {
throw CredentialStoreError.saveFailed(status)
}
}
func load(forKey key: String) -> String? {
guard let data = loadData(forKey: key) else { return nil }
return String(data: data, encoding: .utf8)
}
func loadData(forKey key: String) -> Data? {
var query = baseQuery(for: key)
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: AnyObject?
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess else { return nil }
return result as? Data
}
func delete(forKey key: String) {
SecItemDelete(baseQuery(for: key) as CFDictionary)
}
func exists(forKey key: String) -> Bool {
SecItemCopyMatching(baseQuery(for: key) as CFDictionary, nil) == errSecSuccess
}
// MARK: - JSON array helpers (for login ID lists)
func saveStringArray(_ array: [String], forKey key: String) throws {
guard let data = try? JSONSerialization.data(withJSONObject: array) else {
throw CredentialStoreError.encodingFailed
}
try save(data, forKey: key)
}
func loadStringArray(forKey key: String) -> [String] {
guard let data = loadData(forKey: key),
let array = try? JSONSerialization.jsonObject(with: data) as? [String] else {
return []
}
return array
}
// MARK: - Multi-login helpers
func hasAnyCredentials() -> Bool {
!loadStringArray(forKey: Keys.mibLoginIds).isEmpty
|| !loadStringArray(forKey: Keys.bmlLoginIds).isEmpty
|| !loadStringArray(forKey: Keys.fahipayLoginIds).isEmpty
}
func loginIds(for bank: String) -> [String] {
switch bank {
case "MIB": return loadStringArray(forKey: Keys.mibLoginIds)
case "BML": return loadStringArray(forKey: Keys.bmlLoginIds)
case "FAHIPAY": return loadStringArray(forKey: Keys.fahipayLoginIds)
default: return []
}
}
func addLoginId(_ loginId: String, toBank bank: String) throws {
let key = loginIdsKey(for: bank)
var ids = loadStringArray(forKey: key)
guard !ids.contains(loginId) else { return }
ids.append(loginId)
try saveStringArray(ids, forKey: key)
}
func removeLoginId(_ loginId: String, fromBank bank: String) throws {
let key = loginIdsKey(for: bank)
var ids = loadStringArray(forKey: key)
ids.removeAll { $0 == loginId }
try saveStringArray(ids, forKey: key)
}
// Removes every Keychain entry associated with a given login.
func purgeLogin(_ loginId: String, bank: String) {
switch bank {
case "MIB":
[Keys.mibPassword(loginId), Keys.mibOtpSeed(loginId),
Keys.mibKey1(loginId), Keys.mibKey2(loginId),
Keys.mibAppId(loginId)].forEach(delete)
try? removeLoginId(loginId, fromBank: bank)
case "BML":
let profileIds = loadStringArray(forKey: Keys.bmlProfiles(loginId))
profileIds.forEach { pid in
[Keys.bmlAccessToken(pid), Keys.bmlDeviceId(pid),
Keys.bmlRefreshToken(pid), Keys.bmlExpTime(pid)].forEach(delete)
}
[Keys.bmlPassword(loginId), Keys.bmlOtpSeed(loginId),
Keys.bmlProfiles(loginId)].forEach(delete)
try? removeLoginId(loginId, fromBank: bank)
case "FAHIPAY":
[Keys.fahipayIdCard(loginId), Keys.fahipayPassword(loginId),
Keys.fahipaySessionCookie(loginId), Keys.fahipayAuthId(loginId)].forEach(delete)
try? removeLoginId(loginId, fromBank: bank)
default:
break
}
}
// MARK: - Private
private func baseQuery(for key: String) -> [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key
]
}
private func loginIdsKey(for bank: String) -> String {
switch bank {
case "MIB": return Keys.mibLoginIds
case "BML": return Keys.bmlLoginIds
case "FAHIPAY": return Keys.fahipayLoginIds
default: return "\(bank.lowercased())_login_ids"
}
}
}
// MARK: - Keychain key constants
extension CredentialStore {
// Key names mirror Android's CredentialStore exactly.
enum Keys {
// Login ID lists (stored as JSON arrays)
static let mibLoginIds = "mib_login_ids"
static let bmlLoginIds = "bml_login_ids"
static let fahipayLoginIds = "fahipay_login_ids"
// MIB per-login
static func mibPassword(_ id: String) -> String { "mib_\(id)_enc_password" }
static func mibOtpSeed(_ id: String) -> String { "mib_\(id)_enc_otp_seed" }
static func mibKey1(_ id: String) -> String { "mib_\(id)_enc_key1" }
static func mibKey2(_ id: String) -> String { "mib_\(id)_enc_key2" }
static func mibAppId(_ id: String) -> String { "mib_\(id)_enc_app_id" }
// BML per-login
static func bmlPassword(_ id: String) -> String { "bml_\(id)_enc_password" }
static func bmlOtpSeed(_ id: String) -> String { "bml_\(id)_enc_otp_seed" }
static func bmlProfiles(_ id: String) -> String { "bml_\(id)_all_profiles" }
// BML per-profile (tokens stored separately per profile)
static func bmlAccessToken(_ pid: String) -> String { "bml_\(pid)_enc_access_token" }
static func bmlDeviceId(_ pid: String) -> String { "bml_\(pid)_enc_device_id" }
static func bmlRefreshToken(_ pid: String) -> String { "bml_\(pid)_enc_refresh_token" }
static func bmlExpTime(_ pid: String) -> String { "bml_\(pid)_exp_time" }
// Fahipay per-login
static func fahipayIdCard(_ id: String) -> String { "fahipay_\(id)_enc_id_card" }
static func fahipayPassword(_ id: String) -> String { "fahipay_\(id)_enc_password" }
static func fahipaySessionCookie(_ id: String) -> String { "fahipay_\(id)_enc_session_cookie" }
static func fahipayAuthId(_ id: String) -> String { "fahipay_\(id)_auth_id" }
// Security (Keychain the hash is derived from PIN/pattern via PBKDF2)
static let securityHash = "security_hash"
static let securityHashSalt = "security_hash_salt"
// security_method and onboarding_done live in UserDefaults (not sensitive)
}
}
@@ -0,0 +1,38 @@
import Foundation
struct CachedBmlForeignLimits: Codable {
let userName: String
let limits: [BmlForeignLimit]
}
struct DashboardCachePayload: Codable {
let foreignLimits: [CachedBmlForeignLimits]
let mibFinancing: [MibFinanceDeal]
}
struct DashboardCache {
static let shared = DashboardCache()
private let udKey = "thijooree_dashboard_v1"
private init() {}
func save(foreignLimits: [(userName: String, limits: [BmlForeignLimit])], mibFinancing: [MibFinanceDeal]) {
let payload = DashboardCachePayload(
foreignLimits: foreignLimits.map { CachedBmlForeignLimits(userName: $0.userName, limits: $0.limits) },
mibFinancing: mibFinancing
)
guard let json = try? JSONEncoder().encode(payload),
let encrypted = try? CacheEncryption.shared.encryptData(json) else { return }
UserDefaults.standard.set(encrypted, forKey: udKey)
}
func load() -> DashboardCachePayload? {
guard let encrypted = UserDefaults.standard.string(forKey: udKey),
let json = try? CacheEncryption.shared.decryptData(encrypted),
let payload = try? JSONDecoder().decode(DashboardCachePayload.self, from: json) else { return nil }
return payload
}
func clear() {
UserDefaults.standard.removeObject(forKey: udKey)
}
}
@@ -0,0 +1,41 @@
import Foundation
// Locally persisted contacts saved from transfer receipts.
// Encrypted with AES-256-GCM via CacheEncryption, stored in UserDefaults.
struct SavedContactsStore {
static let shared = SavedContactsStore()
private let udKey = "thijooree_saved_contacts_v1"
private init() {}
func save(_ contact: BankContact) {
var contacts = load()
// Replace existing entry for the same account+source, then prepend (most-recent-first)
contacts.removeAll { $0.benefAccount == contact.benefAccount && $0.source == contact.source }
contacts.insert(contact, at: 0)
if contacts.count > 100 { contacts = Array(contacts.prefix(100)) }
persist(contacts)
}
func load() -> [BankContact] {
guard let encrypted = UserDefaults.standard.string(forKey: udKey),
let json = try? CacheEncryption.shared.decryptData(encrypted),
let contacts = try? JSONDecoder().decode([BankContact].self, from: json) else { return [] }
return contacts
}
func delete(id: String) {
var contacts = load()
contacts.removeAll { $0.id == id }
persist(contacts)
}
func clear() {
UserDefaults.standard.removeObject(forKey: udKey)
}
private func persist(_ contacts: [BankContact]) {
guard let json = try? JSONEncoder().encode(contacts),
let encrypted = try? CacheEncryption.shared.encryptData(json) else { return }
UserDefaults.standard.set(encrypted, forKey: udKey)
}
}