mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-05 11:58:32 +00:00
Initial service implementation for using a PIN code. (#1912)
* Initial service implementation for using a PIN code. * Tweak Danger for commit size 600-800 lines is perfectly normal for our PRs, up it to 1000.
This commit is contained in:
@@ -117,6 +117,13 @@ final class AppSettings {
|
||||
/// An email address that should be used for support requests.
|
||||
let supportEmailAddress = "support@element.io"
|
||||
|
||||
// MARK: - Security
|
||||
|
||||
/// The amount of time the app can remain in the background for without requesting the PIN/TouchID/FaceID.
|
||||
let appLockGracePeriod: TimeInterval = 180
|
||||
/// Any codes that the user isn't allowed to use for their PIN.
|
||||
let appLockPINCodeBlockList = ["0000", "1234"]
|
||||
|
||||
// MARK: - Authentication
|
||||
|
||||
/// The URL that is opened when tapping the Learn more button on the sliding sync alert during authentication.
|
||||
|
||||
@@ -42,13 +42,13 @@ class AppLockFlowCoordinator: CoordinatorProtocol {
|
||||
|
||||
NotificationCenter.default.publisher(for: UIApplication.didEnterBackgroundNotification)
|
||||
.sink { [weak self] _ in
|
||||
self?.showPlaceholderIfNeeded()
|
||||
self?.applicationDidEnterBackground()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)
|
||||
.sink { [weak self] _ in
|
||||
self?.showUnlockScreenIfNeeded()
|
||||
self?.applicationWillEnterForeground()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
@@ -59,18 +59,26 @@ class AppLockFlowCoordinator: CoordinatorProtocol {
|
||||
|
||||
// MARK: - App unlock
|
||||
|
||||
/// Displays the unlock flow with the app's placeholder view to hide obscure the view hierarchy in the app switcher.
|
||||
private func showPlaceholderIfNeeded() {
|
||||
private func applicationDidEnterBackground() {
|
||||
guard appLockService.isEnabled else { return }
|
||||
|
||||
appLockService.applicationDidEnterBackground()
|
||||
showPlaceholder()
|
||||
}
|
||||
|
||||
private func applicationWillEnterForeground() {
|
||||
guard appLockService.isEnabled, appLockService.computeNeedsUnlock(willEnterForegroundAt: .now) else { return }
|
||||
showUnlockScreen()
|
||||
}
|
||||
|
||||
/// Displays the unlock flow with the app's placeholder view to hide obscure the view hierarchy in the app switcher.
|
||||
private func showPlaceholder() {
|
||||
navigationCoordinator.setRootCoordinator(PlaceholderScreenCoordinator(), animated: false)
|
||||
actionsSubject.send(.lockApp)
|
||||
}
|
||||
|
||||
/// Displays the unlock flow with the main unlock screen.
|
||||
private func showUnlockScreenIfNeeded() {
|
||||
guard appLockService.isEnabled, appLockService.needsUnlock else { return }
|
||||
|
||||
private func showUnlockScreen() {
|
||||
let coordinator = AppLockScreenCoordinator(parameters: .init(appLockService: appLockService))
|
||||
coordinator.actions.sink { [weak self] action in
|
||||
guard let self else { return }
|
||||
|
||||
@@ -564,6 +564,88 @@ class KeychainControllerMock: KeychainControllerProtocol {
|
||||
removeAllRestorationTokensCallsCount += 1
|
||||
removeAllRestorationTokensClosure?()
|
||||
}
|
||||
//MARK: - resetSecrets
|
||||
|
||||
var resetSecretsCallsCount = 0
|
||||
var resetSecretsCalled: Bool {
|
||||
return resetSecretsCallsCount > 0
|
||||
}
|
||||
var resetSecretsClosure: (() -> Void)?
|
||||
|
||||
func resetSecrets() {
|
||||
resetSecretsCallsCount += 1
|
||||
resetSecretsClosure?()
|
||||
}
|
||||
//MARK: - containsPINCode
|
||||
|
||||
var containsPINCodeThrowableError: Error?
|
||||
var containsPINCodeCallsCount = 0
|
||||
var containsPINCodeCalled: Bool {
|
||||
return containsPINCodeCallsCount > 0
|
||||
}
|
||||
var containsPINCodeReturnValue: Bool!
|
||||
var containsPINCodeClosure: (() throws -> Bool)?
|
||||
|
||||
func containsPINCode() throws -> Bool {
|
||||
if let error = containsPINCodeThrowableError {
|
||||
throw error
|
||||
}
|
||||
containsPINCodeCallsCount += 1
|
||||
if let containsPINCodeClosure = containsPINCodeClosure {
|
||||
return try containsPINCodeClosure()
|
||||
} else {
|
||||
return containsPINCodeReturnValue
|
||||
}
|
||||
}
|
||||
//MARK: - setPINCode
|
||||
|
||||
var setPINCodeThrowableError: Error?
|
||||
var setPINCodeCallsCount = 0
|
||||
var setPINCodeCalled: Bool {
|
||||
return setPINCodeCallsCount > 0
|
||||
}
|
||||
var setPINCodeReceivedPinCode: String?
|
||||
var setPINCodeReceivedInvocations: [String] = []
|
||||
var setPINCodeClosure: ((String) throws -> Void)?
|
||||
|
||||
func setPINCode(_ pinCode: String) throws {
|
||||
if let error = setPINCodeThrowableError {
|
||||
throw error
|
||||
}
|
||||
setPINCodeCallsCount += 1
|
||||
setPINCodeReceivedPinCode = pinCode
|
||||
setPINCodeReceivedInvocations.append(pinCode)
|
||||
try setPINCodeClosure?(pinCode)
|
||||
}
|
||||
//MARK: - pinCode
|
||||
|
||||
var pinCodeCallsCount = 0
|
||||
var pinCodeCalled: Bool {
|
||||
return pinCodeCallsCount > 0
|
||||
}
|
||||
var pinCodeReturnValue: String?
|
||||
var pinCodeClosure: (() -> String?)?
|
||||
|
||||
func pinCode() -> String? {
|
||||
pinCodeCallsCount += 1
|
||||
if let pinCodeClosure = pinCodeClosure {
|
||||
return pinCodeClosure()
|
||||
} else {
|
||||
return pinCodeReturnValue
|
||||
}
|
||||
}
|
||||
//MARK: - removePINCode
|
||||
|
||||
var removePINCodeCallsCount = 0
|
||||
var removePINCodeCalled: Bool {
|
||||
return removePINCodeCallsCount > 0
|
||||
}
|
||||
var removePINCodeClosure: (() -> Void)?
|
||||
|
||||
func removePINCode() {
|
||||
removePINCodeCallsCount += 1
|
||||
removePINCodeClosure?()
|
||||
}
|
||||
}
|
||||
class MediaPlayerMock: MediaPlayerProtocol {
|
||||
var mediaSource: MediaSourceProxy?
|
||||
|
||||
@@ -16,14 +16,33 @@
|
||||
|
||||
import LocalAuthentication
|
||||
|
||||
enum AppLockServiceError: Error {
|
||||
/// The operation failed to access the keychain.
|
||||
case keychainError
|
||||
/// The PIN code was rejected because it isn't long enough, or contains invalid characters.
|
||||
case invalidPIN
|
||||
/// The PIN code was rejected as an insecure choice.
|
||||
case weakPIN
|
||||
}
|
||||
|
||||
@MainActor
|
||||
protocol AppLockServiceProtocol {
|
||||
/// The app has been configured to automatically lock with a PIN code.
|
||||
var isEnabled: Bool { get }
|
||||
/// The app can additionally be unlocked using FaceID or TouchID.
|
||||
var supportsBiometrics: Bool { get }
|
||||
/// The app should be unlocked with a PIN code/biometrics before being presented.
|
||||
var needsUnlock: Bool { get }
|
||||
/// The type of biometric authentication supported by the device.
|
||||
var biometryType: LABiometryType { get }
|
||||
/// Whether or not the user has enabled unlock via TouchID, FaceID or (possibly) OpticID.
|
||||
var biometricUnlockEnabled: Bool { get set }
|
||||
|
||||
/// Sets the user's PIN code used to unlock the app.
|
||||
func setupPINCode(_ pinCode: String) -> Result<Void, AppLockServiceError>
|
||||
/// Disables the App Lock feature, removing the user's stored PIN code.
|
||||
func disable()
|
||||
|
||||
/// Informs the service that the app has entered the background.
|
||||
func applicationDidEnterBackground()
|
||||
/// Decides whether the app should be unlocked with a PIN code/biometrics on foregrounding.
|
||||
func computeNeedsUnlock(willEnterForegroundAt date: Date) -> Bool
|
||||
|
||||
/// Attempt to unlock the app with the supplied PIN code.
|
||||
func unlock(with pinCode: String) -> Bool
|
||||
@@ -31,25 +50,80 @@ protocol AppLockServiceProtocol {
|
||||
func unlockWithBiometrics() -> Bool
|
||||
}
|
||||
|
||||
/// The service responsible for locking and unlocking the app.
|
||||
class AppLockService: AppLockServiceProtocol {
|
||||
private let keychainController: KeychainControllerProtocol
|
||||
private let appSettings: AppSettings
|
||||
private let context = LAContext()
|
||||
|
||||
var isEnabled: Bool { appSettings.appLockFlowEnabled }
|
||||
var supportsBiometrics: Bool { true }
|
||||
var needsUnlock: Bool { true }
|
||||
private let timer: AppLockTimer
|
||||
|
||||
var isEnabled: Bool {
|
||||
do {
|
||||
guard appSettings.appLockFlowEnabled else { return false }
|
||||
return try keychainController.containsPINCode()
|
||||
} catch {
|
||||
MXLog.error("Keychain access error: \(error)")
|
||||
MXLog.error("Locking the app.")
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
var biometryType: LABiometryType { context.biometryType }
|
||||
var biometricUnlockEnabled = false // Needs to be stored, not sure if in the keychain or defaults yet.
|
||||
|
||||
init(keychainController: KeychainControllerProtocol, appSettings: AppSettings) {
|
||||
self.keychainController = keychainController
|
||||
self.appSettings = appSettings
|
||||
timer = AppLockTimer(gracePeriod: appSettings.appLockGracePeriod)
|
||||
}
|
||||
|
||||
func setupPINCode(_ pinCode: String) -> Result<Void, AppLockServiceError> {
|
||||
guard validate(pinCode) else { return .failure(.invalidPIN) }
|
||||
guard !appSettings.appLockPINCodeBlockList.contains(pinCode) else { return .failure(.weakPIN) }
|
||||
|
||||
do {
|
||||
try keychainController.setPINCode(pinCode)
|
||||
return .success(())
|
||||
} catch {
|
||||
MXLog.error("Keychain access error: \(error)")
|
||||
return .failure(.keychainError)
|
||||
}
|
||||
}
|
||||
|
||||
func disable() {
|
||||
biometricUnlockEnabled = false
|
||||
keychainController.removePINCode()
|
||||
}
|
||||
|
||||
func applicationDidEnterBackground() {
|
||||
timer.applicationDidEnterBackground()
|
||||
}
|
||||
|
||||
func computeNeedsUnlock(willEnterForegroundAt date: Date) -> Bool {
|
||||
timer.computeLockState(willEnterForegroundAt: date)
|
||||
}
|
||||
|
||||
func unlock(with pinCode: String) -> Bool {
|
||||
true
|
||||
guard pinCode == keychainController.pinCode() else { return false }
|
||||
return completeUnlock()
|
||||
}
|
||||
|
||||
func unlockWithBiometrics() -> Bool {
|
||||
guard supportsBiometrics else { return false }
|
||||
guard biometryType != .none, biometricUnlockEnabled else { return false }
|
||||
return completeUnlock()
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
/// Ensures that a provided PIN code is long enough and only contains digits.
|
||||
private func validate(_ pinCode: String) -> Bool {
|
||||
pinCode.count == 4 && pinCode.allSatisfy(\.isNumber)
|
||||
}
|
||||
|
||||
/// Shared logic for completing an unlock via a PIN or biometry.
|
||||
private func completeUnlock() -> Bool {
|
||||
timer.registerUnlock()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
//
|
||||
// Copyright 2023 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// A timer that adds a grace-period to the app before locking it.
|
||||
class AppLockTimer {
|
||||
/// The amount of time the app should remain unlocked for whilst backgrounded.
|
||||
let gracePeriod: TimeInterval
|
||||
|
||||
/// Whether the timer considers the app to be locked or not.
|
||||
///
|
||||
/// Internally this value may be incorrect, always call `needsUnlock` to get the correct value.
|
||||
private var isLocked = true
|
||||
/// The date when the app was last backgrounded whilst in an unlocked state.
|
||||
private var lastUnlockedBackground: Date?
|
||||
|
||||
/// Creates a new timer.
|
||||
/// - Parameter gracePeriod: The amount of time the app should remain unlocked for whilst backgrounded.
|
||||
init(gracePeriod: TimeInterval) {
|
||||
self.gracePeriod = 180
|
||||
}
|
||||
|
||||
/// Signals to the timer to track how long the app will be backgrounded for.
|
||||
func applicationDidEnterBackground(date: Date = .now) {
|
||||
// Only update the last background date if the app is currently unlocked.
|
||||
guard !isLocked else { return }
|
||||
lastUnlockedBackground = date
|
||||
}
|
||||
|
||||
/// Asks the timer to recompute the lock state on foregrounding.
|
||||
func computeLockState(willEnterForegroundAt date: Date) -> Bool {
|
||||
guard !isLocked, let lastUnlockedBackground else { return true }
|
||||
|
||||
isLocked = date.timeIntervalSince(lastUnlockedBackground) >= gracePeriod
|
||||
|
||||
// Don't allow changing the device's clock to unlock the app.
|
||||
if date < lastUnlockedBackground {
|
||||
isLocked = true
|
||||
}
|
||||
|
||||
return isLocked
|
||||
}
|
||||
|
||||
/// Registers a successful unlock with the timer.
|
||||
func registerUnlock() {
|
||||
isLocked = false
|
||||
}
|
||||
}
|
||||
@@ -22,24 +22,36 @@ enum KeychainControllerService: String {
|
||||
case sessions
|
||||
case tests
|
||||
|
||||
var identifier: String {
|
||||
var restorationTokenID: String {
|
||||
InfoPlistReader.main.baseBundleIdentifier + "." + rawValue
|
||||
}
|
||||
|
||||
var mainID: String {
|
||||
InfoPlistReader.main.baseBundleIdentifier + ".keychain.\(rawValue)"
|
||||
}
|
||||
}
|
||||
|
||||
class KeychainController: KeychainControllerProtocol {
|
||||
private let keychain: Keychain
|
||||
|
||||
init(service: KeychainControllerService,
|
||||
accessGroup: String) {
|
||||
keychain = Keychain(service: service.identifier,
|
||||
accessGroup: accessGroup)
|
||||
/// The keychain responsible for storing account restoration tokens (keyed by userID).
|
||||
private let restorationTokenKeychain: Keychain
|
||||
/// The keychain responsible for storing all other secrets in the app (keyed by `Key`s).
|
||||
private let mainKeychain: Keychain
|
||||
|
||||
private enum Key: String {
|
||||
case pinCode
|
||||
}
|
||||
|
||||
init(service: KeychainControllerService, accessGroup: String) {
|
||||
restorationTokenKeychain = Keychain(service: service.restorationTokenID, accessGroup: accessGroup)
|
||||
mainKeychain = Keychain(service: service.mainID, accessGroup: accessGroup)
|
||||
}
|
||||
|
||||
// MARK: - Restoration Tokens
|
||||
|
||||
func setRestorationToken(_ restorationToken: RestorationToken, forUsername username: String) {
|
||||
do {
|
||||
let tokenData = try JSONEncoder().encode(restorationToken)
|
||||
try keychain.set(tokenData, key: username)
|
||||
try restorationTokenKeychain.set(tokenData, key: username)
|
||||
} catch {
|
||||
MXLog.error("Failed storing user restore token with error: \(error)")
|
||||
}
|
||||
@@ -47,7 +59,7 @@ class KeychainController: KeychainControllerProtocol {
|
||||
|
||||
func restorationTokenForUsername(_ username: String) -> RestorationToken? {
|
||||
do {
|
||||
guard let tokenData = try keychain.getData(username) else {
|
||||
guard let tokenData = try restorationTokenKeychain.getData(username) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -59,7 +71,7 @@ class KeychainController: KeychainControllerProtocol {
|
||||
}
|
||||
|
||||
func restorationTokens() -> [KeychainCredentials] {
|
||||
keychain.allKeys().compactMap { username in
|
||||
restorationTokenKeychain.allKeys().compactMap { username in
|
||||
guard let restorationToken = restorationTokenForUsername(username) else {
|
||||
return nil
|
||||
}
|
||||
@@ -72,7 +84,7 @@ class KeychainController: KeychainControllerProtocol {
|
||||
MXLog.warning("Removing restoration token for user: \(username).")
|
||||
|
||||
do {
|
||||
try keychain.remove(username)
|
||||
try restorationTokenKeychain.remove(username)
|
||||
} catch {
|
||||
MXLog.error("Failed removing restore token with error: \(error)")
|
||||
}
|
||||
@@ -82,7 +94,7 @@ class KeychainController: KeychainControllerProtocol {
|
||||
MXLog.warning("Removing all user restoration tokens.")
|
||||
|
||||
do {
|
||||
try keychain.removeAll()
|
||||
try restorationTokenKeychain.removeAll()
|
||||
} catch {
|
||||
MXLog.error("Failed removing all tokens")
|
||||
}
|
||||
@@ -103,4 +115,41 @@ class KeychainController: KeychainControllerProtocol {
|
||||
let restorationToken = RestorationToken(session: session)
|
||||
setRestorationToken(restorationToken, forUsername: session.userId)
|
||||
}
|
||||
|
||||
// MARK: - App Secrets
|
||||
|
||||
func resetSecrets() {
|
||||
MXLog.warning("Resetting main keychain.")
|
||||
|
||||
do {
|
||||
try mainKeychain.removeAll()
|
||||
} catch {
|
||||
MXLog.error("Failed resetting the main keychain.")
|
||||
}
|
||||
}
|
||||
|
||||
func containsPINCode() throws -> Bool {
|
||||
try mainKeychain.contains(Key.pinCode.rawValue)
|
||||
}
|
||||
|
||||
func setPINCode(_ pinCode: String) throws {
|
||||
try mainKeychain.set(pinCode, key: Key.pinCode.rawValue)
|
||||
}
|
||||
|
||||
func pinCode() -> String? {
|
||||
do {
|
||||
return try mainKeychain.getString(Key.pinCode.rawValue)
|
||||
} catch {
|
||||
MXLog.error("Failed retrieving the PIN code.")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func removePINCode() {
|
||||
do {
|
||||
try mainKeychain.remove(Key.pinCode.rawValue)
|
||||
} catch {
|
||||
MXLog.error("Failed removing the PIN code.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,9 +24,24 @@ struct KeychainCredentials {
|
||||
|
||||
// sourcery: AutoMockable
|
||||
protocol KeychainControllerProtocol: ClientSessionDelegate {
|
||||
// MARK: Restoration Tokens
|
||||
|
||||
func setRestorationToken(_ restorationToken: RestorationToken, forUsername: String)
|
||||
func restorationTokenForUsername(_ username: String) -> RestorationToken?
|
||||
func restorationTokens() -> [KeychainCredentials]
|
||||
func removeRestorationTokenForUsername(_ username: String)
|
||||
func removeAllRestorationTokens()
|
||||
|
||||
// MARK: App Secrets
|
||||
|
||||
/// Removes everything from the keychain excluding any restoration tokens.
|
||||
func resetSecrets()
|
||||
/// Whether or not an App Lock PIN code has been set.
|
||||
func containsPINCode() throws -> Bool
|
||||
/// Sets a new PIN code for App Lock.
|
||||
func setPINCode(_ pinCode: String) throws
|
||||
/// The PIN code required to unlock the app.
|
||||
func pinCode() -> String?
|
||||
/// Removes the App Lock PIN code.
|
||||
func removePINCode()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user