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