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