218 lines
8.4 KiB
Swift
218 lines
8.4 KiB
Swift
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)
|
|
}
|
|
}
|