diff --git a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift index 08738552..783a41b4 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift @@ -10,19 +10,34 @@ // SPDX-License-Identifier: EPL-2.0 import Foundation -import os.log +import os + +private let logger = Logger(subsystem: "org.openhab.core", category: "HTTPClient") + +private enum HTTPClientError: Error { + case serverTrustEvaluationFailed(reason: String) +} public class HTTPClient: NSObject { // MARK: - Properties + public enum CertificateEvaluateResult { + case undecided + case deny + case permitOnce + case permitAlways + } + + // this can be changed if we detect another server + public var baseURL: URL? + private var session: URLSession! private let username: String private let password: String private let alwaysSendBasicAuth: Bool private let ignoreSSL: Bool - - // this can be changed if we detect another server - public var baseURL: URL? + private var evaluateContinuation: CheckedContinuation? + private var trustedCertificates: [String: Data] = [:] public init(baseURL: URL? = nil, username: String, password: String, alwaysSendBasicAuth: Bool = false, ignoreSSL: Bool = false) { self.baseURL = baseURL @@ -37,6 +52,7 @@ public class HTTPClient: NSObject { config.timeoutIntervalForResource = 60 session = URLSession(configuration: config, delegate: self, delegateQueue: nil) + initializeCertificatesStore() } /** @@ -291,6 +307,90 @@ public class HTTPClient: NSObject { let authData = authString.data(using: .utf8)! return "Basic \(authData.base64EncodedString())" } + + // MARK: - SSL Certificate Handling + + private func initializeCertificatesStore() { + os_log("Initializing cert store", log: .default, type: .info) + loadTrustedCertificates() + if trustedCertificates.isEmpty { + os_log("No cert store, creating", log: .default, type: .info) + trustedCertificates = [:] + saveTrustedCertificates() + } else { + os_log("Loaded existing cert store", log: .default, type: .info) + } + } + + private func getPersistensePath() -> URL { + #if os(watchOS) + let documentsDirectory = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)[0] + return URL(fileURLWithPath: documentsDirectory).appendingPathComponent("trustedCertificates") + #else + FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.org.openhab.app")!.appendingPathComponent("trustedCertificates") + #endif + } + + private func saveTrustedCertificates() { + do { + let data = try PropertyListEncoder().encode(trustedCertificates) + try data.write(to: getPersistensePath()) + } catch { + os_log("Could not save trusted certificates", log: .default) + } + } + + private func loadTrustedCertificates() { + var decodableTrustedCertificates: [String: Data] = [:] + do { + let rawdata = try Data(contentsOf: getPersistensePath()) + let decoder = PropertyListDecoder() + decodableTrustedCertificates = try decoder.decode([String: Data].self, from: rawdata) + trustedCertificates = decodableTrustedCertificates + } catch { + // if Decodable fails, fall back to NSKeyedArchiver + do { + let rawdata = try Data(contentsOf: getPersistensePath()) + if let unarchivedTrustedCertificates = try? NSKeyedUnarchiver.unarchivedObject(ofClasses: [NSDictionary.self, NSString.self, NSData.self], from: rawdata) as? [String: Data] { + trustedCertificates = unarchivedTrustedCertificates + saveTrustedCertificates() // Ensure that data is written in new format + } + } catch { + os_log("Could not load trusted certificates", log: .default) + } + } + } + + private func storeCertificateData(_ certificate: CFData?, forDomain domain: String) { + let certificateData = certificate as Data? + trustedCertificates[domain] = certificateData + saveTrustedCertificates() + } + + private func certificateData(forDomain domain: String) -> CFData? { + guard let certificateData = trustedCertificates[domain] else { return nil } + return certificateData as CFData + } + + private func getLeafCertificate(trust: SecTrust?) -> SecCertificate? { + if let trust, SecTrustGetCertificateCount(trust) > 0, + let certificates = SecTrustCopyCertificateChain(trust) as? [SecCertificate] { + return certificates[0] + } + return nil + } + + private func waitForEvaluation() async -> CertificateEvaluateResult { + await withCheckedContinuation { continuation in + evaluateContinuation = continuation + } + } + + public func completeEvaluation(_ result: CertificateEvaluateResult) { + logger.info("Completing evaluation with result: \(String(describing: result))") + evaluateContinuation?.resume(returning: result) + evaluateContinuation = nil + } } extension HTTPClient: URLSessionDelegate, URLSessionTaskDelegate { @@ -329,14 +429,91 @@ extension HTTPClient: URLSessionDelegate, URLSessionTaskDelegate { } private func handleServerTrust(challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { - if ignoreSSL { - os_log("Ignoring SSL certificate validation", log: .networking, type: .info) - if let serverTrust = challenge.protectionSpace.serverTrust { - let credential = URLCredential(trust: serverTrust) - return (.useCredential, credential) + let domain = challenge.protectionSpace.host + logger.info("Handling server trust for domain: \(domain)") + + guard let serverTrust = challenge.protectionSpace.serverTrust else { + logger.error("No server trust object available") + return (.cancelAuthenticationChallenge, nil) + } + + var result: SecTrustResultType = .invalid + if #available(iOS 12.0, *) { + var error: CFError? + _ = SecTrustEvaluateWithError(serverTrust, &error) + SecTrustGetTrustResult(serverTrust, &result) + logger.info("Trust evaluation result: \(result.rawValue), error: \(String(describing: error))") + } else { + SecTrustEvaluate(serverTrust, &result) + logger.info("Trust evaluation result: \(result.rawValue)") + } + + if result.isAny(of: .unspecified, .proceed) || ignoreSSL { + logger.info("Certificate is trusted or SSL verification ignored") + return (.useCredential, URLCredential(trust: serverTrust)) + } + + guard let certificate = getLeafCertificate(trust: serverTrust) else { + logger.error("Could not get leaf certificate") + return (.cancelAuthenticationChallenge, nil) + } + + let certificateSummary = SecCertificateCopySubjectSummary(certificate) + let certificateData = SecCertificateCopyData(certificate) + + // If we have a certificate for this domain + if let previousCertificateData = self.certificateData(forDomain: domain) { + if CFEqual(previousCertificateData, certificateData) { + logger.info("Using previously trusted certificate for domain: \(domain)") + return (.useCredential, URLCredential(trust: serverTrust)) + } else { + logger.warning("Certificate mismatch detected for domain: \(domain)") + // Certificate mismatch - possible MitM attack + NotificationCenter.default.post( + name: .evaluateCertificateMismatch, + object: self, + userInfo: ["summary": certificateSummary as Any, "domain": domain] + ) + let evaluateResult = await waitForEvaluation() + logger.info("User decision for certificate mismatch: \(String(describing: evaluateResult))") + + switch evaluateResult { + case .deny: + return (.cancelAuthenticationChallenge, nil) + case .permitOnce: + return (.useCredential, URLCredential(trust: serverTrust)) + case .permitAlways: + storeCertificateData(certificateData, forDomain: domain) + NotificationCenter.default.post(name: .acceptedServerCertificatesChanged, object: self) + return (.useCredential, URLCredential(trust: serverTrust)) + case .undecided: + return (.cancelAuthenticationChallenge, nil) + } } } - return (.performDefaultHandling, nil) + + // New certificate + logger.info("New untrusted certificate for domain: \(domain)") + NotificationCenter.default.post( + name: .evaluateServerTrust, + object: self, + userInfo: ["summary": certificateSummary as Any, "domain": domain] + ) + let evaluateResult = await waitForEvaluation() + logger.info("User decision for new certificate: \(String(describing: evaluateResult))") + + switch evaluateResult { + case .deny: + return (.cancelAuthenticationChallenge, nil) + case .permitOnce: + return (.useCredential, URLCredential(trust: serverTrust)) + case .permitAlways: + storeCertificateData(certificateData, forDomain: domain) + NotificationCenter.default.post(name: .acceptedServerCertificatesChanged, object: self) + return (.useCredential, URLCredential(trust: serverTrust)) + case .undecided: + return (.cancelAuthenticationChallenge, nil) + } } private func handleBasicAuth(challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { @@ -363,3 +540,9 @@ extension URLSessionTask { } } } + +public extension Notification.Name { + static let evaluateServerTrust = Notification.Name("evaluateServerTrust") + static let evaluateCertificateMismatch = Notification.Name("evaluateCertificateMismatch") + static let acceptedServerCertificatesChanged = Notification.Name("acceptedServerCertificatesChanged") +} diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift index 500e7b0c..a68679ea 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -90,6 +90,10 @@ public final class NetworkTracker: ObservableObject { } } + public func restartTracking() { + attemptConnection() + } + // This gets called periodically when we have an active connection to make sure it's still the best choice private func checkActiveConnection() { guard let activeConnection else { diff --git a/openHAB/OpenHABRootViewController.swift b/openHAB/OpenHABRootViewController.swift index fe2339f3..209c1ae3 100644 --- a/openHAB/OpenHABRootViewController.swift +++ b/openHAB/OpenHABRootViewController.swift @@ -9,6 +9,8 @@ // // SPDX-License-Identifier: EPL-2.0 +// swiftlint:disable body_length + import Combine import FirebaseCrashlytics import Foundation @@ -36,6 +38,7 @@ struct CommandItem: CommItem { var link: String } +// swiftlint:disable type_body_length class OpenHABRootViewController: UIViewController { var currentView: OpenHABViewController! var isDemoMode = false @@ -133,6 +136,32 @@ class OpenHABRootViewController: UIViewController { ) .eraseToAnyPublisher() + // Register for certificate trust notifications + NotificationCenter.default.addObserver( + forName: .evaluateServerTrust, + object: nil, + queue: nil + ) { [weak self] notification in + self?.handleCertificateTrust(notification, message: NSLocalizedString("ssl_certificate_invalid", comment: "")) + } + + NotificationCenter.default.addObserver( + forName: .evaluateCertificateMismatch, + object: nil, + queue: nil + ) { [weak self] notification in + self?.handleCertificateTrust(notification, message: NSLocalizedString("ssl_certificate_no_match", comment: "")) + } + + NotificationCenter.default.addObserver( + forName: .acceptedServerCertificatesChanged, + object: nil, + queue: nil + ) { _ in + WatchMessageService.singleton.syncPreferencesToWatch() + NetworkTracker.shared.restartTracking() + } + Publishers.CombineLatest(serverInfo, misc) .debounce(for: .milliseconds(500), scheduler: RunLoop.main) // ensures if multiple values are saved, we get called once .sink { (serverInfoTuple, miscTuple) in @@ -581,6 +610,36 @@ class OpenHABRootViewController: UIViewController { switchView(target: Preferences.defaultView == "sitemap" ? .sitemap("") : .webview) } } + + @objc func handleCertificateTrust(_ notification: Notification, message: String) { + guard let summary = notification.userInfo?["summary"] as? String, + let domain = notification.userInfo?["domain"] as? String, + let client = notification.object as? HTTPClient else { return } + let title = NSLocalizedString("ssl_certificate_warning", comment: "") + let message = String(format: NSLocalizedString(message, comment: ""), summary, domain) + DispatchQueue.main.async { + // Show alert to user + let alert = UIAlertController( + title: title, + message: message, + preferredStyle: .alert + ) + + alert.addAction(UIAlertAction(title: "Always", style: .default) { _ in + client.completeEvaluation(.permitAlways) + }) + + alert.addAction(UIAlertAction(title: "Once", style: .default) { _ in + client.completeEvaluation(.permitOnce) + }) + + alert.addAction(UIAlertAction(title: "Deny", style: .cancel) { _ in + client.completeEvaluation(.deny) + }) + + self.present(alert, animated: true) + } + } } // MARK: - UISideMenuNavigationControllerDelegate diff --git a/openHABWatch/Domain/UserData.swift b/openHABWatch/Domain/UserData.swift index b684b347..0dc87264 100644 --- a/openHABWatch/Domain/UserData.swift +++ b/openHABWatch/Domain/UserData.swift @@ -41,7 +41,10 @@ final class UserData: ObservableObject { private let logger = Logger(subsystem: "org.openhab.app.watchkitapp", category: "UserData") - // Demo + // Add property near other published properties + var currentClient: HTTPClient? + + // Add to init() after decoder setup init() { decoder.dateDecodingStrategy = .formatted(DateFormatter.iso8601Full) @@ -66,7 +69,49 @@ final class UserData: ObservableObject { } init(sitemapName: String = "watch") { + NotificationCenter.default.addObserver( + forName: .evaluateServerTrust, + object: nil, + queue: .main + ) { [weak self] notification in + guard let self, + let summary = notification.userInfo?["summary"] as? String, + let domain = notification.userInfo?["domain"] as? String, + let client = notification.object as? HTTPClient else { return } + + certificateErrorDescription = String(format: NSLocalizedString("ssl_certificate_invalid", comment: ""), summary, domain) + currentClient = client + DispatchQueue.main.async { + self.showCertificateAlert = true + } + } + NotificationCenter.default.addObserver( + forName: .evaluateCertificateMismatch, + object: nil, + queue: .main + ) { [weak self] notification in + guard let self, + let summary = notification.userInfo?["summary"] as? String, + let domain = notification.userInfo?["domain"] as? String, + let client = notification.object as? HTTPClient else { return } + + certificateErrorDescription = String(format: NSLocalizedString("ssl_certificate_no_match", comment: ""), summary, domain) + currentClient = client + DispatchQueue.main.async { + self.showCertificateAlert = true + } + } + + NotificationCenter.default.addObserver( + forName: .acceptedServerCertificatesChanged, + object: nil, + queue: nil + ) { _ in + NetworkTracker.shared.restartTracking() + } + updateNetwork() + NetworkTracker.shared.$activeConnection .receive(on: DispatchQueue.main) .sink { [weak self] activeConnection in diff --git a/openHABWatch/OpenHABWatch.swift b/openHABWatch/OpenHABWatch.swift index e48af752..d41fcf91 100644 --- a/openHABWatch/OpenHABWatch.swift +++ b/openHABWatch/OpenHABWatch.swift @@ -69,5 +69,8 @@ struct OpenHABWatch: App { return request } SDWebImageDownloader.shared.requestModifier = requestModifier + DispatchQueue.main.async { + AppMessageService.singleton.requestApplicationContext() + } } } diff --git a/openHABWatch/Views/ContentView.swift b/openHABWatch/Views/ContentView.swift index 1155767c..8b7145c3 100644 --- a/openHABWatch/Views/ContentView.swift +++ b/openHABWatch/Views/ContentView.swift @@ -32,24 +32,22 @@ struct ContentView: View { } } .navigationBarTitle(Text(title)) - // Appearently this was never implemented -// .actionSheet(isPresented: $viewModel.showCertificateAlert) { -// ActionSheet( -// title: Text(NSLocalizedString("warning", comment: "")), -// message: Text(viewModel.certificateErrorDescription), -// buttons: [ -// .default(Text(NSLocalizedString("abort", comment: ""))) { -// NetworkConnection.shared.serverCertificateManager.evaluateResult = .deny -// }, -// .default(Text(NSLocalizedString("once", comment: ""))) { -// NetworkConnection.shared.serverCertificateManager.evaluateResult = .permitOnce -// }, -// .default(Text(NSLocalizedString("always", comment: ""))) { -// NetworkConnection.shared.serverCertificateManager.evaluateResult = .permitAlways -// } -// ] -// ) -// } + .alert(isPresented: $viewModel.showCertificateAlert) { + Alert( + title: Text(NSLocalizedString("ssl_certificate_warning", comment: "")), + message: Text(viewModel.certificateErrorDescription), + primaryButton: .default(Text(NSLocalizedString("always", comment: ""))) { + if let client = viewModel.currentClient { + client.completeEvaluation(.permitAlways) + } + }, + secondaryButton: .destructive(Text(NSLocalizedString("deny", comment: ""))) { + if let client = viewModel.currentClient { + client.completeEvaluation(.deny) + } + } + ) + } if viewModel.showAlert { Text("Refreshing...") .onAppear {