Files
Thijooree-iOS/Thijooree iOS/Storage/CredentialStore.swift
T

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