// // Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import LocalAuthentication public struct LocalDeviceAuthentication { public enum AuthError: Error { case notRequired case canceled case genericError(localizedErrorMessage: String) } /// An opaque object representing successful authentication. public struct AuthSuccess {} public struct AttemptToken {} private let context: LAContext public init() { context = DeviceOwnerAuthenticationType.localAuthenticationContext() } // MARK: - public func performBiometricAuth() async -> AuthSuccess? { let localDeviceAuthAttemptToken: AttemptToken switch self.checkCanAttempt() { case .success(let attemptToken): localDeviceAuthAttemptToken = attemptToken case .failure(.notRequired): return AuthSuccess() case .failure(.canceled), .failure(.genericError): return nil } switch await self.attempt(token: localDeviceAuthAttemptToken) { case .success, .failure(.notRequired): return AuthSuccess() case .failure(.canceled), .failure(.genericError): return nil } } // MARK: - /// Returns whether checking local device auth is possible. Must be called /// prior to calling ``attempt(token:)``. public func checkCanAttempt() -> Result { var error: NSError? let canEvaluatePolicy = context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) guard canEvaluatePolicy, error == nil else { return .failure(parseAuthError(error)) } return .success(AttemptToken()) } /// Returns the result of performing local device authentication. Must be /// called after calling ``checkCanAttempt()``. public func attempt(token: AttemptToken) async -> Result { do { try await context.evaluatePolicy( .deviceOwnerAuthentication, localizedReason: OWSLocalizedString( "LINK_NEW_DEVICE_AUTHENTICATION_REASON", comment: "Description of how and why Signal iOS uses Touch ID/Face ID/Phone Passcode to unlock device linking.", ), ) return .success(AuthSuccess()) } catch { return .failure(parseAuthError(error)) } } private func parseAuthError(_ error: Error?) -> AuthError { guard let error, let laError = error as? LAError else { owsFailDebug("Unexpected or missing auth error: \(error as Optional)") return .genericError(localizedErrorMessage: DeviceAuthenticationErrorMessage.unknownError) } switch laError.code { case .biometryNotAvailable, .biometryNotEnrolled, .passcodeNotSet, .touchIDNotAvailable, .touchIDNotEnrolled: return .notRequired case .userCancel, .userFallback, .systemCancel, .appCancel: return .canceled case .biometryLockout, .touchIDLockout: return .genericError(localizedErrorMessage: DeviceAuthenticationErrorMessage.lockout) case .authenticationFailed: return .genericError(localizedErrorMessage: DeviceAuthenticationErrorMessage.authenticationFailed) case .invalidContext: owsFailDebug("Context not valid.") return .genericError(localizedErrorMessage: DeviceAuthenticationErrorMessage.unknownError) case .notInteractive: owsFailDebug("Context not interactive!") return .genericError(localizedErrorMessage: DeviceAuthenticationErrorMessage.unknownError) case .companionNotAvailable: owsFailDebug("Companion device not available.") return .genericError(localizedErrorMessage: DeviceAuthenticationErrorMessage.unknownError) @unknown default: owsFailDebug("Unexpected LAContext error code: \(laError.code)") return .genericError(localizedErrorMessage: DeviceAuthenticationErrorMessage.unknownError) } } }