// // Copyright 2025 Element Creations Ltd. // Copyright 2023-2025 New Vector Ltd. // // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. // Please see LICENSE files in the repository root for full details. // import Combine import LocalAuthentication /// The service responsible for locking and unlocking the app. class AppLockService: AppLockServiceProtocol { private let keychainController: KeychainControllerProtocol private let appSettings: AppSettings private let context: LAContext private let timer: AppLockTimer private let unlockPolicy: LAPolicy = .deviceOwnerAuthenticationWithBiometrics var isMandatory: Bool { appSettings.appLockIsMandatory } var isEnabled: Bool { do { return try keychainController.containsPINCode() } catch { MXLog.error("Keychain access error: \(error)") MXLog.error("Locking the app.") return true } } private var isEnabledSubject: PassthroughSubject = .init() var isEnabledPublisher: AnyPublisher { isEnabledSubject.eraseToAnyPublisher() } var biometryType: LABiometryType { updateBiometrics() guard context.evaluatedPolicyDomainState != nil else { return .none } return context.biometryType } var biometricUnlockEnabled: Bool { keychainController.containsPINCodeBiometricState() } var biometricUnlockTrusted: Bool { guard let state = keychainController.pinCodeBiometricState() else { return false } updateBiometrics() return state == context.evaluatedPolicyDomainState } var numberOfPINAttempts: AnyPublisher { appSettings.$appLockNumberOfPINAttempts } init(keychainController: KeychainControllerProtocol, appSettings: AppSettings, context: LAContext = .init()) { self.keychainController = keychainController self.appSettings = appSettings self.context = context timer = AppLockTimer(gracePeriod: appSettings.appLockGracePeriod) updateBiometrics() } func setupPINCode(_ pinCode: String) -> Result { let result = validate(pinCode) guard case .success = result else { return result } do { try keychainController.setPINCode(pinCode) isEnabledSubject.send(true) return .success(()) } catch { MXLog.error("Keychain access error: \(error)") return .failure(.keychainError) } } func validate(_ pinCode: String) -> Result { guard pinCode.count == 4, pinCode.allSatisfy(\.isNumber) else { return .failure(.invalidPIN) } guard !appSettings.appLockPINCodeBlockList.contains(pinCode) else { return .failure(.weakPIN) } return .success(()) } func enableBiometricUnlock() -> Result { guard isEnabled else { return .failure(.pinNotSet) } guard let state = context.evaluatedPolicyDomainState else { return .failure(.biometricUnlockNotSupported) } do { try keychainController.setPINCodeBiometricState(state) return .success(()) } catch { MXLog.error("Keychain access error: \(error)") return .failure(.keychainError) } } func disableBiometricUnlock() { keychainController.removePINCodeBiometricState() } func disable() { keychainController.removePINCode() keychainController.removePINCodeBiometricState() appSettings.appLockNumberOfPINAttempts = 0 isEnabledSubject.send(false) } func applicationDidEnterBackground() { timer.applicationDidEnterBackground() } func computeNeedsUnlock(didBecomeActiveAt date: Date) -> Bool { timer.computeLockState(didBecomeActiveAt: date) } func unlock(with pinCode: String) -> Bool { guard pinCode == keychainController.pinCode() else { MXLog.warning("Wrong PIN entered.") appSettings.appLockNumberOfPINAttempts += 1 return false } if biometricUnlockEnabled, !biometricUnlockTrusted { MXLog.info("Fixing trust for biometric unlock.") updateBiometrics() _ = enableBiometricUnlock() } completeUnlock() return true } func unlockWithBiometrics() async -> AppLockServiceBiometricResult { guard biometryType != .none, biometricUnlockEnabled else { MXLog.error("Biometric unlock not setup.") return .failed } guard biometricUnlockTrusted else { MXLog.error("Biometrics have changed. PIN should be shown.") return .failed } do { let context = unlockContext() guard try await context.evaluatePolicy(unlockPolicy, localizedReason: L10n.screenAppLockBiometricUnlockReasonIos) else { MXLog.warning("\(context.biometryType) failed without error.") return .failed } completeUnlock() return .unlocked } catch LAError.systemCancel { MXLog.error("\(context.biometryType) failed: The system cancelled.") return .interrupted } catch { MXLog.error("\(context.biometryType) failed: \(error)") return .failed } } // MARK: - Private /// Queries the context for supported biometrics and enrolment state. private func updateBiometrics() { var error: NSError? context.canEvaluatePolicy(unlockPolicy, error: &error) if let error { MXLog.error("Biometrics error: \(error)") } } /// Creates a context specifically for unlocking the app. The titles are customised, /// and the fresh context ensures that the user is promoted to unlock based on /// `timer.gracePeriod` rather than any system to defined grace period. private func unlockContext() -> LAContext { // Keep using the injected context for tests etc. guard type(of: context) == LAContext.self else { return context } let context = LAContext() context.localizedFallbackTitle = L10n.actionEnterPin return context } /// Shared logic for completing an unlock via a PIN or biometrics. private func completeUnlock() { timer.registerUnlock() appSettings.appLockNumberOfPINAttempts = 0 } }