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) } }