196 lines
6.6 KiB
Swift
196 lines
6.6 KiB
Swift
//
|
|
// 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<Bool, Never> = .init()
|
|
var isEnabledPublisher: AnyPublisher<Bool, Never> {
|
|
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<Int, Never> {
|
|
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<Void, AppLockServiceError> {
|
|
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<Void, AppLockServiceError> {
|
|
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<Void, AppLockServiceError> {
|
|
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
|
|
}
|
|
}
|