Browse Source

remote: add fingerprint verification for client and server

osy 1 year ago
parent
commit
eda9e94de9

+ 7 - 2
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 {

+ 3 - 2
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)

+ 11 - 7
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"))
             }
         }
     }

+ 26 - 0
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..<nextIndex], radix: 16) {
+                byteArray.append(byte)
+            } else {
+                return nil // Invalid hex character
+            }
+            index = nextIndex
+        }
+        self = byteArray
+    }
+
+    static func ^(lhs: Self, rhs: Self) -> Self {
+        let length = Swift.min(lhs.count, rhs.count)
+        return (0..<length).map({ lhs[$0] ^ rhs[$0] })
+    }
 }
 
 enum UTMRemoteKeyManagerError: Error {

+ 29 - 11
Remote/UTMRemoteServer.swift

@@ -117,6 +117,7 @@ actor UTMRemoteServer {
                 return
             }
             try await keyManager.load()
+            await state.setServerFingerprint(keyManager.fingerprint!)
             registerNotifications()
             listener = Task {
                 await withErrorNotification {
@@ -145,7 +146,7 @@ actor UTMRemoteServer {
 
     private func newRemoteConnection(_ connection: Connection) async {
         let remoteAddress = connection.connection.endpoint.hostname ?? "\(connection.connection.endpoint)"
-        guard let fingerprint = connection.peerCertificateChain.first?.fingerprint().hexString() else {
+        guard let fingerprint = connection.peerCertificateChain.first?.fingerprint() else {
             connection.close()
             return
         }
@@ -190,7 +191,7 @@ actor UTMRemoteServer {
     }
 
     private func establishConnection(_ connection: Connection) async {
-        guard let fingerprint = connection.peerCertificateChain.first?.fingerprint().hexString() else {
+        guard let fingerprint = connection.peerCertificateChain.first?.fingerprint() else {
             connection.close()
             return
         }
@@ -217,6 +218,7 @@ actor UTMRemoteServer {
     private func resetServer() async {
         await withErrorNotification {
             try await keyManager.reset()
+            await state.setServerFingerprint(keyManager.fingerprint!)
         }
     }
     
@@ -279,7 +281,7 @@ extension UTMRemoteServer {
         func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> 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<Client>()
             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
+        }
     }
 }