import CryptoKit import Foundation import LocalAuthentication import OSLog import SwiftData private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "hellbender", category: "AppLock") @Observable @MainActor final class AppLockViewModel { var isLocked = true var needsPINEntry = false var isAuthenticating = false var pinInput = "" var pinError = "" private(set) var failedAttempts: Int = 0 private(set) var lockoutExpiry: Date? private var backgroundTime: Date? // MARK: - Computed var isLockedOut: Bool { guard let expiry = lockoutExpiry else { return false } return Date() < expiry } var lockoutRemainingText: String { guard let expiry = lockoutExpiry else { return "" } let remaining = expiry.timeIntervalSinceNow guard remaining > 0 else { return "" } if remaining > 3600 { let hours = Int(remaining / 3600) let minutes = Int((remaining.truncatingRemainder(dividingBy: 3600)) / 60) return "Try again in \(hours)h \(minutes)m" } else if remaining > 60 { return "Try again in \(Int(remaining / 60))m" } else { return "Try again in \(Int(remaining))s" } } private(set) var hasPIN: Bool = false private(set) var storedPINLength: Int = 6 // MARK: - Init init() { hasPIN = KeychainHelper.load(forKey: Constants.keychainPINHashKey) != nil if let data = KeychainHelper.load(forKey: Constants.keychainPINLengthKey), let str = String(data: data, encoding: .utf8), let len = Int(str) { storedPINLength = len } loadPersistedState() } // MARK: - Authentication func authenticate() { guard !isAuthenticating else { return } isAuthenticating = true logger.info("Starting biometric authentication") let context = LAContext() var error: NSError? guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) else { logger.warning("Biometric policy unavailable: \(error?.localizedDescription ?? "unknown", privacy: .public)") isLocked = false isAuthenticating = false return } context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: "Unlock \(Constants.appName)") { success, _ in DispatchQueue.main.async { [weak self] in guard let self else { return } if success { if hasPIN { logger.info("Biometric success, requesting PIN") needsPINEntry = true } else { logger.info("Biometric success, app unlocked") isLocked = false } } else { logger.info("Biometric authentication declined") } isAuthenticating = false } } } // MARK: - PIN Management func verifyPIN(_ pin: String) -> Bool { if isLockedOut { pinError = lockoutRemainingText pinInput = "" return false } guard let storedHash = KeychainHelper.load(forKey: Constants.keychainPINHashKey) else { return false } let inputHash = hashPIN(pin) if inputHash == storedHash { logger.info("PIN verified successfully, app unlocked") failedAttempts = 0 persistFailedAttempts() lockoutExpiry = nil persistLockoutExpiry() pinError = "" needsPINEntry = false isLocked = false return true } else { failedAttempts += 1 persistFailedAttempts() applyLockout() pinInput = "" let attempts = failedAttempts logger.warning("PIN verification failed (attempt \(attempts)/10)") if failedAttempts >= 10 { pinError = "Too many attempts" } else if isLockedOut { pinError = lockoutRemainingText } else { pinError = "Incorrect PIN (\(failedAttempts)/10)" } return false } } func setPIN(_ pin: String) { logger.info("PIN set (\(pin.count) digits)") let hash = hashPIN(pin) KeychainHelper.save(hash, forKey: Constants.keychainPINHashKey) KeychainHelper.save(Data("\(pin.count)".utf8), forKey: Constants.keychainPINLengthKey) failedAttempts = 0 persistFailedAttempts() lockoutExpiry = nil persistLockoutExpiry() hasPIN = true storedPINLength = pin.count } func removePIN() { logger.info("PIN removed") KeychainHelper.delete(forKey: Constants.keychainPINHashKey) KeychainHelper.delete(forKey: Constants.keychainPINLengthKey) KeychainHelper.delete(forKey: Constants.keychainFailedAttemptsKey) KeychainHelper.delete(forKey: Constants.keychainLockoutExpiryKey) failedAttempts = 0 lockoutExpiry = nil hasPIN = false storedPINLength = 6 } // MARK: - Background / Foreground func handleBackground(at date: Date = Date()) { if backgroundTime == nil { logger.info("App entering background") backgroundTime = date } } func handleForeground(timeout: Int) { if let bgTime = backgroundTime { let elapsed = Int(Date().timeIntervalSince(bgTime)) if elapsed >= timeout { logger.info("Inactivity timeout exceeded (\(elapsed)s >= \(timeout)s), re-locking") isLocked = true needsPINEntry = false pinInput = "" pinError = "" authenticate() } else { logger.info("App returning to foreground (\(elapsed)s < \(timeout)s timeout)") } } backgroundTime = nil } // MARK: - Data Wipe func wipeAllData(modelContext: ModelContext) { let attempts = failedAttempts logger.critical("Wiping all data after \(attempts) failed PIN attempts") // Delete SwiftData records try? modelContext.delete(model: WalletProfile.self) try? modelContext.delete(model: CosignerInfo.self) try? modelContext.delete(model: WalletLabel.self) try? modelContext.delete(model: FrozenUTXO.self) try? modelContext.delete(model: SavedPSBT.self) try? modelContext.save() // Delete wallet files try? FileManager.default.removeItem(at: Constants.walletsDirectory()) // Clear UserDefaults if let bundleID = Bundle.main.bundleIdentifier { UserDefaults.standard.removePersistentDomain(forName: bundleID) } // Clear Keychain KeychainHelper.deleteAll() // Reset BitcoinService BitcoinService.shared.unloadWallet() // Unlock — wiped state will show setup wizard isLocked = false needsPINEntry = false failedAttempts = 0 lockoutExpiry = nil } // MARK: - Private private func hashPIN(_ pin: String) -> Data { let digest = SHA256.hash(data: Data(pin.utf8)) return Data(digest) } private func applyLockout() { let delay: TimeInterval? = switch failedAttempts { case 1 ... 3: nil case 4: 60 case 5: 600 case 6: 5400 case 7 ... 9: 86400 default: nil // 10+ handled by wipe } if let delay { let attempts = failedAttempts logger.warning("Lockout applied: \(Int(delay))s after \(attempts) failed attempts") lockoutExpiry = Date().addingTimeInterval(delay) persistLockoutExpiry() } } private func loadPersistedState() { if let data = KeychainHelper.load(forKey: Constants.keychainFailedAttemptsKey), let str = String(data: data, encoding: .utf8), let count = Int(str) { failedAttempts = count } if let data = KeychainHelper.load(forKey: Constants.keychainLockoutExpiryKey), let str = String(data: data, encoding: .utf8), let interval = Double(str) { let date = Date(timeIntervalSince1970: interval) lockoutExpiry = date > Date() ? date : nil } } private func persistFailedAttempts() { KeychainHelper.save(Data("\(failedAttempts)".utf8), forKey: Constants.keychainFailedAttemptsKey) } private func persistLockoutExpiry() { if let expiry = lockoutExpiry { KeychainHelper.save(Data("\(expiry.timeIntervalSince1970)".utf8), forKey: Constants.keychainLockoutExpiryKey) } else { KeychainHelper.delete(forKey: Constants.keychainLockoutExpiryKey) } } }