Skip to content

Commit

Permalink
Watch network overhaul (#857)
Browse files Browse the repository at this point in the history
* Fixes networking for the Apple Watch

Signed-off-by: Dan Cunningham <dan@digitaldan.com>

* Clean up a bit

Signed-off-by: Dan Cunningham <dan@digitaldan.com>

* support actions when launching app

Signed-off-by: Dan Cunningham <dan@digitaldan.com>

* Reset xcode scheme

Signed-off-by: Dan Cunningham <dan@digitaldan.com>

* small formatting tweaks

Signed-off-by: Dan Cunningham <dan@digitaldan.com>

* Proper cert warning in the app, sycn prefs on start

Signed-off-by: Dan Cunningham <dan@digitaldan.com>

---------

Signed-off-by: Dan Cunningham <dan@digitaldan.com>
  • Loading branch information
digitaldan authored Feb 19, 2025
1 parent 7608c11 commit e378b85
Show file tree
Hide file tree
Showing 6 changed files with 321 additions and 29 deletions.
203 changes: 193 additions & 10 deletions OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<CertificateEvaluateResult, Never>?
private var trustedCertificates: [String: Data] = [:]

public init(baseURL: URL? = nil, username: String, password: String, alwaysSendBasicAuth: Bool = false, ignoreSSL: Bool = false) {
self.baseURL = baseURL
Expand All @@ -37,6 +52,7 @@ public class HTTPClient: NSObject {
config.timeoutIntervalForResource = 60

session = URLSession(configuration: config, delegate: self, delegateQueue: nil)
initializeCertificatesStore()
}

/**
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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?) {
Expand All @@ -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")
}
4 changes: 4 additions & 0 deletions OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
59 changes: 59 additions & 0 deletions openHAB/OpenHABRootViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
//
// SPDX-License-Identifier: EPL-2.0

// swiftlint:disable body_length

import Combine
import FirebaseCrashlytics
import Foundation
Expand Down Expand Up @@ -36,6 +38,7 @@ struct CommandItem: CommItem {
var link: String
}

// swiftlint:disable type_body_length
class OpenHABRootViewController: UIViewController {
var currentView: OpenHABViewController!
var isDemoMode = false
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading

0 comments on commit e378b85

Please sign in to comment.