remote: add fingerprint verification for client and server

This commit is contained in:
osy 2024-02-12 00:01:39 -08:00
parent 427a2012a4
commit eda9e94de9
5 changed files with 76 additions and 22 deletions

View File

@ -173,9 +173,14 @@ private struct ServerConnectView: View {
} }
if !server.fingerprint.isEmpty { if !server.fingerprint.isEmpty {
Section { Section {
Text(server.fingerprint) let fingerprint = (server.fingerprint ^ remoteClient.fingerprint).hexString()
if #available(iOS 16.4, *) {
Text(fingerprint).monospaced()
} else {
Text(fingerprint)
}
} header: { } header: {
Text("Server Fingerprint") Text("Fingerprint")
} }
} }
if isPasswordRequired { if isPasswordRequired {

View File

@ -85,8 +85,9 @@ fileprivate struct ServerOverview: View {
}.width(16) }.width(16)
TableColumn("Name", value: \.name) TableColumn("Name", value: \.name)
.width(ideal: 200) .width(ideal: 200)
TableColumn("Fingerprint", value: \.fingerprint) TableColumn("Fingerprint") { client in
.width(ideal: 300) Text((client.fingerprint ^ remoteServer.serverFingerprint).hexString())
}.width(ideal: 300)
TableColumn("Last Seen", value: \.lastSeen) { client in TableColumn("Last Seen", value: \.lastSeen) { client in
Text(DateFormatter.localizedString(from: client.lastSeen, dateStyle: .short, timeStyle: .short)) Text(DateFormatter.localizedString(from: client.lastSeen, dateStyle: .short, timeStyle: .short))
}.width(ideal: 150) }.width(ideal: 150)

View File

@ -29,6 +29,10 @@ actor UTMRemoteClient {
private(set) var server: Remote! private(set) var server: Remote!
nonisolated var fingerprint: [UInt8] {
keyManager.fingerprint ?? []
}
@MainActor @MainActor
init(data: UTMRemoteData) { init(data: UTMRemoteData) {
self.state = State() self.state = State()
@ -96,7 +100,7 @@ actor UTMRemoteClient {
guard let host = connection.connection.currentPath?.remoteEndpoint?.hostname else { guard let host = connection.connection.currentPath?.remoteEndpoint?.hostname else {
throw ConnectionError.cannotDetermineHost throw ConnectionError.cannotDetermineHost
} }
guard let fingerprint = connection.peerCertificateChain.first?.fingerprint().hexString() else { guard let fingerprint = connection.peerCertificateChain.first?.fingerprint() else {
throw ConnectionError.cannotFindFingerprint throw ConnectionError.cannotFindFingerprint
} }
if server.fingerprint.isEmpty { if server.fingerprint.isEmpty {
@ -126,7 +130,7 @@ actor UTMRemoteClient {
extension UTMRemoteClient { extension UTMRemoteClient {
@MainActor @MainActor
class State: ObservableObject { class State: ObservableObject {
typealias ServerFingerprint = String typealias ServerFingerprint = [UInt8]
struct DiscoveredServer: Identifiable { struct DiscoveredServer: Identifiable {
let hostname: String let hostname: String
@ -154,7 +158,7 @@ extension UTMRemoteClient {
case fingerprint, hostname, port, model, name, lastSeen, password case fingerprint, hostname, port, model, name, lastSeen, password
} }
var id: String { var id: ServerFingerprint {
fingerprint fingerprint
} }
@ -166,7 +170,7 @@ extension UTMRemoteClient {
self.hostname = "" self.hostname = ""
self.name = "" self.name = ""
self.lastSeen = Date() self.lastSeen = Date()
self.fingerprint = "" self.fingerprint = []
} }
init(from discovered: DiscoveredServer) { init(from discovered: DiscoveredServer) {
@ -175,7 +179,7 @@ extension UTMRemoteClient {
self.name = discovered.name self.name = discovered.name
self.lastSeen = Date() self.lastSeen = Date()
self.endpoint = discovered.endpoint self.endpoint = discovered.endpoint
self.fingerprint = "" self.fingerprint = []
} }
} }
@ -465,8 +469,8 @@ extension UTMRemoteClient {
return NSLocalizedString("Password is incorrect.", comment: "UTMRemoteClient") return NSLocalizedString("Password is incorrect.", comment: "UTMRemoteClient")
case .fingerprintUntrusted(_): 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") 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): case .fingerprintMismatch(_):
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) 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"))
} }
} }
} }

View File

@ -148,6 +148,32 @@ extension Array where Element == UInt8 {
func hexString() -> String { func hexString() -> String {
self.map({ String(format: "%02X", $0) }).joined(separator: ":") 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 { enum UTMRemoteKeyManagerError: Error {

View File

@ -117,6 +117,7 @@ actor UTMRemoteServer {
return return
} }
try await keyManager.load() try await keyManager.load()
await state.setServerFingerprint(keyManager.fingerprint!)
registerNotifications() registerNotifications()
listener = Task { listener = Task {
await withErrorNotification { await withErrorNotification {
@ -145,7 +146,7 @@ actor UTMRemoteServer {
private func newRemoteConnection(_ connection: Connection) async { private func newRemoteConnection(_ connection: Connection) async {
let remoteAddress = connection.connection.endpoint.hostname ?? "\(connection.connection.endpoint)" 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() connection.close()
return return
} }
@ -190,7 +191,7 @@ actor UTMRemoteServer {
} }
private func establishConnection(_ connection: Connection) async { 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() connection.close()
return return
} }
@ -217,6 +218,7 @@ actor UTMRemoteServer {
private func resetServer() async { private func resetServer() async {
await withErrorNotification { await withErrorNotification {
try await keyManager.reset() 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) { func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
Task { Task {
let userInfo = response.notification.request.content.userInfo 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 return
} }
switch response.actionIdentifier { switch response.actionIdentifier {
@ -328,31 +330,33 @@ extension UTMRemoteServer {
center.delegate = nil 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 settings = await center.notificationSettings()
let combinedFingerprint = (fingerprint ^ keyManager.fingerprint!).hexString()
guard settings.authorizationStatus == .authorized else { 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 return
} }
let content = UNMutableNotificationContent() let content = UNMutableNotificationContent()
if isUnknown { if isUnknown {
content.title = NSString.localizedUserNotificationString(forKey: "Unknown Remote Client", arguments: nil) 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" content.categoryIdentifier = "UNKNOWN_REMOTE_CLIENT"
} else { } else {
content.title = NSString.localizedUserNotificationString(forKey: "Remote Client Connected", arguments: nil) content.title = NSString.localizedUserNotificationString(forKey: "Remote Client Connected", arguments: nil)
content.body = NSString.localizedUserNotificationString(forKey: "Established connection from %@.", arguments: [remoteAddress]) content.body = NSString.localizedUserNotificationString(forKey: "Established connection from %@.", arguments: [remoteAddress])
content.categoryIdentifier = "TRUSTED_REMOTE_CLIENT" content.categoryIdentifier = "TRUSTED_REMOTE_CLIENT"
} }
content.userInfo = ["FINGERPRINT": fingerprint] let clientFingerprint = fingerprint.hexString()
let request = UNNotificationRequest(identifier: fingerprint, content.userInfo = ["FINGERPRINT": clientFingerprint]
let request = UNNotificationRequest(identifier: clientFingerprint,
content: content, content: content,
trigger: nil) trigger: nil)
do { do {
try await center.add(request) try await center.add(request)
if !isUnknown { if !isUnknown {
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(15)) { DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(15)) {
self.center.removeDeliveredNotifications(withIdentifiers: [fingerprint]) self.center.removeDeliveredNotifications(withIdentifiers: [clientFingerprint])
} }
} }
} catch { } catch {
@ -383,13 +387,14 @@ extension UTMRemoteServer {
extension UTMRemoteServer { extension UTMRemoteServer {
@MainActor @MainActor
class State: ObservableObject { class State: ObservableObject {
typealias ClientFingerprint = String typealias ClientFingerprint = [UInt8]
typealias ServerFingerprint = [UInt8]
struct Client: Codable, Identifiable, Hashable { struct Client: Codable, Identifiable, Hashable {
let fingerprint: ClientFingerprint let fingerprint: ClientFingerprint
var name: String var name: String
var lastSeen: Date var lastSeen: Date
var id: String { var id: ClientFingerprint {
fingerprint fingerprint
} }
@ -440,6 +445,12 @@ extension UTMRemoteServer {
@Published private(set) var isServerActive = false @Published private(set) var isServerActive = false
@Published private(set) var serverFingerprint: ServerFingerprint = [] {
didSet {
UserDefaults.standard.setValue(serverFingerprint.hexString(), forKey: "ServerFingerprint")
}
}
init() { init() {
var _approvedClients = Set<Client>() var _approvedClients = Set<Client>()
if let array = UserDefaults.standard.array(forKey: "TrustedClients") { if let array = UserDefaults.standard.array(forKey: "TrustedClients") {
@ -456,6 +467,9 @@ extension UTMRemoteServer {
} }
self.blockedClients = _blockedClients self.blockedClients = _blockedClients
self.allClients = Array(_approvedClients) + Array(_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 { func isConnected(_ fingerprint: ClientFingerprint) -> Bool {
@ -522,6 +536,10 @@ extension UTMRemoteServer {
approvedClients.remove(client) approvedClients.remove(client)
blockedClients.insert(client) blockedClients.insert(client)
} }
fileprivate func setServerFingerprint(_ fingerprint: ServerFingerprint) {
serverFingerprint = fingerprint
}
} }
} }