Add support for Face ID/Touch ID to app lock. (#1966)

* Fix biometrics with low grace period and backgrounding before unlocked.

* Trigger permissions alert when enabling Face ID.
This commit is contained in:
Doug
2023-10-27 10:09:12 +01:00
committed by GitHub
parent 2eb8945824
commit 11563c6297
27 changed files with 598 additions and 92 deletions
@@ -43,6 +43,7 @@ class AppLockScreenViewModelTests: XCTestCase {
// Given a valid PIN code.
let pinCode = "2023"
keychainController.pinCodeReturnValue = pinCode
keychainController.containsPINCodeBiometricStateReturnValue = false
// When entering it on the lock screen.
let deferred = deferFulfillment(viewModel.actions) { $0 == .appUnlocked }
@@ -69,6 +70,7 @@ class AppLockScreenViewModelTests: XCTestCase {
// Given an invalid PIN code.
let pinCode = "2024"
keychainController.pinCodeReturnValue = "2023"
keychainController.containsPINCodeBiometricStateReturnValue = false
XCTAssertEqual(context.viewState.numberOfPINAttempts, 0, "The shouldn't be any attempts yet.")
XCTAssertFalse(context.viewState.isSubtitleWarning, "No warning should be shown yet.")
XCTAssertNil(context.alertInfo, "No alert should be shown yet.")
@@ -95,6 +97,7 @@ class AppLockScreenViewModelTests: XCTestCase {
func testForceQuitRequiresLogout() {
// Given an app with a PIN set where the user attempted to unlock 3 times.
keychainController.pinCodeReturnValue = "2023"
keychainController.containsPINCodeBiometricStateReturnValue = false
appSettings.appLockNumberOfPINAttempts = 2
XCTAssertNil(context.alertInfo)
viewModel.context.pinCode = "0000"
@@ -14,12 +14,14 @@
// limitations under the License.
//
import LocalAuthentication
import XCTest
@testable import ElementX
@MainActor
class AppLockServiceTests: XCTestCase {
var keychainController: KeychainController!
var appSettings: AppSettings!
var service: AppLockService!
@@ -28,7 +30,8 @@ class AppLockServiceTests: XCTestCase {
appSettings = AppSettings()
appSettings.appLockFlowEnabled = true
let keychainController = KeychainController(service: .tests, accessGroup: InfoPlistReader.main.keychainAccessGroupIdentifier)
keychainController = KeychainController(service: .tests, accessGroup: InfoPlistReader.main.keychainAccessGroupIdentifier)
keychainController.resetSecrets()
service = AppLockService(keychainController: keychainController, appSettings: appSettings)
service.disable()
@@ -38,6 +41,8 @@ class AppLockServiceTests: XCTestCase {
AppSettings.reset()
}
// MARK: - PIN Code
func testValidPINCode() {
// Given a service that hasn't been enabled.
XCTAssertFalse(service.isEnabled, "The service shouldn't be enabled to begin with.")
@@ -176,6 +181,137 @@ class AppLockServiceTests: XCTestCase {
XCTAssertFalse(service.unlock(with: pinCode), "The initial PIN shouldn't work any more.")
}
// MARK: - Biometric Unlock
func testEnableBiometricUnlock() async {
// Given a service with the PIN code already set.
let context = LAContextMock()
context.biometryTypeValue = .touchID
context.evaluatedPolicyDomainStateValue = "👆".data(using: .utf8)
service = AppLockService(keychainController: keychainController, appSettings: appSettings, context: context)
guard case .success = service.setupPINCode("2023") else {
XCTFail("The PIN should be valid.")
return
}
XCTAssertTrue(service.isEnabled, "The service should be enabled.")
XCTAssertEqual(service.biometryType, .touchID, "The biometry type should be in sync with the mock.")
XCTAssertFalse(service.biometricUnlockEnabled, "Biometric unlock should not be enabled.")
XCTAssertFalse(service.biometricUnlockTrusted, "Biometric unlock should not be trusted.")
// When enabling biometric unlock.
guard case .success = service.enableBiometricUnlock() else {
XCTFail("The biometric lock should enable.")
return
}
context.evaluatePolicyReturnValue = true
// Then the service should be unlockable with biometrics.
XCTAssertEqual(service.biometryType, .touchID, "The biometry type should not change.")
XCTAssertTrue(service.biometricUnlockEnabled, "Biometric unlock should now be enabled.")
XCTAssertTrue(service.biometricUnlockTrusted, "Biometric unlock should now be trusted.")
guard await service.unlockWithBiometrics() else {
XCTFail("The biometric unlock should work.")
return
}
}
func testBiometricUnlockTrust() {
// Given a service with the PIN code already set.
let context = LAContextMock()
context.biometryTypeValue = .touchID
context.evaluatedPolicyDomainStateValue = "👆".data(using: .utf8)
service = AppLockService(keychainController: keychainController, appSettings: appSettings, context: context)
let pinCode = "2023"
guard case .success = service.setupPINCode(pinCode) else {
XCTFail("The PIN should be valid.")
return
}
guard case .success = service.enableBiometricUnlock() else {
XCTFail("The biometric lock should enable.")
return
}
XCTAssertTrue(service.isEnabled, "The service should be enabled.")
XCTAssertEqual(service.biometryType, .touchID, "The biometry type should be in sync with the mock.")
XCTAssertTrue(service.biometricUnlockEnabled, "Biometric unlock should be enabled.")
XCTAssertTrue(service.biometricUnlockTrusted, "Biometric unlock should be trusted.")
// When the user changes biometric data.
context.evaluatedPolicyDomainStateValue = "👈".data(using: .utf8)
// Then biometric lock should remain enabled but untrusted.
XCTAssertTrue(service.isEnabled, "The service should remain enabled.")
XCTAssertEqual(service.biometryType, .touchID, "The biometry type should not change.")
XCTAssertTrue(service.biometricUnlockEnabled, "Biometric unlock should remain enabled.")
XCTAssertFalse(service.biometricUnlockTrusted, "Biometric unlock should no longer be trusted.")
// When the user confirms their PIN code.
XCTAssertTrue(service.unlock(with: pinCode), "The PIN code should be accepted")
// Then the biometric lock should once again be trusted.
XCTAssertTrue(service.isEnabled, "The service should remain enabled.")
XCTAssertEqual(service.biometryType, .touchID, "The biometry type should not change.")
XCTAssertTrue(service.biometricUnlockEnabled, "Biometric unlock should remain enabled.")
XCTAssertTrue(service.biometricUnlockTrusted, "Biometric unlock should once again be trusted.")
}
func testDisableBiometricUnlock() {
// Given a service with the PIN code already set.
let context = LAContextMock()
context.biometryTypeValue = .touchID
context.evaluatedPolicyDomainStateValue = "👆".data(using: .utf8)
service = AppLockService(keychainController: keychainController, appSettings: appSettings, context: context)
guard case .success = service.setupPINCode("2023") else {
XCTFail("The PIN should be valid.")
return
}
guard case .success = service.enableBiometricUnlock() else {
XCTFail("The biometric lock should enable.")
return
}
XCTAssertTrue(service.isEnabled, "The service should be enabled.")
XCTAssertEqual(service.biometryType, .touchID, "The biometry type should be in sync with the mock.")
XCTAssertTrue(service.biometricUnlockEnabled, "Biometric unlock should be enabled.")
XCTAssertTrue(service.biometricUnlockTrusted, "Biometric unlock should be trusted.")
// When disabling biometric unlock.
service.disableBiometricUnlock()
// Then only PIN unlock should remain enabled.
XCTAssertTrue(service.isEnabled, "The service should remain enabled.")
XCTAssertEqual(service.biometryType, .touchID, "The biometry type should not change.")
XCTAssertFalse(service.biometricUnlockEnabled, "Biometric unlock should become disabled.")
XCTAssertFalse(service.biometricUnlockTrusted, "Biometric unlock should no longer be trusted.")
}
func testDisablePINWithBiometricUnlock() {
// Given a service with the PIN code already set.
let context = LAContextMock()
context.biometryTypeValue = .touchID
context.evaluatedPolicyDomainStateValue = "👆".data(using: .utf8)
service = AppLockService(keychainController: keychainController, appSettings: appSettings, context: context)
guard case .success = service.setupPINCode("2023") else {
XCTFail("The PIN should be valid.")
return
}
guard case .success = service.enableBiometricUnlock() else {
XCTFail("The biometric lock should enable.")
return
}
XCTAssertTrue(service.isEnabled, "The service should be enabled.")
XCTAssertTrue(service.biometricUnlockEnabled, "Biometric unlock should be enabled.")
XCTAssertTrue(service.biometricUnlockTrusted, "Biometric unlock should be trusted.")
// When disabling the PIN lock.
service.disable()
// Then both PIN and biometric unlock should be disabled.
XCTAssertFalse(service.isEnabled, "The service should remain enabled.")
XCTAssertFalse(service.biometricUnlockEnabled, "Biometric unlock should become disabled.")
XCTAssertFalse(service.biometricUnlockTrusted, "Biometric unlock should no longer be trusted.")
}
// MARK: - Attempt failures
func testResetAttemptsOnUnlock() {
// Given a service that is enabled and has failed unlock attempts.
let pinCode = "2023"
@@ -184,9 +320,7 @@ class AppLockServiceTests: XCTestCase {
return
}
appSettings.appLockNumberOfPINAttempts = 2
appSettings.appLockNumberOfBiometricAttempts = 2
XCTAssertEqual(appSettings.appLockNumberOfPINAttempts, 2, "The initial conditions should be stored.")
XCTAssertEqual(appSettings.appLockNumberOfBiometricAttempts, 2, "The initial conditions should be stored.")
XCTAssertTrue(service.isEnabled, "The service should be enabled.")
// When unlocking the service
@@ -194,7 +328,6 @@ class AppLockServiceTests: XCTestCase {
// Then the attempts counts should both be reset.
XCTAssertEqual(appSettings.appLockNumberOfPINAttempts, 0, "The PIN attempts should be reset.")
XCTAssertEqual(appSettings.appLockNumberOfBiometricAttempts, 0, "The biometric attempts should be reset.")
}
func testResetAttemptsOnDisable() {
@@ -205,9 +338,7 @@ class AppLockServiceTests: XCTestCase {
return
}
appSettings.appLockNumberOfPINAttempts = 2
appSettings.appLockNumberOfBiometricAttempts = 2
XCTAssertEqual(appSettings.appLockNumberOfPINAttempts, 2, "The initial conditions should be stored.")
XCTAssertEqual(appSettings.appLockNumberOfBiometricAttempts, 2, "The initial conditions should be stored.")
XCTAssertTrue(service.isEnabled, "The service should be enabled.")
// When disabling the service
@@ -216,6 +347,38 @@ class AppLockServiceTests: XCTestCase {
// Then the attempts counts should both be reset.
XCTAssertEqual(appSettings.appLockNumberOfPINAttempts, 0, "The PIN attempts should be reset.")
XCTAssertEqual(appSettings.appLockNumberOfBiometricAttempts, 0, "The biometric attempts should be reset.")
}
}
// MARK: - Mocks
/// A customised context that allows injecting a few mock values but otherwise behaves as expected.
/// It works as the actual context does and won't update the return values of `biometryType` and
/// `evaluatedPolicyDomainStateValue` until either `canEvaluatePolicy` or
/// `evaluatePolicy` have been called.
private class LAContextMock: LAContext {
var biometryTypeValue: LABiometryType!
private var internalBiometryTypeValue: LABiometryType!
override var biometryType: LABiometryType { internalBiometryTypeValue }
var evaluatedPolicyDomainStateValue: Data?
private var internalEvaluatedPolicyDomainStateValue: Data?
override var evaluatedPolicyDomainState: Data? { internalEvaluatedPolicyDomainStateValue }
override func canEvaluatePolicy(_ policy: LAPolicy, error: NSErrorPointer) -> Bool {
let result = super.canEvaluatePolicy(policy, error: error)
updateInternalValues()
return result
}
var evaluatePolicyReturnValue: Bool!
override func evaluatePolicy(_ policy: LAPolicy, localizedReason: String) async throws -> Bool {
updateInternalValues()
return evaluatePolicyReturnValue
}
private func updateInternalValues() {
internalBiometryTypeValue = biometryTypeValue
internalEvaluatedPolicyDomainStateValue = evaluatedPolicyDomainStateValue
}
}
@@ -20,16 +20,18 @@ import XCTest
@MainActor
class AppLockSetupBiometricsScreenViewModelTests: XCTestCase {
var appLockService: AppLockService!
var keychainController: KeychainControllerMock!
var appLockService: AppLockServiceMock!
var viewModel: AppLockSetupBiometricsScreenViewModelProtocol!
var context: AppLockSetupBiometricsScreenViewModelType.Context { viewModel.context }
override func setUp() {
AppSettings.reset()
keychainController = KeychainControllerMock()
appLockService = AppLockService(keychainController: keychainController, appSettings: AppSettings())
appLockService = AppLockServiceMock()
appLockService.underlyingIsEnabled = true
appLockService.underlyingBiometryType = .touchID
appLockService.enableBiometricUnlockReturnValue = .success(())
viewModel = AppLockSetupBiometricsScreenViewModel(appLockService: appLockService)
}
@@ -38,24 +40,22 @@ class AppLockSetupBiometricsScreenViewModelTests: XCTestCase {
}
func testAllow() async throws {
// Given a service that has biometric unlock disabled.
XCTAssertFalse(appLockService.biometricUnlockEnabled)
// When allowing Touch/Face ID.
let deferred = deferFulfillment(viewModel.actions) { $0 == .continue }
context.send(viewAction: .allow)
try await deferred.fulfill()
// Then the service should now have biometric unlock enabled.
XCTAssertTrue(appLockService.biometricUnlockEnabled)
XCTAssertEqual(appLockService.enableBiometricUnlockCallsCount, 1)
}
func testSkip() async throws {
// Given a service that has biometric unlock disabled.
XCTAssertFalse(appLockService.biometricUnlockEnabled)
// When skipping biometrics.
let deferred = deferFulfillment(viewModel.actions) { $0 == .continue }
context.send(viewAction: .skip)
try await deferred.fulfill()
// Then the service should now have biometric unlock enabled.
XCTAssertFalse(appLockService.biometricUnlockEnabled)
XCTAssertEqual(appLockService.enableBiometricUnlockCallsCount, 0)
}
}
@@ -87,6 +87,7 @@ class AppLockSetupPINScreenViewModelTests: XCTestCase {
let pinCode = "2023"
keychainController.pinCodeReturnValue = pinCode
keychainController.containsPINCodeReturnValue = true
keychainController.containsPINCodeBiometricStateReturnValue = false
let deferred = deferFulfillment(viewModel.actions, message: "The screen should be finished.") { $0 == .complete }
context.pinCode = pinCode
@@ -123,7 +123,7 @@ class KeychainControllerTests: XCTestCase {
// When setting a PIN code.
try keychain.setPINCode("0000")
// The the PIN code should be stored.
// Then the PIN code should be stored.
try XCTAssertTrue(keychain.containsPINCode(), "The keychain should contain the PIN code.")
XCTAssertEqual(keychain.pinCode(), "0000", "The stored PIN code should match what was set.")
}
@@ -137,7 +137,7 @@ class KeychainControllerTests: XCTestCase {
// When setting a different PIN code.
try keychain.setPINCode("1234")
// The the PIN code should be updated.
// Then the PIN code should be updated.
try XCTAssertTrue(keychain.containsPINCode(), "The keychain should still contain the PIN code.")
XCTAssertEqual(keychain.pinCode(), "1234", "The stored PIN code should match the new value.")
}
@@ -151,8 +151,54 @@ class KeychainControllerTests: XCTestCase {
// When removing the PIN code.
keychain.removePINCode()
// The the PIN code should no longer be stored.
// Then the PIN code should no longer be stored.
try XCTAssertFalse(keychain.containsPINCode(), "The keychain should no longer contain the PIN code.")
XCTAssertNil(keychain.pinCode(), "There shouldn't be a stored PIN code after removing it.")
}
func testAddPINCodeBiometricState() throws {
// Given a keychain without any biometric state.
XCTAssertFalse(keychain.containsPINCodeBiometricState(), "A new keychain shouldn't contain biometric state.")
XCTAssertNil(keychain.pinCodeBiometricState(), "A new keychain shouldn't return biometric state.")
// When setting the state.
let data: Data! = "Face ID".data(using: .utf8)
try keychain.setPINCodeBiometricState(data)
// Then the state should be stored.
XCTAssertTrue(keychain.containsPINCodeBiometricState(), "The keychain should contain the biometric state.")
XCTAssertEqual(keychain.pinCodeBiometricState(), data, "The stored biometric state should match what was set.")
}
func testUpdatePINCodeBiometricState() throws {
// Given a keychain that contains PIN code biometric state.
let data: Data! = "😃".data(using: .utf8)
try keychain.setPINCodeBiometricState(data)
XCTAssertTrue(keychain.containsPINCodeBiometricState(), "The keychain should contain the biometric state.")
XCTAssertEqual(keychain.pinCodeBiometricState(), data, "The stored biometric state should match what was set.")
// When setting different state.
let newData: Data! = "😎".data(using: .utf8)
try keychain.setPINCodeBiometricState(newData)
// Then the state should be updated.
XCTAssertTrue(keychain.containsPINCodeBiometricState(), "The keychain should still contain biometric state.")
XCTAssertNotEqual(keychain.pinCodeBiometricState(), data, "The stored biometric state shouldn't match the old value.")
XCTAssertEqual(keychain.pinCodeBiometricState(), newData, "The stored biometric state should match the new value.")
}
func testRemovePINCodeBiometricState() throws {
// Given a keychain that contains PIN code biometric state.
let data: Data! = "Face ID".data(using: .utf8)
try keychain.setPINCodeBiometricState(data)
XCTAssertTrue(keychain.containsPINCodeBiometricState(), "The keychain should contain the biometric state.")
XCTAssertEqual(keychain.pinCodeBiometricState(), data, "The stored biometric state should match what was set.")
// When removing the state.
keychain.removePINCodeBiometricState()
// Then the state should no longer be stored.
XCTAssertFalse(keychain.containsPINCodeBiometricState(), "The keychain should no longer contain the biometric state.")
XCTAssertNil(keychain.pinCodeBiometricState(), "There shouldn't be any stored biometric state after removing it.")
}
}