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

View File

@ -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)

View File

@ -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"))
}
}
}

View File

@ -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 {

View File

@ -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
}
}
}