import Foundation import Security import LocalAuthentication enum KeychainService { private static let service = "com.encryptedchat.credentials" private static let account = "userCredentials" struct Credentials: Codable { let email: String let password: String let host: String let port: UInt16 } /// Check if saved credentials exist without triggering biometric prompt. static func hasSavedCredentials() -> Bool { let context = LAContext() context.interactionNotAllowed = true let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, kSecMatchLimit as String: kSecMatchLimitOne, kSecReturnAttributes as String: true, kSecUseAuthenticationContext as String: context ] var result: AnyObject? let status = SecItemCopyMatching(query as CFDictionary, &result) // errSecInteractionNotAllowed means item exists but needs biometric return status == errSecSuccess || status == errSecInteractionNotAllowed } /// Save credentials to Keychain with biometric protection. static func saveCredentials(email: String, password: String, host: String, port: UInt16) throws { // Delete any existing entry first deleteCredentials() let credentials = Credentials(email: email, password: password, host: host, port: port) let data = try JSONEncoder().encode(credentials) var accessError: Unmanaged? guard let accessControl = SecAccessControlCreateWithFlags( kCFAllocatorDefault, kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly, .biometryAny, &accessError ) else { throw KeychainError.accessControlCreationFailed } let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, kSecValueData as String: data, kSecAttrAccessControl as String: accessControl ] let status = SecItemAdd(query as CFDictionary, nil) guard status == errSecSuccess else { throw KeychainError.saveFailed(status) } } /// Load credentials from Keychain. Triggers biometric prompt. static func loadCredentials() throws -> Credentials { let context = LAContext() context.localizedReason = "Unlock to log in" let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, kSecReturnData as String: true, kSecMatchLimit as String: kSecMatchLimitOne, kSecUseAuthenticationContext as String: context ] var result: AnyObject? let status = SecItemCopyMatching(query as CFDictionary, &result) guard status == errSecSuccess, let data = result as? Data else { if status == errSecUserCanceled || status == errSecAuthFailed { throw KeychainError.biometricFailed } throw KeychainError.loadFailed(status) } return try JSONDecoder().decode(Credentials.self, from: data) } /// Delete stored credentials from Keychain. @discardableResult static func deleteCredentials() -> Bool { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account ] let status = SecItemDelete(query as CFDictionary) return status == errSecSuccess || status == errSecItemNotFound } /// Check if biometric authentication is available on this device. static func isBiometricAvailable() -> Bool { let context = LAContext() var error: NSError? return context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) } enum KeychainError: LocalizedError { case accessControlCreationFailed case saveFailed(OSStatus) case loadFailed(OSStatus) case biometricFailed var errorDescription: String? { switch self { case .accessControlCreationFailed: return "Failed to create biometric access control" case .saveFailed(let status): return "Failed to save credentials (error \(status))" case .loadFailed(let status): return "Failed to load credentials (error \(status))" case .biometricFailed: return "Biometric authentication failed or was cancelled" } } } }