diff --git a/iosApp/honeyDue.xcodeproj/project.pbxproj b/iosApp/honeyDue.xcodeproj/project.pbxproj index 28607f8..4fc98b0 100644 --- a/iosApp/honeyDue.xcodeproj/project.pbxproj +++ b/iosApp/honeyDue.xcodeproj/project.pbxproj @@ -758,6 +758,7 @@ INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = NO; INFOPLIST_KEY_NSCameraUsageDescription = "honeyDue needs camera access to take photos of tasks, documents, and receipts."; + INFOPLIST_KEY_NSFaceIDUsageDescription = "honeyDue uses Face ID to unlock the app."; INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "honeyDue needs permission to save photos to your library."; INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "honeyDue needs photo library access to attach photos to tasks and documents."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; @@ -1246,6 +1247,7 @@ INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = NO; INFOPLIST_KEY_NSCameraUsageDescription = "honeyDue needs camera access to take photos of tasks, documents, and receipts."; + INFOPLIST_KEY_NSFaceIDUsageDescription = "honeyDue uses Face ID to unlock the app."; INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "honeyDue needs permission to save photos to your library."; INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "honeyDue needs photo library access to attach photos to tasks and documents."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; diff --git a/iosApp/iosApp/AppLock/AppLockManager.swift b/iosApp/iosApp/AppLock/AppLockManager.swift new file mode 100644 index 0000000..43c551d --- /dev/null +++ b/iosApp/iosApp/AppLock/AppLockManager.swift @@ -0,0 +1,125 @@ +import Foundation +import LocalAuthentication +import CryptoKit + +/// App-lock: gates an already-authenticated session behind Face ID / Touch ID +/// with a numeric-PIN fallback. Sits ABOVE auth — RootView overlays the lock +/// screen when an authenticated user has it enabled and it's armed. All state +/// lives in the Keychain (via KeychainHelper). Fully disabled under UI tests so +/// the XCUITest suite is never gated by a lock screen. +@MainActor +final class AppLockManager: ObservableObject { + static let shared = AppLockManager() + + static let pinLength = 6 + + private enum Key { + static let enabled = "app_lock_enabled" + static let pinHash = "app_lock_pin_hash" + static let biometric = "app_lock_biometric_enabled" + } + + /// True when the lock screen should cover the app. + @Published private(set) var isLocked: Bool = false + + private let keychain = KeychainHelper.shared + + private init() { + // Locked on cold launch if the user enabled it. + isLocked = isEnabled + } + + /// Whether app-lock is on. Always false under UI tests. + var isEnabled: Bool { + if UITestRuntime.isEnabled { return false } + return keychain.get(key: Key.enabled) == "true" + } + + var isBiometricEnabled: Bool { + keychain.get(key: Key.biometric) == "true" + } + + var biometricAvailable: Bool { + LAContext().canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil) + } + + var biometryType: LABiometryType { + let ctx = LAContext() + _ = ctx.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil) + return ctx.biometryType + } + + // MARK: - Enable / disable (settings) + + func enable(pin: String, useBiometrics: Bool) { + _ = keychain.save(key: Key.pinHash, value: Self.hash(pin)) + _ = keychain.save(key: Key.biometric, value: useBiometrics ? "true" : "false") + _ = keychain.save(key: Key.enabled, value: "true") + objectWillChange.send() + } + + func disable() { + _ = keychain.delete(key: Key.enabled) + _ = keychain.delete(key: Key.pinHash) + _ = keychain.delete(key: Key.biometric) + isLocked = false + objectWillChange.send() + } + + func setBiometric(_ on: Bool) { + _ = keychain.save(key: Key.biometric, value: on ? "true" : "false") + objectWillChange.send() + } + + // MARK: - Lock / unlock + + /// Arm the lock when leaving the foreground so returning requires re-auth. + /// Called on scenePhase `.background`; the lock screen then also serves as + /// the app-switcher privacy cover. + func lockOnBackground() { + if isEnabled { isLocked = true } + } + + /// Unlock on a correct PIN (constant-time compare). + @discardableResult + func unlock(withPIN pin: String) -> Bool { + guard let stored = keychain.get(key: Key.pinHash) else { return false } + let ok = Self.constantTimeEquals(Self.hash(pin), stored) + if ok { isLocked = false } + return ok + } + + /// Unlock via Face ID / Touch ID. + func unlockWithBiometrics() async -> Bool { + let ctx = LAContext() + guard ctx.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil) else { return false } + do { + let ok = try await ctx.evaluatePolicy( + .deviceOwnerAuthenticationWithBiometrics, + localizedReason: String(localized: "Unlock honeyDue")) + if ok { isLocked = false } + return ok + } catch { + return false + } + } + + /// Called on logout — never leave the login screen covered. + func clearLockState() { + isLocked = false + } + + // MARK: - PIN hashing + + private static func hash(_ pin: String) -> String { + SHA256.hash(data: Data(pin.utf8)).map { String(format: "%02x", $0) }.joined() + } + + private static func constantTimeEquals(_ a: String, _ b: String) -> Bool { + let ab = Array(a.utf8), bb = Array(b.utf8) + guard ab.count == bb.count else { return false } + var diff: UInt8 = 0 + for i in 0.. Void + var onCancel: () -> Void + + @State private var first = "" + @State private var confirm = "" + @State private var confirming = false + @State private var mismatch = false + @FocusState private var focused: Bool + + private let pinLength = AppLockManager.pinLength + + var body: some View { + NavigationStack { + VStack(spacing: 24) { + Text(confirming ? "Re-enter your PIN" : "Choose a \(pinLength)-digit PIN") + .font(.system(size: 18, weight: .semibold, design: .rounded)) + .foregroundColor(Color.appTextPrimary) + .padding(.top, 40) + + HStack(spacing: 16) { + ForEach(0..