// // Copyright 2018 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import Foundation import LocalAuthentication public class ScreenLock: NSObject { public enum Outcome { case success case cancel case failure(error: String) case unexpectedFailure(error: String) } public static let screenLockTimeoutDefault: TimeInterval = 15 * .minute public let screenLockTimeouts: [TimeInterval] = [ 1 * .minute, 5 * .minute, 15 * .minute, 30 * .minute, 1 * .hour, 0, ] public static let ScreenLockDidChange = Notification.Name("ScreenLockDidChange") private static let OWSScreenLock_Key_IsScreenLockEnabled = "OWSScreenLock_Key_IsScreenLockEnabled" private static let OWSScreenLock_Key_ScreenLockTimeoutSeconds = "OWSScreenLock_Key_ScreenLockTimeoutSeconds" // MARK: - Singleton class public static let shared = ScreenLock() override private init() { super.init() SwiftSingletons.register(self) } // MARK: - KV Store public let keyValueStore = KeyValueStore(collection: "OWSScreenLock_Collection") // MARK: - Properties public func isScreenLockEnabled() -> Bool { AssertIsOnMainThread() return SSKEnvironment.shared.databaseStorageRef.read { transaction in return isScreenLockEnabled(tx: transaction) } } public func isScreenLockEnabled(tx: DBReadTransaction) -> Bool { return self.keyValueStore.getBool( ScreenLock.OWSScreenLock_Key_IsScreenLockEnabled, defaultValue: false, transaction: tx, ) } public func setIsScreenLockEnabled(_ value: Bool) { AssertIsOnMainThread() SSKEnvironment.shared.databaseStorageRef.write { transaction in setIsScreenLockEnabled(value, tx: transaction) } NotificationCenter.default.postOnMainThread(name: ScreenLock.ScreenLockDidChange, object: nil) } public func setIsScreenLockEnabled(_ value: Bool, tx: DBWriteTransaction) { self.keyValueStore.setBool( value, key: ScreenLock.OWSScreenLock_Key_IsScreenLockEnabled, transaction: tx, ) } public func screenLockTimeout() -> TimeInterval { AssertIsOnMainThread() return SSKEnvironment.shared.databaseStorageRef.read { transaction in return screenLockTimeout(tx: transaction) } } public func screenLockTimeout(tx: DBReadTransaction) -> TimeInterval { return self.keyValueStore.getDouble( ScreenLock.OWSScreenLock_Key_ScreenLockTimeoutSeconds, defaultValue: ScreenLock.screenLockTimeoutDefault, transaction: tx, ) } public func setScreenLockTimeout(_ value: TimeInterval) { AssertIsOnMainThread() SSKEnvironment.shared.databaseStorageRef.write { transaction in setScreenLockTimeout(value, tx: transaction) } NotificationCenter.default.postOnMainThread(name: ScreenLock.ScreenLockDidChange, object: nil) } public func setScreenLockTimeout(_ value: TimeInterval, tx: DBWriteTransaction) { self.keyValueStore.setDouble( value, key: ScreenLock.OWSScreenLock_Key_ScreenLockTimeoutSeconds, transaction: tx, ) } // MARK: - Methods // This method should only be called: // // * On the main thread. // // Exactly one of these completions will be performed: // // * Asynchronously. // * On the main thread. public func tryToUnlockScreenLock( success: @escaping (() -> Void), failure: @escaping ((Error) -> Void), unexpectedFailure: @escaping ((Error) -> Void), cancel: @escaping (() -> Void), ) { AssertIsOnMainThread() tryToVerifyLocalAuthentication( localizedReason: OWSLocalizedString( "SCREEN_LOCK_REASON_UNLOCK_SCREEN_LOCK", comment: "Description of how and why Signal iOS uses Touch ID/Face ID/Phone Passcode to unlock 'screen lock'.", ), completion: { (outcome: Outcome) in AssertIsOnMainThread() switch outcome { case .failure(let error): Logger.error("local authentication failed with error: \(error)") failure(self.authenticationError(errorDescription: error)) case .unexpectedFailure(let error): Logger.error("local authentication failed with unexpected error: \(error)") unexpectedFailure(self.authenticationError(errorDescription: error)) case .success: success() case .cancel: cancel() } }, ) } // This method should only be called: // // * On the main thread. // // completionParam will be performed: // // * Asynchronously. // * On the main thread. private func tryToVerifyLocalAuthentication( localizedReason: String, completion completionParam: @escaping ((Outcome) -> Void), ) { AssertIsOnMainThread() let defaultErrorDescription = DeviceAuthenticationErrorMessage.unknownError // Ensure completion is always called on the main thread. let completion = { (outcome: Outcome) in DispatchQueue.main.async { completionParam(outcome) } } let context = DeviceOwnerAuthenticationType.localAuthenticationContext() var authError: NSError? let canEvaluatePolicy = context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &authError) if !canEvaluatePolicy || authError != nil { Logger.error("could not determine if local authentication is supported: \(String(describing: authError))") let outcome = self.outcomeForLAError( errorParam: authError, defaultErrorDescription: defaultErrorDescription, ) switch outcome { case .success: owsFailDebug("local authentication unexpected success") completion(.failure(error: defaultErrorDescription)) case .cancel, .failure, .unexpectedFailure: completion(outcome) } return } context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: localizedReason) { success, evaluateError in if success { Logger.info("local authentication succeeded.") completion(.success) } else { let outcome = self.outcomeForLAError( errorParam: evaluateError, defaultErrorDescription: defaultErrorDescription, ) switch outcome { case .success: owsFailDebug("local authentication unexpected success") completion(.failure(error: defaultErrorDescription)) case .cancel, .failure, .unexpectedFailure: completion(outcome) } } } } // MARK: - Outcome private func outcomeForLAError(errorParam: Error?, defaultErrorDescription: String) -> Outcome { if let error = errorParam { guard let laError = error as? LAError else { return .failure(error: defaultErrorDescription) } switch laError.code { case .biometryNotAvailable: Logger.error("local authentication error: biometryNotAvailable.") return .failure(error: ScreenLock.ErrorMessage.authenticationNotAvailable) case .biometryNotEnrolled: Logger.error("local authentication error: biometryNotEnrolled.") return .failure(error: ScreenLock.ErrorMessage.authenticationNotEnrolled) case .biometryLockout: Logger.error("local authentication error: biometryLockout.") return .failure(error: DeviceAuthenticationErrorMessage.lockout) default: // Fall through to second switch break } switch laError.code { case .authenticationFailed: Logger.error("local authentication error: authenticationFailed.") return .failure(error: DeviceAuthenticationErrorMessage.authenticationFailed) case .userCancel, .userFallback, .systemCancel, .appCancel: Logger.info("local authentication cancelled.") return .cancel case .passcodeNotSet: Logger.error("local authentication error: passcodeNotSet.") return .failure(error: ScreenLock.ErrorMessage.passcodeNotSet) case .touchIDNotAvailable: Logger.error("local authentication error: touchIDNotAvailable.") return .failure(error: ScreenLock.ErrorMessage.authenticationNotAvailable) case .touchIDNotEnrolled: Logger.error("local authentication error: touchIDNotEnrolled.") return .failure(error: ScreenLock.ErrorMessage.authenticationNotEnrolled) case .touchIDLockout: Logger.error("local authentication error: touchIDLockout.") return .failure(error: DeviceAuthenticationErrorMessage.lockout) case .invalidContext: owsFailDebug("context not valid.") return .unexpectedFailure(error: defaultErrorDescription) case .notInteractive: owsFailDebug("context not interactive.") return .unexpectedFailure(error: defaultErrorDescription) case .companionNotAvailable: owsFailDebug("companion device not available.") return .unexpectedFailure(error: defaultErrorDescription) @unknown default: owsFailDebug("Unexpected enum value.") return .unexpectedFailure(error: defaultErrorDescription) } } return .failure(error: defaultErrorDescription) } private func authenticationError(errorDescription: String) -> Error { return OWSError( error: .localAuthenticationError, description: errorDescription, isRetryable: false, ) } } // MARK: Error Messages extension ScreenLock { private enum ErrorMessage { static let authenticationNotAvailable = OWSLocalizedString( "SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_AVAILABLE", comment: "Indicates that Touch ID/Face ID/Phone Passcode are not available on this device.", ) static let authenticationNotEnrolled = OWSLocalizedString( "SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_ENROLLED", comment: "Indicates that Touch ID/Face ID/Phone Passcode is not configured on this device.", ) static let passcodeNotSet = OWSLocalizedString( "SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_PASSCODE_NOT_SET", comment: "Indicates that Touch ID/Face ID/Phone Passcode passcode is not set.", ) } } public enum DeviceAuthenticationErrorMessage { public static let errorSheetTitle = OWSLocalizedString( "SCREEN_LOCK_UNLOCK_FAILED", comment: "Title for alert indicating that screen lock could not be unlocked.", ) public static let unknownError = OWSLocalizedString( "SCREEN_LOCK_ENABLE_UNKNOWN_ERROR", comment: "Indicates that an unknown error occurred while using Touch ID/Face ID/Phone Passcode.", ) public static let lockout = OWSLocalizedString( "SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_LOCKOUT", comment: "Indicates that Touch ID/Face ID/Phone Passcode is 'locked out' on this device due to authentication failures.", ) public static let authenticationFailed = OWSLocalizedString( "SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_FAILED", comment: "Indicates that Touch ID/Face ID/Phone Passcode authentication failed.", ) }