#8 add app lock with passcode and biometric unlock
- Gate every layer behind AppLockView when the lock is enabled and lock on launch and on backgrounding; the passcode lives only in the Keychain (AppLockVault) and the enable state defaults to off. - Add the lock section to Settings: enable/setup, change passcode and disable all require the current passcode via a shared passcode sheet; Face ID / Touch ID unlock prompts automatically and can be toggled with passcode confirmation. - Add NSFaceIDUsageDescription to both app targets, cover the passcode policy with tests, guard the feature in verify.sh and document it in SECURITY.md.
This commit is contained in:
1 parent
b0eb44d7a7
commit
0d78e02d2b
13 files changed
+663
-21
No files matched your search
@@ -1,6 +1,7 @@
|
||||
import AVFoundation
|
||||
import Combine
|
||||
import Foundation
|
||||
import LocalAuthentication
|
||||
import SwiftData
|
||||
import UniformTypeIdentifiers
|
||||
import WebRTC
|
||||
@@ -56,6 +57,9 @@ final class AppModel: ObservableObject {
|
||||
@Published var errorMessage: String?
|
||||
@Published var informationalMessage: String?
|
||||
@Published var previewURL: URL?
|
||||
@Published private(set) var isAppLocked = false
|
||||
@Published private(set) var appLockIsEnabled = false
|
||||
@Published private(set) var appLockBiometricIsEnabled = false
|
||||
@Published private(set) var mediaViewerItem: MediaViewerItem?
|
||||
@Published private(set) var mediaPreviewURLs: [String: URL] = [:]
|
||||
@Published private(set) var mediaThumbnailData: [String: Data] = [:]
|
||||
@@ -68,6 +72,7 @@ final class AppModel: ObservableObject {
|
||||
private let xmpp: XMPPService
|
||||
private let credentials: CredentialVault
|
||||
private let preferences: AccountPreferences
|
||||
private let appLock = AppLockVault()
|
||||
private let notifications: NotificationCoordinator
|
||||
private let watchBridge: PhoneWatchBridge
|
||||
private let avatarCache: AvatarCache
|
||||
@@ -129,6 +134,13 @@ final class AppModel: ObservableObject {
|
||||
self.watchBridge = watchBridge
|
||||
self.avatarCache = avatarCache
|
||||
|
||||
// The app lock must be initialised before any view reads it; the
|
||||
// locked state persists only in memory and is re-applied from the
|
||||
// stored preferences on launch.
|
||||
appLockIsEnabled = appLock.isEnabled
|
||||
appLockBiometricIsEnabled = appLock.biometricUnlockEnabled
|
||||
isAppLocked = appLock.isEnabled && !RuntimeEnvironment.isRunningTests
|
||||
|
||||
xmpp.eventHandler = { [weak self] event in
|
||||
self?.consume(event)
|
||||
}
|
||||
@@ -454,12 +466,89 @@ final class AppModel: ObservableObject {
|
||||
if !active {
|
||||
resetTypingState()
|
||||
syncWatch(immediate: true)
|
||||
if appLockIsEnabled {
|
||||
isAppLocked = true
|
||||
}
|
||||
}
|
||||
if active, case .disconnected(_) = connectionStatus {
|
||||
Task { await reconnect() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - App lock
|
||||
|
||||
func unlockWithPasscode(_ passcode: String) -> Bool {
|
||||
guard appLock.verify(passcode: passcode) else { return false }
|
||||
isAppLocked = false
|
||||
return true
|
||||
}
|
||||
|
||||
func unlockWithBiometrics() async -> Bool {
|
||||
guard appLockIsEnabled, appLockBiometricIsEnabled else { return false }
|
||||
let context = LAContext()
|
||||
var error: NSError?
|
||||
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
|
||||
else {
|
||||
return false
|
||||
}
|
||||
do {
|
||||
let granted = try await context.evaluatePolicy(
|
||||
.deviceOwnerAuthenticationWithBiometrics,
|
||||
localizedReason: "Разблокируйте Luma"
|
||||
)
|
||||
guard granted else { return false }
|
||||
isAppLocked = false
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func enableAppLock(passcode: String) -> Bool {
|
||||
guard AppLockPolicy.isValid(passcode) else { return false }
|
||||
do {
|
||||
try appLock.save(passcode: passcode)
|
||||
appLock.isEnabled = true
|
||||
appLockIsEnabled = true
|
||||
isAppLocked = false
|
||||
return true
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func disableAppLock(passcode: String) -> Bool {
|
||||
guard appLock.verify(passcode: passcode) else { return false }
|
||||
try? appLock.deletePasscode()
|
||||
appLock.isEnabled = false
|
||||
appLock.biometricUnlockEnabled = false
|
||||
appLockIsEnabled = false
|
||||
appLockBiometricIsEnabled = false
|
||||
isAppLocked = false
|
||||
return true
|
||||
}
|
||||
|
||||
func setAppLockBiometricUnlock(_ enabled: Bool, passcode: String) -> Bool {
|
||||
guard appLock.verify(passcode: passcode) else { return false }
|
||||
appLock.biometricUnlockEnabled = enabled
|
||||
appLockBiometricIsEnabled = enabled
|
||||
return true
|
||||
}
|
||||
|
||||
func changeAppLockPasscode(from oldPasscode: String, to newPasscode: String) -> Bool {
|
||||
guard AppLockPolicy.isValid(newPasscode), appLock.verify(passcode: oldPasscode) else {
|
||||
return false
|
||||
}
|
||||
do {
|
||||
try appLock.save(passcode: newPasscode)
|
||||
return true
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func setVideoNoteCaptureActive(_ active: Bool) {
|
||||
videoNoteCaptureIsActive = active
|
||||
updateArchiveSyncSuspensionForMedia()
|
||||
|
||||
Reference in new issue
Block a user