// // KeychainManager.swift // FluxRead // // Created by RSA on 13/01/2026. // import Foundation import Security class KeychainManager { static let shared = KeychainManager() private let service = "com.fluxread.devicetoken" private let account = "devUID" private init() {} /// Save device token to Keychain func saveDeviceToken(_ token: String) throws { guard let data = token.data(using: .utf8) else { throw APIError.custom("Failed to encode token") } // Delete existing token first try? deleteDeviceToken() let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, kSecValueData as String: data, kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock ] let status = SecItemAdd(query as CFDictionary, nil) guard status == errSecSuccess else { throw APIError.custom("Failed to save token to Keychain: \(status)") } } /// Retrieve device token from Keychain func getDeviceToken() -> String? { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, kSecReturnData as String: true ] var result: AnyObject? let status = SecItemCopyMatching(query as CFDictionary, &result) guard status == errSecSuccess, let data = result as? Data, let token = String(data: data, encoding: .utf8) else { return nil } return token } /// Delete device token from Keychain func deleteDeviceToken() throws { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account ] let status = SecItemDelete(query as CFDictionary) guard status == errSecSuccess || status == errSecItemNotFound else { throw APIError.custom("Failed to delete token from Keychain: \(status)") } } // MARK: - APNs Push Token private let pushTokenAccount = "apnsPushToken" /// Persist the hex-encoded APNs push token func savePushToken(_ hexToken: String) throws { guard let data = hexToken.data(using: .utf8) else { throw APIError.custom("Failed to encode push token") } // Upsert: delete then add let deleteQuery: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: pushTokenAccount ] SecItemDelete(deleteQuery as CFDictionary) let addQuery: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: pushTokenAccount, kSecValueData as String: data, kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock ] let status = SecItemAdd(addQuery as CFDictionary, nil) guard status == errSecSuccess else { throw APIError.custom("Failed to save push token: \(status)") } } /// Retrieve the previously stored APNs push token, or nil if not set func getPushToken() -> String? { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: pushTokenAccount, kSecReturnData as String: true ] var result: AnyObject? let status = SecItemCopyMatching(query as CFDictionary, &result) guard status == errSecSuccess, let data = result as? Data, let token = String(data: data, encoding: .utf8) else { return nil } return token } }