Skip to content

Commit

Permalink
remote: add fingerprint verification for client and server
Browse files Browse the repository at this point in the history
  • Loading branch information
osy committed Feb 12, 2024
1 parent 8ecdb85 commit 48b749c
Show file tree
Hide file tree
Showing 5 changed files with 76 additions and 22 deletions.
9 changes: 7 additions & 2 deletions Platform/iOS/UTMRemoteConnectView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
5 changes: 3 additions & 2 deletions Platform/macOS/UTMServerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
18 changes: 11 additions & 7 deletions Remote/UTMRemoteClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ actor UTMRemoteClient {

private(set) var server: Remote!

nonisolated var fingerprint: [UInt8] {
keyManager.fingerprint ?? []
}

@MainActor
init(data: UTMRemoteData) {
self.state = State()
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -126,7 +130,7 @@ actor UTMRemoteClient {
extension UTMRemoteClient {
@MainActor
class State: ObservableObject {
typealias ServerFingerprint = String
typealias ServerFingerprint = [UInt8]

struct DiscoveredServer: Identifiable {
let hostname: String
Expand Down Expand Up @@ -154,7 +158,7 @@ extension UTMRemoteClient {
case fingerprint, hostname, port, model, name, lastSeen, password
}

var id: String {
var id: ServerFingerprint {
fingerprint
}

Expand All @@ -166,7 +170,7 @@ extension UTMRemoteClient {
self.hostname = ""
self.name = ""
self.lastSeen = Date()
self.fingerprint = ""
self.fingerprint = []
}

init(from discovered: DiscoveredServer) {
Expand All @@ -175,7 +179,7 @@ extension UTMRemoteClient {
self.name = discovered.name
self.lastSeen = Date()
self.endpoint = discovered.endpoint
self.fingerprint = ""
self.fingerprint = []
}
}

Expand Down Expand Up @@ -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"))
}
}
}
Expand Down
26 changes: 26 additions & 0 deletions Remote/UTMRemoteKeyManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
40 changes: 29 additions & 11 deletions Remote/UTMRemoteServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ actor UTMRemoteServer {
return
}
try await keyManager.load()
await state.setServerFingerprint(keyManager.fingerprint!)
registerNotifications()
listener = Task {
await withErrorNotification {
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand All @@ -217,6 +218,7 @@ actor UTMRemoteServer {
private func resetServer() async {
await withErrorNotification {
try await keyManager.reset()
await state.setServerFingerprint(keyManager.fingerprint!)
}
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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") {
Expand All @@ -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 {
Expand Down Expand Up @@ -522,6 +536,10 @@ extension UTMRemoteServer {
approvedClients.remove(client)
blockedClients.insert(client)
}

fileprivate func setServerFingerprint(_ fingerprint: ServerFingerprint) {
serverFingerprint = fingerprint
}
}
}

Expand Down

0 comments on commit 48b749c

Please sign in to comment.