From 91af68a44bfa3a04239d221b14ed2d808907f614 Mon Sep 17 00:00:00 2001 From: Christoph Hagen Date: Mon, 22 Apr 2024 12:58:49 +0200 Subject: [PATCH] Local route over UDP --- Sesame.xcodeproj/project.pbxproj | 4 + Sesame/Common/Client.swift | 33 ++--- Sesame/Common/RequestCoordinator.swift | 19 ++- Sesame/Common/UDPClient.swift | 170 +++++++++++++++++++++++++ Sesame/History/HistoryView.swift | 17 ++- Sesame/MainView.swift | 3 +- Sesame/SettingsView.swift | 29 ++++- 7 files changed, 250 insertions(+), 25 deletions(-) create mode 100644 Sesame/Common/UDPClient.swift diff --git a/Sesame.xcodeproj/project.pbxproj b/Sesame.xcodeproj/project.pbxproj index b623c55..a3eabd7 100644 --- a/Sesame.xcodeproj/project.pbxproj +++ b/Sesame.xcodeproj/project.pbxproj @@ -53,6 +53,7 @@ 88AEE3842B2236DC0034EDA9 /* SignedMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88AEE3832B2236DC0034EDA9 /* SignedMessage.swift */; }; 88AEE3862B22376D0034EDA9 /* Message+Crypto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88AEE3852B22376D0034EDA9 /* Message+Crypto.swift */; }; 88AEE3882B226FED0034EDA9 /* MessageResult+UI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88AEE3872B226FED0034EDA9 /* MessageResult+UI.swift */; }; + 88BA7DD32BD41B8A008F2A3C /* UDPClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88BA7DD22BD41B8A008F2A3C /* UDPClient.swift */; }; 88E197B229EDC9BC00BF1D19 /* Sesame_WatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88E197B129EDC9BC00BF1D19 /* Sesame_WatchApp.swift */; }; 88E197B429EDC9BC00BF1D19 /* UnlockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88E197B329EDC9BC00BF1D19 /* UnlockView.swift */; }; 88E197B629EDC9BD00BF1D19 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 88E197B529EDC9BD00BF1D19 /* Assets.xcassets */; }; @@ -157,6 +158,7 @@ 88AEE3832B2236DC0034EDA9 /* SignedMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignedMessage.swift; sourceTree = ""; }; 88AEE3852B22376D0034EDA9 /* Message+Crypto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Message+Crypto.swift"; sourceTree = ""; }; 88AEE3872B226FED0034EDA9 /* MessageResult+UI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageResult+UI.swift"; sourceTree = ""; }; + 88BA7DD22BD41B8A008F2A3C /* UDPClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UDPClient.swift; sourceTree = ""; }; 88E197AC29EDC9BC00BF1D19 /* Sesame-Watch Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Sesame-Watch Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 88E197B129EDC9BC00BF1D19 /* Sesame_WatchApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sesame_WatchApp.swift; sourceTree = ""; }; 88E197B329EDC9BC00BF1D19 /* UnlockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnlockView.swift; sourceTree = ""; }; @@ -354,6 +356,7 @@ 8860D7612B23803E00849FAC /* ServerChallenge.swift */, 8860D7642B23B5B200849FAC /* RequestCoordinator.swift */, 8860D7672B23D04100849FAC /* PendingOperation.swift */, + 88BA7DD22BD41B8A008F2A3C /* UDPClient.swift */, ); path = Common; sourceTree = ""; @@ -579,6 +582,7 @@ 88AEE3812B22327F0034EDA9 /* UInt32+Random.swift in Sources */, E24F6C6E2A89749A0040F8C4 /* ConnectionStrategy.swift in Sources */, 884A45C5279F4BBE00D6E650 /* KeyManagement.swift in Sources */, + 88BA7DD32BD41B8A008F2A3C /* UDPClient.swift in Sources */, 88AEE3842B2236DC0034EDA9 /* SignedMessage.swift in Sources */, 8860D74A2B2329CE00849FAC /* SignedMessage+Size.swift in Sources */, 8860D7542B23489300849FAC /* ActiveRequestType.swift in Sources */, diff --git a/Sesame/Common/Client.swift b/Sesame/Common/Client.swift index 92e1e9d..4f23982 100644 --- a/Sesame/Common/Client.swift +++ b/Sesame/Common/Client.swift @@ -3,13 +3,9 @@ import CryptoKit final class Client { - private let localRequestRoute = "message" - - private let urlMessageParameter = "m" - init() {} - func send(_ message: Message, to url: String, through route: TransmissionType, using keys: KeySet) async -> ServerResponse { + func send(_ message: Message, to url: String, port: UInt16, through route: TransmissionType, using keys: KeySet) async -> ServerResponse { let sentTime = Date.now let signedMessage = message.authenticate(using: keys.remote) let response: Message @@ -18,7 +14,7 @@ final class Client { response = await send(signedMessage, toServerUrl: url, authenticateWith: keys.server, verifyUsing: keys.device) case .overLocalWifi: - response = await send(signedMessage, toLocalDeviceUrl: url, verifyUsing: keys.device) + response = await send(signedMessage, toLocalDevice: url, port: port, verifyUsing: keys.device) } let receivedTime = Date.now // Create best guess for creation of challenge. @@ -39,18 +35,19 @@ final class Client { } return (response, serverChallenge) } - - private func send(_ message: SignedMessage, toLocalDeviceUrl server: String, verifyUsing deviceKey: SymmetricKey) async -> Message { - let data = message.encoded.hexEncoded - guard let url = URL(string: server)?.appendingPathComponent("\(localRequestRoute)?\(urlMessageParameter)=\(data)") else { - return message.message.with(result: .serverUrlInvalid) + private func send(_ message: SignedMessage, toLocalDevice host: String, port: UInt16, verifyUsing deviceKey: SymmetricKey) async -> Message { + let client = UDPClient(host: host, port: port) + let response: Data? = await withCheckedContinuation { continuation in + client.begin() + client.send(message: message.encoded) { res in + continuation.resume(returning: res) + } } - - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.timeoutInterval = 10 - return await perform(request, inResponseTo: message.message, verifyUsing: deviceKey) + guard let data = response else { + return message.message.with(result: .deviceNotConnected) + } + return decode(data, inResponseTo: message.message, verifyUsing: deviceKey) } private func send(_ message: SignedMessage, toServerUrl server: String, authenticateWith authToken: Data, verifyUsing deviceKey: SymmetricKey) async -> Message { @@ -71,6 +68,10 @@ final class Client { guard response == .messageAccepted, let data = responseData else { return message.with(result: response) } + return decode(data, inResponseTo: message, verifyUsing: deviceKey) + } + + private func decode(_ data: Data, inResponseTo message: Message, verifyUsing deviceKey: SymmetricKey) -> Message { guard data.count == SignedMessage.size else { print("[WARN] Received message with \(data.count) bytes (\(Array(data)))") return message.with(result: .invalidMessageSizeFromDevice) diff --git a/Sesame/Common/RequestCoordinator.swift b/Sesame/Common/RequestCoordinator.swift index 5f0de49..22c1c65 100644 --- a/Sesame/Common/RequestCoordinator.swift +++ b/Sesame/Common/RequestCoordinator.swift @@ -25,6 +25,9 @@ final class RequestCoordinator: ObservableObject { @AppStorage("localIP") var localAddress: String = "192.168.178.104/" + @AppStorage("localPort") + var localPort: UInt16 = 8888 + @AppStorage("connectionType") var connectionType: ConnectionStrategy = .remoteFirst @@ -171,7 +174,7 @@ final class RequestCoordinator: ObservableObject { return (message.with(result: .noKeyAvailable), nil) } let url = url(for: route) - return await client.send(message, to: url, through: route, using: keys) + return await client.send(message, to: url, port: localPort, through: route, using: keys) } func resetState() { @@ -196,3 +199,17 @@ final class RequestCoordinator: ObservableObject { } } } + +extension UInt16: RawRepresentable { + + public var rawValue: String { + "\(self)" + } + + public init?(rawValue: String) { + guard let value = UInt16(rawValue) else { + return nil + } + self = value + } +} diff --git a/Sesame/Common/UDPClient.swift b/Sesame/Common/UDPClient.swift new file mode 100644 index 0000000..eb6f909 --- /dev/null +++ b/Sesame/Common/UDPClient.swift @@ -0,0 +1,170 @@ +import Foundation +import Network + +enum UDPState: String { + case initial + case connectionCreated + case preparingConnection + case sending + case waitingForResponse +} + +final class UDPClient { + + let host: NWEndpoint.Host + + let port: NWEndpoint.Port + + private var connection: NWConnection? + + private var completion: ((Data?) -> Void)? + + private var state: UDPState = .initial + + init(host: String, port: UInt16) { + self.host = .init("192.168.188.118") + self.port = .init(rawValue: port)! + } + + deinit { + print("Destroying UDP Client") + finish() + } + + func begin() { + guard state == .initial else { + print("Invalid state for begin(): \(state)") + return + } + connection = NWConnection(host: host, port: port, using: .udp) + state = .connectionCreated + print("Created connection: \(connection != nil)") + } + + func send(message: Data, completion: @escaping (Data?) -> Void) { + print("Sending message to \(host) at port \(port)") + guard state == .connectionCreated else { + print("Invalid state preparing for send: \(state)") + return + } + guard let connection else { + print("Failed to send, no connection") + completion(nil) + return + } + + self.completion = completion + connection.stateUpdateHandler = { [weak self] (newState) in + switch (newState) { + case .ready: + print("State: Ready\n") + self?.send(message, over: connection) + case .setup: + print("State: Setup\n") + case .cancelled: + print("Cancelled UDP connection") + self?.finish() + case .preparing: + print("Preparing UDP connection") + case .failed(let error): + print("Failed to start UDP connection: \(error)") + self?.finish() +// default: +// print("ERROR! State not defined!\n") +// self?.finish() + case .waiting(_): + print("Waiting for UDP connection path change") + @unknown default: + print("Unknown UDP connection state: \(newState)") + self?.finish() + } + } + print("Preparing connection") + state = .preparingConnection + connection.start(queue: .global()) + + DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(5)) { + guard self.state == .preparingConnection else { + return + } + print("Timed out preparing connection") + self.finish() + } + } + + private func finish(_ data: Data? = nil) { + completion?(data) + completion = nil + connection?.stateUpdateHandler = nil + connection?.cancel() + connection = nil + state = .initial + } + + private func send(_ data: Data, over connection: NWConnection) { + guard state == .preparingConnection else { + print("Invalid state for send: \(state)") + return + } + + connection.stateUpdateHandler = nil + + let completion = NWConnection.SendCompletion.contentProcessed { [weak self] error in + if let error { + print("Failed to send UDP packet: \(error)") + self?.finish() + } else { + print("Finished sending message") + self?.waitForResponse(over: connection) + } + } + state = .sending + connection.send(content: data, completion: completion) + print("Started to send message") + + DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(5)) { + guard self.state == .sending else { + return + } + print("Timed out waiting for for send to complete") + self.finish() + } + } + + private func waitForResponse(over connection: NWConnection) { + guard state == .sending else { + print("Invalid state for send: \(state)") + return + } + state = .waitingForResponse + connection.receiveMessage { [weak self] (data, context, isComplete, error) in + guard self?.state == .waitingForResponse else { + return + } + guard isComplete else { + if let error { + print("Failed to receive UDP message: \(error)") + } else { + print("Failed to receive complete UDP message without error") + } + self?.finish() + return + } + guard let data else { + print("Received UDP message without data") + self?.finish() + return + } + print("Received \(data.count) bytes") + self?.finish(data) + } + + DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(5)) { + guard self.state == .waitingForResponse else { + return + } + print("Timed out waiting for response") + self.finish() + } + } +} diff --git a/Sesame/History/HistoryView.swift b/Sesame/History/HistoryView.swift index 35f1beb..36326ca 100644 --- a/Sesame/History/HistoryView.swift +++ b/Sesame/History/HistoryView.swift @@ -2,8 +2,11 @@ import SwiftUI import SwiftData struct HistoryView: View { + + @Environment(\.modelContext) + private var context - @Query + @Query(sort: \HistoryItem.startDate, order: .reverse) private var items: [HistoryItem] = [] private var unlockCount: Int { @@ -38,7 +41,17 @@ struct HistoryView: View { } ForEach(items) {entry in HistoryListItem(entry: entry) - } + }.onDelete(perform: { indexSet in + let objects = indexSet.map { items[$0] } + for object in objects { + context.delete(object) + } + do { + try context.save() + } catch { + print("Failed to save after deleting \(objects.count) object(s): \(error)") + } + }) } .navigationTitle("History") } diff --git a/Sesame/MainView.swift b/Sesame/MainView.swift index 9f5c184..8577b83 100644 --- a/Sesame/MainView.swift +++ b/Sesame/MainView.swift @@ -75,7 +75,8 @@ struct ContentView: View { keyManager: coordinator.keyManager, coordinator: coordinator, serverAddress: $coordinator.serverPath, - localAddress: $coordinator.localAddress) + localAddress: $coordinator.localAddress, + localPort: $coordinator.localPort) } .sheet(isPresented: $showHistorySheet) { HistoryView() } .preferredColorScheme(.dark) diff --git a/Sesame/SettingsView.swift b/Sesame/SettingsView.swift index 3434f63..b072764 100644 --- a/Sesame/SettingsView.swift +++ b/Sesame/SettingsView.swift @@ -1,6 +1,7 @@ import SwiftUI import SwiftData import SFSafeSymbols +import Combine struct SettingsView: View { @@ -15,6 +16,12 @@ struct SettingsView: View { @Binding var localAddress: String + @Binding + var localPort: UInt16 + + @State + private var localPortString = "" + var body: some View { NavigationView { ScrollView { @@ -45,6 +52,19 @@ struct SettingsView: View { TextField("Local address", text: $localAddress) .foregroundColor(.secondary) .padding(.leading, 8) + TextField("UDP Port", text: $localPortString) + .keyboardType(.numberPad) + .onReceive(Just(localPortString)) { newValue in + let filtered = newValue.filter { "0123456789".contains($0) } + if filtered != newValue { + self.localPortString = filtered + if let value = UInt16(filtered) { + self.localPort = value + } + } + } + .foregroundColor(.secondary) + .padding(.leading, 8) }.padding(.vertical, 8) ForEach(KeyManagement.KeyType.allCases) { keyType in SingleKeyView( @@ -52,10 +72,8 @@ struct SettingsView: View { type: keyType) } }.padding() - }.onDisappear { - if !localAddress.hasSuffix("/") { - localAddress += "/" - } + }.onAppear { + self.localPortString = "\(localPort)" } .navigationTitle("Settings") } @@ -74,7 +92,8 @@ struct SettingsView: View { keyManager: KeyManagement(), coordinator: .init(modelContext: container.mainContext), serverAddress: .constant("https://example.com"), - localAddress: .constant("192.168.178.42")) + localAddress: .constant("192.168.178.42"), + localPort: .constant(1234)) } catch { fatalError("Failed to create model container.") }