From 48b749c0b2cd3329032ea7e07e702c96e2e98502 Mon Sep 17 00:00:00 2001 From: osy <50960678+osy@users.noreply.github.com> Date: Mon, 12 Feb 2024 00:01:39 -0800 Subject: [PATCH] remote: add fingerprint verification for client and server --- Platform/iOS/UTMRemoteConnectView.swift | 9 ++++-- Platform/macOS/UTMServerView.swift | 5 ++-- Remote/UTMRemoteClient.swift | 18 ++++++----- Remote/UTMRemoteKeyManager.swift | 26 ++++++++++++++++ Remote/UTMRemoteServer.swift | 40 ++++++++++++++++++------- 5 files changed, 76 insertions(+), 22 deletions(-) diff --git a/Platform/iOS/UTMRemoteConnectView.swift b/Platform/iOS/UTMRemoteConnectView.swift index a4d66d246..0b0b22bda 100644 --- a/Platform/iOS/UTMRemoteConnectView.swift +++ b/Platform/iOS/UTMRemoteConnectView.swift @@ -173,9 +173,14 @@ private struct ServerConnectView: View { } if !server.fingerprint.isEmpty { Section { - Text(server.fingerprint) + let fingerprint = (server.fingerprint ^ remoteClient.fingerprint).hexString() + if #available(iOS 16.4, *) { + Text(fingerprint).monospaced() + } else { + Text(fingerprint) + } } header: { - Text("Server Fingerprint") + Text("Fingerprint") } } if isPasswordRequired { diff --git a/Platform/macOS/UTMServerView.swift b/Platform/macOS/UTMServerView.swift index 3cefdb06f..0a278e0de 100644 --- a/Platform/macOS/UTMServerView.swift +++ b/Platform/macOS/UTMServerView.swift @@ -85,8 +85,9 @@ fileprivate struct ServerOverview: View { }.width(16) TableColumn("Name", value: \.name) .width(ideal: 200) - TableColumn("Fingerprint", value: \.fingerprint) - .width(ideal: 300) + TableColumn("Fingerprint") { client in + Text((client.fingerprint ^ remoteServer.serverFingerprint).hexString()) + }.width(ideal: 300) TableColumn("Last Seen", value: \.lastSeen) { client in Text(DateFormatter.localizedString(from: client.lastSeen, dateStyle: .short, timeStyle: .short)) }.width(ideal: 150) diff --git a/Remote/UTMRemoteClient.swift b/Remote/UTMRemoteClient.swift index 22d01f1b8..b6c27abe8 100644 --- a/Remote/UTMRemoteClient.swift +++ b/Remote/UTMRemoteClient.swift @@ -29,6 +29,10 @@ actor UTMRemoteClient { private(set) var server: Remote! + nonisolated var fingerprint: [UInt8] { + keyManager.fingerprint ?? [] + } + @MainActor init(data: UTMRemoteData) { self.state = State() @@ -96,7 +100,7 @@ actor UTMRemoteClient { guard let host = connection.connection.currentPath?.remoteEndpoint?.hostname else { throw ConnectionError.cannotDetermineHost } - guard let fingerprint = connection.peerCertificateChain.first?.fingerprint().hexString() else { + guard let fingerprint = connection.peerCertificateChain.first?.fingerprint() else { throw ConnectionError.cannotFindFingerprint } if server.fingerprint.isEmpty { @@ -126,7 +130,7 @@ actor UTMRemoteClient { extension UTMRemoteClient { @MainActor class State: ObservableObject { - typealias ServerFingerprint = String + typealias ServerFingerprint = [UInt8] struct DiscoveredServer: Identifiable { let hostname: String @@ -154,7 +158,7 @@ extension UTMRemoteClient { case fingerprint, hostname, port, model, name, lastSeen, password } - var id: String { + var id: ServerFingerprint { fingerprint } @@ -166,7 +170,7 @@ extension UTMRemoteClient { self.hostname = "" self.name = "" self.lastSeen = Date() - self.fingerprint = "" + self.fingerprint = [] } init(from discovered: DiscoveredServer) { @@ -175,7 +179,7 @@ extension UTMRemoteClient { self.name = discovered.name self.lastSeen = Date() self.endpoint = discovered.endpoint - self.fingerprint = "" + self.fingerprint = [] } } @@ -465,8 +469,8 @@ extension UTMRemoteClient { return NSLocalizedString("Password is incorrect.", comment: "UTMRemoteClient") case .fingerprintUntrusted(_): return NSLocalizedString("This host is not yet trusted. You should verify that the fingerprints match what is displayed on the host and then select Trust to continue.", comment: "UTMRemoteClient") - case .fingerprintMismatch(let fingerprint): - return String.localizedStringWithFormat(NSLocalizedString("The fingerprint '\(fingerprint)' does not match the saved value for this host. This means that the UTM Server was reset, a different host is using the same name, or an attacker is pretending to be the host. For your protection, you need to delete this saved host to continue.", comment: "UTMRemoteClient"), fingerprint) + case .fingerprintMismatch(_): + return String.localizedStringWithFormat(NSLocalizedString("The host fingerprint does not match the saved value. This means that UTM Server was reset, a different host is using the same name, or an attacker is pretending to be the host. For your protection, you need to delete this saved host to continue.", comment: "UTMRemoteClient")) } } } diff --git a/Remote/UTMRemoteKeyManager.swift b/Remote/UTMRemoteKeyManager.swift index eadabc51f..4df5da5aa 100644 --- a/Remote/UTMRemoteKeyManager.swift +++ b/Remote/UTMRemoteKeyManager.swift @@ -148,6 +148,32 @@ extension Array where Element == UInt8 { func hexString() -> String { self.map({ String(format: "%02X", $0) }).joined(separator: ":") } + + init?(hexString: String) { + let cleanString = hexString.replacingOccurrences(of: ":", with: "") + guard cleanString.count % 2 == 0 else { + return nil + } + + var byteArray = [UInt8]() + var index = cleanString.startIndex + + while index < cleanString.endIndex { + let nextIndex = cleanString.index(index, offsetBy: 2) + if let byte = UInt8(cleanString[index.. Self { + let length = Swift.min(lhs.count, rhs.count) + return (0.. Void) { Task { let userInfo = response.notification.request.content.userInfo - guard let fingerprint = userInfo["FINGERPRINT"] as? String else { + guard let hexString = userInfo["FINGERPRINT"] as? String, let fingerprint = State.ClientFingerprint(hexString: hexString) else { return } switch response.actionIdentifier { @@ -328,31 +330,33 @@ extension UTMRemoteServer { center.delegate = nil } - private func notifyNewConnection(remoteAddress: String, fingerprint: String, isUnknown: Bool = false) async { + private func notifyNewConnection(remoteAddress: String, fingerprint: State.ClientFingerprint, isUnknown: Bool = false) async { let settings = await center.notificationSettings() + let combinedFingerprint = (fingerprint ^ keyManager.fingerprint!).hexString() guard settings.authorizationStatus == .authorized else { - logger.info("Notifications disabled, ignoring connection request from '\(remoteAddress)' with fingerprint '\(fingerprint)'") + logger.info("Notifications disabled, ignoring connection request from '\(remoteAddress)' with fingerprint '\(combinedFingerprint)'") return } let content = UNMutableNotificationContent() if isUnknown { content.title = NSString.localizedUserNotificationString(forKey: "Unknown Remote Client", arguments: nil) - content.body = NSString.localizedUserNotificationString(forKey: "A client with fingerprint '%@' is attempting to connect.", arguments: [fingerprint]) + content.body = NSString.localizedUserNotificationString(forKey: "A client with fingerprint '%@' is attempting to connect.", arguments: [combinedFingerprint]) content.categoryIdentifier = "UNKNOWN_REMOTE_CLIENT" } else { content.title = NSString.localizedUserNotificationString(forKey: "Remote Client Connected", arguments: nil) content.body = NSString.localizedUserNotificationString(forKey: "Established connection from %@.", arguments: [remoteAddress]) content.categoryIdentifier = "TRUSTED_REMOTE_CLIENT" } - content.userInfo = ["FINGERPRINT": fingerprint] - let request = UNNotificationRequest(identifier: fingerprint, + let clientFingerprint = fingerprint.hexString() + content.userInfo = ["FINGERPRINT": clientFingerprint] + let request = UNNotificationRequest(identifier: clientFingerprint, content: content, trigger: nil) do { try await center.add(request) if !isUnknown { DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(15)) { - self.center.removeDeliveredNotifications(withIdentifiers: [fingerprint]) + self.center.removeDeliveredNotifications(withIdentifiers: [clientFingerprint]) } } } catch { @@ -383,13 +387,14 @@ extension UTMRemoteServer { extension UTMRemoteServer { @MainActor class State: ObservableObject { - typealias ClientFingerprint = String + typealias ClientFingerprint = [UInt8] + typealias ServerFingerprint = [UInt8] struct Client: Codable, Identifiable, Hashable { let fingerprint: ClientFingerprint var name: String var lastSeen: Date - var id: String { + var id: ClientFingerprint { fingerprint } @@ -440,6 +445,12 @@ extension UTMRemoteServer { @Published private(set) var isServerActive = false + @Published private(set) var serverFingerprint: ServerFingerprint = [] { + didSet { + UserDefaults.standard.setValue(serverFingerprint.hexString(), forKey: "ServerFingerprint") + } + } + init() { var _approvedClients = Set() if let array = UserDefaults.standard.array(forKey: "TrustedClients") { @@ -456,6 +467,9 @@ extension UTMRemoteServer { } self.blockedClients = _blockedClients self.allClients = Array(_approvedClients) + Array(_blockedClients) + if let value = UserDefaults.standard.string(forKey: "ServerFingerprint"), let serverFingerprint = ServerFingerprint(hexString: value) { + self.serverFingerprint = serverFingerprint + } } func isConnected(_ fingerprint: ClientFingerprint) -> Bool { @@ -522,6 +536,10 @@ extension UTMRemoteServer { approvedClients.remove(client) blockedClients.insert(client) } + + fileprivate func setServerFingerprint(_ fingerprint: ServerFingerprint) { + serverFingerprint = fingerprint + } } }